Skip to content

Commit d61efd1

Browse files
os-zhuangclaude
andauthored
fix(grid): evaluate a bulk action's visible per selected record (#3067) (#3070)
The bar evaluated `visible` against the ambient scope with no record bound. That does not fail open — the lenient evaluator returned `true` for EVERY row-scoped predicate, including ones that should be false: `${record.done}` and `${record.owner == user.id}` both came back `true` with no record in scope. The authored gate was inverted for half its inputs, indistinguishable from a real verdict. `visible` is now evaluated once per selected record with that record in scope, fail-closed per record and warning once on a fault — the row kebab's contract. One evaluation answers both questions: the button is offered when at least one selected record passes, and the run acts only on those. A record-free predicate answers identically for every row, so a plain feature/permission gate still behaves as a button-level gate with no syntactic sniffing for `record`. The confirm step reports how many records were skipped, so a run narrower than the user's selection says so. Eligibility is re-applied to the expanded set after "select all N matching", not just the page selection the button saw. Reverse-control: with only BulkActionBar.tsx + ObjectGrid.tsx reverted, the two new positive assertions fail (all selected records dispatched instead of the eligible one; button rendered when nothing qualifies). Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 746dd00 commit d61efd1

17 files changed

Lines changed: 350 additions & 13 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@object-ui/plugin-grid": patch
3+
"@object-ui/i18n": patch
4+
---
5+
6+
fix(grid): a bulk action's `visible` is evaluated per selected record — objectui#3067
7+
8+
The selection bar evaluated a def's `visible` against the ambient scope with no
9+
record bound. That does not fail open, it answers wrongly: with no `record` in
10+
scope the lenient evaluator returned `true` for **every** row-scoped predicate,
11+
including the ones that should be false — `${record.done}` and
12+
`${record.owner == user.id}` both came back `true`. An authored gate was not
13+
weakened, it was inverted for half its inputs, and nothing distinguished that
14+
from a real verdict.
15+
16+
`visible` is now evaluated **once per selected record, with that record in
17+
scope**, fail-closed per record and warning once on a fault — the same contract
18+
the row kebab uses. One evaluation answers both questions:
19+
20+
- **Is the button offered?** When at least one selected record passes. A
21+
record-free predicate (`features.x`, `current_user.y`) returns the same
22+
verdict for every row, so it still behaves as a plain button-level gate — no
23+
syntactic sniffing for `record` references is involved.
24+
- **Which records does it run on?** The ones that passed. The confirm step
25+
states how many were skipped, so a run over fewer records than the user
26+
ticked says so instead of quietly shrinking the selection.
27+
28+
Eligibility is re-applied to the EXPANDED set after "select all N matching",
29+
not just the page selection the button could see.
30+
31+
The mechanism predates objectui#3002, but only inline-authored
32+
`bulkActionDefs[].visible` used to reach it — written by authors who knew there
33+
was no record. #3031 began promoting object actions into the bar, and their
34+
`visible` is typically written for a row/record surface, which is what put
35+
row-scoped predicates in front of a record-free evaluation.

packages/i18n/src/locales/ar.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ const ar = {
245245
confirmDefault: "سيُطبَّق هذا على {{count}} من السجلات.",
246246
overLimit: "التحديد ({{count}}) يتجاوز حد الإجراء ({{limit}}). قلّل التحديد للمتابعة.",
247247
affectedRecords: "السجلات المتأثرة ({{count}}):",
248+
skippedIneligible: '{{count}} من السجلات المحددة غير مؤهلة لهذا الإجراء وسيتم تخطيها.',
248249
rowFallback: "الصف {{index}}",
249250
andMore: "… و{{count}} أخرى",
250251
processed: "تمت معالجة {{count}} / {{total}}",

packages/i18n/src/locales/de.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ const de = {
245245
confirmDefault: "Dies wird auf {{count}} Datensätze angewendet.",
246246
overLimit: "Die Auswahl ({{count}}) überschreitet das Aktionslimit ({{limit}}). Verkleinern Sie die Auswahl, um fortzufahren.",
247247
affectedRecords: "Betroffene Datensätze ({{count}}):",
248+
skippedIneligible: '{{count}} der ausgewählten Datensätze sind für diese Aktion nicht zulässig und werden übersprungen.',
248249
rowFallback: "Zeile {{index}}",
249250
andMore: "… und {{count}} weitere",
250251
processed: "{{count}} / {{total}} verarbeitet",

packages/i18n/src/locales/en.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,7 @@ const en = {
420420
confirmDefault: 'This will apply to {{count}} record(s).',
421421
overLimit: 'Selection ({{count}}) exceeds the action limit ({{limit}}). Reduce the selection to proceed.',
422422
affectedRecords: 'Affected records ({{count}}):',
423+
skippedIneligible: '{{count}} selected record(s) are not eligible for this action and will be skipped.',
423424
rowFallback: 'Row {{index}}',
424425
andMore: '\u2026 and {{count}} more',
425426
processed: '{{count}} / {{total}} processed',

packages/i18n/src/locales/es.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ const es = {
245245
confirmDefault: "Esto se aplicará a {{count}} registro(s).",
246246
overLimit: "La selección ({{count}}) supera el límite de la acción ({{limit}}). Reduzca la selección para continuar.",
247247
affectedRecords: "Registros afectados ({{count}}):",
248+
skippedIneligible: '{{count}} de los registros seleccionados no son aptos para esta acción y se omitirán.',
248249
rowFallback: "Fila {{index}}",
249250
andMore: "… y {{count}} más",
250251
processed: "{{count}} / {{total}} procesados",

packages/i18n/src/locales/fr.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ const fr = {
245245
confirmDefault: "Ceci s'appliquera à {{count}} enregistrement(s).",
246246
overLimit: "La sélection ({{count}}) dépasse la limite de l'action ({{limit}}). Réduisez la sélection pour continuer.",
247247
affectedRecords: "Enregistrements concernés ({{count}}) :",
248+
skippedIneligible: '{{count}} des enregistrements sélectionnés ne sont pas éligibles à cette action et seront ignorés.',
248249
rowFallback: "Ligne {{index}}",
249250
andMore: "… et {{count}} de plus",
250251
processed: "{{count}} / {{total}} traités",

packages/i18n/src/locales/ja.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ const ja = {
245245
confirmDefault: "{{count}} 件のレコードに適用されます。",
246246
overLimit: "選択件数({{count}})がアクションの上限({{limit}})を超えています。選択を減らしてください。",
247247
affectedRecords: "対象レコード({{count}}):",
248+
skippedIneligible: "選択したレコードのうち {{count}} 件はこのアクションの対象外のため、スキップされます。",
248249
rowFallback: "{{index}} 行目",
249250
andMore: "… 他 {{count}} 件",
250251
processed: "{{count}} / {{total}} 件を処理",

packages/i18n/src/locales/ko.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ const ko = {
245245
confirmDefault: "레코드 {{count}}건에 적용됩니다.",
246246
overLimit: "선택 건수({{count}})가 작업 한도({{limit}})를 초과합니다. 선택을 줄여 주세요.",
247247
affectedRecords: "영향을 받는 레코드({{count}}):",
248+
skippedIneligible: "선택한 레코드 중 {{count}}건은 이 작업의 대상이 아니므로 건너뜁니다.",
248249
rowFallback: "{{index}}행",
249250
andMore: "… 외 {{count}}건",
250251
processed: "{{count}} / {{total}} 처리됨",

packages/i18n/src/locales/pt.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ const pt = {
245245
confirmDefault: "Isto será aplicado a {{count}} registro(s).",
246246
overLimit: "A seleção ({{count}}) excede o limite da ação ({{limit}}). Reduza a seleção para prosseguir.",
247247
affectedRecords: "Registros afetados ({{count}}):",
248+
skippedIneligible: '{{count}} dos registros selecionados não são elegíveis para esta ação e serão ignorados.',
248249
rowFallback: "Linha {{index}}",
249250
andMore: "… e mais {{count}}",
250251
processed: "{{count}} / {{total}} processados",

packages/i18n/src/locales/ru.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ const ru = {
245245
confirmDefault: "Будет применено к записям: {{count}}.",
246246
overLimit: "Выбрано ({{count}}) больше лимита действия ({{limit}}). Уменьшите выбор, чтобы продолжить.",
247247
affectedRecords: "Затрагиваемые записи ({{count}}):",
248+
skippedIneligible: '{{count}} из выбранных записей не подходят для этого действия и будут пропущены.',
248249
rowFallback: "Строка {{index}}",
249250
andMore: "… и ещё {{count}}",
250251
processed: "Обработано {{count}} / {{total}}",

0 commit comments

Comments
 (0)