Skip to content

Commit

Permalink
Merge pull request #9533 from marmelab/source-context
Browse files Browse the repository at this point in the history
Introduce SourceContext
  • Loading branch information
slax57 authored Jan 12, 2024
2 parents c25e4e1 + 3894f03 commit 7f010a0
Show file tree
Hide file tree
Showing 33 changed files with 595 additions and 334 deletions.
14 changes: 4 additions & 10 deletions docs/Inputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<ArrayInput>`:
**Tip**: When used inside an `ArrayInput`, `<FormDataConsumer>` 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 `<SimpleFormIterator>`, as in the following example:

```tsx
import { FormDataConsumer } from 'react-admin';
Expand All @@ -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 ? (
<SelectInput
source={getSource('role')} // Will translate to "authors[0].role"
source="role" // Will translate to "authors[0].role"
choices={[{ id: 1, name: 'Head Writer' }, { id: 2, name: 'Co-Writer' }]}
{...rest}
/>
Expand All @@ -573,7 +567,7 @@ const PostEdit = () => (
);
```

**Tip:** TypeScript users will notice that `scopedFormData` and `getSource` are typed as optional parameters. This is because the `<FormDataConsumer>` component can be used outside of an `<ArrayInput>` and in that case, these parameters will be `undefined`. If you are inside an `<ArrayInput>`, 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 `<FormDataConsumer>` component can be used outside of an `<ArrayInput>` and in that case, this parameter will be `undefined`. If you are inside an `<ArrayInput>`, you can safely assume that this parameter will be defined.

## Hiding Inputs Based On Other Inputs

Expand Down
14 changes: 3 additions & 11 deletions docs/SimpleFormIterator.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,12 +114,7 @@ A list of Input elements, that will be rendered on each row.

By default, `<SimpleFormIterator>` renders one input per line, but they can be displayed inline with the `inline` prop.

`<SimpleFormIterator>` also accepts `<FormDataConsumer>` as child. When used inside a form iterator, `<FormDataConsumer>` 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 `<ArrayInput>`:
`<SimpleFormIterator>` also accepts `<FormDataConsumer>` as child. In this case, `<FormDataConsumer>` 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 `<SimpleFormIterator>`, as in the following example:

```jsx
import { FormDataConsumer } from 'react-admin';
Expand All @@ -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 ? (
<SelectInput
source={getSource('role')} // Will translate to "authors[0].role"
source="role" // Will translate to "authors[0].role"
choices={[{ id: 1, name: 'Head Writer' }, { id: 2, name: 'Co-Writer' }]}
{...rest}
/>
) : null
}
Expand All @@ -153,7 +145,7 @@ const PostEdit = () => (
);
```

**Tip:** TypeScript users will notice that `scopedFormData` and `getSource` are typed as optional parameters. This is because the `<FormDataConsumer>` component can be used outside of a `<SimpleFormIterator>` and in that case, these parameters will be `undefined`. If you are inside a `<SimpleFormIterator>`, 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 `<FormDataConsumer>` component can be used outside of an `<ArrayInput>` and in that case, this parameter will be `undefined`. If you are inside an `<ArrayInput>`, you can safely assume that this parameter will be defined.

**Note**: `<SimpleFormIterator>` only accepts `Input` components as children. If you want to use some `Fields` instead, you have to use a `<FormDataConsumer>`, as follows:

Expand Down
50 changes: 50 additions & 0 deletions docs/Upgrade.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,56 @@ const CompanyField = () => (
```
{% endraw %}

## `<SimpleFormIterator>` no longer clones its children

We've changed the implementation of `<SimpleFormIterator>`, the companion child of `<ArrayInput>`. This internal change is mostly backwards compatible, with one exception: defining the `disabled` prop on the `<ArrayInput>` 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
<ArrayInput disabled={someCondition}>
<SimpleFormIterator>
- <TextInput source="lastName" />
- <TextInput source="firstName" />
+ <TextInput source="lastName" disabled={someCondition} />
+ <TextInput source="firstName" disabled={someCondition} />
</SimpleFormIterator>
</ArrayInput>
```

## `<FormDataConsumer>` no longer passes a `getSource` function

When using `<FormDataConsumer>` inside an `<ArrayInput>`, the child function no longer receives a `getSource` callback. We've made all Input components able to work seamlessly inside an `<ArrayInput>`, 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 = () => (
<Edit>
<SimpleForm>
<ArrayInput source="authors">
<SimpleFormIterator>
<TextInput source="name" />
<FormDataConsumer>
{({
formData, // The whole form data
scopedFormData, // The data for this item of the ArrayInput
- getSource,
}) =>
scopedFormData && getSource && scopedFormData.name ? (
<SelectInput
- source={getSource('role')}
+ source="role" // Will translate to "authors[0].role"
choices={[{ id: 1, name: 'Head Writer' }, { id: 2, name: 'Co-Writer' }]}
/>
) : null
}
</FormDataConsumer>
</SimpleFormIterator>
</ArrayInput>
</SimpleForm>
</Edit>
);
```

## 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.
5 changes: 2 additions & 3 deletions examples/simple/src/posts/PostCreate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,10 +163,10 @@ const PostCreate = () => {
/>
</ReferenceInput>
<FormDataConsumer>
{({ scopedFormData, getSource, ...rest }) =>
{({ scopedFormData }) =>
scopedFormData && scopedFormData.user_id ? (
<SelectInput
source={getSource('role')}
source="role"
choices={[
{
id: 'headwriter',
Expand All @@ -181,7 +181,6 @@ const PostCreate = () => {
name: 'Co-Writer',
},
]}
{...rest}
label="Role"
/>
) : null
Expand Down
5 changes: 2 additions & 3 deletions examples/simple/src/posts/PostEdit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,11 +160,11 @@ const PostEdit = () => {
<AutocompleteInput helperText={false} />
</ReferenceInput>
<FormDataConsumer>
{({ scopedFormData, getSource, ...rest }) =>
{({ scopedFormData }) =>
scopedFormData &&
scopedFormData.user_id ? (
<SelectInput
source={getSource('role')}
source="source"
choices={[
{
id: 'headwriter',
Expand All @@ -180,7 +180,6 @@ const PostEdit = () => {
},
]}
helperText={false}
{...rest}
/>
) : null
}
Expand Down
35 changes: 35 additions & 0 deletions packages/ra-core/src/core/SourceContext.tsx
Original file line number Diff line number Diff line change
@@ -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 (
* <SouceContextProvider value={sourceContext}>
* <TextInput source="lat" />
* <TextInput source="lng" />
* </SouceContextProvider>
* );
* };
*/
export const SourceContext = createContext<SourceContextValue>(null);

export const SourceContextProvider = SourceContext.Provider;

export const useSourceContext = () => useContext(SourceContext);
2 changes: 2 additions & 0 deletions packages/ra-core/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
16 changes: 16 additions & 0 deletions packages/ra-core/src/core/useWrappedSource.ts
Original file line number Diff line number Diff line change
@@ -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 <input name={finalSource} {...props} />;
* };
*/
export const useWrappedSource = (source: string) => {
const sourceContext = useSourceContext();
return sourceContext?.getSource(source) ?? source;
};
11 changes: 7 additions & 4 deletions packages/ra-core/src/form/Form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -53,10 +52,14 @@ export const Form = <RecordType = any>(props: FormProps<RecordType>) => {
const record = useRecordContext(props);
const resource = useResourceContext(props);
const { form, formHandleSubmit } = useAugmentedForm(props);
const sourceContext = React.useMemo<SourceContextValue>(() => ({
getSource: (source: string) => source,
getLabel: (source: string) => `resources.${resource}.fields.${source}`,
}), [resource]);

return (
<OptionalRecordContextProvider value={record}>
<LabelPrefixContextProvider prefix={`resources.${resource}.fields`}>
<SourceContextProvider value={sourceContext}>
<FormProvider {...form}>
<FormGroupsProvider>
<form
Expand All @@ -69,7 +72,7 @@ export const Form = <RecordType = any>(props: FormProps<RecordType>) => {
</form>
</FormGroupsProvider>
</FormProvider>
</LabelPrefixContextProvider>
</SourceContextProvider>
</OptionalRecordContextProvider>
);
};
Expand Down
50 changes: 7 additions & 43 deletions packages/ra-core/src/form/FormDataConsumer.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' };

Expand All @@ -30,46 +30,20 @@ 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(
<FormDataConsumerView
form="a-form"
source="authors[0]"
index={0}
formData={formData}
>
{children}
</FormDataConsumerView>
);

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(
<AdminContext dataProvider={testDataProvider()}>
<SimpleForm>
<BooleanInput source="hi" defaultValue />
<FormDataConsumer>
{({ formData, getSource, ...rest }) => {
{({ formData }) => {
globalFormData = formData;

return <TextInput source="bye" {...rest} />;
return <TextInput source="bye" />;
}}
</FormDataConsumer>
</SimpleForm>
Expand All @@ -87,10 +61,8 @@ describe('FormDataConsumerView', () => {
<SimpleForm>
<BooleanInput source="hi" defaultValue />
<FormDataConsumer>
{({ formData, ...rest }) =>
!formData.hi ? (
<TextInput source="bye" {...rest} />
) : null
{({ formData }) =>
!formData.hi ? <TextInput source="bye" /> : null
}
</FormDataConsumer>
</SimpleForm>
Expand Down Expand Up @@ -121,19 +93,11 @@ describe('FormDataConsumerView', () => {
<SimpleFormIterator>
<TextInput source="name" />
<FormDataConsumer>
{({
formData,
scopedFormData,
getSource,
...rest
}) => {
{({ scopedFormData }) => {
globalScopedFormData = scopedFormData;
return scopedFormData &&
scopedFormData.name ? (
<TextInput
source={getSource('role')}
{...rest}
/>
<TextInput source="role" />
) : null;
}}
</FormDataConsumer>
Expand Down
Loading

0 comments on commit 7f010a0

Please sign in to comment.