Material UI - Autocomplete with React Hook Form - reactjs

I am building a form using Material UI's Autocomplete feature and using recoil for state management. I am also using react-hook-form. I am needing the following criteria met:
Need typeahead that allows first letter typed to return a list of options from an API to show in Autocomplete. Each letter will return a different list of options to select from.
Need to also allow freeSolo so that user can enter a value manually
Needs to be required and follow a pattern to validate the form.
I am using react-hook-form's <Controller> to control the input and allow for features like validation, displaying error messages in the helper text, etc.
The Problems: I am having issues with the typeahead filtering out options based on what I type, along with allowing freeSolo. As I type a new value, the list of options do not filter. The popup just stays open. I also need to validate on change of input for the pattern validation. I have tried with the following example with onInputChange to make use of react-hook-form's useForm and setValue to manually set the value of the field and validate the form. ({shouldValidate: true}). The below example is a custom, reusable component I created for Autocomplete, as well as using that custom component in other parent components. I hope I included as much details as possilbe, but if not, please let me know if you need anything more. Any assistance would be very appreciative!
Parent Component:
const setTrainType = useSetRecoilState(TrainType)
// Trains would return a list of trains based on letter of train type that was passed from input
const trainsList = useRecoilValue(Trains)
const trainOptions = useMemo(() => trainsList.map(trainIDFormatted), [
trainsList,
])
const handleInputChange = useCallback(
(_e: unknown, option: string, reason: string) => {
const capitalized =
option === capitalize(option) ? option : capitalize(option)
setValue('trainID', capitalized, {shouldValidate: true})
if (['input', 'reset'].includes(reason) && capitalized !== '') {
setTrainType(capitalized.charAt(0))
} else {
setTrainType(undefined)
}
},
[setTrainType, setValue],
)
<Autocomplete
autoSelect
freeSolo
disabled={disabled}
helperText=" "
label="Select a train"
name="trainID"
options={trainOptions}
rules={{
pattern: {
message: 'Must match train ID pattern',
value: /^(?:[A-Z]-?[A-Z ]{6}-?[0-9 ]-?[0-9 ]{2}[A-Z ])?$/,
},
required: 'Train is required',
}}
onInputChange={handleInputChange}
/>
Custom autocomplete component:
import {
AutocompleteProps,
Autocomplete as MuiAutocomplete,
} from '#material-ui/lab'
import {get} from 'lodash'
import React, {ReactNode, useCallback} from 'react'
import {
Controller,
ControllerProps,
FieldError,
useFormContext,
} from 'react-hook-form'
import {useRenderInput} from './hooks'
interface Props
extends Pick<ControllerProps<'select'>, 'rules'>,
Omit<
AutocompleteProps<string, false, false, true>,
'error' | 'onChange' | 'required' | 'renderInput'
> {
helperText?: ReactNode
label?: string
name: string
}
/**
* Render controlled autocomplete. Use react-form-hook's FormProvider.
* #param props Component properties
* #param props.helperText Default helper text for error
* #param props.label Input label
* #param props.name Name identifier for react-hook-form
* #param props.required If true then item is required
* #param props.rules Select rules
* #return React component
*/
export const Autocomplete = ({
helperText,
label,
name,
rules,
...props
}: Props) => {
// eslint-disable-next-line #typescript-eslint/unbound-method
const {control, errors, watch} = useFormContext()
const error: FieldError | undefined = get(errors, name)
const required = get(rules, 'required') !== undefined
const value = watch(name)
const renderAutocompleteInput = useRenderInput({
error: error !== undefined,
helperText: get(error, 'message', helperText),
label,
required,
})
const handleOnChange = useCallback(
(_e: unknown, option: string | null) => option,
[],
)
const renderAutocomplete = useCallback(
params => (
<MuiAutocomplete
{...props}
{...params}
renderInput={renderAutocompleteInput}
onChange={handleOnChange}
/>
),
[handleOnChange, props, renderAutocompleteInput],
)
return (
<Controller
control={control}
defaultValue={value ?? ''}
name={name}
render={renderAutocomplete}
rules={rules}
/>
)
}
What it looks like:

Related

Material UI's Autocomplete does not show the selected value in React using react-hook-form

I am having issues making Material UI's Autocomplete show the selected item using react-hook-form with options that are objects containing name and id. When I select an item, the box is just empty.
I have a FieldArray of custom MaterialBars which contain a material:
<Controller
control={control}
name={`materialBars.${index}.materialId`}
render={(
{ field: { value, onChange } }
) => (
<Autocomplete
options={materials.map(material => ({id: material.id, name: material.name})} // materials are fetched from the API
getOptionLabel={(option) => option.name}
value={materialItems.find((item) => `${item.id}` === value) || null}
onChange={(_, val) => onChange(val?.id)}
renderInput={(params) => <TextField {...params} label="Material" />}
/>
)}
/>
There is a working example in the codesandbox.
When I select a material in the list, the box is cleared, and the placeholder text is shown instead of the selected material (when focus is removed from the box). The item is selected under the hood, because in my application, when I press submit, the newly selected material is saved to the backend.
I cannot figure out if the issue lies in the react-hook-form part, material UI or me trying to connect the two. I guess it will be easier if the options are just an array of strings with the name of the material (when the form schema has just the materialId), but it is nice to keep track of the id, for when contacting the API.
You should set the same type on materialId property between FormValue and ListData.
For Example, if I use number type, it should be
https://codesandbox.io/s/autocomplete-forked-mpivv1?file=/src/MaterialBar.tsx
// App.tsx
const { control, reset } = useForm<FormValues>({
defaultValues: {
materialBars: [
// use number instead of string
{ ..., materialId: 1 },
{ ..., materialId: 6 }
]
}
});
// util.ts
export const materials = [
{
id: 1, // keep it as number type
...
},
...
];
// util.ts
export type FormValues = {
materialBars: {
// change it from string type to number
materialId: number;
...
}[];
};
// MaterialBar.tsx
<Controller
...
) => (
<Autocomplete
...
/*
Remove curly brackets
- change`${item.id}` to item.id
*/
value={materialItems.find((item) => item.id === value) || null}
/>
)}
/>

Material UI Autocomplete not updating input value (React)

I made an Autocomplete component in React using Material UI's Autocomplete component. Here's the code
import { useState } from "react";
import { Autocomplete as MuiAutcomplete } from "#mui/material";
import {useFormContext} from "react-hook-form";
interface props {
name: string,
options?: string[],
getOptions?: (value: string) => {
label: string,
id: number
}[] | string[],
freeSolo?: boolean
};
const Autocomplete = ({name, options=[], getOptions, freeSolo=false}: props) => {
const [autocompleteValues, setAutocompleteValues] = useState<any[]>(options);
const {setValue, getValues} = useFormContext();
return (
<MuiAutcomplete
options={autocompleteValues}
renderInput={({ InputProps, inputProps }) => (
<div ref={InputProps.ref}>
<input
type="text"
{...inputProps}
className="bg-transparent outline-none p-1"
/>
</div>
)}
value={getValues(name)}
onChange={(e, v) => {
setValue(name, v);
}}
getOptionLabel={(option) => option.label || option}
freeSolo={freeSolo}
/>
)
}
export default Autocomplete;
The options display just fine when I type but when actually selecting an option the input field doesn't actually get updated. It instead shows this error:
`MUI: The value provided to Autocomplete is invalid.None of the options match with `""`.You can use the `isOptionEqualToValue` prop to customize the equality test. `
I'm not entirely sure what's going on. Here's a video showing the error in case you need clarification https://imgur.com/a/xfm1mpb (sorry for low res, Imgur's compression ruined it)
Can you please details about what options are you passing?
It will be better if you can provide a codesandbox.
It is due to the option which you are selecting is not matching the value in options
The "" value is an actual value for auto complete. If you want to make it work with an empty field you can:
// Set value to null. If you set it to 'undefined' it will give you a warning similar to the current one
value={getValues(name) || null}
Or
// Override the function which checks if the value is equal to the option.
// Basically, you handle the empty string here.
isOptionEqualToValue={(option, currentValue) => {
if (currentValue === '') return true;
return option.name === currentValue.name;
}}

react-select Creatable: transforming created options

I trying to use react-select's Creatable select component to allow users to add multiple CORS Origins to be registered for my authentication server. I would like to be able to allow users to paste full URLs, and have these URLs be transformed into Origins (format: <protocol>://<origin>[:port]) once they are added to the Creatable select.
As an example, the user could paste http://some-domain.com:1234/management/clients?param1=abc&param2=123#fragment_stuff into the Creatable select, and this whole URL would automatically be converted/added as just its origin components: http://some-domain.com:1234.
This is a reduced version the component I've wrote (TypeScript):
import CreatableSelect from 'react-select/creatable';
...
type MyOptionType = {
label: string,
value: string,
}
function SomeComponent(props:{}) {
const [options, setOptions] = useState<MyOptionType[]>([]);
const onOptionsChanged = (newOptions: OptionsType<MyOptionType>) => {
// Get only options containing valid URLs, with valid origins
const validUrlsWithOrigins = newOptions.filter(option => {
try {
return !!(new URL(option.value).origin);
} catch (error) {
return false;
}
});
// Transform options (e.g.: "http://some-domain.com:1234/abc?def=ghi#jkl" will become "http://some-domain.com:1234")
const newOptionsOrigins = validUrlsWithOrigins
.map(option => new URL(option.value).origin)
.map(origin => ({label: origin, value: origin}));
setOptions(newOptionsOrigins);
}
return <CreatableSelect isMulti options={options} onChange={onOptionsChanged} />
}
While debugging using React Developer Tools, I can see that the state of my component is being transformed accordingly, having only the origin part of my URLs being kept in the state:
The problem is that the Creatable select component is rendering the full URL instead of only the URL's Origin:
Why isn't the Creatable select in sync with the component's state? Is there a way to solve this, or is it a limitation on react-select?
You need to distinguish two things here - options prop of CreatableSelect holds an array of all the possibilites. But the value of this component is managed by value property.
You can check Multi-select text input example on docs page but basically you'll need to:
keep values and option separetly:
const [options, setOptions] = React.useState<MyOptionType[]>([]);
const [value, setValue] = React.useState<MyOptionType[]>([]);
const createOption = (label: string) => ({
label,
value: label
});
<CreatableSelect
isMulti
options={options}
value={options}
onChange={onOptionsChanged}
/>
and modify your onOptionsChanged function
set value of transformed and validated input
add new options to options state variable (all options, without duplicates)
Here's some example:
// Transform options (e.g.: "http://some-domain.com:1234/abc?def=ghi#jkl" will become "http://some-domain.com:1234")
const newOptionsOrigins = validUrlsWithOrigins
.map((option) => new URL(option.value).origin)
.map((origin) => createOption(origin));
setValue(newOptionsOrigins);
//get all options without duplicates
const allUniqueOptions: object = {};
[...newOptionsOrigins, ...options].forEach((option) => {
allUniqueOptions[option.value] = option.value;
});
setOptions(
Object.keys(allUniqueOptions).map((option) => createOption(option))
);
};

Handling custom component checkboxes with Field as in Formik

I've been stuck for past couple of hours trying to figure out how to handle a form with a custom checkbox component in formik. I'm passing it via the <Formik as >introduced in Formik 2.0
Issue arises with input type='checkbox' as I can't just directly pass true or false values to it.
Now I'm posting the solution which I am aware is a bad implementation.
I didn't really find a way to properly pass values from the component,
so I wanted to hande it as a separate state in the component as the
checkbox will take care of its own state.
My custom input component is structured the following way
import React, { useState } from 'react';
import { StyledSwitch, Wrapper } from './Switch.styled';
type Props = {
value: boolean;
displayOptions?: boolean;
optionTrue?: string;
optionFalse?: string;
};
const Switch: React.FC<Props> = (props: Props) => {
const { value, optionTrue = 'on', optionFalse = 'off', displayOptions = false } = props;
const [switchVal, setSwitchVal] = useState<boolean>(value);
const handleSwitchChange = (): void => setSwitchVal(!switchVal);
return (
<Wrapper styledVal={switchVal}>
<StyledSwitch type="checkbox" checked={switchVal} onChange={handleSwitchChange} />
{displayOptions && (switchVal ? optionTrue : optionFalse)}
</Wrapper>
);
};
export default Switch;
The ./Switch.styled utilizes styled-components but they are not relevant to this question. Imagine them simply as an <input> and <div> respectively
Now here's the component which handles the switch
import React, { useState } from 'react';
import { Formik, Form, Field } from 'formik';
import Switch from '../../../components/forms/Switch';
const QuizMenu: React.FC = () => {
const [isMultipleChoice, setIsMultipleChoice] = useState<boolean>(false);
const sleep = (ms: number): Promise<number> => new Promise((resolve) => setTimeout(resolve, ms));
return (
<Formik
initialValues={{ isMultipleChoice: 'false', password: '' }}
onSubmit={async (values): Promise<boolean> => {
await sleep(1000);
JSON.stringify(values, null, 2);
return true;
}}
>
{
(): any => ( // to be replaced with formik destruct, but dont want eslint problems before implementation
<Form>
<div>
<Field as={Switch} onClick={setIsMultipleChoice(!isMultipleChoice)} value={isMultipleChoice === true} name="isMultipleChoice" displayOptions />
{ isMultipleChoice }
</div>
</Form>
)
}
</Formik>
);
};
export default QuizMenu
;
Which yields the following error:
Error: Maximum update depth exceeded. This can happen when a component
repeatedly calls setState inside componentWillUpdate or
componentDidUpdate. React limits the number of nested updates to
prevent infinite loops.
I also tried editing the value to string as per input type='checkboxed'but I can't really find a way to handle it. If you handle it in a separate handleChange() function you get rid of the error, but then the state doesn't update for some reason.
What would be the proper way of handling this?

Angular 1.X ng-options equivalent using React

Angular 1.X has ng-options for the choices in a select dropdown, each item being an object. In plain HTML, the value of an option can only be a string. When you select one option in Angular, you can see the actual selected object while in plain html, you can only get that string value.
How do you do the equivalent of that in React (+Redux)?
I came up with a solution that does not use JSON.stringify / parse for the value of the select React element nor does it use the index of the array of choice objects as the value.
The example is a simple select dropdown for a person's gender -- either male or female. Each of those choices is an actual object with id, text, and value properties. Here is the code:
MySelect component
import React, { Component } from 'react';
class MySelect extends Component {
onGenderChange = (event) => {
// Add the second argument -- the data -- and pass it along
// to parent component's onChange function
const data = { options: this.props.options };
this.props.onGenderChange(event, data);
}
render() {
const { options, selectedOption } = this.props;
// Goes through the array of option objects and create an <option> element for each
const selectOptions = options.map(
option => <option key={option.id} value={option.value}>{option.text}</option>
);
// Note that if the selectedOption is not given (i.e. is null),
// we assign a default value being the first option provided
return (
<select
value={(selectedOption && selectedOption.value) || options[0].value}
onChange={this.onGenderChange}
>
{selectOptions}
</select>
);
}
}
App component that uses MySelect
import _ from 'lodash';
import React, { Component } from 'react';
class App extends Component {
state = {
selected: null
}
onGenderChange = (event, data) => {
// The value of the selected option
console.log(event.target.value);
// The object for the selected option
const selectedOption = _.find(data.options, { value: parseInt(event.target.value, 10) });
console.log(selectedOption);
this.setState({
selected: selectedOption
});
}
render() {
const options = [
{
id: 1,
text: 'male',
value: 123456
},
{
id: 2,
text: 'female',
value: 654321
}
];
return (
<div>
<label>Select a Gender:</label>
<MySelect
options={options}
selectedOption={this.state.selected}
onGenderChange={this.onGenderChange}
/>
</div>
);
}
}
Lodash is used to look up the choice object in the array of choice objects inside the onGenderChange function in the App component. Note that the onChange passed to the MySelect component requires two arguments -- an extra data argument is added in order to be able to access the choice objects ("options"). With that, you can just set the state (or call an action creator if using Redux) with the choice object for the selected option.
I run into this same situation while migrating a angular 1 app to react. And I felt it is a much needed feature that I couldn't find so here I leave my implementation of NgOption in react using bootstrap (you can can change that to whatever you are using) for anybody that's missing the goo'old angular 1:
import React from 'react';
import { Form, InputGroup } from 'react-bootstrap';
interface props<T, U>{
options: Array<T>,
selected?: U,
onSelect: (value: T) => any,
as: (value: T) => string,
trackBy: (value: T) => U,
disabled?: boolean
}
type state = {}
export default class NgOptions extends React.Component<props<any, any>, state> {
componentDidMount() {
if (this.props.selected) {
this.props.onSelect(this.props.options.find((o) => this.props.trackBy(o)===this.props.selected))
}
}
public render() {
return ( <InputGroup>
<Form.Control as="select"
disabled={this.props.disabled}
onChange={(e) => this.props.onSelect(this.props.options.find((o) => this.props.trackBy(o)===e.target.value))}>
{this.props.options.map( option =>
<option key={this.props.trackBy(option)}
selected={this.props.selected===this.props.trackBy(option)}
value={this.props.trackBy(option)}>{this.props.as(option)}</option>
)}
</Form.Control>
</InputGroup>
)
}
}

Resources