Reputation: 654
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!
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
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}
...
Autocomplete custom filter documentation: https://material-ui.com/components/autocomplete/#custom-filter
Upvotes: 23