diff --git a/packages/ra-core/src/controller/input/useReferenceInputController.spec.tsx b/packages/ra-core/src/controller/input/useReferenceInputController.spec.tsx
index 693dec63695..bd50f575498 100644
--- a/packages/ra-core/src/controller/input/useReferenceInputController.spec.tsx
+++ b/packages/ra-core/src/controller/input/useReferenceInputController.spec.tsx
@@ -140,6 +140,91 @@ describe('useReferenceInputController', () => {
});
});
+ it('should fetch current value using getList when optionValue is not id', async () => {
+ const children = jest.fn().mockReturnValue(
child
);
+ const optionValue = 'login';
+ const currentUser = { id: 2, login: 'jdoe', name: 'Jane Doe' };
+ const dataProvider = testDataProvider({
+ getList: jest
+ .fn()
+ .mockResolvedValueOnce({
+ data: [{ id: 1, login: 'asmith', name: 'Alice Smith' }],
+ total: 1,
+ })
+ .mockResolvedValueOnce({
+ data: [currentUser],
+ total: 1,
+ }),
+ getMany: jest.fn(),
+ });
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(dataProvider.getList).toHaveBeenCalledTimes(2);
+ expect(dataProvider.getMany).not.toHaveBeenCalled();
+ });
+
+ expect(dataProvider.getList).toHaveBeenNthCalledWith(
+ 1,
+ 'users',
+ expect.objectContaining({
+ filter: {},
+ meta: undefined,
+ pagination: {
+ page: 1,
+ perPage: 25,
+ },
+ sort: {
+ field: 'id',
+ order: 'ASC',
+ },
+ signal: undefined,
+ })
+ );
+ expect(dataProvider.getList).toHaveBeenNthCalledWith(
+ 2,
+ 'users',
+ expect.objectContaining({
+ filter: { login: 'jdoe' },
+ pagination: {
+ page: 1,
+ perPage: 1,
+ },
+ sort: {
+ field: 'id',
+ order: 'DESC',
+ },
+ signal: undefined,
+ })
+ );
+
+ await waitFor(() => {
+ expect(children).toHaveBeenCalledWith(
+ expect.objectContaining({
+ allChoices: [currentUser, { id: 1, login: 'asmith', name: 'Alice Smith' }],
+ availableChoices: [{ id: 1, login: 'asmith', name: 'Alice Smith' }],
+ selectedChoices: [currentUser],
+ total: 2,
+ isFromReference: true,
+ })
+ );
+ });
+ });
+
it('should not fetch current value using getMany if it is empty', async () => {
const children = jest.fn().mockReturnValue(child
);
render(
diff --git a/packages/ra-core/src/controller/input/useReferenceInputController.ts b/packages/ra-core/src/controller/input/useReferenceInputController.ts
index 4e844f696eb..4d096796af8 100644
--- a/packages/ra-core/src/controller/input/useReferenceInputController.ts
+++ b/packages/ra-core/src/controller/input/useReferenceInputController.ts
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useWatch } from 'react-hook-form';
import { keepPreviousData, type UseQueryOptions } from '@tanstack/react-query';
+import get from 'lodash/get.js';
import { useGetList } from '../../dataProvider';
import { useReference } from '../useReference';
@@ -58,6 +59,7 @@ export const useReferenceInputController = (
filter,
page: initialPage = 1,
perPage: initialPerPage = 25,
+ optionValue = 'id',
sort: initialSort,
queryOptions = {},
reference,
@@ -77,6 +79,8 @@ export const useReferenceInputController = (
// selection logic
const finalSource = useWrappedSource(source);
const currentValue = useWatch({ name: finalSource });
+ const currentValueEnabled = currentValue != null && currentValue !== '';
+ const useOptionValueLookup = optionValue !== 'id';
const isGetMatchingEnabled = enableGetChoices
? enableGetChoices(params.filterValues)
@@ -116,7 +120,38 @@ export const useReferenceInputController = (
// fetch current value
const {
- referenceRecord: currentReferenceRecord,
+ data: currentReferenceRecords,
+ refetch: refetchCurrentReference,
+ error: errorCurrentReference,
+ isLoading: isLoadingCurrentReference,
+ isFetching: isFetchingCurrentReference,
+ isPaused: isPausedCurrentReference,
+ isPending: isPendingCurrentReference,
+ isPlaceholderData: isPlaceholderDataCurrentReference,
+ } = useGetList(
+ reference,
+ {
+ pagination: {
+ page: 1,
+ perPage: 1,
+ },
+ sort: { field: 'id', order: 'DESC' },
+ filter: useOptionValueLookup
+ ? { [optionValue]: currentValue }
+ : {},
+ meta,
+ },
+ {
+ enabled: useOptionValueLookup && currentValueEnabled,
+ placeholderData: keepPreviousData,
+ ...(otherQueryOptions as UseQueryOptions<
+ GetListResult
+ >),
+ }
+ );
+
+ const {
+ referenceRecord: currentReferenceRecordById,
refetch: refetchReference,
error: errorReference,
isLoading: isLoadingReference,
@@ -129,18 +164,28 @@ export const useReferenceInputController = (
reference,
// @ts-ignore the types of the queryOptions for the getMAny and getList are not compatible
options: {
- enabled: currentValue != null && currentValue !== '',
+ enabled: !useOptionValueLookup && currentValueEnabled,
meta,
...otherQueryOptions,
},
});
+ const currentReferenceRecord = useOptionValueLookup
+ ? currentReferenceRecords?.[0]
+ : currentReferenceRecordById;
+
const isPending =
// The reference query isn't enabled when there is no value yet but as it has no data, react-query will flag it as pending
- (currentValue != null && currentValue !== '' && isPendingReference) ||
+ (currentValueEnabled &&
+ (useOptionValueLookup
+ ? isPendingCurrentReference
+ : isPendingReference)) ||
isPendingPossibleValues;
- const isPaused = isPausedReference || isPausedPossibleValues;
+ const isPaused =
+ (useOptionValueLookup
+ ? isPausedCurrentReference
+ : isPausedReference) || isPausedPossibleValues;
// We need to delay the update of the referenceRecord and the finalData
// to the next React state update, because otherwise it can raise a warning
@@ -164,14 +209,17 @@ export const useReferenceInputController = (
referenceRecord == null ||
possibleValuesData == null ||
(possibleValuesData ?? []).find(
- record => record.id === referenceRecord.id
+ record =>
+ get(record, optionValue) === get(referenceRecord, optionValue)
)
) {
// Here we might have the referenceRecord but no data (because of enableGetChoices for instance)
const finalData = possibleValuesData ?? [];
if (
referenceRecord &&
- finalData.find(r => r.id === referenceRecord.id) == null
+ finalData.find(
+ r => get(r, optionValue) === get(referenceRecord, optionValue)
+ ) == null
) {
finalData.push(referenceRecord);
}
@@ -190,8 +238,17 @@ export const useReferenceInputController = (
const refetch = useCallback(() => {
refetchGetList();
- refetchReference();
- }, [refetchGetList, refetchReference]);
+ if (useOptionValueLookup) {
+ refetchCurrentReference();
+ } else {
+ refetchReference();
+ }
+ }, [
+ refetchCurrentReference,
+ refetchGetList,
+ refetchReference,
+ useOptionValueLookup,
+ ]);
const currentSort = useMemo(
() => ({
@@ -211,16 +268,27 @@ export const useReferenceInputController = (
// TODO v6: same as above
selectedChoices: referenceRecord ? [referenceRecord] : [],
displayedFilters: params.displayedFilters,
- error: errorReference || errorPossibleValues,
+ error:
+ (useOptionValueLookup
+ ? errorCurrentReference
+ : errorReference) || errorPossibleValues,
filter: params.filter,
filterValues: params.filterValues,
hideFilter: paramsModifiers.hideFilter,
- isFetching: isFetchingReference || isFetchingPossibleValues,
- isLoading: isLoadingReference || isLoadingPossibleValues,
- isPaused: isPausedReference || isPausedPossibleValues,
+ isFetching:
+ (useOptionValueLookup
+ ? isFetchingCurrentReference
+ : isFetchingReference) || isFetchingPossibleValues,
+ isLoading:
+ (useOptionValueLookup
+ ? isLoadingCurrentReference
+ : isLoadingReference) || isLoadingPossibleValues,
+ isPaused,
isPending,
isPlaceholderData:
- isPlaceholderDataReference ||
+ (useOptionValueLookup
+ ? isPlaceholderDataCurrentReference
+ : isPlaceholderDataReference) ||
isPlaceholderDataPossibleValues,
page: params.page,
perPage: params.perPage,
@@ -246,17 +314,23 @@ export const useReferenceInputController = (
}) as ChoicesContextValue,
[
currentSort,
+ errorCurrentReference,
errorPossibleValues,
errorReference,
finalData,
finalTotal,
+ isFetchingCurrentReference,
isFetchingPossibleValues,
isFetchingReference,
+ isLoadingCurrentReference,
isLoadingPossibleValues,
isLoadingReference,
+ isPausedCurrentReference,
isPausedPossibleValues,
isPausedReference,
isPending,
+ isPendingCurrentReference,
+ isPlaceholderDataCurrentReference,
isPlaceholderDataReference,
isPlaceholderDataPossibleValues,
pageInfo,
@@ -277,6 +351,8 @@ export const useReferenceInputController = (
refetch,
source,
total,
+ useOptionValueLookup,
+ optionValue,
]
);
};
@@ -296,6 +372,7 @@ export interface UseReferenceInputControllerParams<
> & { meta?: any };
page?: number;
perPage?: number;
+ optionValue?: string;
record?: RaRecord;
reference: string;
resource?: string;
diff --git a/packages/ra-core/src/dataProvider/useUpdate.spec.tsx b/packages/ra-core/src/dataProvider/useUpdate.spec.tsx
index c7cecbe5c15..c57223e750f 100644
--- a/packages/ra-core/src/dataProvider/useUpdate.spec.tsx
+++ b/packages/ra-core/src/dataProvider/useUpdate.spec.tsx
@@ -12,6 +12,7 @@ import { QueryClient, useMutationState } from '@tanstack/react-query';
import { CoreAdminContext } from '../core';
import { RaRecord } from '../types';
import { useUpdate } from './useUpdate';
+import { useGetOne } from './useGetOne';
import {
ErrorCase as ErrorCasePessimistic,
SuccessCase as SuccessCasePessimistic,
@@ -459,6 +460,89 @@ describe('useUpdate', () => {
screen.getByText('Update title').click();
await screen.findByText('{"id":1,"title":"world"}'); // and not just {"title":"world"}
});
+ it('when optimistic, preserves Date values in the cache', async () => {
+ const publishedAt = new Date('2024-02-03T00:00:00.000Z');
+ const nextPublishedAt = new Date('2024-03-04T00:00:00.000Z');
+ const queryClient = new QueryClient();
+ const dataProvider = {
+ getOne: async () => ({
+ data: {
+ id: 1,
+ title: 'Hello',
+ metadata: {
+ publishedAt,
+ },
+ },
+ }),
+ update: () => new Promise(() => {}),
+ } as any;
+ const Dummy = () => {
+ const { data } = useGetOne('posts', { id: 1 });
+ const [update] = useUpdate();
+ return (
+ <>
+
+ {data?.metadata?.publishedAt instanceof Date
+ ? 'date'
+ : 'not-date'}
+
+
+ >
+ );
+ };
+
+ render(
+
+
+
+ );
+
+ await screen.findByText('date');
+ screen.getByText('Update title').click();
+ await screen.findByText('date');
+ expect(
+ queryClient.getQueryData([
+ 'posts',
+ 'getOne',
+ { id: '1', meta: undefined },
+ ])
+ ).toMatchObject({
+ metadata: {
+ publishedAt: nextPublishedAt,
+ },
+ });
+ expect(
+ (
+ queryClient.getQueryData([
+ 'posts',
+ 'getOne',
+ { id: '1', meta: undefined },
+ ]) as any
+ ).metadata.publishedAt
+ ).toBeInstanceOf(Date);
+ });
it('when undoable, displays result and success side effects right away and fetched on confirm', async () => {
render();
await screen.findByText('Hello');
diff --git a/packages/ra-core/src/dataProvider/useUpdate.ts b/packages/ra-core/src/dataProvider/useUpdate.ts
index 197610ed1f1..11b6045d840 100644
--- a/packages/ra-core/src/dataProvider/useUpdate.ts
+++ b/packages/ra-core/src/dataProvider/useUpdate.ts
@@ -19,6 +19,7 @@ import type {
} from '../types';
import { useMutationWithMutationMode } from './useMutationWithMutationMode';
import { useEvent } from '../util';
+import removeUndefined from '../util/removeUndefined';
/**
* Get a callback to call the dataProvider.update() method, the result and the loading state.
@@ -149,14 +150,9 @@ export const useUpdate = (
const now = Date.now();
const updatedAt =
mutationMode === 'undoable' ? now + 5 * 1000 : now;
- // Stringify and parse the data to remove undefined values.
- // If we don't do this, an update with { id: undefined } as payload
- // would remove the id from the record, which no real data provider does.
- const clonedData = JSON.parse(
- JSON.stringify(
- mutationMode === 'pessimistic' ? result : params?.data
- )
- );
+ const clonedData = removeUndefined(
+ mutationMode === 'pessimistic' ? result : params?.data
+ ) as Partial;
const updateColl = (old: RecordType[]) => {
if (!old) return old;
@@ -240,7 +236,7 @@ export const useUpdate = (
const optimisticResult = {
...previousRecord,
...clonedData,
- };
+ } as RecordType;
return optimisticResult;
},
getQueryKeys: ({ resource, ...params }) => {
diff --git a/packages/ra-core/src/dataProvider/useUpdateMany.ts b/packages/ra-core/src/dataProvider/useUpdateMany.ts
index 842ed1729c3..4e66ff3111f 100644
--- a/packages/ra-core/src/dataProvider/useUpdateMany.ts
+++ b/packages/ra-core/src/dataProvider/useUpdateMany.ts
@@ -19,6 +19,7 @@ import type {
} from '../types';
import { useMutationWithMutationMode } from './useMutationWithMutationMode';
import { useEvent } from '../util';
+import removeUndefined from '../util/removeUndefined';
/**
* Get a callback to call the dataProvider.updateMany() method, the result and the loading state.
@@ -142,11 +143,8 @@ export const useUpdateMany = <
mutationMode === 'undoable'
? Date.now() + 1000 * 5
: Date.now();
- // Stringify and parse the data to remove undefined values.
- // If we don't do this, an update with { id: undefined } as payload
- // would remove the id from the record, which no real data provider does.
const clonedData = params?.data
- ? JSON.parse(JSON.stringify(params?.data))
+ ? removeUndefined(params.data)
: undefined;
const updateColl = (old: RecordType[]) => {
diff --git a/packages/ra-core/src/util/index.ts b/packages/ra-core/src/util/index.ts
index 0ea9d5fc836..3ddc6f22dc7 100644
--- a/packages/ra-core/src/util/index.ts
+++ b/packages/ra-core/src/util/index.ts
@@ -2,6 +2,7 @@ import escapePath from './escapePath';
import FieldTitle, { FieldTitleProps } from './FieldTitle';
import removeEmpty from './removeEmpty';
import removeKey from './removeKey';
+import removeUndefined from './removeUndefined';
import Ready from './Ready';
import warning from './warning';
import useWhyDidYouUpdate from './useWhyDidYouUpdate';
@@ -18,6 +19,7 @@ export {
Ready,
removeEmpty,
removeKey,
+ removeUndefined,
warning,
useWhyDidYouUpdate,
getMutationMode,
diff --git a/packages/ra-core/src/util/removeUndefined.spec.ts b/packages/ra-core/src/util/removeUndefined.spec.ts
new file mode 100644
index 00000000000..76d902f1bfa
--- /dev/null
+++ b/packages/ra-core/src/util/removeUndefined.spec.ts
@@ -0,0 +1,32 @@
+import expect from 'expect';
+
+import removeUndefined from './removeUndefined';
+
+describe('removeUndefined', () => {
+ it('removes undefined values without changing Date instances', () => {
+ const date = new Date('2024-01-01T00:00:00.000Z');
+
+ const result = removeUndefined({
+ id: undefined,
+ title: 'Hello',
+ publishedAt: date,
+ metadata: {
+ archivedAt: undefined,
+ updatedAt: date,
+ },
+ tags: [undefined, { createdAt: date }],
+ });
+
+ expect(result).toEqual({
+ title: 'Hello',
+ publishedAt: date,
+ metadata: {
+ updatedAt: date,
+ },
+ tags: [undefined, { createdAt: date }],
+ });
+ expect(result.publishedAt).toBeInstanceOf(Date);
+ expect(result.metadata.updatedAt).toBeInstanceOf(Date);
+ expect(result.tags[1].createdAt).toBeInstanceOf(Date);
+ });
+});
diff --git a/packages/ra-core/src/util/removeUndefined.ts b/packages/ra-core/src/util/removeUndefined.ts
new file mode 100644
index 00000000000..a4e34d4b64b
--- /dev/null
+++ b/packages/ra-core/src/util/removeUndefined.ts
@@ -0,0 +1,22 @@
+const isPlainObject = (value: unknown): value is Record =>
+ Object.prototype.toString.call(value) === '[object Object]';
+
+const removeUndefined = (value: T): T => {
+ if (Array.isArray(value)) {
+ return value.map(item => removeUndefined(item)) as T;
+ }
+
+ if (!isPlainObject(value)) {
+ return value;
+ }
+
+ return Object.entries(value).reduce((acc, [key, child]) => {
+ if (child === undefined) {
+ return acc;
+ }
+
+ return Object.assign(acc, { [key]: removeUndefined(child) });
+ }, {} as Record) as T;
+};
+
+export default removeUndefined;