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,
}}
>