fix(front): mass update targets explicitly selected records (#21548)

## 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.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21548?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Charles Bochet
2026-06-14 21:11:46 +02:00
committed by GitHub
parent 25b0e4d81c
commit 965ff4337f
2 changed files with 47 additions and 26 deletions
@@ -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'],
},
});
});
@@ -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;