Skip to content

Commit 07fffeb

Browse files
committed
Introduce <ArrayInputBase>, <SimpleFomIteratorBase> and <SimpleFormIteratorItemBase>
1 parent 62b9028 commit 07fffeb

File tree

10 files changed

+642
-448
lines changed

10 files changed

+642
-448
lines changed
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import * as React from 'react';
2+
import { type ReactNode, useEffect } from 'react';
3+
import { useFieldArray, useFormContext } from 'react-hook-form';
4+
import type { InputProps } from '../../form/useInput';
5+
import { composeSyncValidators } from '../../form/validation/validate';
6+
import { useGetValidationErrorMessage } from '../../form/validation/useGetValidationErrorMessage';
7+
import { useApplyInputDefaultValues } from '../../form/useApplyInputDefaultValues';
8+
import { useFormGroupContext } from '../../form/groups/useFormGroupContext';
9+
import { useFormGroups } from '../../form/groups/useFormGroups';
10+
import {
11+
OptionalResourceContextProvider,
12+
SourceContextProvider,
13+
type SourceContextValue,
14+
useSourceContext,
15+
} from '../../core';
16+
import { ArrayInputContext } from './ArrayInputContext';
17+
18+
/**
19+
* To edit arrays of data embedded inside a record, <ArrayInput> creates a list of sub-forms.
20+
*
21+
* @example
22+
*
23+
* import { ArrayInput, SimpleFormIterator, DateInput, TextInput } from 'react-admin';
24+
*
25+
* <ArrayInput source="backlinks">
26+
* <SimpleFormIterator>
27+
* <DateInput source="date" />
28+
* <TextInput source="url" />
29+
* </SimpleFormIterator>
30+
* </ArrayInput>
31+
*
32+
* <ArrayInput> allows the edition of embedded arrays, like the backlinks field
33+
* in the following post record:
34+
*
35+
* {
36+
* id: 123
37+
* backlinks: [
38+
* {
39+
* date: '2012-08-10T00:00:00.000Z',
40+
* url: 'http://example.com/foo/bar.html',
41+
* },
42+
* {
43+
* date: '2012-08-14T00:00:00.000Z',
44+
* url: 'https://blog.johndoe.com/2012/08/12/foobar.html',
45+
* }
46+
* ]
47+
* }
48+
*
49+
* <ArrayInput> expects a single child, which must be a *form iterator* component.
50+
* A form iterator is a component accepting a fields object as passed by
51+
* react-hook-form-arrays's useFieldArray() hook, and defining a layout for
52+
* an array of fields. For instance, the <SimpleFormIterator> component
53+
* displays an array of fields in an unordered list (<ul>), one sub-form by
54+
* list item (<li>). It also provides controls for adding and removing
55+
* a sub-record (a backlink in this example).
56+
*
57+
* @see {@link https://react-hook-form.com/docs/usefieldarray}
58+
*/
59+
export const ArrayInputBase = (props: ArrayInputBaseProps) => {
60+
const {
61+
children,
62+
defaultValue = [],
63+
isPending,
64+
loading,
65+
resource: resourceFromProps,
66+
source: arraySource,
67+
validate,
68+
} = props;
69+
70+
const formGroupName = useFormGroupContext();
71+
const formGroups = useFormGroups();
72+
const parentSourceContext = useSourceContext();
73+
const finalSource = parentSourceContext.getSource(arraySource);
74+
75+
const sanitizedValidate = Array.isArray(validate)
76+
? composeSyncValidators(validate)
77+
: validate;
78+
const getValidationErrorMessage = useGetValidationErrorMessage();
79+
80+
const { getValues } = useFormContext();
81+
82+
const fieldProps = useFieldArray({
83+
name: finalSource,
84+
rules: {
85+
validate: async value => {
86+
if (!sanitizedValidate) return true;
87+
const error = await sanitizedValidate(
88+
value,
89+
getValues(),
90+
props
91+
);
92+
93+
if (!error) return true;
94+
return getValidationErrorMessage(error);
95+
},
96+
},
97+
});
98+
99+
useEffect(() => {
100+
if (formGroups && formGroupName != null) {
101+
formGroups.registerField(finalSource, formGroupName);
102+
}
103+
104+
return () => {
105+
if (formGroups && formGroupName != null) {
106+
formGroups.unregisterField(finalSource, formGroupName);
107+
}
108+
};
109+
}, [finalSource, formGroups, formGroupName]);
110+
111+
useApplyInputDefaultValues({
112+
inputProps: { ...props, defaultValue },
113+
isArrayInput: true,
114+
fieldArrayInputControl: fieldProps,
115+
});
116+
117+
// The SourceContext will be read by children of ArrayInput to compute their composed source and label
118+
//
119+
// <ArrayInput source="orders" /> => SourceContext is "orders"
120+
// <SimpleFormIterator> => SourceContext is "orders.0"
121+
// <DateInput source="date" /> => final source for this input will be "orders.0.date"
122+
// </SimpleFormIterator>
123+
// </ArrayInput>
124+
//
125+
const sourceContext = React.useMemo<SourceContextValue>(
126+
() => ({
127+
// source is the source of the ArrayInput child
128+
getSource: (source: string) => {
129+
if (!source) {
130+
// SimpleFormIterator calls getSource('') to get the arraySource
131+
return parentSourceContext.getSource(arraySource);
132+
}
133+
134+
// We want to support nesting and composition with other inputs (e.g. TranslatableInputs, ReferenceOneInput, etc),
135+
// we must also take into account the parent SourceContext
136+
//
137+
// <ArrayInput source="orders" /> => SourceContext is "orders"
138+
// <SimpleFormIterator> => SourceContext is "orders.0"
139+
// <DateInput source="date" /> => final source for this input will be "orders.0.date"
140+
// <ArrayInput source="items" /> => SourceContext is "orders.0.items"
141+
// <SimpleFormIterator> => SourceContext is "orders.0.items.0"
142+
// <TextInput source="reference" /> => final source for this input will be "orders.0.items.0.reference"
143+
// </SimpleFormIterator>
144+
// </ArrayInput>
145+
// </SimpleFormIterator>
146+
// </ArrayInput>
147+
return parentSourceContext.getSource(
148+
`${arraySource}.${source}`
149+
);
150+
},
151+
// if Array source is items, and child source is name, .0.name => resources.orders.fields.items.name
152+
getLabel: (source: string) =>
153+
parentSourceContext.getLabel(`${arraySource}.${source}`),
154+
}),
155+
[parentSourceContext, arraySource]
156+
);
157+
158+
if (isPending && loading !== undefined && loading !== false) {
159+
return loading;
160+
}
161+
162+
return (
163+
<ArrayInputContext.Provider value={fieldProps}>
164+
<OptionalResourceContextProvider value={resourceFromProps}>
165+
<SourceContextProvider value={sourceContext}>
166+
{children}
167+
</SourceContextProvider>
168+
</OptionalResourceContextProvider>
169+
</ArrayInputContext.Provider>
170+
);
171+
};
172+
173+
export const getArrayInputError = error => {
174+
if (Array.isArray(error)) {
175+
return undefined;
176+
}
177+
return error;
178+
};
179+
180+
export interface ArrayInputBaseProps
181+
extends Omit<InputProps, 'disabled' | 'readOnly'> {
182+
children: ReactNode;
183+
loading?: ReactNode;
184+
isFetching?: boolean;
185+
isLoading?: boolean;
186+
isPending?: boolean;
187+
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import * as React from 'react';
2+
import { Children, type ReactNode, useMemo, useRef } from 'react';
3+
import get from 'lodash/get';
4+
import { type UseFieldArrayReturn, useFormContext } from 'react-hook-form';
5+
import { FormDataConsumer } from '../../form/FormDataConsumer';
6+
import { useWrappedSource } from '../../core/useWrappedSource';
7+
import type { RaRecord } from '../../types';
8+
import { useEvent } from '../../util';
9+
import { useRecordContext } from '../record/useRecordContext';
10+
import { useArrayInput } from './useArrayInput';
11+
import { SimpleFormIteratorContext } from './SimpleFormIteratorContext';
12+
13+
export const SimpleFormIteratorBase = (props: SimpleFormIteratorBaseProps) => {
14+
const { inputs, render } = props;
15+
16+
const finalSource = useWrappedSource('');
17+
if (!finalSource) {
18+
throw new Error(
19+
'SimpleFormIterator can only be called within an iterator input like ArrayInput'
20+
);
21+
}
22+
23+
const { append, fields, move, remove, replace } = useArrayInput(props);
24+
const { trigger, getValues } = useFormContext();
25+
const record = useRecordContext(props);
26+
const initialDefaultValue = useRef({});
27+
28+
const removeField = useEvent((index: number) => {
29+
remove(index);
30+
const isScalarArray = getValues(finalSource).every(
31+
(value: any) => typeof value !== 'object'
32+
);
33+
if (isScalarArray) {
34+
// Trigger validation on the Array to avoid ghost errors.
35+
// Otherwise, validation errors on removed fields might still be displayed
36+
trigger(finalSource);
37+
}
38+
});
39+
40+
if (fields.length > 0) {
41+
const { id, ...rest } = fields[0];
42+
initialDefaultValue.current = rest;
43+
for (const k in initialDefaultValue.current)
44+
initialDefaultValue.current[k] = null;
45+
}
46+
47+
const addField = useEvent((item: any = undefined) => {
48+
let defaultValue = item;
49+
if (item == null) {
50+
defaultValue = initialDefaultValue.current;
51+
if (
52+
Children.count(inputs) === 1 &&
53+
React.isValidElement(Children.only(inputs)) &&
54+
// @ts-ignore
55+
!Children.only(inputs).props.source &&
56+
// Make sure it's not a FormDataConsumer
57+
// @ts-ignore
58+
Children.only(inputs).type !== FormDataConsumer
59+
) {
60+
// ArrayInput used for an array of scalar values
61+
// (e.g. tags: ['foo', 'bar'])
62+
defaultValue = '';
63+
} else {
64+
// ArrayInput used for an array of objects
65+
// (e.g. authors: [{ firstName: 'John', lastName: 'Doe' }, { firstName: 'Jane', lastName: 'Doe' }])
66+
defaultValue = defaultValue || ({} as Record<string, unknown>);
67+
Children.forEach(inputs, input => {
68+
if (
69+
React.isValidElement(input) &&
70+
input.type !== FormDataConsumer &&
71+
input.props.source
72+
) {
73+
defaultValue[input.props.source] =
74+
input.props.defaultValue ?? null;
75+
}
76+
});
77+
}
78+
}
79+
append(defaultValue);
80+
});
81+
82+
const handleReorder = useEvent((origin: number, destination: number) => {
83+
move(origin, destination);
84+
});
85+
86+
const handleArrayClear = useEvent(() => {
87+
replace([]);
88+
});
89+
90+
const records = get(record, finalSource);
91+
92+
const context = useMemo(
93+
() => ({
94+
total: fields.length,
95+
add: addField,
96+
clear: handleArrayClear,
97+
remove: removeField,
98+
reOrder: handleReorder,
99+
source: finalSource,
100+
}),
101+
[
102+
addField,
103+
fields.length,
104+
handleArrayClear,
105+
handleReorder,
106+
removeField,
107+
finalSource,
108+
]
109+
);
110+
111+
if (!fields) {
112+
return null;
113+
}
114+
return (
115+
<SimpleFormIteratorContext.Provider value={context}>
116+
{render({ fields, records })}
117+
</SimpleFormIteratorContext.Provider>
118+
);
119+
};
120+
121+
export interface SimpleFormIteratorBaseProps
122+
extends Partial<UseFieldArrayReturn> {
123+
inline?: boolean;
124+
inputs: ReactNode;
125+
meta?: {
126+
// the type defined in FieldArrayRenderProps says error is boolean, which is wrong.
127+
error?: any;
128+
submitFailed?: boolean;
129+
};
130+
render: (props: {
131+
fields: Record<'id', string>[];
132+
records: RaRecord[];
133+
}) => ReactNode;
134+
record?: RaRecord;
135+
resource?: string;
136+
source?: string;
137+
}

packages/ra-core/src/controller/input/SimpleFormIteratorContext.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export const SimpleFormIteratorContext = createContext<
1212

1313
export type SimpleFormIteratorContextValue = {
1414
add: (item?: any) => void;
15+
clear: () => void;
1516
remove: (index: number) => void;
1617
reOrder: (index: number, newIndex: number) => void;
1718
source: string;

0 commit comments

Comments
 (0)