From 965ff4337f1a53a8575fd0d936f93832da0e8aea Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Sun, 14 Jun 2026 21:11:46 +0200 Subject: [PATCH] fix(front): mass update targets explicitly selected records (#21548) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Mass update silently does nothing for explicitly-selected records in a filtered view (reported in [quality-feedbacks](https://discord.com/channels/1130383047699738754/1515444299934728213) — "Mass update does not work (for boolean?)", high severity). When you select specific records (selection mode) and run **Update records**, the action built its target filter with `computeContextStoreFilters`, which intersects the selected record ids with the **current view filters**: ```ts // selection mode (before) queryFilter = makeAndFilterVariables([ anyFieldFilter, { id: { in: selectedRecordIds } }, computeRecordGqlOperationFilter({ ...view filters... }), // ← intersect with view ]); ``` `useIncrementalUpdateManyRecords` first **fetches** the ids matching that filter, then updates them: ```ts if (firstPageRecordIds.length > 0) { await mutateRecordsBatch(...); // never runs when the fetch returns 0 } ``` So any selected record that doesn't match the view filter is silently dropped. When *none* of the selected records match, the fetch returns 0 → the `updateMany` mutation never fires → only the trailing refetch/aggregate queries run, and nothing changes. The confirmation modal still says "Update N records" (it reads `selectedRecordIds.length`), and the side-panel header shows "0 selected" (it reads the find result) — the exact symptoms in the report. This is especially easy to hit when updating the very field a view is filtered on (e.g. a view filtered "QA Done = false" and you set "QA Done = true" on the selected rows). ## Fix In **selection mode**, target exactly the selected ids — the user picked those records, so the action must act on them regardless of the active view filter / any-field search: ```ts // selection mode (after) return { id: { in: contextStoreTargetedRecordsRule.selectedRecordIds } }; ``` Exclusion mode (select-all) is unchanged — it still needs the view filter to define "all matching except N". This also makes the side-panel "N selected" count and other selection-mode actions (delete, export, …) consistent with the explicit selection. ## Reproduction (deterministic) 1. Companies view filtered **QA Done = false**. 2. Select 3 rows individually. 3. Change those rows so they no longer match the filter (set `qaDone = true`) without refetching the view — they stay selected. 4. Open **Update**, set **Employees = 100**, confirm "Update 3 records". **Before:** `Employees` stays `NULL`; side panel shows "0 selected". **After:** `Employees = 100` on all 3; side panel shows "3 selected". ## Tests - Updated `computeContextStoreFilters` selection-mode test to the new shape. - Added a regression test asserting selection mode targets only the selected ids and ignores active view filters / any-field search. `nx lint:diff-with-main`, `nx typecheck twenty-front`, and the unit tests pass. Review in cubic --- .../computeContextStoreFilters.test.ts | 52 +++++++++++++++---- .../utils/computeContextStoreFilters.ts | 21 ++------ 2 files changed, 47 insertions(+), 26 deletions(-) diff --git a/packages/twenty-front/src/modules/context-store/utils/__tests__/computeContextStoreFilters.test.ts b/packages/twenty-front/src/modules/context-store/utils/__tests__/computeContextStoreFilters.test.ts index 343374a000..38cf7b059b 100644 --- a/packages/twenty-front/src/modules/context-store/utils/__tests__/computeContextStoreFilters.test.ts +++ b/packages/twenty-front/src/modules/context-store/utils/__tests__/computeContextStoreFilters.test.ts @@ -35,15 +35,49 @@ describe('computeContextStoreFilters', () => { }); expect(filters).toEqual({ - and: [ - {}, - { - id: { - in: ['1', '2', '3'], - }, - }, - {}, - ], + id: { + in: ['1', '2', '3'], + }, + }); + }); + + it('should target only the selected records and ignore view filters in selection mode', () => { + const contextStoreTargetedRecordsRule: ContextStoreTargetedRecordsRule = { + mode: 'selection', + selectedRecordIds: ['1', '2', '3'], + }; + + const contextStoreFilters: RecordFilter[] = [ + { + id: 'name-filter', + fieldMetadataId: personObjectMetadataItem.fields.find( + (field) => field.name === 'name', + )!.id, + value: 'John', + displayValue: 'John', + displayAvatarUrl: undefined, + operand: ViewFilterOperand.CONTAINS, + type: 'TEXT', + label: 'Name', + }, + ]; + + const filters = computeContextStoreFilters({ + contextStoreTargetedRecordsRule, + contextStoreFilters, + contextStoreFilterGroups: [], + objectMetadataItem: personObjectMetadataItem, + fieldMetadataItems: personObjectMetadataItem.fields, + filterValueDependencies: mockFilterValueDependencies, + contextStoreAnyFieldFilterValue: 'any field search', + }); + + // Explicitly selected records must be targeted as-is, even if they would + // not match the active view filters or any-field search. + expect(filters).toEqual({ + id: { + in: ['1', '2', '3'], + }, }); }); diff --git a/packages/twenty-front/src/modules/context-store/utils/computeContextStoreFilters.ts b/packages/twenty-front/src/modules/context-store/utils/computeContextStoreFilters.ts index bfcaf77f4c..ab6212c092 100644 --- a/packages/twenty-front/src/modules/context-store/utils/computeContextStoreFilters.ts +++ b/packages/twenty-front/src/modules/context-store/utils/computeContextStoreFilters.ts @@ -61,24 +61,11 @@ export const computeContextStoreFilters = ({ ]); } if (contextStoreTargetedRecordsRule.mode === 'selection') { - if (contextStoreTargetedRecordsRule.selectedRecordIds.length === 0) { - return { id: { in: [] } }; - } - - queryFilter = makeAndFilterVariables([ - recordGqlFilterForAnyFieldFilter, - { - id: { - in: contextStoreTargetedRecordsRule.selectedRecordIds, - }, + return { + id: { + in: contextStoreTargetedRecordsRule.selectedRecordIds, }, - computeRecordGqlOperationFilter({ - filterValueDependencies, - fieldMetadataItems, - recordFilters: contextStoreFilters, - recordFilterGroups: contextStoreFilterGroups, - }), - ]); + }; } return queryFilter;