Skip to content

Commit e4fd550

Browse files
authored
Merge pull request marmelab#10715 from marmelab/fix-createLabel-optionText-fn
Fix `<AutocompleteInput>` and `<SelectInput>` renders undefined instead of the `createLabel` when `optionText` is a function or a `recordRepresentation` is set
2 parents cbeea64 + 2ca0253 commit e4fd550

File tree

8 files changed

+230
-20
lines changed

8 files changed

+230
-20
lines changed

packages/ra-core/src/form/choices/useChoices.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ export interface UseChoicesOptions {
2727
optionText?: OptionText;
2828
disableValue?: string;
2929
translateChoice?: boolean;
30+
createValue?: string;
31+
createHintValue?: string;
3032
}
3133

3234
/*
@@ -45,11 +47,20 @@ export const useChoices = ({
4547
optionValue = 'id',
4648
disableValue = 'disabled',
4749
translateChoice = true,
50+
createValue = '@@ra-create',
51+
createHintValue = '@@ra-create-hint',
4852
}: UseChoicesOptions) => {
4953
const translate = useTranslate();
5054

5155
const getChoiceText = useCallback(
5256
choice => {
57+
if (choice?.id === createValue || choice?.id === createHintValue) {
58+
return get(
59+
choice,
60+
typeof optionText === 'string' ? optionText : 'name'
61+
);
62+
}
63+
5364
if (isValidElement<{ record: any }>(optionText)) {
5465
return (
5566
<RecordContextProvider value={choice}>
@@ -68,7 +79,7 @@ export const useChoices = ({
6879
? translate(String(choiceName), { _: choiceName })
6980
: String(choiceName);
7081
},
71-
[optionText, translate, translateChoice]
82+
[createHintValue, createValue, optionText, translate, translateChoice]
7283
);
7384

7485
const getChoiceValue = useCallback(

packages/ra-core/src/form/useSuggestions.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export const useSuggestions = ({
3030
choices,
3131
createText = 'ra.action.create',
3232
createValue = '@@create',
33+
createHintValue = '@@ra-create-hint',
3334
limitChoicesToValue,
3435
matchSuggestion,
3536
optionText,
@@ -43,6 +44,8 @@ export const useSuggestions = ({
4344
optionText,
4445
optionValue,
4546
translateChoice,
47+
createValue,
48+
createHintValue,
4649
});
4750

4851
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -93,7 +96,6 @@ export interface UseSuggestionsOptions extends UseChoicesOptions {
9396
allowDuplicates?: boolean;
9497
choices?: any[];
9598
createText?: string;
96-
createValue?: any;
9799
limitChoicesToValue?: boolean;
98100
matchSuggestion?: (
99101
filter: string,

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1452,6 +1452,50 @@ describe('<AutocompleteInput />', () => {
14521452
expect(input.value).not.toBe('Create x');
14531453
expect(input.value).toBe('x');
14541454
}, 10000);
1455+
1456+
it('should include an option with the custom createLabel when the input is empty and optionText is a string', async () => {
1457+
render(<CreateLabel optionText="full_name" />);
1458+
const input = (await screen.findByLabelText(
1459+
'Author'
1460+
)) as HTMLInputElement;
1461+
input.focus();
1462+
fireEvent.change(input, {
1463+
target: { value: '' },
1464+
});
1465+
const customCreateLabel = screen.queryByText(
1466+
'Start typing to create a new item'
1467+
);
1468+
expect(customCreateLabel).not.toBeNull();
1469+
expect(
1470+
(customCreateLabel as HTMLElement).getAttribute('aria-disabled')
1471+
).toEqual('true');
1472+
expect(screen.queryByText(/Create/)).toBeNull();
1473+
});
1474+
1475+
it('should include an option with the custom createLabel when the input is empty and optionText is a function', async () => {
1476+
render(
1477+
<CreateLabel
1478+
optionText={choice =>
1479+
`${choice.first_name} ${choice.last_name}`
1480+
}
1481+
/>
1482+
);
1483+
const input = (await screen.findByLabelText(
1484+
'Author'
1485+
)) as HTMLInputElement;
1486+
input.focus();
1487+
fireEvent.change(input, {
1488+
target: { value: '' },
1489+
});
1490+
const customCreateLabel = screen.queryByText(
1491+
'Start typing to create a new item'
1492+
);
1493+
expect(customCreateLabel).not.toBeNull();
1494+
expect(
1495+
(customCreateLabel as HTMLElement).getAttribute('aria-disabled')
1496+
).toEqual('true');
1497+
expect(screen.queryByText(/Create/)).toBeNull();
1498+
});
14551499
});
14561500
describe('create', () => {
14571501
it('should allow the creation of a new choice', async () => {

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

Lines changed: 79 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -258,12 +258,48 @@ export const OptionTextElement = () => (
258258
</Wrapper>
259259
);
260260

261-
const choicesForCreationSupport = [
262-
{ id: 1, name: 'Leo Tolstoy' },
263-
{ id: 2, name: 'Victor Hugo' },
264-
{ id: 3, name: 'William Shakespeare' },
265-
{ id: 4, name: 'Charles Baudelaire' },
266-
{ id: 5, name: 'Marcel Proust' },
261+
const choicesForCreationSupport: Partial<{
262+
id: number;
263+
name: string;
264+
full_name: string;
265+
first_name: string;
266+
last_name: string;
267+
}>[] = [
268+
{
269+
id: 1,
270+
name: 'Leo Tolstoy',
271+
full_name: 'Leo Tolstoy',
272+
first_name: 'Leo',
273+
last_name: 'Tolstoy',
274+
},
275+
{
276+
id: 2,
277+
name: 'Victor Hugo',
278+
full_name: 'Victor Hugo',
279+
first_name: 'Victor',
280+
last_name: 'Hugo',
281+
},
282+
{
283+
id: 3,
284+
name: 'William Shakespeare',
285+
full_name: 'William Shakespeare',
286+
first_name: 'William',
287+
last_name: 'Shakespeare',
288+
},
289+
{
290+
id: 4,
291+
name: 'Charles Baudelaire',
292+
full_name: 'Charles Baudelaire',
293+
first_name: 'Charles',
294+
last_name: 'Baudelaire',
295+
},
296+
{
297+
id: 5,
298+
name: 'Marcel Proust',
299+
full_name: 'Marcel Proust',
300+
first_name: 'Marcel',
301+
last_name: 'Proust',
302+
},
267303
];
268304

269305
const OnCreateInput = () => {
@@ -450,7 +486,9 @@ export const CreateDialog = () => (
450486
</Wrapper>
451487
);
452488

453-
const CreateLabelInput = () => {
489+
const CreateLabelInput = ({
490+
optionText,
491+
}: Pick<AutocompleteInputProps, 'optionText'>) => {
454492
const [choices, setChoices] = useState(choicesForCreationSupport);
455493
return (
456494
<AutocompleteInput
@@ -459,25 +497,55 @@ const CreateLabelInput = () => {
459497
onCreate={async filter => {
460498
if (!filter) return;
461499

462-
const newOption = {
500+
const newOption: Partial<{
501+
id: number;
502+
name: string;
503+
full_name: string;
504+
first_name: string;
505+
last_name: string;
506+
}> = {
463507
id: choices.length + 1,
464-
name: filter,
465508
};
509+
if (optionText == null) {
510+
newOption.name = filter;
511+
} else if (typeof optionText === 'string') {
512+
newOption[optionText] = filter;
513+
} else {
514+
newOption.first_name = filter;
515+
newOption.last_name = filter;
516+
}
466517
setChoices(options => [...options, newOption]);
467518
// Wait until next tick to give some time for React to update the state
468519
await new Promise(resolve => setTimeout(resolve));
469520
return newOption;
470521
}}
471522
createLabel="Start typing to create a new item"
523+
optionText={optionText}
472524
/>
473525
);
474526
};
475527

476-
export const CreateLabel = () => (
528+
export const CreateLabel = ({
529+
optionText,
530+
}: Pick<AutocompleteInputProps, 'optionText'>) => (
477531
<Wrapper>
478-
<CreateLabelInput />
532+
<CreateLabelInput optionText={optionText} />
479533
</Wrapper>
480534
);
535+
CreateLabel.args = {
536+
optionText: undefined,
537+
};
538+
CreateLabel.argTypes = {
539+
optionText: {
540+
options: ['default', 'string', 'function'],
541+
mapping: {
542+
default: undefined,
543+
string: 'full_name',
544+
function: choice => `${choice.first_name} ${choice.last_name}`,
545+
},
546+
control: { type: 'inline-radio' },
547+
},
548+
};
481549

482550
const CreateItemLabelInput = () => {
483551
const [choices, setChoices] = useState(choicesForCreationSupport);

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,8 @@ If you provided a React element for the optionText prop, you must also provide t
326326
optionText ??
327327
(isFromReference ? getRecordRepresentation : undefined),
328328
optionValue,
329+
createValue,
330+
createHintValue,
329331
selectedItem: selectedChoice,
330332
suggestionLimit,
331333
translateChoice: translateChoice ?? !isFromReference,

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -697,6 +697,46 @@ describe('<SelectInput />', () => {
697697
});
698698
promptSpy.mockRestore();
699699
});
700+
701+
it('should support using a custom createLabel with optionText being a string', async () => {
702+
const promptSpy = jest.spyOn(window, 'prompt');
703+
promptSpy.mockImplementation(jest.fn(() => 'New Category'));
704+
render(<CreateLabel optionText="full_name" />);
705+
const input = (await screen.findByLabelText(
706+
'Category'
707+
)) as HTMLInputElement;
708+
fireEvent.mouseDown(input);
709+
// Expect the custom create label to be displayed
710+
fireEvent.click(await screen.findByText('Create a new category'));
711+
// Expect a prompt to have opened
712+
await waitFor(() => {
713+
expect(promptSpy).toHaveBeenCalled();
714+
});
715+
promptSpy.mockRestore();
716+
});
717+
718+
it('should support using a custom createLabel with optionText being a function', async () => {
719+
const promptSpy = jest.spyOn(window, 'prompt');
720+
promptSpy.mockImplementation(jest.fn(() => 'New Category'));
721+
render(
722+
<CreateLabel
723+
optionText={choice =>
724+
`${choice.full_name} (${choice.language})`
725+
}
726+
/>
727+
);
728+
const input = (await screen.findByLabelText(
729+
'Category'
730+
)) as HTMLInputElement;
731+
fireEvent.mouseDown(input);
732+
// Expect the custom create label to be displayed
733+
fireEvent.click(await screen.findByText('Create a new category'));
734+
// Expect a prompt to have opened
735+
await waitFor(() => {
736+
expect(promptSpy).toHaveBeenCalled();
737+
});
738+
promptSpy.mockRestore();
739+
});
700740
});
701741

702742
it('should support creation of a new choice through the create element', async () => {

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

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313

1414
import { Create as RaCreate, Edit } from '../detail';
1515
import { SimpleForm } from '../form';
16-
import { SelectInput } from './SelectInput';
16+
import { SelectInput, SelectInputProps } from './SelectInput';
1717
import { TextInput } from './TextInput';
1818
import { ReferenceInput } from './ReferenceInput';
1919
import { SaveButton } from '../button/SaveButton';
@@ -321,31 +321,70 @@ export const OnCreate = () => {
321321
);
322322
};
323323

324-
export const CreateLabel = () => {
325-
const categories = [
326-
{ name: 'Tech', id: 'tech' },
327-
{ name: 'Lifestyle', id: 'lifestyle' },
324+
export const CreateLabel = ({
325+
optionText,
326+
}: Pick<SelectInputProps, 'optionText'>) => {
327+
const categories: Partial<{
328+
id: string;
329+
name: string;
330+
full_name: string;
331+
language: string;
332+
}>[] = [
333+
{ id: 'tech', name: 'Tech', full_name: 'Tech', language: 'en' },
334+
{
335+
id: 'lifestyle',
336+
name: 'Lifestyle',
337+
full_name: 'Lifestyle',
338+
language: 'en',
339+
},
328340
];
329341
return (
330342
<Wrapper name="category">
331343
<SelectInput
332344
onCreate={() => {
333345
const newCategoryName = prompt('Enter a new category');
334346
if (!newCategoryName) return;
335-
const newCategory = {
347+
const newCategory: Partial<{
348+
id: string;
349+
name: string;
350+
full_name: string;
351+
language: string;
352+
}> = {
336353
id: newCategoryName.toLowerCase(),
337-
name: newCategoryName,
338354
};
355+
if (optionText == null) {
356+
newCategory.name = newCategoryName;
357+
} else if (typeof optionText === 'string') {
358+
newCategory[optionText] = newCategoryName;
359+
} else {
360+
newCategory.full_name = newCategoryName;
361+
newCategory.language = 'fr';
362+
}
339363
categories.push(newCategory);
340364
return newCategory;
341365
}}
342366
source="category"
343367
choices={categories}
344368
createLabel="Create a new category"
369+
optionText={optionText}
345370
/>
346371
</Wrapper>
347372
);
348373
};
374+
CreateLabel.args = {
375+
optionText: undefined,
376+
};
377+
CreateLabel.argTypes = {
378+
optionText: {
379+
options: ['default', 'string', 'function'],
380+
mapping: {
381+
default: undefined,
382+
string: 'full_name',
383+
function: choice => `${choice.full_name} (${choice.language})`,
384+
},
385+
control: { type: 'inline-radio' },
386+
},
387+
};
349388

350389
const i18nProvider = polyglotI18nProvider(() => englishMessages);
351390

0 commit comments

Comments
 (0)