diff --git a/packages/ra-core/src/controller/ListController.spec.tsx b/packages/ra-core/src/controller/ListController.spec.tsx index 1dd88eac6e5..2584a926afc 100644 --- a/packages/ra-core/src/controller/ListController.spec.tsx +++ b/packages/ra-core/src/controller/ListController.spec.tsx @@ -3,10 +3,11 @@ import { fireEvent, cleanup } from 'react-testing-library'; import lolex from 'lolex'; import TextField from '@material-ui/core/TextField/TextField'; -import ListController, { +import ListController from './ListController'; +import { getListControllerProps, sanitizeListRestProps, -} from './ListController'; +} from './useListController'; import renderWithRedux from '../util/renderWithRedux'; import { CRUD_CHANGE_LIST_PARAMS } from '../actions'; diff --git a/packages/ra-core/src/controller/ListController.tsx b/packages/ra-core/src/controller/ListController.tsx index 76ca0480e82..fba2cd5f9b8 100644 --- a/packages/ra-core/src/controller/ListController.tsx +++ b/packages/ra-core/src/controller/ListController.tsx @@ -1,270 +1,37 @@ -import { isValidElement, ReactNode, ReactElement, useMemo } from 'react'; -import inflection from 'inflection'; +import { ReactNode } from 'react'; -import { SORT_ASC } from '../reducer/admin/resource/list/queryReducer'; -import { ListParams } from '../actions/listActions'; -import { useCheckMinimumRequiredProps } from './checkMinimumRequiredProps'; -import { Sort, AuthProvider, RecordMap, Identifier, Translate } from '../types'; -import { Location } from 'history'; +import useListController, { + ListProps, + ListControllerProps, +} from './useListController'; import { useTranslate } from '../i18n'; -import useListParams from './useListParams'; -import useGetList from './../fetch/useGetList'; -import useRecordSelection from './useRecordSelection'; -import useVersion from './useVersion'; +import { Translate } from '../types'; -interface ChildrenFuncParams { - basePath: string; - currentSort: Sort; - data: RecordMap; - defaultTitle: string; - displayedFilters: any; - filterValues: any; - hasCreate: boolean; - hideFilter: (filterName: string) => void; - ids: Identifier[]; - isLoading: boolean; - loadedOnce: boolean; - onSelect: (ids: Identifier[]) => void; - onToggleItem: (id: Identifier) => void; - onUnselectItems: () => void; - page: number; - perPage: number; - resource: string; - selectedIds: Identifier[]; - setFilters: (filters: any) => void; - setPage: (page: number) => void; - setPerPage: (page: number) => void; - setSort: (sort: Sort) => void; - showFilter: (filterName: string, defaultValue: any) => void; +interface ListControllerComponentProps extends ListControllerProps { translate: Translate; - total: number; - version: number; } -interface Props { - // the props you can change - children: (params: ChildrenFuncParams) => ReactNode; - filter?: object; - filters?: ReactElement; - filterDefaultValues?: object; - pagination?: ReactElement; - perPage?: number; - sort?: Sort; - // the props managed by react-admin - authProvider?: AuthProvider; - basePath: string; - debounce?: number; - hasCreate?: boolean; - hasEdit?: boolean; - hasList?: boolean; - hasShow?: boolean; - location: Location; - path?: string; - query: ListParams; - resource: string; - [key: string]: any; +interface Props extends ListProps { + children: (params: ListControllerComponentProps) => ReactNode; } -const defaultSort = { - field: 'id', - order: SORT_ASC, -}; /** - * List page component - * - * The component renders the list layout (title, buttons, filters, pagination), - * and fetches the list of records from the REST API. - * It then delegates the rendering of the list of records to its child component. - * Usually, it's a , responsible for displaying a table with one row for each post. - * - * In Redux terms, is a connected component, and is a dumb component. - * - * Props: - * - title - * - perPage - * - sort - * - filter (the permanent filter to apply to the query) - * - actions - * - filters (a React component used to display the filter form) - * - pagination + * Render prop version of the useListController hook. * + * @see useListController * @example - * const PostFilter = (props) => ( - * - * - * - * - * ); - * export const PostList = (props) => ( - * } - * > - * - * - * - * - * - * - * ); + * + * const ListView = () =>
...
; + * const List = props => ( + * + * {controllerProps => } + * + * ) */ -const ListController = (props: Props) => { - useCheckMinimumRequiredProps( - 'List', - ['basePath', 'location', 'resource', 'children'], - props - ); - if (props.filter && isValidElement(props.filter)) { - throw new Error( - ' received a React element as `filter` props. If you intended to set the list filter elements, use the `filters` (with an s) prop instead. The `filter` prop is internal and should not be set by the developer.' - ); - } - - const { - basePath, - children, - resource, - hasCreate, - location, - filterDefaultValues, - sort = defaultSort, - perPage = 10, - filter, - debounce = 500, - } = props; - - const translate = useTranslate(); - const version = useVersion(); - - const [query, queryModifiers] = useListParams({ - resource, - location, - filterDefaultValues, - sort, - perPage, - debounce, - }); - - const [selectedIds, selectionModifiers] = useRecordSelection(resource); - - const { data, ids, total, loading, loaded } = useGetList( - resource, - { - page: query.page, - perPage: query.perPage, - }, - { field: query.sort, order: query.order }, - { ...query.filter, ...filter }, - { - version, - onFailure: { - notification: { - body: 'ra.notification.http_error', - level: 'warning', - }, - }, - } - ); - - if (!query.page && !(ids || []).length && query.page > 1 && total > 0) { - // query for a page that doesn't exist, check the previous page - queryModifiers.setPage(query.page - 1); - } - - const currentSort = useMemo( - () => ({ - field: query.sort, - order: query.order, - }), - [query.sort, query.order] - ); - - const resourceName = translate(`resources.${resource}.name`, { - smart_count: 2, - _: inflection.humanize(inflection.pluralize(resource)), - }); - const defaultTitle = translate('ra.page.list', { - name: resourceName, - }); - - return children({ - basePath, - currentSort, - data, - defaultTitle, - displayedFilters: query.displayedFilters, - filterValues: query.filterValues, - hasCreate, - ids, - isLoading: loading, - loadedOnce: loaded, - onSelect: selectionModifiers.select, - onToggleItem: selectionModifiers.toggle, - onUnselectItems: selectionModifiers.clearSelection, - page: query.page, - perPage: query.perPage, - resource, - selectedIds, - setFilters: queryModifiers.setFilters, - hideFilter: queryModifiers.hideFilter, - showFilter: queryModifiers.showFilter, - setPage: queryModifiers.setPage, - setPerPage: queryModifiers.setPerPage, - setSort: queryModifiers.setSort, - translate, - total, - version, - }); +const ListController = ({ children, ...props }: Props) => { + const controllerProps = useListController(props); + const translate = useTranslate(); // injected for backwards compatibility + return children({ translate, ...controllerProps }); }; -export const injectedProps = [ - 'basePath', - 'currentSort', - 'data', - 'defaultTitle', - 'displayedFilters', - 'filterValues', - 'hasCreate', - 'hideFilter', - 'ids', - 'isLoading', - 'loadedOnce', - 'onSelect', - 'onToggleItem', - 'onUnselectItems', - 'page', - 'perPage', - 'refresh', - 'resource', - 'selectedIds', - 'setFilters', - 'setPage', - 'setPerPage', - 'setSort', - 'showFilter', - 'total', - 'translate', - 'version', -]; - -/** - * Select the props injected by the ListController - * to be passed to the List children need - * This is an implementation of pick() - */ -export const getListControllerProps = props => - injectedProps.reduce((acc, key) => ({ ...acc, [key]: props[key] }), {}); - -/** - * Select the props not injected by the ListController - * to be used inside the List children to sanitize props injected by List - * This is an implementation of omit() - */ -export const sanitizeListRestProps = props => - Object.keys(props) - .filter(propName => !injectedProps.includes(propName)) - .reduce((acc, key) => ({ ...acc, [key]: props[key] }), {}); - export default ListController; diff --git a/packages/ra-core/src/controller/index.ts b/packages/ra-core/src/controller/index.ts index 2684a51ba08..171f069516d 100644 --- a/packages/ra-core/src/controller/index.ts +++ b/packages/ra-core/src/controller/index.ts @@ -1,15 +1,17 @@ -import { - getListControllerProps, - sanitizeListRestProps, -} from './ListController'; import CreateController from './CreateController'; import EditController from './EditController'; import ListController from './ListController'; import ShowController from './ShowController'; +import { + getListControllerProps, + sanitizeListRestProps, +} from './useListController'; import useRecordSelection from './useRecordSelection'; import useVersion from './useVersion'; import useSortState from './useSortState'; import usePaginationState from './usePaginationState'; +import useListController from './useListController'; +import { useCheckMinimumRequiredProps } from './checkMinimumRequiredProps'; export { getListControllerProps, sanitizeListRestProps, @@ -17,6 +19,8 @@ export { EditController, ListController, ShowController, + useCheckMinimumRequiredProps, + useListController, useRecordSelection, useVersion, useSortState, diff --git a/packages/ra-core/src/controller/useListController.tsx b/packages/ra-core/src/controller/useListController.tsx new file mode 100644 index 00000000000..524e79e62cb --- /dev/null +++ b/packages/ra-core/src/controller/useListController.tsx @@ -0,0 +1,241 @@ +import { isValidElement, ReactElement, useMemo } from 'react'; +import inflection from 'inflection'; +import { Location } from 'history'; + +import { useCheckMinimumRequiredProps } from './checkMinimumRequiredProps'; +import useListParams from './useListParams'; +import useRecordSelection from './useRecordSelection'; +import useVersion from './useVersion'; +import { useTranslate } from '../i18n'; +import { SORT_ASC } from '../reducer/admin/resource/list/queryReducer'; +import { ListParams } from '../actions/listActions'; +import { Sort, RecordMap, Identifier } from '../types'; +import useGetList from './../fetch/useGetList'; + +export interface ListProps { + // the props you can change + filter?: object; + filters?: ReactElement; + filterDefaultValues?: object; + pagination?: ReactElement; + perPage?: number; + sort?: Sort; + // the props managed by react-admin + basePath: string; + debounce?: number; + hasCreate?: boolean; + hasEdit?: boolean; + hasList?: boolean; + hasShow?: boolean; + location: Location; + path?: string; + query: ListParams; + resource: string; + [key: string]: any; +} + +const defaultSort = { + field: 'id', + order: SORT_ASC, +}; + +export interface ListControllerProps { + basePath: string; + currentSort: Sort; + data: RecordMap; + defaultTitle: string; + displayedFilters: any; + filterValues: any; + hasCreate: boolean; + hideFilter: (filterName: string) => void; + ids: Identifier[]; + isLoading: boolean; + loadedOnce: boolean; + onSelect: (ids: Identifier[]) => void; + onToggleItem: (id: Identifier) => void; + onUnselectItems: () => void; + page: number; + perPage: number; + resource: string; + selectedIds: Identifier[]; + setFilters: (filters: any) => void; + setPage: (page: number) => void; + setPerPage: (page: number) => void; + setSort: (sort: Sort) => void; + showFilter: (filterName: string, defaultValue: any) => void; + total: number; + version: number; +} + +/** + * Prepare data for the List view + * + * @param {Object} props The props passed to the List component. + * + * @return {Object} controllerProps Fetched and computed data for the List view + * + * @example + * + * import {useListController } from 'react-admin'; + * import ListView from './ListView'; + * + * const MyList = props => { + * const controllerProps = useListController(props); + * return ; + * } + */ +const useListController = (props: ListProps): ListControllerProps => { + useCheckMinimumRequiredProps( + 'List', + ['basePath', 'location', 'resource'], + props + ); + + const { + basePath, + resource, + hasCreate, + location, + filterDefaultValues, + sort = defaultSort, + perPage = 10, + filter, + debounce = 500, + } = props; + + if (filter && isValidElement(filter)) { + throw new Error( + ' received a React element as `filter` props. If you intended to set the list filter elements, use the `filters` (with an s) prop instead. The `filter` prop is internal and should not be set by the developer.' + ); + } + const translate = useTranslate(); + const version = useVersion(); + + const [query, queryModifiers] = useListParams({ + resource, + location, + filterDefaultValues, + sort, + perPage, + debounce, + }); + + const [selectedIds, selectionModifiers] = useRecordSelection(resource); + + const { data, ids, total, loading, loaded } = useGetList( + resource, + { + page: query.page, + perPage: query.perPage, + }, + { field: query.sort, order: query.order }, + { ...query.filter, ...filter }, + { + version, + onFailure: { + notification: { + body: 'ra.notification.http_error', + level: 'warning', + }, + }, + } + ); + + if (!query.page && !(ids || []).length && query.page > 1 && total > 0) { + // query for a page that doesn't exist, check the previous page + queryModifiers.setPage(query.page - 1); + } + + const currentSort = useMemo( + () => ({ + field: query.sort, + order: query.order, + }), + [query.sort, query.order] + ); + + const resourceName = translate(`resources.${resource}.name`, { + smart_count: 2, + _: inflection.humanize(inflection.pluralize(resource)), + }); + const defaultTitle = translate('ra.page.list', { + name: resourceName, + }); + + return { + basePath, + currentSort, + data, + defaultTitle, + displayedFilters: query.displayedFilters, + filterValues: query.filterValues, + hasCreate, + ids, + isLoading: loading, + loadedOnce: loaded, + onSelect: selectionModifiers.select, + onToggleItem: selectionModifiers.toggle, + onUnselectItems: selectionModifiers.clearSelection, + page: query.page, + perPage: query.perPage, + resource, + selectedIds, + setFilters: queryModifiers.setFilters, + hideFilter: queryModifiers.hideFilter, + showFilter: queryModifiers.showFilter, + setPage: queryModifiers.setPage, + setPerPage: queryModifiers.setPerPage, + setSort: queryModifiers.setSort, + total, + version, + }; +}; + +export const injectedProps = [ + 'basePath', + 'currentSort', + 'data', + 'defaultTitle', + 'displayedFilters', + 'filterValues', + 'hasCreate', + 'hideFilter', + 'ids', + 'isLoading', + 'loadedOnce', + 'onSelect', + 'onToggleItem', + 'onUnselectItems', + 'page', + 'perPage', + 'refresh', + 'resource', + 'selectedIds', + 'setFilters', + 'setPage', + 'setPerPage', + 'setSort', + 'showFilter', + 'total', + 'version', +]; + +/** + * Select the props injected by the useListController hook + * to be passed to the List children need + * This is an implementation of pick() + */ +export const getListControllerProps = props => + injectedProps.reduce((acc, key) => ({ ...acc, [key]: props[key] }), {}); + +/** + * Select the props not injected by the useListController hook + * to be used inside the List children to sanitize props injected by List + * This is an implementation of omit() + */ +export const sanitizeListRestProps = props => + Object.keys(props) + .filter(propName => !injectedProps.includes(propName)) + .reduce((acc, key) => ({ ...acc, [key]: props[key] }), {}); + +export default useListController; diff --git a/packages/ra-ui-materialui/src/list/List.js b/packages/ra-ui-materialui/src/list/List.js index bd95e20291e..26e040ec597 100644 --- a/packages/ra-ui-materialui/src/list/List.js +++ b/packages/ra-ui-materialui/src/list/List.js @@ -2,9 +2,10 @@ import React, { Children, cloneElement } from 'react'; import PropTypes from 'prop-types'; import Card from '@material-ui/core/Card'; import classnames from 'classnames'; -import { withStyles, createStyles } from '@material-ui/core/styles'; +import { makeStyles } from '@material-ui/core/styles'; import { - ListController, + useCheckMinimumRequiredProps, + useListController, getListControllerProps, ComponentPropType, } from 'ra-core'; @@ -19,7 +20,7 @@ import defaultTheme from '../defaultTheme'; const DefaultBulkActionButtons = props => ; -export const styles = createStyles(theme => ({ +export const useStyles = makeStyles(theme => ({ root: {}, main: { display: 'flex', @@ -101,13 +102,12 @@ const sanitizeRestProps = ({ title, toggleItem, total, - translate, version, ...rest }) => rest; -export const ListView = withStyles(styles)( - ({ +export const ListView = props => { + const { actions, aside, filter, @@ -117,64 +117,64 @@ export const ListView = withStyles(styles)( pagination, children, className, - classes, + classes: classesOverride, component: Content, exporter, title, ...rest - }) => { - const { defaultTitle, version } = rest; - const controllerProps = getListControllerProps(rest); + } = props; + useCheckMinimumRequiredProps('List', ['children'], props); + const classes = useStyles({ classes: classesOverride }); + const { defaultTitle, version } = rest; + const controllerProps = getListControllerProps(rest); - return ( -
- + return ( + <div + className={classnames('list-page', classes.root, className)} + {...sanitizeRestProps(rest)} + > + <Title title={title} defaultTitle={defaultTitle} /> - {(filters || actions) && ( - <ListToolbar - filters={filters} - {...controllerProps} - actions={actions} - bulkActions={bulkActions} - exporter={exporter} - permanentFilter={filter} - /> - )} - <div className={classes.main}> - <Content - className={classnames(classes.content, { - [classes.bulkActionsDisplayed]: - controllerProps.selectedIds.length > 0, + {(filters || actions) && ( + <ListToolbar + filters={filters} + {...controllerProps} + actions={actions} + bulkActions={bulkActions} + exporter={exporter} + permanentFilter={filter} + /> + )} + <div className={classes.main}> + <Content + className={classnames(classes.content, { + [classes.bulkActionsDisplayed]: + controllerProps.selectedIds.length > 0, + })} + key={version} + > + {bulkActions !== false && + bulkActionButtons !== false && + bulkActionButtons && + !bulkActions && ( + <BulkActionsToolbar {...controllerProps}> + {bulkActionButtons} + </BulkActionsToolbar> + )} + {children && + cloneElement(Children.only(children), { + ...controllerProps, + hasBulkActions: + bulkActions !== false && + bulkActionButtons !== false, })} - key={version} - > - {bulkActions !== false && - bulkActionButtons !== false && - bulkActionButtons && - !bulkActions && ( - <BulkActionsToolbar {...controllerProps}> - {bulkActionButtons} - </BulkActionsToolbar> - )} - {children && - cloneElement(Children.only(children), { - ...controllerProps, - hasBulkActions: - bulkActions !== false && - bulkActionButtons !== false, - })} - {pagination && - cloneElement(pagination, controllerProps)} - </Content> - {aside && cloneElement(aside, controllerProps)} - </div> + {pagination && cloneElement(pagination, controllerProps)} + </Content> + {aside && cloneElement(aside, controllerProps)} </div> - ); - } -); + </div> + ); +}; ListView.propTypes = { actions: PropTypes.element, @@ -217,7 +217,6 @@ ListView.propTypes = { showFilter: PropTypes.func, title: TitlePropType, total: PropTypes.number, - translate: PropTypes.func, version: PropTypes.number, }; @@ -270,11 +269,10 @@ ListView.defaultProps = { * </List> * ); */ -const List = props => ( - <ListController {...props}> - {controllerProps => <ListView {...props} {...controllerProps} />} - </ListController> -); +const List = props => { + const controllerProps = useListController(props); + return <ListView {...props} {...controllerProps} />; +}; List.propTypes = { // the props you can change diff --git a/packages/ra-ui-materialui/src/list/ListGuesser.js b/packages/ra-ui-materialui/src/list/ListGuesser.js index d5f87e0c673..4c1f6202a89 100644 --- a/packages/ra-ui-materialui/src/list/ListGuesser.js +++ b/packages/ra-ui-materialui/src/list/ListGuesser.js @@ -1,21 +1,18 @@ -import React, { Component } from 'react'; +import React, { useState, useEffect } from 'react'; import inflection from 'inflection'; -import { withStyles } from '@material-ui/core/styles'; import { - ListController, + useListController, getElementsFromRecords, InferredElement, } from 'ra-core'; -import { ListView, styles } from './List'; +import { ListView } from './List'; import listFieldTypes from './listFieldTypes'; -export class ListViewGuesser extends Component { - state = { - inferredChild: null, - }; - componentDidUpdate() { - const { ids, data, resource } = this.props; - if (ids.length > 0 && data && !this.state.inferredChild) { +const ListViewGuesser = props => { + const { ids, data, resource } = props; + const [inferredChild, setInferredChild] = useState(null); + useEffect(() => { + if (ids.length > 0 && data && !inferredChild) { const inferredElements = getElementsFromRecords( ids.map(id => data[id]), listFieldTypes @@ -39,21 +36,18 @@ ${inferredChild.getRepresentation()} </List> );` ); - this.setState({ inferredChild: inferredChild.getElement() }); + setInferredChild(inferredChild.getElement()); } - } + }, [data, ids, inferredChild, resource]); - render() { - return <ListView {...this.props}>{this.state.inferredChild}</ListView>; - } -} + return <ListView {...props}>{inferredChild}</ListView>; +}; ListViewGuesser.propTypes = ListView.propTypes; -const ListGuesser = props => ( - <ListController {...props}> - {controllerProps => <ListViewGuesser {...props} {...controllerProps} />} - </ListController> -); +const ListGuesser = props => { + const controllerProps = useListController(props); + return <ListViewGuesser {...props} {...controllerProps} />; +}; -export default withStyles(styles)(ListGuesser); +export default ListGuesser;