diff --git a/docs_headless/astro.config.mjs b/docs_headless/astro.config.mjs index 997eb125f02..9d9aaa938fb 100644 --- a/docs_headless/astro.config.mjs +++ b/docs_headless/astro.config.mjs @@ -173,7 +173,12 @@ export default defineConfig({ }, { label: 'Inputs', - items: ['inputs', 'useinput'], + items: [ + 'inputs', + 'useinput', + 'arrayinputbase', + 'simpleformiteratorbase', + ], }, { label: 'Preferences', diff --git a/docs_headless/src/content/docs/ArrayInputBase.md b/docs_headless/src/content/docs/ArrayInputBase.md new file mode 100644 index 00000000000..369cafc002e --- /dev/null +++ b/docs_headless/src/content/docs/ArrayInputBase.md @@ -0,0 +1,177 @@ +--- +layout: default +title: "" +--- + +`` allows editing of embedded arrays, like the `items` field in the following `order` record: + +```json +{ + "id": 1, + "date": "2022-08-30", + "customer": "John Doe", + "items": [ + { + "name": "Office Jeans", + "price": 45.99, + "quantity": 1, + }, + { + "name": "Black Elegance Jeans", + "price": 69.99, + "quantity": 2, + }, + { + "name": "Slim Fit Jeans", + "price": 55.99, + "quantity": 1, + }, + ], +} +``` + +## Usage + +`` expects a single child, which must be a *form iterator* component. A form iterator is a component rendering a field array (the object returned by react-hook-form's [`useFieldArray`](https://react-hook-form.com/docs/usefieldarray)). You can build such component using [the ``](./SimpleFormIteratorBase.md). + +```tsx +import { ArrayInputBase, EditBase, Form } from 'ra-core'; +import { MyFormIterator } from './MyFormIterator'; +import { DateInput } from './DateInput'; +import { NumberInput } from './NumberInput'; +import { TextInput } from './TextInput'; + +export const OrderEdit = () => ( + +
+ +
+
Items:
+ + + + + + + +
+ + +
+) +``` + +**Note**: Setting [`shouldUnregister`](https://react-hook-form.com/docs/useform#shouldUnregister) on a form should be avoided when using `` (which internally uses `useFieldArray`) as the unregister function gets called after input unmount/remount and reorder. This limitation is mentioned in the react-hook-form [documentation](https://react-hook-form.com/docs/usecontroller#props). If you are in such a situation, you can use the [`transform`](./EditBase.md#transform) prop to manually clean the submitted values. + +## Props + +| Prop | Required | Type | Default | Description | +|-----------------| -------- |---------------------------| ------- |---------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `source` | Required | `string` | - | Name of the entity property to use for the input value | +| `defaultValue` | Optional | `any` | - | Default value of the input. | +| `validate` | Optional | `Function` | `array` | - | Validation rules for the current property. See the [Validation Documentation](./Validation.md#per-input-validation-built-in-field-validators) for details. | + +## Global validation + +If you are using an `` inside a form with global validation, you need to shape the errors object returned by the `validate` function like an array too. + +For instance, to display the following errors: + +![ArrayInput global validation](../../img/ArrayInput-global-validation.png) + +You need to return an errors object shaped like this: + +```js + { + authors: [ + {}, + { + name: 'A name is required', + role: 'ra.validation.required' // translation keys are supported too + }, + ], + } +``` + +**Tip:** You can find a sample `validate` function that handles arrays in the [Form Validation documentation](./Validation.md#global-validation). + +## Disabling The Input + +`` does not support the `disabled` and `readOnly` props. + +If you need to disable the input, make sure the children are either `disabled` and `readOnly`: + +```jsx +import { ArrayInputBase, EditBase, Form } from 'ra-core'; +import { MyFormIterator } from './MyFormIterator'; +import { DateInput } from './DateInput'; +import { NumberInput } from './NumberInput'; +import { TextInput } from './TextInput'; + +const OrderEdit = () => ( + +
+ + +
+
Items:
+ + + + + + + +
+ + +
+); +``` + +## Changing An Item's Value Programmatically + +You can leverage `react-hook-form`'s [`setValue`](https://react-hook-form.com/docs/useform/setvalue) method to change an item's value programmatically. + +However you need to know the `name` under which the input was registered in the form, and this name is dynamically generated depending on the index of the item in the array. + +To get the name of the input for a given index, you can leverage the `SourceContext` created by react-admin, which can be accessed using the `useSourceContext` hook. + +This context provides a `getSource` function that returns the effective `source` for an input in the current context, which you can use as input name for `setValue`. + +Here is an example where we leverage `getSource` and `setValue` to change the role of an user to 'admin' when the 'Make Admin' button is clicked: + +```tsx +import { ArrayInputBase, useSourceContext } from 'ra-core'; +import { useFormContext } from 'react-hook-form'; +import { MyFormIterator } from './MyFormIterator'; + +const MakeAdminButton = () => { + const sourceContext = useSourceContext(); + const { setValue } = useFormContext(); + + const onClick = () => { + // sourceContext.getSource('role') will for instance return + // 'users.0.role' + setValue(sourceContext.getSource('role'), 'admin'); + }; + + return ( + + ); +}; + +const UserArray = () => ( + + + + + + + +); +``` + +**Tip:** If you only need the item's index, you can leverage the `useSimpleFormIteratorItem` hook instead. \ No newline at end of file diff --git a/docs_headless/src/content/docs/SimpleFormIteratorBase.md b/docs_headless/src/content/docs/SimpleFormIteratorBase.md new file mode 100644 index 00000000000..b245671493f --- /dev/null +++ b/docs_headless/src/content/docs/SimpleFormIteratorBase.md @@ -0,0 +1,70 @@ +--- +layout: default +title: "" +--- + +`` helps building a component that lets users edit, add, remove and reorder sub-records. It is designed to be used as a child of [``](./ArrayInputBase.md) or [``](https://react-admin-ee.marmelab.com/documentation/ra-core-ee#referencemanyinputbase). You can also use it within an `ArrayInputContext` containing a *field array*, i.e. the value returned by [react-hook-form's `useFieldArray` hook](https://react-hook-form.com/docs/usefieldarray). + +## Usage + +Here's how one could implement a minimal `SimpleFormIterator` using ``: + +```tsx +import { + SimpleFormIteratorBase, + SimpleFormIteratorItemBase, + useArrayInput, + useFieldValue, + useSimpleFormIterator, + useSimpleFormIteratorItem, + useWrappedSource, + type SimpleFormIteratorBaseProps +} from 'ra-core'; + +export const SimpleFormIterator = ({ children, ...props }: SimpleFormIteratorBaseProps) => { + const { fields } = useArrayInput(props); + // Get the parent source by passing an empty string as source + const source = useWrappedSource(''); + const records = useFieldValue({ source }); + + return ( + +
    + {fields.map((member, index) => ( + +
  • + {children} + +
  • +
    + ))} +
+ +
+ ) +} + +const RemoveItemButton = () => { + const { remove } = useSimpleFormIteratorItem(); + return ( + + ) +} + +const AddItemButton = () => { + const { add } = useSimpleFormIterator(); + return ( + + ) +} +``` + +## Props + +| Prop | Required | Type | Default | Description | +|-------------------|----------|----------------|-----------------------|-----------------------------------------------| +| `children` | Optional | `ReactElement` | - | List of inputs to display for each array item | \ No newline at end of file diff --git a/docs_headless/src/img/ArrayInput-global-validation.png b/docs_headless/src/img/ArrayInput-global-validation.png new file mode 100644 index 00000000000..863b339797d Binary files /dev/null and b/docs_headless/src/img/ArrayInput-global-validation.png differ diff --git a/packages/ra-core/src/controller/input/ArrayInputBase.stories.tsx b/packages/ra-core/src/controller/input/ArrayInputBase.stories.tsx new file mode 100644 index 00000000000..e1a8675fde0 --- /dev/null +++ b/packages/ra-core/src/controller/input/ArrayInputBase.stories.tsx @@ -0,0 +1,73 @@ +import * as React from 'react'; +import fakeRestDataProvider from 'ra-data-fakerest'; +import { TestMemoryRouter } from '../../routing'; +import { EditBase } from '../edit'; +import { + Admin, + DataTable, + TextInput, + SimpleFormIterator, + SimpleForm, +} from '../../test-ui'; +import { ListBase } from '../list'; +import { Resource } from '../../core'; +import { ArrayInputBase } from './ArrayInputBase'; + +export default { title: 'ra-core/controller/input/ArrayInputBase' }; + +export const Basic = () => ( + + + + + + + record.tags + ? record.tags + .map(tag => tag.name) + .join(', ') + : '' + } + /> + + + } + edit={ + + + +
+
Tags:
+ + + + + + +
+
+
+ } + /> +
+
+); diff --git a/packages/ra-core/src/controller/input/ArrayInputBase.tsx b/packages/ra-core/src/controller/input/ArrayInputBase.tsx new file mode 100644 index 00000000000..fdabd093707 --- /dev/null +++ b/packages/ra-core/src/controller/input/ArrayInputBase.tsx @@ -0,0 +1,175 @@ +import * as React from 'react'; +import { type ReactNode, useEffect } from 'react'; +import { useFieldArray, useFormContext } from 'react-hook-form'; +import type { InputProps } from '../../form/useInput'; +import { composeSyncValidators } from '../../form/validation/validate'; +import { useGetValidationErrorMessage } from '../../form/validation/useGetValidationErrorMessage'; +import { useApplyInputDefaultValues } from '../../form/useApplyInputDefaultValues'; +import { useFormGroupContext } from '../../form/groups/useFormGroupContext'; +import { useFormGroups } from '../../form/groups/useFormGroups'; +import { + OptionalResourceContextProvider, + SourceContextProvider, + type SourceContextValue, + useSourceContext, +} from '../../core'; +import { ArrayInputContext } from './ArrayInputContext'; + +/** + * To edit arrays of data embedded inside a record, creates a list of sub-forms. + * + * @example + * + * import { ArrayInputBase } from 'ra-core'; + * import { SimpleFormIterator, DateInput, TextInput } from 'my-react-admin-ui'; + * + * + * + * + * + * + * + * + * allows the edition of embedded arrays, like the backlinks field + * in the following post record: + * + * { + * id: 123 + * backlinks: [ + * { + * date: '2012-08-10T00:00:00.000Z', + * url: 'http://example.com/foo/bar.html', + * }, + * { + * date: '2012-08-14T00:00:00.000Z', + * url: 'https://blog.johndoe.com/2012/08/12/foobar.html', + * } + * ] + * } + * + * expects a single child, which must be a *form iterator* component. + * A form iterator is a component accepting a fields object as passed by + * react-hook-form-arrays's useFieldArray() hook, and defining a layout for + * an array of fields. + * + * @see {@link https://react-hook-form.com/docs/usefieldarray} + */ +export const ArrayInputBase = (props: ArrayInputBaseProps) => { + const { + children, + defaultValue = [], + resource: resourceFromProps, + source: arraySource, + validate, + } = props; + + const formGroupName = useFormGroupContext(); + const formGroups = useFormGroups(); + const parentSourceContext = useSourceContext(); + const finalSource = parentSourceContext.getSource(arraySource); + + const sanitizedValidate = Array.isArray(validate) + ? composeSyncValidators(validate) + : validate; + const getValidationErrorMessage = useGetValidationErrorMessage(); + + const { getValues } = useFormContext(); + + const fieldProps = useFieldArray({ + name: finalSource, + rules: { + validate: async value => { + if (!sanitizedValidate) return true; + const error = await sanitizedValidate( + value, + getValues(), + props + ); + + if (!error) return true; + return getValidationErrorMessage(error); + }, + }, + }); + + useEffect(() => { + if (formGroups && formGroupName != null) { + formGroups.registerField(finalSource, formGroupName); + } + + return () => { + if (formGroups && formGroupName != null) { + formGroups.unregisterField(finalSource, formGroupName); + } + }; + }, [finalSource, formGroups, formGroupName]); + + useApplyInputDefaultValues({ + inputProps: { ...props, defaultValue }, + isArrayInput: true, + fieldArrayInputControl: fieldProps, + }); + + // The SourceContext will be read by children of ArrayInput to compute their composed source and label + // + // => SourceContext is "orders" + // => SourceContext is "orders.0" + // => final source for this input will be "orders.0.date" + // + // + // + const sourceContext = React.useMemo( + () => ({ + // source is the source of the ArrayInput child + getSource: (source: string) => { + if (!source) { + // SimpleFormIterator calls getSource('') to get the arraySource + return parentSourceContext.getSource(arraySource); + } + + // We want to support nesting and composition with other inputs (e.g. TranslatableInputs, ReferenceOneInput, etc), + // we must also take into account the parent SourceContext + // + // => SourceContext is "orders" + // => SourceContext is "orders.0" + // => final source for this input will be "orders.0.date" + // => SourceContext is "orders.0.items" + // => SourceContext is "orders.0.items.0" + // => final source for this input will be "orders.0.items.0.reference" + // + // + // + // + return parentSourceContext.getSource( + `${arraySource}.${source}` + ); + }, + // if Array source is items, and child source is name, .0.name => resources.orders.fields.items.name + getLabel: (source: string) => + parentSourceContext.getLabel(`${arraySource}.${source}`), + }), + [parentSourceContext, arraySource] + ); + + return ( + + + + {children} + + + + ); +}; + +export const getArrayInputError = error => { + if (Array.isArray(error)) { + return undefined; + } + return error; +}; + +export interface ArrayInputBaseProps + extends Omit { + children: ReactNode; +} diff --git a/packages/ra-core/src/controller/input/SimpleFormIteratorBase.stories.tsx b/packages/ra-core/src/controller/input/SimpleFormIteratorBase.stories.tsx new file mode 100644 index 00000000000..6eb818d0bd4 --- /dev/null +++ b/packages/ra-core/src/controller/input/SimpleFormIteratorBase.stories.tsx @@ -0,0 +1,133 @@ +import * as React from 'react'; +import fakeRestDataProvider from 'ra-data-fakerest'; +import { useWrappedSource } from '../../core/useWrappedSource'; +import { useFieldValue } from '../../util/useFieldValue'; +import { TestMemoryRouter } from '../../routing/TestMemoryRouter'; +import { Admin, DataTable, SimpleForm, TextInput } from '../../test-ui'; +import { Resource } from '../../core/Resource'; +import { ListBase } from '../list/ListBase'; +import { EditBase } from '../edit/EditBase'; +import { ArrayInputBase } from './ArrayInputBase'; +import { useArrayInput } from './useArrayInput'; +import { SimpleFormIteratorItemBase } from './SimpleFormIteratorItemBase'; +import { useSimpleFormIteratorItem } from './useSimpleFormIteratorItem'; +import { useSimpleFormIterator } from './useSimpleFormIterator'; +import { + SimpleFormIteratorBase, + SimpleFormIteratorBaseProps, +} from './SimpleFormIteratorBase'; +import { useGetArrayInputNewItemDefaults } from './useGetArrayInputNewItemDefaults'; +import { useEvent } from '../../util'; + +export default { title: 'ra-core/controller/input/SimpleFormIteratorBase' }; + +const SimpleFormIterator = ({ + children, + ...props +}: SimpleFormIteratorBaseProps) => { + const { fields } = useArrayInput(props); + // Get the parent source by passing an empty string as source + const source = useWrappedSource(''); + const records = useFieldValue({ source }); + const getArrayInputNewItemDefaults = + useGetArrayInputNewItemDefaults(fields); + + const getItemDefaults = useEvent((item: any = undefined) => { + if (item != null) return item; + return getArrayInputNewItemDefaults(children); + }); + return ( + +
    + {fields.map((member, index) => ( + +
  • + {children} + +
  • +
    + ))} +
+ +
+ ); +}; + +const RemoveItemButton = () => { + const { remove } = useSimpleFormIteratorItem(); + return ( + + ); +}; + +const AddItemButton = () => { + const { add } = useSimpleFormIterator(); + return ( + + ); +}; + +export const Basic = () => ( + + + + + + + record.tags + ? record.tags + .map((tag: any) => tag.name) + .join(', ') + : '' + } + /> + + + } + edit={ + + + +
+
Tags:
+ + + + + + +
+
+
+ } + /> +
+
+); diff --git a/packages/ra-core/src/controller/input/SimpleFormIteratorBase.tsx b/packages/ra-core/src/controller/input/SimpleFormIteratorBase.tsx new file mode 100644 index 00000000000..265542594d9 --- /dev/null +++ b/packages/ra-core/src/controller/input/SimpleFormIteratorBase.tsx @@ -0,0 +1,95 @@ +import * as React from 'react'; +import { type ReactNode, useMemo } from 'react'; +import { type UseFieldArrayReturn, useFormContext } from 'react-hook-form'; +import { useWrappedSource } from '../../core/useWrappedSource'; +import type { RaRecord } from '../../types'; +import { useEvent } from '../../util'; +import { useArrayInput } from './useArrayInput'; +import { SimpleFormIteratorContext } from './SimpleFormIteratorContext'; + +const DefaultGetItemDefaults = item => item; + +export const SimpleFormIteratorBase = (props: SimpleFormIteratorBaseProps) => { + const { + children, + getItemDefaults: getItemDefaultsProp = DefaultGetItemDefaults, + } = props; + const getItemDefaults = useEvent(getItemDefaultsProp); + + const finalSource = useWrappedSource(''); + if (!finalSource) { + throw new Error( + 'SimpleFormIterator can only be called within an iterator input like ArrayInput' + ); + } + + const { append, fields, move, remove, replace } = useArrayInput(props); + const { trigger, getValues } = useFormContext(); + + const removeField = useEvent((index: number) => { + remove(index); + const isScalarArray = getValues(finalSource).every( + (value: any) => typeof value !== 'object' + ); + if (isScalarArray) { + // Trigger validation on the Array to avoid ghost errors. + // Otherwise, validation errors on removed fields might still be displayed + trigger(finalSource); + } + }); + + const addField = useEvent((item: any = undefined) => { + append(getItemDefaults(item)); + }); + + const handleReorder = useEvent((origin: number, destination: number) => { + move(origin, destination); + }); + + const handleArrayClear = useEvent(() => { + replace([]); + }); + + const context = useMemo( + () => ({ + total: fields.length, + add: addField, + clear: handleArrayClear, + remove: removeField, + reOrder: handleReorder, + source: finalSource, + }), + [ + addField, + fields.length, + handleArrayClear, + handleReorder, + removeField, + finalSource, + ] + ); + + if (!fields) { + return null; + } + return ( + + {children} + + ); +}; + +export interface SimpleFormIteratorBaseProps + extends Partial { + children: ReactNode; + inline?: boolean; + meta?: { + // the type defined in FieldArrayRenderProps says error is boolean, which is wrong. + error?: any; + submitFailed?: boolean; + }; + getItemDefaults?: (item: any) => any; + record?: RaRecord; + resource?: string; + source?: string; +} diff --git a/packages/ra-core/src/controller/input/SimpleFormIteratorContext.ts b/packages/ra-core/src/controller/input/SimpleFormIteratorContext.ts index 1688f1cb416..8a3c9c12f45 100644 --- a/packages/ra-core/src/controller/input/SimpleFormIteratorContext.ts +++ b/packages/ra-core/src/controller/input/SimpleFormIteratorContext.ts @@ -12,6 +12,7 @@ export const SimpleFormIteratorContext = createContext< export type SimpleFormIteratorContextValue = { add: (item?: any) => void; + clear: () => void; remove: (index: number) => void; reOrder: (index: number, newIndex: number) => void; source: string; diff --git a/packages/ra-core/src/controller/input/SimpleFormIteratorItemBase.tsx b/packages/ra-core/src/controller/input/SimpleFormIteratorItemBase.tsx new file mode 100644 index 00000000000..5eb408e1471 --- /dev/null +++ b/packages/ra-core/src/controller/input/SimpleFormIteratorItemBase.tsx @@ -0,0 +1,94 @@ +import * as React from 'react'; +import { type ReactNode, useMemo } from 'react'; +import { + SourceContextProvider, + useResourceContext, + useSourceContext, +} from '../../core'; +import type { RaRecord } from '../../types'; +import { useSimpleFormIterator } from './useSimpleFormIterator'; +import { + SimpleFormIteratorItemContext, + type SimpleFormIteratorItemContextValue, +} from './SimpleFormIteratorItemContext'; +import type { ArrayInputContextValue } from './ArrayInputContext'; + +export const SimpleFormIteratorItemBase = ( + props: SimpleFormIteratorItemBaseProps +) => { + const { children, index } = props; + const resource = useResourceContext(props); + if (!resource) { + throw new Error( + 'SimpleFormIteratorItem must be used in a ResourceContextProvider or be passed a resource prop.' + ); + } + const { total, reOrder, remove } = useSimpleFormIterator(); + + const context = useMemo( + () => ({ + index, + total, + reOrder: newIndex => reOrder(index, newIndex), + remove: () => remove(index), + }), + [index, total, reOrder, remove] + ); + + const parentSourceContext = useSourceContext(); + const sourceContext = useMemo( + () => ({ + getSource: (source: string) => { + if (!source) { + // source can be empty for scalar values, e.g. + // => SourceContext is "tags" + // => SourceContext is "tags.0" + // => use its parent's getSource so finalSource = "tags.0" + // + // + return parentSourceContext.getSource(`${index}`); + } else { + // Normal input with source, e.g. + // => SourceContext is "orders" + // => SourceContext is "orders.0" + // => use its parent's getSource so finalSource = "orders.0.date" + // + // + return parentSourceContext.getSource(`${index}.${source}`); + } + }, + getLabel: (source: string) => { + // => LabelContext is "orders" + // => LabelContext is ALSO "orders" + // => use its parent's getLabel so finalLabel = "orders.date" + // + // + // + // we don't prefix with the index to avoid that translation keys contain it + return parentSourceContext.getLabel(source); + }, + }), + [index, parentSourceContext] + ); + + return ( + + + {children} + + + ); +}; + +export type SimpleFormIteratorDisableRemoveFunction = ( + record: RaRecord +) => boolean; + +export type SimpleFormIteratorItemBaseProps = + Partial & { + children?: ReactNode; + index: number; + record?: RaRecord; + resource?: string; + source?: string; + }; diff --git a/packages/ra-core/src/controller/input/index.ts b/packages/ra-core/src/controller/input/index.ts index 5c8e31cc38d..ad94c71eafb 100644 --- a/packages/ra-core/src/controller/input/index.ts +++ b/packages/ra-core/src/controller/input/index.ts @@ -3,10 +3,14 @@ export * from './useReferenceArrayInputController'; export * from './useReferenceInputController'; export * from './ReferenceInputBase'; export * from './ReferenceArrayInputBase'; +export * from './ArrayInputBase'; export * from './ArrayInputContext'; +export * from './SimpleFormIteratorBase'; +export * from './SimpleFormIteratorItemBase'; export * from './useArrayInput'; export * from './sanitizeInputRestProps'; export * from './SimpleFormIteratorContext'; export * from './SimpleFormIteratorItemContext'; export * from './useSimpleFormIterator'; export * from './useSimpleFormIteratorItem'; +export * from './useGetArrayInputNewItemDefaults'; diff --git a/packages/ra-core/src/controller/input/useGetArrayInputNewItemDefaults.ts b/packages/ra-core/src/controller/input/useGetArrayInputNewItemDefaults.ts new file mode 100644 index 00000000000..adf9fbc3e30 --- /dev/null +++ b/packages/ra-core/src/controller/input/useGetArrayInputNewItemDefaults.ts @@ -0,0 +1,47 @@ +import { Children, isValidElement, useRef, type ReactNode } from 'react'; +import { FormDataConsumer } from '../../form/FormDataConsumer'; +import type { ArrayInputContextValue } from './ArrayInputContext'; +import { useEvent } from '../../util'; + +export const useGetArrayInputNewItemDefaults = ( + fields: ArrayInputContextValue['fields'] +) => { + const initialDefaultValue = useRef>({}); + if (fields.length > 0) { + const { id, ...rest } = fields[0]; + initialDefaultValue.current = rest; + for (const k in initialDefaultValue.current) + initialDefaultValue.current[k] = null; + } + + return useEvent((inputs?: ReactNode) => { + if ( + Children.count(inputs) === 1 && + isValidElement(Children.only(inputs)) && + // @ts-ignore + !Children.only(inputs).props.source && + // Make sure it's not a FormDataConsumer + // @ts-ignore + Children.only(inputs).type !== FormDataConsumer + ) { + // ArrayInput used for an array of scalar values + // (e.g. tags: ['foo', 'bar']) + return ''; + } + + // ArrayInput used for an array of objects + // (e.g. authors: [{ firstName: 'John', lastName: 'Doe' }, { firstName: 'Jane', lastName: 'Doe' }]) + const defaultValue = initialDefaultValue.current; + Children.forEach(inputs, input => { + if ( + isValidElement(input) && + input.type !== FormDataConsumer && + input.props.source + ) { + defaultValue[input.props.source] = + input.props.defaultValue ?? null; + } + }); + return defaultValue; + }); +}; diff --git a/packages/ra-core/src/controller/list/WithListContext.tsx b/packages/ra-core/src/controller/list/WithListContext.tsx index f6172d2718a..8fe7378e951 100644 --- a/packages/ra-core/src/controller/list/WithListContext.tsx +++ b/packages/ra-core/src/controller/list/WithListContext.tsx @@ -1,4 +1,4 @@ -import React, { ReactElement } from 'react'; +import React, { ReactNode } from 'react'; import { RaRecord } from '../../types'; import { ListControllerResult } from './useListController'; import { useListContextWithProps } from './useListContextWithProps'; @@ -79,9 +79,7 @@ export interface WithListContextProps > > > { - render?: ( - context: Partial> - ) => ReactElement | false | null; + render?: (context: Partial>) => ReactNode; loading?: React.ReactNode; offline?: React.ReactNode; errorState?: ListControllerResult['error']; diff --git a/packages/ra-core/src/index.ts b/packages/ra-core/src/index.ts index 9f11dcefc87..bd415c3c80f 100644 --- a/packages/ra-core/src/index.ts +++ b/packages/ra-core/src/index.ts @@ -13,3 +13,4 @@ export * from './routing'; export * from './store'; export * from './types'; export * from './util'; +export * as testUI from './test-ui'; diff --git a/packages/ra-core/src/test-ui/Admin.tsx b/packages/ra-core/src/test-ui/Admin.tsx new file mode 100644 index 00000000000..4711f669513 --- /dev/null +++ b/packages/ra-core/src/test-ui/Admin.tsx @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { CoreAdmin, CoreAdminProps } from '../core'; + +import { Layout } from './Layout'; +import { defaultI18nProvider } from './defaultI18nProvider'; + +export const Admin = (props: CoreAdminProps) => { + const { layout = Layout } = props; + return ( + + ); +}; diff --git a/packages/ra-core/src/test-ui/ArrayInput.tsx b/packages/ra-core/src/test-ui/ArrayInput.tsx new file mode 100644 index 00000000000..e117f5f4f32 --- /dev/null +++ b/packages/ra-core/src/test-ui/ArrayInput.tsx @@ -0,0 +1,38 @@ +import * as React from 'react'; +import { isRequired } from '../form/validation/validate'; +import { InputProps } from '../form/useInput'; +import { FieldTitle } from '../util/FieldTitle'; +import { ArrayInputBase } from '../controller/input/ArrayInputBase'; + +export const ArrayInput = (props: ArrayInputProps) => { + const { + label, + children, + resource: resourceFromProps, + source: arraySource, + validate, + } = props; + + return ( +
+
+ +
+ {children} +
+ ); +}; + +export interface ArrayInputProps + extends Omit { + className?: string; + children: React.ReactNode; + isFetching?: boolean; + isLoading?: boolean; + isPending?: boolean; +} diff --git a/packages/ra-core/src/test-ui/AutocompleteArrayInput.tsx b/packages/ra-core/src/test-ui/AutocompleteArrayInput.tsx new file mode 100644 index 00000000000..de73ec8f71c --- /dev/null +++ b/packages/ra-core/src/test-ui/AutocompleteArrayInput.tsx @@ -0,0 +1,70 @@ +import * as React from 'react'; +import { + FieldTitle, + InputProps, + isRequired, + useChoicesContext, + useInput, +} from '../'; + +export const AutocompleteArrayInput = (props: Partial) => { + const { allChoices, source, setFilters, filterValues } = + useChoicesContext(); + + const { field, fieldState } = useInput({ source, ...props }); + + return ( +
+
+ +
+ + setFilters({ ...filterValues, q: e.target.value }) + } + /> + +
    + {allChoices?.map(choice => ( +
  • + +
  • + ))} +
+ {fieldState.error ? ( +

+ {fieldState.error.message} +

+ ) : null} +
+ ); +}; diff --git a/packages/ra-core/src/test-ui/Confirm.tsx b/packages/ra-core/src/test-ui/Confirm.tsx new file mode 100644 index 00000000000..66fba23a4f0 --- /dev/null +++ b/packages/ra-core/src/test-ui/Confirm.tsx @@ -0,0 +1,83 @@ +import { Translate } from '../'; +import * as React from 'react'; + +export const Confirm = ({ + isOpen, + content, + onClose, + onConfirm, + title, + translateOptions = {}, + titleTranslateOptions = translateOptions, + contentTranslateOptions = translateOptions, +}: { + isOpen: boolean; + title: string; + content: string; + onConfirm: () => void; + onClose: () => void; + translateOptions?: Record; + titleTranslateOptions?: Record; + contentTranslateOptions?: Record; +}) => { + return isOpen ? ( +
+
+

+ {typeof title === 'string' ? ( + + ) : ( + title + )} +

+

+ {typeof content === 'string' ? ( + + ) : ( + content + )} +

+
+ + +
+
+
+ ) : null; +}; diff --git a/packages/ra-core/src/test-ui/DataTable.tsx b/packages/ra-core/src/test-ui/DataTable.tsx new file mode 100644 index 00000000000..0153134b421 --- /dev/null +++ b/packages/ra-core/src/test-ui/DataTable.tsx @@ -0,0 +1,174 @@ +import * as React from 'react'; +import { + DataTableBase, + DataTableBaseProps, + DataTableRenderContext, + RaRecord, + RecordContextProvider, + useDataTableCallbacksContext, + useDataTableRenderContext, + useEvent, + useFieldValue, + useGetPathForRecordCallback, + useListContext, + useRecordContext, + useResourceContext, +} from '../'; +import { useNavigate } from 'react-router'; + +const DataTableCol = (props: { + children?: React.ReactNode; + render?: (record: RaRecord) => React.ReactNode; + field?: React.ElementType; + source?: string; + label?: React.ReactNode; +}) => { + const renderContext = useDataTableRenderContext(); + switch (renderContext) { + case 'header': + return ; + case 'data': + return ; + } +}; + +const DataTableHeadCell = (props: { + label?: React.ReactNode; + source?: string; +}) => { + return ( + + {props.label ?? ( + <> + {props.source?.substring(0, 1).toUpperCase()} + {props.source?.substring(1)} + + )} + + ); +}; + +const DataTableCell = (props: { + children?: React.ReactNode; + render?: (record: RaRecord | undefined) => React.ReactNode; + field?: React.ElementType; + source?: string; +}) => { + const record = useRecordContext(); + if (props.render) { + return {props.render(record)}; + } + if (props.children) { + return {props.children}; + } + if (props.field) { + return ( + + {React.createElement(props.field, { source: props.source })} + + ); + } + if (props.source) { + return ( + + + + ); + } +}; + +const DataTableCellValue = (props: { source: string }) => { + const value = useFieldValue(props); + return <>{value}; +}; + +const DataTableRow = (props: { + children: React.ReactNode; + record?: RaRecord; + resource?: string; +}) => { + const getPathForRecord = useGetPathForRecordCallback(); + const navigate = useNavigate(); + const record = useRecordContext(props); + if (!record) { + throw new Error( + 'DataTableRow can only be used within a RecordContext or be passed a record prop' + ); + } + const resource = useResourceContext(props); + if (!resource) { + throw new Error( + 'DataTableRow can only be used within a ResourceContext or be passed a resource prop' + ); + } + const { rowClick } = useDataTableCallbacksContext(); + + const handleClick = useEvent(async (event: React.MouseEvent) => { + event.persist(); + const temporaryLink = + typeof rowClick === 'function' + ? rowClick(record.id, resource, record) + : rowClick; + + const link = isPromise(temporaryLink) + ? await temporaryLink + : temporaryLink; + + const path = await getPathForRecord({ + record, + resource, + link, + }); + if (path === false || path == null) { + return; + } + navigate(path, { + state: { _scrollToTop: true }, + }); + }); + + return {props.children}; +}; + +const isPromise = (value: any): value is Promise => + value && typeof value.then === 'function'; + +export const DataTable = ( + props: Omit +) => { + const { data } = useListContext(); + + return ( + + + + + {props.children} + + + + + {data?.map(record => ( + + {props.children} + + ))} + + +
+
+ ); +}; + +DataTable.Col = DataTableCol; diff --git a/packages/ra-core/src/test-ui/DeleteButton.tsx b/packages/ra-core/src/test-ui/DeleteButton.tsx new file mode 100644 index 00000000000..795d77999d9 --- /dev/null +++ b/packages/ra-core/src/test-ui/DeleteButton.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; +import { + Translate, + useDeleteController, + UseDeleteControllerParams, + useRecordContext, +} from '../'; + +export const DeleteButton = ( + props: UseDeleteControllerParams & { label: React.ReactNode } +) => { + const record = useRecordContext(); + const controllerProps = useDeleteController({ + record, + mutationMode: 'optimistic', + ...props, + }); + + return ( + + ); +}; diff --git a/packages/ra-core/src/test-ui/Layout.tsx b/packages/ra-core/src/test-ui/Layout.tsx new file mode 100644 index 00000000000..c8d191c83a7 --- /dev/null +++ b/packages/ra-core/src/test-ui/Layout.tsx @@ -0,0 +1,67 @@ +import { useRefresh, useResourceDefinitions, useTranslate } from '../'; +import * as React from 'react'; +import { Link } from 'react-router-dom'; +import { Notification } from './Notification'; + +export const Layout = ({ children }: { children: React.ReactNode }) => { + const resources = useResourceDefinitions(); + const translate = useTranslate(); + const refresh = useRefresh(); + return ( +
+
+ +
+
+
{children}
+ +
+ ); +}; diff --git a/packages/ra-core/src/test-ui/Notification.tsx b/packages/ra-core/src/test-ui/Notification.tsx new file mode 100644 index 00000000000..982b2f13a85 --- /dev/null +++ b/packages/ra-core/src/test-ui/Notification.tsx @@ -0,0 +1,142 @@ +import * as React from 'react'; +import { useState, useEffect, useCallback } from 'react'; + +import { + CloseNotificationContext, + type NotificationPayload, + undoableEventEmitter, + useNotificationContext, + useTakeUndoableMutation, + useTranslate, +} from '../'; + +/** + * Provides a way to show a notification. + * @see useNotify + * + * @example Basic usage + * + * + * @param props The component props + * @param {string} props.type The notification type. Defaults to 'info'. + * @param {number} props.autoHideDuration Duration in milliseconds to wait until hiding a given notification. Defaults to 4000. + * @param {boolean} props.multiLine Set it to `true` if the notification message should be shown in more than one line. + */ +export const Notification = () => { + const { notifications, takeNotification } = useNotificationContext(); + const takeMutation = useTakeUndoableMutation(); + const [open, setOpen] = useState(false); + const [currentNotification, setCurrentNotification] = React.useState< + NotificationPayload | undefined + >(undefined); + const translate = useTranslate(); + + useEffect(() => { + if (notifications.length && !currentNotification) { + // Set a new snack when we don't have an active one + const notification = takeNotification(); + if (notification) { + setCurrentNotification(notification); + setOpen(true); + } + } + + if (currentNotification) { + const beforeunload = (e: BeforeUnloadEvent) => { + e.preventDefault(); + const confirmationMessage = ''; + e.returnValue = confirmationMessage; + return confirmationMessage; + }; + + if (currentNotification?.notificationOptions?.undoable) { + window.addEventListener('beforeunload', beforeunload); + return () => { + window.removeEventListener('beforeunload', beforeunload); + }; + } + } + }, [notifications, currentNotification, open, takeNotification]); + + const handleRequestClose = useCallback(() => { + setOpen(false); + }, [setOpen]); + + const handleExited = useCallback(() => { + if ( + currentNotification && + currentNotification.notificationOptions?.undoable + ) { + const mutation = takeMutation(); + if (mutation) { + mutation({ isUndo: false }); + } else { + // FIXME kept for BC: remove in v6 + undoableEventEmitter.emit('end', { isUndo: false }); + } + } + setCurrentNotification(undefined); + }, [currentNotification, takeMutation]); + + const handleUndo = useCallback(() => { + const mutation = takeMutation(); + if (mutation) { + mutation({ isUndo: true }); + } else { + // FIXME kept for BC: remove in v6 + undoableEventEmitter.emit('end', { isUndo: true }); + } + setOpen(false); + }, [takeMutation]); + + const { message, notificationOptions } = currentNotification || {}; + const { messageArgs, undoable } = notificationOptions || {}; + + useEffect(() => { + if (!undoable) return; + const timer = setTimeout(() => { + handleExited(); + }, notificationOptions?.autoHideDuration || 4000); + return () => clearTimeout(timer); + }, [undoable, handleExited, notificationOptions]); + + if (!currentNotification) return null; + return ( + +
+

+ {message && typeof message === 'string' + ? translate(message, messageArgs) + : message} +

+
+ {undoable ? ( + + ) : null} + +
+
+
+ ); +}; diff --git a/packages/ra-core/src/test-ui/Pagination.tsx b/packages/ra-core/src/test-ui/Pagination.tsx new file mode 100644 index 00000000000..6e55b698aa0 --- /dev/null +++ b/packages/ra-core/src/test-ui/Pagination.tsx @@ -0,0 +1,66 @@ +import * as React from 'react'; +import { Translate, useListContext, useTranslate } from '../'; + +export const Pagination = () => { + const { page, perPage, total, setPage } = useListContext(); + const translate = useTranslate(); + + if (total === undefined) { + return null; + } + const nbPages = Math.ceil(total / perPage) || 1; + return ( +
+
+ + {`${(page - 1) * perPage + 1}-${Math.min(page * perPage, total)} of ${total}`} + +
+
+ {page > 1 && ( + + )} + {Array.from({ length: nbPages }, (_, i) => i + 1).map(p => ( + + ))} + {page < nbPages && ( + + )} +
+
+ ); +}; diff --git a/packages/ra-core/src/test-ui/SimpleForm.tsx b/packages/ra-core/src/test-ui/SimpleForm.tsx new file mode 100644 index 00000000000..c8d96dc8c29 --- /dev/null +++ b/packages/ra-core/src/test-ui/SimpleForm.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; +import { Form, FormProps, Translate } from '../'; +import { useFormContext } from 'react-hook-form'; + +export const SimpleForm = ({ children, ...props }: FormProps) => ( +
+
+ {React.Children.map(children, child => ( +
{child}
+ ))} +
+
+ +
+
+); + +const SaveButton = () => { + const { formState } = useFormContext(); + const { isSubmitting } = formState; + return ( + + ); +}; diff --git a/packages/ra-core/src/test-ui/SimpleFormIterator.tsx b/packages/ra-core/src/test-ui/SimpleFormIterator.tsx new file mode 100644 index 00000000000..f38e4900cba --- /dev/null +++ b/packages/ra-core/src/test-ui/SimpleFormIterator.tsx @@ -0,0 +1,327 @@ +import * as React from 'react'; +import { + type ReactElement, + type ReactNode, + useCallback, + useState, +} from 'react'; +import { + type RaRecord, + useArrayInput, + useTranslate, + useWrappedSource, + Translate, + useSimpleFormIterator, + ArrayInputContextValue, + useSimpleFormIteratorItem, + SimpleFormIteratorBase, + SimpleFormIteratorItemBase, + useFieldValue, +} from '../'; +import { type UseFieldArrayReturn } from 'react-hook-form'; + +import { Confirm } from './Confirm'; + +const DefaultAddItemButton = ( + props: React.DetailedHTMLProps< + React.ButtonHTMLAttributes, + HTMLButtonElement + > +) => { + const { add, source } = useSimpleFormIterator(); + const { className, ...rest } = props; + return ( + + ); +}; + +const DefaultRemoveItemButton = ( + props: Omit< + React.DetailedHTMLProps< + React.ButtonHTMLAttributes, + HTMLButtonElement + >, + 'onClick' + > +) => { + const { remove, index } = useSimpleFormIteratorItem(); + const { source } = useSimpleFormIterator(); + const { className, ...rest } = props; + + return ( + + ); +}; + +const DefaultReOrderButtons = ({ className }: { className?: string }) => { + const { index, total, reOrder } = useSimpleFormIteratorItem(); + const { source } = useSimpleFormIterator(); + + return ( + + + + + ); +}; + +export type DisableRemoveFunction = (record: RaRecord) => boolean; + +export const SimpleFormIteratorItem = React.forwardRef< + any, + Partial & { + children?: ReactNode; + disabled?: boolean; + disableRemove?: boolean | DisableRemoveFunction; + disableReordering?: boolean; + getItemLabel?: boolean | GetItemLabelFunc; + index: number; + inline?: boolean; + record: RaRecord; + removeButton?: ReactElement; + reOrderButtons?: ReactElement; + resource?: string; + source?: string; + } +>(function SimpleFormIteratorItem(props, ref) { + const { + children, + disabled, + disableReordering, + disableRemove, + getItemLabel, + index, + inline, + record, + removeButton = , + reOrderButtons = , + } = props; + // Returns a boolean to indicate whether to disable the remove button for certain fields. + // If disableRemove is a function, then call the function with the current record to + // determining if the button should be disabled. Otherwise, use a boolean property that + // enables or disables the button for all of the fields. + const disableRemoveField = (record: RaRecord) => { + if (typeof disableRemove === 'boolean') { + return disableRemove; + } + return disableRemove && disableRemove(record); + }; + + const label = + typeof getItemLabel === 'function' ? getItemLabel(index) : getItemLabel; + + return ( + +
  • +
    + {label != null && label !== false && {label}} +
    + {children} +
    + {!disabled && ( + + {!disableReordering && reOrderButtons} + + {!disableRemoveField(record) && removeButton} + + )} +
    +
    +
  • +
    + ); +}); + +export const SimpleFormIterator = (props: SimpleFormIteratorProps) => { + const { + addButton = , + removeButton, + reOrderButtons, + children, + className, + resource, + disabled, + disableAdd = false, + disableClear, + disableRemove = false, + disableReordering, + inline, + getItemLabel = false, + fullWidth, + } = props; + + const finalSource = useWrappedSource(''); + if (!finalSource) { + throw new Error( + 'SimpleFormIterator can only be called within an iterator input like ArrayInput' + ); + } + + const [confirmIsOpen, setConfirmIsOpen] = useState(false); + const { fields, replace } = useArrayInput(props); + const translate = useTranslate(); + + const handleArrayClear = useCallback(() => { + replace([]); + setConfirmIsOpen(false); + }, [replace]); + + const records = useFieldValue({ source: finalSource }); + + return fields ? ( + +
    +
      + {fields.map((member, index) => ( + + + {children} + + + ))} +
    + {!disabled && + !(disableAdd && (disableClear || disableRemove)) && ( +
    + {!disableAdd &&
    {addButton}
    } + {fields.length > 0 && + !disableClear && + !disableRemove && ( +
    + + setConfirmIsOpen(false) + } + /> + +
    + )} +
    + )} +
    +
    + ) : null; +}; + +type GetItemLabelFunc = (index: number) => string | ReactElement; + +export interface SimpleFormIteratorProps extends Partial { + addButton?: ReactElement; + children?: ReactNode; + className?: string; + readOnly?: boolean; + disabled?: boolean; + disableAdd?: boolean; + disableClear?: boolean; + disableRemove?: boolean | DisableRemoveFunction; + disableReordering?: boolean; + fullWidth?: boolean; + getItemLabel?: boolean | GetItemLabelFunc; + inline?: boolean; + meta?: { + // the type defined in FieldArrayRenderProps says error is boolean, which is wrong. + error?: any; + submitFailed?: boolean; + }; + record?: RaRecord; + removeButton?: ReactElement; + reOrderButtons?: ReactElement; + resource?: string; + source?: string; +} diff --git a/packages/ra-core/src/test-ui/SimpleList.tsx b/packages/ra-core/src/test-ui/SimpleList.tsx new file mode 100644 index 00000000000..617b859afc1 --- /dev/null +++ b/packages/ra-core/src/test-ui/SimpleList.tsx @@ -0,0 +1,38 @@ +import * as React from 'react'; +import { RecordContextProvider, WithListContext } from '../'; + +export const SimpleList = ({ + children, + render, + inline = false, +}: { + children?: React.ReactNode; + render?: (record: any) => React.ReactNode; + inline?: boolean; +}) => ( + + isPending ? null : children ? ( + children + ) : ( +
      + {data?.map(record => ( + +
    • {render ? render(record) : children}
    • +
      + ))} +
    + ) + } + /> +); diff --git a/packages/ra-core/src/test-ui/SimpleShowLayout.tsx b/packages/ra-core/src/test-ui/SimpleShowLayout.tsx new file mode 100644 index 00000000000..7a89f9743ec --- /dev/null +++ b/packages/ra-core/src/test-ui/SimpleShowLayout.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; + +export const SimpleShowLayout = ({ + children, +}: { + children: React.ReactNode; +}) => ( +
    + {React.Children.map(children, child => ( +
    {child}
    + ))} +
    +); diff --git a/packages/ra-core/src/test-ui/TextInput.tsx b/packages/ra-core/src/test-ui/TextInput.tsx new file mode 100644 index 00000000000..3d6b5434c4e --- /dev/null +++ b/packages/ra-core/src/test-ui/TextInput.tsx @@ -0,0 +1,50 @@ +import * as React from 'react'; +import { FieldTitle, InputProps, isRequired, useInput } from '../'; + +export const TextInput = ({ + multiline, + type = 'text', + ...props +}: InputProps & { + type?: React.HTMLInputTypeAttribute; + multiline?: boolean; +}) => { + const { id, field, fieldState } = useInput(props); + + return ( +
    + +
    + {multiline ? ( +