diff --git a/UPGRADE.md b/UPGRADE.md index 9a5661534a9..5cb2bd40b8c 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -309,3 +309,26 @@ export default (type, params) => { // ... } ``` + +## ReferenceInputController isLoading injected props renamed to loading + +When using custom component with ReferenceInputController, you should rename the component `isLoading` prop to `loading`. + +```diff +- +- {({ isLoading, otherProps }) => ( +- +- )} +- ++ ++ {({ loading, otherProps }) => ( ++ ++ )} ++ +``` \ No newline at end of file diff --git a/docs/Inputs.md b/docs/Inputs.md index b71e9074d6f..98a15bd3f4f 100644 --- a/docs/Inputs.md +++ b/docs/Inputs.md @@ -922,7 +922,7 @@ The child component may further filter results (that's the case, for instance, f The child component receives the following props from ``: -- `isLoading`: whether the request for possible values is loading or not +- `loading`: whether the request for possible values is loading or not - `filter`: the current filter of the request for possible values. Defaults to `{}`. - `pagination`: the current pagination of the request for possible values. Defaults to `{ page: 1, perPage: 25 }`. - `sort`: the current sorting of the request for possible values. Defaults to `{ field: 'id', order: 'DESC' }`. diff --git a/packages/ra-core/src/controller/field/ReferenceFieldController.spec.tsx b/packages/ra-core/src/controller/field/ReferenceFieldController.spec.tsx index 375dbc8716b..b5afb10a772 100644 --- a/packages/ra-core/src/controller/field/ReferenceFieldController.spec.tsx +++ b/packages/ra-core/src/controller/field/ReferenceFieldController.spec.tsx @@ -20,6 +20,7 @@ describe('', () => { record={{ id: 1, postId: 123 }} source="postId" reference="posts" + resource="comments" basePath="" />, defaultState @@ -35,6 +36,7 @@ describe('', () => { record={{ id: 1, postId: null }} source="postId" reference="posts" + resource="comments" basePath="" />, defaultState @@ -51,7 +53,6 @@ describe('', () => { reference="posts" resource="comments" basePath="/comments" - crudGetManyAccumulate={crudGetManyAccumulate} > {children} , @@ -59,7 +60,8 @@ describe('', () => { ); expect(children).toBeCalledWith({ - isLoading: false, + loading: false, + loaded: true, referenceRecord: { id: 123, title: 'foo' }, resourceLinkPath: '/posts/123', }); @@ -89,7 +91,8 @@ describe('', () => { ); expect(children).toBeCalledWith({ - isLoading: false, + loading: false, + loaded: true, referenceRecord: { id: 123, title: 'foo' }, resourceLinkPath: '/prefix/posts/123', }); @@ -119,7 +122,8 @@ describe('', () => { ); expect(children).toBeCalledWith({ - isLoading: false, + loading: false, + loaded: true, referenceRecord: { id: 123, title: 'foo' }, resourceLinkPath: '/edit/123', }); @@ -134,7 +138,6 @@ describe('', () => { reference="show" resource="edit" basePath="/edit" - crudGetManyAccumulate={crudGetManyAccumulate} > {children} , @@ -150,7 +153,8 @@ describe('', () => { ); expect(children).toBeCalledWith({ - isLoading: false, + loading: false, + loaded: true, referenceRecord: { id: 123, title: 'foo' }, resourceLinkPath: '/show/123', }); @@ -173,7 +177,8 @@ describe('', () => { ); expect(children).toBeCalledWith({ - isLoading: false, + loading: false, + loaded: true, referenceRecord: { id: 123, title: 'foo' }, resourceLinkPath: '/posts/123/show', }); @@ -204,7 +209,8 @@ describe('', () => { ); expect(children).toBeCalledWith({ - isLoading: false, + loading: false, + loaded: true, referenceRecord: { id: 123, title: 'foo' }, resourceLinkPath: '/edit/123/show', }); @@ -219,7 +225,6 @@ describe('', () => { reference="show" resource="edit" basePath="/edit" - crudGetManyAccumulate={crudGetManyAccumulate} link="show" > {children} @@ -236,7 +241,8 @@ describe('', () => { ); expect(children).toBeCalledWith({ - isLoading: false, + loading: false, + loaded: true, referenceRecord: { id: 123, title: 'foo' }, resourceLinkPath: '/show/123/show', }); @@ -249,6 +255,7 @@ describe('', () => { record={{ id: 1, postId: 123 }} source="postId" reference="posts" + resource="comments" basePath="/foo" link={false} > @@ -258,7 +265,8 @@ describe('', () => { ); expect(children).toBeCalledWith({ - isLoading: false, + loading: false, + loaded: true, referenceRecord: { id: 123, title: 'foo' }, resourceLinkPath: false, }); diff --git a/packages/ra-core/src/controller/field/ReferenceFieldController.tsx b/packages/ra-core/src/controller/field/ReferenceFieldController.tsx index f6172e714e1..b7deac98262 100644 --- a/packages/ra-core/src/controller/field/ReferenceFieldController.tsx +++ b/packages/ra-core/src/controller/field/ReferenceFieldController.tsx @@ -1,19 +1,24 @@ import { FunctionComponent, ReactNode, ReactElement } from 'react'; +import get from 'lodash/get'; + import { Record } from '../../types'; -import useReference, { - UseReferenceProps, - LinkToFunctionType, -} from './useReference'; + +import getResourceLinkPath, { LinkToFunctionType } from './getResourceLinkPath'; +import useReference, { UseReferenceProps } from '../useReference'; + +interface childrenParams extends UseReferenceProps { + resourceLinkPath: string | false; +} interface Props { allowEmpty?: boolean; basePath: string; - children: (params: UseReferenceProps) => ReactNode; + children: (params: childrenParams) => ReactNode; record?: Record; reference: string; resource: string; source: string; - link: string | boolean | LinkToFunctionType; + link?: string | boolean | LinkToFunctionType; } /** @@ -47,9 +52,15 @@ interface Props { */ export const ReferenceFieldController: FunctionComponent = ({ children, + record, + source, ...props }) => { - return children(useReference(props)) as ReactElement; + const id = get(record, source); + return children({ + ...useReference({ ...props, id }), + resourceLinkPath: getResourceLinkPath({ ...props, record, source }), + }) as ReactElement; }; export default ReferenceFieldController; diff --git a/packages/ra-core/src/controller/field/ReferenceManyFieldController.tsx b/packages/ra-core/src/controller/field/ReferenceManyFieldController.tsx index e040daa14cb..25db5f5fd56 100644 --- a/packages/ra-core/src/controller/field/ReferenceManyFieldController.tsx +++ b/packages/ra-core/src/controller/field/ReferenceManyFieldController.tsx @@ -33,6 +33,8 @@ interface Props { total?: number; } +const defaultPerPage = 25; + /** * Render related records to the current one. * @@ -91,10 +93,10 @@ export const ReferenceManyFieldController: FunctionComponent = ({ sort: initialSort, children, }) => { - const { sort, setSort } = useSortState(initialSort); - const { page, perPage, setPage, setPerPage } = usePaginationState( - initialPerPage - ); + const { sort, setSortField } = useSortState(initialSort); + const { page, perPage, setPage, setPerPage } = usePaginationState({ + perPage: initialPerPage || defaultPerPage, + }); const { data, ids, @@ -124,7 +126,7 @@ export const ReferenceManyFieldController: FunctionComponent = ({ referenceBasePath, setPage, setPerPage, - setSort, + setSort: setSortField, total, }); }; diff --git a/packages/ra-core/src/controller/field/useReference.ts b/packages/ra-core/src/controller/field/getResourceLinkPath.ts similarity index 57% rename from packages/ra-core/src/controller/field/useReference.ts rename to packages/ra-core/src/controller/field/getResourceLinkPath.ts index 6b0ff2763ff..5099288d959 100644 --- a/packages/ra-core/src/controller/field/useReference.ts +++ b/packages/ra-core/src/controller/field/getResourceLinkPath.ts @@ -1,11 +1,7 @@ -import { useEffect } from 'react'; -// @ts-ignore -import { useDispatch, useSelector } from 'react-redux'; import get from 'lodash/get'; -import { crudGetManyAccumulate } from '../../actions'; import { linkToRecord } from '../../util'; -import { Record, ReduxState } from '../../types'; +import { Record } from '../../types'; export type LinkToFunctionType = (record: Record, reference: string) => string; @@ -15,75 +11,59 @@ interface Option { allowEmpty?: boolean; basePath: string; record?: Record; + source: string; reference: string; resource: string; - source: string; - link: LinkToType; + link?: LinkToType; linkType?: LinkToType; // deprecated, use link instead } -export interface UseReferenceProps { - isLoading: boolean; - referenceRecord: Record; - resourceLinkPath: string | false; -} - /** * @typedef ReferenceProps * @type {Object} - * @property {boolean} isLoading: boolean indicating if the reference has loaded + * @property {boolean} loading: boolean indicating if the reference is loading + * @property {boolean} loaded: boolean indicating if the reference has loaded * @property {Object} referenceRecord: the referenced record. * @property {string | false} resourceLinkPath link to the page of the related record (depends on link) (false is no link) */ /** - * Fetch reference record, and return it when avaliable - * - * The reference prop sould be the name of one of the components - * added as child. + * Get the link toward the referenced resource * * @example * - * const { isLoading, referenceRecord, resourceLinkPath } = useReference({ - * source: 'userId', - * reference: 'users', - * record: { - * userId: 7 - * } + * const linkPath = getResourceLinkPath({ + * basePath: '/comments', + * link: 'edit', + * reference: 'users', + * record: { + * userId: 7 + * }, + * resource: 'comments', + * source: 'userId', * }); * * @param {Object} option - * @param {boolean} option.allowEmpty do we allow for no referenced record (default to false) * @param {string} option.basePath basepath to current resource * @param {string | false | LinkToFunctionType} option.link="edit" The link toward the referenced record. 'edit', 'show' or false for no link (default to edit). Alternatively a function that returns a string * @param {string | false | LinkToFunctionType} [option.linkType] DEPRECATED : old name for link - * @param {Object} option.record The The current resource record * @param {string} option.reference The linked resource name + * @param {Object} option.record The The current resource record * @param {string} option.resource The current resource name * @param {string} option.source The key of the linked resource identifier * * @returns {ReferenceProps} The reference props */ -export const useReference = ({ - allowEmpty = false, +const getResourceLinkPath = ({ basePath, link = 'edit', linkType, - record = { id: '' }, reference, + record = { id: '' }, resource, source, -}: Option): UseReferenceProps => { +}: Option): string | false => { const sourceId = get(record, source); - const referenceRecord = useSelector( - getReferenceRecord(sourceId, reference) - ); - const dispatch = useDispatch(); - useEffect(() => { - if (sourceId !== null && typeof sourceId !== 'undefined') { - dispatch(crudGetManyAccumulate(reference, [sourceId])); - } - }, [sourceId, reference]); // eslint-disable-line react-hooks/exhaustive-deps const rootPath = basePath.replace(resource, reference); // Backward compatibility: keep linkType but with warning const getResourceLinkPath = (linkTo: LinkToType) => @@ -103,15 +83,7 @@ export const useReference = ({ linkType !== undefined ? linkType : link ); - return { - isLoading: !referenceRecord && !allowEmpty, - referenceRecord, - resourceLinkPath, - }; + return resourceLinkPath; }; -const getReferenceRecord = (sourceId, reference) => (state: ReduxState) => - state.admin.resources[reference] && - state.admin.resources[reference].data[sourceId]; - -export default useReference; +export default getResourceLinkPath; diff --git a/packages/ra-core/src/controller/field/index.ts b/packages/ra-core/src/controller/field/index.ts index 82efc593192..341d655ad65 100644 --- a/packages/ra-core/src/controller/field/index.ts +++ b/packages/ra-core/src/controller/field/index.ts @@ -1,7 +1,7 @@ import ReferenceArrayFieldController from './ReferenceArrayFieldController'; import ReferenceFieldController from './ReferenceFieldController'; import ReferenceManyFieldController from './ReferenceManyFieldController'; -import useReference from './useReference'; +import getResourceLinkPath from './getResourceLinkPath'; import useReferenceArray from './useReferenceArray'; import useReferenceMany from './useReferenceMany'; @@ -9,7 +9,7 @@ export { useReferenceArray, ReferenceArrayFieldController, ReferenceFieldController, - useReference, + getResourceLinkPath, useReferenceMany, ReferenceManyFieldController, }; diff --git a/packages/ra-core/src/controller/index.ts b/packages/ra-core/src/controller/index.ts index 1da352fc3b7..4212f351fd3 100644 --- a/packages/ra-core/src/controller/index.ts +++ b/packages/ra-core/src/controller/index.ts @@ -14,6 +14,7 @@ import useListController from './useListController'; import useEditController from './useEditController'; import useCreateController from './useCreateController'; import useShowController from './useShowController'; +import useReference from './useReference'; import { useCheckMinimumRequiredProps } from './checkMinimumRequiredProps'; export { getListControllerProps, @@ -31,6 +32,7 @@ export { useVersion, useSortState, usePaginationState, + useReference, }; export * from './field'; diff --git a/packages/ra-core/src/controller/input/ReferenceInputController.spec.tsx b/packages/ra-core/src/controller/input/ReferenceInputController.spec.tsx index c5617b602c9..f742b3a3d1c 100644 --- a/packages/ra-core/src/controller/input/ReferenceInputController.spec.tsx +++ b/packages/ra-core/src/controller/input/ReferenceInputController.spec.tsx @@ -1,531 +1,69 @@ import React from 'react'; -import assert from 'assert'; -import { shallow } from 'enzyme'; -import { render } from 'react-testing-library'; -import { UnconnectedReferenceInputController as ReferenceInputController } from './ReferenceInputController'; +import { WrappedFieldInputProps } from 'redux-form'; +import { cleanup } from 'react-testing-library'; +import omit from 'lodash/omit'; + +import renderWithRedux from '../../util/renderWithRedux'; +import ReferenceInputController from './ReferenceInputController'; describe('', () => { const defaultProps = { basePath: '/comments', children: jest.fn(), - crudGetManyAccumulate: jest.fn(), - crudGetMatchingAccumulate: jest.fn(), - meta: {}, - input: { value: undefined }, + input: { value: undefined } as WrappedFieldInputProps, onChange: jest.fn(), reference: 'posts', resource: 'comments', source: 'post_id', - translate: x => `*${x}*`, }; - it('should set isLoading to true if the references are being searched and a selected reference does not have data', () => { - const children = jest.fn(); - shallow( - - {children} - - ); - - assert.equal(children.mock.calls[0][0].isLoading, true); - }); - - it('should set isLoading to true if the references are being searched and there is no reference already selected', () => { - const children = jest.fn(); - shallow( - - {children} - - ); - assert.equal(children.mock.calls[0][0].isLoading, true); - }); - - it('should set isLoading to false if the references are being searched but a selected reference have data', () => { - const children = jest.fn(); - shallow( - - {children} - - ); - assert.equal(children.mock.calls[0][0].isLoading, false); - assert.deepEqual(children.mock.calls[0][0].choices, [{ id: 1 }]); - }); - - it('should set isLoading to false if the references were found but a selected reference does not have data', () => { - const children = jest.fn(); - shallow( - - {children} - - ); - assert.equal(children.mock.calls[0][0].isLoading, false); - assert.deepEqual(children.mock.calls[0][0].choices, [{ id: 2 }]); - }); + afterEach(cleanup); - it('should set error in case of references fetch error and selected reference does not have data', () => { - const children = jest.fn(); - shallow( + it('should fetch reference matchingReferences, and provide filter pagination and sort', () => { + const children = jest.fn().mockReturnValue(

child

); + const { dispatch } = renderWithRedux( {children} - - ); - assert.equal( - children.mock.calls[0][0].error, - '*ra.input.references.single_missing*' - ); - }); - - it('should set error in case of references fetch error and there is no reference already selected', () => { - const children = jest.fn(); - shallow( - - {children} - - ); - assert.equal(children.mock.calls[0][0].error, '*fetch error*'); - }); - - it('should not set error in case of references fetch error but selected reference have data', () => { - const children = jest.fn(); - shallow( - - {children} - - ); - - assert.equal(children.mock.calls[0][0].error, undefined); - }); - - it('should not set error if the references are empty (but fetched without error) and a selected reference does not have data', () => { - const children = jest.fn(); - shallow( - - {children} - - ); - assert.equal(children.mock.calls[0][0].error, undefined); - }); - - it('should set warning in case of references fetch error and there selected reference with data', () => { - const children = jest.fn(); - shallow( - - {children} - - ); - assert.equal(children.mock.calls[0][0].warning, '*fetch error*'); - }); - - it('should set warning if references were found but not the already selected one', () => { - const children = jest.fn(); - shallow( - - {children} - - ); - assert.equal( - children.mock.calls[0][0].warning, - '*ra.input.references.single_missing*' - ); - }); - - it('should not set warning if all references were found', () => { - const children = jest.fn(); - shallow( - - {children} - - ); - assert.equal(children.mock.calls[0][0].warning, undefined); - }); - - it('should call crudGetMatchingAccumulate on mount with default fetch values', () => { - const crudGetMatchingAccumulate = jest.fn(); - shallow( - - ); - assert.deepEqual(crudGetMatchingAccumulate.mock.calls[0], [ - 'posts', - 'comments@post_id', - { - page: 1, - perPage: 25, - }, - { - field: 'id', - order: 'DESC', - }, - {}, - ]); - }); - - it('should allow to customize crudGetMatchingAccumulate arguments with perPage, sort, and filter props', () => { - const crudGetMatchingAccumulate = jest.fn(); - shallow( - - ); - assert.deepEqual(crudGetMatchingAccumulate.mock.calls[0], [ - 'posts', - 'comments@post_id', - { - page: 1, - perPage: 5, - }, - { - field: 'foo', - order: 'ASC', - }, - { - q: 'foo', - }, - ]); - }); - - it('should allow to customize crudGetMatchingAccumulate arguments with perPage, sort, and filter props without loosing original default filter', () => { - const crudGetMatchingAccumulate = jest.fn(); - const wrapper = shallow( - - ); - - wrapper.instance().setFilter('search_me'); - - assert.deepEqual(crudGetMatchingAccumulate.mock.calls[1], [ - 'posts', - 'comments@post_id', - { - page: 1, - perPage: 5, - }, - { - field: 'foo', - order: 'ASC', - }, - { - foo: 'bar', - q: 'search_me', - }, - ]); - }); - - it('should call crudGetMatchingAccumulate when setFilter is called', () => { - const crudGetMatchingAccumulate = jest.fn(); - const wrapper = shallow( - - ); - wrapper.instance().setFilter('bar'); - assert.deepEqual(crudGetMatchingAccumulate.mock.calls[1], [ - 'posts', - 'comments@post_id', - { - page: 1, - perPage: 25, - }, - { - field: 'id', - order: 'DESC', - }, - { - q: 'bar', - }, - ]); - }); - - it('should use custom filterToQuery function prop', () => { - const crudGetMatchingAccumulate = jest.fn(); - const wrapper = shallow( - ({ foo: searchText })} - /> - ); - wrapper.instance().setFilter('bar'); - assert.deepEqual(crudGetMatchingAccumulate.mock.calls[1], [ - 'posts', - 'comments@post_id', - { - page: 1, - perPage: 25, - }, - { - field: 'id', - order: 'DESC', - }, + , { - foo: 'bar', - }, - ]); - }); - - it('should call crudGetManyAccumulate on mount if value is set', () => { - const crudGetManyAccumulate = jest.fn(); - shallow( - - ); - assert.deepEqual(crudGetManyAccumulate.mock.calls[0], ['posts', [5]]); - }); - - it('should pass onChange down to child component', () => { - const children = jest.fn(); - const onChange = jest.fn(); - shallow( - - {children} - - ); - assert.deepEqual(children.mock.calls[0][0].onChange, onChange); - }); - - it('should only call crudGetMatchingAccumulate when calling setFilter', () => { - const crudGetMatchingAccumulate = jest.fn(); - const crudGetManyAccumulate = jest.fn(); - const wrapper = shallow( - - ); - assert.equal(crudGetMatchingAccumulate.mock.calls.length, 1); - assert.equal(crudGetManyAccumulate.mock.calls.length, 1); - - wrapper.instance().setFilter('bar'); - assert.equal(crudGetMatchingAccumulate.mock.calls.length, 2); - assert.equal(crudGetManyAccumulate.mock.calls.length, 1); - }); - - it('should only call crudGetMatching when props are changed from outside', () => { - const crudGetMatchingAccumulate = jest.fn(); - const crudGetManyAccumulate = jest.fn(); - const ControllerWrapper = props => ( - - {() => null} - - ); - - const { rerender } = render(); - assert.equal(crudGetMatchingAccumulate.mock.calls.length, 1); - assert.equal(crudGetManyAccumulate.mock.calls.length, 1); - - rerender(); - - assert.equal(crudGetManyAccumulate.mock.calls.length, 1); - assert.deepEqual(crudGetMatchingAccumulate.mock.calls[1], [ - 'posts', - 'comments@post_id', - { page: 1, perPage: 25 }, - { field: 'id', order: 'DESC' }, - { foo: 'bar' }, - ]); - - rerender( - - ); - - assert.equal(crudGetManyAccumulate.mock.calls.length, 1); - assert.deepEqual(crudGetMatchingAccumulate.mock.calls[2], [ - 'posts', - 'comments@post_id', - { page: 1, perPage: 25 }, - { field: 'foo', order: 'ASC' }, - { foo: 'bar' }, - ]); - - rerender( - - ); - - assert.equal(crudGetManyAccumulate.mock.calls.length, 1); - assert.deepEqual(crudGetMatchingAccumulate.mock.calls[3], [ - 'posts', - 'comments@post_id', - { page: 1, perPage: 42 }, - { field: 'foo', order: 'ASC' }, - { foo: 'bar' }, - ]); - }); - - it('should only call crudGetMatchingAccumulate when props are changed from outside', () => { - const crudGetMatchingAccumulate = jest.fn(); - const crudGetManyAccumulate = jest.fn(); - const wrapper = shallow( - - ); - expect(crudGetMatchingAccumulate).toHaveBeenCalledTimes(1); - expect(crudGetManyAccumulate).toHaveBeenCalledTimes(1); - - wrapper.setProps({ filter: { foo: 'bar' } }); - expect(crudGetMatchingAccumulate.mock.calls.length).toBe(2); - expect(crudGetManyAccumulate).toHaveBeenCalledTimes(1); - - wrapper.setProps({ sort: { field: 'foo', order: 'ASC' } }); - expect(crudGetMatchingAccumulate.mock.calls.length).toBe(3); - expect(crudGetManyAccumulate).toHaveBeenCalledTimes(1); - - wrapper.setProps({ perPage: 42 }); - expect(crudGetMatchingAccumulate.mock.calls.length).toBe(4); - expect(crudGetManyAccumulate).toHaveBeenCalledTimes(1); - }); - - it('should call crudGetManyAccumulate when input value changes', () => { - const crudGetManyAccumulate = jest.fn(); - const wrapper = shallow( - - ); - assert.equal(crudGetManyAccumulate.mock.calls.length, 1); - wrapper.setProps({ input: { value: 6 } }); - assert.equal(crudGetManyAccumulate.mock.calls.length, 2); - }); - - it('should call crudGetManyAccumulate and crudGetMatchingAccumulate when record changes', () => { - const crudGetManyAccumulate = jest.fn(); - const crudGetMatchingAccumulate = jest.fn(); - const wrapper = shallow( - + admin: { + resources: { posts: { data: { 1: { id: 1 } } } }, + references: { + possibleValues: { 'comments@post_id': [2, 1] }, + }, + }, + } + ); + + expect( + omit(children.mock.calls[0][0], [ + 'onChange', + 'setPagination', + 'setFilter', + 'setSort', + ]) + ).toEqual({ + choices: [{ id: 1 }], + error: null, + filter: { q: '' }, + loading: false, + pagination: { page: 1, perPage: 25 }, + sort: { field: 'id', order: 'DESC' }, + warning: null, + }); + + expect(dispatch).toBeCalledTimes(2); + expect(dispatch.mock.calls[0][0].type).toBe( + 'RA/CRUD_GET_MATCHING_ACCUMULATE' + ); + expect(dispatch.mock.calls[1][0].type).toBe( + 'RA/CRUD_GET_MANY_ACCUMULATE' ); - assert.equal(crudGetMatchingAccumulate.mock.calls.length, 1); - assert.equal(crudGetManyAccumulate.mock.calls.length, 1); - wrapper.setProps({ record: { id: 1 } }); - assert.equal(crudGetMatchingAccumulate.mock.calls.length, 2); - assert.equal(crudGetManyAccumulate.mock.calls.length, 2); }); }); diff --git a/packages/ra-core/src/controller/input/ReferenceInputController.tsx b/packages/ra-core/src/controller/input/ReferenceInputController.tsx index 89b82a9830c..e343b3fb034 100644 --- a/packages/ra-core/src/controller/input/ReferenceInputController.tsx +++ b/packages/ra-core/src/controller/input/ReferenceInputController.tsx @@ -1,71 +1,33 @@ -import { Component, ReactNode, ComponentType } from 'react'; -import { connect } from 'react-redux'; -import debounce from 'lodash/debounce'; -import compose from 'recompose/compose'; -import { createSelector } from 'reselect'; -import isEqual from 'lodash/isEqual'; +import { + ReactNode, + ComponentType, + FunctionComponent, + ReactElement, +} from 'react'; import { WrappedFieldInputProps } from 'redux-form'; -import { - crudGetManyAccumulate as crudGetManyAccumulateAction, - crudGetMatchingAccumulate as crudGetMatchingAccumulateAction, -} from '../../actions/accumulateActions'; -import { - getPossibleReferences, - getPossibleReferenceValues, - getReferenceResource, -} from '../../reducer'; -import { getStatusForInput as getDataStatus } from './referenceDataStatus'; -import withTranslate from '../../i18n/translate'; -import { Sort, Translate, Record, Pagination, Dispatch } from '../../types'; -import { MatchingReferencesError } from './types'; +import { Sort, Record } from '../../types'; +import useReferenceInput, { ReferenceInputValue } from './useReferenceInput'; +import { filter } from 'async'; const defaultReferenceSource = (resource: string, source: string) => `${resource}@${source}`; -interface ChildrenFuncParams { - choices: Record[]; - error?: string; - filter?: any; - isLoading: boolean; - onChange: (value: any) => void; - pagination: Pagination; - setFilter: (filter: any) => void; - setPagination: (pagination: Pagination) => void; - setSort: (sort: Sort) => void; - sort: Sort; - warning?: string; -} - interface Props { allowEmpty?: boolean; basePath: string; - children: (params: ChildrenFuncParams) => ReactNode; - filter?: object; - filterToQuery: (filter: {}) => any; + children: (params: ReferenceInputValue) => ReactNode; + filter?: any; + filterToQuery?: (filter: string) => any; input?: WrappedFieldInputProps; - perPage: number; + perPage?: number; record?: Record; reference: string; - referenceSource: typeof defaultReferenceSource; + referenceSource?: typeof defaultReferenceSource; resource: string; sort?: Sort; source: string; -} - -interface EnhancedProps { - crudGetMatchingAccumulate: Dispatch; - crudGetManyAccumulate: Dispatch; - matchingReferences?: Record[] | MatchingReferencesError; onChange: () => void; - referenceRecord?: Record; - translate: Translate; -} - -interface State { - pagination: Pagination; - sort: Sort; - filter: any; } /** @@ -147,183 +109,29 @@ interface State { * *
*/ -export class UnconnectedReferenceInputController extends Component< - Props & EnhancedProps, - State -> { - public static defaultProps = { - allowEmpty: false, - filter: {}, - filterToQuery: searchText => ({ q: searchText }), - matchingReferences: null, - perPage: 25, - sort: { field: 'id', order: 'DESC' }, - referenceRecord: null, - referenceSource: defaultReferenceSource, // used in tests - }; - - public state: State; - private debouncedSetFilter; - - constructor(props) { - super(props); - const { perPage, sort, filter } = props; - this.state = { pagination: { page: 1, perPage }, sort, filter }; - this.debouncedSetFilter = debounce(this.setFilter.bind(this), 500); - } - - componentDidMount() { - this.fetchReferenceAndOptions(this.props); - } - - componentWillReceiveProps(nextProps: Props & EnhancedProps) { - if ( - (this.props.record || { id: undefined }).id !== - (nextProps.record || { id: undefined }).id - ) { - this.fetchReferenceAndOptions(nextProps); - } else if (this.props.input.value !== nextProps.input.value) { - this.fetchReference(nextProps); - } else if ( - !isEqual(nextProps.filter, this.props.filter) || - !isEqual(nextProps.sort, this.props.sort) || - nextProps.perPage !== this.props.perPage - ) { - this.setState( - state => ({ - filter: nextProps.filter, - pagination: { - ...state.pagination, - perPage: nextProps.perPage, - }, - sort: nextProps.sort, - }), - this.fetchOptions - ); - } - } - - setFilter = (filter: any) => { - if (filter !== this.state.filter) { - this.setState( - { filter: this.props.filterToQuery(filter) }, - this.fetchOptions - ); - } - }; - - setPagination = (pagination: Pagination) => { - if (pagination !== this.state.pagination) { - this.setState({ pagination }, this.fetchOptions); - } - }; - - setSort = (sort: Sort) => { - if (sort !== this.state.sort) { - this.setState({ sort }, this.fetchOptions); - } - }; - - fetchReference = (props = this.props) => { - const { crudGetManyAccumulate, input, reference } = props; - const id = input.value; - if (id) { - crudGetManyAccumulate(reference, [id]); - } - }; - - fetchOptions = (props = this.props) => { - const { - crudGetMatchingAccumulate, - filter: filterFromProps, +export const ReferenceInputController: FunctionComponent = ({ + input, + children, + perPage = 25, + filter: permanentFilter = {}, + reference, + filterToQuery, + referenceSource = defaultReferenceSource, + resource, + source, +}) => { + return children( + useReferenceInput({ + input, + perPage, + permanentFilter: filter, reference, + filterToQuery, referenceSource, resource, source, - } = props; - const { pagination, sort, filter } = this.state; - - crudGetMatchingAccumulate( - reference, - referenceSource(resource, source), - pagination, - sort, - { - ...filterFromProps, - ...filter, - } - ); - }; - - fetchReferenceAndOptions(props) { - this.fetchReference(props); - this.fetchOptions(props); - } - - render() { - const { - input, - referenceRecord, - matchingReferences, - onChange, - children, - translate, - } = this.props; - const { pagination, sort, filter } = this.state; - - const dataStatus = getDataStatus({ - input, - matchingReferences, - referenceRecord, - translate, - }); - - return children({ - choices: dataStatus.choices, - error: dataStatus.error, - isLoading: dataStatus.waiting, - onChange, - filter, - setFilter: this.debouncedSetFilter, - pagination, - setPagination: this.setPagination, - sort, - setSort: this.setSort, - warning: dataStatus.warning, - }); - } -} - -const makeMapStateToProps = () => - createSelector( - [ - getReferenceResource, - getPossibleReferenceValues, - (_, props) => props.input.value, - ], - (referenceState, possibleValues, inputId) => ({ - matchingReferences: getPossibleReferences( - referenceState, - possibleValues, - [inputId] - ), - referenceRecord: referenceState && referenceState.data[inputId], }) - ); - -const ReferenceInputController = compose( - withTranslate, - connect( - makeMapStateToProps(), - { - crudGetManyAccumulate: crudGetManyAccumulateAction, - crudGetMatchingAccumulate: crudGetMatchingAccumulateAction, - } - ) -)(UnconnectedReferenceInputController); - -ReferenceInputController.defaultProps = { - referenceSource: defaultReferenceSource, // used in makeMapStateToProps + ) as ReactElement; }; export default ReferenceInputController as ComponentType; diff --git a/packages/ra-core/src/controller/input/useGetMatchingReferences.spec.tsx b/packages/ra-core/src/controller/input/useGetMatchingReferences.spec.tsx new file mode 100644 index 00000000000..03a72e3aa1e --- /dev/null +++ b/packages/ra-core/src/controller/input/useGetMatchingReferences.spec.tsx @@ -0,0 +1,603 @@ +import renderHook from '../../util/renderHook'; +import useMatchingReferences from './useGetMatchingReferences'; +import { cleanup } from 'react-testing-library'; + +describe('useMatchingReferences', () => { + const defaultProps = { + reference: 'posts', + resource: 'comments', + source: 'post_id', + filter: { q: '' }, + pagination: { + perPage: 25, + page: 1, + }, + sort: { field: 'id', order: 'DESC' }, + referenceSource: undefined, + }; + + afterEach(cleanup); + + it('should fetch matchingReferences only on mount', () => { + const { dispatch, rerender } = renderHook(() => { + return useMatchingReferences(defaultProps); + }); + + expect(dispatch).toBeCalledTimes(1); + expect(dispatch.mock.calls[0][0].type).toBe( + 'RA/CRUD_GET_MATCHING_ACCUMULATE' + ); + expect( + JSON.parse(dispatch.mock.calls[0][0].meta.accumulateKey) + ).toEqual({ + resource: 'posts', + relatedTo: 'comments@post_id', + pagination: { + perPage: 25, + page: 1, + }, + sort: { + field: 'id', + order: 'DESC', + }, + filter: { + q: '', + }, + }); + }); + + it('should not fetch matchingReferences on subsequent rerender', () => { + const { dispatch, rerender } = renderHook(() => { + return useMatchingReferences(defaultProps); + }); + + rerender(() => { + return useMatchingReferences({ + reference: 'posts', + resource: 'comments', + source: 'post_id', + filter: { q: '' }, + pagination: { + perPage: 25, + page: 1, + }, + sort: { field: 'id', order: 'DESC' }, + referenceSource: undefined, + }); // deep but not shallow equal + }); + expect(dispatch).toBeCalledTimes(1); + }); + + it('should fetch matchingReferences when the filter prop changes', () => { + const { dispatch, rerender } = renderHook(() => { + return useMatchingReferences(defaultProps); + }); + + expect(dispatch).toBeCalledTimes(1); + expect(dispatch.mock.calls[0][0].type).toBe( + 'RA/CRUD_GET_MATCHING_ACCUMULATE' + ); + expect( + JSON.parse(dispatch.mock.calls[0][0].meta.accumulateKey) + ).toEqual({ + resource: 'posts', + relatedTo: 'comments@post_id', + pagination: { + perPage: 25, + page: 1, + }, + sort: { + field: 'id', + order: 'DESC', + }, + filter: { + q: '', + }, + }); + + rerender(() => { + return useMatchingReferences({ + ...defaultProps, + filter: { q: 'typing' }, + }); + }); + expect(dispatch).toBeCalledTimes(2); + expect(dispatch.mock.calls[1][0].type).toBe( + 'RA/CRUD_GET_MATCHING_ACCUMULATE' + ); + expect( + JSON.parse(dispatch.mock.calls[1][0].meta.accumulateKey) + ).toEqual({ + resource: 'posts', + relatedTo: 'comments@post_id', + pagination: { + perPage: 25, + page: 1, + }, + sort: { + field: 'id', + order: 'DESC', + }, + filter: { + q: 'typing', + }, + }); + }); + + it('should refetch matchingReferences when the reference prop changes', () => { + const { dispatch, rerender } = renderHook(() => { + return useMatchingReferences(defaultProps); + }); + + expect(dispatch).toBeCalledTimes(1); + expect(dispatch.mock.calls[0][0].type).toBe( + 'RA/CRUD_GET_MATCHING_ACCUMULATE' + ); + expect( + JSON.parse(dispatch.mock.calls[0][0].meta.accumulateKey) + ).toEqual({ + resource: 'posts', + relatedTo: 'comments@post_id', + pagination: { + perPage: 25, + page: 1, + }, + sort: { + field: 'id', + order: 'DESC', + }, + filter: { + q: '', + }, + }); + + rerender(() => { + return useMatchingReferences({ + ...defaultProps, + reference: 'blog_posts', + }); + }); + expect(dispatch).toBeCalledTimes(2); + expect(dispatch.mock.calls[1][0].type).toBe( + 'RA/CRUD_GET_MATCHING_ACCUMULATE' + ); + expect( + JSON.parse(dispatch.mock.calls[1][0].meta.accumulateKey) + ).toEqual({ + resource: 'blog_posts', + relatedTo: 'comments@post_id', + pagination: { + perPage: 25, + page: 1, + }, + sort: { + field: 'id', + order: 'DESC', + }, + filter: { + q: '', + }, + }); + }); + + it('should refetch matchingReferences when the resource prop changes', () => { + const { dispatch, rerender } = renderHook(() => { + return useMatchingReferences(defaultProps); + }); + + expect(dispatch).toBeCalledTimes(1); + expect(dispatch.mock.calls[0][0].type).toBe( + 'RA/CRUD_GET_MATCHING_ACCUMULATE' + ); + expect( + JSON.parse(dispatch.mock.calls[0][0].meta.accumulateKey) + ).toEqual({ + resource: 'posts', + relatedTo: 'comments@post_id', + pagination: { + perPage: 25, + page: 1, + }, + sort: { + field: 'id', + order: 'DESC', + }, + filter: { + q: '', + }, + }); + + rerender(() => { + return useMatchingReferences({ ...defaultProps, resource: 'note' }); + }); + expect(dispatch).toBeCalledTimes(2); + expect(dispatch.mock.calls[1][0].type).toBe( + 'RA/CRUD_GET_MATCHING_ACCUMULATE' + ); + expect( + JSON.parse(dispatch.mock.calls[1][0].meta.accumulateKey) + ).toEqual({ + resource: 'posts', + relatedTo: 'note@post_id', + pagination: { + perPage: 25, + page: 1, + }, + sort: { + field: 'id', + order: 'DESC', + }, + filter: { + q: '', + }, + }); + }); + + it('should refetch matchingReferences when the source prop changes', () => { + const { dispatch, rerender } = renderHook(() => { + return useMatchingReferences(defaultProps); + }); + + expect(dispatch).toBeCalledTimes(1); + expect(dispatch.mock.calls[0][0].type).toBe( + 'RA/CRUD_GET_MATCHING_ACCUMULATE' + ); + expect( + JSON.parse(dispatch.mock.calls[0][0].meta.accumulateKey) + ).toEqual({ + resource: 'posts', + relatedTo: 'comments@post_id', + pagination: { + perPage: 25, + page: 1, + }, + sort: { + field: 'id', + order: 'DESC', + }, + filter: { + q: '', + }, + }); + + rerender(() => { + return useMatchingReferences({ + ...defaultProps, + source: 'blog_posts_id', + }); + }); + expect(dispatch).toBeCalledTimes(2); + expect(dispatch.mock.calls[1][0].type).toBe( + 'RA/CRUD_GET_MATCHING_ACCUMULATE' + ); + expect( + JSON.parse(dispatch.mock.calls[1][0].meta.accumulateKey) + ).toEqual({ + resource: 'posts', + relatedTo: 'comments@blog_posts_id', + pagination: { + perPage: 25, + page: 1, + }, + sort: { + field: 'id', + order: 'DESC', + }, + filter: { + q: '', + }, + }); + }); + + it('should refetch matchingReferences when the pagination.page prop changes', () => { + const { dispatch, rerender } = renderHook(() => { + return useMatchingReferences(defaultProps); + }); + + expect(dispatch).toBeCalledTimes(1); + expect(dispatch.mock.calls[0][0].type).toBe( + 'RA/CRUD_GET_MATCHING_ACCUMULATE' + ); + expect( + JSON.parse(dispatch.mock.calls[0][0].meta.accumulateKey) + ).toEqual({ + resource: 'posts', + relatedTo: 'comments@post_id', + pagination: { + perPage: 25, + page: 1, + }, + sort: { + field: 'id', + order: 'DESC', + }, + filter: { + q: '', + }, + }); + + rerender(() => { + return useMatchingReferences({ + ...defaultProps, + pagination: { + perPage: 25, + page: 2, + }, + }); + }); + expect(dispatch).toBeCalledTimes(2); + expect(dispatch.mock.calls[1][0].type).toBe( + 'RA/CRUD_GET_MATCHING_ACCUMULATE' + ); + expect( + JSON.parse(dispatch.mock.calls[1][0].meta.accumulateKey) + ).toEqual({ + resource: 'posts', + relatedTo: 'comments@post_id', + pagination: { + perPage: 25, + page: 2, + }, + sort: { + field: 'id', + order: 'DESC', + }, + filter: { + q: '', + }, + }); + }); + + it('should refetch matchingReferences when the pagination.pagination prop changes', () => { + const { dispatch, rerender } = renderHook(() => { + return useMatchingReferences(defaultProps); + }); + + expect(dispatch).toBeCalledTimes(1); + expect(dispatch.mock.calls[0][0].type).toBe( + 'RA/CRUD_GET_MATCHING_ACCUMULATE' + ); + expect( + JSON.parse(dispatch.mock.calls[0][0].meta.accumulateKey) + ).toEqual({ + resource: 'posts', + relatedTo: 'comments@post_id', + pagination: { + perPage: 25, + page: 1, + }, + sort: { + field: 'id', + order: 'DESC', + }, + filter: { + q: '', + }, + }); + + rerender(() => { + return useMatchingReferences({ + ...defaultProps, + pagination: { + perPage: 50, + page: 1, + }, + }); + }); + expect(dispatch).toBeCalledTimes(2); + expect(dispatch.mock.calls[1][0].type).toBe( + 'RA/CRUD_GET_MATCHING_ACCUMULATE' + ); + expect( + JSON.parse(dispatch.mock.calls[1][0].meta.accumulateKey) + ).toEqual({ + resource: 'posts', + relatedTo: 'comments@post_id', + pagination: { + perPage: 50, + page: 1, + }, + sort: { + field: 'id', + order: 'DESC', + }, + filter: { + q: '', + }, + }); + }); + + it('should refetch matchingReferences when the sort.field prop changes', () => { + const { dispatch, rerender } = renderHook(() => { + return useMatchingReferences(defaultProps); + }); + + expect(dispatch).toBeCalledTimes(1); + expect(dispatch.mock.calls[0][0].type).toBe( + 'RA/CRUD_GET_MATCHING_ACCUMULATE' + ); + expect( + JSON.parse(dispatch.mock.calls[0][0].meta.accumulateKey) + ).toEqual({ + resource: 'posts', + relatedTo: 'comments@post_id', + pagination: { + perPage: 25, + page: 1, + }, + sort: { + field: 'id', + order: 'DESC', + }, + filter: { + q: '', + }, + }); + + rerender(() => { + return useMatchingReferences({ + ...defaultProps, + sort: { + field: 'uid', + order: 'DESC', + }, + }); + }); + expect(dispatch).toBeCalledTimes(2); + expect(dispatch.mock.calls[1][0].type).toBe( + 'RA/CRUD_GET_MATCHING_ACCUMULATE' + ); + expect( + JSON.parse(dispatch.mock.calls[1][0].meta.accumulateKey) + ).toEqual({ + resource: 'posts', + relatedTo: 'comments@post_id', + pagination: { + perPage: 25, + page: 1, + }, + sort: { + field: 'uid', + order: 'DESC', + }, + filter: { + q: '', + }, + }); + }); + + it('should refetch matchingReferences when the sort.order prop changes', () => { + const { dispatch, rerender } = renderHook(() => { + return useMatchingReferences(defaultProps); + }); + + expect(dispatch).toBeCalledTimes(1); + expect(dispatch.mock.calls[0][0].type).toBe( + 'RA/CRUD_GET_MATCHING_ACCUMULATE' + ); + expect( + JSON.parse(dispatch.mock.calls[0][0].meta.accumulateKey) + ).toEqual({ + resource: 'posts', + relatedTo: 'comments@post_id', + pagination: { + perPage: 25, + page: 1, + }, + sort: { + field: 'id', + order: 'DESC', + }, + filter: { + q: '', + }, + }); + + rerender(() => { + return useMatchingReferences({ + ...defaultProps, + sort: { + field: 'id', + order: 'ASC', + }, + }); + }); + expect(dispatch).toBeCalledTimes(2); + expect(dispatch.mock.calls[1][0].type).toBe( + 'RA/CRUD_GET_MATCHING_ACCUMULATE' + ); + expect( + JSON.parse(dispatch.mock.calls[1][0].meta.accumulateKey) + ).toEqual({ + resource: 'posts', + relatedTo: 'comments@post_id', + pagination: { + perPage: 25, + page: 1, + }, + sort: { + field: 'id', + order: 'ASC', + }, + filter: { + q: '', + }, + }); + }); + + it('should pass matching references from redux state to its children', () => { + const { hookValue } = renderHook( + () => { + return useMatchingReferences(defaultProps); + }, + true, + { + admin: { + resources: { + posts: { data: { 1: { id: 1 }, 2: { id: 2 } } }, + }, + references: { + possibleValues: { 'comments@post_id': [2, 1] }, + }, + }, + } + ); + + expect(hookValue.matchingReferences).toEqual([{ id: 2 }, { id: 1 }]); + + expect(hookValue.loading).toBe(false); + expect(hookValue.error).toBe(null); + }); + + it('should pass an error if an error is in redux state', () => { + const { hookValue } = renderHook( + () => { + return useMatchingReferences(defaultProps); + }, + true, + { + admin: { + resources: { + posts: { data: { 1: { id: 1 }, 2: { id: 2 } } }, + }, + references: { + possibleValues: { + 'comments@post_id': { + error: 'Something bad happened', + }, + }, + }, + }, + } + ); + + expect(hookValue.matchingReferences).toBe(null); + + expect(hookValue.loading).toBe(false); + expect(hookValue.error).toBe('Something bad happened'); + }); + + it('should pass loading true if no matching reference yet', () => { + const { hookValue } = renderHook( + () => { + return useMatchingReferences(defaultProps); + }, + true, + { + admin: { + resources: { + posts: { data: {} }, + }, + references: { + possibleValues: {}, + }, + }, + } + ); + + expect(hookValue.matchingReferences).toBe(null); + + expect(hookValue.loading).toBe(true); + expect(hookValue.error).toBe(null); + }); +}); diff --git a/packages/ra-core/src/controller/input/useGetMatchingReferences.ts b/packages/ra-core/src/controller/input/useGetMatchingReferences.ts new file mode 100644 index 00000000000..a119bbabde9 --- /dev/null +++ b/packages/ra-core/src/controller/input/useGetMatchingReferences.ts @@ -0,0 +1,127 @@ +import { useMemo, useCallback } from 'react'; +// @ts-ignore +import { useSelector, useDispatch } from 'react-redux'; + +import { Filter } from '../useFilterState'; +import { crudGetMatchingAccumulate } from '../../actions/accumulateActions'; +import { + getPossibleReferences, + getPossibleReferenceValues, + getReferenceResource, +} from '../../reducer'; +import { Pagination, Sort, Record } from '../../types'; +import { useDeepCompareEffect } from '../../util/hooks'; + +interface UseMatchingReferencesOption { + reference: string; + referenceSource: (resource: string, source: string) => string; + resource: string; + source: string; + filter: Filter; + pagination: Pagination; + sort: Sort; + id: string; +} + +interface UseMatchingReferencesProps { + error?: string; + matchingReferences?: Record[]; + loading: boolean; +} + +const defaultReferenceSource = (resource: string, source: string) => + `${resource}@${source}`; + +export default ({ + reference, + referenceSource = defaultReferenceSource, + resource, + source, + filter, + pagination, + sort, + id, +}: UseMatchingReferencesOption): UseMatchingReferencesProps => { + const dispatch = useDispatch(); + + useDeepCompareEffect(() => { + dispatch( + crudGetMatchingAccumulate( + reference, + referenceSource(resource, source), + pagination, + sort, + filter + ) + ); + }, [ + dispatch, + filter, + reference, + referenceSource, + resource, + source, + pagination, + sort, + ]); + + const matchingReferences = useGetMatchingReferenceSelector({ + referenceSource, + filter, + reference, + resource, + source, + id, + }); + + if (!matchingReferences) { + return { + loading: true, + error: null, + matchingReferences: null, + }; + } + + if (matchingReferences.error) { + return { + loading: false, + matchingReferences: null, + error: matchingReferences.error, + }; + } + + return { + loading: false, + error: null, + matchingReferences, + }; +}; + +const useGetMatchingReferenceSelector = ({ + referenceSource, + filter, + reference, + resource, + source, + id, +}) => { + const getMatchingReferences = useCallback( + state => { + const referenceResource = getReferenceResource(state, { + reference, + }); + const possibleValues = getPossibleReferenceValues(state, { + referenceSource, + resource, + source, + }); + + return getPossibleReferences(referenceResource, possibleValues, [ + id, + ]); + }, + [referenceSource, reference, resource, source, id] + ); + + return useSelector(getMatchingReferences); +}; diff --git a/packages/ra-core/src/controller/input/useReferenceInput.ts b/packages/ra-core/src/controller/input/useReferenceInput.ts new file mode 100644 index 00000000000..7071737d9c5 --- /dev/null +++ b/packages/ra-core/src/controller/input/useReferenceInput.ts @@ -0,0 +1,135 @@ +import { WrappedFieldInputProps } from 'redux-form'; + +import { getStatusForInput as getDataStatus } from './referenceDataStatus'; +import useTranslate from '../../i18n/useTranslate'; +import { Sort, Record, Pagination } from '../../types'; +import useReference from '../useReference'; +import useGetMatchingReferences from './useGetMatchingReferences'; +import usePaginationState from '../usePaginationState'; +import { useSortState } from '..'; +import useFilterState from '../useFilterState'; + +const defaultReferenceSource = (resource: string, source: string) => + `${resource}@${source}`; + +export interface ReferenceInputValue { + choices: Record[]; + error?: string; + loading: boolean; + pagination: Pagination; + setFilter: (filter: string) => void; + filter: any; + setPagination: (pagination: Pagination) => void; + setSort: (sort: Sort) => void; + sort: Sort; + warning?: string; +} + +interface Option { + allowEmpty?: boolean; + permanentFilter?: any; + filterToQuery?: (filter: string) => any; + input?: WrappedFieldInputProps; + perPage?: number; + record?: Record; + reference: string; + referenceSource?: typeof defaultReferenceSource; + resource: string; + sort?: Sort; + source: string; +} + +/** + * A hook for choosing a reference record. Useful for foreign keys. + * + * This hook fetches the possible values in the reference resource + * (using the `CRUD_GET_MATCHING` REST method), it returns the possible choices + * as the `choices` attribute. + * + * @example + * const { + * choices, // the available reference resource + * } = useReferenceInput({ + * input, // the input props + * resource: 'comments', + * reference: 'posts', + * source: 'post_id', + * }); + * + * The hook also allow to filter results. It returns a `setFilter` + * function. It uses the value to create a filter + * for the query - by default { q: [searchText] }. You can customize the mapping + * searchText => searchQuery by setting a custom `filterToQuery` function option + * You can also add a permanentFilter to further filter the result: + * + * @example + * const { + * choices, // the available reference resource + * setFilter, + * } = useReferenceInput({ + * input, // the input props + * resource: 'comments', + * reference: 'posts', + * source: 'post_id', + * permanentFilter: { + * author: 'john' + * }, + * filterToQuery: searchText => ({ title: searchText }) + * }); + */ +export default ({ + input, + perPage = 25, + permanentFilter = {}, + reference, + filterToQuery, + referenceSource = defaultReferenceSource, + resource, + source, +}: Option): ReferenceInputValue => { + const translate = useTranslate(); + + const { pagination, setPagination } = usePaginationState({ perPage }); + const { sort, setSort } = useSortState(); + const { filter, setFilter } = useFilterState({ + permanentFilter, + filterToQuery, + }); + + const { matchingReferences } = useGetMatchingReferences({ + reference, + referenceSource, + filter, + pagination, + sort, + resource, + source, + id: input.value, + }); + + const { referenceRecord } = useReference({ + id: input.value, + reference, + allowEmpty: true, + }); + + const dataStatus = getDataStatus({ + input, + matchingReferences, + referenceRecord, + translate, + }); + + return { + choices: dataStatus.choices, + error: dataStatus.error, + loading: dataStatus.waiting, + filter, + setFilter, + pagination, + setPagination, + sort, + setSort, + warning: dataStatus.warning, + }; +}; diff --git a/packages/ra-core/src/controller/useFilterState.spec.ts b/packages/ra-core/src/controller/useFilterState.spec.ts new file mode 100644 index 00000000000..f82056a611d --- /dev/null +++ b/packages/ra-core/src/controller/useFilterState.spec.ts @@ -0,0 +1,73 @@ +import renderHook from '../util/renderHook'; +import useFilterState from './useFilterState'; +import { act } from 'react-testing-library'; + +describe('useFilterState', () => { + it('should initialize filterState with default filter', () => { + const { hookValue } = renderHook(() => useFilterState({})); + + expect(hookValue.filter).toEqual({ q: '' }); + }); + + it('should initialize filterState with permanent filter', () => { + const { hookValue } = renderHook(() => + useFilterState({ permanentFilter: { type: 'thisOne' } }) + ); + + expect(hookValue.filter).toEqual({ q: '', type: 'thisOne' }); + }); + + it('should initialize using filterToQuery if provided', () => { + const { hookValue } = renderHook(() => + useFilterState({ filterToQuery: v => ({ search: v }) }) + ); + + expect(hookValue.filter).toEqual({ search: '' }); + }); + + it('should return a setFilter function to update the filter value after a given debounceTime', async () => { + const { hookValue, childrenMock } = renderHook(() => + useFilterState({ debounceTime: 10 }) + ); + + expect(hookValue.filter).toEqual({ q: '' }); + + act(() => hookValue.setFilter('needle in a haystack')); + + expect(childrenMock).toBeCalledTimes(1); + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(childrenMock).toBeCalledTimes(2); + + expect(childrenMock.mock.calls[1][0].filter).toEqual({ + q: 'needle in a haystack', + }); + }); + + it('should provide setFilter to update filter value after given debounceTime preserving permanentFilter and filterToQuery', async () => { + const { hookValue, childrenMock } = renderHook(() => + useFilterState({ + debounceTime: 10, + permanentFilter: { type: 'thisOne' }, + filterToQuery: v => ({ search: v }), + }) + ); + + act(() => hookValue.setFilter('needle in a haystack')); + + expect(childrenMock).toBeCalledTimes(1); + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(childrenMock).toBeCalledTimes(2); + + expect(childrenMock.mock.calls[0][0].filter).toEqual({ + type: 'thisOne', + search: '', + }); + + expect(childrenMock.mock.calls[1][0].filter).toEqual({ + type: 'thisOne', + search: 'needle in a haystack', + }); + }); +}); diff --git a/packages/ra-core/src/controller/useFilterState.ts b/packages/ra-core/src/controller/useFilterState.ts new file mode 100644 index 00000000000..e07c8ac9b0e --- /dev/null +++ b/packages/ra-core/src/controller/useFilterState.ts @@ -0,0 +1,84 @@ +import { useState } from 'react'; +import debounce from 'lodash/debounce'; + +export interface Filter { + [k: string]: any; +} + +interface UseFilterStateOptions { + filterToQuery?: (v: string) => Filter; + permanentFilter?: Filter; + debounceTime?: number; +} + +interface UseFilterStateProps { + filter: Filter; + setFilter: (v: string) => void; +} + +/** + * @name setFilter + * @function + * @param {string} the value + */ + +/** + * @typedef FilterProps + * @type {Object} + * @property {Object} filter: The filter object. + * @property {setFilter} setFilter: Update the filter with the given string + */ + +/** + * Hooks to provide filter state and setFilter which update the query part of the filter + * + * @example + * + * const { filter, setFilter } = useFilter({ + * filterToQuery: v => ({ query: v }), + * permanentFilter: { foo: 'bar' }, + * debounceTime: 500, + * }); + * // filter inital value: + * { + * query: '', + * foo: 'bar' + * } + * // after updating filter + * setFilter('needle'); + * { + * query: 'needle', + * foo: 'bar' + * } + * + * @param {Object} option + * @param {Function} option.filterToQuery function to convert the filter string to a filter object + * @param {Object} option.permanentFilter permanent filter to be merged with the filter string default to {} + * @param {number} option.debounceTime Time between filter update allow to debounce the search + * + * @returns {FilterProps} The filter props + */ +export default ({ + filterToQuery = v => ({ q: v }), + permanentFilter = {}, + debounceTime = 500, +}: UseFilterStateOptions): UseFilterStateProps => { + const [filter, setFilterValue] = useState({ + ...permanentFilter, + ...filterToQuery(''), + }); + + const setFilter = debounce( + value => + setFilterValue({ + ...permanentFilter, + ...filterToQuery(value), + }), + debounceTime + ); + + return { + filter, + setFilter, + }; +}; diff --git a/packages/ra-core/src/controller/usePaginationState.spec.ts b/packages/ra-core/src/controller/usePaginationState.spec.ts new file mode 100644 index 00000000000..1571a3708c4 --- /dev/null +++ b/packages/ra-core/src/controller/usePaginationState.spec.ts @@ -0,0 +1,80 @@ +import renderHook from '../util/renderHook'; +import usePaginationState from './usePaginationState'; +import { act } from 'react-testing-library'; + +describe('usePaginationState', () => { + it('should initialize pagination state with default', () => { + const { hookValue } = renderHook(() => usePaginationState()); + expect(hookValue.pagination).toEqual({ page: 1, perPage: 25 }); + }); + + it('should take given page and perPage props to initalize', () => { + const { hookValue } = renderHook(() => + usePaginationState({ perPage: 50, page: 10 }) + ); + expect(hookValue.pagination).toEqual({ page: 10, perPage: 50 }); + }); + + it('should update perPage state when the perPage props update', () => { + const { hookValue, childrenMock, rerender } = renderHook(() => + usePaginationState({ perPage: 50, page: 10 }) + ); + expect(hookValue.pagination).toEqual({ page: 10, perPage: 50 }); + rerender(() => usePaginationState({ perPage: 100, page: 10 })); + + expect(childrenMock).toBeCalledTimes(3); + + expect(childrenMock.mock.calls[2][0].pagination).toEqual({ + page: 10, + perPage: 100, + }); + }); + + it('should provide a setPagination function to update the pagination state (page + perPage)', () => { + const { hookValue, childrenMock } = renderHook(() => + usePaginationState() + ); + expect(hookValue.pagination).toEqual({ page: 1, perPage: 25 }); + + act(() => hookValue.setPagination({ perPage: 100, page: 20 })); + + expect(childrenMock).toBeCalledTimes(2); + + expect(childrenMock.mock.calls[1][0].pagination).toEqual({ + page: 20, + perPage: 100, + }); + }); + + it('should provide setPage function to update the page state', () => { + const { hookValue, childrenMock } = renderHook(() => + usePaginationState() + ); + expect(hookValue.pagination).toEqual({ page: 1, perPage: 25 }); + + act(() => hookValue.setPage(20)); + + expect(childrenMock).toBeCalledTimes(2); + + expect(childrenMock.mock.calls[1][0].pagination).toEqual({ + page: 20, + perPage: 25, + }); + }); + + it('should provide a setPerPage function to update the perPage state', () => { + const { hookValue, childrenMock } = renderHook(() => + usePaginationState() + ); + expect(hookValue.pagination).toEqual({ page: 1, perPage: 25 }); + + act(() => hookValue.setPerPage(100)); + + expect(childrenMock).toBeCalledTimes(2); + + expect(childrenMock.mock.calls[1][0].pagination).toEqual({ + page: 1, + perPage: 100, + }); + }); +}); diff --git a/packages/ra-core/src/controller/usePaginationState.ts b/packages/ra-core/src/controller/usePaginationState.ts index 588c6057baa..da6e7c0f646 100644 --- a/packages/ra-core/src/controller/usePaginationState.ts +++ b/packages/ra-core/src/controller/usePaginationState.ts @@ -1,14 +1,31 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useReducer, useCallback, useRef } from 'react'; +import { Pagination } from '../types'; interface PaginationProps { page: number; perPage: number; + pagination: Pagination; setPage: (page: number) => void; setPerPage: (perPage: number) => void; + setPagination: (pagination: Pagination) => void; } +const paginationReducer = ( + prevState: Pagination, + nextState: Partial +): Pagination => { + return { + ...prevState, + ...nextState, + }; +}; + +const defaultPagination = { + page: 1, + perPage: 25, +}; + /** - * set the sort to the given field, swap the order if the field is the same * @name setNumber * @function * @param {number} state the state value @@ -33,15 +50,32 @@ interface PaginationProps { * @param {numper} initialPerPage the initial value per page * @returns {PaginationProps} The pagination props */ -export default (initialPerPage: number = 25): PaginationProps => { - const [page, setPage] = useState(1); - const [perPage, setPerPage] = useState(initialPerPage); - useEffect(() => setPerPage(initialPerPage), [initialPerPage]); +export default ( + initialPagination: { perPage?: number; page?: number } = {} +): PaginationProps => { + const [pagination, setPagination] = useReducer(paginationReducer, { + ...defaultPagination, + ...initialPagination, + }); + const isFirstRender = useRef(true); + + const setPerPage = useCallback(perPage => setPagination({ perPage }), []); + const setPage = useCallback(page => setPagination({ page }), []); + + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + setPerPage(initialPagination.perPage || 25); + }, [initialPagination.perPage, setPerPage]); return { - page, - perPage, + page: pagination.page, + perPage: pagination.perPage, + pagination, setPage, setPerPage, + setPagination, }; }; diff --git a/packages/ra-core/src/controller/useReference.spec.ts b/packages/ra-core/src/controller/useReference.spec.ts new file mode 100644 index 00000000000..1cb90334353 --- /dev/null +++ b/packages/ra-core/src/controller/useReference.spec.ts @@ -0,0 +1,169 @@ +import renderHook from '../util/renderHook'; +import useReference from './useReference'; +import { cleanup } from 'react-testing-library'; + +describe('useReference', () => { + const defaultProps = { + id: '1', + reference: 'posts', + allowEmpty: false, + }; + + afterEach(cleanup); + + it('should fetch reference on mount', () => { + const { dispatch, rerender } = renderHook(() => { + return useReference(defaultProps); + }); + + expect(dispatch).toBeCalledTimes(1); + expect(dispatch.mock.calls[0][0].type).toBe( + 'RA/CRUD_GET_MANY_ACCUMULATE' + ); + + rerender(() => { + return useReference(defaultProps); + }); + expect(dispatch).toBeCalledTimes(1); + }); + + it('should refetch reference when id changes', () => { + const { dispatch, rerender } = renderHook(() => { + return useReference(defaultProps); + }); + + expect(dispatch).toBeCalledTimes(1); + expect(dispatch.mock.calls[0][0].type).toBe( + 'RA/CRUD_GET_MANY_ACCUMULATE' + ); + expect(dispatch.mock.calls[0][0].payload).toEqual({ + ids: ['1'], + resource: 'posts', + }); + rerender(() => { + return useReference({ ...defaultProps, id: '2' }); + }); + + expect(dispatch).toBeCalledTimes(2); + expect(dispatch.mock.calls[1][0].type).toBe( + 'RA/CRUD_GET_MANY_ACCUMULATE' + ); + expect(dispatch.mock.calls[1][0].payload).toEqual({ + ids: ['2'], + resource: 'posts', + }); + }); + + it('should refetch reference when reference prop changes', () => { + const { dispatch, rerender } = renderHook(() => { + return useReference(defaultProps); + }); + + expect(dispatch).toBeCalledTimes(1); + expect(dispatch.mock.calls[0][0].type).toBe( + 'RA/CRUD_GET_MANY_ACCUMULATE' + ); + expect(dispatch.mock.calls[0][0].payload).toEqual({ + ids: ['1'], + resource: 'posts', + }); + rerender(() => { + return useReference({ ...defaultProps, reference: 'comments' }); + }); + + expect(dispatch).toBeCalledTimes(2); + expect(dispatch.mock.calls[1][0].type).toBe( + 'RA/CRUD_GET_MANY_ACCUMULATE' + ); + expect(dispatch.mock.calls[1][0].payload).toEqual({ + ids: ['1'], + resource: 'comments', + }); + }); + + it('it should not refetch reference when allowEmpty change', () => { + const { dispatch, rerender } = renderHook(() => { + return useReference(defaultProps); + }); + + expect(dispatch).toBeCalledTimes(1); + expect(dispatch.mock.calls[0][0].type).toBe( + 'RA/CRUD_GET_MANY_ACCUMULATE' + ); + expect(dispatch.mock.calls[0][0].payload).toEqual({ + ids: ['1'], + resource: 'posts', + }); + rerender(() => { + return useReference({ ...defaultProps, allowEmpty: true }); + }); + + expect(dispatch).toBeCalledTimes(1); + }); + + it('should retrieve referenceRecord from redux state', () => { + const { hookValue } = renderHook( + () => { + return useReference(defaultProps); + }, + true, + { + admin: { + resources: { + posts: { data: { 1: { id: 1 }, 2: { id: 2 } } }, + }, + }, + } + ); + + expect(hookValue).toEqual({ + referenceRecord: { id: 1 }, + loading: false, + loaded: true, + }); + }); + + it('should set loading to true if no referenceRecord yet', () => { + const { hookValue } = renderHook( + () => { + return useReference(defaultProps); + }, + true, + { + admin: { + resources: { + posts: { data: {} }, + }, + }, + } + ); + + expect(hookValue).toEqual({ + referenceRecord: undefined, + loading: true, + loaded: false, + }); + }); + + it('should set loading to false even if no referenceRecord yet when allowEmpty is true', () => { + const { hookValue } = renderHook( + () => { + return useReference({ ...defaultProps, allowEmpty: true }); + }, + true, + { + admin: { + resources: { + posts: { data: {} }, + }, + }, + } + ); + + expect(hookValue).toEqual({ + referenceRecord: undefined, + loading: false, + loaded: true, + }); + }); +}); diff --git a/packages/ra-core/src/controller/useReference.ts b/packages/ra-core/src/controller/useReference.ts new file mode 100644 index 00000000000..fca03a7147b --- /dev/null +++ b/packages/ra-core/src/controller/useReference.ts @@ -0,0 +1,83 @@ +import { useEffect, useCallback } from 'react'; +// @ts-ignore +import { useDispatch, useSelector } from 'react-redux'; + +import { crudGetManyAccumulate } from '../actions'; +import { Record } from '../types'; +import { getReferenceResource } from '../reducer'; + +interface Option { + id: string; + reference: string; + allowEmpty?: boolean; +} + +export interface UseReferenceProps { + loading: boolean; + loaded: boolean; + referenceRecord: Record; +} + +/** + * @typedef ReferenceProps + * @type {Object} + * @property {boolean} loading: boolean indicating if the reference is loading + * @property {boolean} loaded: boolean indicating if the reference has loaded + * @property {Object} referenceRecord: the referenced record. + */ + +/** + * Fetch reference record, and return it when available + * + * The reference prop sould be the name of one of the components + * added as child. + * + * @example + * + * const { loading, loaded, referenceRecord } = useReference({ + * id: 7, + * reference: 'users', + * }); + * + * @param {Object} option + * @param {boolean} option.allowEmpty do we allow for no referenced record (default to false) + * @param {string} option.reference The linked resource name + * @param {string} option.id The id of the reference + * + * @returns {ReferenceProps} The reference record + */ +export const useReference = ({ + allowEmpty = false, + reference, + id, +}: Option): UseReferenceProps => { + const dispatch = useDispatch(); + useEffect(() => { + if (id !== null && typeof id !== 'undefined') { + dispatch(crudGetManyAccumulate(reference, [id])); + } + }, [dispatch, id, reference]); + + const referenceRecord = useReferenceSelector({ reference, id }); + + return { + loading: !referenceRecord && !allowEmpty, + loaded: !!referenceRecord || allowEmpty, + referenceRecord, + }; +}; + +const useReferenceSelector = ({ id, reference }) => { + const getReferenceRecord = useCallback( + state => { + const referenceState = getReferenceResource(state, { reference }); + + return referenceState && referenceState.data[id]; + }, + [id, reference] + ); + + return useSelector(getReferenceRecord); +}; + +export default useReference; diff --git a/packages/ra-core/src/controller/useSortState.spec.ts b/packages/ra-core/src/controller/useSortState.spec.ts new file mode 100644 index 00000000000..615e932912b --- /dev/null +++ b/packages/ra-core/src/controller/useSortState.spec.ts @@ -0,0 +1,71 @@ +import renderHook from '../util/renderHook'; +import useSortState, { defaultSort } from './useSortState'; +import { act } from 'react-testing-library'; + +describe('useSortState', () => { + it('should initialize sortState with default sort', () => { + const { hookValue } = renderHook(() => useSortState()); + + expect(hookValue.sort).toEqual(defaultSort); + }); + + it('should initialize sortState with given sort', () => { + const { hookValue } = renderHook(() => + useSortState({ + field: 'name', + order: 'ASC', + }) + ); + + expect(hookValue.sort).toEqual({ field: 'name', order: 'ASC' }); + }); + + it('should provide setSort method to change the whole sort', () => { + const { hookValue, childrenMock } = renderHook(() => + useSortState({ field: 'id', order: 'DESC' }) + ); + + expect(hookValue.sort).toEqual({ field: 'id', order: 'DESC' }); + + act(() => hookValue.setSort({ field: 'name', order: 'ASC' })); + expect(childrenMock.mock.calls[1][0].sort).toEqual({ + field: 'name', + order: 'ASC', + }); + }); + + describe('setSortField in return value', () => { + it('should just change the order if receiving the current field', () => { + const { hookValue, childrenMock } = renderHook(() => + useSortState({ field: 'id', order: 'DESC' }) + ); + + expect(hookValue.sort).toEqual({ field: 'id', order: 'DESC' }); + + act(() => hookValue.setSortField('id')); + expect(childrenMock.mock.calls[1][0].sort).toEqual({ + field: 'id', + order: 'ASC', + }); + }); + + it('should change the field and set the order to ASC if receiving another field', () => { + const { hookValue, childrenMock } = renderHook(() => + useSortState({ field: 'id', order: 'ASC' }) + ); + + expect(hookValue.sort).toEqual({ field: 'id', order: 'ASC' }); + + act(() => hookValue.setSortField('name')); + expect(childrenMock.mock.calls[1][0].sort).toEqual({ + field: 'name', + order: 'ASC', + }); + act(() => hookValue.setSortField('id')); + expect(childrenMock.mock.calls[2][0].sort).toEqual({ + field: 'id', + order: 'ASC', + }); + }); + }); +}); diff --git a/packages/ra-core/src/controller/useSortState.ts b/packages/ra-core/src/controller/useSortState.ts index c1b59363521..aca320d09fb 100644 --- a/packages/ra-core/src/controller/useSortState.ts +++ b/packages/ra-core/src/controller/useSortState.ts @@ -7,26 +7,68 @@ import { import { Sort } from '../types'; interface SortProps { - setSort: (field: string) => void; + setSortField: (field: string) => void; + setSortOrder: (order: string) => void; + setSort: (sort: Sort) => void; sort: Sort; } -const sortReducer = (state: Sort, field: string | Sort): Sort => { - if (typeof field !== 'string') { - return field; +interface Action { + type: 'SET_SORT' | 'SET_SORT_FIELD' | 'SET_SORT_ORDER'; + payload: { + sort?: Sort; + field?: string; + order?: string; + }; +} + +const sortReducer = (state: Sort, action: Action): Sort => { + switch (action.type) { + case 'SET_SORT': + return action.payload.sort; + case 'SET_SORT_FIELD': { + const { field } = action.payload; + const order = + state.field === field + ? state.order === SORT_ASC + ? SORT_DESC + : SORT_ASC + : SORT_ASC; + return { field, order }; + } + case 'SET_SORT_ORDER': { + const { order } = action.payload; + return { + ...state, + order, + }; + } + default: + return state; } - const order = - state.field === field && state.order === SORT_ASC - ? SORT_DESC - : SORT_ASC; - return { field, order }; }; +export const defaultSort = { field: 'id', order: 'DESC' }; + /** - * set the sort to the given field, swap the order if the field is the same + * set the sort { field, order } * @name setSort * @function - * @param {string} field the name of the field to sort + * @param {Sort} sort the sort object + */ + +/** + * set the sort field, swap the order if the field is the same + * @name setSortField + * @function + * @param {string} field the sort field + */ + +/** + * set the sort order + * @name setSortOrder + * @function + * @param {string} order the sort order eiather ASC or DESC */ /** @@ -36,6 +78,8 @@ const sortReducer = (state: Sort, field: string | Sort): Sort => { * @property {String} sort.field: the sort object. * @property {'ASC' | 'DESC'} sort.order: the sort object. * @property {setSort} setSort + * @property {setSortField} setSortField + * @property {setSortOrder} setSortOrder */ /** @@ -43,16 +87,22 @@ const sortReducer = (state: Sort, field: string | Sort): Sort => { * * @example * - * const { sort, setSort } = useSort({ field: 'name',order: 'ASC' }); + * const { sort, setSort, setSortField, setSortOrder } = useSort({ + * field: 'name', + * order: 'ASC', + * }); + * + * setSort({ field: 'name', order: 'ASC' }); + * // is the same as + * setSortField('name'); + * setSortOrder('ASC'); * * @param {Object} initialSort - * @param {string} initialSort.resource The current resource name - * @param {string} initialSort.reference The linked resource name + * @param {string} initialSort.field The initial sort field + * @param {string} initialSort.order The initial sort order * @returns {SortProps} The sort props */ -export default ( - initialSort: Sort = { field: 'id', order: 'DESC' } -): SortProps => { +export default (initialSort: Sort = defaultSort): SortProps => { const [sort, dispatch] = useReducer(sortReducer, initialSort); const isFirstRender = useRef(true); useEffect(() => { @@ -60,11 +110,16 @@ export default ( isFirstRender.current = false; return; } - dispatch(initialSort); + dispatch({ type: 'SET_SORT', payload: { sort: initialSort } }); }, [initialSort.field, initialSort.order]); // eslint-disable-line react-hooks/exhaustive-deps return { - setSort: (field: string) => dispatch(field), + setSort: (sort: Sort) => + dispatch({ type: 'SET_SORT', payload: { sort } }), + setSortField: (field: string) => + dispatch({ type: 'SET_SORT_FIELD', payload: { field } }), + setSortOrder: (order: string) => + dispatch({ type: 'SET_SORT_ORDER', payload: { order } }), sort, }; }; diff --git a/packages/ra-core/src/reducer/admin/references/possibleValues.ts b/packages/ra-core/src/reducer/admin/references/possibleValues.ts index ed518772ab2..d0337006613 100644 --- a/packages/ra-core/src/reducer/admin/references/possibleValues.ts +++ b/packages/ra-core/src/reducer/admin/references/possibleValues.ts @@ -40,8 +40,9 @@ const possibleValuesreducer: Reducer = ( } }; -export const getPossibleReferenceValues = (state, props) => - state[props.referenceSource(props.resource, props.source)]; +export const getPossibleReferenceValues = (state, props) => { + return state[props.referenceSource(props.resource, props.source)]; +}; export const getPossibleReferences = ( referenceState, diff --git a/packages/ra-core/src/util/TestContext.tsx b/packages/ra-core/src/util/TestContext.tsx index 78bb8e91f55..7772028850c 100644 --- a/packages/ra-core/src/util/TestContext.tsx +++ b/packages/ra-core/src/util/TestContext.tsx @@ -55,6 +55,7 @@ class TestContext extends Component { constructor(props) { super(props); const { initialState = {}, enableReducers = false } = props; + this.storeWithDefault = enableReducers ? createAdminStore({ initialState: merge({}, defaultStore, initialState), diff --git a/packages/ra-core/src/util/renderHook.tsx b/packages/ra-core/src/util/renderHook.tsx new file mode 100644 index 00000000000..11f31561005 --- /dev/null +++ b/packages/ra-core/src/util/renderHook.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { render, RenderResult } from 'react-testing-library'; + +import renderWithRedux, { RenderWithReduxResult } from './renderWithRedux'; + +const TestHook = ({ children, hook }) => { + return children(hook()); +}; + +interface RenderHookResult extends RenderResult { + hookValue: any; + childrenMock: jest.Mock; + rerender: (f: any) => any; +} +interface RenderHookWithReduxResult extends RenderWithReduxResult { + hookValue: any; + childrenMock: jest.Mock; + rerender: (f: any) => any; +} + +/** + * render given hook using react-testing-library and return hook value + * @param hook the hook to render + * @param withRedux should we provide a redux context default to true + * @param reduxState optional initial state for redux context + * + * @returns {RenderHookResult} + * @returns {RenderHookWithReduxResult} + */ +function renderHook( + hook: Function, + withRedux?: true, + reduxState?: {} +): RenderHookWithReduxResult; +function renderHook(hook: Function, withRedux: false): RenderHookResult; +function renderHook(hook, withRedux = true, reduxState?) { + let hookValue = null; + const children = props => { + hookValue = props; + return

child

; + }; + const childrenMock = jest.fn().mockImplementation(children); + const result = withRedux + ? renderWithRedux( + , + reduxState + ) + : render(); + + return { + ...result, + hookValue, + childrenMock, + rerender: newHook => { + result.rerender( + + ); + }, + }; +} + +export default renderHook; diff --git a/packages/ra-core/src/util/renderWithRedux.tsx b/packages/ra-core/src/util/renderWithRedux.tsx index b62582c5979..0ebe561a145 100644 --- a/packages/ra-core/src/util/renderWithRedux.tsx +++ b/packages/ra-core/src/util/renderWithRedux.tsx @@ -1,8 +1,13 @@ import React from 'react'; -import { render } from 'react-testing-library'; +import { render, RenderResult } from 'react-testing-library'; import TestContext from './TestContext'; +export interface RenderWithReduxResult extends RenderResult { + dispatch: jest.Mock; + reduxStore: any; +} + /** * render with react-testing library adding redux context for unit test. * @example @@ -18,7 +23,7 @@ import TestContext from './TestContext'; * dispatch: spy on the redux stroe dispatch method * reduxStore: the redux store used by the tested component */ -export default (component, initialState = {}) => { +export default (component, initialState = {}): RenderWithReduxResult => { let dispatch; let reduxStore; const renderResult = render( diff --git a/packages/ra-ui-materialui/src/field/ReferenceField.js b/packages/ra-ui-materialui/src/field/ReferenceField.js index ac291146c4d..2c4b3ec1097 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceField.js +++ b/packages/ra-ui-materialui/src/field/ReferenceField.js @@ -2,7 +2,7 @@ import React, { Children } from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; import { withStyles, createStyles } from '@material-ui/core/styles'; -import { useReference } from 'ra-core'; +import { useReference, getResourceLinkPath } from 'ra-core'; import LinearProgress from '../layout/LinearProgress'; import Link from '../Link'; @@ -137,9 +137,8 @@ const ReferenceField = ({ children, ...props }) => { throw new Error(' only accepts a single child'); } - const { isLoading, referenceRecord, resourceLinkPath } = useReference( - props - ); + const { isLoading, referenceRecord } = useReference(props); + const resourceLinkPath = getResourceLinkPath(props); return ( { ); } const { sort, setSort } = useSortState(initialSort); - const { page, perPage, setPage, setPerPage } = usePaginationState( - initialPerPage - ); + const { page, perPage, setPage, setPerPage } = usePaginationState({ + perPage: initialPerPage, + }); const useReferenceManyProps = useReferenceMany({ resource, diff --git a/packages/ra-ui-materialui/src/input/ReferenceInput.js b/packages/ra-ui-materialui/src/input/ReferenceInput.js index 67446d5dff6..44e5d9aca19 100644 --- a/packages/ra-ui-materialui/src/input/ReferenceInput.js +++ b/packages/ra-ui-materialui/src/input/ReferenceInput.js @@ -55,7 +55,7 @@ export const ReferenceInputView = ({ error, input, isRequired, - isLoading, + loading, label, meta, onChange, @@ -68,7 +68,7 @@ export const ReferenceInputView = ({ warning, ...rest }) => { - if (isLoading) { + if (loading) { return ( ', () => { {...{ ...defaultProps, input: { value: 1 }, - isLoading: true, + loading: true, }} >