UNPKG

ra-core

Version:

Core components of react-admin, a frontend Framework for building admin applications on top of REST services, using ES6, React

592 lines (559 loc) 17.7 kB
import { isValidElement, useEffect, useMemo } from 'react'; import { useAuthenticated, useRequireAccess } from '../../auth'; import { useTranslate } from '../../i18n'; import { useNotify } from '../../notification'; import { useGetList, UseGetListHookValue, UseGetListOptions, } from '../../dataProvider'; import { useResourceContext, useGetResourceLabel } from '../../core'; import { useRecordSelection } from './useRecordSelection'; import { useListParams } from './useListParams'; import { useSelectAll } from './useSelectAll'; import { defaultExporter } from '../../export'; import { SORT_ASC } from './queryReducer'; import type { FilterPayload, SortPayload, RaRecord, Exporter, } from '../../types'; import type { UseReferenceArrayFieldControllerParams, UseReferenceManyFieldControllerParams, } from '../field'; /** * 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 <ListView {...controllerProps} {...props} />; * } */ export const useListController = < RecordType extends RaRecord = any, ErrorType = Error, >( props: ListControllerProps<RecordType, ErrorType> = {} ): ListControllerResult<RecordType, ErrorType> => { const { debounce = 500, disableAuthentication = false, disableSyncWithLocation = false, exporter = defaultExporter, filter, filterDefaultValues, perPage = 10, queryOptions = {}, sort = defaultSort, storeKey, } = props; const resource = useResourceContext(props); const { meta, ...otherQueryOptions } = queryOptions; if (!resource) { throw new Error( `useListController requires a non-empty resource prop or context` ); } if ( filter && (isValidElement(filter) || (Array.isArray(filter) && filter.some(isValidElement))) ) { throw new Error( 'useListController 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 { isPending: isPendingAuthenticated } = useAuthenticated({ enabled: !disableAuthentication, }); const { isPending: isPendingCanAccess } = useRequireAccess<RecordType>({ action: 'list', resource, enabled: !disableAuthentication && !isPendingAuthenticated, }); const translate = useTranslate(); const notify = useNotify(); const [query, queryModifiers] = useListParams({ debounce, disableSyncWithLocation, filterDefaultValues, perPage, resource, sort, storeKey, }); const [selectedIds, selectionModifiers] = useRecordSelection({ resource, disableSyncWithStore: storeKey === false, }); const { data, pageInfo, total, meta: responseMeta, error, isLoading, isFetching, isPending, refetch, isPaused, isPlaceholderData, } = useGetList<RecordType, ErrorType>( resource, { pagination: { page: query.page, perPage: query.perPage, }, sort: { field: query.sort, order: query.order }, filter: { ...query.filter, ...filter }, meta, }, { enabled: (!isPendingAuthenticated && !isPendingCanAccess) || disableAuthentication, placeholderData: previousData => previousData, retry: false, onError: error => notify( (error as Error)?.message || 'ra.notification.http_error', { type: 'error', messageArgs: { _: (error as Error)?.message, }, } ), ...otherQueryOptions, } ); useEffect(() => { if (isPaused && isPlaceholderData) { notify('ra.message.placeholder_data_warning', { type: 'warning', messageArgs: { _: 'Network issue: Data refresh failed.', }, }); } }, [isPaused, isPlaceholderData, notify]); // change page if there is no data useEffect(() => { if ( query.page <= 0 || (!isFetching && query.page > 1 && (data == null || data?.length === 0)) ) { // Query for a page that doesn't exist, set page to 1 queryModifiers.setPage(1); return; } if (total == null) { return; } const totalPages = Math.ceil(total / query.perPage) || 1; if (!isFetching && query.page > totalPages) { // Query for a page out of bounds, set page to the last existing page // It occurs when deleting the last element of the last page queryModifiers.setPage(totalPages); } }, [isFetching, query.page, query.perPage, data, queryModifiers, total]); const currentSort = useMemo( () => ({ field: query.sort, order: query.order, }), [query.sort, query.order] ); const getResourceLabel = useGetResourceLabel(); const defaultTitle = translate(`resources.${resource}.page.list`, { _: translate('ra.page.list', { name: getResourceLabel(resource, 2), }), }); const onSelectAll = useSelectAll({ resource, sort: { field: query.sort, order: query.order }, filter: { ...query.filter, ...filter }, }); return { sort: currentSort, data, meta: responseMeta, defaultTitle, displayedFilters: query.displayedFilters, error, exporter, filter, filterValues: query.filterValues, hideFilter: queryModifiers.hideFilter, isFetching, isLoading, isPending, onSelect: selectionModifiers.select, onSelectAll, onToggleItem: selectionModifiers.toggle, onUnselectItems: selectionModifiers.clearSelection, page: query.page, perPage: query.perPage, refetch, resource, selectedIds, setFilters: queryModifiers.setFilters, setPage: queryModifiers.setPage, setPerPage: queryModifiers.setPerPage, setSort: queryModifiers.setSort, showFilter: queryModifiers.showFilter, total, hasNextPage: pageInfo ? pageInfo.hasNextPage : total != null ? query.page * query.perPage < total : undefined, hasPreviousPage: pageInfo ? pageInfo.hasPreviousPage : query.page > 1, } as ListControllerResult<RecordType, ErrorType>; }; export interface ListControllerProps< RecordType extends RaRecord = any, ErrorType = Error, > { /** * The debounce delay for filter queries in milliseconds. Defaults to 500ms. * * @see https://marmelab.com/react-admin/List.html#debounce * @example * // wait 1 seconds instead of 500 milliseconds befoce calling the dataProvider * const PostList = () => ( * <List debounce={1000}> * ... * </List> * ); */ debounce?: number; /** * Allow anonymous access to the list view. Defaults to false. * * @see https://marmelab.com/react-admin/List.html#disableauthentication * @example * import { List } from 'react-admin'; * * const BoolkList = () => ( * <List disableAuthentication> * ... * </List> * ); */ disableAuthentication?: boolean; /** * Whether to disable the synchronization of the list parameters with the current location (URL search parameters) * * @see https://marmelab.com/react-admin/List.html#disablesyncwithlocation * @example * const Dashboard = () => ( * <div> * // ... * <ResourceContextProvider value="posts"> * <List disableSyncWithLocation> * <SimpleList * primaryText={record => record.title} * secondaryText={record => `${record.views} views`} * tertiaryText={record => new Date(record.published_at).toLocaleDateString()} * /> * </List> * </ResourceContextProvider> * <ResourceContextProvider value="comments"> * <List disableSyncWithLocation> * <SimpleList * primaryText={record => record.title} * secondaryText={record => `${record.views} views`} * tertiaryText={record => new Date(record.published_at).toLocaleDateString()} * /> * </List> * </ResourceContextProvider> * </div> * ) */ disableSyncWithLocation?: boolean; /** * The function called when a user exports the list * * @see https://marmelab.com/react-admin/List.html#exporter * @example * import { List, downloadCSV } from 'react-admin'; * import jsonExport from 'jsonexport/dist'; * * const exporter = posts => { * const postsForExport = posts.map(post => { * const { backLinks, author, ...postForExport } = post; // omit backLinks and author * postForExport.author_name = post.author.name; // add a field * return postForExport; * }); * jsonExport(postsForExport, { * headers: ['id', 'title', 'author_name', 'body'] // order fields in the export * }, (err, csv) => { * downloadCSV(csv, 'posts'); // download as 'posts.csv` file * }); * }; * * const PostList = () => ( * <List exporter={exporter}> * ... * </List> * ) */ exporter?: Exporter<RecordType> | false; /** * Permanent filter applied to all getList queries, regardless of the user selected filters. * * @see https://marmelab.com/react-admin/List.html#filter * @example * export const PostList = () => ( * <List filter={{ is_published: true }}> * ... * </List> * ); */ filter?: FilterPayload; /** * The filter to apply when calling getList if the filter is empty. * * @see https://marmelab.com/react-admin/List.html#filterdefaultvalues * @example * const postFilters = [ * <TextInput label="Search" source="q" alwaysOn />, * <BooleanInput source="is_published" alwaysOn />, * <TextInput source="title" defaultValue="Hello, World!" />, * ]; * * export const PostList = () => ( * <List filters={postFilters} filterDefaultValues={{ is_published: true }}> * ... * </List> * ); */ filterDefaultValues?: object; /** * The number of results per page. Defaults to 10. * * @see https://marmelab.com/react-admin/List.html#perpage * @example * export const PostList = () => ( * <List perPage={25}> * ... * </List> * ); */ perPage?: number; /** * The options passed to react-query's useQuery when calling getList. * * @see https://marmelab.com/react-admin/List.html#queryoptions * @example * import { useNotify, useRedirect, List } from 'react-admin'; * * const PostList = () => { * const notify = useNotify(); * const redirect = useRedirect(); * * const onError = (error) => { * notify(`Could not load list: ${error.message}`, { type: 'error' }); * redirect('/dashboard'); * }; * * return ( * <List queryOptions={{ onError }}> * ... * </List> * ); * } */ queryOptions?: UseGetListOptions<RecordType, ErrorType>; /** * The resource name. Defaults to the resource from ResourceContext. * * @see https://marmelab.com/react-admin/List.html#resource * @example * import { List } from 'react-admin'; * * const PostList = () => ( * <List resource="posts"> * ... * </List> * ); */ resource?: string; /** * The default sort field and order. Defaults to { field: 'id', order: 'ASC' }. * * @see https://marmelab.com/react-admin/List.html#sort * @example * export const PostList = () => ( * <List sort={{ field: 'published_at', order: 'DESC' }}> * ... * </List> * ); */ sort?: SortPayload; /** * The key to use to store the current filter & sort. Pass false to disable. * * @see https://marmelab.com/react-admin/List.html#storekey * @example * const NewerBooks = () => ( * <List * resource="books" * storeKey="newerBooks" * sort={{ field: 'year', order: 'DESC' }} * > * ... * </List> * ); */ storeKey?: string | false; } const defaultSort = { field: 'id', order: SORT_ASC, } as const; export const injectedProps = [ 'sort', 'data', 'defaultTitle', 'displayedFilters', 'error', 'exporter', 'filterValues', 'hasNextPage', 'hasPreviousPage', 'hideFilter', 'isFetching', 'isLoading', 'isPending', 'onSelect', 'onSelectAll', 'onToggleItem', 'onUnselectItems', 'page', 'perPage', 'refetch', 'refresh', 'resource', 'selectedIds', 'setFilters', 'setPage', 'setPerPage', 'setSort', 'showFilter', 'total', 'totalPages', ]; /** * 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 interface ListControllerBaseResult<RecordType extends RaRecord = any> { sort: SortPayload; defaultTitle?: string; displayedFilters: any; exporter?: Exporter | false; filter?: FilterPayload; filterValues: any; hideFilter: (filterName: string) => void; onSelect: (ids: RecordType['id'][]) => void; onSelectAll: (options?: { limit?: number; queryOptions?: | UseGetListOptions<RecordType> | UseReferenceArrayFieldControllerParams<RecordType>['queryOptions'] | UseReferenceManyFieldControllerParams<RecordType>['queryOptions']; }) => void; onToggleItem: (id: RecordType['id']) => void; onUnselectItems: () => void; page: number; perPage: number; refetch: (() => void) | UseGetListHookValue<RecordType>['refetch']; resource: string; selectedIds: RecordType['id'][]; setFilters: ( filters: any, displayedFilters?: any, debounce?: boolean ) => void; setPage: (page: number) => void; setPerPage: (page: number) => void; setSort: (sort: SortPayload) => void; showFilter: (filterName: string, defaultValue: any) => void; hasNextPage?: boolean; hasPreviousPage?: boolean; isFetching?: boolean; isLoading?: boolean; } export interface ListControllerLoadingResult<RecordType extends RaRecord = any> extends ListControllerBaseResult<RecordType> { data: undefined; total: undefined; meta: undefined; error: null; isPending: true; } export interface ListControllerErrorResult< RecordType extends RaRecord = any, TError = Error, > extends ListControllerBaseResult<RecordType> { data: undefined; total: undefined; meta: undefined; error: TError; isPending: false; } export interface ListControllerRefetchErrorResult< RecordType extends RaRecord = any, TError = Error, > extends ListControllerBaseResult<RecordType> { data: RecordType[]; total: number; meta?: any; error: TError; isPending: false; } export interface ListControllerSuccessResult<RecordType extends RaRecord = any> extends ListControllerBaseResult<RecordType> { data: RecordType[]; total: number; meta?: any; error: null; isPending: false; } export type ListControllerResult< RecordType extends RaRecord = any, ErrorType = Error, > = | ListControllerLoadingResult<RecordType> | ListControllerErrorResult<RecordType, ErrorType> | ListControllerRefetchErrorResult<RecordType, ErrorType> | ListControllerSuccessResult<RecordType>;