React-Hook-Form unexpected behavior with Multi Level Nested Object - reactjs

Lately i discovered the react-hook-from plugin which on the first sight seem to be perfect to start using and replace other plugins due to its great performance.
After using the plugin for some really simple forms i came across a complicated form that i wish to handle with the plugin. My form is based on a nested object which has the following structure. (Typescript definition)
type model = {
tag: string;
visible: boolean;
columns?: (modelColumn | model)[];
}
type modelColumn = {
property: string;
}
So in order to handle to handle a n-level nested form i created the following components.
const initialData = {
...
datasource: {
tag: "tag1",
visible: true,
columns: [
{
property: "property",
},
{
property: "property1",
},
{
tag: "tag2",
visible: true,
columns: [
{
property: "property",
},
{
tag: "tag3",
visible: false,
columns: [
{
property: "property",
}
]
}
]
},
{
entity: "tag4",
visible: false,
}
],
},
...
}
export const EditorContent: React.FunctionComponent<EditorContentProps> = (props: any) => {
const form = useForm({
mode: 'all',
defaultValues: initialData,
});
return (
<FormProvider {...form}>
<form>
<Handler
path="datasource"
/>
</form>
</FormProvider>
);
}
In the above component the main form is created loaded with initial data. (I provided an example value). The Handler component has the login of recursion with the path property to call nested logic of the form data type. Here is the sample implementation.
...
import { get, isNil } from "lodash";
import { useFieldArray, useForm, useFormContext, useWatch } from "react-hook-form";
...
export type HandlerProps = {
path: string;
index?: number;
...
}
export const Handler: React.FunctionComponent<HandlerProps> = (props) => {
const { path, index, onDelete, ...rest } = props;
const { control } = useFormContext();
const name = isNil(index)? `${path}` :`${path}[${index}]`;
const { fields, append, insert, remove } = useFieldArray({
control: control,
name: `${name}.columns`
});
...
const value = useWatch({
control,
name: `${name}`,
});
...
const addHandler = () => {
append({ property: null });
};
console.log(`Render path ${name}`);
return (
<React.Fragment>
<Row>
<Col>
<FormField name={`${name}.tag`} defaultValue={value.tag}>
<Input
label={`Tag`}
/>
</FormField>
</Col>
<Col>
<FormField defaultValue={value.visible} name={`${name}.visible`} >
<Switch />
</FormField>
</Col>
<Col>
<button onClick={addHandler}>Add Column Property</button>
</Col>
</Row>
{
fields && (
fields.map((field: any, _index: number) => {
if (field.property !== undefined) {
return (
<Column
path={`${name}.columns`}
index={_index}
control={control}
fields={fields}
onDelete={() => remove(_index) }
/>
)
} else {
return (
<Handler
path={`${name}.columns`}
index={_index}
/>
)
}
})
)
}
</React.Fragment>
);
}
Essentially the Handler Component uses the form's context and call itself if it should render a nested object of the form like datasource.columns[x] which the register to useFieldArray to get it's columns. Everything so far works fine. I render the complete tree (if i can say) like object form correctly.
For reference here is the code of the Column Component as also for the formField helper component FormField.
export const Column: React.FunctionComponent<ColumnProps> = (props) => {
const { fields, control, path, index, onDelete } = props;
const value = useWatch({
control,
name: `${path}[${index}]`,
defaultValue: !isNil(fields[index])? fields[index]: {
property: null,
}
});
console.log(`Render of Column ${path} ${value.property}`);
return (
<Row>
<Col>
<button onClick={onDelete}>Remove Property</button>
</Col>
<Col>
<FormField name={`${path}[${index}].property`} defaultValue={value.property}>
<Input
label={`Column Property name`}
/>
</FormField>
</Col>
</Row>
);
}
export type FormFieldProps = {
name: string;
disabled?: boolean;
validation?: any;
defaultValue?: any;
children?: any;
};
export const FormField: React.FunctionComponent<FormFieldProps> = (props) => {
const {
children,
name,
validation,
defaultValue,
disabled,
} = props;
const { errors, control, setValue } = useFormContext();
return (
<Controller
name={name}
control={control}
defaultValue={defaultValue? defaultValue: null}
rules={{ required: true }}
render={props => {
return (
React.cloneElement(children, {
...children.props,
...{
disabled: disabled || children.props.disabled,
value: props.value,
onChange: (v:any) => {
props.onChange(v);
setValue(name, v, { shouldValidate: true });
},
}
})
);
}}
/>
);
}
The problem is when i remove a field from the array. The Handler component re-renders having correct values on the fields data. But the values of useWatch have the initial data which leads to a wrong render with wrong fields being displayed an the forms starts to mesh up.
Is there any advice on how to render such a nested form the correct way. I guess this is not an issue from the react-hook-form but the implementation has an error which seems to cause the problem.

Related

Using object as a value in `react-hook-form`

Description
I have a component that uses an object as a value.
I would like to use this component with react-hook-form
The problem is that react-hook-form thinks that my object is a nested form control
Setup
This is just an example.
Range date picker is a common use case for such a behaviour
Example codepen
The value that the component accepts:
type ComponentValue = {
a: string;
b: string;
}
The component:
const Component = ({ value, onChange }: { value: ComponentValue, onChange: (value: ComponentValue) => void }) => {
return (
<input value={value.a} onChange={e => onChange({ a: e.target.value, b: (Math.random() * 100 | 0).toString() }) } />
)
}
Form value
type FormValues = {
someField: ComponentValue;
// other fields
};
Default values:
const defaultValues = {
someField: {
a: 'Default a',
b: 'Default b'
},
// other fields
};
useForm usage:
const {
register,
handleSubmit,
formState: { errors },
control,
} = useForm<FormValues>({
defaultValues,
});
The problem
Hovering over (or trying to use) errors reveals the type:
const errors: {
someField?: {
a?: FieldError | undefined;
b?: FieldError | undefined;
} | undefined;
// other fields
}
But I would it to be:
const errors: {
someField?: FieldError | undefined;
// other fields
}
Summary
Can I somehow force react-hook-form to treat my object as a value instead of a nested form field?
If you want to combine 2 inputs in a same component, you can try the approach with a controller.
export default function App() {
const { control, handleSubmit, watch } = useForm({
defaultValues: {
travel: {
from: "paris",
to: "london"
}
}
});
const onSubmit = (data) => {
console.log("dans onSubmit", JSON.stringify(data, null, 2));
};
return (
<>
<h1>Travel</h1>
<form
onSubmit={handleSubmit(onSubmit)}
style={{ display: "flex", flexDirection: "column", maxWidth: 600 }}
noValidate
>
<TravelPicker control={control} />
<input type="submit" />
<pre>{JSON.stringify(watch(), null, 2)}</pre>
</form>
</>
);
The component have a control props, to be able to register the 2 inputs :
const TravelPicker = ({ control }) => {
return (
<>
<label>
From
<Controller
name="travel.from"
control={control}
render={({ field: { onChange, value, name } }) => (
<input name={name} value={value} onChange={(e) => onChange(e)} />
)}
/>
</label>
<label>
To
<Controller
name="travel.to"
control={control}
render={({ field: { onChange, value, name } }) => (
<input name={name} value={value} onChange={(e) => onChange(e)} />
)}
/>
</label>
</>
);
};
The errors object has then the same structure as the form inputs.
See this sandbox for a functional exemple.
The createor of RHF seems to indicate here that you can also have only one component for 2 inputs.
You can use setValue and setError like below:
const {setValue, setError} = useForm();
// ... some where in your form
<input
type = "text"
name = "from"
onChange = {
(event) => {
const value = event.target.value;
if(value === ""){
setError("from", "This field is required");
}else{
setValue(`from[0]`, {
"otherProperty" : "otherValue",
"from_value": value, // this is your input value
});
}
}
}
/>
Probably you would be iterating through an array. change static 0 with your index in setValue If not you may use object syntax like below:
//....
setValue(`from.your_property_name`, {
"otherProperty" : "otherValue",
"from_value": value, // this is your input value
});
// ....
This won't cause re-render of component.
More info in doc
NOTE: Please be respectful when commenting.

How to type object in a material ui select

I'm trying to implement a Select component using reactjs material ui and typescript.
However, I am getting the following typing error:
Property 'id' does not exist on type 'string'.
Property 'name' does not exist on type 'string'.
My Component:
import React from 'react';
import { TextField, MenuItem, TextFieldProps } from '#material-ui/core';
import { useField, useFormikContext } from 'formik';
interface Options {
id: number;
name: string;
}
type ITextFieldProps = TextFieldProps & {
name: string;
options: Options;
};
const SelectWrapper: React.FC<ITextFieldProps> = ({
name,
options,
...otherProps
}) => {
const { setFieldValue } = useFormikContext();
const [field, meta] = useField(name);
const handleChange = (evt: React.ChangeEvent<any>) => {
const { value } = evt.target;
setFieldValue(name, value);
};
const configSelect = {
...field,
...otherProps,
select: true,
fullWidth: true,
onChange: handleChange,
error: false,
helperText: '',
};
if (meta && meta.touched && meta.error) {
configSelect.error = true;
configSelect.helperText = meta.error;
}
return (
<TextField variant="outlined" {...configSelect}>
{Object.keys(options).map(opt => {
return (
<MenuItem key={opt.id} value={opt.id}>
{opt.name}
</MenuItem>
);
})}
</TextField>
);
};
export default SelectWrapper;
The options object is composed of:
{
"id": 10053,
"name": "Direção Defensiva – Controle de Acidentes Ambev - 2021 (Distribuição)"
},
{
"id": 10052,
"name": "Segurança na Operação de Empilhadeira – 2021"
}
I understand that the problem is in the object's typing, however, I can't find the correct way to type it.
What is the correct way to type id and name for this to work?
From what it looks like, options is actually an array of objects rather than just an object. So all you would need to do is map over the options variable. You are currently using Object.keys which is what you use if you are wanting to iterate over the keys in an object.
{options.map(opt => {
return (
<MenuItem key={opt.id} value={opt.id}>
{opt.name}
</MenuItem>
);
})}

ant.design <Checkbox.Group/> with mutually exclusive checkboxes

The following component should always only have at most one of the two checkboxes checked. Although the state checkedList always only contains at most one value the UI doesn't reflect this state.
What am I missing?
interface TeilnehmerProps {
rolle: Teilnehmerrolle,
teilnehmer: Teilnehmer,
readonly: boolean
}
export const TeilnehmerCard = (props: TeilnehmerProps) => {
const { rolle, teilnehmer, readonly } = props;
const [name, setName] = useState<string>(teilnehmer?.name);
const [checkedList, setCheckedList] = useState<CheckboxValueType[]>([teilnehmer?.abwesenheit]);
const handleChecked = (list: CheckboxValueType[]) => {
const set = new Set(list);
if (set.size === 2) {
set.delete(checkedList[0]);
setCheckedList(Array.from(set));
} else if (set.size === 1) {
setCheckedList(Array.from(set));
} else {
setCheckedList([]);
}
};
useEffect(() => {
console.log(checkedList);
}, [checkedList]);
return <Card title="" size="small" className={styles.teilnehmerCard}>
<Row key={teilnehmer?.rolle}>
<Col lg={4}>
<Form.Item name={`${teilnehmer?.rolle}.abwesenheit`}
initialValue={[teilnehmer?.abwesenheit]}>
<Checkbox.Group value={checkedList}
disabled={readonly}
options={[{ value: 'ENTSCHULDIGT', label: '' }, { value: 'UNENTSCHULDIGT', label: '' }]}
onChange={handleChecked}>
</Checkbox.Group>
</Form.Item>
</Col>
</Row>
</Card>;
};
Once wrapped in <Form.Item/> one cannot and shouldn't control the form values anymore, but use form.setFieldsValue().

Adding new options to form on click in ReactJs

I am doing a React search where user can add multiple filters. The idea is that at first there is only one filter (select and input field) and if user wishes to add more, he can add one more row of (select and input) and it will also take that into account.
I cannot figure out the part on how to add more rows of (select, input) and furthermore, how to read their data as the list size and everything can change.
So I have multiple options in the select array:
const options = [
{ label: "foo", value: 1 },
{ label: "bar", value: 2 },
{ label: "bin", value: 3 }
];
Now if user selects the first value from the Select box and then types a text in the input box I will get their values and I could do a search based on that.
const options = [
{ label: "foo", value: 1 },
{ label: "bar", value: 2 },
{ label: "bin", value: 3 }
];
class App extends React.Component {
state = {
selectedOption: null,
textValue: null
};
handleOptionChange = selectedOption => {
this.setState({ selectedOption: selectedOption.value });
};
handleTextChange = event => {
this.setState({ textValue: event.target.value });
};
handleSubmit = () => {
console.log(
"SelectedOption: " +
this.state.selectedOption +
", textValue: " +
this.state.textValue
);
};
addNewRow = () => {
console.log("adding new row of filters");
};
render() {
const { selectedOption } = this.state;
return (
<div>
<div style={{ display: "flex" }}>
<Select
value={selectedOption}
onChange={this.handleOptionChange}
options={options}
/>
<input
type="text"
value={this.state.textValue}
onChange={this.handleTextChange}
/>
</div>
<button onClick={this.addNewRow}>AddNewRow</button>
<button onClick={this.handleSubmit}>Submit</button>
</div>
);
}
}
export default App;
I have also created a CodeSandBox for this.
If user clicks on the addNewRow a new row should appear and the previous (search, input) should be selectable without the row that was previously selected.
I don't even really know how I should approach this.
To add new row of inputs on click of button you need to add new input item into the list of inputs, like I have mention below::
import React, { Component } from 'react'
import Select from "react-select";
const options = [
{ label: "foo", value: 1 },
{ label: "bar", value: 2 },
{ label: "bin", value: 3 }
];
class App extends Component {
constructor(props) {
super(props);
this.state = { inputGroups: ['input-0'] };
}
handleSubmit = () => {
console.log("form submitted");
};
AddNewRow() {
var newInput = `input-${this.state.inputGroups.length}`;
this.setState(prevState => ({ inputGroups: prevState.inputGroups.concat([newInput]) }));
}
render() {
return (
<div>
<div>
<div>
{this.state.inputGroups.map(input =>
<div key={input} style={{ display: "flex" }}>
<Select
options={options}
/>
<input
type="text"
// value={this.state.textValue}
// onChange={this.handleTextChange}
/>
</div>
)}
</div>
</div>
<button onClick={() => this.AddNewRow()}>AddNewRow</button>
<button onClick={this.handleSubmit()}>Submit</button>
</div>
);
}
}
export default App;
After click on "AddNewRow" button it will add new input group for you. Now you need to wrap this inputGroup inside "Form" to get data of each inputGroup on click of submit.
I hope it will resolve your issue.

How to iterate children in React using render props

I have this pseudo code for my form. Where I would like to display just fields with canAccess=true.
const initialValues = {
firstName: { canAccess: true, value: 'Mary' },
surName: { canAccess: false, value: 'Casablanca' }
}
<Form initialValues={initialValues}>
{props =>
<>
<div className="nestedItem">
<Field name="firstName" />
</div>
<Field name="surName" />
</>
}
</Form>
With this code I would like to see rendered just field with firstName.
I know that I can iterate through React.Children.map() but I don't know how to iterate children when using render props.
Also there can be nested elements, so I would like to find specific type of component by name.
Thanks for help.
const initialValues = {
firstName: { canAccess: true, value: 'Mary' },
surName: { canAccess: false, value: 'Casablanca' }
}
<Form initialValues={initialValues}>
{props =>
<>
{
Object.keys(props.initialValues).map(k => (
k.canAccess && <Field name={k} />
));
}
</>
}
</Form>
Edit: Your form can perform some logic and pass back filtered items to your component.
getFilteredItems = items => Object.keys(items).reduce((acc, key) => {
const item = items[key];
const { canAccess } = item;
if(!canAccess) return acc;
return {
...acc,
[key]: item
}
}, {}));
render() {
const { initialValues, children } = this.props;
const filteredItems = this.getFilteredItems(initialValues);
return children(filteredItems);
}
<Form initialValues={initialValues}>
{ props =>
<>
{
Object.keys(props).map(k => <Field name={k} />)
}
</>
</Form>
This is what I was looking for.
const Form = ({initialValues, children}) =>
props =>
<Authorized initialValues={initialValues}>
{typeof children === 'function' ? children(props) : children}
</Authorized>
const Authorized = ({initialValues, children}) => {
// Do check
React.Children.map(chidlren, x => {
if(x.type === Field ) // && initialValues contains x.props.name
return x
return null
... })
}

Resources