Skip to content

Commit

Permalink
Merge pull request #9911 from marmelab/use-source-prefix-context-in-a…
Browse files Browse the repository at this point in the history
…rray-input-and-simple-form-iterator

`<ArrayInput>` uses `SourceContext` instead of cloning children
  • Loading branch information
fzaninotto authored Jun 12, 2024
2 parents e2629df + 6ceaf4c commit 998edc7
Show file tree
Hide file tree
Showing 22 changed files with 676 additions and 230 deletions.
20 changes: 16 additions & 4 deletions packages/ra-core/src/core/SourceContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ export type SourceContextValue =
| undefined;

/**
* Context that provides a function that accept a source and return a modified source (prefixed, suffixed, etc.) for fields and inputs.
* Context that provides a function that accept a source and return getters for the modified source and label.
*
* This allows some special inputs to prefix or suffix the source of their children.
*
* @example
* const sourceContext = {
Expand All @@ -23,15 +25,25 @@ export type SourceContextValue =
* }
* const CoordinatesInput = () => {
* return (
* <SouceContextProvider value={sourceContext}>
* <SourceContextProvider value={sourceContext}>
* <TextInput source="lat" />
* <TextInput source="lng" />
* </SouceContextProvider>
* </SourceContextProvider>
* );
* };
*/
export const SourceContext = createContext<SourceContextValue>(undefined);

export const SourceContextProvider = SourceContext.Provider;

export const useSourceContext = () => useContext(SourceContext);
export const useSourceContext = () => {
const context = useContext(SourceContext);

if (!context) {
throw new Error('Inputs must be used inside a react-admin Form');
}

return context;
};

export const useOptionalSourceContext = () => useContext(SourceContext);
2 changes: 1 addition & 1 deletion packages/ra-core/src/core/useWrappedSource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@ import { useSourceContext } from './SourceContext';
*/
export const useWrappedSource = (source: string) => {
const sourceContext = useSourceContext();
return sourceContext?.getSource(source) ?? source;
return sourceContext.getSource(source);
};
18 changes: 10 additions & 8 deletions packages/ra-core/src/form/FormDataConsumer.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,23 @@ import {
ArrayInput,
} from 'ra-ui-materialui';
import expect from 'expect';
import { ResourceContextProvider } from '..';
import { Form, ResourceContextProvider } from '..';

describe('FormDataConsumerView', () => {
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' };

render(
<FormDataConsumerView
form="a-form"
formData={formData}
source="a-field"
>
{children}
</FormDataConsumerView>
<Form>
<FormDataConsumerView
form="a-form"
formData={formData}
source="a-field"
>
{children}
</FormDataConsumerView>
</Form>
);

expect(children).toHaveBeenCalledWith({
Expand Down
2 changes: 1 addition & 1 deletion packages/ra-core/src/i18n/TestTranslationProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const testI18nProvider = ({
}: {
translate?: I18nProvider['translate'];
messages?: Record<string, string | ((options?: any) => string)>;
}): I18nProvider => {
} = {}): I18nProvider => {
return {
translate: messages
? (key, options) => {
Expand Down
10 changes: 10 additions & 0 deletions packages/ra-core/src/i18n/TranslatableContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ export interface TranslatableContextValue {
locales: string[];
selectedLocale: string;
selectLocale: SelectTranslatableLocale;
getRecordForLocale: GetRecordForLocale;
}

export type SelectTranslatableLocale = (locale: string) => void;

/**
* Returns a record where translatable fields have their values set to the value of the given locale.
* This is necessary because the fields rely on the RecordContext to get their values and have no knowledge of the locale.
*
* Given the record { title: { en: 'title_en', fr: 'title_fr' } } and the locale 'fr',
* the record for the locale 'fr' will be { title: 'title_fr' }
*/
export type GetRecordForLocale = (record: any, locale: string) => any;
40 changes: 40 additions & 0 deletions packages/ra-core/src/i18n/useTranslatable.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { getRecordForLocale } from './useTranslatable';
describe('useTranslatable', () => {
describe('getRecordForLocale', () => {
it('should return a record where translatable fields have their values set to the value of the given locale', () => {
// Given the record { title: { en: 'title_en', fr: 'title_fr' } } and the locale 'fr',
// the record for the locale 'fr' will be { title: 'title_fr' }
const record = {
fractal: true,
title: { en: 'title_en', fr: 'title_fr' },
items: [
{ description: { en: 'item1_en', fr: 'item1_fr' } },
{ description: { en: 'item2_en', fr: 'item2_fr' } },
],
};

const recordForLocale = getRecordForLocale(record, 'fr');

expect(recordForLocale).toEqual({
fractal: true,
title: 'title_fr',
items: [
{ description: 'item1_fr' },
{ description: 'item2_fr' },
],
});
});

it('should return the record as is if it is empty', () => {
const record = {};
const recordForLocale = getRecordForLocale(record, 'fr');
expect(recordForLocale).toEqual({});
});

it('should return the record as is if it is undefined', () => {
const record = undefined;
const recordForLocale = getRecordForLocale(record, 'fr');
expect(recordForLocale).toEqual(undefined);
});
});
});
80 changes: 80 additions & 0 deletions packages/ra-core/src/i18n/useTranslatable.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { useState, useMemo } from 'react';
import set from 'lodash/set';
import get from 'lodash/get';
import cloneDeep from 'lodash/cloneDeep';
import { TranslatableContextValue } from './TranslatableContext';
import { useLocaleState } from './useLocaleState';

Expand Down Expand Up @@ -29,6 +32,7 @@ export const useTranslatable = (
locales,
selectedLocale,
selectLocale: setSelectedLocale,
getRecordForLocale,
}),
[locales, selectedLocale]
);
Expand All @@ -40,3 +44,79 @@ export type UseTranslatableOptions = {
defaultLocale?: string;
locales: string[];
};

/**
* Returns a record where translatable fields have their values set to the value of the given locale.
* This is necessary because the fields rely on the RecordContext to get their values and have no knowledge of the locale.
*
* Given the record { title: { en: 'title_en', fr: 'title_fr' } } and the locale 'fr',
* the record for the locale 'fr' will be { title: 'title_fr' }
*/
export const getRecordForLocale = (record: {} | undefined, locale: string) => {
if (!record) {
return record;
}
// Get all paths of the record
const paths = getRecordPaths(record);

// For each path, if a path ends with the locale, set the value of the path without the locale
// to the value of the path with the locale
const recordForLocale = paths.reduce((acc, path) => {
if (path.includes(locale)) {
const pathWithoutLocale = path.slice(0, -1);
const value = get(record, path);
return set(acc, pathWithoutLocale, value);
}
return acc;
}, cloneDeep(record));

return recordForLocale;
};

// Return all the possible paths of the record as an array of arrays
// For example, given the record
// {
// title: { en: 'title_en', fr: 'title_fr' },
// items: [
// { description: { en: 'item1_en', fr: 'item1_fr' } },
// { description: { en: 'item2_en', fr: 'item2_fr' } }
// ]
// },
// the paths will be
// [
// ['title'],
// ['title', 'en'],
// ['title', 'fr'],
// ['items'],
// ['items', '0'],
// ['items', '0', 'description'],
// ['items', '0', 'description', 'en'],
// ['items', '0', 'description', 'fr'],
// ['items', '1'],
// ['items', '1', 'description'],
// ['items', '1', 'description', 'en'],
// ['items', '1', 'description', 'fr']]
const getRecordPaths = (
record: any = {},
path: Array<string> = []
): Array<Array<string>> => {
return Object.entries(record).reduce((acc, [key, value]) => {
if (typeof value === 'object') {
return [
...acc,
[...path, key],
...getRecordPaths(value, [...path, key]),
];
}
if (Array.isArray(value)) {
return value.reduce(
(acc, item, index) => [
...acc,
...getRecordPaths(item, [...path, key, `${index}`]),
],
acc
);
}
return [...acc, [...path, key]];
}, []);
};
4 changes: 2 additions & 2 deletions packages/ra-core/src/i18n/useTranslateLabel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import { useCallback, ReactElement } from 'react';

import { useTranslate } from './useTranslate';
import { getFieldLabelTranslationArgs } from '../util';
import { useResourceContext, useSourceContext } from '../core';
import { useResourceContext, useOptionalSourceContext } from '../core';

export const useTranslateLabel = () => {
const translate = useTranslate();
const resourceFromContext = useResourceContext();
const sourceContext = useSourceContext();
const sourceContext = useOptionalSourceContext();

return useCallback(
({
Expand Down
25 changes: 0 additions & 25 deletions packages/ra-core/src/util/useFieldValue.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import * as React from 'react';
import { render, screen } from '@testing-library/react';
import { useFieldValue, UseFieldValueOptions } from './useFieldValue';
import { RecordContextProvider } from '../controller';
import { SourceContextProvider } from '..';

describe('useFieldValue', () => {
const Component = (props: UseFieldValueOptions) => {
Expand Down Expand Up @@ -65,28 +64,4 @@ describe('useFieldValue', () => {

await screen.findByText('John');
});

it('should return the field value from the record inside a SourceContext', async () => {
render(
<RecordContextProvider
value={{
id: 2,
name: { fr: 'Neuromancien', en: 'Neuromancer' },
}}
>
<SourceContextProvider
value={{
getSource(source) {
return `${source}.fr`;
},
getLabel: source => source,
}}
>
<Component source="name" />
</SourceContextProvider>
</RecordContextProvider>
);

await screen.findByText('Neuromancien');
});
});
14 changes: 7 additions & 7 deletions packages/ra-core/src/util/useFieldValue.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import get from 'lodash/get';
import { Call, Objects } from 'hotscript';
import { useRecordContext } from '../controller';
import { useSourceContext } from '../core';

/**
* A hook that gets the value of a field of the current record.
Expand All @@ -23,14 +22,15 @@ export const useFieldValue = <
params: UseFieldValueOptions<RecordType>
) => {
const { defaultValue, source } = params;
const sourceContext = useSourceContext();
// We use the record from the RecordContext and do not rely on the SourceContext on purpose to
// avoid having the wrong source targeting the record.
// Indeed, some components may create a sub record context (SimpleFormIterator, TranslatableInputs, etc.). In this case,
// it they used the SourceContext as well, they would have the wrong source.
// Inputs needs the SourceContext as they rely on the Form value and you can't have nested forms.
// Fields needs the RecordContext as they rely on the Record value and you can have nested RecordContext.
const record = useRecordContext<RecordType>(params);

return get(
record,
sourceContext?.getSource(source) ?? source,
defaultValue
);
return get(record, source, defaultValue);
};

export interface UseFieldValueOptions<
Expand Down
9 changes: 3 additions & 6 deletions packages/ra-ui-materialui/src/field/ArrayField.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import * as React from 'react';
import { ReactNode } from 'react';
import get from 'lodash/get';
import {
ListContextProvider,
useRecordContext,
useList,
SortPayload,
FilterPayload,
RaRecord,
useFieldValue,
} from 'ra-core';

import { FieldProps } from './types';
Expand Down Expand Up @@ -81,9 +79,8 @@ const ArrayFieldImpl = <
>(
props: ArrayFieldProps<RecordType>
) => {
const { children, resource, source, perPage, sort, filter } = props;
const record = useRecordContext(props);
const data = (get(record, source, emptyArray) as RaRecord[]) || emptyArray;
const { children, resource, perPage, sort, filter } = props;
const data = useFieldValue(props) || emptyArray;
const listContext = useList({ data, resource, perPage, sort, filter });
return (
<ListContextProvider value={listContext}>
Expand Down
5 changes: 3 additions & 2 deletions packages/ra-ui-materialui/src/field/TranslatableFields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export const TranslatableFields = (
selector = <TranslatableFieldsTabs groupKey={groupKey} />,
children,
className,
resource: resourceProp,
} = props;
const record = useRecordContext(props);
if (!record) {
Expand All @@ -86,7 +87,7 @@ export const TranslatableFields = (
const resource = useResourceContext(props);
if (!resource) {
throw new Error(
`<TranslatableFields> was called outside of a RecordContext and without a record prop. You must set the record prop.`
`<TranslatableFields> was called outside of a ResourceContext and without a record prop. You must set the resource prop.`
);
}
const context = useTranslatable({ defaultLocale, locales });
Expand All @@ -100,7 +101,7 @@ export const TranslatableFields = (
key={locale}
locale={locale}
record={record}
resource={resource}
resource={resourceProp}
groupKey={groupKey}
>
{children}
Expand Down
Loading

0 comments on commit 998edc7

Please sign in to comment.