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

Leverage SourceContext in fields #9620

Merged
merged 14 commits into from
Feb 26, 2024
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();
fzaninotto marked this conversation as resolved.
Show resolved Hide resolved
- 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;
}

fzaninotto marked this conversation as resolved.
Show resolved Hide resolved
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 @@ -236,6 +236,9 @@ title: "Index"
* [`useEditContext`](./useEditContext.md)
* [`useEditController`](./useEditController.md)

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

**- G -**
* [`useGetIdentity`](./useGetIdentity.md)
* [`useGetList`](./useGetList.md)
Expand Down
1 change: 1 addition & 0 deletions docs/navigation.html
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,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>
);
```

fzaninotto marked this conversation as resolved.
Show resolved Hide resolved
## Params

### `source`

The name of the property on the record object that contains the value to display. Can be a deep path.
fzaninotto marked this conversation as resolved.
Show resolved Hide resolved

```tsx
import * as React from 'react';
+import { useFieldValue } from 'react-admin';
djhi marked this conversation as resolved.
Show resolved Hide resolved

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.
fzaninotto marked this conversation as resolved.
Show resolved Hide resolved


```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;
fzaninotto marked this conversation as resolved.
Show resolved Hide resolved
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 () => {
fzaninotto marked this conversation as resolved.
Show resolved Hide resolved
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' }}
slax57 marked this conversation as resolved.
Show resolved Hide resolved
/>
</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');
});
});
slax57 marked this conversation as resolved.
Show resolved Hide resolved
47 changes: 47 additions & 0 deletions packages/ra-core/src/util/useFieldValue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
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.
* @param params The hook parameters
* @param params.source The field source
* @param params.record The record to use. Uses the record from the RecordContext if not provided
* @param params.defaultValue The value to return when the field value is empty
* @returns The field value
*
* @example
* const MyField = (props: { source: string }) => {
* const value = useFieldValue(props);
* return <span>{value}</span>;
* }
*/
export const useFieldValue = <
RecordType extends Record<string, any> = Record<string, any>
>(
params: UseFieldValueOptions<RecordType>
) => {
const { defaultValue, source } = params;
const sourceContext = useSourceContext();
const record = useRecordContext<RecordType>(params);

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

export interface UseFieldValueOptions<
RecordType extends Record<string, any> = Record<string, any>
> {
// FIXME: Find a way to throw a type error when defaultValue is not of RecordType[Source] type
defaultValue?: any;
source: Call<Objects.AllPaths, RecordType> extends never
? AnyString
: Call<Objects.AllPaths, RecordType>;
record?: RecordType;
}
fzaninotto marked this conversation as resolved.
Show resolved Hide resolved

type AnyString = string & {};
Loading
Loading