I'm trying to essentially achieve the following image which is found here:

In that thread, they talk about the best way to display categories and subcategories and the consensus is an MUI Autocomplete.
I'm not however sure how I would achieve something like that at all and would like some help with how I could achieve it.
What I need is for the user to only be able to select one category, whether it be a "root category" or a sub-category. So in the example above, either the "Boysenberry" or the "Brulee Berry".
I also want to try and have the id of said category so I can apply it on my back end (which I'm sure I can do.
My fetched json structure looks like the below:
[
{
"id": 1,
"name": "Audio Visual Equipment",
"parent": null,
"stockItems": [],
"childCategories": [
{
"id": 2,
"name": "Projectors",
"stockItems": [],
"childCategories": [
{
"id": 3,
"name": "Lenses",
"stockItems": [],
"childCategories": []
}
]
}
]
},
{
"id": 4,
"name": "Lighting Equipment",
"parent": null,
"stockItems": [],
"childCategories": [
{
"id": 5,
"name": "Intelligent",
"stockItems": [],
"childCategories": []
},
{
"id": 6,
"name": "Generic",
"stockItems": [],
"childCategories": []
},
{
"id": 7,
"name": "Control",
"stockItems": [],
"childCategories": []
}
]
},
{
"id": 8,
"name": "Sound Equipment",
"parent": null,
"stockItems": [],
"childCategories": [
{
"id": 9,
"name": "Mixing Desk",
"stockItems": [],
"childCategories": []
}
]
},
{
"id": 10,
"name": "Cables",
"parent": null,
"stockItems": [],
"childCategories": [
{
"id": 11,
"name": "Multicore",
"stockItems": [],
"childCategories": []
},
{
"id": 12,
"name": "Lighting",
"stockItems": [],
"childCategories": []
},
{
"id": 13,
"name": "Audio",
"stockItems": [],
"childCategories": []
},
{
"id": 14,
"name": "Video",
"stockItems": [],
"childCategories": []
},
{
"id": 15,
"name": "Power",
"stockItems": [],
"childCategories": []
}
]
}
]
EDIT:-
I get the following warning when I refresh the page:
MUI: The value provided to Autocomplete is invalid.None of the options match with `-1`.You can use the `isOptionEqualToValue` prop to customize the equality test.
When I then click on the Autocomplete, I get the "root" categories only. When I then click on one, the name is not shown and I get the following error:
MUI: The value provided to Autocomplete is invalid.None of the options match with `1`.You can use the `isOptionEqualToValue` prop to customize the equality test.
My approach is to "flatten" the list of categories into a single array so that MUI can evaluate each sub-category. Each of my flat options has a depth property so that I can display it with the correct level of indentation.
We can use the code from the Checkboxes example and add an indentation with the MUI sx prop:
renderOption={(props, option, { selected }) => (
<li {...props}>
<Checkbox checked={selected} sx={{ ml: 2 * option.depth }} />
{option.name}
</li>
)}

I'm assuming that we want to display the top-level category above a sub-category which matches on the sub-category term only. Like in your linked "ber" example, if the category was "Fall Gold" and the subcategory was "Fall Gold Berry". This means that we should consider the child terms when deciding if a term is a match.
To achieve this, I am including a matchTerms property on all option objects and using a custom filterOptions function on the Autocomplete which looks at this property. With the createFilterOptions utility, we just need to determine what texts to examine:
filterOptions={(createFilterOptions({
// join with some arbitrary separator to prevent matches across adjacent terms
stringify: (option) => option.matchTerms.join("//")
}))}

The last piece of this is the highlighting, which is not included in MUI. The MUI docs recommend the autosuggest-highlight package and include an example of how to use it. We can copy that, changing option.title to option.name.

JavaScript
import {
Autocomplete,
TextField,
Checkbox,
createFilterOptions
} from "@mui/material";
import { data } from "./data";
import parse from "autosuggest-highlight/parse";
import match from "autosuggest-highlight/match";
const toOptions = (category, depth = 0, parentId = null) => {
const { id, name, childCategories = [] } = category;
const children = childCategories.flatMap((child) =>
toOptions(child, depth + 1, id)
);
const option = {
id,
name,
depth,
parentId,
matchTerms: [name].concat(children.map((obj) => obj.name))
};
return [option].concat(children);
};
const optionsList = data.flatMap((category) => toOptions(category));
export default () => {
return (
<Autocomplete
options={optionsList}
getOptionLabel={(option) => option.name}
renderOption={(props, option, { selected, inputValue }) => {
const matches = match(option.name, inputValue);
const parts = parse(option.name, matches);
return (
<li {...props}>
<Checkbox checked={selected} sx={{ ml: 2 * option.depth }} />
<div>
{parts.map((part, index) => (
<span
key={index}
style={{
fontWeight: part.highlight ? 700 : 400
}}
>
{part.text}
</span>
))}
</div>
</li>
);
}}
renderInput={(params) => <TextField {...params} />}
filterOptions={createFilterOptions({
// join with some arbitrary separator to prevent matches across adjacent terms
stringify: (option) => option.matchTerms.join("//")
})}
/>
);
};
TypeScript
import {
Autocomplete,
TextField,
Checkbox,
createFilterOptions
} from "@mui/material";
import { data } from "./data";
import parse from "autosuggest-highlight/parse";
import match from "autosuggest-highlight/match";
// describes the input data
type Category = {
id: number;
name: string;
childCategories?: Category[];
};
// describes the format that we want
interface Option {
id: number;
name: string;
depth: number;
parentId: number | null;
matchTerms: string[];
}
const toOptions = (
category: Category,
depth: number = 0,
parentId: number | null = null
): Option[] => {
const { id, name, childCategories = [] } = category;
const children = childCategories.flatMap((child) =>
toOptions(child, depth + 1, id)
);
const option = {
id,
name,
depth,
parentId,
matchTerms: [name].concat(children.map((obj) => obj.name))
};
return [option].concat(children);
};
const optionsList: Option[] = data.flatMap((category) => toOptions(category));
export default () => {
return (
<Autocomplete
options={optionsList}
getOptionLabel={(option) => option.name}
renderOption={(props, option, { selected, inputValue }) => {
const matches = match(option.name, inputValue);
const parts = parse(option.name, matches);
return (
<li {...props}>
<Checkbox checked={selected} sx={{ ml: 2 * option.depth }} />
<div>
{parts.map((part, index) => (
<span
key={index}
style={{
fontWeight: part.highlight ? 700 : 400
}}
>
{part.text}
</span>
))}
</div>
</li>
);
}}
renderInput={(params) => <TextField {...params} />}
filterOptions={createFilterOptions({
// join with some arbitrary separator to prevent matches across adjacent terms
stringify: (option) => option.matchTerms.join("//")
})}
/>
);
};
CodeSandbox Link
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With