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:
Samuel Arbibe
2026-03-24 18:14:30 +02:00
committed by GitHub
parent 49afe5dbc4
commit cec23e89fa
13 changed files with 137 additions and 28 deletions
@@ -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 () => {
@@ -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,
};
};