Skip to content

Commit

Permalink
Merge pull request #9620 from marmelab/source-context-fields
Browse files Browse the repository at this point in the history
Leverage SourceContext in fields
  • Loading branch information
slax57 authored Feb 26, 2024
2 parents e4b7c34 + 2c25397 commit f568a02
Show file tree
Hide file tree
Showing 38 changed files with 428 additions and 161 deletions.
19 changes: 11 additions & 8 deletions docs/Fields.md
Original file line number Diff line number Diff line change
Expand Up @@ -501,22 +501,25 @@ export const UserList = () => (

**Tip**: In such custom fields, the `source` is optional. React-admin uses it to determine which column to use for sorting when the column header is clicked. In case you use the `source` property for additional purposes, the sorting can be overridden by the `sortBy` property on any `Field` component.

If you build a reusable field accepting a `source` props, you will probably want to support deep field sources (e.g. source values like `author.name`). Use [lodash/get](https://www.npmjs.com/package/lodash.get) to replace the simple object lookup. For instance, for a Text field:
If you build a reusable field accepting a `source` props, you will probably want to support deep field sources (e.g. source values like `author.name`). Use the [`useFieldValue` hook](/useFieldValue.md) to replace the simple object lookup. For instance, for a Text field:

```diff
import * as React from 'react';
+import get from 'lodash/get';
import { useRecordContext } from 'react-admin';

const TextField = ({ source }) => {
const record = useRecordContext();
- return record ? <span>{record[source]}</span> : null;
+ return record ? <span>{get(record, source)}</span> : null;
-import { useRecordContext } from 'react-admin';
+import { useFieldValue } from 'react-admin';

const TextField = (props) => {
- const record = useRecordContext();
+ const value = useFieldValue(props);
- return record ? <span>{record[props.source]}</span> : null;
+ return <span>{value}</span> : null;
}

export default TextField;
```

**Tip**: Note that when using `useFieldValue`, you don't need to check that `record` is defined.

## Hiding A Field Based On The Value Of Another

In a Show view, you may want to display or hide fields based on the value of another field - for instance, show an `email` field only if the `hasEmail` boolean field is `true`.
Expand Down
3 changes: 3 additions & 0 deletions docs/Reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,9 @@ title: "Index"
* [`useEditContext`](./useEditContext.md)
* [`useEditController`](./useEditController.md)

**- F -**
* [`useFieldValue`](./useFieldValue.md)

**- G -**
* [`useGetIdentity`](./useGetIdentity.md)
* [`useGetList`](./useGetList.md)
Expand Down
22 changes: 22 additions & 0 deletions docs/Upgrade.md
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,28 @@ const PostEdit = () => (
);
```

## Fields Components Requires The `source` Prop

The `FieldProps` interface now requires the `source` prop to be defined. As a consequence, all the default fields components also require the `source` prop to be defined.
This impacts custom fields that typed their props with the `FieldProps` interface. If your custom field is not meant to be used in a `<Datagrid>`, you may declare the `source` prop optional:

```diff
import { FieldProps, useRecordContext } from 'react-admin';

-const AvatarField = (props: FieldProps) => {
+const AvatarField = (props: Omit<FieldProps, 'source'>) => {
const record = useRecordContext();
if (!record) return null;
return (
<Avatar
src={record.avatar}
alt={`${record.first_name} ${record.last_name}`}
{...props}
/>
);
}
```

## 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.
1 change: 1 addition & 0 deletions docs/navigation.html
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@
<li {% if page.path == 'TranslatableFields.md' %} class="active" {% endif %}><a class="nav-link" href="./TranslatableFields.html"><code>&lt;TranslatableFields&gt;</code></a></li>
<li {% if page.path == 'UrlField.md' %} class="active" {% endif %}><a class="nav-link" href="./UrlField.html"><code>&lt;UrlField&gt;</code></a></li>
<li {% if page.path == 'WrapperField.md' %} class="active" {% endif %}><a class="nav-link" href="./WrapperField.html"><code>&lt;WrapperField&gt;</code></a></li>
<li {% if page.path == 'useFieldValue.md' %} class="active" {% endif %}><a class="nav-link" href="./useFieldValue.html"><code>useFieldValue</code></a></li>
</ul>

<ul><div>Inputs</div>
Expand Down
83 changes: 83 additions & 0 deletions docs/useFieldValue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
---
layout: default
title: "useFieldValue"
---

# `useFieldValue`

A hook that gets the value of a field of the current record. It gets the current record from the context or use the one provided as a prop. It supports deep sources such as `name.fr`.

## Usage

Here is an example `TextField` component:

```tsx
// In TextField.tsx
import * as React from 'react';
import { useFieldValue, type FieldProps } from 'react-admin';

export const TextField = (props: FieldProps) => {
const value = useFieldValue(props);
return <span>{value}</span>;
}

// In PostShow.tsx
import { Show, SimpleShowLayout } from 'react-admin';
import { TextField } from './TextField.tsx';

export const PostShow = () => (
<Show>
<SimpleShowLayout>
<TextField source="author.name" label="Author" />
</SimpleShowLayout>
</Show>
);
```

## Params

### `source`

The name of the property on the record object that contains the value to display. Can be a deep path.

```tsx
import * as React from 'react';
import { useFieldValue } from 'react-admin';

export const CustomerCard = () => {
const firstName = useFieldValue({ source: 'firstName' });
const lastName = useFieldValue({ source: 'lastName' });
return <span>{lastName} {firstName}</span>;
}
```

### `record`

The record from which to read the value. Read from the `RecordContext` by default.


```tsx
import * as React from 'react';
import { useFieldValue, useGetOne } from 'react-admin';

export const CustomerCard = ({ id }: { id: string }) => {
const { data } = useGetOne('customer', { id });
const firstName = useFieldValue({ source: 'firstName', record: data });
const lastName = useFieldValue({ source: 'lastName', record: data });
return <span>{lastName} {firstName}</span>;
}
```

### `defaultValue`

The value to return when the record does not have a value for the specified `source`.

```tsx
import * as React from 'react';
import { useFieldValue } from 'react-admin';

export const CustomerStatus = () => {
const status = useFieldValue({ source: 'status', defaultValue: 'active' });
return <span>{status}</span>;
}
```
2 changes: 1 addition & 1 deletion examples/demo/src/visitors/AvatarField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Avatar, SxProps } from '@mui/material';
import { FieldProps, useRecordContext } from 'react-admin';
import { Customer } from '../types';

interface Props extends FieldProps<Customer> {
interface Props extends Omit<FieldProps<Customer>, 'source'> {
sx?: SxProps;
size?: string;
}
Expand Down
2 changes: 1 addition & 1 deletion examples/demo/src/visitors/FullNameField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { FieldProps, useRecordContext } from 'react-admin';
import AvatarField from './AvatarField';
import { Customer } from '../types';

interface Props extends FieldProps<Customer> {
interface Props extends Omit<FieldProps<Customer>, 'source'> {
size?: string;
sx?: SxProps;
}
Expand Down
2 changes: 1 addition & 1 deletion examples/demo/src/visitors/SegmentsField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const segmentsById = segments.reduce((acc, segment) => {
return acc;
}, {} as { [key: string]: any });

const SegmentsField = (_: FieldProps) => {
const SegmentsField = (_: Omit<FieldProps, 'source'> & { source?: string }) => {
const translate = useTranslate();
const record = useRecordContext<Customer>();
if (!record || !record.groups) {
Expand Down
1 change: 1 addition & 0 deletions packages/ra-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"clsx": "^1.1.1",
"date-fns": "^2.19.0",
"eventemitter3": "^4.0.7",
"hotscript": "^1.0.12",
"inflection": "~1.12.0",
"jsonexport": "^3.2.0",
"lodash": "~4.17.5",
Expand Down
4 changes: 0 additions & 4 deletions packages/ra-core/src/i18n/TranslatableContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,9 @@ export const TranslatableContext = createContext<
>(undefined);

export interface TranslatableContextValue {
getLabel: GetTranslatableLabel;
getSource: GetTranslatableSource;
locales: string[];
selectedLocale: string;
selectLocale: SelectTranslatableLocale;
}

export type GetTranslatableSource = (field: string, locale?: string) => string;
export type GetTranslatableLabel = (field: string, label?: string) => string;
export type SelectTranslatableLocale = (locale: string) => void;
12 changes: 1 addition & 11 deletions packages/ra-core/src/i18n/useTranslatable.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { useState, useMemo } from 'react';
import { useResourceContext } from '../core';
import { TranslatableContextValue } from './TranslatableContext';
import { useLocaleState } from './useLocaleState';
import { useTranslateLabel } from './useTranslateLabel';

/**
* Hook supplying the logic to translate a field value in multiple languages.
Expand All @@ -25,22 +23,14 @@ export const useTranslatable = (
const [localeFromUI] = useLocaleState();
const { defaultLocale = localeFromUI, locales } = options;
const [selectedLocale, setSelectedLocale] = useState(defaultLocale);
const resource = useResourceContext({});
const translateLabel = useTranslateLabel();

const context = useMemo<TranslatableContextValue>(
() => ({
// 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,
selectedLocale,
selectLocale: setSelectedLocale,
}),
[locales, resource, selectedLocale, translateLabel]
[locales, selectedLocale]
);

return context;
Expand Down
1 change: 0 additions & 1 deletion packages/ra-core/src/util/getFieldLabelTranslationArgs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ export const getFieldLabelTranslationArgs = (
options?: Args
): TranslationArguments => {
if (!options) return [''];

const {
label,
defaultLabel,
Expand Down
1 change: 1 addition & 0 deletions packages/ra-core/src/util/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { getMutationMode } from './getMutationMode';
export * from './getFieldLabelTranslationArgs';
export * from './mergeRefs';
export * from './useEvent';
export * from './useFieldValue';

export {
escapePath,
Expand Down
92 changes: 92 additions & 0 deletions packages/ra-core/src/util/useFieldValue.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
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) => {
return <div>{useFieldValue(props) ?? 'None'}</div>;
};

it('should return undefined if no record is available', async () => {
render(<Component source="name" />);

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

it('should return the provided defaultValue if no record is available', async () => {
render(<Component source="name" defaultValue="Molly Millions" />);

await screen.findByText('Molly Millions');
});

it('should return the provided defaultValue if the record does not have a value for the source', async () => {
render(
<RecordContextProvider value={{ id: 123 }}>
<Component source="name" defaultValue="Peter Riviera" />
</RecordContextProvider>
);

await screen.findByText('Peter Riviera');
});

it('should return the field value from the record in RecordContext', async () => {
render(
<RecordContextProvider value={{ name: 'John Wick' }}>
<Component source="name" />
</RecordContextProvider>
);

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

it('should return the field value from the record in props', async () => {
render(
<RecordContextProvider value={{ id: 2, name: 'John Wick' }}>
<Component
source="name"
record={{ id: 1, name: 'Johnny Silverhand' }}
/>
</RecordContextProvider>
);

await screen.findByText('Johnny Silverhand');
});

it('should return the field value from a deep path', async () => {
render(
<RecordContextProvider
value={{ id: 2, name: { firstName: 'John', lastName: 'Wick' } }}
>
<Component source="name.firstName" />
</RecordContextProvider>
);

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');
});
});
Loading

0 comments on commit f568a02

Please sign in to comment.