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

Add ability to use record from context in Field components #5995

Merged
merged 5 commits into from
Mar 4, 2021
Merged
Changes from all commits
Commits
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
17 changes: 10 additions & 7 deletions docs/Fields.md
Original file line number Diff line number Diff line change
@@ -25,17 +25,20 @@ export const PostList = (props) => (
);
```

`Field` components need a `record` and a `source` prop to work, and basically display the `record[source]` data. There is nothing magic there - you can easily write your own:
`Field` components read the current `record` from the current `RecordContext` (set by react-admin). There is nothing magic there - you can easily write your own:

{% raw %}
```jsx
const PurpleTextField = ({ record, source }) => (
<span style={{ color: 'purple' }}>{record[source]}</span>
);
import { useRecordContext } from 'react-admin';

const PurpleTextField = ({ source }) => {
const record = useRecordContext();
return (<span style={{ color: 'purple' }}>{record && record[source]}</span>);
};
```
{% endraw %}

Some react-admin components (e.g. `<Datagrid>` or `<SimpleShowLayout>`) clone their children and pass them a `record` value. That's why most of the time, you don't have to pass the `record` manually. But you can totally render a `Field` component by passing it a `record` value ; in fact, it's a great way to understand how `Field` components work:
React-admin Field components also accept a `record` prop. This allows you to use them outside of a `RecordContext`, or to use another `record` than the one in the current context.

```jsx
// a post looks like
@@ -57,11 +60,11 @@ const PostShow = ({ id }) => {

## Common Field Props

All field components accept the following props:
All Field components accept the following props:

| Prop | Required | Type | Default | Description |
| ----------------- | -------- | ------------------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `record` | Required | `Object` | - | Object containing the properties to display. `<Datagrid>`, `<SimpleForm>` and other components inject that prop to their children |
| `record` | Optional | `Object` | - | Object containing the properties to display, to override the record from the current `RecordContext` |
| `source` | Required | `string` | - | Name of the property to display |
| `label` | Optional | `string` &#124; `ReactElement` | `source` | Used as a table header or an input label |
| `sortable` | Optional | `boolean` | `true` | When used in a `List`, should the list be sortable using the `source` attribute? Setting it to `false` disables the click handler on the column header. |
62 changes: 36 additions & 26 deletions packages/ra-core/src/controller/RecordContext.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import * as React from 'react';
import { createContext, ReactNode, useContext, useMemo } from 'react';
import pick from 'lodash/pick';
import { createContext, ReactNode, useContext } from 'react';
import { Record } from '../types';

/**
@@ -13,7 +12,7 @@ import { Record } from '../types';
* import { useEditController, EditContext } from 'ra-core';
*
* const Edit = props => {
* const { record }= useEditController(props);
* const { record } = useEditController(props);
* return (
* <RecordContextProvider value={record}>
* ...
@@ -25,22 +24,21 @@ export const RecordContext = createContext<Record | Omit<Record, 'id'>>(
undefined
);

export const RecordContextProvider = ({
export const RecordContextProvider = <
RecordType extends Record | Omit<Record, 'id'> = Record
>({
children,
value,
}: RecordContextOptions) => (
}: RecordContextOptions<RecordType>) => (
<RecordContext.Provider value={value}>{children}</RecordContext.Provider>
);

RecordContext.displayName = 'RecordContext';

export const usePickRecordContext = <
RecordType extends Record | Omit<Record, 'id'> = Record
>(
context: RecordType
) => {
const value = useMemo(() => pick(context, ['record']), [context.record]); // eslint-disable-line
return value;
};
export interface RecordContextOptions<RecordType> {
children: ReactNode;
value?: RecordType;
}

/**
* Hook to read the record from a RecordContext.
@@ -49,30 +47,42 @@ export const usePickRecordContext = <
* (e.g. as a descendent of <Edit> or <EditBase>) or within a <ShowContextProvider>
* (e.g. as a descendent of <Show> or <ShowBase>)
*
* @returns {Record} The record context
* @example // basic usage
*
* import { useRecordContext } from 'ra-core';
*
* const TitleField = () => {
* const record = useRecordContext();
* return <span>{record && record.title}</span>;
* };
*
* @example // allow record override via props
*
* import { useRecordContext } from 'ra-core';
*
* const TitleField = (props) => {
* const record = useRecordContext(props);
* return <span>{record && record.title}</span>;
* };
* render(<TextField record={record} />);
*
* @returns {Record} A record object
*/
export const useRecordContext = <
RecordType extends Record | Omit<Record, 'id'> = Record
>(
props: RecordType
props: UseRecordContextParams<RecordType>
): RecordType => {
// Can't find a way to specify the RecordType when CreateContext is declared
// @ts-ignore
const context = useContext<RecordType>(RecordContext);

if (!context) {
// As the record could very well be undefined because not yet loaded
// We don't display a deprecation warning yet
// @deprecated - to be removed in 4.0
return props;
}

return context;
return (props && props.record) || context;
};

export interface RecordContextOptions<
export interface UseRecordContextParams<
RecordType extends Record | Omit<Record, 'id'> = Record
> {
children: ReactNode;
value?: RecordType;
record?: RecordType;
[key: string]: any;
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import * as React from 'react';
import { ReactElement } from 'react';
import { RecordContextProvider, usePickRecordContext } from '../RecordContext';

import { RecordContextProvider } from '../RecordContext';
import { CreateContext } from './CreateContext';
import { CreateControllerProps } from './useCreateController';
import { SaveContextProvider, usePickSaveContext } from './SaveContext';
import { Record } from '../../types';

/**
* Create a Create Context.
@@ -20,7 +22,7 @@ import { SaveContextProvider, usePickSaveContext } from './SaveContext';
* };
*
* const MyCreateView = () => {
* const { record } = useRecordContext();
* const record = useRecordContext();
* // or, to rerender only when the save operation change but not data
* const { saving } = useCreateContext();
* }
@@ -37,7 +39,9 @@ export const CreateContextProvider = ({
}) => (
<CreateContext.Provider value={value}>
<SaveContextProvider value={usePickSaveContext(value)}>
<RecordContextProvider value={usePickRecordContext(value)}>
<RecordContextProvider<Partial<Record>>
value={value && value.record}
>
{children}
</RecordContextProvider>
</SaveContextProvider>
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as React from 'react';
import { ReactElement } from 'react';
import { RecordContextProvider, usePickRecordContext } from '../RecordContext';
import { RecordContextProvider } from '../RecordContext';
import { EditContext } from './EditContext';
import { EditControllerProps } from './useEditController';
import { SaveContextProvider, usePickSaveContext } from './SaveContext';
@@ -20,7 +20,7 @@ import { SaveContextProvider, usePickSaveContext } from './SaveContext';
* };
*
* const MyEditView = () => {
* const { record } = useRecordContext();
* const record = useRecordContext();
* // or, to rerender only when the save operation change but not data
* const { saving } = useEditContext();
* }
@@ -37,7 +37,7 @@ export const EditContextProvider = ({
}) => (
<EditContext.Provider value={value}>
<SaveContextProvider value={usePickSaveContext(value)}>
<RecordContextProvider value={usePickRecordContext(value)}>
<RecordContextProvider value={value && value.record}>
{children}
</RecordContextProvider>
</SaveContextProvider>
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as React from 'react';
import { ReactElement } from 'react';
import { RecordContextProvider, usePickRecordContext } from '../RecordContext';
import { RecordContextProvider } from '../RecordContext';
import { ShowContext } from './ShowContext';
import { ShowControllerProps } from './useShowController';

@@ -19,7 +19,7 @@ import { ShowControllerProps } from './useShowController';
* };
*
* const MyShowView = () => {
* const { record } = useRecordContext();
* const record = useRecordContext();
* }
*
* @see ShowContext
@@ -33,7 +33,7 @@ export const ShowContextProvider = ({
value: ShowControllerProps;
}) => (
<ShowContext.Provider value={value}>
<RecordContextProvider value={usePickRecordContext(value)}>
<RecordContextProvider value={value && value.record}>
{children}
</RecordContextProvider>
</ShowContext.Provider>
106 changes: 53 additions & 53 deletions packages/ra-ui-materialui/src/field/ArrayField.tsx
Original file line number Diff line number Diff line change
@@ -9,7 +9,7 @@ import {
ReactElement,
} from 'react';
import get from 'lodash/get';
import { Identifier, ListContextProvider } from 'ra-core';
import { Identifier, ListContextProvider, useRecordContext } from 'ra-core';

import { PublicFieldProps, InjectedFieldProps, fieldPropTypes } from './types';
import PropTypes from 'prop-types';
@@ -118,68 +118,68 @@ const getDataAndIds = (
* );
* TagsField.defaultProps = { addLabel: true };
*/
export const ArrayField: FC<ArrayFieldProps> = memo<ArrayFieldProps>(
({
export const ArrayField: FC<ArrayFieldProps> = memo<ArrayFieldProps>(props => {
const {
addLabel,
basePath,
children,
record,
record: _record,
resource,
sortable,
source,
fieldKey,
...rest
}) => {
const [ids, setIds] = useState(initialState.ids);
const [data, setData] = useState(initialState.data);
} = props;
const record = useRecordContext(props);
const [ids, setIds] = useState(initialState.ids);
const [data, setData] = useState(initialState.data);

useEffect(() => {
const { ids, data } = getDataAndIds(record, source, fieldKey);
setIds(ids);
setData(data);
}, [record, source, fieldKey]);
useEffect(() => {
const { ids, data } = getDataAndIds(record, source, fieldKey);
setIds(ids);
setData(data);
}, [record, source, fieldKey]);

return (
<ListContextProvider
value={{
ids,
data,
loading: false,
basePath,
selectedIds: [],
currentSort: { field: null, order: null },
displayedFilters: null,
filterValues: null,
hasCreate: null,
hideFilter: null,
loaded: null,
onSelect: null,
onToggleItem: null,
onUnselectItems: null,
page: null,
perPage: null,
resource,
setFilters: null,
setPage: null,
setPerPage: null,
setSort: null,
showFilter: null,
total: null,
}}
>
{cloneElement(Children.only(children), {
ids,
data,
loading: false,
basePath,
currentSort: {},
resource,
...rest,
})}
</ListContextProvider>
);
}
);
return (
<ListContextProvider
value={{
ids,
data,
loading: false,
basePath,
selectedIds: [],
currentSort: { field: null, order: null },
displayedFilters: null,
filterValues: null,
hasCreate: null,
hideFilter: null,
loaded: null,
onSelect: null,
onToggleItem: null,
onUnselectItems: null,
page: null,
perPage: null,
resource,
setFilters: null,
setPage: null,
setPerPage: null,
setSort: null,
showFilter: null,
total: null,
}}
>
{cloneElement(Children.only(children), {
ids,
data,
loading: false,
basePath,
currentSort: {},
resource,
...rest,
})}
</ListContextProvider>
);
});

ArrayField.defaultProps = {
addLabel: true,
15 changes: 15 additions & 0 deletions packages/ra-ui-materialui/src/field/BooleanField.spec.tsx
Original file line number Diff line number Diff line change
@@ -2,6 +2,7 @@ import * as React from 'react';
import expect from 'expect';
import BooleanField from './BooleanField';
import { render } from '@testing-library/react';
import { RecordContextProvider } from 'ra-core';

const defaultProps = {
record: { id: 123, published: true },
@@ -21,6 +22,20 @@ describe('<BooleanField />', () => {
expect(queryByTitle('ra.boolean.false')).toBeNull();
});

it('should use record from RecordContext', () => {
const { queryByTitle } = render(
<RecordContextProvider value={{ id: 123, published: true }}>
<BooleanField source="published" />
</RecordContextProvider>
);
expect(queryByTitle('ra.boolean.true')).not.toBeNull();
expect(
(queryByTitle('ra.boolean.true').firstChild as HTMLElement).dataset
.testid
).toBe('true');
expect(queryByTitle('ra.boolean.false')).toBeNull();
});

it('should use valueLabelTrue for custom truthy text', () => {
const { queryByTitle } = render(
<BooleanField
4 changes: 2 additions & 2 deletions packages/ra-ui-materialui/src/field/BooleanField.tsx
Original file line number Diff line number Diff line change
@@ -9,7 +9,7 @@ import ClearIcon from '@material-ui/icons/Clear';
import { Tooltip, Typography } from '@material-ui/core';
import { makeStyles } from '@material-ui/core/styles';
import { TypographyProps } from '@material-ui/core/Typography';
import { useTranslate } from 'ra-core';
import { useTranslate, useRecordContext } from 'ra-core';

import { PublicFieldProps, InjectedFieldProps, fieldPropTypes } from './types';
import sanitizeFieldRestProps from './sanitizeFieldRestProps';
@@ -32,13 +32,13 @@ export const BooleanField: FC<BooleanFieldProps> = memo<BooleanFieldProps>(
classes: classesOverride,
emptyText,
source,
record = {},
valueLabelTrue,
valueLabelFalse,
TrueIcon,
FalseIcon,
...rest
} = props;
const record = useRecordContext(props);
const translate = useTranslate();
const classes = useStyles(props);
const value = get(record, source);
10 changes: 10 additions & 0 deletions packages/ra-ui-materialui/src/field/ChipField.spec.tsx
Original file line number Diff line number Diff line change
@@ -2,6 +2,7 @@ import * as React from 'react';
import expect from 'expect';
import ChipField from './ChipField';
import { render } from '@testing-library/react';
import { RecordContextProvider } from 'ra-core';

describe('<ChipField />', () => {
it('should display the record value added as source', () => {
@@ -16,6 +17,15 @@ describe('<ChipField />', () => {
expect(getByText('foo')).not.toBeNull();
});

it('should use record from RecordContext', () => {
const { getByText } = render(
<RecordContextProvider value={{ id: 123, name: 'foo' }}>
<ChipField className="className" classes={{}} source="name" />
</RecordContextProvider>
);
expect(getByText('foo')).not.toBeNull();
});

it('should not display any label added as props', () => {
const { getByText } = render(
<ChipField
3 changes: 2 additions & 1 deletion packages/ra-ui-materialui/src/field/ChipField.tsx
Original file line number Diff line number Diff line change
@@ -5,6 +5,7 @@ import Chip, { ChipProps } from '@material-ui/core/Chip';
import Typography from '@material-ui/core/Typography';
import { makeStyles } from '@material-ui/core/styles';
import classnames from 'classnames';
import { useRecordContext } from 'ra-core';

import sanitizeFieldRestProps from './sanitizeFieldRestProps';
import { PublicFieldProps, InjectedFieldProps, fieldPropTypes } from './types';
@@ -21,10 +22,10 @@ export const ChipField: FC<ChipFieldProps> = memo<ChipFieldProps>(props => {
className,
classes: classesOverride,
source,
record = {},
emptyText,
...rest
} = props;
const record = useRecordContext(props);
const classes = useStyles(props);
const value = get(record, source);

15 changes: 15 additions & 0 deletions packages/ra-ui-materialui/src/field/DateField.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import * as React from 'react';
import expect from 'expect';
import { render } from '@testing-library/react';
import { RecordContextProvider } from 'ra-core';

import DateField from './DateField';

describe('<DateField />', () => {
@@ -29,6 +31,19 @@ describe('<DateField />', () => {
expect(queryByText(date)).not.toBeNull();
});

it('should use record from RecordContext', () => {
const { queryByText } = render(
<RecordContextProvider
value={{ id: 123, foo: new Date('2017-04-23') }}
>
<DateField source="foo" locales="en-US" />
</RecordContextProvider>
);

const date = new Date('2017-04-23').toLocaleDateString('en-US');
expect(queryByText(date)).not.toBeNull();
});

it('should render a date and time when the showtime prop is passed', () => {
const { queryByText } = render(
<DateField
68 changes: 34 additions & 34 deletions packages/ra-ui-materialui/src/field/DateField.tsx
Original file line number Diff line number Diff line change
@@ -3,6 +3,7 @@ import { FC, memo } from 'react';
import PropTypes from 'prop-types';
import get from 'lodash/get';
import Typography, { TypographyProps } from '@material-ui/core/Typography';
import { useRecordContext } from 'ra-core';

import sanitizeFieldRestProps from './sanitizeFieldRestProps';
import { PublicFieldProps, InjectedFieldProps, fieldPropTypes } from './types';
@@ -41,55 +42,54 @@ const toLocaleStringSupportsLocales = (() => {
* // renders the record { id: 1234, new Date('2012-11-07') } as
* <span>mercredi 7 novembre 2012</span>
*/
export const DateField: FC<DateFieldProps> = memo<DateFieldProps>(
({
export const DateField: FC<DateFieldProps> = memo<DateFieldProps>(props => {
const {
className,
emptyText,
locales,
options,
record,
showTime = false,
source,
...rest
}) => {
if (!record) {
return null;
}
const value = get(record, source);
if (value == null) {
return emptyText ? (
<Typography
component="span"
variant="body2"
className={className}
{...sanitizeFieldRestProps(rest)}
>
{emptyText}
</Typography>
) : null;
}

const date = value instanceof Date ? value : new Date(value);
const dateString = showTime
? toLocaleStringSupportsLocales
? date.toLocaleString(locales, options)
: date.toLocaleString()
: toLocaleStringSupportsLocales
? date.toLocaleDateString(locales, options)
: date.toLocaleDateString();

return (
} = props;
const record = useRecordContext(props);
if (!record) {
return null;
}
const value = get(record, source);
if (value == null) {
return emptyText ? (
<Typography
component="span"
variant="body2"
className={className}
{...sanitizeFieldRestProps(rest)}
>
{dateString}
{emptyText}
</Typography>
);
) : null;
}
);

const date = value instanceof Date ? value : new Date(value);
const dateString = showTime
? toLocaleStringSupportsLocales
? date.toLocaleString(locales, options)
: date.toLocaleString()
: toLocaleStringSupportsLocales
? date.toLocaleDateString(locales, options)
: date.toLocaleDateString();

return (
<Typography
component="span"
variant="body2"
className={className}
{...sanitizeFieldRestProps(rest)}
>
{dateString}
</Typography>
);
});

DateField.defaultProps = {
addLabel: true,
15 changes: 15 additions & 0 deletions packages/ra-ui-materialui/src/field/EmailField.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import * as React from 'react';
import expect from 'expect';
import { render } from '@testing-library/react';
import { RecordContextProvider } from 'ra-core';

import EmailField from './EmailField';

const url = 'foo@bar.com';
@@ -17,6 +19,19 @@ describe('<EmailField />', () => {
expect(link.innerHTML).toEqual(url);
});

it('should use record from RecordContext', () => {
const record = { id: 123, foo: url };
const { getByText } = render(
<RecordContextProvider value={record}>
<EmailField source="foo" />
</RecordContextProvider>
);
const link = getByText(url) as HTMLAnchorElement;
expect(link.tagName).toEqual('A');
expect(link.href).toEqual(`mailto:${url}`);
expect(link.innerHTML).toEqual(url);
});

it('should handle deep fields', () => {
const record = { id: 123, foo: { bar: url } };
const { getByText } = render(
53 changes: 27 additions & 26 deletions packages/ra-ui-materialui/src/field/EmailField.tsx
Original file line number Diff line number Diff line change
@@ -2,43 +2,44 @@ import * as React from 'react';
import { FC, AnchorHTMLAttributes, memo } from 'react';
import get from 'lodash/get';
import Typography from '@material-ui/core/Typography';
import { Link } from '@material-ui/core';
import { useRecordContext } from 'ra-core';

import sanitizeFieldRestProps from './sanitizeFieldRestProps';
import { PublicFieldProps, InjectedFieldProps, fieldPropTypes } from './types';
import { Link } from '@material-ui/core';

// useful to prevent click bubbling in a datagrid with rowClick
const stopPropagation = e => e.stopPropagation();

const EmailField: FC<EmailFieldProps> = memo<EmailFieldProps>(
({ className, source, record = {}, emptyText, ...rest }) => {
const value = get(record, source);

if (value == null) {
return emptyText ? (
<Typography
component="span"
variant="body2"
className={className}
{...sanitizeFieldRestProps(rest)}
>
{emptyText}
</Typography>
) : null;
}

return (
<Link
const EmailField: FC<EmailFieldProps> = memo<EmailFieldProps>(props => {
const { className, source, emptyText, ...rest } = props;
const record = useRecordContext(props);
const value = get(record, source);

if (value == null) {
return emptyText ? (
<Typography
component="span"
variant="body2"
className={className}
href={`mailto:${value}`}
onClick={stopPropagation}
{...sanitizeFieldRestProps(rest)}
>
{value}
</Link>
);
{emptyText}
</Typography>
) : null;
}
);

return (
<Link
className={className}
href={`mailto:${value}`}
onClick={stopPropagation}
{...sanitizeFieldRestProps(rest)}
>
{value}
</Link>
);
});

EmailField.defaultProps = {
addLabel: true,
22 changes: 21 additions & 1 deletion packages/ra-ui-materialui/src/field/FileField.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import * as React from 'react';
import expect from 'expect';
import FileField from './FileField';
import { render } from '@testing-library/react';
import { RecordContextProvider } from 'ra-core';

import FileField from './FileField';

const defaultProps = {
classes: {},
@@ -46,6 +48,24 @@ describe('<FileField />', () => {
expect(link.title).toEqual('Hello world!');
});

it('should use record from RecordContext', () => {
const { getByTitle } = render(
<RecordContextProvider
value={{
id: 123,
url: 'http://foo.com/bar.jpg',
title: 'Hello world!',
}}
>
<FileField {...defaultProps} title="title" />
</RecordContextProvider>
);

const link = getByTitle('Hello world!') as HTMLAnchorElement;
expect(link.href).toEqual('http://foo.com/bar.jpg');
expect(link.title).toEqual('Hello world!');
});

it('should support deep linking', () => {
const { getByTitle } = render(
<FileField
3 changes: 2 additions & 1 deletion packages/ra-ui-materialui/src/field/FileField.tsx
Original file line number Diff line number Diff line change
@@ -5,6 +5,7 @@ import get from 'lodash/get';
import { makeStyles } from '@material-ui/core/styles';
import Typography from '@material-ui/core/Typography';
import classnames from 'classnames';
import { useRecordContext } from 'ra-core';

import sanitizeFieldRestProps from './sanitizeFieldRestProps';
import { PublicFieldProps, InjectedFieldProps, fieldPropTypes } from './types';
@@ -27,7 +28,6 @@ const FileField: FC<FileFieldProps> = props => {
className,
classes: classesOverride,
emptyText,
record,
source,
title,
src,
@@ -37,6 +37,7 @@ const FileField: FC<FileFieldProps> = props => {
rel,
...rest
} = props;
const record = useRecordContext(props);
const sourceValue = get(record, source);
const classes = useStyles(props);

11 changes: 11 additions & 0 deletions packages/ra-ui-materialui/src/field/FunctionField.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as React from 'react';
import { render } from '@testing-library/react';
import FunctionField from './FunctionField';
import { RecordContextProvider } from 'ra-core';

describe('<FunctionField />', () => {
it('should render using the render function', () => {
@@ -14,6 +15,16 @@ describe('<FunctionField />', () => {
expect(queryByText('ba')).not.toBeNull();
});

it('should use record from RecordContext', () => {
const record = { id: 123, foo: 'bar' };
const { queryByText } = render(
<RecordContextProvider value={record}>
<FunctionField render={r => r && r.foo.substr(0, 2)} />
</RecordContextProvider>
);
expect(queryByText('ba')).not.toBeNull();
});

it('should use custom className', () => {
const { queryByText } = render(
<FunctionField
17 changes: 8 additions & 9 deletions packages/ra-ui-materialui/src/field/FunctionField.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as React from 'react';
import { useMemo } from 'react';
import { Record } from 'ra-core';
import { Record, useRecordContext } from 'ra-core';
import PropTypes from 'prop-types';
import Typography, { TypographyProps } from '@material-ui/core/Typography';

@@ -17,14 +17,12 @@ import { PublicFieldProps, InjectedFieldProps, fieldPropTypes } from './types';
* render={record => record && `${record.first_name} ${record.last_name}`}
* />
*/
const FunctionField = <RecordType extends Record = Record>({
className,
record,
source = '',
render,
...rest
}: FunctionFieldProps<RecordType>) =>
useMemo(
const FunctionField = <RecordType extends Record = Record>(
props: FunctionFieldProps<RecordType>
) => {
const { className, source = '', render, ...rest } = props;
const record = useRecordContext(props);
return useMemo(
() =>
record ? (
<Typography
@@ -38,6 +36,7 @@ const FunctionField = <RecordType extends Record = Record>({
) : null,
[className, record, source, render, rest]
);
};

FunctionField.defaultProps = {
addLabel: true,
21 changes: 21 additions & 0 deletions packages/ra-ui-materialui/src/field/ImageField.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import * as React from 'react';
import expect from 'expect';
import { render } from '@testing-library/react';
import { RecordContextProvider } from 'ra-core';

import ImageField from './ImageField';

const defaultProps = {
@@ -47,6 +49,25 @@ describe('<ImageField />', () => {
expect(img.title).toEqual('Hello world!');
});

it('should use record from RecordContext', () => {
const { getByRole } = render(
<RecordContextProvider
value={{
id: 123,
url: 'http://foo.com/bar.jpg',
title: 'Hello world!',
}}
>
<ImageField {...defaultProps} title="title" />
</RecordContextProvider>
);

const img = getByRole('img') as HTMLImageElement;
expect(img.src).toEqual('http://foo.com/bar.jpg');
expect(img.alt).toEqual('Hello world!');
expect(img.title).toEqual('Hello world!');
});

it('should support deep linking', () => {
const { getByRole } = render(
<ImageField
3 changes: 2 additions & 1 deletion packages/ra-ui-materialui/src/field/ImageField.tsx
Original file line number Diff line number Diff line change
@@ -5,6 +5,7 @@ import get from 'lodash/get';
import { makeStyles } from '@material-ui/core/styles';
import Typography from '@material-ui/core/Typography';
import classnames from 'classnames';
import { useRecordContext } from 'ra-core';

import sanitizeFieldRestProps from './sanitizeFieldRestProps';
import { PublicFieldProps, InjectedFieldProps, fieldPropTypes } from './types';
@@ -34,12 +35,12 @@ const ImageField: FC<ImageFieldProps> = props => {
className,
classes: classesOverride,
emptyText,
record,
source,
src,
title,
...rest
} = props;
const record = useRecordContext(props);
const sourceValue = get(record, source);
const classes = useStyles(props);
if (!sourceValue) {
11 changes: 11 additions & 0 deletions packages/ra-ui-materialui/src/field/NumberField.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import * as React from 'react';
import expect from 'expect';
import { render } from '@testing-library/react';
import { RecordContextProvider } from 'ra-core';

import NumberField from './NumberField';

describe('<NumberField />', () => {
@@ -37,6 +39,15 @@ describe('<NumberField />', () => {
expect(queryByText('1')).not.toBeNull();
});

it('should use record from RecordContext', () => {
const { queryByText } = render(
<RecordContextProvider value={{ id: 123, foo: 1 }}>
<NumberField source="foo" />
</RecordContextProvider>
);
expect(queryByText('1')).not.toBeNull();
});

it('should pass the options prop to Intl.NumberFormat', () => {
const { queryByText } = render(
<NumberField
22 changes: 12 additions & 10 deletions packages/ra-ui-materialui/src/field/NumberField.tsx
Original file line number Diff line number Diff line change
@@ -3,6 +3,7 @@ import { FC, memo } from 'react';
import PropTypes from 'prop-types';
import get from 'lodash/get';
import Typography, { TypographyProps } from '@material-ui/core/Typography';
import { useRecordContext } from 'ra-core';

import sanitizeFieldRestProps from './sanitizeFieldRestProps';
import { PublicFieldProps, InjectedFieldProps, fieldPropTypes } from './types';
@@ -42,16 +43,17 @@ const hasNumberFormat = !!(
* <span>25,99 $US</span>
*/
export const NumberField: FC<NumberFieldProps> = memo<NumberFieldProps>(
({
className,
emptyText,
record,
source,
locales,
options,
textAlign,
...rest
}) => {
props => {
const {
className,
emptyText,
source,
locales,
options,
textAlign,
...rest
} = props;
const record = useRecordContext(props);
if (!record) {
return null;
}
3 changes: 2 additions & 1 deletion packages/ra-ui-materialui/src/field/ReferenceArrayField.tsx
Original file line number Diff line number Diff line change
@@ -10,6 +10,7 @@ import {
SortPayload,
FilterPayload,
ResourceContextProvider,
useRecordContext,
} from 'ra-core';

import { fieldPropTypes, PublicFieldProps, InjectedFieldProps } from './types';
@@ -80,12 +81,12 @@ const ReferenceArrayField: FC<ReferenceArrayFieldProps> = props => {
filter,
page = 1,
perPage,
record,
reference,
resource,
sort,
source,
} = props;
const record = useRecordContext(props);

if (React.Children.count(children) !== 1) {
throw new Error(
33 changes: 32 additions & 1 deletion packages/ra-ui-materialui/src/field/ReferenceField.spec.tsx
Original file line number Diff line number Diff line change
@@ -2,7 +2,7 @@ import * as React from 'react';
import expect from 'expect';
import { render } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { DataProviderContext } from 'ra-core';
import { DataProviderContext, RecordContextProvider } from 'ra-core';
import { renderWithRedux } from 'ra-test';

import ReferenceField, { ReferenceFieldView } from './ReferenceField';
@@ -31,6 +31,7 @@ describe('<ReferenceField />', () => {
const links = container.getElementsByTagName('a');
expect(links).toHaveLength(0);
});

it('should display a loader on mount if the reference is not in the store and a second has passed', async () => {
const { queryByRole, container } = renderWithRedux(
<ReferenceFieldView
@@ -209,6 +210,36 @@ describe('<ReferenceField />', () => {
expect(links.item(0).href).toBe('http://localhost/posts/123');
});

it('should use record from RecordContext', () => {
const { container, getByText } = renderWithRedux(
<MemoryRouter>
<RecordContextProvider value={record}>
<ReferenceField
resource="comments"
source="postId"
reference="posts"
basePath="/comments"
>
<TextField source="title" />
</ReferenceField>
</RecordContextProvider>
</MemoryRouter>,
{
admin: {
resources: {
posts: {
data: { 123: { id: 123, title: 'hello' } },
},
},
},
}
);
expect(getByText('hello')).not.toBeNull();
const links = container.getElementsByTagName('a');
expect(links).toHaveLength(1);
expect(links.item(0).href).toBe('http://localhost/posts/123');
});

it('should call the dataProvider for the related record', async () => {
const dataProvider = {
getMany: jest.fn(() =>
70 changes: 38 additions & 32 deletions packages/ra-ui-materialui/src/field/ReferenceField.tsx
Original file line number Diff line number Diff line change
@@ -12,7 +12,9 @@ import {
getResourceLinkPath,
LinkToType,
ResourceContextProvider,
RecordContextProvider,
Record,
useRecordContext,
} from 'ra-core';

import LinearProgress from '../layout/LinearProgress';
@@ -65,21 +67,19 @@ import { ClassesOverride } from '../types';
* In previous versions of React-Admin, the prop `linkType` was used. It is now deprecated and replaced with `link`. However
* backward-compatibility is still kept
*/
const ReferenceField: FC<ReferenceFieldProps> = ({
record,
source,
emptyText,
...props
}) =>
get(record, source) == null ? (
const ReferenceField: FC<ReferenceFieldProps> = props => {
const { source, emptyText, ...rest } = props;
const record = useRecordContext(props);
return get(record, source) == null ? (
emptyText ? (
<Typography component="span" variant="body2">
{emptyText}
</Typography>
) : null
) : (
<NonEmptyReferenceField {...props} record={record} source={source} />
<NonEmptyReferenceField {...rest} record={record} source={source} />
);
};

ReferenceField.propTypes = {
addLabel: PropTypes.bool,
@@ -217,33 +217,39 @@ export const ReferenceFieldView: FC<ReferenceFieldViewProps> = props => {

if (resourceLinkPath) {
return (
<Link
to={resourceLinkPath as string}
className={className}
onClick={stopPropagation}
>
{cloneElement(Children.only(children), {
className: classnames(
children.props.className,
classes.link // force color override for Typography components
),
record: referenceRecord,
resource: reference,
basePath,
translateChoice,
...sanitizeFieldRestProps(rest),
})}
</Link>
<RecordContextProvider value={referenceRecord}>
<Link
to={resourceLinkPath as string}
className={className}
onClick={stopPropagation}
>
{cloneElement(Children.only(children), {
className: classnames(
children.props.className,
classes.link // force color override for Typography components
),
record: referenceRecord,
resource: reference,
basePath,
translateChoice,
...sanitizeFieldRestProps(rest),
})}
</Link>
</RecordContextProvider>
);
}

return cloneElement(Children.only(children), {
record: referenceRecord,
resource: reference,
basePath,
translateChoice,
...sanitizeFieldRestProps(rest),
});
return (
<RecordContextProvider value={referenceRecord}>
{cloneElement(Children.only(children), {
record: referenceRecord,
resource: reference,
basePath,
translateChoice,
...sanitizeFieldRestProps(rest),
})}
</RecordContextProvider>
);
};

ReferenceFieldView.propTypes = {
3 changes: 2 additions & 1 deletion packages/ra-ui-materialui/src/field/ReferenceManyField.tsx
Original file line number Diff line number Diff line change
@@ -7,6 +7,7 @@ import {
ListContextProvider,
ListControllerProps,
ResourceContextProvider,
useRecordContext,
} from 'ra-core';

import { PublicFieldProps, fieldPropTypes, InjectedFieldProps } from './types';
@@ -65,13 +66,13 @@ export const ReferenceManyField: FC<ReferenceManyFieldProps> = props => {
filter,
page = 1,
perPage,
record,
reference,
resource,
sort,
source,
target,
} = props;
const record = useRecordContext(props);

if (React.Children.count(children) !== 1) {
throw new Error(
14 changes: 14 additions & 0 deletions packages/ra-ui-materialui/src/field/RichTextField.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import * as React from 'react';
import expect from 'expect';
import { render } from '@testing-library/react';
import { RecordContextProvider } from 'ra-core';

import RichTextField, { removeTags } from './RichTextField';

describe('stripTags', () => {
@@ -54,6 +56,18 @@ describe('<RichTextField />', () => {
);
});

it('should use record from RecordContext', () => {
const record = { id: 123, body: '<h1>Hello world!</h1>' };
const { container } = render(
<RecordContextProvider value={record}>
<RichTextField source="body" />
</RecordContextProvider>
);
expect(container.children[0].innerHTML).toEqual(
'<span><h1>Hello world!</h1></span>'
);
});

it('should handle deep fields', () => {
const record = { id: 123, foo: { body: '<h1>Hello world!</h1>' } };
const { container } = render(
6 changes: 5 additions & 1 deletion packages/ra-ui-materialui/src/field/RichTextField.tsx
Original file line number Diff line number Diff line change
@@ -3,14 +3,18 @@ import { FC, memo } from 'react';
import PropTypes from 'prop-types';
import get from 'lodash/get';
import Typography, { TypographyProps } from '@material-ui/core/Typography';
import { useRecordContext } from 'ra-core';

import sanitizeFieldRestProps from './sanitizeFieldRestProps';
import { InjectedFieldProps, PublicFieldProps, fieldPropTypes } from './types';

export const removeTags = (input: string) =>
input ? input.replace(/<[^>]+>/gm, '') : '';

const RichTextField: FC<RichTextFieldProps> = memo<RichTextFieldProps>(
({ className, emptyText, source, record = {}, stripTags, ...rest }) => {
props => {
const { className, emptyText, source, stripTags, ...rest } = props;
const record = useRecordContext(props);
const value = get(record, source);

return (
18 changes: 16 additions & 2 deletions packages/ra-ui-materialui/src/field/SelectField.spec.tsx
Original file line number Diff line number Diff line change
@@ -2,9 +2,13 @@ import * as React from 'react';
import { FC } from 'react';
import expect from 'expect';
import { render } from '@testing-library/react';

import { Record, TestTranslationProvider } from 'ra-core';
import {
Record,
TestTranslationProvider,
RecordContextProvider,
} from 'ra-core';
import { renderWithRedux } from 'ra-test';

import SelectField from './SelectField';

describe('<SelectField />', () => {
@@ -53,6 +57,16 @@ describe('<SelectField />', () => {
expect(queryAllByText('hello')).toHaveLength(1);
});

it('should use record from RecordContext', () => {
const record = { id: 123, foo: 0 };
const { queryByText } = render(
<RecordContextProvider value={record}>
<SelectField {...defaultProps} />
</RecordContextProvider>
);
expect(queryByText('hello')).not.toBeNull();
});

it('should use custom className', () => {
const { container } = render(
<SelectField
25 changes: 13 additions & 12 deletions packages/ra-ui-materialui/src/field/SelectField.tsx
Original file line number Diff line number Diff line change
@@ -2,7 +2,7 @@ import * as React from 'react';
import { FC, memo } from 'react';
import PropTypes from 'prop-types';
import get from 'lodash/get';
import { ChoicesProps, useChoices } from 'ra-core';
import { ChoicesProps, useChoices, useRecordContext } from 'ra-core';
import Typography from '@material-ui/core/Typography';

import sanitizeFieldRestProps from './sanitizeFieldRestProps';
@@ -68,17 +68,18 @@ import { PublicFieldProps, InjectedFieldProps, fieldPropTypes } from './types';
* **Tip**: <ReferenceField> sets `translateChoice` to false by default.
*/
export const SelectField: FC<SelectFieldProps> = memo<SelectFieldProps>(
({
className,
emptyText,
source,
record,
choices,
optionValue,
optionText,
translateChoice,
...rest
}) => {
props => {
const {
className,
emptyText,
source,
choices,
optionValue,
optionText,
translateChoice,
...rest
} = props;
const record = useRecordContext(props);
const value = get(record, source);
const { getChoiceText, getChoiceValue } = useChoices({
optionText,
16 changes: 16 additions & 0 deletions packages/ra-ui-materialui/src/field/TextField.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import * as React from 'react';
import expect from 'expect';
import { render, getNodeText } from '@testing-library/react';
import { RecordContextProvider } from 'ra-core';

import TextField from './TextField';

describe('<TextField />', () => {
@@ -16,6 +18,20 @@ describe('<TextField />', () => {
queryByText("I'm sorry, Dave. I'm afraid I can't do that.")
).not.toBeNull();
});
it('should use record from RecordContext', () => {
const record = {
id: 123,
title: "I'm sorry, Dave. I'm afraid I can't do that.",
};
const { queryByText } = render(
<RecordContextProvider value={record}>
<TextField source="title" />
</RecordContextProvider>
);
expect(
queryByText("I'm sorry, Dave. I'm afraid I can't do that.")
).not.toBeNull();
});

it.each([null, undefined])(
'should display emptyText prop if provided for %s value',
37 changes: 19 additions & 18 deletions packages/ra-ui-materialui/src/field/TextField.tsx
Original file line number Diff line number Diff line change
@@ -2,28 +2,29 @@ import * as React from 'react';
import { FC, memo, ElementType } from 'react';
import get from 'lodash/get';
import Typography, { TypographyProps } from '@material-ui/core/Typography';
import { useRecordContext } from 'ra-core';

import sanitizeFieldRestProps from './sanitizeFieldRestProps';
import { PublicFieldProps, InjectedFieldProps, fieldPropTypes } from './types';

const TextField: FC<TextFieldProps> = memo<TextFieldProps>(
({ className, source, record = {}, emptyText, ...rest }) => {
const value = get(record, source);

return (
<Typography
component="span"
variant="body2"
className={className}
{...sanitizeFieldRestProps(rest)}
>
{value != null && typeof value !== 'string'
? JSON.stringify(value)
: value || emptyText}
</Typography>
);
}
);
const TextField: FC<TextFieldProps> = memo<TextFieldProps>(props => {
const { className, source, emptyText, ...rest } = props;
const record = useRecordContext(props);
const value = get(record, source);

return (
<Typography
component="span"
variant="body2"
className={className}
{...sanitizeFieldRestProps(rest)}
>
{value != null && typeof value !== 'string'
? JSON.stringify(value)
: value || emptyText}
</Typography>
);
});

// what? TypeScript loses the displayName if we don't set it explicitly
TextField.displayName = 'TextField';
3 changes: 2 additions & 1 deletion packages/ra-ui-materialui/src/field/TranslatableFields.tsx
Original file line number Diff line number Diff line change
@@ -5,6 +5,7 @@ import {
useTranslatable,
UseTranslatableOptions,
Record,
useRecordContext,
} from 'ra-core';
import { TranslatableFieldsTabs } from './TranslatableFieldsTabs';
import { TranslatableFieldsTabContent } from './TranslatableFieldsTabContent';
@@ -74,10 +75,10 @@ export const TranslatableFields = (
groupKey = '',
selector = <TranslatableFieldsTabs groupKey={groupKey} />,
children,
record,
resource,
basePath,
} = props;
const record = useRecordContext(props);
const context = useTranslatable({ defaultLocale, locales });
const classes = useStyles(props);

43 changes: 22 additions & 21 deletions packages/ra-ui-materialui/src/field/UrlField.tsx
Original file line number Diff line number Diff line change
@@ -3,36 +3,37 @@ import { FC, AnchorHTMLAttributes, memo } from 'react';
import get from 'lodash/get';
import sanitizeFieldRestProps from './sanitizeFieldRestProps';
import { Typography, Link } from '@material-ui/core';
import { useRecordContext } from 'ra-core';
import { PublicFieldProps, InjectedFieldProps, fieldPropTypes } from './types';

const UrlField: FC<UrlFieldProps> = memo<UrlFieldProps>(
({ className, emptyText, source, record = {}, ...rest }) => {
const value = get(record, source);

if (value == null && emptyText) {
return (
<Typography
component="span"
variant="body2"
className={className}
{...sanitizeFieldRestProps(rest)}
>
{emptyText}
</Typography>
);
}
const UrlField: FC<UrlFieldProps> = memo<UrlFieldProps>(props => {
const { className, emptyText, source, ...rest } = props;
const record = useRecordContext(props);
const value = get(record, source);

if (value == null && emptyText) {
return (
<Link
<Typography
component="span"
variant="body2"
className={className}
href={value}
{...sanitizeFieldRestProps(rest)}
>
{value}
</Link>
{emptyText}
</Typography>
);
}
);

return (
<Link
className={className}
href={value}
{...sanitizeFieldRestProps(rest)}
>
{value}
</Link>
);
});

UrlField.defaultProps = {
addLabel: true,
47 changes: 27 additions & 20 deletions packages/ra-ui-materialui/src/list/SingleFieldList.tsx
Original file line number Diff line number Diff line change
@@ -9,6 +9,7 @@ import {
sanitizeListRestProps,
useListContext,
useResourceContext,
RecordContextProvider,
} from 'ra-core';

import Link from '../Link';
@@ -96,29 +97,35 @@ const SingleFieldList: FC<SingleFieldListProps> = props => {

if (resourceLinkPath) {
return (
<Link
className={classes.link}
key={id}
to={resourceLinkPath}
onClick={stopPropagation}
>
{cloneElement(Children.only(children), {
record: data[id],
resource,
basePath,
// Workaround to force ChipField to be clickable
onClick: handleClick,
})}
</Link>
<RecordContextProvider value={data[id]}>
<Link
className={classes.link}
key={id}
to={resourceLinkPath}
onClick={stopPropagation}
>
{cloneElement(Children.only(children), {
record: data[id],
resource,
basePath,
// Workaround to force ChipField to be clickable
onClick: handleClick,
})}
</Link>
</RecordContextProvider>
);
}

return cloneElement(Children.only(children), {
key: id,
record: data[id],
resource,
basePath,
});
return (
<RecordContextProvider value={data[id]}>
{cloneElement(Children.only(children), {
key: id,
record: data[id],
resource,
basePath,
})}
</RecordContextProvider>
);
})}
</div>
);
6 changes: 3 additions & 3 deletions packages/ra-ui-materialui/src/list/datagrid/DatagridRow.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import React, {
Fragment,
isValidElement,
cloneElement,
createElement,
@@ -24,6 +23,7 @@ import {
Identifier,
Record,
useResourceContext,
RecordContextProvider,
} from 'ra-core';
import { shallowEqual } from 'react-redux';
import { useHistory } from 'react-router-dom';
@@ -142,7 +142,7 @@ const DatagridRow: FC<DatagridRowProps> = React.forwardRef((props, ref) => {
);

return (
<Fragment>
<RecordContextProvider value={record}>
<TableRow
ref={ref}
className={className}
@@ -215,7 +215,7 @@ const DatagridRow: FC<DatagridRowProps> = React.forwardRef((props, ref) => {
</TableCell>
</TableRow>
)}
</Fragment>
</RecordContextProvider>
);
});