diff --git a/UPGRADE.md b/UPGRADE.md index 4fd25da680c..112127ab867 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -809,6 +809,56 @@ const App = () => { } ``` +## Custom Menus Should Get Resource Definition From Context + +React-admin used to store the definitino of each resource (its name, icon, label, etc.) in the Redux state. This is no longer the case, as the resource definition is now stored in a custom context. + +If you relied on the `useResourceDefinition` hook, this change shouldn't affect you. + +If you need to access the definitions of all resources, however, you must upgrade your code, and use the new `useResourceDefinitions` hook. + +The most common use case is when you override the default `` component: + +```diff +// in src/Menu.js +import * as React from 'react'; +import { createElement } from 'react'; +-import { useSelector } from 'react-redux'; +import { useMediaQuery } from '@material-ui/core'; +-import { DashboardMenuItem, Menu, MenuItemLink, getResources } from 'react-admin'; ++import { DashboardMenuItem, Menu, MenuItemLink, useResourceDefinitions } from 'react-admin'; +import DefaultIcon from '@material-ui/icons/ViewList'; +import LabelIcon from '@material-ui/icons/Label'; + +export const Menu = (props) => { +- const resources = useSelector(getResources); ++ const resourcesDefinitions = useResourceDefinitions(); ++ const resources = Object.keys(resourcesDefinitions).map(name => resourcesDefinitions[name]); + const open = useSelector(state => state.admin.ui.sidebarOpen); + return ( + + + {resources.map(resource => ( + : + } + onClick={props.onMenuClick} + sidebarIsOpen={open} + /> + ))} + {/* add your custom menus here */} + + ); +}; +``` + ## No More Prop Injection In Page Components Page components (``, ``, etc.) used to expect to receive props (route parameters, permissions, resource name). These components don't receive any props anymore by default. They use hooks to get the props they need from contexts or route state. @@ -990,13 +1040,79 @@ const CommentGrid = () => { ## Removed Reducers -React-admin no longer relies on Redux to fetch relationships. Instead, the cache of previously fetched relationships is managed by react-query. +If your code used `useSelector` to read the react-admin application state, it will likely break. React-admin v4 uses Redux much less than v3, and the shape of the Redux state has changed. + +React-admin no longer uses Redux for **data fetching**. Instead, it uses react-query. If you used to read data from the Redux store (which was a bad practice by the way), you'll have to use specialized data provider hooks instead. + +```diff +import * as React from "react"; +-import { useSelector } from 'react-redux'; ++import { useGetOne } from 'react-admin'; +import { Loading, Error } from '../ui'; + +const UserProfile = ({ record }) => { +- const data = useSelector(state => state.resources.users.data[record.id]); ++ const { data, isLoading, error } = useGetOne( ++ 'users', ++ { id: record.id } ++ ); ++ if (isLoading) { return ; } ++ if (error) { return ; } + return
User {data.username}
; +}; +``` + +Besides, the `loadedOnce` reducer, used internally for the previous version of the List page logic, is no longer necessary and has been removed. + +React-admin no longer relies on Redux to fetch **relationships**. Instead, the cache of previously fetched relationships is managed by react-query. If you need to get the records related to the current one via a one-to-many relationship (e.g. to fetch all the books of a given author), you can use the `useGetManyReference` hook instead of the `oneToMany` reducer. If you need to get possible values for a relationship, use the `useGetList` hook instead of the `possibleValues` reducer. -Besides, the `loadedOnce` reducer, used internally for the previous version of the List page logic, is no longer necessary and has been removed. +React-admin no longer uses Redux for **resource definitions**. Instead, it uses a custom context. If you used the `useResourceDefinition` hook, this change is backwards compatible. But if you used to read the Redux state directly, you'll have to upgrade your code. This often happens for custom menus, using the `getResources` selector: + +```diff +// in src/Menu.js +import * as React from 'react'; +import { createElement } from 'react'; +-import { useSelector } from 'react-redux'; +import { useMediaQuery } from '@material-ui/core'; +-import { DashboardMenuItem, Menu, MenuItemLink, getResources } from 'react-admin'; ++import { DashboardMenuItem, Menu, MenuItemLink, useResourceDefinitions } from 'react-admin'; +import DefaultIcon from '@material-ui/icons/ViewList'; +import LabelIcon from '@material-ui/icons/Label'; + +export const Menu = (props) => { +- const resources = useSelector(getResources); ++ const resourcesDefinitions = useResourceDefinitions(); ++ const resources = Object.keys(resourcesDefinitions).map(name => resourcesDefinitions[name]); + const open = useSelector(state => state.admin.ui.sidebarOpen); + return ( + + + {resources.map(resource => ( + : + } + onClick={props.onMenuClick} + sidebarIsOpen={open} + /> + ))} + {/* add your custom menus here */} + + ); +}; +``` + +Reducers for the **list parameters** (current sort & filters, selected ids, expanded rows) have moved up to the root reducer (so they don't need the resource to be registered first). This shouldn't impact you if you used the react-admin hooks (`useListParams`, `useSelection`) to read the state. ## Redux-Saga Was Removed diff --git a/docs/Admin.md b/docs/Admin.md index 188c483ef05..6b580d935b3 100644 --- a/docs/Admin.md +++ b/docs/Admin.md @@ -217,21 +217,22 @@ import * as React from 'react'; import { createElement } from 'react'; import { useSelector } from 'react-redux'; import { useMediaQuery } from '@material-ui/core'; -import { MenuItemLink, getResources } from 'react-admin'; +import { MenuItemLink, useResourceDefinitions } from 'react-admin'; import LabelIcon from '@material-ui/icons/Label'; const Menu = ({ onMenuClick, logout }) => { const isXSmall = useMediaQuery(theme => theme.breakpoints.down('xs')); const open = useSelector(state => state.admin.ui.sidebarOpen); - const resources = useSelector(getResources); + const resources = useResourceDefinitions(); + return (
- {resources.map(resource => ( + {Object.keys(resources).map(name => ( @@ -296,7 +297,7 @@ For more details on predefined themes and custom themes, refer to the [Material ## `layout` -If you want to deeply customize the app header, the menu, or the notifications, the best way is to provide a custom layout component. It must contain a `{children}` placeholder, where react-admin will render the resources. If you use material UI fields and inputs, it should contain a `` element. And finally, if you want to show the spinner in the app header when the app fetches data in the background, the Layout should connect to the redux store. +If you want to deeply customize the app header, the menu, or the notifications, the best way is to provide a custom layout component. It must contain a `{children}` placeholder, where react-admin will render the resources. Use the [default layout](https://github.com/marmelab/react-admin/blob/master/packages/ra-ui-materialui/src/layout/Layout.tsx) as a starting point, and check [the Theming documentation](./Theming.md#using-a-custom-layout) for examples. diff --git a/docs/Resource.md b/docs/Resource.md index e932ea9535d..afd9d724a5f 100644 --- a/docs/Resource.md +++ b/docs/Resource.md @@ -11,9 +11,9 @@ In react-admin terms, a *resource* is a string that refers to an entity type (li A `` component has 3 responsibilities: -- It defines the page components to use for interacting with the resource records (to display a list of records, the details of a record, or to create a new one). -- It initializes the internal data store so that react-admin components can see it as a mirror of the API for a given resource. -- It creates a context that lets every descendent component know in which resource they are used (this context is called `ResourceContext`). +- It defines the components for the CRUD routes of a given resource (to display a list of records, the details of a record, or to create a new one). +- It creates a context that lets every descendent component know the current resource name (this context is called `ResourceContext`). +- It stores the resource definition (its name, icon, and label) inside a shared context (this context is called `ResourceDefinitionContext`). `` components can only be used as children of [the `` component](./Admin.md). @@ -44,8 +44,6 @@ const App = () => ( ); ``` -**Tip**: You must add a `` when you declare a reference (via ``, ``, ``, `` or ``), because react-admin uses resources to define the data store structure. That's why there is an empty `tags` resource in the example above. - **Tip**: How does a resource map to an API endpoint? The `` component doesn't know this mapping - it's [the `dataProvider`'s job](./DataProviders.md) to define it. ## `name` diff --git a/docs/Theming.md b/docs/Theming.md index bea959ed847..c1a3687273e 100644 --- a/docs/Theming.md +++ b/docs/Theming.md @@ -853,7 +853,7 @@ const App = () => ( ); ``` -**Tip**: You can generate the menu items for each of the resources by reading the Resource configurations from the Redux store: +**Tip**: You can generate the menu items for each of the resources by reading the Resource configurations context: ```jsx // in src/Menu.js @@ -861,26 +861,26 @@ import * as React from 'react'; import { createElement } from 'react'; import { useSelector } from 'react-redux'; import { useMediaQuery } from '@material-ui/core'; -import { DashboardMenuItem, Menu, MenuItemLink, getResources } from 'react-admin'; +import { DashboardMenuItem, Menu, MenuItemLink, useResourceDefinitions } from 'react-admin'; import DefaultIcon from '@material-ui/icons/ViewList'; import LabelIcon from '@material-ui/icons/Label'; export const Menu = (props) => { - const resources = useSelector(getResources); + const resources = useResourceDefinitions() const open = useSelector(state => state.admin.ui.sidebarOpen); return ( - {resources.map(resource => ( + {Object.keys(resources).map(name => ( : + resources[name].icon ? : } onClick={props.onMenuClick} sidebarIsOpen={open} diff --git a/packages/ra-core/src/actions/index.ts b/packages/ra-core/src/actions/index.ts index 504c6f9b2db..b2698b56bd0 100644 --- a/packages/ra-core/src/actions/index.ts +++ b/packages/ra-core/src/actions/index.ts @@ -1,8 +1,6 @@ export * from './clearActions'; export * from './filterActions'; export * from './listActions'; -export * from './localeActions'; export * from './notificationActions'; -export * from './resourcesActions'; export * from './uiActions'; export * from './undoActions'; diff --git a/packages/ra-core/src/actions/localeActions.ts b/packages/ra-core/src/actions/localeActions.ts deleted file mode 100644 index ac93927e4cc..00000000000 --- a/packages/ra-core/src/actions/localeActions.ts +++ /dev/null @@ -1,52 +0,0 @@ -export const CHANGE_LOCALE = 'RA/CHANGE_LOCALE'; - -export interface ChangeLocaleAction { - readonly type: typeof CHANGE_LOCALE; - readonly payload: string; -} - -export const changeLocale = (locale: string): ChangeLocaleAction => ({ - type: CHANGE_LOCALE, - payload: locale, -}); - -export const CHANGE_LOCALE_SUCCESS = 'RA/CHANGE_LOCALE_SUCCESS'; - -export interface ChangeLocaleSuccessAction { - readonly type: typeof CHANGE_LOCALE_SUCCESS; - readonly payload: { - locale: string; - messages: any; - }; -} - -export const changeLocaleSuccess = ( - locale: string, - messages: any -): ChangeLocaleSuccessAction => ({ - type: CHANGE_LOCALE_SUCCESS, - payload: { - locale, - messages, - }, -}); - -export const CHANGE_LOCALE_FAILURE = 'RA/CHANGE_LOCALE_FAILURE'; - -export interface ChangeLocaleFailureAction { - readonly type: typeof CHANGE_LOCALE_FAILURE; - readonly error: any; - readonly payload: { - locale: string; - }; -} -export const changeLocaleFailure = ( - locale: string, - error: any -): ChangeLocaleFailureAction => ({ - type: CHANGE_LOCALE_FAILURE, - error, - payload: { - locale, - }, -}); diff --git a/packages/ra-core/src/actions/resourcesActions.ts b/packages/ra-core/src/actions/resourcesActions.ts deleted file mode 100644 index c2f230baa88..00000000000 --- a/packages/ra-core/src/actions/resourcesActions.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { ResourceDefinition } from '../types'; - -export const REGISTER_RESOURCE = 'RA/REGISTER_RESOURCE'; - -export interface RegisterResourceAction { - readonly type: typeof REGISTER_RESOURCE; - readonly payload: ResourceDefinition; -} - -export const registerResource = ( - resource: ResourceDefinition -): RegisterResourceAction => ({ - type: REGISTER_RESOURCE, - payload: resource, -}); - -export const UNREGISTER_RESOURCE = 'RA/UNREGISTER_RESOURCE'; - -export interface UnregisterResourceAction { - readonly type: typeof UNREGISTER_RESOURCE; - readonly payload: string; -} - -export const unregisterResource = ( - resourceName: string -): UnregisterResourceAction => ({ - type: UNREGISTER_RESOURCE, - payload: resourceName, -}); diff --git a/packages/ra-core/src/controller/input/useReferenceArrayInputController.spec.tsx b/packages/ra-core/src/controller/input/useReferenceArrayInputController.spec.tsx index bf4113aeb16..a29918fe8ee 100644 --- a/packages/ra-core/src/controller/input/useReferenceArrayInputController.spec.tsx +++ b/packages/ra-core/src/controller/input/useReferenceArrayInputController.spec.tsx @@ -7,7 +7,7 @@ import { Form } from 'react-final-form'; import { useReferenceArrayInputController } from './useReferenceArrayInputController'; import { CoreAdminContext } from '../../core'; import { testDataProvider } from '../../dataProvider'; -import { SORT_ASC } from '../../reducer/admin/resource/list/queryReducer'; +import { SORT_ASC } from '../list/queryReducer'; const ReferenceArrayInputController = props => { const { children, ...rest } = props; diff --git a/packages/ra-core/src/controller/list/index.ts b/packages/ra-core/src/controller/list/index.ts index ef3682aeab2..f3569edaae7 100644 --- a/packages/ra-core/src/controller/list/index.ts +++ b/packages/ra-core/src/controller/list/index.ts @@ -12,3 +12,4 @@ export * from './useListFilterContext'; export * from './useListPaginationContext'; export * from './useListParams'; export * from './useListSortContext'; +export * from './queryReducer'; diff --git a/packages/ra-core/src/reducer/admin/resource/list/queryReducer.spec.ts b/packages/ra-core/src/controller/list/queryReducer.spec.ts similarity index 99% rename from packages/ra-core/src/reducer/admin/resource/list/queryReducer.spec.ts rename to packages/ra-core/src/controller/list/queryReducer.spec.ts index 09fa269a0ec..ae5f95bf4c3 100644 --- a/packages/ra-core/src/reducer/admin/resource/list/queryReducer.spec.ts +++ b/packages/ra-core/src/controller/list/queryReducer.spec.ts @@ -1,5 +1,5 @@ import expect from 'expect'; -import queryReducer, { SORT_ASC, SORT_DESC } from './queryReducer'; +import { queryReducer, SORT_ASC, SORT_DESC } from './queryReducer'; describe('Query Reducer', () => { describe('SET_PAGE action', () => { diff --git a/packages/ra-core/src/reducer/admin/resource/list/queryReducer.ts b/packages/ra-core/src/controller/list/queryReducer.ts similarity index 95% rename from packages/ra-core/src/reducer/admin/resource/list/queryReducer.ts rename to packages/ra-core/src/controller/list/queryReducer.ts index 9892d00ecad..96b43c6f9a8 100644 --- a/packages/ra-core/src/reducer/admin/resource/list/queryReducer.ts +++ b/packages/ra-core/src/controller/list/queryReducer.ts @@ -1,9 +1,9 @@ import { Reducer } from 'redux'; import set from 'lodash/set'; -import removeEmpty from '../../../../util/removeEmpty'; -import removeKey from '../../../../util/removeKey'; -import { ListParams } from '../../../../actions'; +import removeEmpty from '../../util/removeEmpty'; +import removeKey from '../../util/removeKey'; +import { ListParams } from '../../actions'; export const SET_SORT = 'SET_SORT'; export const SORT_ASC = 'ASC'; @@ -51,7 +51,7 @@ type ActionTypes = /** * This reducer is for the react-router query string, NOT for redux. */ -const queryReducer: Reducer = ( +export const queryReducer: Reducer = ( previousState, action: ActionTypes ) => { diff --git a/packages/ra-core/src/controller/list/useListController.spec.tsx b/packages/ra-core/src/controller/list/useListController.spec.tsx index 1a7beed1321..0c40b85d0c1 100644 --- a/packages/ra-core/src/controller/list/useListController.spec.tsx +++ b/packages/ra-core/src/controller/list/useListController.spec.tsx @@ -15,7 +15,7 @@ import { } from './useListController'; import { CoreAdminContext, createAdminStore } from '../../core'; import { CRUD_CHANGE_LIST_PARAMS } from '../../actions'; -import { SORT_ASC } from '../../reducer/admin/resource/list/queryReducer'; +import { SORT_ASC } from './queryReducer'; describe('useListController', () => { const defaultProps = { @@ -87,18 +87,7 @@ describe('useListController', () => { }; const store = createAdminStore({ - initialState: { - admin: { - resources: { - posts: { - list: { - params: {}, - cachedRequests: {}, - }, - }, - }, - }, - }, + initialState: { admin: { listParams: {} } }, }); const dispatch = jest.spyOn(store, 'dispatch'); render( @@ -122,7 +111,7 @@ describe('useListController', () => { expect(changeParamsCalls).toHaveLength(1); const state = store.getState(); - expect(state.admin.resources.posts.list.params.filter).toEqual({ + expect(state.admin.listParams.posts.filter).toEqual({ q: 'hello', }); }); @@ -136,15 +125,10 @@ describe('useListController', () => { const store = createAdminStore({ initialState: { admin: { - resources: { + listParams: { posts: { - list: { - params: { - filter: { q: 'hello' }, - displayedFilters: { q: true }, - }, - cachedRequests: {}, - }, + filter: { q: 'hello' }, + displayedFilters: { q: true }, }, }, }, @@ -187,10 +171,10 @@ describe('useListController', () => { ).toHaveLength(2); const state = store.getState(); - expect(state.admin.resources.posts.list.params.filter).toEqual({}); - expect( - state.admin.resources.posts.list.params.displayedFilters - ).toEqual({ q: true }); + expect(state.admin.listParams.posts.filter).toEqual({}); + expect(state.admin.listParams.posts.displayedFilters).toEqual({ + q: true, + }); }); it('should update data if permanent filters change', () => { @@ -289,14 +273,9 @@ describe('useListController', () => { dataProvider={testDataProvider()} initialState={{ admin: { - resources: { + listParams: { posts: { - list: { - params: { - filter: { q: 'hello' }, - }, - cachedRequests: {}, - }, + filter: { q: 'hello' }, }, }, }, @@ -327,14 +306,9 @@ describe('useListController', () => { dataProvider={testDataProvider()} initialState={{ admin: { - resources: { + listParams: { posts: { - list: { - params: { - filter: { q: 'hello' }, - }, - cachedRequests: {}, - }, + filter: { q: 'hello' }, }, }, }, diff --git a/packages/ra-core/src/controller/list/useListController.ts b/packages/ra-core/src/controller/list/useListController.ts index 68d943b8246..f4bbb0539d2 100644 --- a/packages/ra-core/src/controller/list/useListController.ts +++ b/packages/ra-core/src/controller/list/useListController.ts @@ -5,7 +5,7 @@ import { useAuthenticated } from '../../auth'; import { useTranslate } from '../../i18n'; import { useNotify } from '../../sideEffect'; import { useGetList, UseGetListHookValue } from '../../dataProvider'; -import { SORT_ASC } from '../../reducer/admin/resource/list/queryReducer'; +import { SORT_ASC } from './queryReducer'; import { defaultExporter } from '../../export'; import { FilterPayload, SortPayload, Record, Exporter } from '../../types'; import { useResourceContext, useGetResourceLabel } from '../../core'; diff --git a/packages/ra-core/src/controller/list/useListParams.spec.tsx b/packages/ra-core/src/controller/list/useListParams.spec.tsx index 0ebf77826fe..84d73624c36 100644 --- a/packages/ra-core/src/controller/list/useListParams.spec.tsx +++ b/packages/ra-core/src/controller/list/useListParams.spec.tsx @@ -9,10 +9,7 @@ import { fireEvent, waitFor } from '@testing-library/react'; import { CoreAdminContext, createAdminStore } from '../../core'; import { testDataProvider } from '../../dataProvider'; import { useListParams, getQuery, getNumberOrDefault } from './useListParams'; -import { - SORT_DESC, - SORT_ASC, -} from '../../reducer/admin/resource/list/queryReducer'; +import { SORT_DESC, SORT_ASC } from './queryReducer'; describe('useListParams', () => { describe('getQuery', () => { diff --git a/packages/ra-core/src/controller/list/useListParams.ts b/packages/ra-core/src/controller/list/useListParams.ts index e07e281f17f..a05f352d904 100644 --- a/packages/ra-core/src/controller/list/useListParams.ts +++ b/packages/ra-core/src/controller/list/useListParams.ts @@ -13,7 +13,7 @@ import queryReducer, { SET_PER_PAGE, SET_SORT, SORT_ASC, -} from '../../reducer/admin/resource/list/queryReducer'; +} from './queryReducer'; import { changeListParams, ListParams } from '../../actions'; import { SortPayload, ReduxState, FilterPayload } from '../../types'; import removeEmpty from '../../util/removeEmpty'; @@ -81,9 +81,7 @@ export const useListParams = ({ const [localParams, setLocalParams] = useState(defaultParams); const params = useSelector( (reduxState: ReduxState) => - reduxState.admin.resources[resource] - ? reduxState.admin.resources[resource].list.params - : defaultParams, + reduxState.admin.listParams[resource] || defaultParams, shallowEqual ); const tempParams = useRef(); diff --git a/packages/ra-core/src/controller/useExpanded.tsx b/packages/ra-core/src/controller/useExpanded.tsx index 2b8fd65e9b6..414e91c7e6d 100644 --- a/packages/ra-core/src/controller/useExpanded.tsx +++ b/packages/ra-core/src/controller/useExpanded.tsx @@ -25,14 +25,13 @@ const useExpanded = ( const dispatch = useDispatch(); const expandedList = useSelector( (reduxState: ReduxState) => - reduxState.admin.resources[resource] - ? reduxState.admin.resources[resource].list.expanded - : undefined + reduxState.admin.expandedRows[resource] || undefined ); const expanded = expandedList === undefined ? false : expandedList.map(el => el == id).indexOf(true) !== -1; // eslint-disable-line eqeqeq + const toggleExpanded = useCallback(() => { dispatch(toggleListItemExpand(resource, id)); }, [dispatch, resource, id]); diff --git a/packages/ra-core/src/controller/useRecordSelection.ts b/packages/ra-core/src/controller/useRecordSelection.ts index 57dfa2aeb30..13e9f3b3b73 100644 --- a/packages/ra-core/src/controller/useRecordSelection.ts +++ b/packages/ra-core/src/controller/useRecordSelection.ts @@ -25,9 +25,7 @@ const useRecordSelection = ( const dispatch = useDispatch(); const selectedIds = useSelector( (reduxState: ReduxState) => - reduxState.admin.resources[resource] - ? reduxState.admin.resources[resource].list.selectedIds - : defaultRecords, + reduxState.admin.selectedIds[resource] || defaultRecords, shallowEqual ); const selectionModifiers = useMemo( diff --git a/packages/ra-core/src/controller/useSortState.ts b/packages/ra-core/src/controller/useSortState.ts index b6d594044a4..d505af1a3cf 100644 --- a/packages/ra-core/src/controller/useSortState.ts +++ b/packages/ra-core/src/controller/useSortState.ts @@ -1,9 +1,6 @@ import { useReducer, useEffect, useRef, useCallback } from 'react'; -import { - SORT_ASC, - SORT_DESC, -} from '../reducer/admin/resource/list/queryReducer'; +import { SORT_ASC, SORT_DESC } from './list/queryReducer'; import { SortPayload } from '../types'; export interface SortProps { diff --git a/packages/ra-core/src/core/CoreAdminContext.tsx b/packages/ra-core/src/core/CoreAdminContext.tsx index e5d334f59f9..a611a925428 100644 --- a/packages/ra-core/src/core/CoreAdminContext.tsx +++ b/packages/ra-core/src/core/CoreAdminContext.tsx @@ -12,6 +12,7 @@ import { } from '../dataProvider'; import createAdminStore from './createAdminStore'; import TranslationProvider from '../i18n/TranslationProvider'; +import { ResourceDefinitionContextProvider } from './ResourceDefinitionContext'; import { AuthProvider, LegacyAuthProvider, @@ -92,7 +93,9 @@ React-admin requires a valid dataProvider function to work.`); history={finalHistory} basename={basename} > - {children} + + {children} + diff --git a/packages/ra-core/src/core/ResourceContext.ts b/packages/ra-core/src/core/ResourceContext.ts index d32e491d44b..5953bd95dfe 100644 --- a/packages/ra-core/src/core/ResourceContext.ts +++ b/packages/ra-core/src/core/ResourceContext.ts @@ -1,7 +1,7 @@ import { createContext } from 'react'; /** - * Context to store the current resource information. + * Context to store the current resource name. * * Use the useResource() hook to read the context. That's what most components do in react-admin. * diff --git a/packages/ra-core/src/core/ResourceDefinitionContext.tsx b/packages/ra-core/src/core/ResourceDefinitionContext.tsx new file mode 100644 index 00000000000..8cd25b5cfe8 --- /dev/null +++ b/packages/ra-core/src/core/ResourceDefinitionContext.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { createContext, useState } from 'react'; +import isEqual from 'lodash/isEqual'; + +import { ResourceDefinition } from '../types'; + +export type ResourceDefinitions = { + [name: string]: ResourceDefinition; +}; + +export type ResourceDefinitionContextValue = [ + ResourceDefinitions, + (config: ResourceDefinition) => void +]; + +export const ResourceDefinitionContext = createContext< + ResourceDefinitionContextValue +>([{}, () => {}]); + +/** + * Context to store the current resource Definition. + * + * Use the useResourceDefinition() hook to read the context. + * + * @example + * + * import { useResourceDefinition, useTranslate } from 'ra-core'; + * + * const PostMenuItem = () => { + * const { name, icon } = useResourceDefinition({ resource: 'posts' }); + * + * return ( + * + * {icon} + * {name} + * + * ); + * }; + */ +export const ResourceDefinitionContextProvider = ({ + definitions: defaultDefinitions = {}, + children, +}) => { + const [definitions, setState] = useState( + defaultDefinitions + ); + + const setDefinition = (config: ResourceDefinition) => { + setState(prev => + isEqual(prev[config.name], config) + ? prev + : { + ...prev, + [config.name]: config, + } + ); + }; + + return ( + + {children} + + ); +}; diff --git a/packages/ra-core/src/core/createAdminStore.ts b/packages/ra-core/src/core/createAdminStore.ts index 3b14364736b..cca2cf2b7ec 100644 --- a/packages/ra-core/src/core/createAdminStore.ts +++ b/packages/ra-core/src/core/createAdminStore.ts @@ -20,15 +20,14 @@ export default ({ customReducers = {}, initialState }: Params = {}) => { appReducer( action.type !== CLEAR_STATE ? state - : // Erase data from the store but keep location, notifications, ui prefs, etc. + : // Erase state from the store but keep notifications, ui prefs, etc. // This allows e.g. to display a notification on logout { ...state, admin: { ...state.admin, - loading: 0, - resources: {}, - customQueries: {}, + expandedRows: {}, + selectedIds: {}, }, }, action diff --git a/packages/ra-core/src/core/index.ts b/packages/ra-core/src/core/index.ts index 45d36230750..8387a474404 100644 --- a/packages/ra-core/src/core/index.ts +++ b/packages/ra-core/src/core/index.ts @@ -2,9 +2,11 @@ export * from './dataFetchActions'; export * from './components'; export * from './ResourceContext'; export * from './ResourceContextProvider'; +export * from './ResourceDefinitionContext'; export * from './useScrollToTop'; export * from './useResourceContext'; export * from './useResourceDefinition'; +export * from './useResourceDefinitions'; export * from './useGetResourceLabel'; // there seems to be a bug in TypeScript: this only works if the exports are in this order. // Swapping the two exports leads to the core module missing the dataFetchActions constants diff --git a/packages/ra-core/src/core/useGetResourceLabel.spec.tsx b/packages/ra-core/src/core/useGetResourceLabel.spec.tsx index 12ff4b26e12..7a170c4b8e1 100644 --- a/packages/ra-core/src/core/useGetResourceLabel.spec.tsx +++ b/packages/ra-core/src/core/useGetResourceLabel.spec.tsx @@ -1,9 +1,10 @@ import * as React from 'react'; -import { renderWithRedux } from 'ra-test'; +import { screen, render } from '@testing-library/react'; import { useGetResourceLabel } from './useGetResourceLabel'; + import { TestTranslationProvider } from '../i18n'; -describe('useResourceLabel', () => { +describe('useGetResourceLabel', () => { test.each([ [2, 'Posts'], [1, 'Post'], @@ -18,24 +19,13 @@ describe('useResourceLabel', () => { return

{label}

; }; - const { queryByText } = renderWithRedux( + render( - , - { - admin: { - resources: { - posts: { - props: { - name: 'posts', - }, - }, - }, - }, - } + ); - expect(queryByText(expected)).not.toBeNull(); + expect(screen.queryByText(expected)).not.toBeNull(); } ); }); diff --git a/packages/ra-core/src/core/useGetResourceLabel.ts b/packages/ra-core/src/core/useGetResourceLabel.ts index a40907bb475..3067cdc12c7 100644 --- a/packages/ra-core/src/core/useGetResourceLabel.ts +++ b/packages/ra-core/src/core/useGetResourceLabel.ts @@ -1,6 +1,6 @@ import inflection from 'inflection'; -import { useStore } from 'react-redux'; -import { getResources } from '../reducer'; + +import { useResourceDefinitions } from './useResourceDefinitions'; import { useTranslate } from '../i18n'; /** @@ -9,14 +9,14 @@ import { useTranslate } from '../i18n'; * @returns {GetResourceLabel} A function which takes a resource name and an optional number indicating the number of items (used for pluralization) and returns a translated string. * @example * const Menu = () => { - * const resources = useSelector(getResources, shallowEqual); + * const resources = useResourceDefinitions(); * const getResourceLabel = useGetResourceLabel(); * * return ( *
    - * {resources.map(resource => ( - *
  • - * {getResourceLabel(resource.name, 2)} + * {Object.keys(resources).map(name => ( + *
  • + * {getResourceLabel(name, 2)} *
  • * ))} *
@@ -24,13 +24,11 @@ import { useTranslate } from '../i18n'; * } */ export const useGetResourceLabel = (): GetResourceLabel => { - const store = useStore(); const translate = useTranslate(); + const definitions = useResourceDefinitions(); return (resource: string, count = 2): string => { - const resourceDefinition = getResources(store.getState()).find( - r => r?.name === resource - ); + const resourceDefinition = definitions[resource]; const label = translate(`resources.${resource}.name`, { smart_count: count, diff --git a/packages/ra-core/src/core/useRegisterResource.ts b/packages/ra-core/src/core/useRegisterResource.ts index 2c69e6fcb1b..8625f24db26 100644 --- a/packages/ra-core/src/core/useRegisterResource.ts +++ b/packages/ra-core/src/core/useRegisterResource.ts @@ -1,23 +1,14 @@ -import { useDispatch, useSelector } from 'react-redux'; -import isEqual from 'lodash/isEqual'; +import { useContext } from 'react'; -import { registerResource } from '../actions'; -import { ReduxState, ResourceDefinition } from '../types'; +import { ResourceDefinitionContext } from './ResourceDefinitionContext'; +import { ResourceDefinition } from '../types'; export const useRegisterResource = () => { - const dispatch = useDispatch(); - const knownResources = useSelector( - state => state.admin.resources - ); + const [, setResourceConfiguration] = useContext(ResourceDefinitionContext); return (...resources: ResourceDefinition[]) => { resources.forEach(resource => { - if ( - !knownResources[resource.name] || - !isEqual(knownResources[resource.name]?.props, resource) - ) { - dispatch(registerResource(resource)); - } + setResourceConfiguration(resource); }); }; }; diff --git a/packages/ra-core/src/core/useResourceDefinition.spec.tsx b/packages/ra-core/src/core/useResourceDefinition.spec.tsx new file mode 100644 index 00000000000..708996bdad6 --- /dev/null +++ b/packages/ra-core/src/core/useResourceDefinition.spec.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { render } from '@testing-library/react'; + +import { useResourceDefinition } from './useResourceDefinition'; +import { ResourceDefinitionContextProvider } from './ResourceDefinitionContext'; + +describe('useResourceDefinition', () => { + const UseResourceDefinition = ({ + resource = 'posts', + callback, + }: { + resource?: string; + callback: (params: any) => void; + }) => { + const resourceDefinition = useResourceDefinition({ resource }); + callback(resourceDefinition); + return ; + }; + + it('should not fail when used outside of a ResourceDefinitionContext', () => { + const callback = jest.fn(); + render(); + expect(callback).toHaveBeenCalledWith({ + hasCreate: undefined, + hasEdit: undefined, + hasList: undefined, + hasShow: undefined, + }); + }); + + it('should use the definition from ResourceDefinitionContext', () => { + const callback = jest.fn(); + render( + + + + ); + expect(callback).toHaveBeenCalledWith({ + hasCreate: undefined, + hasEdit: undefined, + hasList: undefined, + hasShow: undefined, + options: { label: 'Posts' }, + }); + }); +}); diff --git a/packages/ra-core/src/core/useResourceDefinition.ts b/packages/ra-core/src/core/useResourceDefinition.ts index 135cd4b8dc8..fd717d329f3 100644 --- a/packages/ra-core/src/core/useResourceDefinition.ts +++ b/packages/ra-core/src/core/useResourceDefinition.ts @@ -1,22 +1,39 @@ -import { useSelector } from 'react-redux'; +import { useMemo } from 'react'; import defaults from 'lodash/defaults'; -import { getResources } from '../reducer'; -import { ResourceDefinition } from '../types'; + +import { useResourceDefinitions } from './useResourceDefinitions'; import { useResourceContext } from './useResourceContext'; -import { useMemo } from 'react'; +import { ResourceDefinition } from '../types'; /** - * Hook which returns the definition of the requested resource + * Hook to get the definition of a given resource + * + * @example // Get the current resource definition (based on ResourceContext) + * + * const definition = useResourceDefinition(); + * console.log(definition); + * // { + * // name: 'posts', + * // hasList: true, + * // hasEdit: true, + * // hasShow: true, + * // hasCreate: true, + * // options: {}, + * // icon: PostIcon, + * // } + * + * @example // Pass a resource prop to check a different ressource definition + * + * const definition = useResourceDefinition({ resource: 'posts' }); */ export const useResourceDefinition = ( props?: UseResourceDefinitionOptions ): ResourceDefinition => { const resource = useResourceContext(props); - const resources = useSelector(getResources); + const resourceDefinitions = useResourceDefinitions(); const { hasCreate, hasEdit, hasList, hasShow } = props || {}; const definition = useMemo(() => { - const definitionFromRedux = resources.find(r => r?.name === resource); return defaults( {}, { @@ -25,19 +42,17 @@ export const useResourceDefinition = ( hasList, hasShow, }, - definitionFromRedux + resourceDefinitions[resource] ); - }, [resource, resources, hasCreate, hasEdit, hasList, hasShow]); + }, [resource, resourceDefinitions, hasCreate, hasEdit, hasList, hasShow]); return definition; }; export interface UseResourceDefinitionOptions { readonly resource?: string; - readonly options?: any; readonly hasList?: boolean; readonly hasEdit?: boolean; readonly hasShow?: boolean; readonly hasCreate?: boolean; - readonly icon?: any; } diff --git a/packages/ra-core/src/core/useResourceDefinitions.spec.tsx b/packages/ra-core/src/core/useResourceDefinitions.spec.tsx new file mode 100644 index 00000000000..a6dc8084064 --- /dev/null +++ b/packages/ra-core/src/core/useResourceDefinitions.spec.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { render } from '@testing-library/react'; + +import { useResourceDefinitions } from './useResourceDefinitions'; +import { ResourceDefinitionContextProvider } from './ResourceDefinitionContext'; + +describe('useResourceDefinitions', () => { + const UseResourceDefinitions = ({ + callback, + }: { + resource?: string; + callback: (params: any) => void; + }) => { + const resourceDefinition = useResourceDefinitions(); + callback(resourceDefinition); + return ; + }; + + it('should not fail when used outside of a ResourceDefinitionContext', () => { + const callback = jest.fn(); + render(); + expect(callback).toHaveBeenCalledWith({}); + }); + + it('should use the definition from ResourceDefinitionContext', () => { + const callback = jest.fn(); + render( + + + + ); + expect(callback).toHaveBeenCalledWith({ + posts: { + options: { label: 'Posts' }, + }, + comments: { + options: { label: 'Comments' }, + }, + }); + }); +}); diff --git a/packages/ra-core/src/core/useResourceDefinitions.ts b/packages/ra-core/src/core/useResourceDefinitions.ts new file mode 100644 index 00000000000..ce430e5f9d4 --- /dev/null +++ b/packages/ra-core/src/core/useResourceDefinitions.ts @@ -0,0 +1,26 @@ +import { useContext } from 'react'; + +import { + ResourceDefinitionContext, + ResourceDefinitions, +} from './ResourceDefinitionContext'; + +/** + * Get the definition of the all resources + * + * @example + * + * const definitions = useResourceDefinitions(); + * console.log(definitions.posts); + * // { + * // name: 'posts', + * // hasList: true, + * // hasEdit: true, + * // hasShow: true, + * // hasCreate: true, + * // options: {}, + * // icon: PostIcon, + * // } + */ +export const useResourceDefinitions = (): ResourceDefinitions => + useContext(ResourceDefinitionContext)[0]; diff --git a/packages/ra-core/src/index.ts b/packages/ra-core/src/index.ts index 26c3103f448..92fd724c1ad 100644 --- a/packages/ra-core/src/index.ts +++ b/packages/ra-core/src/index.ts @@ -1,8 +1,7 @@ import createAppReducer from './reducer'; import adminReducer from './reducer/admin'; -import queryReducer from './reducer/admin/resource/list/queryReducer'; -export { createAppReducer, adminReducer, queryReducer }; +export { createAppReducer, adminReducer }; export * from './core'; export * from './actions'; export * from './auth'; @@ -14,7 +13,7 @@ export * from './util'; export * from './controller'; export * from './form'; -export { getResources, getReferenceResource, getNotification } from './reducer'; +export { getNotification } from './reducer'; export * from './sideEffect'; export * from './types'; diff --git a/packages/ra-core/src/reducer/admin/expandedRows.spec.ts b/packages/ra-core/src/reducer/admin/expandedRows.spec.ts new file mode 100644 index 00000000000..fdc6298f589 --- /dev/null +++ b/packages/ra-core/src/reducer/admin/expandedRows.spec.ts @@ -0,0 +1,52 @@ +import expect from 'expect'; + +import { expandedRows } from './expandedRows'; +import { toggleListItemExpand } from '../../actions/listActions'; + +describe('expanded reducer', () => { + describe('TOGGLE_LIST_ITEM_EXPAND action', () => { + it("should add the identifier to the list if it's not present", () => { + expect( + expandedRows( + { foo: [1, 2, 3, 5] }, + toggleListItemExpand('foo', 4) + ) + ).toEqual({ foo: [1, 2, 3, 5, 4] }); + }); + it("should remove the identifier from the list if it's present", () => { + expect( + expandedRows( + { foo: [1, 2, 3, 5] }, + toggleListItemExpand('foo', 3) + ) + ).toEqual({ foo: [1, 2, 5] }); + }); + it('should tolerate identifiers with the wrong type', () => { + expect( + expandedRows( + { foo: [1, 2, 3, 5] }, + toggleListItemExpand('foo', '3') + ) + ).toEqual({ foo: [1, 2, 5] }); + expect( + expandedRows( + { foo: [1, 2, '3', 5] }, + toggleListItemExpand('foo', 3) + ) + ).toEqual({ foo: [1, 2, 5] }); + }); + it('should work on a resource without any prior activity', () => { + expect(expandedRows({}, toggleListItemExpand('foo', 3))).toEqual({ + foo: [3], + }); + }); + it('should not affect other resources', () => { + expect( + expandedRows( + { foo: [1, 2, 3, 5], bar: [6, 7, 8] }, + toggleListItemExpand('foo', 3) + ) + ).toEqual({ foo: [1, 2, 5], bar: [6, 7, 8] }); + }); + }); +}); diff --git a/packages/ra-core/src/reducer/admin/expandedRows.ts b/packages/ra-core/src/reducer/admin/expandedRows.ts new file mode 100644 index 00000000000..590852f5fc7 --- /dev/null +++ b/packages/ra-core/src/reducer/admin/expandedRows.ts @@ -0,0 +1,40 @@ +import { Reducer } from 'redux'; +import { + ToggleListItemExpandAction, + TOGGLE_LIST_ITEM_EXPAND, +} from '../../actions/listActions'; +import { Identifier } from '../../types'; + +type State = { + [key: string]: Identifier[]; +}; + +type ActionTypes = + | ToggleListItemExpandAction + | { + type: 'OTHER_ACTION'; + payload: any; + }; + +const initialState = {}; + +export const expandedRows: Reducer = ( + previousState = initialState, + action: ActionTypes +) => { + if (action.type === TOGGLE_LIST_ITEM_EXPAND) { + const previousIds = previousState[action.meta.resource] || []; + const index = previousIds.findIndex(el => el == action.payload); // eslint-disable-line eqeqeq + return { + ...previousState, + [action.meta.resource]: + index > -1 + ? [ + ...previousIds.slice(0, index), + ...previousIds.slice(index + 1), + ] + : [...previousIds, action.payload], + }; + } + return previousState; +}; diff --git a/packages/ra-core/src/reducer/admin/index.ts b/packages/ra-core/src/reducer/admin/index.ts index f5911afcf87..84aea5cbe59 100644 --- a/packages/ra-core/src/reducer/admin/index.ts +++ b/packages/ra-core/src/reducer/admin/index.ts @@ -1,10 +1,11 @@ -import { combineReducers } from 'redux'; -import resources, { - getResources as resourceGetResources, - getReferenceResource as resourceGetReferenceResource, -} from './resource'; +import { combineReducers, Reducer } from 'redux'; + import notifications from './notifications'; import ui from './ui'; +import { selectedIds } from './selectedIds'; +import { expandedRows } from './expandedRows'; +import { listParams } from './listParams'; +import { ReduxState } from '../../types'; const defaultReducer = () => null; @@ -16,13 +17,9 @@ export default combineReducers({ * * @see https://stackoverflow.com/questions/43375079/redux-warning-only-appearing-in-tests */ - resources: resources || defaultReducer, notifications: notifications || defaultReducer, ui: ui || defaultReducer, -}); - -export const getResources = state => resourceGetResources(state.resources); - -export const getReferenceResource = (state, props) => { - return resourceGetReferenceResource(state.resources, props); -}; + selectedIds: selectedIds || defaultReducer, + expandedRows: expandedRows || defaultReducer, + listParams: listParams || defaultReducer, +}) as Reducer; diff --git a/packages/ra-core/src/reducer/admin/listParams.spec.ts b/packages/ra-core/src/reducer/admin/listParams.spec.ts new file mode 100644 index 00000000000..ab690d9f169 --- /dev/null +++ b/packages/ra-core/src/reducer/admin/listParams.spec.ts @@ -0,0 +1,108 @@ +import expect from 'expect'; + +import { listParams } from './listParams'; +import { changeListParams } from '../../actions/listActions'; + +describe('listParams reducer', () => { + describe('CRUD_CHANGE_LIST_PARAMS action', () => { + it('should set the list params for that resource', () => { + expect( + listParams( + { + foo: { + sort: 'id', + order: 'DESC', + page: 1, + perPage: 10, + filter: {}, + displayedFilters: [], + }, + }, + changeListParams('foo', { + sort: 'id', + order: 'ASC', + page: 1, + perPage: 10, + filter: {}, + displayedFilters: [], + }) + ) + ).toEqual({ + foo: { + sort: 'id', + order: 'ASC', + page: 1, + perPage: 10, + filter: {}, + displayedFilters: [], + }, + }); + }); + + it('should work on a resource without any prior activity', () => { + expect( + listParams( + {}, + changeListParams('foo', { + sort: 'id', + order: 'ASC', + page: 1, + perPage: 10, + filter: {}, + displayedFilters: [], + }) + ) + ).toEqual({ + foo: { + sort: 'id', + order: 'ASC', + page: 1, + perPage: 10, + filter: {}, + displayedFilters: [], + }, + }); + }); + it('should not affect other resources', () => { + expect( + listParams( + { + bar: { + sort: 'id', + order: 'DESC', + page: 1, + perPage: 10, + filter: {}, + displayedFilters: [], + }, + }, + changeListParams('foo', { + sort: 'id', + order: 'ASC', + page: 1, + perPage: 10, + filter: {}, + displayedFilters: [], + }) + ) + ).toEqual({ + bar: { + sort: 'id', + order: 'DESC', + page: 1, + perPage: 10, + filter: {}, + displayedFilters: [], + }, + foo: { + sort: 'id', + order: 'ASC', + page: 1, + perPage: 10, + filter: {}, + displayedFilters: [], + }, + }); + }); + }); +}); diff --git a/packages/ra-core/src/reducer/admin/listParams.ts b/packages/ra-core/src/reducer/admin/listParams.ts new file mode 100644 index 00000000000..8dd91033405 --- /dev/null +++ b/packages/ra-core/src/reducer/admin/listParams.ts @@ -0,0 +1,37 @@ +import { Reducer } from 'redux'; +import { + CRUD_CHANGE_LIST_PARAMS, + ChangeListParamsAction, +} from '../../actions/listActions'; + +const defaultState = {}; + +export interface State { + [key: string]: { + sort: string; + order: string; + page: number; + perPage: number; + filter: any; + displayedFilters: any; + }; +} + +type ActionTypes = + | ChangeListParamsAction + | { type: 'OTHER_ACTION'; payload: any }; + +export const listParams: Reducer = ( + previousState = defaultState, + action: ActionTypes +) => { + switch (action.type) { + case CRUD_CHANGE_LIST_PARAMS: + return { + ...previousState, + [action.meta.resource]: action.payload, + }; + default: + return previousState; + } +}; diff --git a/packages/ra-core/src/reducer/admin/resource/index.spec.ts b/packages/ra-core/src/reducer/admin/resource/index.spec.ts deleted file mode 100644 index 53035d54957..00000000000 --- a/packages/ra-core/src/reducer/admin/resource/index.spec.ts +++ /dev/null @@ -1,250 +0,0 @@ -import expect from 'expect'; -import reducer, { getReferenceResource } from './index'; -import { REGISTER_RESOURCE, UNREGISTER_RESOURCE } from '../../../actions'; -import { CRUD_CHANGE_LIST_PARAMS } from '../../../actions/listActions'; - -describe('Resources Reducer', () => { - it('should return previous state if the action has no resource meta and is not REGISTER_RESOURCE nor UNREGISTER_RESOURCE', () => { - const previousState = { previous: true }; - expect( - reducer(previousState, { - type: 'OTHER_ACTION', - meta: {}, - }) - ).toEqual(previousState); - }); - - it('should initialize a new resource upon REGISTER_RESOURCE', () => { - expect( - reducer( - { - posts: { - list: { - params: { - filter: {}, - order: null, - page: 1, - perPage: null, - sort: null, - }, - expanded: [], - selectedIds: [], - }, - props: { name: 'posts' }, - }, - comments: { - list: { - params: { - filter: {}, - order: null, - page: 1, - perPage: null, - sort: null, - }, - expanded: [], - selectedIds: [], - }, - props: { name: 'comments' }, - }, - }, - { - type: REGISTER_RESOURCE, - payload: { - name: 'users', - options: 'foo', - }, - } - ) - ).toEqual({ - posts: { - list: { - params: { - filter: {}, - order: null, - page: 1, - perPage: null, - sort: null, - }, - expanded: [], - selectedIds: [], - }, - props: { name: 'posts' }, - }, - comments: { - list: { - params: { - filter: {}, - order: null, - page: 1, - perPage: null, - sort: null, - }, - expanded: [], - selectedIds: [], - }, - props: { name: 'comments' }, - }, - users: { - list: { - params: { - filter: {}, - order: null, - page: 1, - perPage: null, - sort: null, - }, - expanded: [], - selectedIds: [], - }, - props: { name: 'users', options: 'foo' }, - }, - }); - }); - - it('should remove a resource upon UNREGISTER_RESOURCE', () => { - expect( - reducer( - { - posts: { - list: { - params: { - filter: {}, - order: null, - page: 1, - perPage: null, - sort: null, - }, - expanded: [], - selectedIds: [], - }, - props: { name: 'posts' }, - }, - comments: { - list: { - params: { - filter: {}, - order: null, - page: 1, - perPage: null, - sort: null, - }, - expanded: [], - selectedIds: [], - }, - props: { name: 'comments' }, - }, - }, - { - type: UNREGISTER_RESOURCE, - payload: 'comments', - } - ) - ).toEqual({ - posts: { - list: { - params: { - filter: {}, - order: null, - page: 1, - perPage: null, - sort: null, - }, - expanded: [], - selectedIds: [], - }, - props: { name: 'posts' }, - }, - }); - }); - - it('should call inner reducers for each resource when action has a resource meta', () => { - expect( - reducer( - { - posts: { - list: { - params: { - filter: {}, - order: null, - page: 1, - perPage: null, - sort: null, - }, - expanded: [], - selectedIds: [], - }, - props: { name: 'posts' }, - }, - comments: { - list: { - params: { - filter: {}, - order: null, - page: 1, - perPage: null, - sort: null, - }, - expanded: [], - selectedIds: [], - }, - props: { name: 'comments' }, - }, - }, - { - // @ts-ignore - type: CRUD_CHANGE_LIST_PARAMS, - meta: { resource: 'posts' }, - payload: { - filter: { commentable: true }, - order: null, - page: 1, - perPage: null, - sort: null, - }, - } - ) - ).toEqual({ - posts: { - list: { - params: { - filter: { commentable: true }, - order: null, - page: 1, - perPage: null, - sort: null, - }, - expanded: [], - selectedIds: [], - }, - props: { name: 'posts' }, - }, - comments: { - list: { - params: { - filter: {}, - order: null, - page: 1, - perPage: null, - sort: null, - }, - expanded: [], - selectedIds: [], - }, - props: { name: 'comments' }, - }, - }); - }); - - describe('getReferenceResource selector', () => { - it('should return the reference resource', () => { - const state = { - posts: 'POSTS', - comments: 'COMMENTS', - }; - const props = { - reference: 'comments', - }; - expect(getReferenceResource(state, props)).toEqual('COMMENTS'); - }); - }); -}); diff --git a/packages/ra-core/src/reducer/admin/resource/index.ts b/packages/ra-core/src/reducer/admin/resource/index.ts deleted file mode 100644 index 38176d6a1fb..00000000000 --- a/packages/ra-core/src/reducer/admin/resource/index.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { - REGISTER_RESOURCE, - RegisterResourceAction, - UNREGISTER_RESOURCE, - UnregisterResourceAction, -} from '../../../actions'; - -import list from './list'; - -const initialState = {}; - -type ActionTypes = - | RegisterResourceAction - | UnregisterResourceAction - | { type: 'OTHER_ACTION'; payload?: any; meta?: { resource?: string } }; - -export default (previousState = initialState, action: ActionTypes) => { - if (action.type === REGISTER_RESOURCE) { - const resourceState = { - props: action.payload, - list: list(undefined, action), - }; - return { - ...previousState, - [action.payload.name]: resourceState, - }; - } - - if (action.type === UNREGISTER_RESOURCE) { - return Object.keys(previousState).reduce((acc, key) => { - if (key === action.payload) { - return acc; - } - - return { ...acc, [key]: previousState[key] }; - }, {}); - } - - if (!action.meta || !action.meta.resource) { - return previousState; - } - - const resources = Object.keys(previousState); - const newState = resources.reduce( - (acc, resource) => ({ - ...acc, - [resource]: - action.meta.resource === resource - ? { - props: previousState[resource].props, - list: list(previousState[resource].list, action), - } - : previousState[resource], - }), - {} - ); - - return newState; -}; - -export const getResources = state => - Object.keys(state).map(key => state[key].props); - -export const getReferenceResource = (state, props) => state[props.reference]; diff --git a/packages/ra-core/src/reducer/admin/resource/list/expanded.spec.ts b/packages/ra-core/src/reducer/admin/resource/list/expanded.spec.ts deleted file mode 100644 index 4c78a2c0185..00000000000 --- a/packages/ra-core/src/reducer/admin/resource/list/expanded.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -import expect from 'expect'; - -import expand from './expanded'; -import { toggleListItemExpand } from '../../../../actions/listActions'; - -describe('expanded reducer', () => { - describe('TOGGLE_LIST_ITEM_EXPAND action', () => { - it("should add the identifier to the list if it's not present", () => { - expect( - expand([1, 2, 3, 5], toggleListItemExpand('foo', 4)) - ).toEqual([1, 2, 3, 5, 4]); - }); - it("should remove the identifier from the list if it's present", () => { - expect( - expand([1, 2, 3, 5], toggleListItemExpand('foo', 3)) - ).toEqual([1, 2, 5]); - }); - it('should tolerate identifiers with the wrong type', () => { - expect( - expand([1, 2, 3, 5], toggleListItemExpand('foo', '3')) - ).toEqual([1, 2, 5]); - expect( - expand([1, 2, '3', 5], toggleListItemExpand('foo', 3)) - ).toEqual([1, 2, 5]); - }); - }); -}); diff --git a/packages/ra-core/src/reducer/admin/resource/list/expanded.ts b/packages/ra-core/src/reducer/admin/resource/list/expanded.ts deleted file mode 100644 index 30001843c0e..00000000000 --- a/packages/ra-core/src/reducer/admin/resource/list/expanded.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Reducer } from 'redux'; -import { - ToggleListItemExpandAction, - TOGGLE_LIST_ITEM_EXPAND, -} from '../../../../actions/listActions'; -import { Identifier } from '../../../../types'; - -type IdentifierArray = Identifier[]; - -type ActionTypes = - | ToggleListItemExpandAction - | { - type: 'OTHER_ACTION'; - payload: any; - }; -const initialState = []; - -const expanded: Reducer = ( - previousState = initialState, - action: ActionTypes -) => { - if (action.type === TOGGLE_LIST_ITEM_EXPAND) { - const index = previousState - .map(el => el == action.payload) // eslint-disable-line eqeqeq - .indexOf(true); - if (index === -1) { - // expand - return [...previousState, action.payload]; - } else { - // close - return [ - ...previousState.slice(0, index), - ...previousState.slice(index + 1), - ]; - } - } - return previousState; -}; - -export default expanded; diff --git a/packages/ra-core/src/reducer/admin/resource/list/index.ts b/packages/ra-core/src/reducer/admin/resource/list/index.ts deleted file mode 100644 index a3b52a10a82..00000000000 --- a/packages/ra-core/src/reducer/admin/resource/list/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { combineReducers } from 'redux'; -import expanded from './expanded'; -import params from './params'; -import selectedIds from './selectedIds'; - -const defaultReducer = () => null; - -export default combineReducers({ - /** - * ts-jest does some aggressive module mocking when unit testing reducers individually. - * To avoid 'No reducer provided for key "..."' warnings, - * we pass default reducers. Sorry for legibility. - * - * @see https://stackoverflow.com/questions/43375079/redux-warning-only-appearing-in-tests - */ - expanded: expanded || defaultReducer, - params: params || defaultReducer, - selectedIds: selectedIds || defaultReducer, -}); diff --git a/packages/ra-core/src/reducer/admin/resource/list/params.ts b/packages/ra-core/src/reducer/admin/resource/list/params.ts deleted file mode 100644 index 8f2e6e9f4bd..00000000000 --- a/packages/ra-core/src/reducer/admin/resource/list/params.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Reducer } from 'redux'; -import { - CRUD_CHANGE_LIST_PARAMS, - ChangeListParamsAction, -} from '../../../../actions/listActions'; - -const defaultState = { - sort: null, - order: null, - page: 1, - perPage: null, - filter: {}, -}; - -export interface ParamsState { - sort: string; - order: string; - page: number; - perPage: number; - filter: any; -} - -type ActionTypes = - | ChangeListParamsAction - | { type: 'OTHER_ACTION'; payload: any }; - -const paramsReducer: Reducer = ( - previousState = defaultState, - action: ActionTypes -) => { - switch (action.type) { - case CRUD_CHANGE_LIST_PARAMS: - return action.payload; - default: - return previousState; - } -}; - -export default paramsReducer; diff --git a/packages/ra-core/src/reducer/admin/resource/list/selectedIds.ts b/packages/ra-core/src/reducer/admin/resource/list/selectedIds.ts deleted file mode 100644 index f4c10e76878..00000000000 --- a/packages/ra-core/src/reducer/admin/resource/list/selectedIds.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { Reducer } from 'redux'; -import { - SET_LIST_SELECTED_IDS, - SetListSelectedIdsAction, - TOGGLE_LIST_ITEM, - ToggleListItemAction, - UNSELECT_LIST_ITEMS, - UnselectListItemsAction, -} from '../../../../actions'; -import { Identifier } from '../../../../types'; - -const initialState = []; - -type State = Identifier[]; - -type ActionTypes = - | SetListSelectedIdsAction - | ToggleListItemAction - | UnselectListItemsAction - | { - type: 'OTHER_ACTION'; - meta: any; - payload: any; - }; - -const selectedIdsReducer: Reducer = ( - previousState: State = initialState, - action: ActionTypes -) => { - if (action.type === SET_LIST_SELECTED_IDS) { - return action.payload; - } - if (action.type === TOGGLE_LIST_ITEM) { - const index = previousState.indexOf(action.payload); - if (index > -1) { - return [ - ...previousState.slice(0, index), - ...previousState.slice(index + 1), - ]; - } else { - return [...previousState, action.payload]; - } - } - if (action.type === UNSELECT_LIST_ITEMS) { - const ids = action.payload; - if (!ids || ids.length === 0) return previousState; - let newState = [...previousState]; - ids.forEach(id => { - const index = newState.indexOf(id); - if (index > -1) { - newState = [ - ...previousState.slice(0, index), - ...previousState.slice(index + 1), - ]; - } - }); - return newState; - } - - return action.meta && action.meta.unselectAll - ? initialState - : previousState; -}; - -export default selectedIdsReducer; diff --git a/packages/ra-core/src/reducer/admin/selectedIds.ts b/packages/ra-core/src/reducer/admin/selectedIds.ts new file mode 100644 index 00000000000..f63f6c8b135 --- /dev/null +++ b/packages/ra-core/src/reducer/admin/selectedIds.ts @@ -0,0 +1,73 @@ +import { Reducer } from 'redux'; +import { + SET_LIST_SELECTED_IDS, + SetListSelectedIdsAction, + TOGGLE_LIST_ITEM, + ToggleListItemAction, + UNSELECT_LIST_ITEMS, + UnselectListItemsAction, +} from '../../actions'; +import { Identifier } from '../../types'; + +const initialState = {}; + +type State = { + [key: string]: Identifier[]; +}; + +type ActionTypes = + | SetListSelectedIdsAction + | ToggleListItemAction + | UnselectListItemsAction + | { + type: 'OTHER_ACTION'; + meta: any; + payload: any; + }; + +export const selectedIds: Reducer = ( + previousState: State = initialState, + action: ActionTypes +) => { + if (action.type === SET_LIST_SELECTED_IDS) { + return { + ...previousState, + [action.meta.resource]: action.payload, + }; + } + if (action.type === TOGGLE_LIST_ITEM) { + const previousIds = previousState[action.meta.resource] || []; + const index = previousIds.indexOf(action.payload); + return { + ...previousState, + [action.meta.resource]: + index > -1 + ? [ + ...previousIds.slice(0, index), + ...previousIds.slice(index + 1), + ] + : [...previousIds, action.payload], + }; + } + if (action.type === UNSELECT_LIST_ITEMS) { + const ids = action.payload; + if (!ids || ids.length === 0) return previousState; + const previousIds = previousState[action.meta.resource] || []; + let newIds = [...previousIds]; + ids.forEach(id => { + const index = newIds.indexOf(id); + if (index > -1) { + newIds = [ + ...previousIds.slice(0, index), + ...previousIds.slice(index + 1), + ]; + } + }); + return { + ...previousState, + [action.meta.resource]: newIds, + }; + } + + return previousState; +}; diff --git a/packages/ra-core/src/reducer/index.ts b/packages/ra-core/src/reducer/index.ts index 239b7565d18..b9b7b5180da 100644 --- a/packages/ra-core/src/reducer/index.ts +++ b/packages/ra-core/src/reducer/index.ts @@ -1,8 +1,8 @@ import { combineReducers, Reducer } from 'redux'; -import admin, { - getResources as adminGetResources, - getReferenceResource as adminGetReferenceResource, -} from './admin'; + +import admin from './admin'; +import { ReduxState } from '../types'; + export { getNotification } from './admin/notifications'; interface CustomReducers { @@ -13,8 +13,4 @@ export default (customReducers: CustomReducers) => combineReducers({ admin, ...customReducers, - }); - -export const getResources = state => adminGetResources(state.admin); -export const getReferenceResource = (state, props) => - adminGetReferenceResource(state.admin, props); + }) as Reducer; diff --git a/packages/ra-core/src/types.ts b/packages/ra-core/src/types.ts index 1ea82eeb617..5b46c720aa3 100644 --- a/packages/ra-core/src/types.ts +++ b/packages/ra-core/src/types.ts @@ -260,14 +260,19 @@ export interface ReduxState { ui: { sidebarOpen: boolean; }; - resources: { + expandedRows: { + [name: string]: Identifier[]; + }; + selectedIds: { + [name: string]: Identifier[]; + }; + listParams: { [name: string]: { - props: ResourceDefinition; - list: { - expanded: Identifier[]; - params: any; - selectedIds: Identifier[]; - }; + sort: string; + order: string; + page: number; + perPage: number; + filter: any; }; }; }; diff --git a/packages/ra-test/src/TestContext.spec.tsx b/packages/ra-test/src/TestContext.spec.tsx index 78f2a1038cc..39c4305e132 100644 --- a/packages/ra-test/src/TestContext.spec.tsx +++ b/packages/ra-test/src/TestContext.spec.tsx @@ -9,8 +9,10 @@ import { WithDataProvider } from './TestContext.stories'; const primedStore = { admin: { notifications: [], - resources: {}, ui: {}, + selectedIds: {}, + expandedRows: {}, + listParams: {}, }, }; diff --git a/packages/ra-test/src/TestContext.tsx b/packages/ra-test/src/TestContext.tsx index a18c4d2e48b..cd6eaac0f58 100644 --- a/packages/ra-test/src/TestContext.tsx +++ b/packages/ra-test/src/TestContext.tsx @@ -10,8 +10,10 @@ import { createAdminStore, ReduxState } from 'ra-core'; export const defaultStore = { admin: { - resources: {}, ui: {}, + expandedRows: {}, + selectedIds: {}, + listParams: {}, notifications: [], }, }; diff --git a/packages/ra-ui-materialui/src/button/SaveButton.spec.tsx b/packages/ra-ui-materialui/src/button/SaveButton.spec.tsx index 7b4317ef006..4063124d905 100644 --- a/packages/ra-ui-materialui/src/button/SaveButton.spec.tsx +++ b/packages/ra-ui-materialui/src/button/SaveButton.spec.tsx @@ -248,8 +248,7 @@ describe('', () => { - , - { admin: { resources: { posts: { data: {} } } } } + ); // waitFor for the dataProvider.getOne() return await waitFor(() => { @@ -298,8 +297,7 @@ describe('', () => { - , - { admin: { resources: { posts: { data: {} } } } } + ); // waitFor for the dataProvider.getOne() return await waitFor(() => { @@ -352,8 +350,7 @@ describe('', () => { - , - { admin: { resources: { posts: { data: {} } } } } + ); // waitFor for the dataProvider.getOne() return await waitFor(() => { diff --git a/packages/ra-ui-materialui/src/detail/Edit.spec.tsx b/packages/ra-ui-materialui/src/detail/Edit.spec.tsx index bfbfc4d2560..c58170991ec 100644 --- a/packages/ra-ui-materialui/src/detail/Edit.spec.tsx +++ b/packages/ra-ui-materialui/src/detail/Edit.spec.tsx @@ -39,8 +39,7 @@ describe('', () => { - , - { admin: { resources: { foo: { data: {} } } } } + ); await waitFor(() => { expect(queryAllByText('lorem')).toHaveLength(1); diff --git a/packages/ra-ui-materialui/src/layout/Menu.tsx b/packages/ra-ui-materialui/src/layout/Menu.tsx index fc4f23dc697..42c522b5b6c 100644 --- a/packages/ra-ui-materialui/src/layout/Menu.tsx +++ b/packages/ra-ui-materialui/src/layout/Menu.tsx @@ -1,18 +1,22 @@ import * as React from 'react'; +import { ReactNode, createElement } from 'react'; import { styled } from '@mui/material/styles'; -import { ReactNode } from 'react'; import PropTypes from 'prop-types'; -import { shallowEqual, useSelector } from 'react-redux'; +import { useSelector } from 'react-redux'; import lodashGet from 'lodash/get'; import DefaultIcon from '@mui/icons-material/ViewList'; import classnames from 'classnames'; -import { useGetResourceLabel, getResources, ReduxState } from 'ra-core'; +import { + useResourceDefinitions, + useGetResourceLabel, + ReduxState, +} from 'ra-core'; import { DashboardMenuItem } from './DashboardMenuItem'; import { MenuItemLink } from './MenuItemLink'; export const Menu = (props: MenuProps) => { - const resources = useSelector(getResources, shallowEqual) as Array; + const resources = useResourceDefinitions(); const getResourceLabel = useGetResourceLabel(); const { hasDashboard, @@ -20,17 +24,17 @@ export const Menu = (props: MenuProps) => { children = ( <> {hasDashboard && } - {resources - .filter(r => r.hasList) - .map(resource => ( + {Object.keys(resources) + .filter(name => resources[name].hasList) + .map(name => ( + resources[name].icon ? ( + createElement(resources[name].icon) ) : ( ) diff --git a/packages/ra-ui-materialui/src/list/datagrid/DatagridRow.spec.tsx b/packages/ra-ui-materialui/src/list/datagrid/DatagridRow.spec.tsx index b6294c40d62..4228061e958 100644 --- a/packages/ra-ui-materialui/src/list/datagrid/DatagridRow.spec.tsx +++ b/packages/ra-ui-materialui/src/list/datagrid/DatagridRow.spec.tsx @@ -16,18 +16,7 @@ const render = element => renderWithRedux( {element} -
, - { - admin: { - resources: { - posts: { - list: { - expanded: [], - }, - }, - }, - }, - } + ); describe('', () => {