Fix batch update optimistic and prevent accidental mass-update (#17213)
## Problem - ⚠️ Multi-edit could silently update ALL records of an object with no undo, no ctrl-z - After a batch update the table showed stale data — `useIncrementalUpdateManyRecords` had no explicit `findMany` refetch and `skipOptimisticEffect` was missing - Clicking inside the side panel deselected all kanban cards — `RecordBoardClickOutsideEffect` uses `refs:[]` and the side panel had no `data-click-outside-id` - Clicking a currency/select dropdown inside the panel also triggered deselection — `FloatingPortal` renders outside the side panel DOM, bypassing the click-outside-id exclusion - An empty selection silently matched every record — `computeContextStoreFilters` returned `undefined` filter when `selectedRecordIds` was `[]` ## Fix - Table refreshes correctly after batch update — `useRefetchFindManyRecords` explicitly refetches `FindMany<Object>` queries; `useIncrementalUpdateManyRecords` adds `skipOptimisticEffect: true` and calls it in `finally` - Clicking the side panel no longer deselects kanban cards — `SidePanelForDesktop` carries `data-click-outside-id`; `RecordBoardClickOutsideEffect` + `RecordTableBodyFocusClickOutsideEffect` exclude it - Clicking dropdowns inside the panel no longer deselects either — `ParentClickOutsideIdContext` propagates the side panel ID into `FloatingPortal` content via `DropdownInternalContainer` - Empty selection can no longer match all records — `computeContextStoreFilters` returns `{ id: { in: [] } }` instead of `undefined` - Apply is disabled with no selection; a confirmation modal shows the count + no-undo warning before executing — `UpdateMultipleRecordsContainer` ## Not included - Undo / snapshot restore — requires backend changes, out of scope ## Blast radius - `ParentClickOutsideIdContext` touches `DropdownInternalContainer` (207 `<Dropdown>` usages). `parentClickOutsideId` is `undefined` everywhere outside the side panel → attribute not rendered → zero behavioral change for existing consumers. --------- Co-authored-by: Samuel Arbibe <samuelarbibe@Samuels-MacBook-Pro.local> Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com> Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
+15
@@ -1,6 +1,7 @@
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { useIncrementalFetchAndMutateRecords } from '@/object-record/hooks/useIncrementalFetchAndMutateRecords';
|
||||
import { useIncrementalUpdateManyRecords } from '@/object-record/hooks/useIncrementalUpdateManyRecords';
|
||||
import { useRefetchFindManyRecords } from '@/object-record/hooks/useRefetchFindManyRecords';
|
||||
import { useUpdateManyRecords } from '@/object-record/hooks/useUpdateManyRecords';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
@@ -15,6 +16,9 @@ jest.mock('@/object-record/hooks/useRefetchAggregateQueries', () => ({
|
||||
refetchAggregateQueries: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
jest.mock('@/object-record/hooks/useRefetchFindManyRecords', () => ({
|
||||
useRefetchFindManyRecords: jest.fn(),
|
||||
}));
|
||||
jest.mock('@/object-record/hooks/useIncrementalFetchAndMutateRecords');
|
||||
|
||||
const mockUseObjectMetadataItem = jest.mocked(useObjectMetadataItem);
|
||||
@@ -25,6 +29,7 @@ const mockUseUpdateManyRecords = jest.mocked(useUpdateManyRecords);
|
||||
const mockUseIncrementalFetchAndMutateRecords = jest.mocked(
|
||||
useIncrementalFetchAndMutateRecords,
|
||||
);
|
||||
const mockUseRefetchFindManyRecords = jest.mocked(useRefetchFindManyRecords);
|
||||
|
||||
describe('useIncrementalUpdateManyRecords', () => {
|
||||
const mockUpdateManyRecords = jest.fn();
|
||||
@@ -53,6 +58,11 @@ describe('useIncrementalUpdateManyRecords', () => {
|
||||
updateProgress: mockUpdateProgress,
|
||||
cancel: jest.fn(),
|
||||
});
|
||||
|
||||
const mockRefetchFindManyRecords = jest.fn();
|
||||
mockUseRefetchFindManyRecords.mockReturnValue({
|
||||
refetchFindManyRecords: mockRefetchFindManyRecords,
|
||||
});
|
||||
});
|
||||
|
||||
it('should call incrementalFetchAndMutate and execute mutations via useUpdateManyRecords', async () => {
|
||||
@@ -81,6 +91,7 @@ describe('useIncrementalUpdateManyRecords', () => {
|
||||
delayInMsBetweenRequests: 50,
|
||||
skipRegisterObjectOperation: true,
|
||||
skipRefetchAggregateQueries: true,
|
||||
skipOptimisticEffect: true,
|
||||
abortSignal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(mockUpdateProgress).toHaveBeenCalledWith(2, 2);
|
||||
@@ -102,6 +113,10 @@ describe('useIncrementalUpdateManyRecords', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { refetchFindManyRecords } =
|
||||
mockUseRefetchFindManyRecords.mock.results[0].value;
|
||||
expect(refetchFindManyRecords).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should pass abortSignal to updateManyRecords', async () => {
|
||||
|
||||
+8
-1
@@ -1,11 +1,12 @@
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { DEFAULT_QUERY_PAGE_SIZE } from '@/object-record/constants/DefaultQueryPageSize';
|
||||
import { type UseFindManyRecordsParams } from '@/object-record/hooks/useFetchMoreRecordsWithPagination';
|
||||
import { useIncrementalFetchAndMutateRecords } from '@/object-record/hooks/useIncrementalFetchAndMutateRecords';
|
||||
import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
|
||||
import { useRefetchFindManyRecords } from '@/object-record/hooks/useRefetchFindManyRecords';
|
||||
import { useUpdateManyRecords } from '@/object-record/hooks/useUpdateManyRecords';
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
import { dispatchObjectRecordOperationBrowserEvent } from '@/browser-event/utils/dispatchObjectRecordOperationBrowserEvent';
|
||||
import { getUpdatedFieldsFromRecordInput } from '@/object-record/utils/getUpdatedFieldsFromRecordInput';
|
||||
|
||||
const DEFAULT_DELAY_BETWEEN_MUTATIONS_MS = 50;
|
||||
@@ -39,6 +40,10 @@ export const useIncrementalUpdateManyRecords = <
|
||||
|
||||
const { refetchAggregateQueries } = useRefetchAggregateQueries();
|
||||
|
||||
const { refetchFindManyRecords } = useRefetchFindManyRecords({
|
||||
objectMetadataNamePlural: objectMetadataItem.namePlural,
|
||||
});
|
||||
|
||||
const {
|
||||
incrementalFetchAndMutate,
|
||||
progress,
|
||||
@@ -68,6 +73,7 @@ export const useIncrementalUpdateManyRecords = <
|
||||
delayInMsBetweenRequests: delayInMsBetweenMutations,
|
||||
skipRegisterObjectOperation: true,
|
||||
skipRefetchAggregateQueries: true,
|
||||
skipOptimisticEffect: true,
|
||||
abortSignal,
|
||||
});
|
||||
|
||||
@@ -78,6 +84,7 @@ export const useIncrementalUpdateManyRecords = <
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
await refetchFindManyRecords();
|
||||
await refetchAggregateQueries({
|
||||
objectMetadataNamePlural: objectMetadataItem.namePlural,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
|
||||
import { capitalize } from 'twenty-shared/utils';
|
||||
|
||||
export const useRefetchFindManyRecords = ({
|
||||
objectMetadataNamePlural,
|
||||
}: {
|
||||
objectMetadataNamePlural: string;
|
||||
}) => {
|
||||
const apolloCoreClient = useApolloCoreClient();
|
||||
|
||||
const refetchFindManyRecords = async () => {
|
||||
const findManyRecordsQueryName = `FindMany${capitalize(
|
||||
objectMetadataNamePlural,
|
||||
)}`;
|
||||
|
||||
await apolloCoreClient.refetchQueries({
|
||||
include: [findManyRecordsQueryName],
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
refetchFindManyRecords,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user