Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce SourceContext #9533

Merged
merged 28 commits into from
Jan 12, 2024
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
afd756d
Introduce SourcePrefixContext
djhi Dec 18, 2023
8b09539
Update upgrade guide
djhi Dec 18, 2023
f782ab8
Refactor to handle TranslatableInput
djhi Dec 20, 2023
7396578
Apply suggestions from code review
djhi Dec 20, 2023
e00521c
Apply suggestions from code review
djhi Dec 20, 2023
c954747
Apply suggestions from code review
djhi Dec 20, 2023
7d14f1d
Linting
djhi Dec 20, 2023
6dd99fc
Rewriting
fzaninotto Jan 3, 2024
4484f60
Add an upgrade guide section about FormDataConsumer
djhi Jan 4, 2024
f56bb8c
Revert FieldTitle unnecessary change
djhi Jan 4, 2024
2ad60b3
Rename SourceContext file and extract useWrappedSource
djhi Jan 4, 2024
cfaf018
Revert breaking changes for labels
djhi Jan 4, 2024
04ddc0e
Make test title clearer
djhi Jan 4, 2024
ba8ffca
Apply reviews suggestions
djhi Jan 5, 2024
6244870
Refactor to handle labels correctly
djhi Jan 5, 2024
35c6d9b
Refactor to centralize label logic
djhi Jan 5, 2024
59bc45f
Restore removed test
djhi Jan 8, 2024
d9c9fb8
Apply review suggestion
djhi Jan 8, 2024
005da20
Apply suggestions from code review
djhi Jan 8, 2024
e602813
Reintroduce useWrappedSource
djhi Jan 8, 2024
b1f8ce1
Compute TranslatableInputs label translationKey in place
djhi Jan 8, 2024
8c1d5b7
Rename variables
djhi Jan 8, 2024
cc0e461
Replace LabelPrefix with SourceContext
djhi Jan 9, 2024
6d69112
Simplify TranslatableInputsTabContent getLabel
djhi Jan 11, 2024
84c71d8
Handle scalar array inputs
djhi Jan 11, 2024
5496cca
Apply suggestions from code review
djhi Jan 11, 2024
50258d5
Update documentation to remove passing rest parameters
djhi Jan 11, 2024
3894f03
Better ArrayInput i18n story
djhi Jan 12, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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}>
fzaninotto marked this conversation as resolved.
Show resolved Hide resolved
<SimpleFormIterator>
- <TextInput source="lastName" />
- <TextInput source="firstName" />
+ <TextInput source="lastName" disabled={someCondition} />
+ <TextInput source="firstName" disabled={someCondition} />
</SimpleFormIterator>
</ArrayInput>
```

slax57 marked this conversation as resolved.
Show resolved Hide resolved
## `<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;
slax57 marked this conversation as resolved.
Show resolved Hide resolved
};

/**
* 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
Loading