From 6e145a4690c22dddc1b423e5e471fefff9729d3f Mon Sep 17 00:00:00 2001 From: louzhedong Date: Sun, 7 Jun 2026 23:07:02 +0800 Subject: [PATCH 1/2] feat: support optionValue in reference inputs --- .../useReferenceInputController.spec.tsx | 85 +++++++++++++++ .../input/useReferenceInputController.ts | 103 +++++++++++++++--- 2 files changed, 175 insertions(+), 13 deletions(-) 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( + +
+ + {children} + +
+
+ ); + + 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; From ffc0e5b70d079c4d8f99628edd5f4b70370ef89a Mon Sep 17 00:00:00 2001 From: louzhedong Date: Mon, 8 Jun 2026 09:13:53 +0800 Subject: [PATCH 2/2] fix: preserve dates in optimistic updates --- .../src/dataProvider/useUpdate.spec.tsx | 84 +++++++++++++++++++ .../ra-core/src/dataProvider/useUpdate.ts | 14 ++-- .../ra-core/src/dataProvider/useUpdateMany.ts | 6 +- packages/ra-core/src/util/index.ts | 2 + .../ra-core/src/util/removeUndefined.spec.ts | 32 +++++++ packages/ra-core/src/util/removeUndefined.ts | 22 +++++ 6 files changed, 147 insertions(+), 13 deletions(-) create mode 100644 packages/ra-core/src/util/removeUndefined.spec.ts create mode 100644 packages/ra-core/src/util/removeUndefined.ts 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;