mateoc15
mateoc15

Reputation: 654

Autocomplete not rendering as expected Material UI

My autocomplete component is pulling a list of books from an API. I am rendering them as options in the Autocomplete component, and also outputting them as a list at the bottom of the page for debugging purposes. Also outputting the JSON from the API.

Two issues seem to be intertwined. First, the Autocomplete options don't seem to be all rendering. There are up to 10 results (limited to 10 by the API call) and they're all rending in the list below the autocomplete, but not in the list of options in the Autocomplete. Second, when the API is being called (like the time between changing the text from "abc" to "abcd") it shows "No options" rather than displaying the options from just "abc".

In the sandbox code here try typing slowly - 1 2 3 4 5 6 - you'll see that there are results in the <ul> but not in the <Autocomplete>.

Any ideas on why this (or maybe both separately) are happening?

Thanks!

List options issue

Code from sandbox:

import React, { useState, useEffect } from "react";
import Autocomplete from "@material-ui/lab/Autocomplete";
import {
  makeStyles,
  Typography,
  Popper,
  InputAdornment,
  TextField,
  Card,
  CardContent,
  CircularProgress,
  Grid,
  Container
} from "@material-ui/core";
import MenuBookIcon from "@material-ui/icons/MenuBook";
import moment from "moment";

// sample ISBN: 9781603090254

function isbnMatch(isbn) {
  const str = String(isbn).replace(/[^0-9a-zA-Z]/, ""); // strip out everything except alphanumeric
  const r = /^[0-9]{13}$|^[0-9]{10}$|^[0-9]{9}[Xx]$/; // set the regex for 10, 13, or 9+X characters
  return str.match(r);

  // return str.match(/^[0-9]{3}$|^[0-9]{3}$|^[0-9]{2}[Xx]$/);
}

const useStyles = makeStyles((theme) => ({
  adornedEnd: {
    backgroundColor: "inherit",
    height: "2.4rem",
    maxHeight: "3rem"
  },
  popper: {
    maxWidth: "fit-content"
  }
}));

export default function ISBNAutocomplete() {
  console.log(`Starting ISBNAutocomplete`);

  const classes = useStyles();

  const [options, setOptions] = useState([]);
  const [inputText, setInputText] = useState("");
  const [open, setOpen] = useState(false);
  const loading = open && options.length === 0 && inputText.length > 0;

  useEffect(() => {
    async function fetchData(searchText) {
      const isbn = isbnMatch(searchText);
      //console.log(`searchText = ${searchText}`);
      //console.log(`isbnMatch(searchText) = ${isbn}`);

      const fetchString = `https://www.googleapis.com/books/v1/volumes?maxResults=10&q=${
        isbn ? "isbn:" + isbn : searchText
      }&projection=full`;
      //console.log(fetchString);

      const res = await fetch(fetchString);

      const json = await res.json();

      //console.log(JSON.stringify(json, null, 4));

      json && json.items ? setOptions(json.items) : setOptions([]);
    }

    if (inputText?.length > 0) {
      // only search the API if there is something in the text box
      fetchData(inputText);
    } else {
      setOptions([]);
      setOpen(false);
    }
  }, [inputText, setOptions]);

  const styles = (theme) => ({
    popper: {
      maxWidth: "fit-content",
      overflow: "hidden"
    }
  });

  const OptionsPopper = function (props) {
    return <Popper {...props} style={styles.popper} placement="bottom-start" />;
  };

  console.log(`Rendering ISBNAutocomplete`);

  return (
    <>
      <Container>
        <h1>Autocomplete</h1>

        <Autocomplete
          id="isbnSearch"
          options={options}
          open={open}
          //noOptionsText=""
          style={{ width: 400 }}
          PopperComponent={OptionsPopper}
          onOpen={() => {
            setOpen(true);
          }}
          onClose={() => {
            setOpen(false);
          }}
          onChange={(event, value) => {
            console.log("ONCHANGE!");
            console.log(`value: ${JSON.stringify(value, null, 4)}`);
          }}
          onMouseDownCapture={(event) => {
            event.stopPropagation();
            console.log("STOPPED PROPAGATION");
          }}
          onInputChange={(event, newValue) => {
            // text box value changed
            //console.log("onInputChange start");
            setInputText(newValue);
            // if ((newValue).length > 3) { setInputText(newValue); }
            // else { setOptions([]); }
            //console.log("onInputChange end");
          }}
          getOptionLabel={(option) =>
            option.volumeInfo && option.volumeInfo.title
              ? option.volumeInfo.title
              : "Unknown Title"
          }
          getOptionSelected={(option, value) => option.id === value.id}
          renderOption={(option) => {
            console.log(`OPTIONS LENGTH: ${options.length}`);
            return (
              <Card>
                <CardContent>
                  <Grid container>
                    <Grid item xs={4}>
                      {option.volumeInfo &&
                      option.volumeInfo.imageLinks &&
                      option.volumeInfo.imageLinks.smallThumbnail ? (
                        <img
                          src={option.volumeInfo.imageLinks.smallThumbnail}
                          width="50"
                          height="50"
                        />
                      ) : (
                        <MenuBookIcon size="50" />
                      )}
                    </Grid>
                    <Grid item xs={8}>
                      <Typography variant="h5">
                        {option.volumeInfo.title}
                      </Typography>
                      <Typography variant="h6">
                        (
                        {new moment(option.volumeInfo.publishedDate).isValid()
                          ? new moment(option.volumeInfo.publishedDate).format(
                              "yyyy"
                            )
                          : option.volumeInfo.publishedDate}
                        )
                      </Typography>
                    </Grid>
                  </Grid>
                </CardContent>
              </Card>
            );
          }}
          renderInput={(params) => (
            <>
              <TextField
                {...params}
                label="ISBN - 10 or 13 digit"
                //"Search for a book"
                variant="outlined"
                value={inputText}
                InputProps={{
                  ...params.InputProps, // make sure the "InputProps" is same case - not "inputProps"
                  autoComplete: "new-password", // forces no auto-complete history
                  endAdornment: (
                    <InputAdornment
                      position="end"
                      color="inherit"
                      className={classes.adornedEnd}
                    >
                      <>
                        {loading ? (
                          <CircularProgress color="secondary" size={"2rem"} />
                        ) : null}
                      </>
                      {/* <>{<CircularProgress color="secondary" size={"2rem"} />}</> */}
                    </InputAdornment>
                  ),
                  style: {
                    paddingRight: "5px"
                  }
                }}
              />
            </>
          )}
        />

        <ul>
          {options &&
            options.map((item) => (
              <li key={item.id}>{item.volumeInfo.title}</li>
            ))}
        </ul>
        <span>
          inputText: <pre>{inputText && inputText}</pre>
        </span>
        <span>
          <pre>
            {options && JSON.stringify(options, null, 3).substr(0, 500)}
          </pre>
        </span>
        <span>Sample ISBN: 9781603090254</span>
      </Container>
    </>
  );
}

Upvotes: 4

Views: 16434

Answers (1)

Ryan Cogswell
Ryan Cogswell

Reputation: 80976

By default, Autocomplete filters the current options array by the current input value. In use cases where the options are static, this doesn't cause any issue. Even when the options are asynchronously loaded, this only causes an issue if the number of query matches is limited. In your case, the fetch is executed with maxResults=10 so only 10 matches are returned at most. So if you are typing "123" slowly, typing "1" brings back 10 matches for "1" and none of those matches contain "12" so once you type the "2", none of those 10 options match the new input value, so it gets filtered to an empty array and the "No options" text is displayed until the fetch for "12" completes. If you now delete the "2", you won't see the problem repeat because all of the options for "12" also contain "1", so after filtering by the input value there are still options displayed. You also wouldn't see this problem if all of the matches for "1" had been returned, because then some of those options would also contain "12" so when you type the "2" the options would just be filtered down to that subset.

Fortunately, it is easy to address this. If you want Autocomplete to always show the options you have provided it (on the assumption that you will modify the options prop asynchronously based on changes to the input value), you can override its filterOptions function so that it doesn't do any filtering:

        <Autocomplete
          id="isbnSearch"
          options={options}
          filterOptions={(options) => options}
          open={open}
          ...

Edit Autocomplete filterOptions

Autocomplete custom filter documentation: https://material-ui.com/components/autocomplete/#custom-filter

Upvotes: 23

Related Questions