diff --git a/packages/ra-core/src/controller/edit/useEditController.spec.tsx b/packages/ra-core/src/controller/edit/useEditController.spec.tsx index d374d9a1195..9713df6db3f 100644 --- a/packages/ra-core/src/controller/edit/useEditController.spec.tsx +++ b/packages/ra-core/src/controller/edit/useEditController.spec.tsx @@ -151,14 +151,13 @@ describe('useEditController', () => { describe('queryOptions', () => { it('should accept custom client query options', async () => { - const mock = jest - .spyOn(console, 'error') - .mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementationOnce(() => {}); const getOne = jest .fn() .mockImplementationOnce(() => Promise.reject(new Error())); const onError = jest.fn(); const dataProvider = ({ getOne } as unknown) as DataProvider; + render( { ); + await waitFor(() => { expect(getOne).toHaveBeenCalled(); expect(onError).toHaveBeenCalled(); }); - mock.mockRestore(); }); it('should accept a meta in query options', async () => { @@ -959,6 +958,7 @@ describe('useEditController', () => { it('should return errors from the update call in pessimistic mode', async () => { let post = { id: 12 }; + jest.spyOn(console, 'error').mockImplementationOnce(() => {}); const update = jest.fn().mockImplementationOnce(() => { return Promise.reject({ body: { errors: { foo: 'invalid' } } }); }); diff --git a/packages/ra-core/src/controller/input/ReferenceInputBase.spec.tsx b/packages/ra-core/src/controller/input/ReferenceInputBase.spec.tsx index b28a905d224..23d8a831f29 100644 --- a/packages/ra-core/src/controller/input/ReferenceInputBase.spec.tsx +++ b/packages/ra-core/src/controller/input/ReferenceInputBase.spec.tsx @@ -130,10 +130,15 @@ describe('', () => { }); it('should use meta when fetching current value', async () => { + const getList = jest + .fn() + .mockImplementationOnce(() => + Promise.resolve({ data: [], total: 25 }) + ); const getMany = jest .fn() .mockImplementationOnce(() => Promise.resolve({ data: [] })); - const dataProvider = testDataProvider({ getMany }); + const dataProvider = testDataProvider({ getList, getMany }); render(
diff --git a/packages/ra-core/src/controller/list/useInfiniteListController.spec.tsx b/packages/ra-core/src/controller/list/useInfiniteListController.spec.tsx index a1ecc1a4a8f..8585d1e2a3a 100644 --- a/packages/ra-core/src/controller/list/useInfiniteListController.spec.tsx +++ b/packages/ra-core/src/controller/list/useInfiniteListController.spec.tsx @@ -43,9 +43,7 @@ describe('useInfiniteListController', () => { describe('queryOptions', () => { it('should accept custom client query options', async () => { - const mock = jest - .spyOn(console, 'error') - .mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementationOnce(() => {}); const getList = jest .fn() .mockImplementationOnce(() => Promise.reject(new Error())); @@ -65,7 +63,6 @@ describe('useInfiniteListController', () => { expect(getList).toHaveBeenCalled(); expect(onError).toHaveBeenCalled(); }); - mock.mockRestore(); }); it('should accept meta in queryOptions', async () => { diff --git a/packages/ra-core/src/controller/list/useListController.spec.tsx b/packages/ra-core/src/controller/list/useListController.spec.tsx index 6da00e367d5..86140200850 100644 --- a/packages/ra-core/src/controller/list/useListController.spec.tsx +++ b/packages/ra-core/src/controller/list/useListController.spec.tsx @@ -29,9 +29,7 @@ describe('useListController', () => { describe('queryOptions', () => { it('should accept custom client query options', async () => { - const mock = jest - .spyOn(console, 'error') - .mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementationOnce(() => {}); const getList = jest .fn() .mockImplementationOnce(() => Promise.reject(new Error())); @@ -48,7 +46,6 @@ describe('useListController', () => { expect(getList).toHaveBeenCalled(); expect(onError).toHaveBeenCalled(); }); - mock.mockRestore(); }); it('should accept meta in queryOptions', async () => { diff --git a/packages/ra-core/src/dataProvider/defaultDataProvider.ts b/packages/ra-core/src/dataProvider/defaultDataProvider.ts index 901c1ac9ffc..e8b7b4f0d6c 100644 --- a/packages/ra-core/src/dataProvider/defaultDataProvider.ts +++ b/packages/ra-core/src/dataProvider/defaultDataProvider.ts @@ -3,30 +3,30 @@ import { DataProvider } from '../types'; // avoids adding a context in tests export const defaultDataProvider: DataProvider = { create: async () => { - throw new Error('not implemented'); + throw new Error('create is not implemented'); }, delete: async () => { - throw new Error('not implemented'); + throw new Error('delete not implemented'); }, deleteMany: async () => { - throw new Error('not implemented'); + throw new Error('deleteMany is not implemented'); }, getList: async () => { - throw new Error('not implemented'); + throw new Error('getList is not implemented'); }, getMany: async () => { - throw new Error('not implemented'); + throw new Error('getMany is not implemented'); }, getManyReference: async () => { - throw new Error('not implemented'); + throw new Error('getManyReference is not implemented'); }, getOne: async () => { - throw new Error('not implemented'); + throw new Error('getOne is not implemented'); }, update: async () => { - throw new Error('not implemented'); + throw new Error('update not implemented'); }, updateMany: async () => { - throw new Error('not implemented'); + throw new Error('updateMany not implemented'); }, }; diff --git a/packages/ra-core/src/form/Form.stories.tsx b/packages/ra-core/src/form/Form.stories.tsx index 5d0115aa244..81c7dced708 100644 --- a/packages/ra-core/src/form/Form.stories.tsx +++ b/packages/ra-core/src/form/Form.stories.tsx @@ -97,6 +97,7 @@ const CustomInput = (props: UseControllerProps) => { type="text" aria-invalid={fieldState.invalid} {...field} + value={field.value ?? ''} />

{fieldState.error?.message}

diff --git a/packages/ra-core/src/notification/NotificationContextProvider.tsx b/packages/ra-core/src/notification/NotificationContextProvider.tsx index ae08d0c9d21..f6c2d575558 100644 --- a/packages/ra-core/src/notification/NotificationContextProvider.tsx +++ b/packages/ra-core/src/notification/NotificationContextProvider.tsx @@ -15,6 +15,7 @@ export const NotificationContextProvider = ({ children }) => { }, []); const takeNotification = useCallback(() => { + if (notifications.length === 0) return; const [notification, ...rest] = notifications; setNotifications(rest); return notification; diff --git a/packages/ra-core/src/util/ComponentPropType.ts b/packages/ra-core/src/util/ComponentPropType.ts index b57a2bbbaec..a01617f93ca 100644 --- a/packages/ra-core/src/util/ComponentPropType.ts +++ b/packages/ra-core/src/util/ComponentPropType.ts @@ -6,4 +6,5 @@ export default (props, propName, componentName) => { `Invalid prop '${propName}' supplied to '${componentName}': the prop is not a valid React component` ); } + return null; }; diff --git a/packages/ra-ui-materialui/src/auth/Login.tsx b/packages/ra-ui-materialui/src/auth/Login.tsx index 3c5e626679a..2bcb0fd4c10 100644 --- a/packages/ra-ui-materialui/src/auth/Login.tsx +++ b/packages/ra-ui-materialui/src/auth/Login.tsx @@ -29,7 +29,7 @@ import { LoginForm as DefaultLoginForm } from './LoginForm'; */ export const Login = (props: LoginProps) => { const { children = defaultLoginForm, backgroundImage, ...rest } = props; - const containerRef = useRef(); + const containerRef = useRef(null); let backgroundImageLoaded = false; const checkAuth = useCheckAuth(); const navigate = useNavigate(); diff --git a/packages/ra-ui-materialui/src/button/ExportButton.tsx b/packages/ra-ui-materialui/src/button/ExportButton.tsx index 70ab766dff3..bbde25cadb6 100644 --- a/packages/ra-ui-materialui/src/button/ExportButton.tsx +++ b/packages/ra-ui-materialui/src/button/ExportButton.tsx @@ -92,10 +92,8 @@ const defaultIcon = ; const sanitizeRestProps = ({ resource, ...rest -}: Omit< - ExportButtonProps, - 'sort' | 'maxResults' | 'label' | 'exporter' | 'meta' ->) => rest; +}: Omit) => + rest; interface Props { exporter?: Exporter; @@ -115,10 +113,6 @@ ExportButton.propTypes = { label: PropTypes.string, maxResults: PropTypes.number, resource: PropTypes.string, - sort: PropTypes.exact({ - field: PropTypes.string, - order: PropTypes.oneOf(['ASC', 'DESC'] as const), - }), icon: PropTypes.element, meta: PropTypes.any, }; diff --git a/packages/ra-ui-materialui/src/button/PrevNextButtons.spec.tsx b/packages/ra-ui-materialui/src/button/PrevNextButtons.spec.tsx index 6e7087c79c4..b31cba7c093 100644 --- a/packages/ra-ui-materialui/src/button/PrevNextButtons.spec.tsx +++ b/packages/ra-ui-materialui/src/button/PrevNextButtons.spec.tsx @@ -15,8 +15,6 @@ import { describe('', () => { beforeEach(() => { window.scrollTo = jest.fn(); - // avoid logs due to the use of ListGuesser - console.log = jest.fn(); }); afterEach(() => { diff --git a/packages/ra-ui-materialui/src/layout/Layout.tsx b/packages/ra-ui-materialui/src/layout/Layout.tsx index 1477a217d57..4d6d4a11054 100644 --- a/packages/ra-ui-materialui/src/layout/Layout.tsx +++ b/packages/ra-ui-materialui/src/layout/Layout.tsx @@ -23,7 +23,9 @@ export const Layout = (props: LayoutProps) => { ...rest } = props; - const [errorInfo, setErrorInfo] = useState(null); + const [errorInfo, setErrorInfo] = useState( + undefined + ); const handleError = (error: Error, info: ErrorInfo) => { setErrorInfo(info); diff --git a/packages/ra-ui-materialui/src/layout/Notification.tsx b/packages/ra-ui-materialui/src/layout/Notification.tsx index 97c1d1dc5d5..1f77a2ffeb3 100644 --- a/packages/ra-ui-materialui/src/layout/Notification.tsx +++ b/packages/ra-ui-materialui/src/layout/Notification.tsx @@ -9,6 +9,7 @@ import { useNotificationContext, undoableEventEmitter, useTranslate, + NotificationPayload, } from 'ra-core'; const defaultAnchorOrigin: SnackbarOrigin = { @@ -39,7 +40,9 @@ export const Notification = (props: NotificationProps) => { } = props; const { notifications, takeNotification } = useNotificationContext(); const [open, setOpen] = useState(false); - const [messageInfo, setMessageInfo] = React.useState(undefined); + const [currentNotification, setCurrentNotification] = React.useState< + NotificationPayload | undefined + >(undefined); const translate = useTranslate(); useEffect(() => { @@ -50,54 +53,61 @@ export const Notification = (props: NotificationProps) => { return confirmationMessage; }; - if (messageInfo?.notificationOptions?.undoable) { + if (currentNotification?.notificationOptions?.undoable) { window.addEventListener('beforeunload', beforeunload); } - if (notifications.length && !messageInfo) { + if (notifications.length && !currentNotification) { // Set a new snack when we don't have an active one - setMessageInfo(takeNotification()); - setOpen(true); - } else if (notifications.length && messageInfo && open) { + const notification = takeNotification(); + if (notification) { + setCurrentNotification(notification); + setOpen(true); + } + } else if (notifications.length && currentNotification && open) { // Close an active snack when a new one is added setOpen(false); } return () => { - if (messageInfo?.notificationOptions?.undoable) { + if (currentNotification?.notificationOptions?.undoable) { window.removeEventListener('beforeunload', beforeunload); } }; - }, [notifications, messageInfo, open, takeNotification]); + }, [notifications, currentNotification, open, takeNotification]); const handleRequestClose = useCallback(() => { setOpen(false); }, [setOpen]); const handleExited = useCallback(() => { - if (messageInfo && messageInfo.notificationOptions.undoable) { + if ( + currentNotification && + currentNotification.notificationOptions?.undoable + ) { undoableEventEmitter.emit('end', { isUndo: false }); } - setMessageInfo(undefined); - }, [messageInfo]); + setCurrentNotification(undefined); + }, [currentNotification]); const handleUndo = useCallback(() => { undoableEventEmitter.emit('end', { isUndo: true }); setOpen(false); }, []); - if (!messageInfo) return null; + if (!currentNotification) return null; const { message, type: typeFromMessage, - notificationOptions: { - autoHideDuration: autoHideDurationFromMessage, - messageArgs, - multiLine: multilineFromMessage, - undoable, - ...options - }, - } = messageInfo; + notificationOptions, + } = currentNotification; + const { + autoHideDuration: autoHideDurationFromMessage, + messageArgs, + multiLine: multilineFromMessage, + undoable, + ...options + } = notificationOptions || {}; return ( { // as 0 and null are valid values autoHideDurationFromMessage === undefined ? autoHideDuration - : autoHideDurationFromMessage + : autoHideDurationFromMessage ?? undefined } disableWindowBlurListener={undoable} TransitionProps={{ onExited: handleExited }} @@ -140,7 +150,11 @@ export const Notification = (props: NotificationProps) => { {...rest} {...options} > - {message && typeof message !== 'string' ? message : null} + {message && + typeof message !== 'string' && + React.isValidElement(message) + ? message + : undefined} ); }; @@ -166,25 +180,25 @@ const StyledSnackbar = styled(Snackbar, { overridesResolver: (props, styles) => styles.root, })(({ theme, type }: NotificationProps & { theme?: Theme }) => ({ [`& .${NotificationClasses.success}`]: { - backgroundColor: theme.palette.success.main, - color: theme.palette.success.contrastText, + backgroundColor: theme?.palette.success.main, + color: theme?.palette.success.contrastText, }, [`& .${NotificationClasses.error}`]: { - backgroundColor: theme.palette.error.main, - color: theme.palette.error.contrastText, + backgroundColor: theme?.palette.error.main, + color: theme?.palette.error.contrastText, }, [`& .${NotificationClasses.warning}`]: { - backgroundColor: theme.palette.warning.main, - color: theme.palette.warning.contrastText, + backgroundColor: theme?.palette.warning.main, + color: theme?.palette.warning.contrastText, }, [`& .${NotificationClasses.undo}`]: { color: type === 'success' - ? theme.palette.success.contrastText - : theme.palette.primary.light, + ? theme?.palette.success.contrastText + : theme?.palette.primary.light, }, [`& .${NotificationClasses.multiLine}`]: { whiteSpace: 'pre-wrap', diff --git a/packages/ra-ui-materialui/src/layout/Title.tsx b/packages/ra-ui-materialui/src/layout/Title.tsx index 219c24069a3..05ea24906c7 100644 --- a/packages/ra-ui-materialui/src/layout/Title.tsx +++ b/packages/ra-ui-materialui/src/layout/Title.tsx @@ -9,7 +9,7 @@ import { PageTitleConfigurable } from './PageTitleConfigurable'; export const Title = (props: TitleProps) => { const { defaultTitle, title, preferenceKey, ...rest } = props; - const [container, setContainer] = useState(() => + const [container, setContainer] = useState(() => typeof document !== 'undefined' ? document.getElementById('react-admin-title') : null diff --git a/packages/ra-ui-materialui/src/layout/UserMenu.tsx b/packages/ra-ui-materialui/src/layout/UserMenu.tsx index ab27b8b5e01..dd0f60ac210 100644 --- a/packages/ra-ui-materialui/src/layout/UserMenu.tsx +++ b/packages/ra-ui-materialui/src/layout/UserMenu.tsx @@ -107,7 +107,7 @@ export const UserMenu = (props: UserMenuProps) => { * ); */ -export const UserMenuContext = createContext(undefined); +export const UserMenuContext = createContext( + undefined +); export type UserMenuContextValue = { /** diff --git a/packages/ra-ui-materialui/src/list/FilterContext.tsx b/packages/ra-ui-materialui/src/list/FilterContext.tsx index d5bdf879512..31578ad0edc 100644 --- a/packages/ra-ui-materialui/src/list/FilterContext.tsx +++ b/packages/ra-ui-materialui/src/list/FilterContext.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; -export type FilterContextType = React.ReactNode[]; +export type FilterContextType = React.ReactNode[] | undefined; /** * Make filters accessible to sub components diff --git a/packages/ra-ui-materialui/src/list/ListActions.tsx b/packages/ra-ui-materialui/src/list/ListActions.tsx index 30ff4580181..f1a8fb50ca6 100644 --- a/packages/ra-ui-materialui/src/list/ListActions.tsx +++ b/packages/ra-ui-materialui/src/list/ListActions.tsx @@ -3,7 +3,6 @@ import { cloneElement, useMemo, useContext, ReactElement } from 'react'; import PropTypes from 'prop-types'; import { sanitizeListRestProps, - SortPayload, Exporter, useListContext, useResourceContext, @@ -48,7 +47,6 @@ export const ListActions = (props: ListActionsProps) => { const { className, filters: filtersProp, hasCreate: _, ...rest } = props; const { - sort, displayedFilters, filterValues, exporter, @@ -72,12 +70,7 @@ export const ListActions = (props: ListActionsProps) => { : filters && } {hasCreate && } {exporter !== false && ( - + )} ), @@ -91,7 +84,6 @@ export const ListActions = (props: ListActionsProps) => { filters, total, className, - sort, exporter, hasCreate, ] @@ -100,10 +92,6 @@ export const ListActions = (props: ListActionsProps) => { ListActions.propTypes = { className: PropTypes.string, - sort: PropTypes.shape({ - field: PropTypes.string, - order: PropTypes.oneOf(['ASC', 'DESC'] as const), - }), displayedFilters: PropTypes.object, exporter: PropTypes.oneOfType([PropTypes.func, PropTypes.bool]), filters: PropTypes.element, @@ -115,7 +103,6 @@ ListActions.propTypes = { }; export interface ListActionsProps extends ToolbarProps { - sort?: SortPayload; className?: string; resource?: string; filters?: ReactElement; diff --git a/packages/ra-ui-materialui/src/list/ListGuesser.tsx b/packages/ra-ui-materialui/src/list/ListGuesser.tsx index 5c9e3adae73..1991981464b 100644 --- a/packages/ra-ui-materialui/src/list/ListGuesser.tsx +++ b/packages/ra-ui-materialui/src/list/ListGuesser.tsx @@ -71,7 +71,7 @@ export const ListGuesser = ( perPage={perPage} queryOptions={{ placeholderData: previousData => - keepPreviousData ? previousData : null, + keepPreviousData ? previousData : undefined, }} resource={resource} sort={sort} @@ -86,7 +86,7 @@ const ListViewGuesser = ( ) => { const { data } = useListContext(); const resource = useResourceContext(); - const [child, setChild] = useState(null); + const [child, setChild] = useState(null); const { enableLog = process.env.NODE_ENV === 'development', ...rest @@ -107,11 +107,19 @@ const ListViewGuesser = ( null, inferredElements ); - setChild(inferredChild.getElement()); + const inferredChildElement = inferredChild.getElement(); + const representation = inferredChild.getRepresentation(); + if (!resource) { + throw new Error( + 'Cannot use outside of a ResourceContext' + ); + } + if (!inferredChildElement || !representation) { + return; + } - if (!enableLog) return; + setChild(inferredChildElement); - const representation = inferredChild.getRepresentation(); const components = ['List'] .concat( Array.from( @@ -124,20 +132,22 @@ const ListViewGuesser = ( ) .sort(); - // eslint-disable-next-line no-console - console.log( - `Guessed List: + if (enableLog) { + // eslint-disable-next-line no-console + console.log( + `Guessed List: import { ${components.join(', ')} } from 'react-admin'; export const ${inflection.capitalize( - inflection.singularize(resource) - )}List = () => ( + inflection.singularize(resource) + )}List = () => ( ${inferredChild.getRepresentation()} );` - ); + ); + } } }, [data, child, resource, enableLog]); diff --git a/packages/ra-ui-materialui/src/list/ListView.tsx b/packages/ra-ui-materialui/src/list/ListView.tsx index de21d0d35da..ca333ef6695 100644 --- a/packages/ra-ui-materialui/src/list/ListView.tsx +++ b/packages/ra-ui-materialui/src/list/ListView.tsx @@ -59,7 +59,7 @@ export const ListView = ( )} {children} {error ? ( - + {}} /> ) : ( pagination !== false && pagination )} diff --git a/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.tsx b/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.tsx index 822a4bcac7e..013763ba019 100644 --- a/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.tsx +++ b/packages/ra-ui-materialui/src/list/SimpleList/SimpleList.tsx @@ -105,13 +105,6 @@ export const SimpleList = ( ); } - if (data == null || data.length === 0 || total === 0) { - if (empty) { - return empty; - } - - return null; - } const renderAvatar = ( record: RecordType, avatarCallback: FunctionToElement @@ -127,7 +120,15 @@ export const SimpleList = ( } }; - return (total == null && data?.length > 0) || total > 0 ? ( + if (data == null || total == null || data.length === 0 || total === 0) { + if (empty) { + return empty; + } + + return null; + } + + return ( {data.map((record, rowIndex) => ( @@ -232,7 +233,7 @@ export const SimpleList = ( ))} - ) : null; + ); }; SimpleList.propTypes = { @@ -313,15 +314,18 @@ const LinkOrNot = ( const type = typeof linkType === 'function' ? linkType(record, id) : linkType; - return type === false ? ( - - {children} - - ) : ( + if (type === false) { + return ( + + {children} + + ); + } + return ( // @ts-ignore string; export interface LinkOrNotProps { - linkType?: string | FunctionLinkType | false; - resource: string; + linkType: string | FunctionLinkType | false; + resource?: string; id: Identifier; record: RaRecord; children: ReactNode; diff --git a/packages/ra-ui-materialui/src/list/datagrid/DatagridCell.tsx b/packages/ra-ui-materialui/src/list/datagrid/DatagridCell.tsx index 4a7afb77b92..3b8e751f800 100644 --- a/packages/ra-ui-materialui/src/list/datagrid/DatagridCell.tsx +++ b/packages/ra-ui-materialui/src/list/datagrid/DatagridCell.tsx @@ -27,7 +27,7 @@ DatagridCell.propTypes = { export interface DatagridCellProps extends TableCellProps { className?: string; - field?: JSX.Element; + field: JSX.Element; record?: RaRecord; resource?: string; } diff --git a/packages/ra-ui-materialui/src/list/datagrid/DatagridConfigurable.tsx b/packages/ra-ui-materialui/src/list/datagrid/DatagridConfigurable.tsx index f9824c6d29e..b1d62447e4a 100644 --- a/packages/ra-ui-materialui/src/list/datagrid/DatagridConfigurable.tsx +++ b/packages/ra-ui-materialui/src/list/datagrid/DatagridConfigurable.tsx @@ -51,7 +51,7 @@ export const DatagridConfigurable = ({ >(`preferences.${finalPreferenceKey}.availableColumns`, []); // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [_, setOmit] = useStore( + const [_, setOmit] = useStore( `preferences.${finalPreferenceKey}.omit`, omit ); @@ -132,12 +132,17 @@ DatagridConfigurable.propTypes = Datagrid.propTypes; * This Datagrid filters its children depending on preferences */ const DatagridWithPreferences = ({ children, ...props }: DatagridProps) => { - const [availableColumns] = usePreference('availableColumns', []); - const [omit] = usePreference('omit', []); + const [availableColumns] = usePreference( + 'availableColumns', + [] + ); + const [omit] = usePreference('omit', []); const [columns] = usePreference( 'columns', availableColumns - .filter(column => !omit?.includes(column.source)) + .filter(column => + column.source ? !omit?.includes(column.source) : true + ) .map(column => column.index) ); const childrenArray = React.Children.toArray(children); diff --git a/packages/ra-ui-materialui/src/list/datagrid/DatagridRow.tsx b/packages/ra-ui-materialui/src/list/datagrid/DatagridRow.tsx index 0c2c8b1ab6a..215dea415f6 100644 --- a/packages/ra-ui-materialui/src/list/datagrid/DatagridRow.tsx +++ b/packages/ra-ui-materialui/src/list/datagrid/DatagridRow.tsx @@ -62,15 +62,28 @@ const DatagridRow: FC = React.forwardRef((props, ref) => { ...rest } = props; + if (typeof id === 'undefined') { + throw new Error('DatagridRow expects an id prop'); + } const context = useDatagridContext(); const translate = useTranslate(); const record = useRecordContext(props); + if (!record) { + throw new Error( + 'DatagridRow can only be used within a RecordContext or be passed a record prop' + ); + } + const resource = useResourceContext(props); + if (!resource) { + throw new Error( + 'DatagridRow can only be used within a ResourceContext or be passed a resource prop' + ); + } const expandable = (!context || !context.isRowExpandable || context.isRowExpandable(record)) && expand; - const resource = useResourceContext(props); const createPath = useCreatePath(); const [expanded, toggleExpanded] = useExpanded( resource, @@ -105,7 +118,7 @@ const DatagridRow: FC = React.forwardRef((props, ref) => { ); const handleToggleSelection = useCallback( event => { - if (!selectable) return; + if (!selectable || !onToggleItem) return; onToggleItem(id, event); event.stopPropagation(); }, diff --git a/packages/ra-ui-materialui/src/list/datagrid/SelectColumnsButton.tsx b/packages/ra-ui-materialui/src/list/datagrid/SelectColumnsButton.tsx index 54f93e6c2f1..b54d31044cc 100644 --- a/packages/ra-ui-materialui/src/list/datagrid/SelectColumnsButton.tsx +++ b/packages/ra-ui-materialui/src/list/datagrid/SelectColumnsButton.tsx @@ -3,6 +3,7 @@ import { useStore, useTranslate, useResourceContext } from 'ra-core'; import { Box, Button, + ButtonProps, Popover, useMediaQuery, Theme, @@ -55,7 +56,9 @@ export const SelectColumnsButton = (props: SelectColumnsButtonProps) => { const [columns, setColumns] = useStore( `preferences.${finalPreferenceKey}.columns`, availableColumns - .filter(column => !omit?.includes(column.source)) + .filter(column => + column.source ? !omit?.includes(column.source) : true + ) .map(column => column.index) ); const translate = useTranslate(); @@ -191,16 +194,13 @@ const StyledButton = styled(Button, { }, }); -/* eslint-disable @typescript-eslint/no-unused-vars */ const sanitizeRestProps = ({ - resource = null, - preferenceKey = null, + resource, + preferenceKey, ...rest -}) => rest; -/* eslint-enable @typescript-eslint/no-unused-vars */ +}: SelectColumnsButtonProps): ButtonProps => rest; -export interface SelectColumnsButtonProps - extends React.HtmlHTMLAttributes { +export interface SelectColumnsButtonProps extends ButtonProps { resource?: string; preferenceKey?: string; } diff --git a/packages/ra-ui-materialui/src/list/filter/FilterButton.spec.tsx b/packages/ra-ui-materialui/src/list/filter/FilterButton.spec.tsx index c6216dae059..e2af6daf210 100644 --- a/packages/ra-ui-materialui/src/list/filter/FilterButton.spec.tsx +++ b/packages/ra-ui-materialui/src/list/filter/FilterButton.spec.tsx @@ -2,7 +2,11 @@ import * as React from 'react'; import expect from 'expect'; import { render, fireEvent, screen, waitFor } from '@testing-library/react'; import { createTheme } from '@mui/material/styles'; -import { ListContextProvider, ListControllerResult } from 'ra-core'; +import { + ListContextProvider, + ListControllerResult, + ResourceContextProvider, +} from 'ra-core'; import { AdminContext } from '../../AdminContext'; import { FilterButton } from './FilterButton'; @@ -44,12 +48,15 @@ describe('', () => { ); const { getByLabelText, queryByText } = render( - - - + + + + + ); @@ -62,26 +69,28 @@ describe('', () => { it('should display the filter button if all filters are shown and there is a filter value', () => { render( - - , - , - ]} - /> - + + + , + , + ]} + /> + + ); expect( @@ -97,11 +106,15 @@ describe('', () => { ); const { getByLabelText, queryByText } = render( - - - + + + + + ); @@ -187,22 +200,24 @@ describe('', () => { it('should not display save query in filter button', async () => { const { queryByText } = render( - - , - ]} - disableSaveQuery - /> - + + + , + ]} + disableSaveQuery + /> + + ); expect( diff --git a/packages/ra-ui-materialui/src/list/filter/FilterButton.tsx b/packages/ra-ui-materialui/src/list/filter/FilterButton.tsx index b112c3af027..b745e417333 100644 --- a/packages/ra-ui-materialui/src/list/filter/FilterButton.tsx +++ b/packages/ra-ui-materialui/src/list/filter/FilterButton.tsx @@ -28,7 +28,7 @@ import { extractValidSavedQueries, useSavedQueries } from './useSavedQueries'; import { AddSavedQueryDialog } from './AddSavedQueryDialog'; import { RemoveSavedQueryDialog } from './RemoveSavedQueryDialog'; -export const FilterButton = (props: FilterButtonProps): JSX.Element => { +export const FilterButton = (props: FilterButtonProps) => { const { filters: filtersProp, className, @@ -40,7 +40,12 @@ export const FilterButton = (props: FilterButtonProps): JSX.Element => { const filters = useContext(FilterContext) || filtersProp; const resource = useResourceContext(props); const translate = useTranslate(); - const [savedQueries] = useSavedQueries(resource); + if (!resource && !disableSaveQuery) { + throw new Error( + ' must be called inside a ResourceContextProvider, or must provide a resource prop' + ); + } + const [savedQueries] = useSavedQueries(resource || ''); const navigate = useNavigate(); const { displayedFilters = {}, @@ -194,8 +199,8 @@ export const FilterButton = (props: FilterButtonProps): JSX.Element => { filter: JSON.stringify( savedQuery.value.filter ), - sort: savedQuery.value.sort.field, - order: savedQuery.value.sort.order, + sort: savedQuery.value.sort?.field, + order: savedQuery.value.sort?.order, page: 1, perPage: savedQuery.value.perPage, displayedFilters: JSON.stringify( diff --git a/packages/ra-ui-materialui/src/list/filter/FilterButtonMenuItem.tsx b/packages/ra-ui-materialui/src/list/filter/FilterButtonMenuItem.tsx index cd4e38a120d..d479ff98775 100644 --- a/packages/ra-ui-materialui/src/list/filter/FilterButtonMenuItem.tsx +++ b/packages/ra-ui-materialui/src/list/filter/FilterButtonMenuItem.tsx @@ -46,6 +46,6 @@ FilterButtonMenuItem.propTypes = { export interface FilterButtonMenuItemProps { filter: JSX.Element; onShow: (params: { source: string; defaultValue: any }) => void; - resource: string; + resource?: string; autoFocus?: boolean; } diff --git a/packages/ra-ui-materialui/src/list/filter/FilterForm.tsx b/packages/ra-ui-materialui/src/list/filter/FilterForm.tsx index de9aa6d2a95..8e8fdef913a 100644 --- a/packages/ra-ui-materialui/src/list/filter/FilterForm.tsx +++ b/packages/ra-ui-materialui/src/list/filter/FilterForm.tsx @@ -105,6 +105,7 @@ export const FilterFormBase = (props: FilterFormBaseProps) => { const { displayedFilters = {}, hideFilter } = useListContext(); useEffect(() => { + if (!filters) return; filters.forEach((filter: JSX.Element) => { if (filter.props.alwaysOn && filter.props.defaultValue) { throw new Error( @@ -115,6 +116,7 @@ export const FilterFormBase = (props: FilterFormBaseProps) => { }, [filters]); const getShownFilters = () => { + if (!filters) return []; const values = form.getValues(); return filters.filter((filterElement: JSX.Element) => { const filterValue = get(values, filterElement.props.source); diff --git a/packages/ra-ui-materialui/src/list/filter/SavedQueryFilterListItem.tsx b/packages/ra-ui-materialui/src/list/filter/SavedQueryFilterListItem.tsx index f347eed5ab2..f3df65f735a 100644 --- a/packages/ra-ui-materialui/src/list/filter/SavedQueryFilterListItem.tsx +++ b/packages/ra-ui-materialui/src/list/filter/SavedQueryFilterListItem.tsx @@ -46,10 +46,10 @@ export const SavedQueryFilterListItem = memo( navigate({ search: stringify({ filter: JSON.stringify(value.filter), - sort: value.sort.field, - order: value.sort.order, + sort: value.sort?.field, + order: value.sort?.order, page: 1, - perPage: value.perPage, + perPage: value.perPage ?? perPage, displayedFilters: JSON.stringify(value.displayedFilters), }), }); diff --git a/packages/ra-ui-materialui/src/list/pagination/Pagination.tsx b/packages/ra-ui-materialui/src/list/pagination/Pagination.tsx index bdc00cc4b1a..969fbb5b5f8 100644 --- a/packages/ra-ui-materialui/src/list/pagination/Pagination.tsx +++ b/packages/ra-ui-materialui/src/list/pagination/Pagination.tsx @@ -48,7 +48,7 @@ export const Pagination: FC = memo(props => { const handlePageChange = useCallback( (event, page) => { event && event.stopPropagation(); - if (page < 0 || page > totalPages - 1) { + if (page < 0 || (totalPages && page > totalPages - 1)) { throw new Error( translate('ra.navigation.page_out_of_boundaries', { page: page + 1, @@ -94,7 +94,7 @@ export const Pagination: FC = memo(props => { } // Avoid rendering TablePagination if "page" value is invalid - if (total === 0 || page < 1 || (total != null && page > totalPages)) { + if (total === 0 || page < 1 || (total != null && page > totalPages!)) { if (limit != null && process.env.NODE_ENV === 'development') { console.warn( 'The Pagination limit prop is deprecated. Empty state should be handled by the component displaying data (Datagrid, SimpleList).'