diff --git a/packages/ra-core/src/controller/field/useReferenceArrayFieldController.ts b/packages/ra-core/src/controller/field/useReferenceArrayFieldController.ts index ad421915a84..4ea74ebaa56 100644 --- a/packages/ra-core/src/controller/field/useReferenceArrayFieldController.ts +++ b/packages/ra-core/src/controller/field/useReferenceArrayFieldController.ts @@ -60,7 +60,7 @@ export const useReferenceArrayFieldController = ( const ids = useMemo(() => { if (Array.isArray(value)) return value; - console.warn(`Value of field '${source}' is not an array.`); + console.warn(`Value of field '${source}' is not an array.`, value); return emptyArray; }, [value, source]); diff --git a/packages/ra-ui-materialui/src/field/ReferenceArrayField.spec.tsx b/packages/ra-ui-materialui/src/field/ReferenceArrayField.spec.tsx index 5341c3786bd..6b3121cdefa 100644 --- a/packages/ra-ui-materialui/src/field/ReferenceArrayField.spec.tsx +++ b/packages/ra-ui-materialui/src/field/ReferenceArrayField.spec.tsx @@ -18,6 +18,7 @@ import { import { TextField } from './TextField'; import { SingleFieldList } from '../list'; import { AdminContext } from '../AdminContext'; +import { DifferentIdTypes } from './ReferenceArrayField.stories'; const theme = createTheme({}); @@ -78,7 +79,7 @@ describe('', () => { ); expect(queryAllByRole('progressbar')).toHaveLength(0); - expect(container.firstChild.textContent).not.toBeUndefined(); + expect(container.firstChild?.textContent).not.toBeUndefined(); expect(getByText('hello')).not.toBeNull(); expect(getByText('world')).not.toBeNull(); }); @@ -106,7 +107,7 @@ describe('', () => { ); expect(queryAllByRole('progressbar')).toHaveLength(0); - expect(container.firstChild.textContent).toBe(''); + expect(container.firstChild?.textContent).toBe(''); }); it('should support record with string identifier', () => { @@ -138,7 +139,7 @@ describe('', () => { ); expect(queryAllByRole('progressbar')).toHaveLength(0); - expect(container.firstChild.textContent).not.toBeUndefined(); + expect(container.firstChild?.textContent).not.toBeUndefined(); expect(getByText('hello')).not.toBeNull(); expect(getByText('world')).not.toBeNull(); }); @@ -301,4 +302,12 @@ describe('', () => { await screen.findByText('tag:management'); await screen.findByText('tag:design'); }); + + it('should handle IDs of different types', async () => { + render(); + + expect(await screen.findByText('artist_1')).not.toBeNull(); + expect(await screen.findByText('artist_2')).not.toBeNull(); + expect(await screen.findByText('artist_3')).not.toBeNull(); + }); }); diff --git a/packages/ra-ui-materialui/src/field/ReferenceArrayField.stories.tsx b/packages/ra-ui-materialui/src/field/ReferenceArrayField.stories.tsx new file mode 100644 index 00000000000..4dff459efd3 --- /dev/null +++ b/packages/ra-ui-materialui/src/field/ReferenceArrayField.stories.tsx @@ -0,0 +1,43 @@ +import * as React from 'react'; +import fakeRestProvider from 'ra-data-fakerest'; + +import { AdminContext } from '../AdminContext'; +import { Datagrid } from '../list'; +import { ReferenceArrayField } from './ReferenceArrayField'; +import { TextField } from './TextField'; +import { Show } from '../detail'; +import { CardContent } from '@mui/material'; + +const fakeData = { + bands: [{ id: 1, name: 'band_1', members: [1, '2', '3'] }], + artists: [ + { id: 1, name: 'artist_1' }, + { id: 2, name: 'artist_2' }, + { id: 3, name: 'artist_3' }, + { id: 4, name: 'artist_4' }, + ], +}; + +export default { title: 'ra-ui-materialui/fields/ReferenceArrayField' }; + +export const DifferentIdTypes = () => { + return ( + + + + + + + + + + + + + + ); +}; diff --git a/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx b/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx index 86f9199b2b2..64c312977e3 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/AutocompleteInput.spec.tsx @@ -17,6 +17,7 @@ import { InsideReferenceInput, InsideReferenceInputDefaultValue, Nullable, + NullishValuesSupport, VeryLargeOptionsNumber, } from './AutocompleteInput.stories'; import { act } from '@testing-library/react-hooks'; @@ -1477,4 +1478,24 @@ describe('', () => { expect(input.value).toEqual(''); }); }); + + it('should handle nullish values', async () => { + render(); + + const checkInputValue = async (label: string, expected: any) => { + const input = (await screen.findByLabelText( + label + )) as HTMLInputElement; + await waitFor(() => { + expect(input.value).toStrictEqual(expected); + }); + }; + + await checkInputValue('prefers_empty-string', ''); + await checkInputValue('prefers_null', ''); + await checkInputValue('prefers_undefined', ''); + await checkInputValue('prefers_zero-string', '0'); + await checkInputValue('prefers_zero-number', '0'); + await checkInputValue('prefers_valid-value', '1'); + }); }); diff --git a/packages/ra-ui-materialui/src/input/AutocompleteInput.stories.tsx b/packages/ra-ui-materialui/src/input/AutocompleteInput.stories.tsx index d26e7e0a959..4c509e26a62 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/AutocompleteInput.stories.tsx @@ -1,16 +1,26 @@ import * as React from 'react'; import { Admin, AdminContext } from 'react-admin'; -import { Resource, required, useCreate, useRecordContext } from 'ra-core'; +import { + Resource, + required, + useCreate, + useRecordContext, + ListBase, + useListContext, + RecordContextProvider, +} from 'ra-core'; import { createMemoryHistory } from 'history'; import { Dialog, DialogContent, - TextField, DialogActions, Button, Stack, + TextField, Typography, + Box, } from '@mui/material'; +import fakeRestProvider from 'ra-data-fakerest'; import { Edit } from '../detail'; import { SimpleForm } from '../form'; @@ -791,3 +801,77 @@ export const EmptyText = () => ( ); + +const nullishValuesFakeData = { + fans: [ + { id: 'null', name: 'null', prefers: null }, + { id: 'undefined', name: 'undefined', prefers: undefined }, + { id: 'empty-string', name: 'empty string', prefers: '' }, + { id: 'zero-string', name: '0', prefers: 0 }, + { id: 'zero-number', name: '0', prefers: '0' }, + { id: 'valid-value', name: '1', prefers: 1 }, + ], + artists: [{ id: 0 }, { id: 1 }], +}; + +const FanList = props => { + const { data } = useListContext(); + return data ? ( + <> + {data.map(fan => ( + + + + + Fan #{fan.id} +
+ {`${ + fan.name + } [${typeof fan.prefers}]`} +
+
+ + }> + option.id} + choices={nullishValuesFakeData.artists} + helperText={false} + /> + + +
+
+ ))} + + ) : ( + <>Loading + ); +}; + +export const NullishValuesSupport = () => { + return ( + + + Test nullish values + + + Story demonstrating nullish values support: each fan specify a + preferred artist. The prefer value is evaluated + against artist IDs. + + + + + + ); +}; diff --git a/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx b/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx index cf4b9d9ef4c..0d44165c802 100644 --- a/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx +++ b/packages/ra-ui-materialui/src/input/AutocompleteInput.tsx @@ -525,7 +525,7 @@ If you provided a React element for the optionText prop, you must also provide t ]); const isOptionEqualToValue = (option, value) => { - return getChoiceValue(option) === getChoiceValue(value); + return String(getChoiceValue(option)) === String(getChoiceValue(value)); }; return ( @@ -740,11 +740,17 @@ const getSelectedItems = ( if (multiple) { return (value || []) .map(item => - choices.find(choice => item === get(choice, optionValue)) + choices.find( + choice => String(item) === String(get(choice, optionValue)) + ) ) .filter(item => !!item); } - return choices.find(choice => get(choice, optionValue) === value) || ''; + return ( + choices.find( + choice => String(get(choice, optionValue)) === String(value) + ) || '' + ); }; const DefaultFilterToQuery = searchText => ({ q: searchText }); diff --git a/packages/ra-ui-materialui/src/input/ReferenceArrayInput.spec.tsx b/packages/ra-ui-materialui/src/input/ReferenceArrayInput.spec.tsx index 8283158f9c5..6ea2d3db8e3 100644 --- a/packages/ra-ui-materialui/src/input/ReferenceArrayInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/ReferenceArrayInput.spec.tsx @@ -21,6 +21,7 @@ import { DatagridInput } from './DatagridInput'; import { TextField } from '../field'; import { ReferenceArrayInput } from './ReferenceArrayInput'; import { SelectArrayInput } from './SelectArrayInput'; +import { DifferentIdTypes } from './ReferenceArrayInput.stories'; describe('', () => { const defaultProps = { @@ -85,7 +86,10 @@ describe('', () => { const dataProvider = testDataProvider({ getList: () => // @ts-ignore - Promise.resolve({ data: [{ id: 1 }, { id: 2 }], total: 2 }), + Promise.resolve({ + data: [{ id: 1 }, { id: 2 }], + total: 2, + }), }); render( @@ -244,4 +248,44 @@ describe('', () => { }); }); }); + + it('should support different types of ids', async () => { + render(); + await screen.findByText('#1', { + selector: 'div.MuiChip-root .MuiChip-label', + }); + expect( + screen.queryByText('#2', { + selector: 'div.MuiChip-root .MuiChip-label', + }) + ).not.toBeNull(); + expect( + screen.queryByText('#3', { selector: 'div.MuiChip-root' }) + ).toBeNull(); + }); + + it('should unselect a value when types of ids are different', async () => { + render(); + + const chip1 = await screen.findByText('#1', { + selector: '.MuiChip-label', + }); + const chip2 = await screen.findByText('#2', { + selector: '.MuiChip-label', + }); + + if (chip2.nextSibling) fireEvent.click(chip2.nextSibling); + expect( + screen.queryByText('#2', { + selector: '.MuiChip-label', + }) + ).toBeNull(); + + if (chip1.nextSibling) fireEvent.click(chip1.nextSibling); + expect( + screen.queryByText('#1', { + selector: '.MuiChip-label', + }) + ).toBeNull(); + }); }); diff --git a/packages/ra-ui-materialui/src/input/ReferenceArrayInput.stories.tsx b/packages/ra-ui-materialui/src/input/ReferenceArrayInput.stories.tsx index ae2bef825d0..e080109fed7 100644 --- a/packages/ra-ui-materialui/src/input/ReferenceArrayInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/ReferenceArrayInput.stories.tsx @@ -1,14 +1,15 @@ import * as React from 'react'; import { createMemoryHistory } from 'history'; -import { Form, testDataProvider } from 'ra-core'; +import { DataProvider, Form, testDataProvider } from 'ra-core'; import polyglotI18nProvider from 'ra-i18n-polyglot'; import englishMessages from 'ra-language-english'; import { Admin, Resource } from 'react-admin'; +import fakeRestProvider from 'ra-data-fakerest'; import { AdminContext } from '../AdminContext'; -import { Create } from '../detail'; +import { Create, Edit } from '../detail'; import { SimpleForm } from '../form'; -import { DatagridInput } from '../input'; +import { DatagridInput, TextInput } from '../input'; import { TextField } from '../field'; import { ReferenceArrayInput } from './ReferenceArrayInput'; import { AutocompleteArrayInput } from './AutocompleteArrayInput'; @@ -85,11 +86,13 @@ export const WithAutocompleteInput = () => ( export const ErrorAutocomplete = () => ( Promise.reject(new Error('fetch error')), - getMany: () => - Promise.resolve({ data: [{ id: 5, name: 'test1' }] }), - }} + dataProvider={ + ({ + getList: () => Promise.reject(new Error('fetch error')), + getMany: () => + Promise.resolve({ data: [{ id: 5, name: 'test1' }] }), + } as unknown) as DataProvider + } i18nProvider={i18nProvider} >
{}} defaultValues={{ tag_ids: [1, 3] }}> @@ -120,11 +123,13 @@ export const WithSelectArrayInput = () => ( export const ErrorSelectArray = () => ( Promise.reject(new Error('fetch error')), - getMany: () => - Promise.resolve({ data: [{ id: 5, name: 'test1' }] }), - }} + dataProvider={ + ({ + getList: () => Promise.reject(new Error('fetch error')), + getMany: () => + Promise.resolve({ data: [{ id: 5, name: 'test1' }] }), + } as unknown) as DataProvider + } i18nProvider={i18nProvider} > {}} defaultValues={{ tag_ids: [1, 3] }}> @@ -155,11 +160,13 @@ export const WithCheckboxGroupInput = () => ( export const ErrorCheckboxGroupInput = () => ( Promise.reject(new Error('fetch error')), - getMany: () => - Promise.resolve({ data: [{ id: 5, name: 'test1' }] }), - }} + dataProvider={ + ({ + getList: () => Promise.reject(new Error('fetch error')), + getMany: () => + Promise.resolve({ data: [{ id: 5, name: 'test1' }] }), + } as unknown) as DataProvider + } i18nProvider={i18nProvider} > {}} defaultValues={{ tag_ids: [1, 3] }}> @@ -192,11 +199,15 @@ export const WithDatagridInput = () => ( export const ErrorDatagridInput = () => ( Promise.reject(new Error('fetch error')), - getMany: () => - Promise.resolve({ data: [{ id: 5, name: 'test1' }] }), - }} + dataProvider={ + ({ + getList: () => Promise.reject(new Error('fetch error')), + getMany: () => + Promise.resolve({ + data: [{ id: 5, name: 'test1' }], + }), + } as unknown) as DataProvider + } i18nProvider={i18nProvider} > {}} defaultValues={{ tag_ids: [1, 3] }}> @@ -212,3 +223,24 @@ export const ErrorDatagridInput = () => ( ); + +export const DifferentIdTypes = () => { + const fakeData = { + bands: [{ id: 1, name: 'band_1', members: [1, '2'] }], + artists: [ + { id: 1, name: 'artist_1' }, + { id: 2, name: 'artist_2' }, + { id: 3, name: 'artist_3' }, + ], + }; + return ( + + + + + + + + + ); +}; diff --git a/packages/ra-ui-materialui/src/input/SelectArrayInput.spec.tsx b/packages/ra-ui-materialui/src/input/SelectArrayInput.spec.tsx index 19edda1e536..33582d1394e 100644 --- a/packages/ra-ui-materialui/src/input/SelectArrayInput.spec.tsx +++ b/packages/ra-ui-materialui/src/input/SelectArrayInput.spec.tsx @@ -11,6 +11,7 @@ import { AdminContext } from '../AdminContext'; import { SimpleForm } from '../form'; import { SelectArrayInput } from './SelectArrayInput'; import { useCreateSuggestionContext } from './useSupportCreateSuggestion'; +import { DifferentIdTypes } from './SelectArrayInput.stories'; describe('', () => { const defaultProps = { @@ -575,4 +576,36 @@ describe('', () => { expect(onChange).toHaveBeenCalledWith(['js_fatigue']); }); }); + + it('should show selected values when ids type are inconsistant', async () => { + render(); + await waitFor(() => { + expect(screen.queryByText('artist_1')).not.toBeNull(); + }); + expect(screen.queryByText('artist_2')).not.toBeNull(); + expect(screen.queryByText('artist_3')).toBeNull(); + }); + + it('should unselect values when ids type are different', async () => { + render(); + + expect( + await screen.findByText('resources.bands.fields.members') + ).not.toBeNull(); + + fireEvent.mouseDown( + screen.getByLabelText('resources.bands.fields.members') + ); + + const option = await screen.findByText('artist_2', { + selector: '.MuiMenuItem-root', + }); + fireEvent.click(option); + + expect( + screen.queryByText('artist_2', { + selector: '.MuiChip-label', + }) + ).toBeNull(); + }); }); diff --git a/packages/ra-ui-materialui/src/input/SelectArrayInput.stories.tsx b/packages/ra-ui-materialui/src/input/SelectArrayInput.stories.tsx index 2000d39739b..803afef6431 100644 --- a/packages/ra-ui-materialui/src/input/SelectArrayInput.stories.tsx +++ b/packages/ra-ui-materialui/src/input/SelectArrayInput.stories.tsx @@ -8,12 +8,14 @@ import { DialogContent, TextField, } from '@mui/material'; +import fakeRestProvider from 'ra-data-fakerest'; import { AdminContext } from '../AdminContext'; -import { Create } from '../detail'; +import { Create, Edit } from '../detail'; import { SimpleForm } from '../form'; import { SelectArrayInput } from './SelectArrayInput'; import { useCreateSuggestionContext } from './useSupportCreateSuggestion'; +import { TextInput } from './TextInput'; export default { title: 'ra-ui-materialui/input/SelectArrayInput' }; @@ -99,3 +101,29 @@ export const CreateProp = () => ( ); + +export const DifferentIdTypes = () => { + const fakeData = { + bands: [{ id: 1, name: 'band_1', members: [1, '2'] }], + artists: [ + { id: 1, name: 'artist_1' }, + { id: 2, name: 'artist_2' }, + { id: 3, name: 'artist_3' }, + ], + }; + const dataProvider = fakeRestProvider(fakeData, false); + return ( + + + + + + + + + ); +}; diff --git a/packages/ra-ui-materialui/src/input/SelectArrayInput.tsx b/packages/ra-ui-materialui/src/input/SelectArrayInput.tsx index 97184982c31..f7ed263f3ad 100644 --- a/packages/ra-ui-materialui/src/input/SelectArrayInput.tsx +++ b/packages/ra-ui-materialui/src/input/SelectArrayInput.tsx @@ -159,6 +159,20 @@ export const SelectArrayInput = (props: SelectArrayInputProps) => { // We might receive an event from the mui component // In this case, it will be the choice id if (eventOrChoice?.target) { + // when used with different IDs types, unselection leads to double selection with both types + // instead of the value being removed from the array + // e.g. we receive eventOrChoice.target.value = [1, '2', 2] instead of [1] after removing 2 + // this snippet removes a value if it is present twice + eventOrChoice.target.value = eventOrChoice.target.value.reduce( + (acc, value) => { + // eslint-disable-next-line eqeqeq + const index = acc.findIndex(v => v == value); + return index < 0 + ? [...acc, value] + : [...acc.slice(0, index), ...acc.slice(index + 1)]; + }, + [] + ); field.onChange(eventOrChoice); } else { // Or we might receive a choice directly, for instance a newly created one @@ -262,8 +276,8 @@ export const SelectArrayInput = (props: SelectArrayInputProps) => { {selected .map(item => (allChoices || []).find( - choice => - getChoiceValue(choice) === item + // eslint-disable-next-line eqeqeq + choice => getChoiceValue(choice) == item ) ) .filter(item => !!item)