Skip to content

Commit cf53a23

Browse files
committed
Support ReactNode as create label in AutocompleteInput, AutocompleteArrayInput, SelectInput and SelectArrayInput.
1 parent 5a4b869 commit cf53a23

11 files changed

+253
-14
lines changed

packages/ra-ui-materialui/src/input/AutocompleteArrayInput.spec.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { AutocompleteArrayInput } from './AutocompleteArrayInput';
1414
import { useCreateSuggestionContext } from './useSupportCreateSuggestion';
1515
import {
1616
CreateItemLabel,
17+
CreateItemLabelRendered,
1718
CreateLabel,
1819
InsideReferenceArrayInput,
1920
InsideReferenceArrayInputOnChange,
@@ -1289,4 +1290,40 @@ describe('<AutocompleteArrayInput />', () => {
12891290
});
12901291
expect(screen.getByDisplayValue('French')).not.toBeNull();
12911292
});
1293+
1294+
it('should allow to pass rendered createLabel and createItemLabel', async () => {
1295+
render(<CreateItemLabelRendered />);
1296+
1297+
const input = await screen.findByRole('combobox');
1298+
fireEvent.focus(input);
1299+
fireEvent.change(input, { target: { value: '' } });
1300+
1301+
expect((await screen.findByTestId('new-role-hint')).textContent).toBe(
1302+
'Start typing to create a new role'
1303+
);
1304+
1305+
fireEvent.change(input, { target: { value: 'Guest' } });
1306+
1307+
expect((await screen.findByTestId('new-role-chip')).textContent).toBe(
1308+
'Guest'
1309+
);
1310+
});
1311+
it('should not use the rendered createItemLabel as the value of the input', async () => {
1312+
render(<CreateItemLabelRendered />);
1313+
const input = (await screen.findByLabelText('Roles', undefined, {
1314+
timeout: 2000,
1315+
})) as HTMLInputElement;
1316+
await waitFor(() => {
1317+
expect(input.value).toBe('');
1318+
});
1319+
fireEvent.focus(input);
1320+
expect(screen.getAllByRole('option')).toHaveLength(3);
1321+
fireEvent.change(input, { target: { value: 'x' } });
1322+
await waitFor(() => {
1323+
expect(screen.getAllByRole('option')).toHaveLength(1);
1324+
});
1325+
fireEvent.click(screen.getByText('Create'));
1326+
expect(input.value).not.toBe('Create x');
1327+
expect(input.value).toBe('');
1328+
}, 10000);
12921329
});

packages/ra-ui-materialui/src/input/AutocompleteArrayInput.stories.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@ import { Admin } from 'react-admin';
44
import CloseIcon from '@mui/icons-material/Close';
55
import {
66
Button,
7+
Chip,
78
Dialog,
89
DialogActions,
910
DialogContent,
1011
DialogTitle,
1112
IconButton,
1213
TextField,
14+
Typography,
1315
} from '@mui/material';
1416

1517
import {
@@ -315,6 +317,27 @@ export const CreateItemLabel = () => (
315317
</Wrapper>
316318
);
317319

320+
export const CreateItemLabelRendered = () => (
321+
<Wrapper>
322+
<AutocompleteArrayInput
323+
source="roles"
324+
choices={choices}
325+
sx={{ width: 400 }}
326+
create={<CreateRole />}
327+
createLabel={
328+
<Typography data-testid="new-role-hint">
329+
Start typing to create a new <strong>role</strong>
330+
</Typography>
331+
}
332+
createItemLabel={item => (
333+
<Typography component="div">
334+
Create <Chip label={item} data-testid="new-role-chip" />
335+
</Typography>
336+
)}
337+
/>
338+
</Wrapper>
339+
);
340+
318341
const dataProvider = {
319342
getOne: () =>
320343
Promise.resolve({

packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
OnCreateSlow,
3030
CreateLabel,
3131
CreateItemLabel,
32+
CreateItemLabelRendered,
3233
} from './AutocompleteInput.stories';
3334
import { ReferenceArrayInput } from './ReferenceArrayInput';
3435
import { AutocompleteArrayInput } from './AutocompleteArrayInput';
@@ -1989,4 +1990,40 @@ describe('<AutocompleteInput />', () => {
19891990
screen.getByText('Victor Hugo');
19901991
});
19911992
});
1993+
1994+
it('should allow to pass rendered createLabel and createItemLabel', async () => {
1995+
render(<CreateItemLabelRendered />);
1996+
1997+
const input = await screen.findByRole('combobox');
1998+
fireEvent.focus(input);
1999+
fireEvent.change(input, { target: { value: '' } });
2000+
2001+
expect((await screen.findByTestId('new-choice-hint')).textContent).toBe(
2002+
'Start typing to create a new author'
2003+
);
2004+
2005+
fireEvent.change(input, { target: { value: 'Gustave Flaubert' } });
2006+
2007+
expect((await screen.findByTestId('new-choice-chip')).textContent).toBe(
2008+
'Gustave Flaubert'
2009+
);
2010+
});
2011+
it('should not use the rendered createItemLabel as the value of the input', async () => {
2012+
render(<CreateItemLabelRendered delay={1500} />);
2013+
const input = (await screen.findByLabelText('Author', undefined, {
2014+
timeout: 2000,
2015+
})) as HTMLInputElement;
2016+
await waitFor(() => {
2017+
expect(input.value).toBe('Leo Tolstoy');
2018+
});
2019+
fireEvent.focus(input);
2020+
expect(screen.getAllByRole('option')).toHaveLength(4);
2021+
fireEvent.change(input, { target: { value: 'x' } });
2022+
await waitFor(() => {
2023+
expect(screen.getAllByRole('option')).toHaveLength(1);
2024+
});
2025+
fireEvent.click(screen.getByText('Create'));
2026+
expect(input.value).not.toBe('Create x');
2027+
expect(input.value).toBe('');
2028+
}, 10000);
19922029
});

packages/ra-ui-materialui/src/input/AutocompleteInput.stories.tsx

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import ExpandCircleDownIcon from '@mui/icons-material/ExpandCircleDown';
1919
import {
2020
Box,
2121
Button,
22+
Chip,
2223
Dialog,
2324
DialogActions,
2425
DialogContent,
@@ -41,6 +42,7 @@ import { AutocompleteInput, AutocompleteInputProps } from './AutocompleteInput';
4142
import { ReferenceInput } from './ReferenceInput';
4243
import { TextInput } from './TextInput';
4344
import { useCreateSuggestionContext } from './useSupportCreateSuggestion';
45+
import { delayedDataProvider } from './common';
4446

4547
export default { title: 'ra-ui-materialui/input/AutocompleteInput' };
4648

@@ -597,6 +599,48 @@ export const CreateItemLabel = () => (
597599
</Wrapper>
598600
);
599601

602+
const CreateItemLabelRenderedInput = () => {
603+
const [choices, setChoices] = useState(choicesForCreationSupport);
604+
return (
605+
<AutocompleteInput
606+
source="author"
607+
choices={choices}
608+
createLabel={
609+
<Typography data-testid="new-choice-hint">
610+
Start typing to create a new <strong>author</strong>
611+
</Typography>
612+
}
613+
createItemLabel={item => (
614+
<Typography component="div">
615+
Create <Chip label={item} data-testid="new-choice-chip" />
616+
</Typography>
617+
)}
618+
onCreate={async filter => {
619+
const newAuthor = {
620+
id: choices.length + 1,
621+
name: filter,
622+
};
623+
setChoices(authors => [...authors, newAuthor]);
624+
// Wait until next tick to give some time for React to update the state
625+
await new Promise(resolve => setTimeout(resolve));
626+
return newAuthor;
627+
}}
628+
TextFieldProps={{
629+
placeholder: 'Start typing to create a new item',
630+
}}
631+
// Disable clearOnBlur because opening the prompt blurs the input
632+
// and creates a flicker
633+
clearOnBlur={false}
634+
/>
635+
);
636+
};
637+
638+
export const CreateItemLabelRendered = ({ delay = 0 }: { delay?: number }) => (
639+
<Wrapper dataProvider={delayedDataProvider(dataProviderDefault, delay)}>
640+
<CreateItemLabelRenderedInput />
641+
</Wrapper>
642+
);
643+
600644
const authorsWithFirstAndLastName = [
601645
{ id: 1, first_name: 'Leo', last_name: 'Tolstoy', language: 'Russian' },
602646
{ id: 2, first_name: 'Victor', last_name: 'Hugo', language: 'French' },

packages/ra-ui-materialui/src/input/AutocompleteInput.tsx

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,14 @@ If you provided a React element for the optionText prop, you must also provide t
447447
emptyValue,
448448
]
449449
);
450+
const getOptionLabelString = useCallback(
451+
(option: any, isListItem: boolean = false) => {
452+
const optionLabel = getOptionLabel(option, isListItem);
453+
// Can be a ReactNode when it's the create option.
454+
return typeof optionLabel === 'string' ? optionLabel : '';
455+
},
456+
[getOptionLabel]
457+
);
450458

451459
const finalOnBlur = useCallback(
452460
(event): void => {
@@ -493,10 +501,13 @@ If you provided a React element for the optionText prop, you must also provide t
493501
event?.type === 'change' ||
494502
!doesQueryMatchSelection(newInputValue)
495503
) {
496-
const createOptionLabel = translate(createItemLabel, {
497-
item: filterValue,
498-
_: createItemLabel,
499-
});
504+
const createOptionLabel =
505+
typeof createItemLabel === 'string'
506+
? translate(createItemLabel, {
507+
item: filterValue,
508+
_: createItemLabel,
509+
})
510+
: undefined;
500511
const isCreate = newInputValue === createOptionLabel;
501512
const valueToSet = isCreate ? filterValue : newInputValue;
502513
setFilterValue(valueToSet);
@@ -722,7 +733,8 @@ If you provided a React element for the optionText prop, you must also provide t
722733
? suggestions
723734
: []
724735
}
725-
getOptionLabel={getOptionLabel}
736+
getOptionKey={(option: any) => option?.id}
737+
getOptionLabel={getOptionLabelString}
726738
inputValue={filterValue}
727739
loading={
728740
isPending &&

packages/ra-ui-materialui/src/input/SelectArrayInput.spec.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
InsideReferenceArrayInput,
1919
InsideReferenceArrayInputDefaultValue,
2020
CreateLabel,
21+
CreateLabelRendered,
2122
} from './SelectArrayInput.stories';
2223

2324
describe('<SelectArrayInput />', () => {
@@ -629,6 +630,19 @@ describe('<SelectArrayInput />', () => {
629630
await screen.findByLabelText('Role name');
630631
});
631632

633+
it('should support using a custom rendered createLabel', async () => {
634+
render(<CreateLabelRendered />);
635+
const input = (await screen.findByLabelText(
636+
'Roles'
637+
)) as HTMLInputElement;
638+
fireEvent.mouseDown(input);
639+
// Expect the custom create label to be displayed
640+
const newRoleLabel = await screen.findByTestId('new-role-label');
641+
expect(newRoleLabel.textContent).toBe('Create a new role');
642+
fireEvent.click(newRoleLabel);
643+
await screen.findByText('Role name');
644+
});
645+
632646
it('should receive an event object on change', async () => {
633647
const choices = [...defaultProps.choices];
634648
const onChange = jest.fn();

packages/ra-ui-materialui/src/input/SelectArrayInput.stories.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
IconButton,
1010
Stack,
1111
TextField,
12+
Typography,
1213
} from '@mui/material';
1314
import fakeRestProvider from 'ra-data-fakerest';
1415
import polyglotI18nProvider from 'ra-i18n-polyglot';
@@ -353,6 +354,22 @@ export const CreateLabel = () => (
353354
</Wrapper>
354355
);
355356

357+
export const CreateLabelRendered = () => (
358+
<Wrapper>
359+
<SelectArrayInput
360+
source="roles"
361+
choices={choices}
362+
defaultValue={['u001', 'u003']}
363+
create={<CreateRole />}
364+
createLabel={
365+
<Typography data-testid="new-role-label">
366+
Create a new <strong>role</strong>
367+
</Typography>
368+
}
369+
/>
370+
</Wrapper>
371+
);
372+
356373
export const DifferentIdTypes = () => {
357374
const fakeData = {
358375
bands: [{ id: 1, name: 'band_1', members: [1, '2'] }],

packages/ra-ui-materialui/src/input/SelectInput.spec.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
TranslateChoice,
2121
FetchChoices,
2222
CreateLabel,
23+
CreateLabelRendered,
2324
} from './SelectInput.stories';
2425

2526
describe('<SelectInput />', () => {
@@ -731,6 +732,20 @@ describe('<SelectInput />', () => {
731732
promptSpy.mockRestore();
732733
});
733734

735+
it('should support using a custom rendered createLabel', async () => {
736+
render(<CreateLabelRendered />);
737+
const input = (await screen.findByLabelText(
738+
'Category'
739+
)) as HTMLInputElement;
740+
fireEvent.mouseDown(input);
741+
// Expect the custom create label to be displayed
742+
const newCategoryLabel =
743+
await screen.findByTestId('new-category-label');
744+
expect(newCategoryLabel.textContent).toBe('Create a new category');
745+
fireEvent.click(newCategoryLabel);
746+
await screen.findByText('New category name');
747+
});
748+
734749
it('should support using a custom createLabel with optionText being a string', async () => {
735750
const promptSpy = jest.spyOn(window, 'prompt');
736751
promptSpy.mockImplementation(jest.fn(() => 'New Category'));

packages/ra-ui-materialui/src/input/SelectInput.stories.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
DialogTitle,
88
IconButton,
99
TextField,
10+
Typography,
1011
} from '@mui/material';
1112
import {
1213
CreateBase,
@@ -410,6 +411,21 @@ CreateLabel.argTypes = {
410411
},
411412
};
412413

414+
export const CreateLabelRendered = () => (
415+
<Wrapper>
416+
<SelectInput
417+
createLabel={
418+
<Typography data-testid="new-category-label">
419+
Create a new <strong>category</strong>
420+
</Typography>
421+
}
422+
create={<CreateCategory />}
423+
source="category"
424+
choices={categories}
425+
/>
426+
</Wrapper>
427+
);
428+
413429
const i18nProvider = polyglotI18nProvider(() => englishMessages);
414430

415431
const Wrapper = ({ children, onSuccess = console.log, name = 'gender' }) => (

packages/ra-ui-materialui/src/input/common.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as React from 'react';
22
import { useWatch } from 'react-hook-form';
3+
import { DataProvider } from 'ra-core';
34

45
export const FormInspector = ({ name = 'title' }) => {
56
const value = useWatch({ name });
@@ -12,3 +13,21 @@ export const FormInspector = ({ name = 'title' }) => {
1213
</div>
1314
);
1415
};
16+
17+
export const delayedDataProvider = (
18+
dataProvider: DataProvider,
19+
delay = process.env.NODE_ENV === 'test' ? 100 : 300
20+
) =>
21+
new Proxy(dataProvider, {
22+
get: (target, name) => (resource, params) => {
23+
if (typeof name === 'symbol' || name === 'then') {
24+
return;
25+
}
26+
return new Promise(resolve =>
27+
setTimeout(
28+
() => resolve(dataProvider[name](resource, params)),
29+
delay
30+
)
31+
);
32+
},
33+
});

0 commit comments

Comments
 (0)