From 76ddad3e8db945ceec53a0b9c903a420718be20a Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Wed, 27 Nov 2024 15:04:07 +0100 Subject: [PATCH 1/6] Introduce SimpleList rowClick --- docs/SimpleList.md | 21 +- .../src/routing/useGetPathForRecord.ts | 5 + .../src/list/SimpleList/SimpleList.spec.tsx | 139 +++--- .../list/SimpleList/SimpleList.stories.tsx | 165 +++++-- .../src/list/SimpleList/SimpleList.tsx | 435 +++++++++++------- .../src/list/datagrid/Datagrid.tsx | 2 +- .../src/list/datagrid/DatagridBody.tsx | 3 +- .../src/list/datagrid/DatagridRow.tsx | 7 +- .../src/list/datagrid/index.ts | 7 +- packages/ra-ui-materialui/src/list/index.ts | 1 + packages/ra-ui-materialui/src/list/types.ts | 7 + 11 files changed, 525 insertions(+), 267 deletions(-) create mode 100644 packages/ra-ui-materialui/src/list/types.ts diff --git a/docs/SimpleList.md b/docs/SimpleList.md index 2bf275370cf..7a4cdbad752 100644 --- a/docs/SimpleList.md +++ b/docs/SimpleList.md @@ -28,7 +28,7 @@ export const PostList = () => ( primaryText={record => record.title} secondaryText={record => `${record.views} views`} tertiaryText={record => new Date(record.published_at).toLocaleDateString()} - linkType={record => record.canEdit ? "edit" : "show"} + rowClick={(id, resource, record) => record.canEdit ? "edit" : "show"} rowSx={record => ({ backgroundColor: record.nb_views >= 500 ? '#efe' : 'white' })} /> @@ -44,7 +44,7 @@ export const PostList = () => ( | `primaryText` | Optional | mixed | record representation | The primary text to display. | | `secondaryText` | Optional | mixed | | The secondary text to display. | | `tertiaryText` | Optional | mixed | | The tertiary text to display. | -| `linkType` | Optional |mixed | `"edit"` | The target of each item click. | +| `rowClick` | Optional |mixed | `"edit"` | The action to trigger when the user clicks on a row. | | `leftAvatar` | Optional | function | | A function returning an `` component to display before the primary text. | | `leftIcon` | Optional | function | | A function returning an `` component to display before the primary text. | | `rightAvatar` | Optional | function | | A function returning an `` component to display after the primary text. | @@ -80,9 +80,9 @@ This prop should be a function returning an `` component. When present, This prop should be a function returning an `` component. When present, the `` renders a `` before the `` -## `linkType` +## `rowClick` -The `` items link to the edition page by default. You can also set the `linkType` prop to `show` directly to link to the `` page instead. +The `` items link to the edition page by default. You can also set the `rowClick` prop to `show` directly to link to the `` page instead. ```jsx import { List, SimpleList } from 'react-admin'; @@ -93,17 +93,18 @@ export const PostList = () => ( primaryText={record => record.title} secondaryText={record => `${record.views} views`} tertiaryText={record => new Date(record.published_at).toLocaleDateString()} - linkType="show" + rowClick="show" /> ); ``` -`linkType` accepts the following values: +`rowClick` accepts the following values: -* `linkType="edit"`: links to the edit page. This is the default behavior. -* `linkType="show"`: links to the show page. -* `linkType={false}`: does not create any link. +* `rowClick="edit"`: links to the edit page. This is the default behavior. +* `rowClick="show"`: links to the show page. +* `rowClick={false}`: does not link to anything. +* `rowClick={(id, resource, record) => path}`: path can be any of the above values ## `primaryText` @@ -254,7 +255,7 @@ export const PostList = () => { primaryText={record => record.title} secondaryText={record => `${record.views} views`} tertiaryText={record => new Date(record.published_at).toLocaleDateString()} - linkType={record => record.canEdit ? "edit" : "show"} + rowClick={(id, resource, record) => record.canEdit ? "edit" : "show"} /> ) : ( diff --git a/packages/ra-core/src/routing/useGetPathForRecord.ts b/packages/ra-core/src/routing/useGetPathForRecord.ts index 7d1fa19d77a..4a34aa559d0 100644 --- a/packages/ra-core/src/routing/useGetPathForRecord.ts +++ b/packages/ra-core/src/routing/useGetPathForRecord.ts @@ -81,6 +81,11 @@ export const useGetPathForRecord = ( useEffect(() => { if (!record) return; + if (link === false) { + setPath(false); + return; + } + // Handle the inferred link type case if (link == null) { // We must check whether the resource has an edit view because if there is no diff --git a/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.spec.tsx b/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.spec.tsx index 04734497c80..37bb980fa6c 100644 --- a/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.spec.tsx +++ b/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.spec.tsx @@ -6,13 +6,20 @@ import { waitFor, within, } from '@testing-library/react'; -import { ListContext, ResourceContextProvider } from 'ra-core'; +import { + ListContext, + ResourceContextProvider, + ResourceDefinitionContextProvider, +} from 'ra-core'; +import { Location } from 'react-router'; import { AdminContext } from '../../AdminContext'; import { SimpleList } from './SimpleList'; import { TextField } from '../../field/TextField'; import { + LinkType, NoPrimaryText, + RowClick, Standalone, StandaloneEmpty, } from './SimpleList.stories'; @@ -20,9 +27,20 @@ import { Basic } from '../filter/FilterButton.stories'; const Wrapper = ({ children }: any) => ( - - {children} - + + + {children} + + ); @@ -59,58 +77,24 @@ describe('', () => { }); it.each([ - [ - 'edit', - 'edit', - ['http://localhost/#/posts/1', 'http://localhost/#/posts/2'], - ], - [ - 'show', - 'show', - [ - 'http://localhost/#/posts/1/show', - 'http://localhost/#/posts/2/show', - ], - ], - [ - 'custom', - (record, id) => `/posts/${id}/custom`, - [ - 'http://localhost/#/posts/1/custom', - 'http://localhost/#/posts/2/custom', - ], - ], + ['edit', 'edit', '/books/1'], + ['show', 'show', '/books/1/show'], + ['custom', (record, id) => `/books/${id}/custom`, '/books/1/custom'], ])( - 'should render %s links for each item', - async (_, link, expectedUrls) => { + 'should render %s links for each item with linkType', + async (_, linkType, expectedUrls) => { + let location: Location; render( - { + location = l; }} - > - record.id.toString()} - secondaryText={} - /> - , - { wrapper: Wrapper } + /> ); - + fireEvent.click(await screen.findByText('War and Peace')); await waitFor(() => { - expect(screen.getByText('1').closest('a').href).toEqual( - expectedUrls[0] - ); - expect(screen.getByText('2').closest('a').href).toEqual( - expectedUrls[1] - ); + expect(location?.pathname).toEqual(expectedUrls); }); } ); @@ -143,6 +127,57 @@ describe('', () => { }); }); + it.each([ + ['edit', 'edit', '/books/1'], + ['show', 'show', '/books/1/show'], + ['custom', id => `/books/${id}/custom`, '/books/1/custom'], + ])( + 'should render %s links for each item with rowClick', + async (_, rowClick, expectedUrls) => { + let location: Location; + render( + { + location = l; + }} + /> + ); + fireEvent.click(await screen.findByText('War and Peace')); + await waitFor(() => { + expect(location?.pathname).toEqual(expectedUrls); + }); + } + ); + + it('should not render links if rowClick is false', async () => { + render( + + record.id.toString()} + secondaryText={} + /> + , + { wrapper: Wrapper } + ); + + await waitFor(() => { + expect(screen.getByText('1').closest('a')).toBeNull(); + expect(screen.getByText('2').closest('a')).toBeNull(); + }); + }); + it('should display a message when there is no result', () => { render( ', () => { }); it('should display a message when there is no result', async () => { render(); - await screen.findByText('No results found.'); + await screen.findByText('ra.navigation.no_results'); }); }); }); diff --git a/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.stories.tsx b/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.stories.tsx index 91386aae39e..2ff72ddad5a 100644 --- a/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.stories.tsx +++ b/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.stories.tsx @@ -7,16 +7,19 @@ import { TestMemoryRouter, ResourceContextProvider, ResourceProps, + ResourceDefinitionContextProvider, } from 'ra-core'; import defaultMessages from 'ra-language-english'; import polyglotI18nProvider from 'ra-i18n-polyglot'; -import { Box, FormControlLabel, FormGroup, Switch } from '@mui/material'; +import { Alert, Box, FormControlLabel, FormGroup, Switch } from '@mui/material'; +import { Location } from 'react-router'; -import { SimpleList } from './SimpleList'; +import { FunctionLinkType, SimpleList } from './SimpleList'; import { AdminUI } from '../../AdminUI'; import { AdminContext, AdminContextProps } from '../../AdminContext'; import { EditGuesser } from '../../detail'; import { List, ListProps } from '../List'; +import { RowClickFunction } from '../types'; export default { title: 'ra-ui-materialui/list/SimpleList' }; @@ -105,17 +108,121 @@ const data = { export const Basic = () => ( - - record.title} - secondaryText={record => record.author} - tertiaryText={record => record.year} - /> - + + + record.title} + secondaryText={record => record.author} + tertiaryText={record => record.year} + /> + + ); +export const LinkType = ({ + linkType, + locationCallback, +}: { + linkType: string | FunctionLinkType | false; + locationCallback?: (l: Location) => void; +}) => ( + + + + + Inferred should target edit + record.title} + secondaryText={record => record.author} + tertiaryText={record => record.year} + linkType={linkType} + /> + + + + +); + +LinkType.args = { + linkType: 'edit', +}; +LinkType.argTypes = { + linkType: { + options: ['inferred', 'edit', 'show', 'no-link', 'function'], + mapping: { + inferred: undefined, + show: 'show', + edit: 'edit', + 'no-link': false, + function: (record, id) => alert(`Clicked on ${id}`), + }, + control: { type: 'select' }, + }, +}; + +export const RowClick = ({ + locationCallback, + rowClick, +}: { + locationCallback?: (l: Location) => void; + rowClick: string | RowClickFunction | false; +}) => ( + + + + + Inferred should target edit + record.title} + secondaryText={record => record.author} + tertiaryText={record => record.year} + rowClick={rowClick} + /> + + + + +); + +RowClick.args = { + rowClick: 'edit', +}; +RowClick.argTypes = { + rowClick: { + options: ['inferred', 'edit', 'show', 'no-link', 'function'], + mapping: { + inferred: undefined, + show: 'show', + edit: 'edit', + 'no-link': false, + function: id => alert(`Clicked on ${id}`), + }, + control: { type: 'select' }, + }, +}; + const myDataProvider = fakeRestDataProvider(data); const Wrapper = ({ @@ -289,26 +396,32 @@ export const FullAppInError = () => ( export const Standalone = () => ( - record.title} - secondaryText={record => record.author} - tertiaryText={record => record.year} - linkType={false} - /> + + + record.title} + secondaryText={record => record.author} + tertiaryText={record => record.year} + linkType={false} + /> + + ); export const StandaloneEmpty = () => ( - - - data={[]} - primaryText={record => record.title} - secondaryText={record => record.author} - tertiaryText={record => record.year} - linkType={false} - /> - + + + + data={[]} + primaryText={record => record.title} + secondaryText={record => record.author} + tertiaryText={record => record.year} + linkType={false} + /> + + ); diff --git a/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.tsx b/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.tsx index eb2fe10364f..165a3afeef5 100644 --- a/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.tsx +++ b/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.tsx @@ -1,11 +1,7 @@ -import * as React from 'react'; -import { styled } from '@mui/material/styles'; import type { SxProps } from '@mui/material'; -import { isValidElement, ReactNode, ReactElement } from 'react'; import { Avatar, List, - ListProps, ListItem, ListItemAvatar, ListItemButton, @@ -13,22 +9,31 @@ import { ListItemProps, ListItemSecondaryAction, ListItemText, + ListProps, } from '@mui/material'; -import { Link } from 'react-router-dom'; +import { styled } from '@mui/material/styles'; import { Identifier, + LinkToType, RaRecord, RecordContextProvider, sanitizeListRestProps, + useEvent, + useGetPathForRecord, + useGetPathForRecordCallback, + useGetRecordRepresentation, useListContextWithProps, + useRecordContext, useResourceContext, - useGetRecordRepresentation, - useCreatePath, useTranslate, } from 'ra-core'; +import * as React from 'react'; +import { isValidElement, ReactElement, ReactNode } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; -import { SimpleListLoading } from './SimpleListLoading'; import { ListNoResults } from '../ListNoResults'; +import { SimpleListLoading } from './SimpleListLoading'; +import { RowClickFunction } from '../types'; /** * The component renders a list of records as a Material UI . @@ -44,7 +49,8 @@ import { ListNoResults } from '../ListNoResults'; * - leftIcon: same * - rightAvatar: same * - rightIcon: same - * - linkType: 'edit' or 'show', or a function returning 'edit' or 'show' based on the record + * - linkType: deprecated 'edit' or 'show', or a function returning 'edit' or 'show' based on the record + * - rowClick: The action to trigger when the user clicks on a row. * - rowStyle: function returning a style object based on (record, index) * - rowSx: function returning a sx object based on (record, index) * @@ -74,21 +80,20 @@ export const SimpleList = ( hasBulkActions, leftAvatar, leftIcon, - linkType = 'edit', + linkType, + rowClick, primaryText, rightAvatar, rightIcon, secondaryText, tertiaryText, + ref, rowSx, rowStyle, ...rest } = props; const { data, isPending, total } = useListContextWithProps(props); - const resource = useResourceContext(props); - const getRecordRepresentation = useGetRecordRepresentation(resource); - const translate = useTranslate(); if (isPending === true) { return ( @@ -102,21 +107,6 @@ export const SimpleList = ( ); } - const renderAvatar = ( - record: RecordType, - avatarCallback: FunctionToElement - ) => { - const avatarValue = avatarCallback(record, record.id); - if ( - typeof avatarValue === 'string' && - (avatarValue.startsWith('http') || avatarValue.startsWith('data:')) - ) { - return ; - } else { - return {avatarValue}; - } - }; - if (data == null || data.length === 0 || total === 0) { if (empty) { return empty; @@ -129,114 +119,21 @@ export const SimpleList = ( {data.map((record, rowIndex) => ( - - - {leftIcon && ( - - {leftIcon(record, record.id)} - - )} - {leftAvatar && ( - - {renderAvatar(record, leftAvatar)} - - )} - - {primaryText - ? typeof primaryText === 'string' - ? translate(primaryText, { - ...record, - _: primaryText, - }) - : isValidElement(primaryText) - ? primaryText - : // @ts-ignore - primaryText( - record, - record.id - ) - : getRecordRepresentation(record)} - - {!!tertiaryText && - (isValidElement(tertiaryText) ? ( - tertiaryText - ) : ( - - {typeof tertiaryText === - 'string' - ? translate( - tertiaryText, - { - ...record, - _: tertiaryText, - } - ) - : isValidElement( - tertiaryText - ) - ? tertiaryText - : // @ts-ignore - tertiaryText( - record, - record.id - )} - - ))} - - } - secondary={ - !!secondaryText && - (typeof secondaryText === 'string' - ? translate(secondaryText, { - ...record, - _: secondaryText, - }) - : isValidElement(secondaryText) - ? secondaryText - : // @ts-ignore - secondaryText(record, record.id)) - } - /> - {(rightAvatar || rightIcon) && ( - - {rightAvatar && ( - - {renderAvatar(record, rightAvatar)} - - )} - {rightIcon && ( - - {rightIcon(record, record.id)} - - )} - - )} - - + ))} @@ -248,21 +145,45 @@ export type FunctionToElement = ( id: Identifier ) => ReactNode; -export interface SimpleListProps - extends Omit { - className?: string; - empty?: ReactElement; - hasBulkActions?: boolean; +interface SimpleListBaseProps { leftAvatar?: FunctionToElement; leftIcon?: FunctionToElement; primaryText?: FunctionToElement | ReactElement | string; + /** + * @deprecated use rowClick instead + */ linkType?: string | FunctionLinkType | false; + + /** + * The action to trigger when the user clicks on a row. + * + * @see https://marmelab.com/react-admin/Datagrid.html#rowclick + * @example + * import { List, Datagrid } from 'react-admin'; + * + * export const PostList = () => ( + * + * + * ... + * + + * + * ); + */ + rowClick?: string | RowClickFunction | false; rightAvatar?: FunctionToElement; rightIcon?: FunctionToElement; secondaryText?: FunctionToElement | ReactElement | string; tertiaryText?: FunctionToElement | ReactElement | string; rowSx?: (record: RecordType, index: number) => SxProps; rowStyle?: (record: RecordType, index: number) => any; +} +export interface SimpleListProps + extends SimpleListBaseProps, + Omit { + className?: string; + empty?: ReactElement; + hasBulkActions?: boolean; // can be injected when using the component without context resource?: string; data?: RecordType[]; @@ -272,41 +193,225 @@ export interface SimpleListProps total?: number; } -const LinkOrNot = ( - props: LinkOrNotProps & Omit +const SimpleListItem = ( + props: SimpleListItemProps ) => { - const { - classes: classesOverride, - linkType, - resource, - id, - children, - record, - ...rest - } = props; - const createPath = useCreatePath(); - const type = - typeof linkType === 'function' ? linkType(record, id) : linkType; + const { linkType, rowClick, rowIndex, rowSx, rowStyle } = props; + const resource = useResourceContext(props); + const record = useRecordContext(props); + const navigate = useNavigate(); + // If we don't have a function to get the path, we can compute the path immediately and set the href + // on the Link correctly without onClick (better for accessibility) + const isFunctionLink = + typeof linkType === 'function' || typeof rowClick === 'function'; + const pathForRecord = useGetPathForRecord({ + link: isFunctionLink ? false : linkType ?? rowClick, + }); + const getPathForRecord = useGetPathForRecordCallback(); + const handleClick = useEvent( + async (event: React.MouseEvent) => { + // No need to handle non function linkType or rowClick + if (!isFunctionLink) return; + if (!record) return; + event.persist(); - if (type === false) { - return {children}; + let link: LinkToType = + typeof linkType === 'function' + ? linkType(record, record.id) + : typeof rowClick === 'function' + ? (record, resource) => + rowClick(record.id, resource, record) + : false; + + const path = await getPathForRecord({ + record, + resource, + link, + }); + if (path === false || path == null) { + return; + } + navigate(path); + } + ); + + if (!record) return null; + + if (isFunctionLink) { + return ( + + {/* @ts-ignore */} + + + + + ); } + + if (pathForRecord) { + return ( + + + + + + ); + } + return ( - // @ts-ignore - - {children} - + + ); }; +const SimpleListItemContent = ( + props: SimpleListItemProps +) => { + const { + leftAvatar, + leftIcon, + primaryText, + rightAvatar, + rightIcon, + secondaryText, + tertiaryText, + } = props; + const resource = useResourceContext(props); + const record = useRecordContext(props); + const getRecordRepresentation = useGetRecordRepresentation(resource); + const translate = useTranslate(); + + const renderAvatar = ( + record: RecordType, + avatarCallback: FunctionToElement + ) => { + const avatarValue = avatarCallback(record, record.id); + if ( + typeof avatarValue === 'string' && + (avatarValue.startsWith('http') || avatarValue.startsWith('data:')) + ) { + return ; + } else { + return {avatarValue}; + } + }; + + if (!record) return null; + + return ( + <> + {leftIcon && ( + {leftIcon(record, record.id)} + )} + {leftAvatar && ( + + {renderAvatar(record, leftAvatar)} + + )} + + {primaryText + ? typeof primaryText === 'string' + ? translate(primaryText, { + ...record, + _: primaryText, + }) + : isValidElement(primaryText) + ? primaryText + : // @ts-ignore + primaryText(record, record.id) + : getRecordRepresentation(record)} + + {!!tertiaryText && + (isValidElement(tertiaryText) ? ( + tertiaryText + ) : ( + + {typeof tertiaryText === 'string' + ? translate(tertiaryText, { + ...record, + _: tertiaryText, + }) + : isValidElement(tertiaryText) + ? tertiaryText + : // @ts-ignore + tertiaryText(record, record.id)} + + ))} + + } + secondary={ + !!secondaryText && + (typeof secondaryText === 'string' + ? translate(secondaryText, { + ...record, + _: secondaryText, + }) + : isValidElement(secondaryText) + ? secondaryText + : // @ts-ignore + secondaryText(record, record.id)) + } + /> + {(rightAvatar || rightIcon) && ( + + {rightAvatar && ( + {renderAvatar(record, rightAvatar)} + )} + {rightIcon && ( + + {rightIcon(record, record.id)} + + )} + + )} + + ); +}; + +interface SimpleListItemProps + extends SimpleListBaseProps, + Omit { + rowIndex: number; +} + export type FunctionLinkType = (record: RaRecord, id: Identifier) => string; export interface LinkOrNotProps { - linkType: string | FunctionLinkType | false; + // @deprecated: use rowClick instead + linkType?: string | FunctionLinkType | false; + rowClick?: string | RowClickFunction | false; resource?: string; id: Identifier; record: RaRecord; diff --git a/packages/ra-ui-materialui/src/list/datagrid/Datagrid.tsx b/packages/ra-ui-materialui/src/list/datagrid/Datagrid.tsx index 9a373ad9ba6..48ed65064c7 100644 --- a/packages/ra-ui-materialui/src/list/datagrid/Datagrid.tsx +++ b/packages/ra-ui-materialui/src/list/datagrid/Datagrid.tsx @@ -29,7 +29,7 @@ import difference from 'lodash/difference'; import { DatagridHeader } from './DatagridHeader'; import DatagridLoading from './DatagridLoading'; import DatagridBody, { PureDatagridBody } from './DatagridBody'; -import { RowClickFunction } from './DatagridRow'; +import { RowClickFunction } from '../types'; import DatagridContextProvider from './DatagridContextProvider'; import { DatagridClasses, DatagridRoot } from './useDatagridStyles'; import { BulkActionsToolbar } from '../BulkActionsToolbar'; diff --git a/packages/ra-ui-materialui/src/list/datagrid/DatagridBody.tsx b/packages/ra-ui-materialui/src/list/datagrid/DatagridBody.tsx index 84c68e6f020..1504d05c348 100644 --- a/packages/ra-ui-materialui/src/list/datagrid/DatagridBody.tsx +++ b/packages/ra-ui-materialui/src/list/datagrid/DatagridBody.tsx @@ -4,8 +4,9 @@ import { SxProps, TableBody, TableBodyProps } from '@mui/material'; import clsx from 'clsx'; import { Identifier, RaRecord, RecordContextProvider } from 'ra-core'; +import { RowClickFunction } from '../types'; import { DatagridClasses } from './useDatagridStyles'; -import DatagridRow, { PureDatagridRow, RowClickFunction } from './DatagridRow'; +import DatagridRow, { PureDatagridRow } from './DatagridRow'; const DatagridBody: React.ForwardRefExoticComponent< Omit & diff --git a/packages/ra-ui-materialui/src/list/datagrid/DatagridRow.tsx b/packages/ra-ui-materialui/src/list/datagrid/DatagridRow.tsx index 1197027ed18..dac125aad37 100644 --- a/packages/ra-ui-materialui/src/list/datagrid/DatagridRow.tsx +++ b/packages/ra-ui-materialui/src/list/datagrid/DatagridRow.tsx @@ -27,6 +27,7 @@ import DatagridCell from './DatagridCell'; import ExpandRowButton from './ExpandRowButton'; import { DatagridClasses } from './useDatagridStyles'; import { useDatagridContext } from './useDatagridContext'; +import { RowClickFunction } from '../types'; const computeNbColumns = (expand, children, hasBulkActions) => expand @@ -265,12 +266,6 @@ export interface DatagridRowProps selectable?: boolean; } -export type RowClickFunction = ( - id: Identifier, - resource: string, - record: RaRecord -) => string | false | Promise; - const areEqual = (prevProps, nextProps) => { const { children: _1, expand: _2, ...prevPropsWithoutChildren } = prevProps; const { children: _3, expand: _4, ...nextPropsWithoutChildren } = nextProps; diff --git a/packages/ra-ui-materialui/src/list/datagrid/index.ts b/packages/ra-ui-materialui/src/list/datagrid/index.ts index 6c343b05785..4691d44d4ed 100644 --- a/packages/ra-ui-materialui/src/list/datagrid/index.ts +++ b/packages/ra-ui-materialui/src/list/datagrid/index.ts @@ -8,11 +8,7 @@ import DatagridHeaderCell, { DatagridHeaderCellProps, } from './DatagridHeaderCell'; import DatagridLoading, { DatagridLoadingProps } from './DatagridLoading'; -import DatagridRow, { - DatagridRowProps, - PureDatagridRow, - RowClickFunction, -} from './DatagridRow'; +import DatagridRow, { DatagridRowProps, PureDatagridRow } from './DatagridRow'; import ExpandRowButton, { ExpandRowButtonProps } from './ExpandRowButton'; export * from './Datagrid'; @@ -43,5 +39,4 @@ export type { DatagridLoadingProps, DatagridRowProps, ExpandRowButtonProps, - RowClickFunction, }; diff --git a/packages/ra-ui-materialui/src/list/index.ts b/packages/ra-ui-materialui/src/list/index.ts index 4f66d57ec52..3b826932002 100644 --- a/packages/ra-ui-materialui/src/list/index.ts +++ b/packages/ra-ui-materialui/src/list/index.ts @@ -16,3 +16,4 @@ export * from './pagination'; export * from './Placeholder'; export * from './SimpleList'; export * from './SingleFieldList'; +export * from './types'; diff --git a/packages/ra-ui-materialui/src/list/types.ts b/packages/ra-ui-materialui/src/list/types.ts new file mode 100644 index 00000000000..73b472b17fa --- /dev/null +++ b/packages/ra-ui-materialui/src/list/types.ts @@ -0,0 +1,7 @@ +import { Identifier, RaRecord } from 'ra-core'; + +export type RowClickFunction = ( + id: Identifier, + resource: string, + record: RecordType +) => string | false | Promise; From ffbac39d867a9fdf7043b563e9522d73f6d64eab Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Wed, 27 Nov 2024 15:26:56 +0100 Subject: [PATCH 2/6] Extract SimpleListItem --- .../src/list/SimpleList/SimpleList.tsx | 196 ++---------------- .../src/list/SimpleList/SimpleListItem.tsx | 164 +++++++++++++++ 2 files changed, 183 insertions(+), 177 deletions(-) create mode 100644 packages/ra-ui-materialui/src/list/SimpleList/SimpleListItem.tsx diff --git a/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.tsx b/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.tsx index 165a3afeef5..76efa55943c 100644 --- a/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.tsx +++ b/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.tsx @@ -1,26 +1,17 @@ -import type { SxProps } from '@mui/material'; import { Avatar, List, - ListItem, ListItemAvatar, - ListItemButton, ListItemIcon, - ListItemProps, ListItemSecondaryAction, ListItemText, ListProps, } from '@mui/material'; import { styled } from '@mui/material/styles'; import { - Identifier, - LinkToType, RaRecord, RecordContextProvider, sanitizeListRestProps, - useEvent, - useGetPathForRecord, - useGetPathForRecordCallback, useGetRecordRepresentation, useListContextWithProps, useRecordContext, @@ -28,12 +19,16 @@ import { useTranslate, } from 'ra-core'; import * as React from 'react'; -import { isValidElement, ReactElement, ReactNode } from 'react'; -import { Link, useNavigate } from 'react-router-dom'; +import { isValidElement, ReactElement } from 'react'; import { ListNoResults } from '../ListNoResults'; import { SimpleListLoading } from './SimpleListLoading'; -import { RowClickFunction } from '../types'; +import { + FunctionToElement, + SimpleListBaseProps, + SimpleListItem, + SimpleListItemProps, +} from './SimpleListItem'; /** * The component renders a list of records as a Material UI . @@ -122,62 +117,28 @@ export const SimpleList = ( + > + + ))} ); }; -export type FunctionToElement = ( - record: RecordType, - id: Identifier -) => ReactNode; - -interface SimpleListBaseProps { - leftAvatar?: FunctionToElement; - leftIcon?: FunctionToElement; - primaryText?: FunctionToElement | ReactElement | string; - /** - * @deprecated use rowClick instead - */ - linkType?: string | FunctionLinkType | false; - - /** - * The action to trigger when the user clicks on a row. - * - * @see https://marmelab.com/react-admin/Datagrid.html#rowclick - * @example - * import { List, Datagrid } from 'react-admin'; - * - * export const PostList = () => ( - * - * - * ... - * - - * - * ); - */ - rowClick?: string | RowClickFunction | false; - rightAvatar?: FunctionToElement; - rightIcon?: FunctionToElement; - secondaryText?: FunctionToElement | ReactElement | string; - tertiaryText?: FunctionToElement | ReactElement | string; - rowSx?: (record: RecordType, index: number) => SxProps; - rowStyle?: (record: RecordType, index: number) => any; -} export interface SimpleListProps extends SimpleListBaseProps, Omit { @@ -193,107 +154,6 @@ export interface SimpleListProps total?: number; } -const SimpleListItem = ( - props: SimpleListItemProps -) => { - const { linkType, rowClick, rowIndex, rowSx, rowStyle } = props; - const resource = useResourceContext(props); - const record = useRecordContext(props); - const navigate = useNavigate(); - // If we don't have a function to get the path, we can compute the path immediately and set the href - // on the Link correctly without onClick (better for accessibility) - const isFunctionLink = - typeof linkType === 'function' || typeof rowClick === 'function'; - const pathForRecord = useGetPathForRecord({ - link: isFunctionLink ? false : linkType ?? rowClick, - }); - const getPathForRecord = useGetPathForRecordCallback(); - const handleClick = useEvent( - async (event: React.MouseEvent) => { - // No need to handle non function linkType or rowClick - if (!isFunctionLink) return; - if (!record) return; - event.persist(); - - let link: LinkToType = - typeof linkType === 'function' - ? linkType(record, record.id) - : typeof rowClick === 'function' - ? (record, resource) => - rowClick(record.id, resource, record) - : false; - - const path = await getPathForRecord({ - record, - resource, - link, - }); - if (path === false || path == null) { - return; - } - navigate(path); - } - ); - - if (!record) return null; - - if (isFunctionLink) { - return ( - - {/* @ts-ignore */} - - - - - ); - } - - if (pathForRecord) { - return ( - - - - - - ); - } - - return ( - - - - ); -}; - const SimpleListItemContent = ( props: SimpleListItemProps ) => { @@ -400,24 +260,6 @@ const SimpleListItemContent = ( ); }; -interface SimpleListItemProps - extends SimpleListBaseProps, - Omit { - rowIndex: number; -} - -export type FunctionLinkType = (record: RaRecord, id: Identifier) => string; - -export interface LinkOrNotProps { - // @deprecated: use rowClick instead - linkType?: string | FunctionLinkType | false; - rowClick?: string | RowClickFunction | false; - resource?: string; - id: Identifier; - record: RaRecord; - children: ReactNode; -} - const PREFIX = 'RaSimpleList'; export const SimpleListClasses = { diff --git a/packages/ra-ui-materialui/src/list/SimpleList/SimpleListItem.tsx b/packages/ra-ui-materialui/src/list/SimpleList/SimpleListItem.tsx new file mode 100644 index 00000000000..a05ffe65be9 --- /dev/null +++ b/packages/ra-ui-materialui/src/list/SimpleList/SimpleListItem.tsx @@ -0,0 +1,164 @@ +import * as React from 'react'; +import { ReactElement, ReactNode } from 'react'; +import type { SxProps } from '@mui/material'; +import { ListItem, ListItemButton, ListItemProps } from '@mui/material'; +import { + Identifier, + LinkToType, + RaRecord, + useEvent, + useGetPathForRecord, + useGetPathForRecordCallback, + useRecordContext, + useResourceContext, +} from 'ra-core'; +import { Link, useNavigate } from 'react-router-dom'; +import { RowClickFunction } from '../types'; + +export const SimpleListItem = ( + props: SimpleListItemProps +) => { + const { children, linkType, rowClick, rowIndex, rowSx, rowStyle } = props; + const resource = useResourceContext(props); + const record = useRecordContext(props); + const navigate = useNavigate(); + // If we don't have a function to get the path, we can compute the path immediately and set the href + // on the Link correctly without onClick (better for accessibility) + const isFunctionLink = + typeof linkType === 'function' || typeof rowClick === 'function'; + const pathForRecord = useGetPathForRecord({ + link: isFunctionLink ? false : linkType ?? rowClick, + }); + const getPathForRecord = useGetPathForRecordCallback(); + const handleClick = useEvent( + async (event: React.MouseEvent) => { + // No need to handle non function linkType or rowClick + if (!isFunctionLink) return; + if (!record) return; + event.persist(); + + let link: LinkToType = + typeof linkType === 'function' + ? linkType(record, record.id) + : typeof rowClick === 'function' + ? (record, resource) => + rowClick(record.id, resource, record) + : false; + + const path = await getPathForRecord({ + record, + resource, + link, + }); + if (path === false || path == null) { + return; + } + navigate(path); + } + ); + + if (!record) return null; + + if (isFunctionLink) { + return ( + + {/* @ts-ignore */} + + {children} + + + ); + } + + if (pathForRecord) { + return ( + + + {children} + + + ); + } + + return ( + + {children} + + ); +}; + +export type FunctionToElement = ( + record: RecordType, + id: Identifier +) => ReactNode; + +export type FunctionLinkType = (record: RaRecord, id: Identifier) => string; + +export interface SimpleListBaseProps { + leftAvatar?: FunctionToElement; + leftIcon?: FunctionToElement; + primaryText?: FunctionToElement | ReactElement | string; + /** + * @deprecated use rowClick instead + */ + linkType?: string | FunctionLinkType | false; + + /** + * The action to trigger when the user clicks on a row. + * + * @see https://marmelab.com/react-admin/Datagrid.html#rowclick + * @example + * import { List, Datagrid } from 'react-admin'; + * + * export const PostList = () => ( + * + * + * ... + * + + * + * ); + */ + rowClick?: string | RowClickFunction | false; + rightAvatar?: FunctionToElement; + rightIcon?: FunctionToElement; + secondaryText?: FunctionToElement | ReactElement | string; + tertiaryText?: FunctionToElement | ReactElement | string; + rowSx?: (record: RecordType, index: number) => SxProps; + rowStyle?: (record: RecordType, index: number) => any; +} + +export interface SimpleListItemProps + extends SimpleListBaseProps, + Omit { + rowIndex: number; +} From f583f8b5fa7824b150cc1f4760619b21bd09c002 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Wed, 4 Dec 2024 11:17:44 +0100 Subject: [PATCH 3/6] Remove unnecessary event.persist --- .../src/list/SimpleList/SimpleListItem.tsx | 42 +++++++++---------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/packages/ra-ui-materialui/src/list/SimpleList/SimpleListItem.tsx b/packages/ra-ui-materialui/src/list/SimpleList/SimpleListItem.tsx index a05ffe65be9..1303d5f37c8 100644 --- a/packages/ra-ui-materialui/src/list/SimpleList/SimpleListItem.tsx +++ b/packages/ra-ui-materialui/src/list/SimpleList/SimpleListItem.tsx @@ -30,32 +30,28 @@ export const SimpleListItem = ( link: isFunctionLink ? false : linkType ?? rowClick, }); const getPathForRecord = useGetPathForRecordCallback(); - const handleClick = useEvent( - async (event: React.MouseEvent) => { - // No need to handle non function linkType or rowClick - if (!isFunctionLink) return; - if (!record) return; - event.persist(); + const handleClick = useEvent(async () => { + // No need to handle non function linkType or rowClick + if (!isFunctionLink) return; + if (!record) return; - let link: LinkToType = - typeof linkType === 'function' - ? linkType(record, record.id) - : typeof rowClick === 'function' - ? (record, resource) => - rowClick(record.id, resource, record) - : false; + let link: LinkToType = + typeof linkType === 'function' + ? linkType(record, record.id) + : typeof rowClick === 'function' + ? (record, resource) => rowClick(record.id, resource, record) + : false; - const path = await getPathForRecord({ - record, - resource, - link, - }); - if (path === false || path == null) { - return; - } - navigate(path); + const path = await getPathForRecord({ + record, + resource, + link, + }); + if (path === false || path == null) { + return; } - ); + navigate(path); + }); if (!record) return null; From ea7cbdddf373c349579c187ab775863d9187fc62 Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Wed, 4 Dec 2024 11:18:04 +0100 Subject: [PATCH 4/6] Improve stories --- .../src/list/SimpleList/SimpleList.stories.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.stories.tsx b/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.stories.tsx index 2ff72ddad5a..3ce34f44646 100644 --- a/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.stories.tsx +++ b/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.stories.tsx @@ -14,12 +14,13 @@ import polyglotI18nProvider from 'ra-i18n-polyglot'; import { Alert, Box, FormControlLabel, FormGroup, Switch } from '@mui/material'; import { Location } from 'react-router'; -import { FunctionLinkType, SimpleList } from './SimpleList'; import { AdminUI } from '../../AdminUI'; import { AdminContext, AdminContextProps } from '../../AdminContext'; import { EditGuesser } from '../../detail'; import { List, ListProps } from '../List'; import { RowClickFunction } from '../types'; +import { SimpleList } from './SimpleList'; +import { FunctionLinkType } from './SimpleListItem'; export default { title: 'ra-ui-materialui/list/SimpleList' }; @@ -166,7 +167,8 @@ LinkType.argTypes = { show: 'show', edit: 'edit', 'no-link': false, - function: (record, id) => alert(`Clicked on ${id}`), + function: (record, id) => + alert(`Clicked on record ${record.title} (#${id})`), }, control: { type: 'select' }, }, @@ -217,7 +219,10 @@ RowClick.argTypes = { show: 'show', edit: 'edit', 'no-link': false, - function: id => alert(`Clicked on ${id}`), + function: (id, resource, record) => + alert( + `Clicked on record ${record.title} (#${id}) of type ${resource}` + ), }, control: { type: 'select' }, }, From cb1aa7ea3b8693ddc6ea9d32f6da850b13a9ebcd Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Wed, 4 Dec 2024 11:18:12 +0100 Subject: [PATCH 5/6] Improve tests --- .../src/list/SimpleList/SimpleList.spec.tsx | 38 ++++++++++++++++--- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.spec.tsx b/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.spec.tsx index 37bb980fa6c..043985cc520 100644 --- a/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.spec.tsx +++ b/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.spec.tsx @@ -79,10 +79,17 @@ describe('', () => { it.each([ ['edit', 'edit', '/books/1'], ['show', 'show', '/books/1/show'], - ['custom', (record, id) => `/books/${id}/custom`, '/books/1/custom'], + [ + 'a function that returns a custom path', + (record, id) => + `/books/${id}/${record.title.toLowerCase().replaceAll(' ', '-')}`, + '/books/1/war-and-peace', + ], + ['a function that returns edit', () => 'edit', '/books/1'], + ['a function that returns show', () => 'show', '/books/1/show'], ])( - 'should render %s links for each item with linkType', - async (_, linkType, expectedUrls) => { + 'Providing %s as linkType should render a link for each item', + async (_, linkType, expectedUrl) => { let location: Location; render( ', () => { ); fireEvent.click(await screen.findByText('War and Peace')); await waitFor(() => { - expect(location?.pathname).toEqual(expectedUrls); + expect(location?.pathname).toEqual(expectedUrl); }); } ); @@ -130,9 +137,28 @@ describe('', () => { it.each([ ['edit', 'edit', '/books/1'], ['show', 'show', '/books/1/show'], - ['custom', id => `/books/${id}/custom`, '/books/1/custom'], + [ + 'a function that returns a custom path', + (id, resource, record) => + `/${resource}/${id}/${record.title.toLowerCase().replaceAll(' ', '-')}`, + '/books/1/war-and-peace', + ], + ['a function that returns edit', () => 'edit', '/books/1'], + ['a function that returns show', () => 'show', '/books/1/show'], + ['a function that resolves to edit', async () => 'edit', '/books/1'], + [ + 'a function that resolves to show', + async () => 'show', + '/books/1/show', + ], + [ + 'a function that resolves to a custom path', + async (id, resource, record) => + `/${resource}/${id}/${record.title.toLowerCase().replaceAll(' ', '-')}`, + '/books/1/war-and-peace', + ], ])( - 'should render %s links for each item with rowClick', + 'Providing %s as rowClick should render a link for each item', async (_, rowClick, expectedUrls) => { let location: Location; render( From 52f30086d84c85cc705c199a61d93c58a666390e Mon Sep 17 00:00:00 2001 From: Gildas <1122076+djhi@users.noreply.github.com> Date: Wed, 4 Dec 2024 11:18:22 +0100 Subject: [PATCH 6/6] Improve documentation --- docs/SimpleList.md | 1 + packages/ra-ui-materialui/src/list/SimpleList/SimpleList.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/SimpleList.md b/docs/SimpleList.md index 7a4cdbad752..b63a5d1c8ac 100644 --- a/docs/SimpleList.md +++ b/docs/SimpleList.md @@ -104,6 +104,7 @@ export const PostList = () => ( * `rowClick="edit"`: links to the edit page. This is the default behavior. * `rowClick="show"`: links to the show page. * `rowClick={false}`: does not link to anything. +* `rowClick="/custom"`: links to a custom path. * `rowClick={(id, resource, record) => path}`: path can be any of the above values ## `primaryText` diff --git a/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.tsx b/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.tsx index 76efa55943c..d16d6195f09 100644 --- a/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.tsx +++ b/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.tsx @@ -44,7 +44,7 @@ import { * - leftIcon: same * - rightAvatar: same * - rightIcon: same - * - linkType: deprecated 'edit' or 'show', or a function returning 'edit' or 'show' based on the record + * - linkType: deprecated - 'edit' or 'show', or a function returning 'edit' or 'show' based on the record * - rowClick: The action to trigger when the user clicks on a row. * - rowStyle: function returning a style object based on (record, index) * - rowSx: function returning a sx object based on (record, index)