diff --git a/docs/Inputs.md b/docs/Inputs.md index d83e2bf2c91..15848b3c7a6 100644 --- a/docs/Inputs.md +++ b/docs/Inputs.md @@ -534,12 +534,7 @@ const OrderEdit = () => ( ); ``` -**Tip**: When using a `FormDataConsumer` inside an `ArrayInput`, the `FormDataConsumer` will provide two additional properties to its children function: - -- `scopedFormData`: an object containing the current values of the currently rendered item from the `ArrayInput` -- `getSource`: a function that translates the source into a valid one for the `ArrayInput` - -And here is an example usage for `getSource` inside ``: +**Tip**: When used inside an `ArrayInput`, `` provides one additional property to its child function called `scopedFormData`. It's an object containing the current values of the *currently rendered item*. This allows you to create dependencies between inputs inside a ``, as in the following example: ```tsx import { FormDataConsumer } from 'react-admin'; @@ -554,12 +549,11 @@ const PostEdit = () => ( {({ formData, // The whole form data scopedFormData, // The data for this item of the ArrayInput - getSource, // A function to get the valid source inside an ArrayInput ...rest }) => - scopedFormData && getSource && scopedFormData.name ? ( + scopedFormData && scopedFormData.name ? ( @@ -573,7 +567,7 @@ const PostEdit = () => ( ); ``` -**Tip:** TypeScript users will notice that `scopedFormData` and `getSource` are typed as optional parameters. This is because the `` component can be used outside of an `` and in that case, these parameters will be `undefined`. If you are inside an ``, you can safely assume that these parameters will be defined. +**Tip:** TypeScript users will notice that `scopedFormData` is typed as an optional parameter. This is because the `` component can be used outside of an `` and in that case, this parameter will be `undefined`. If you are inside an ``, you can safely assume that this parameter will be defined. ## Hiding Inputs Based On Other Inputs diff --git a/docs/SimpleFormIterator.md b/docs/SimpleFormIterator.md index 8fa004b39e1..df12e28a5fe 100644 --- a/docs/SimpleFormIterator.md +++ b/docs/SimpleFormIterator.md @@ -114,12 +114,7 @@ A list of Input elements, that will be rendered on each row. By default, `` renders one input per line, but they can be displayed inline with the `inline` prop. -`` also accepts `` as child. When used inside a form iterator, `` provides two additional properties to its children function: - -- `scopedFormData`: an object containing the current values of the currently rendered item from the ArrayInput -- `getSource`: a function that translates the source into a valid one for the ArrayInput - -And here is an example usage for `getSource` inside ``: +`` also accepts `` as child. In this case, `` provides one additional property to its child function called `scopedFormData`. It's an object containing the current values of the *currently rendered item*. This allows you to create dependencies between inputs inside a ``, as in the following example: ```jsx import { FormDataConsumer } from 'react-admin'; @@ -134,14 +129,11 @@ const PostEdit = () => ( {({ formData, // The whole form data scopedFormData, // The data for this item of the ArrayInput - getSource, // A function to get the valid source inside an ArrayInput - ...rest }) => scopedFormData && scopedFormData.name ? ( ) : null } @@ -153,7 +145,7 @@ const PostEdit = () => ( ); ``` -**Tip:** TypeScript users will notice that `scopedFormData` and `getSource` are typed as optional parameters. This is because the `` component can be used outside of a `` and in that case, these parameters will be `undefined`. If you are inside a ``, you can safely assume that these parameters will be defined. +**Tip:** TypeScript users will notice that `scopedFormData` is typed as an optional parameter. This is because the `` component can be used outside of an `` and in that case, this parameter will be `undefined`. If you are inside an ``, you can safely assume that this parameter will be defined. **Note**: `` only accepts `Input` components as children. If you want to use some `Fields` instead, you have to use a ``, as follows: diff --git a/docs/Upgrade.md b/docs/Upgrade.md index 19d5258d998..d1470ddcb36 100644 --- a/docs/Upgrade.md +++ b/docs/Upgrade.md @@ -204,6 +204,56 @@ const CompanyField = () => ( ``` {% endraw %} +## `` no longer clones its children + +We've changed the implementation of ``, the companion child of ``. This internal change is mostly backwards compatible, with one exception: defining the `disabled` prop on the `` component does not disable the children inputs anymore. If you relied on this behavior, you now have to specify the `disabled` prop on each input: + +```diff + + +- +- ++ ++ + + +``` + +## `` no longer passes a `getSource` function + +When using `` inside an ``, the child function no longer receives a `getSource` callback. We've made all Input components able to work seamlessly inside an ``, so it's no longer necessary to transform their source with `getSource`: + +```diff +import { Edit, SimpleForm, TextInput, ArrayInput, SelectInput, FormDataConsumer } from 'react-admin'; + +const PostEdit = () => ( + + + + + + + {({ + formData, // The whole form data + scopedFormData, // The data for this item of the ArrayInput +- getSource, + }) => + scopedFormData && getSource && scopedFormData.name ? ( + + ) : null + } + + + + + +); +``` + ## Upgrading to v4 If you are on react-admin v3, follow the [Upgrading to v4](https://marmelab.com/react-admin/doc/4.16/Upgrade.html) guide before upgrading to v5. diff --git a/examples/simple/src/posts/PostCreate.tsx b/examples/simple/src/posts/PostCreate.tsx index f8acc6ee04e..80e4c62f4f0 100644 --- a/examples/simple/src/posts/PostCreate.tsx +++ b/examples/simple/src/posts/PostCreate.tsx @@ -163,10 +163,10 @@ const PostCreate = () => { /> - {({ scopedFormData, getSource, ...rest }) => + {({ scopedFormData }) => scopedFormData && scopedFormData.user_id ? ( { name: 'Co-Writer', }, ]} - {...rest} label="Role" /> ) : null diff --git a/examples/simple/src/posts/PostEdit.tsx b/examples/simple/src/posts/PostEdit.tsx index 68f742fc0dd..73bdfe0c7ed 100644 --- a/examples/simple/src/posts/PostEdit.tsx +++ b/examples/simple/src/posts/PostEdit.tsx @@ -160,11 +160,11 @@ const PostEdit = () => { - {({ scopedFormData, getSource, ...rest }) => + {({ scopedFormData }) => scopedFormData && scopedFormData.user_id ? ( { }, ]} helperText={false} - {...rest} /> ) : null } diff --git a/packages/ra-core/src/core/SourceContext.tsx b/packages/ra-core/src/core/SourceContext.tsx new file mode 100644 index 00000000000..231d322f6cb --- /dev/null +++ b/packages/ra-core/src/core/SourceContext.tsx @@ -0,0 +1,35 @@ +import { createContext, useContext } from 'react'; + +export type SourceContextValue = { + /* + * Returns the source for a field or input, modified according to the context. + */ + getSource: (source: string) => string; + /* + * Returns the label for a field or input, modified according to the context. Returns a translation key. + */ + getLabel: (source: string) => string; +}; + +/** + * Context that provides a function that accept a source and return a modified source (prefixed, suffixed, etc.) for fields and inputs. + * + * @example + * const sourceContext = { + * getSource: source => `coordinates.${source}`, + * getLabel: source => `resources.posts.fields.${source}`, + * } + * const CoordinatesInput = () => { + * return ( + * + * + * + * + * ); + * }; + */ +export const SourceContext = createContext(null); + +export const SourceContextProvider = SourceContext.Provider; + +export const useSourceContext = () => useContext(SourceContext); diff --git a/packages/ra-core/src/core/index.ts b/packages/ra-core/src/core/index.ts index ac7a29add76..4409feaf86f 100644 --- a/packages/ra-core/src/core/index.ts +++ b/packages/ra-core/src/core/index.ts @@ -7,9 +7,11 @@ export * from './Resource'; export * from './ResourceContext'; export * from './ResourceContextProvider'; export * from './ResourceDefinitionContext'; +export * from './SourceContext'; export * from './useGetResourceLabel'; export * from './useResourceDefinitionContext'; export * from './useResourceContext'; export * from './useResourceDefinition'; export * from './useResourceDefinitions'; export * from './useGetRecordRepresentation'; +export * from './useWrappedSource'; diff --git a/packages/ra-core/src/core/useWrappedSource.ts b/packages/ra-core/src/core/useWrappedSource.ts new file mode 100644 index 00000000000..33c3e5fe5cb --- /dev/null +++ b/packages/ra-core/src/core/useWrappedSource.ts @@ -0,0 +1,16 @@ +import { useSourceContext } from './SourceContext'; + +/** + * Get the source prop for a field or input by checking if a source context is available. + * @param {string} source The original source prop + * @returns {string} The source prop, either the original one or the one modified by the SourceContext. + * @example + * const MyInput = ({ source, ...props }) => { + * const finalSource = useWrappedSource(source); + * return ; + * }; + */ +export const useWrappedSource = (source: string) => { + const sourceContext = useSourceContext(); + return sourceContext?.getSource(source) ?? source; +}; diff --git a/packages/ra-core/src/form/Form.tsx b/packages/ra-core/src/form/Form.tsx index ebd01f28910..5d440bf6d4a 100644 --- a/packages/ra-core/src/form/Form.tsx +++ b/packages/ra-core/src/form/Form.tsx @@ -14,8 +14,7 @@ import { OptionalRecordContextProvider, SaveHandler, } from '../controller'; -import { useResourceContext } from '../core'; -import { LabelPrefixContextProvider } from '../util'; +import { SourceContextProvider, SourceContextValue, useResourceContext } from '../core'; import { ValidateForm } from './getSimpleValidationResolver'; import { useAugmentedForm } from './useAugmentedForm'; @@ -53,10 +52,14 @@ export const Form = (props: FormProps) => { const record = useRecordContext(props); const resource = useResourceContext(props); const { form, formHandleSubmit } = useAugmentedForm(props); + const sourceContext = React.useMemo(() => ({ + getSource: (source: string) => source, + getLabel: (source: string) => `resources.${resource}.fields.${source}`, + }), [resource]); return ( - +
(props: FormProps) => {
-
+
); }; diff --git a/packages/ra-core/src/form/FormDataConsumer.spec.tsx b/packages/ra-core/src/form/FormDataConsumer.spec.tsx index 5f3fccd8e0d..4d03a3a4d3e 100644 --- a/packages/ra-core/src/form/FormDataConsumer.spec.tsx +++ b/packages/ra-core/src/form/FormDataConsumer.spec.tsx @@ -14,7 +14,7 @@ import { import expect from 'expect'; describe('FormDataConsumerView', () => { - it('does not call its children function with scopedFormData and getSource if it did not receive an index prop', () => { + it('does not call its children function with scopedFormData if it did not receive a source containing an index', () => { const children = jest.fn(); const formData = { id: 123, title: 'A title' }; @@ -30,35 +30,9 @@ describe('FormDataConsumerView', () => { expect(children).toHaveBeenCalledWith({ formData, - getSource: expect.anything(), }); }); - it('calls its children function with scopedFormData and getSource if it received an index prop', () => { - const children = jest.fn(({ getSource }) => { - getSource('id'); - return null; - }); - const formData = { id: 123, title: 'A title', authors: [{ id: 0 }] }; - - render( - - {children} - - ); - - expect(children.mock.calls[0][0].formData).toEqual(formData); - expect(children.mock.calls[0][0].scopedFormData).toEqual({ id: 0 }); - expect(children.mock.calls[0][0].getSource('id')).toEqual( - 'authors[0].id' - ); - }); - it('calls its children with updated formData on first render', async () => { let globalFormData; render( @@ -66,10 +40,10 @@ describe('FormDataConsumerView', () => { - {({ formData, getSource, ...rest }) => { + {({ formData }) => { globalFormData = formData; - return ; + return ; }} @@ -87,10 +61,8 @@ describe('FormDataConsumerView', () => { - {({ formData, ...rest }) => - !formData.hi ? ( - - ) : null + {({ formData }) => + !formData.hi ? : null } @@ -121,19 +93,11 @@ describe('FormDataConsumerView', () => { - {({ - formData, - scopedFormData, - getSource, - ...rest - }) => { + {({ scopedFormData }) => { globalScopedFormData = scopedFormData; return scopedFormData && scopedFormData.name ? ( - + ) : null; }} diff --git a/packages/ra-core/src/form/FormDataConsumer.tsx b/packages/ra-core/src/form/FormDataConsumer.tsx index aa79132d3f7..f478e41a116 100644 --- a/packages/ra-core/src/form/FormDataConsumer.tsx +++ b/packages/ra-core/src/form/FormDataConsumer.tsx @@ -3,6 +3,7 @@ import { ReactNode } from 'react'; import { useFormContext, FieldValues } from 'react-hook-form'; import get from 'lodash/get'; import { useFormValues } from './useFormValues'; +import { useWrappedSource } from '../core'; /** * Get the current (edited) value of the record from the form and pass it @@ -15,8 +16,8 @@ import { useFormValues } from './useFormValues'; * > * * - * {({ formData, ...rest }) => formData.hasEmail && - * + * {({ formData }) => formData.hasEmail && + * * } * * @@ -30,11 +31,10 @@ import { useFormValues } from './useFormValues'; * * * > - * {({ formData, ...rest }) => + * {({ formData }) => * * } *
@@ -64,20 +64,19 @@ export const FormDataConsumerView = < >( props: Props ) => { - const { children, form, formData, source, index, ...rest } = props; + const { children, formData, source } = props; let ret; + const finalSource = useWrappedSource(source); + // Passes an empty string here as we don't have the children sources and we just want to know if we are in an iterator + const matches = ArraySourceRegex.exec(finalSource); + // If we have an index, we are in an iterator like component (such as the SimpleFormIterator) - if (typeof index !== 'undefined' && source) { - const scopedFormData = get(formData, source); - const getSource = (scopedSource: string) => `${source}.${scopedSource}`; - ret = children({ formData, scopedFormData, getSource, ...rest }); + if (matches) { + const scopedFormData = get(formData, matches[0]); + ret = children({ formData, scopedFormData }); } else { - ret = children({ - formData, - getSource: (scopedSource: string) => scopedSource, - ...rest, - }); + ret = children({ formData }); } return ret === undefined ? null : ret; @@ -85,13 +84,14 @@ export const FormDataConsumerView = < export default FormDataConsumer; +const ArraySourceRegex = new RegExp(/.+\.\d+$/); + export interface FormDataConsumerRenderParams< TFieldValues extends FieldValues = FieldValues, TScopedFieldValues extends FieldValues = TFieldValues > { formData: TFieldValues; scopedFormData?: TScopedFieldValues; - getSource: (source: string) => string; } export type FormDataConsumerRender< diff --git a/packages/ra-core/src/form/useApplyInputDefaultValues.ts b/packages/ra-core/src/form/useApplyInputDefaultValues.ts index 89bf0132432..267bc0aa12b 100644 --- a/packages/ra-core/src/form/useApplyInputDefaultValues.ts +++ b/packages/ra-core/src/form/useApplyInputDefaultValues.ts @@ -7,6 +7,7 @@ import { import get from 'lodash/get'; import { useRecordContext } from '../controller'; import { InputProps } from './useInput'; +import { useWrappedSource } from '../core'; interface StandardInput { inputProps: Partial & { source: string }; @@ -32,6 +33,8 @@ export const useApplyInputDefaultValues = ({ fieldArrayInputControl, }: Props) => { const { defaultValue, source } = inputProps; + const finalSource = useWrappedSource(source); + const record = useRecordContext(inputProps); const { getValues, @@ -40,9 +43,9 @@ export const useApplyInputDefaultValues = ({ formState, reset, } = useFormContext(); - const recordValue = get(record, source); - const formValue = get(getValues(), source); - const { isDirty } = getFieldState(source, formState); + const recordValue = get(record, finalSource); + const formValue = get(getValues(), finalSource); + const { isDirty } = getFieldState(finalSource, formState); useEffect(() => { if ( @@ -58,11 +61,11 @@ export const useApplyInputDefaultValues = ({ // Since we use get(record, source), if source is like foo.23.bar, // this effect will run. However we only want to set the default value // for the subfield bar if the record actually has a value for foo.23 - const pathContainsIndex = source + const pathContainsIndex = finalSource .split('.') .some(pathPart => numericRegex.test(pathPart)); if (pathContainsIndex) { - const parentPath = source.split('.').slice(0, -1).join('.'); + const parentPath = finalSource.split('.').slice(0, -1).join('.'); const parentValue = get(getValues(), parentPath); if (parentValue == null) { // the parent is undefined, so we don't want to set the default value @@ -88,7 +91,7 @@ export const useApplyInputDefaultValues = ({ return; } - resetField(source, { defaultValue }); + resetField(finalSource, { defaultValue }); }); }; diff --git a/packages/ra-core/src/form/useInput.ts b/packages/ra-core/src/form/useInput.ts index 85decdc4108..f0b56bf4356 100644 --- a/packages/ra-core/src/form/useInput.ts +++ b/packages/ra-core/src/form/useInput.ts @@ -16,6 +16,7 @@ import { useFormGroupContext } from './useFormGroupContext'; import { useFormGroups } from './useFormGroups'; import { useApplyInputDefaultValues } from './useApplyInputDefaultValues'; import { useEvent } from '../util'; +import { useWrappedSource } from '../core'; // replace null or undefined values by empty string to avoid controlled/uncontrolled input warning const defaultFormat = (value: any) => (value == null ? '' : value); @@ -38,22 +39,33 @@ export const useInput = ( validate, ...options } = props; - const finalName = name || source; + const finalSource = useWrappedSource(source); + const finalName = name || finalSource; const formGroupName = useFormGroupContext(); const formGroups = useFormGroups(); const record = useRecordContext(); + if ( + !source && + props.label == null && + process.env.NODE_ENV === 'development' + ) { + console.warn( + 'Input components require either a source or a label prop.' + ); + } + useEffect(() => { if (!formGroups || formGroupName == null) { return; } - formGroups.registerField(source, formGroupName); + formGroups.registerField(finalSource, formGroupName); return () => { - formGroups.unregisterField(source, formGroupName); + formGroups.unregisterField(finalSource, formGroupName); }; - }, [formGroups, formGroupName, source]); + }, [formGroups, formGroupName, finalSource]); const sanitizedValidate = Array.isArray(validate) ? composeValidators(validate) @@ -65,7 +77,7 @@ export const useInput = ( // (i.e. field level defaultValue override form level defaultValues for this field). const { field: controllerField, fieldState, formState } = useController({ name: finalName, - defaultValue: get(record, source, defaultValue), + defaultValue: get(record, finalSource, defaultValue), rules: { validate: async (value, values) => { if (!sanitizedValidate) return true; @@ -120,7 +132,7 @@ export const useInput = ( }; return { - id: id || source, + id: id || finalSource, field, fieldState, formState, diff --git a/packages/ra-core/src/i18n/TestTranslationProvider.tsx b/packages/ra-core/src/i18n/TestTranslationProvider.tsx index aad86867de0..af5c8b366e0 100644 --- a/packages/ra-core/src/i18n/TestTranslationProvider.tsx +++ b/packages/ra-core/src/i18n/TestTranslationProvider.tsx @@ -2,29 +2,35 @@ import * as React from 'react'; import lodashGet from 'lodash/get'; import { I18nContextProvider } from './I18nContextProvider'; +import { I18nProvider } from '../types'; export const TestTranslationProvider = ({ translate, messages, children, }: any) => ( - { - const message = lodashGet(messages, key); - console.log({ key, options, message }); - return message - ? typeof message === 'function' - ? message(options) - : message - : options?._ || key; - } - : translate, - changeLocale: () => Promise.resolve(), - getLocale: () => 'en', - }} - > + {children} ); + +export const testI18nProvider = ({ + translate, + messages, +}: { + translate?: I18nProvider['translate']; + messages?: Record string)>; +}): I18nProvider => ({ + translate: messages + ? (key, options) => { + const message = lodashGet(messages, key); + return message + ? typeof message === 'function' + ? message(options) + : message + : options?._ || key; + } + : translate, + changeLocale: () => Promise.resolve(), + getLocale: () => 'en', +}); diff --git a/packages/ra-core/src/i18n/useTranslatable.ts b/packages/ra-core/src/i18n/useTranslatable.ts index ce7c5acad7c..876ab0c3585 100644 --- a/packages/ra-core/src/i18n/useTranslatable.ts +++ b/packages/ra-core/src/i18n/useTranslatable.ts @@ -30,8 +30,10 @@ export const useTranslatable = ( const context = useMemo( () => ({ + // TODO: remove once fields use SourceContext getSource: (source: string, locale: string = selectedLocale) => `${source}.${locale}`, + // TODO: remove once fields use SourceContext getLabel: (source: string, label?: string) => translateLabel({ source, resource, label }) as string, locales, diff --git a/packages/ra-core/src/i18n/useTranslateLabel.spec.tsx b/packages/ra-core/src/i18n/useTranslateLabel.spec.tsx new file mode 100644 index 00000000000..a863f289493 --- /dev/null +++ b/packages/ra-core/src/i18n/useTranslateLabel.spec.tsx @@ -0,0 +1,177 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { useTranslateLabel } from './useTranslateLabel'; +import { TestTranslationProvider } from './TestTranslationProvider'; +import { SourceContextProvider } from '..'; + +describe('useTranslateLabel', () => { + const TranslateLabel = ({ + source, + label, + resource, + }: { + source?: string; + label?: string | false | React.ReactElement; + resource?: string; + }) => { + const translateLabel = useTranslateLabel(); + return ( + <> + {translateLabel({ + label, + source, + resource, + })} + + ); + }; + + it('should return null when label is false', () => { + render( + + + + ); + expect(screen.queryByText(/title/)).toBeNull(); + }); + + it('should return null when label is empty', () => { + render( + + + + ); + expect(screen.queryByText(/title/)).toBeNull(); + }); + + it('should return the label element when provided', () => { + render( + + My title} + source="title" + resource="posts" + /> + + ); + screen.getByText('My title'); + }); + + it('should return the label text when provided', () => { + render( + + + + ); + screen.getByText('My title'); + }); + + it('should return the translated label text when provided', () => { + render( + + + + ); + screen.getByText('My title'); + }); + + it('should return the inferred label from source and resource when no label is provided', () => { + render( + + + + ); + screen.getByText('Title'); + }); + + it('should return the translated inferred label from source and resource when no label is provided', () => { + render( + + + + ); + screen.getByText('My Title'); + }); + + it('should return the inferred label from SourceContext when no label is provided but a SourceContext is present', () => { + render( + + source, + getLabel: source => `test.${source}`, + }} + > + + + + ); + screen.getByText('Title'); + }); + + it('should return the translated label from SourceContext when no label is provided but a SourceContext is present', () => { + render( + + source, + getLabel: source => `test.${source}`, + }} + > + + + + ); + screen.getByText('Label for title'); + }); + + it('should return the inferred label when a resource prop is provided even when a SourceContext is present', () => { + render( + + source, + getLabel: source => `test.${source}`, + }} + > + + + + ); + screen.getByText('Title'); + }); +}); diff --git a/packages/ra-core/src/i18n/useTranslateLabel.ts b/packages/ra-core/src/i18n/useTranslateLabel.ts index 47f28258829..1bebd4cd78a 100644 --- a/packages/ra-core/src/i18n/useTranslateLabel.ts +++ b/packages/ra-core/src/i18n/useTranslateLabel.ts @@ -1,13 +1,13 @@ import { useCallback, ReactElement } from 'react'; import { useTranslate } from './useTranslate'; -import { useLabelPrefix, getFieldLabelTranslationArgs } from '../util'; -import { useResourceContext } from '../core'; +import { getFieldLabelTranslationArgs } from '../util'; +import { useResourceContext, useSourceContext } from '../core'; export const useTranslateLabel = () => { const translate = useTranslate(); - const prefix = useLabelPrefix(); const resourceFromContext = useResourceContext(); + const sourceContext = useSourceContext(); return useCallback( ({ @@ -19,6 +19,8 @@ export const useTranslateLabel = () => { label?: string | false | ReactElement; resource?: string; }) => { + const finalSource = sourceContext?.getSource(source) ?? source; + if (label === false || label === '') { return null; } @@ -30,13 +32,13 @@ export const useTranslateLabel = () => { return translate( ...getFieldLabelTranslationArgs({ label: label as string, - prefix, + defaultLabel: sourceContext?.getLabel(source), resource, resourceFromContext, - source, + source: finalSource, }) ); }, - [prefix, resourceFromContext, translate] + [resourceFromContext, translate, sourceContext] ); }; diff --git a/packages/ra-core/src/util/LabelPrefixContext.ts b/packages/ra-core/src/util/LabelPrefixContext.ts deleted file mode 100644 index 5ade1aa9910..00000000000 --- a/packages/ra-core/src/util/LabelPrefixContext.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { createContext } from 'react'; - -export const LabelPrefixContext = createContext(''); diff --git a/packages/ra-core/src/util/LabelPrefixContextProvider.tsx b/packages/ra-core/src/util/LabelPrefixContextProvider.tsx deleted file mode 100644 index b5d0efa6cf0..00000000000 --- a/packages/ra-core/src/util/LabelPrefixContextProvider.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import * as React from 'react'; -import { LabelPrefixContext } from './LabelPrefixContext'; -import { useLabelPrefix } from './useLabelPrefix'; - -export const LabelPrefixContextProvider = ({ - prefix, - concatenate = true, - children, -}) => { - const oldPrefix = useLabelPrefix(); - const newPrefix = - oldPrefix && concatenate ? `${oldPrefix}.${prefix}` : prefix; - return ( - - {children} - - ); -}; diff --git a/packages/ra-core/src/util/getFieldLabelTranslationArgs.spec.ts b/packages/ra-core/src/util/getFieldLabelTranslationArgs.spec.ts index f693abe149f..8bf08f8aef7 100644 --- a/packages/ra-core/src/util/getFieldLabelTranslationArgs.spec.ts +++ b/packages/ra-core/src/util/getFieldLabelTranslationArgs.spec.ts @@ -80,17 +80,17 @@ describe('getFieldLabelTranslationArgs', () => { getFieldLabelTranslationArgs({ resource: 'posts', resourceFromContext: 'users', - prefix: 'resources.users.fields', + defaultLabel: 'resources.users.fields.name', source: 'referenceOne.users@@name', }) ).toEqual(['resources.users.fields.name', { _: 'Name' }]); }); - it('should prefer the resource over the prefix', () => { + it('should prefer the resource over the defaultLabel', () => { expect( getFieldLabelTranslationArgs({ resource: 'books', - prefix: 'resources.posts.fields', + defaultLabel: 'resources.posts.fields.title', source: 'title', }) ).toEqual([`resources.books.fields.title`, { _: 'Title' }]); @@ -106,10 +106,10 @@ describe('getFieldLabelTranslationArgs', () => { ).toEqual([`resources.posts.fields.title`, { _: 'Title' }]); }); - it('should prefer the prefix over the resourceFromContext', () => { + it('should prefer the defaultLabel over the resourceFromContext', () => { expect( getFieldLabelTranslationArgs({ - prefix: 'resources.posts.fields', + defaultLabel: 'resources.posts.fields.title', resourceFromContext: 'books', source: 'title', }) diff --git a/packages/ra-core/src/util/getFieldLabelTranslationArgs.ts b/packages/ra-core/src/util/getFieldLabelTranslationArgs.ts index bdcbd6a6b8f..e929def61df 100644 --- a/packages/ra-core/src/util/getFieldLabelTranslationArgs.ts +++ b/packages/ra-core/src/util/getFieldLabelTranslationArgs.ts @@ -2,7 +2,7 @@ import inflection from 'inflection'; interface Args { label?: string; - prefix?: string; + defaultLabel?: string; resource?: string; resourceFromContext?: string; source?: string; @@ -12,7 +12,7 @@ type TranslationArguments = [string, any?]; /** * Returns an array of arguments to use with the translate function for the label of a field. - * The label will be the one specified by the label prop or one computed from the resource and source props. + * The label will be computed from the resource and source props. * * Usage: * @@ -21,10 +21,18 @@ type TranslationArguments = [string, any?]; * * @see useTranslateLabel for a ready-to-use hook */ -export default (options?: Args): TranslationArguments => { +export const getFieldLabelTranslationArgs = ( + options?: Args +): TranslationArguments => { if (!options) return ['']; - const { label, prefix, resource, resourceFromContext, source } = options; + const { + label, + defaultLabel, + resource, + resourceFromContext, + source, + } = options; if (typeof label !== 'undefined') return [label, { _: label }]; @@ -32,28 +40,33 @@ export default (options?: Args): TranslationArguments => { const { sourceWithoutDigits, sourceSuffix } = getSourceParts(source); - const defaultLabel = inflection.transform( + const defaultLabelTranslation = inflection.transform( sourceSuffix.replace(/\./g, ' '), ['underscore', 'humanize'] ); if (resource) { return [ - `resources.${resource}.fields.${sourceWithoutDigits}`, - { _: defaultLabel }, + getResourceFieldLabelKey(resource, sourceWithoutDigits), + { _: defaultLabelTranslation }, ]; } - if (prefix) { - return [`${prefix}.${sourceWithoutDigits}`, { _: defaultLabel }]; + if (defaultLabel) { + return [defaultLabel, { _: defaultLabelTranslation }]; } return [ - `resources.${resourceFromContext}.fields.${sourceWithoutDigits}`, - { _: defaultLabel }, + getResourceFieldLabelKey(resourceFromContext, sourceWithoutDigits), + { _: defaultLabelTranslation }, ]; }; +export default getFieldLabelTranslationArgs; + +export const getResourceFieldLabelKey = (resource: string, source: string) => + `resources.${resource}.fields.${source}`; + /** * Uses the source string to guess a translation message and a default label. * diff --git a/packages/ra-core/src/util/index.ts b/packages/ra-core/src/util/index.ts index c3aa327aeec..d02a278ad07 100644 --- a/packages/ra-core/src/util/index.ts +++ b/packages/ra-core/src/util/index.ts @@ -1,6 +1,5 @@ import escapePath from './escapePath'; import FieldTitle, { FieldTitleProps } from './FieldTitle'; -import getFieldLabelTranslationArgs from './getFieldLabelTranslationArgs'; import ComponentPropType from './ComponentPropType'; import removeEmpty from './removeEmpty'; import removeKey from './removeKey'; @@ -8,13 +7,13 @@ import Ready from './Ready'; import warning from './warning'; import useWhyDidYouUpdate from './useWhyDidYouUpdate'; import { getMutationMode } from './getMutationMode'; +export * from './getFieldLabelTranslationArgs'; export * from './mergeRefs'; export * from './useEvent'; export { escapePath, FieldTitle, - getFieldLabelTranslationArgs, ComponentPropType, Ready, removeEmpty, @@ -28,7 +27,4 @@ export type { FieldTitleProps }; export * from './asyncDebounce'; export * from './hooks'; export * from './shallowEqual'; -export * from './LabelPrefixContext'; -export * from './LabelPrefixContextProvider'; -export * from './useLabelPrefix'; export * from './useCheckForApplicationUpdate'; diff --git a/packages/ra-core/src/util/useLabelPrefix.ts b/packages/ra-core/src/util/useLabelPrefix.ts deleted file mode 100644 index 24dda069ddf..00000000000 --- a/packages/ra-core/src/util/useLabelPrefix.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { useContext } from 'react'; -import { LabelPrefixContext } from './LabelPrefixContext'; - -export const useLabelPrefix = () => useContext(LabelPrefixContext); diff --git a/packages/ra-no-code/src/ResourceConfiguration/ConfigurationInputsFromFieldDefinition.tsx b/packages/ra-no-code/src/ResourceConfiguration/ConfigurationInputsFromFieldDefinition.tsx index 83bcab9c0b0..c2b61b9c9e9 100644 --- a/packages/ra-no-code/src/ResourceConfiguration/ConfigurationInputsFromFieldDefinition.tsx +++ b/packages/ra-no-code/src/ResourceConfiguration/ConfigurationInputsFromFieldDefinition.tsx @@ -36,7 +36,7 @@ export const ConfigurationInputsFromFieldDefinition = ({ choices={ReferenceSelectionChoice} /> - {({ formData, ...rest }) => { + {({ formData }) => { const resourceName = get( formData, `${sourcePrefix}.props.reference` @@ -54,7 +54,6 @@ export const ConfigurationInputsFromFieldDefinition = ({ field.props.label || field.props.source, }))} - {...rest} /> ); }} diff --git a/packages/ra-ui-materialui/src/input/ArrayInput/ArrayInput.spec.tsx b/packages/ra-ui-materialui/src/input/ArrayInput/ArrayInput.spec.tsx index 393b1a4e605..0f014e00c7d 100644 --- a/packages/ra-ui-materialui/src/input/ArrayInput/ArrayInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/ArrayInput/ArrayInput.spec.tsx @@ -3,6 +3,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { RecordContextProvider, + ResourceContextProvider, minLength, required, testDataProvider, @@ -104,22 +105,24 @@ describe('', () => { it('should clone each input once per value in the array', () => { render( - - - - - - - - + + + + + + + + + + ); expect( @@ -143,29 +146,30 @@ describe('', () => { it('should apply validation to both itself and its inner inputs', async () => { render( - - + - - - - - - + + + + + + + + ); diff --git a/packages/ra-ui-materialui/src/input/ArrayInput/ArrayInput.stories.tsx b/packages/ra-ui-materialui/src/input/ArrayInput/ArrayInput.stories.tsx index 1ac0385db24..45e35c5d0dc 100644 --- a/packages/ra-ui-materialui/src/input/ArrayInput/ArrayInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/ArrayInput/ArrayInput.stories.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { Admin } from 'react-admin'; -import { required, Resource } from 'ra-core'; +import { required, Resource, testI18nProvider } from 'ra-core'; import { createMemoryHistory } from 'history'; import { InputAdornment } from '@mui/material'; @@ -167,6 +167,52 @@ export const Scalar = () => ( ); +export const ScalarI18n = () => ( + + ( + { + console.log(data); + }, + }} + > + + + + + + + + + + )} + /> + +); + const order = { id: 1, date: '2022-08-30', diff --git a/packages/ra-ui-materialui/src/input/ArrayInput/ArrayInput.tsx b/packages/ra-ui-materialui/src/input/ArrayInput/ArrayInput.tsx index c17726eee40..cf2a2d757f8 100644 --- a/packages/ra-ui-materialui/src/input/ArrayInput/ArrayInput.tsx +++ b/packages/ra-ui-materialui/src/input/ArrayInput/ArrayInput.tsx @@ -10,6 +10,7 @@ import { useGetValidationErrorMessage, useFormGroupContext, useFormGroups, + useWrappedSource, } from 'ra-core'; import { useFieldArray, useFormContext } from 'react-hook-form'; import { @@ -90,6 +91,7 @@ export const ArrayInput = (props: ArrayInputProps) => { const formGroupName = useFormGroupContext(); const formGroups = useFormGroups(); + const finalSource = useWrappedSource(source); const sanitizedValidate = Array.isArray(validate) ? composeSyncValidators(validate) @@ -105,7 +107,7 @@ export const ArrayInput = (props: ArrayInputProps) => { } = useFormContext(); const fieldProps = useFieldArray({ - name: source, + name: finalSource, rules: { validate: async value => { if (!sanitizedValidate) return true; @@ -125,14 +127,14 @@ export const ArrayInput = (props: ArrayInputProps) => { // We need to register the array itself as a field to enable validation at its level useEffect(() => { - register(source); - formGroups.registerField(source, formGroupName); + register(finalSource); + formGroups.registerField(finalSource, formGroupName); return () => { - unregister(source, { keepValue: true }); - formGroups.unregisterField(source, formGroupName); + unregister(finalSource, { keepValue: true }); + formGroups.unregisterField(finalSource, formGroupName); }; - }, [register, unregister, source, formGroups, formGroupName]); + }, [register, unregister, finalSource, formGroups, formGroupName]); useApplyInputDefaultValues({ inputProps: props, @@ -140,7 +142,7 @@ export const ArrayInput = (props: ArrayInputProps) => { fieldArrayInputControl: fieldProps, }); - const { isDirty, error } = getFieldState(source, formState); + const { isDirty, error } = getFieldState(finalSource, formState); if (isPending) { return ( @@ -166,7 +168,7 @@ export const ArrayInput = (props: ArrayInputProps) => { {...sanitizeInputRestProps(rest)} > ', () => { expect((inputElements[1] as HTMLInputElement).value).toBe('bar'); }); - it('should render disabled inputs when disabled is true', () => { - render( - - - - - - - - - - ); - const inputElements = screen.queryAllByLabelText( - 'resources.undefined.fields.emails.email' - ); - expect(inputElements).toHaveLength(2); - expect((inputElements[0] as HTMLInputElement).disabled).toBeTruthy(); - expect((inputElements[0] as HTMLInputElement).value).toBe('foo'); - expect((inputElements[1] as HTMLInputElement).disabled).toBeTruthy(); - expect((inputElements[1] as HTMLInputElement).value).toBe('bar'); - }); - it('should allow to override the disabled prop of each inputs', () => { render( @@ -501,17 +474,14 @@ describe('', () => { - {({ getSource }) => ( + {() => ( <> - + )} @@ -864,13 +834,9 @@ describe('', () => { - {({ scopedFormData, getSource }) => + {({ scopedFormData }) => scopedFormData && scopedFormData.name ? ( - string)('role')} - /> + ) : null } @@ -911,14 +877,7 @@ describe('', () => { - {({ getSource }) => ( - string)('role')} - /> - )} + {() => } diff --git a/packages/ra-ui-materialui/src/input/ArrayInput/SimpleFormIteratorItem.tsx b/packages/ra-ui-materialui/src/input/ArrayInput/SimpleFormIteratorItem.tsx index 747ace0564d..17b4db5542d 100644 --- a/packages/ra-ui-materialui/src/input/ArrayInput/SimpleFormIteratorItem.tsx +++ b/packages/ra-ui-materialui/src/input/ArrayInput/SimpleFormIteratorItem.tsx @@ -1,17 +1,21 @@ import * as React from 'react'; import { - Children, cloneElement, MouseEvent, MouseEventHandler, - isValidElement, ReactElement, ReactNode, useMemo, } from 'react'; import { Typography } from '@mui/material'; import clsx from 'clsx'; -import { RaRecord } from 'ra-core'; +import { + getResourceFieldLabelKey, + RaRecord, + SourceContextProvider, + useResourceContext, + useSourceContext, +} from 'ra-core'; import { SimpleFormIteratorClasses } from './useSimpleFormIteratorStyles'; import { useSimpleFormIterator } from './useSimpleFormIterator'; @@ -35,10 +39,9 @@ export const SimpleFormIteratorItem = React.forwardRef( record, removeButton, reOrderButtons, - resource, source, } = props; - + const resource = useResourceContext(props); const { total, reOrder, remove } = useSimpleFormIterator(); // 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 @@ -77,6 +80,27 @@ export const SimpleFormIteratorItem = React.forwardRef( ? getItemLabel(index) : getItemLabel; + const parentSourceContext = useSourceContext(); + const sourceContext = useMemo( + () => ({ + getSource: (source: string) => + source ? `${member}.${source}` : member, + getLabel: (source: string) => { + // remove digits, e.g. 'book.authors.2.categories.3.identifier.name' => 'book.authors.categories.identifier.name' + const sanitizedMember = member.replace(/\.\d+/g, ''); + // source may be empty for scalar values arrays + const itemSource = source + ? `${sanitizedMember}.${source}` + : sanitizedMember; + + return parentSourceContext + ? parentSourceContext.getLabel(itemSource) + : getResourceFieldLabelKey(resource, itemSource); + }, + }), + [member, parentSourceContext, resource] + ); + return (
  • @@ -94,24 +118,9 @@ export const SimpleFormIteratorItem = React.forwardRef( inline && SimpleFormIteratorClasses.inline )} > - {Children.map( - children, - (input: ReactElement, index2) => { - if (!isValidElement(input)) { - return null; - } - const { source, ...inputProps } = input.props; - return cloneElement(input, { - source: source - ? `${member}.${source}` - : member, - index: source ? undefined : index2, - resource, - disabled, - ...inputProps, - }); - } - )} + + {children} + {!disabled && ( diff --git a/packages/ra-ui-materialui/src/input/SelectArrayInput.stories.tsx b/packages/ra-ui-materialui/src/input/SelectArrayInput.stories.tsx index 0f460c57146..c0d7aa60e8b 100644 --- a/packages/ra-ui-materialui/src/input/SelectArrayInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/SelectArrayInput.stories.tsx @@ -9,6 +9,7 @@ import { TextField, } from '@mui/material'; import fakeRestProvider from 'ra-data-fakerest'; +import { useWatch } from 'react-hook-form'; import { AdminContext } from '../AdminContext'; import { Create, Edit } from '../detail'; @@ -18,8 +19,6 @@ import { ReferenceArrayInput } from './ReferenceArrayInput'; import { useCreateSuggestionContext } from './useSupportCreateSuggestion'; import { TextInput } from './TextInput'; import { ArrayInput, SimpleFormIterator } from './ArrayInput'; -import { FormDataConsumer } from 'ra-core'; -import { useWatch } from 'react-hook-form'; export default { title: 'ra-ui-materialui/input/SelectArrayInput' }; @@ -112,24 +111,15 @@ export const InsideArrayInput = () => ( defaultValue={[{ data: ['foo'] }]} > - - {({ getSource }) => { - const source = getSource!('data'); - return ( - <> - - - ); - }} - + diff --git a/packages/ra-ui-materialui/src/input/TranslatableInputs.spec.tsx b/packages/ra-ui-materialui/src/input/TranslatableInputs.spec.tsx index 3e2eafc03fc..3628f58292a 100644 --- a/packages/ra-ui-materialui/src/input/TranslatableInputs.spec.tsx +++ b/packages/ra-ui-materialui/src/input/TranslatableInputs.spec.tsx @@ -163,11 +163,11 @@ describe('', () => { ); - fireEvent.change(screen.queryByDisplayValue('english name'), { + fireEvent.change(screen.getByDisplayValue('english name'), { target: { value: 'english name updated' }, }); fireEvent.click(screen.getByText('ra.locales.fr')); - fireEvent.change(screen.queryByDisplayValue('french nested field'), { + fireEvent.change(screen.getByDisplayValue('french nested field'), { target: { value: 'french nested field updated' }, }); fireEvent.click(screen.getByText('ra.action.save')); diff --git a/packages/ra-ui-materialui/src/input/TranslatableInputsTabContent.tsx b/packages/ra-ui-materialui/src/input/TranslatableInputsTabContent.tsx index be486333742..a5ca53fb311 100644 --- a/packages/ra-ui-materialui/src/input/TranslatableInputsTabContent.tsx +++ b/packages/ra-ui-materialui/src/input/TranslatableInputsTabContent.tsx @@ -2,16 +2,14 @@ import * as React from 'react'; import { styled } from '@mui/material/styles'; import { Stack, StackProps } from '@mui/material'; import clsx from 'clsx'; -import { - Children, - cloneElement, - isValidElement, - ReactElement, - ReactNode, -} from 'react'; +import { ReactElement, ReactNode, useMemo } from 'react'; import { FormGroupContextProvider, RaRecord, + SourceContextProvider, + getResourceFieldLabelKey, + useResourceContext, + useSourceContext, useTranslatableContext, } from 'ra-core'; @@ -23,7 +21,20 @@ export const TranslatableInputsTabContent = ( props: TranslatableInputsTabContentProps ): ReactElement => { const { children, groupKey = '', locale, ...other } = props; - const { selectedLocale, getLabel, getSource } = useTranslatableContext(); + const resource = useResourceContext(props); + const { selectedLocale } = useTranslatableContext(); + const parentSourceContext = useSourceContext(); + const sourceContext = useMemo( + () => ({ + getSource: (source: string) => `${source}.${locale}`, + getLabel: (source: string) => { + return parentSourceContext + ? parentSourceContext.getLabel(source) + : getResourceFieldLabelKey(resource, source); + }, + }), + [locale, parentSourceContext, resource] + ); return ( @@ -37,18 +48,9 @@ export const TranslatableInputsTabContent = ( })} {...other} > - {Children.map(children, child => - isValidElement(child) - ? cloneElement(child, { - ...child.props, - label: getLabel( - child.props.source, - child.props.label - ), - source: getSource(child.props.source, locale), - }) - : null - )} + + {children} + ); diff --git a/packages/ra-ui-materialui/src/list/filter/FilterForm.tsx b/packages/ra-ui-materialui/src/list/filter/FilterForm.tsx index 5634f114716..4162de568b0 100644 --- a/packages/ra-ui-materialui/src/list/filter/FilterForm.tsx +++ b/packages/ra-ui-materialui/src/list/filter/FilterForm.tsx @@ -10,8 +10,9 @@ import PropTypes from 'prop-types'; import { styled } from '@mui/material/styles'; import { FormGroupsProvider, - LabelPrefixContextProvider, ListFilterContextValue, + SourceContextProvider, + SourceContextValue, useListContext, useResourceContext, } from 'ra-core'; @@ -133,8 +134,17 @@ export const FilterFormBase = (props: FilterFormBaseProps) => { [hideFilter] ); + const sourceContext = React.useMemo( + () => ({ + getSource: (source: string) => source, + getLabel: (source: string) => + `resources.${resource}.fields.${source}`, + }), + [resource] + ); + return ( - + { ))}
    - + ); };