Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Autocomplete] getOptionLabel, onInputChange, onChange are super confusing when used together #19426

Closed
2 tasks done
jdoklovic opened this issue Jan 27, 2020 · 5 comments
Closed
2 tasks done
Labels
component: autocomplete This is the name of the generic UI component, not the React module!

Comments

@jdoklovic
Copy link

  • The issue is present in the latest release.
  • I have searched the issues of this repository and believe that this is not a duplicate.

Current Behavior 😯

After working through some of the things in #19267, I'm still having problems implementing a freesolo query editor with autocomplete.

Example of what I'm trying to accomplish:
jql-editor

Essentially, I put together a few examples from the demos page and thought I could use getOptionLabel to update the input field with the correct value after a user picks an option, however this doesn't exactly work properly because of the way events are fired and state is managed.

The biggest problem is that I currently update the local inputValue custom state with a TextField onChange handler and that function as well as useEffect are not always fired as expected. Also, getOptionLabel is fired multiple times and not always the same number of times.

Expected Behavior 🤔

When a user makes any change (typing, selecting an option, editing the value) there should always be a handler (the same one) that can update state.

Steps to Reproduce 🕹

Component code:

<Autocomplete
            id="jql-editor"
            style={{ width: 300 }}
            getOptionLabel={getOptionLabel}
            filterOptions={x => x}
            options={options}
            includeInputInList
            freeSolo
            autoHighlight
            disableOpenOnFocus
            onChange={handleAutocompleteChange}
            onInputChange={handleAutocompleteInputChange}
            renderInput={params => (
                <TextField
                    {...params}
                    label="Enter JQL"
                    variant="outlined"
                    fullWidth
                    inputRef={inputField}
                    onChange={handleTextFieldChange}
                />
            )}
            renderOption={option => {
                const matches = match(option.displayName, inputValue);
                const parts = parse(option.displayName, matches);

                return (
                    <div key={option.value}>
                        {parts.map((part, index) => (
                            <span key={index} style={{ fontWeight: part.highlight ? 700 : 400 }} onClick={() => console.log(`item clicked: ${part.text}`)}>
                                {part.text}
                            </span>
                        ))}
                    </div>
                );
            }}
        />

Notes:
handleTextFieldChange updates the local 'inputValue' state from event.target.value

handleAutocompleteChange & handleAutocompleteInputChange are doing nothing but logging the current state.

getOptionLabel takes the passed option, the selectionStart and the inputValue and calculates the new complete query string.

useEffect fetches options using an async function and sets the new options as local state which should force the Autocomplete to re-render if there are new options. This is dependent on the inputValue state being correct to properly fetch new options which is currently failing and in a lot of cases, returning the same options even though the textField value has changed because the local state isn't correct.

Context 🔦

After debugging, It looks like the events are fired as such:

handleTextFieldChange: fires on typing, but not when option is selected

handleAutocompleteChange: only fires when an option is selected with the option value

handleAutocompleteInputChange: fires on typing as 'input' and on option selected as 'reset'

getOptionLabel: fires when option is selected. Also fires on focus events, and "randomly".

Here's a screenshot of the events that happen when typing a single letter and then selecting an option. They are separated by a "click" log entry:

ac-events-uncontrolled

After that, here' are the events that fire when the input is focused again and then a single backspace is pressed. Note: when backspace is pressed:

ac-events-backspace-edit

Your Environment 🌎

Tech Version
Material-UI v4.8.3
Material-UI LAB v4.0.0-alpha.39
React 16.12.0
Browser chrome
TypeScript 3.7.4
etc.
@oliviertassinari oliviertassinari added the support: Stack Overflow Please ask the community on Stack Overflow label Jan 27, 2020
@support support bot closed this as completed Jan 27, 2020
@oliviertassinari
Copy link
Member

@jdoklovic Good luck with the task!

@oliviertassinari
Copy link
Member

oliviertassinari commented Jan 27, 2020

Actually, let's keep it open in case somebody has time to look at it (I might look at it later on.). It seems that we have a good signal/noise ratio here: https://www.linkedin.com/in/jdoklovic/. Thanks for opening it.

@support support bot removed the support: Stack Overflow Please ask the community on Stack Overflow label Jan 27, 2020
@oliviertassinari oliviertassinari added the component: autocomplete This is the name of the generic UI component, not the React module! label Jan 27, 2020
@mui mui deleted a comment from support bot Jan 27, 2020
@jdoklovic
Copy link
Author

@oliviertassinari here's the code I have that's mostly working. Still suffers from #19705
Just posting it here in case someone else finds this.

interface JQLInputProps {
    jqlAutocompleteRestData: JqlAutocompleteRestData;
    suggestionFetcher: FieldSuggestionFetcher;
    onJqlChange?: (newValue: string) => void;
    value?: string;
    disabled?: boolean;
}

const loadingOption: Suggestion = { displayName: 'Loading...', value: '__-loading-__' };

export const JQLInput: React.FunctionComponent<JQLInputProps & TextFieldProps> = ({ jqlAutocompleteRestData, suggestionFetcher, value, onJqlChange, disabled, ...tfProps }) => {
    const [inputValue, setInputValue] = useState((value) ? value : '');
    const [selStart, setSelStart] = useState<number>(0);
    const [fieldPositionDirty, setFieldPositionDirty] = useState<boolean>(false);
    const inputField = useRef<HTMLInputElement>();
    const [open, setOpen] = React.useState<boolean>(false);
    const [fetching, setFetching] = React.useState<boolean>(false);

    const suggestor = useMemo<JqlSuggestor>(() => new JqlSuggestor(jqlAutocompleteRestData, suggestionFetcher), [jqlAutocompleteRestData, suggestionFetcher]);

    // get initial data
    const suggestions: Suggestion[] = [{ displayName: 'NOT', value: 'NOT' }, { displayName: 'start group (', value: '(' }];
    suggestions.push(...jqlAutocompleteRestData.visibleFieldNames.map<Suggestion>(f => ({ displayName: f.displayName, value: f.value })));

    const [options, setOptions] = useState<Suggestions>(suggestions);
    const optionsLoading = open && fetching;

    const fetch = React.useMemo(
        () =>
            async (input: string, startIndex: number) => {
                setOptions([loadingOption]);
                if (!open) {
                    setOpen(true);
                }
                var suggestions: Suggestions = [];

                setFetching(true);
                suggestions = await suggestor.getSuggestions(input, startIndex);
                setFetching(false);
                setOptions(suggestions);
            },
        [open, suggestor],
    );

    const handleTextFieldChange = (event: React.ChangeEvent<HTMLInputElement>) => { // fires on typing, but not when option is selected
        // update our local state
        if (inputField.current) {
            var newStart = (inputField.current.selectionStart) ? inputField.current.selectionStart : 0;
            fetch(event.target.value, newStart);
            setSelStart(newStart);
        }
        setInputValue(event.target.value);

    };

    const handleAutocompleteChange = (event: React.ChangeEvent<HTMLInputElement>, value: any) => { //only fires when an option is selected with the option value
        var insertText: string = (value) ? value.value : "";
        var newCursorPos = 0;
        if (insertText === '' || insertText === loadingOption.value) {
            return;
        }

        if (inputField.current) {
            newCursorPos = selStart;
            [insertText, newCursorPos] = suggestor.calculateNewValueForSelected(inputValue, insertText, selStart);
        }
        setInputValue(insertText);
        setSelStart(newCursorPos);

        // if we're not at the end, we're editing and need to update the cursor position later when the text field catches up
        if (newCursorPos !== insertText.length) {
            setFieldPositionDirty(true);
        }
    };

    const getOptionLabel = (option: Suggestion): string => { //fires when option is selected and should return the new input box value in it's entirety. Also fires on focus events, and randomly.
        // if we recently edited a value, we need to set the new cursor position manually
        if (fieldPositionDirty && inputField.current) {
            inputField.current.value = inputValue; // have to reset the value to get the right sel index
            inputField.current.selectionStart = selStart;
            inputField.current.selectionEnd = selStart;
            setFieldPositionDirty(false);
        }

        return inputValue;
    };

    const handleTextFieldKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
        // update our local cursorPosition on arrow keys so we fetch the proper options
        switch (event.key) {
            case 'ArrowRight': {
                if (inputField.current && inputField.current.selectionStart !== null && inputField.current.selectionStart !== inputField.current.value.length) {
                    const newStart = (inputField.current.selectionStart < inputField.current.value.length) ? inputField.current.selectionStart + 1 : inputField.current.value.length;
                    fetch(inputField.current.value, newStart);
                    setSelStart(newStart);
                }
                break;
            }
            case 'ArrowLeft': {
                if (inputField.current && inputField.current.selectionStart && inputField.current.selectionStart !== 0) {
                    const newStart = (inputField.current.selectionStart > 0) ? inputField.current.selectionStart - 1 : 0;
                    fetch(inputField.current.value, newStart);
                    setSelStart(newStart);
                }
                break;
            }
            case 'Enter': {

                break;
            }
        };
    };

    useEffect(() => {

        if (onJqlChange) {
            onJqlChange(inputValue);
        }

    }, [inputValue, onJqlChange]);

    return (
        <Autocomplete
            id="jql-editor"
            disableOpenOnFocus={false}
            getOptionLabel={getOptionLabel}
            filterOptions={x => x}
            options={options}
            value={inputValue}
            includeInputInList
            freeSolo
            autoHighlight
            onChange={handleAutocompleteChange}
            loading={optionsLoading}
            open={open}
            disabled={disabled}
            onOpen={() => {
                setOpen(true);
            }}
            onClose={() => {
                setOpen(false);
            }}
            renderInput={params => (
                <TextField
                    {...params}
                    {...tfProps}
                    inputRef={inputField}
                    onChange={handleTextFieldChange}
                    onKeyDown={handleTextFieldKeyDown}
                    InputProps={{
                        ...params.InputProps,
                        endAdornment: (
                            <React.Fragment>
                                {optionsLoading ? <CircularProgress color="inherit" size={20} /> : null}
                                {params.InputProps.endAdornment}
                            </React.Fragment>
                        ),
                    }}
                />
            )}
            renderOption={option => {
                const matches = match(option.displayName, suggestor.getCurrentToken().value);
                const parts = parse(option.displayName, matches);

                return (
                    <div key={option.value}>
                        {parts.map((part, index) => (
                            <span key={index} style={{ fontWeight: part.highlight ? 700 : 400 }}>
                                {part.text}
                            </span>
                        ))}
                    </div>
                );
            }}
        />
    );
};

@oliviertassinari
Copy link
Member

I'm closing as I don't think there is much leverage to apply.

@wiznotwiz
Copy link

Any ideas on how to use inputValue and onInputChange to persist the user's query for multiple selections without using onChange to get the selected values? I saw in another thread that onChange and onInputChange shouldn't be used together, but how else could I get the selected values? I've been pulling my hair out all day over this - if I use onChange and onInputChange together, my Autocomplete is just way too slow.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
component: autocomplete This is the name of the generic UI component, not the React module!
Projects
None yet
Development

No branches or pull requests

3 participants