Skip to content

Commit a30cc3f

Browse files
committed
Introduce <ReferenceArrayInputBase>
1 parent ccf10db commit a30cc3f

File tree

5 files changed

+345
-30
lines changed

5 files changed

+345
-30
lines changed
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import * as React from 'react';
2+
import { render, screen, waitFor } from '@testing-library/react';
3+
import { testDataProvider } from 'ra-core';
4+
import { Basic, WithError } from './ReferenceArrayInputBase.stories';
5+
6+
describe('<ReferenceArrayInputBase>', () => {
7+
afterEach(async () => {
8+
// wait for the getManyAggregate batch to resolve
9+
await waitFor(() => new Promise(resolve => setTimeout(resolve, 0)));
10+
});
11+
12+
it('should pass down the error if any occurred', async () => {
13+
jest.spyOn(console, 'error').mockImplementation(() => {});
14+
render(<WithError />);
15+
await waitFor(() => {
16+
expect(screen.queryByText('Error: fetch error')).not.toBeNull();
17+
});
18+
});
19+
it('should pass the correct resource down to child component', async () => {
20+
render(<Basic />);
21+
await screen.findByText('Selected tags: 1, 3');
22+
});
23+
24+
it('should provide a ChoicesContext with all available choices', async () => {
25+
render(<Basic />);
26+
await screen.findByText('Total tags: 5');
27+
});
28+
29+
it('should apply default values', async () => {
30+
render(<Basic />);
31+
await screen.findByText('Selected tags: 1, 3');
32+
});
33+
34+
it('should accept meta in queryOptions', async () => {
35+
const getList = jest
36+
.fn()
37+
.mockImplementationOnce(() =>
38+
Promise.resolve({ data: [], total: 25 })
39+
);
40+
const dataProvider = testDataProvider({ getList });
41+
render(<Basic meta dataProvider={dataProvider} />);
42+
await waitFor(() => {
43+
expect(getList).toHaveBeenCalledWith('tags', {
44+
filter: {},
45+
pagination: { page: 1, perPage: 25 },
46+
sort: { field: 'id', order: 'DESC' },
47+
meta: { foo: 'bar' },
48+
signal: undefined,
49+
});
50+
});
51+
});
52+
});
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import * as React from 'react';
2+
import polyglotI18nProvider from 'ra-i18n-polyglot';
3+
import englishMessages from 'ra-language-english';
4+
5+
import { CoreAdmin } from '../../core/CoreAdmin';
6+
import { Resource } from '../../core/Resource';
7+
import { CreateBase } from '../../controller/create/CreateBase';
8+
import { testDataProvider } from '../../dataProvider/testDataProvider';
9+
import { DataProvider } from '../../types';
10+
import { Form } from '../../form/Form';
11+
import { useInput } from '../../form/useInput';
12+
import { InputProps } from '../../form/types';
13+
import { TestMemoryRouter } from '../../routing/TestMemoryRouter';
14+
import {
15+
ReferenceArrayInputBase,
16+
ReferenceArrayInputBaseProps,
17+
} from './ReferenceArrayInputBase';
18+
import { ChoicesProps, useChoicesContext } from '../../form';
19+
import { useGetRecordRepresentation } from '../..';
20+
21+
export default { title: 'ra-core/controller/ReferenceArrayInputBase' };
22+
23+
const tags = [
24+
{ id: 0, name: '3D' },
25+
{ id: 1, name: 'Architecture' },
26+
{ id: 2, name: 'Design' },
27+
{ id: 3, name: 'Painting' },
28+
{ id: 4, name: 'Photography' },
29+
];
30+
31+
const defaultDataProvider = testDataProvider({
32+
getList: () =>
33+
// @ts-ignore
34+
Promise.resolve({
35+
data: tags,
36+
total: tags.length,
37+
}),
38+
// @ts-ignore
39+
getMany: (resource, params) => {
40+
if (process.env.NODE_ENV !== 'test') {
41+
console.log('getMany', resource, params);
42+
}
43+
return Promise.resolve({
44+
data: params.ids.map(id => tags.find(tag => tag.id === id)),
45+
});
46+
},
47+
});
48+
49+
const i18nProvider = polyglotI18nProvider(() => englishMessages);
50+
51+
const CheckboxGroupInput = (
52+
props: Omit<InputProps, 'source'> & ChoicesProps
53+
) => {
54+
const { allChoices, isPending, error, resource, source, total } =
55+
useChoicesContext(props);
56+
const input = useInput({ ...props, source });
57+
const getRecordRepresentation = useGetRecordRepresentation(resource);
58+
59+
if (isPending) {
60+
return <span>Loading...</span>;
61+
}
62+
63+
if (error) {
64+
return <span>Error: {error.message}</span>;
65+
}
66+
67+
return (
68+
<div>
69+
{allChoices.map(choice => (
70+
<label key={choice.id}>
71+
<input
72+
type="checkbox"
73+
// eslint-disable-next-line eqeqeq
74+
checked={input.field.value.some(id => id == choice.id)}
75+
onChange={() => {
76+
const newValue = input.field.value.some(
77+
// eslint-disable-next-line eqeqeq
78+
id => id == choice.id
79+
)
80+
? input.field.value.filter(
81+
// eslint-disable-next-line eqeqeq
82+
id => id != choice.id
83+
)
84+
: [...input.field.value, choice.id];
85+
input.field.onChange(newValue);
86+
}}
87+
/>
88+
{getRecordRepresentation(choice)}
89+
</label>
90+
))}
91+
<div>
92+
Selected {resource}: {input.field.value.join(', ')}
93+
</div>
94+
<div>
95+
Total {resource}: {total}
96+
</div>
97+
</div>
98+
);
99+
};
100+
101+
export const Basic = ({
102+
dataProvider = defaultDataProvider,
103+
meta,
104+
...props
105+
}: Partial<ReferenceArrayInputBaseProps> & {
106+
dataProvider?: DataProvider;
107+
meta?: boolean;
108+
}) => (
109+
<TestMemoryRouter initialEntries={['/posts/create']}>
110+
<CoreAdmin dataProvider={dataProvider} i18nProvider={i18nProvider}>
111+
<Resource
112+
name="posts"
113+
create={
114+
<CreateBase resource="posts" record={{ tags_ids: [1, 3] }}>
115+
<h1>Create Post</h1>
116+
<Form>
117+
<ReferenceArrayInputBase
118+
reference="tags"
119+
resource="posts"
120+
source="tags_ids"
121+
queryOptions={
122+
meta ? { meta: { foo: 'bar' } } : {}
123+
}
124+
{...props}
125+
>
126+
<CheckboxGroupInput />
127+
</ReferenceArrayInputBase>
128+
</Form>
129+
</CreateBase>
130+
}
131+
/>
132+
</CoreAdmin>
133+
</TestMemoryRouter>
134+
);
135+
136+
Basic.args = {
137+
meta: false,
138+
};
139+
140+
Basic.argTypes = {
141+
meta: { control: 'boolean' },
142+
};
143+
144+
export const WithError = () => (
145+
<TestMemoryRouter initialEntries={['/posts/create']}>
146+
<CoreAdmin
147+
dataProvider={
148+
{
149+
getList: () => Promise.reject(new Error('fetch error')),
150+
getMany: () =>
151+
Promise.resolve({ data: [{ id: 5, name: 'test1' }] }),
152+
} as unknown as DataProvider
153+
}
154+
i18nProvider={i18nProvider}
155+
>
156+
<Resource
157+
name="posts"
158+
create={
159+
<CreateBase resource="posts" record={{ tags_ids: [1, 3] }}>
160+
<h1>Create Post</h1>
161+
<Form
162+
onSubmit={() => {}}
163+
defaultValues={{ tag_ids: [1, 3] }}
164+
>
165+
<ReferenceArrayInputBase
166+
reference="tags"
167+
resource="posts"
168+
source="tag_ids"
169+
>
170+
<CheckboxGroupInput />
171+
</ReferenceArrayInputBase>
172+
</Form>
173+
</CreateBase>
174+
}
175+
/>
176+
</CoreAdmin>
177+
</TestMemoryRouter>
178+
);
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import * as React from 'react';
2+
import { InputProps } from '../../form/types';
3+
import {
4+
useReferenceArrayInputController,
5+
type UseReferenceArrayInputParams,
6+
} from './useReferenceArrayInputController';
7+
import { ResourceContextProvider } from '../../core/ResourceContextProvider';
8+
import { ChoicesContextProvider } from '../../form/choices/ChoicesContextProvider';
9+
import { RaRecord } from '../../types';
10+
11+
/**
12+
* An Input component for fields containing a list of references to another resource.
13+
* Useful for 'hasMany' relationship.
14+
*
15+
* @example
16+
* The post object has many tags, so the post resource looks like:
17+
* {
18+
* id: 1234,
19+
* tag_ids: [ "1", "23", "4" ]
20+
* }
21+
*
22+
* ReferenceArrayInput component fetches the current resources (using
23+
* `dataProvider.getMany()`) as well as possible resources (using
24+
* `dataProvider.getList()`) in the reference endpoint. It then
25+
* delegates rendering to its child component, to which it makes the possible
26+
* choices available through the ChoicesContext.
27+
*
28+
* Use it with a selector component as child, like `<SelectArrayInput>`
29+
* or <CheckboxGroupInput>.
30+
*
31+
* @example
32+
* export const PostEdit = () => (
33+
* <Edit>
34+
* <SimpleForm>
35+
* <ReferenceArrayInput source="tag_ids" reference="tags">
36+
* <SelectArrayInput optionText="name" />
37+
* </ReferenceArrayInput>
38+
* </SimpleForm>
39+
* </Edit>
40+
* );
41+
*
42+
* By default, restricts the possible values to 25. You can extend this limit
43+
* by setting the `perPage` prop.
44+
*
45+
* @example
46+
* <ReferenceArrayInput
47+
* source="tag_ids"
48+
* reference="tags"
49+
* perPage={100}>
50+
* <SelectArrayInput optionText="name" />
51+
* </ReferenceArrayInput>
52+
*
53+
* By default, orders the possible values by id desc. You can change this order
54+
* by setting the `sort` prop (an object with `field` and `order` properties).
55+
*
56+
* @example
57+
* <ReferenceArrayInput
58+
* source="tag_ids"
59+
* reference="tags"
60+
* sort={{ field: 'name', order: 'ASC' }}>
61+
* <SelectArrayInput optionText="name" />
62+
* </ReferenceArrayInput>
63+
*
64+
* Also, you can filter the query used to populate the possible values. Use the
65+
* `filter` prop for that.
66+
*
67+
* @example
68+
* <ReferenceArrayInput
69+
* source="tag_ids"
70+
* reference="tags"
71+
* filter={{ is_public: true }}>
72+
* <SelectArrayInput optionText="name" />
73+
* </ReferenceArrayInput>
74+
*
75+
* The enclosed component may filter results. ReferenceArrayInput create a ChoicesContext which provides
76+
* a `setFilters` function. You can call this function to filter the results.
77+
*/
78+
export const ReferenceArrayInputBase = <RecordType extends RaRecord = any>(
79+
props: ReferenceArrayInputBaseProps<RecordType>
80+
) => {
81+
const { children, reference, sort, filter = defaultFilter } = props;
82+
if (React.Children.count(children) !== 1) {
83+
throw new Error(
84+
'<ReferenceArrayInputBase> only accepts a single child (like <Datagrid>)'
85+
);
86+
}
87+
88+
const controllerProps = useReferenceArrayInputController({
89+
...props,
90+
sort,
91+
filter,
92+
});
93+
94+
return (
95+
<ResourceContextProvider value={reference}>
96+
<ChoicesContextProvider value={controllerProps}>
97+
{children}
98+
</ChoicesContextProvider>
99+
</ResourceContextProvider>
100+
);
101+
};
102+
103+
const defaultFilter = {};
104+
105+
export interface ReferenceArrayInputBaseProps<RecordType extends RaRecord = any>
106+
extends InputProps,
107+
UseReferenceArrayInputParams<RecordType> {
108+
children: React.ReactNode;
109+
}

packages/ra-core/src/controller/input/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
export * from './useReferenceArrayInputController';
88
export * from './useReferenceInputController';
99
export * from './ReferenceInputBase';
10+
export * from './ReferenceArrayInputBase';
1011

1112
export {
1213
getStatusForInput,

0 commit comments

Comments
 (0)