From 592019e45f725c8bb092f7762821cbc398fb4459 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 15:37:47 +0000 Subject: [PATCH] fix(core): harvest the CEL optional record.?x spelling into the view projection collectPredicateFieldRefs read only record.x / data.x, so a predicate written with the CEL optional selector left its operand out of $select. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jTLzhrs8dyC8QCdCKg --- .../29409-predicate-optional-field-harvest.md | 12 +++++ .../utils/__tests__/predicate-fields.test.ts | 47 +++++++++++++++++++ .../core/src/utils/predicate-fields.ts | 9 +++- 3 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 .changeset/29409-predicate-optional-field-harvest.md diff --git a/.changeset/29409-predicate-optional-field-harvest.md b/.changeset/29409-predicate-optional-field-harvest.md new file mode 100644 index 00000000000..40858eaa7b8 --- /dev/null +++ b/.changeset/29409-predicate-optional-field-harvest.md @@ -0,0 +1,12 @@ +--- +'@object-ui/core': patch +--- + +fix(core): the view projection harvest reads the CEL optional-field spelling `record.?x` / `data.?x` + +Clause-②: no +Metadata-protocol: none + +A list view adds to its `$select` the fields its predicates read (row and bulk action `visible` / `disabled`, the object's actions, the CRUD `visibleWhen` / `disabledWhen` overrides and conditional formatting), so a gate on a field no column shows still finds that field on the row. The harvester only knew `record.x` and `data.x`. A predicate written with the CEL optional selector, `record.?status.orValue('')`, left `status` out of the projection, so on a row that did not show `status` the selector always answered "absent" and the action behaved as if the field were empty. + +`collectPredicateFieldRefs` now reads `record.?x` and `data.?x` as the field `x`, beside the plain spelling. Nothing else changes: a harvested name is still projected only when the object declares it (or it is a platform column) and the principal may read it. A selector that names no field (`record.?` alone, `record.?.x`) and the JavaScript spelling `record?.x` still contribute nothing. diff --git a/ui/packages/core/src/utils/__tests__/predicate-fields.test.ts b/ui/packages/core/src/utils/__tests__/predicate-fields.test.ts index fd4cfbc1f1e..d9c4fe3c43a 100644 --- a/ui/packages/core/src/utils/__tests__/predicate-fields.test.ts +++ b/ui/packages/core/src/utils/__tests__/predicate-fields.test.ts @@ -52,6 +52,41 @@ describe('collectPredicateFieldRefs', () => { it('is not confused by a field name embedded in a longer path', () => { expect(collectPredicateFieldRefs(['record.owner.id == os.user.id'])).toEqual(['owner']); }); + + // The CEL optional-field selector reads the same key as `record.x`, and it is + // the spelling written for a sparse row — so its operand is owed to `$select` + // exactly as the plain spelling's is. + it('harvests the CEL optional `record.?x` spelling', () => { + expect(collectPredicateFieldRefs(['record.?status.orValue("") == "open"'])).toEqual(['status']); + }); + + it('harvests the CEL optional `data.?x` spelling too', () => { + expect(collectPredicateFieldRefs(['data.?owner_id == os.user.id'])).toEqual(['owner_id']); + }); + + it('dedupes the optional and plain spellings of one field, in first-seen order', () => { + expect( + collectPredicateFieldRefs(['has(record.stage) && record.?locked.orValue(false) && record.?stage == "won"']), + ).toEqual(['stage', 'locked']); + }); + + it('harvests an optional selector inside the { dialect, source } envelope', () => { + expect( + collectPredicateFieldRefs([{ dialect: 'cel', source: 'record.?approved_by != null' }]), + ).toEqual(['approved_by']); + }); + + it('yields nothing for an optional selector that names no field', () => { + expect( + collectPredicateFieldRefs([ + 'record.? == null', // no name after the selector + 'record.?.status', // selector, then a path dot: not a field name + 'data.?1st == 2', // not an identifier + 'myrecord.?status', // `record` must be the whole identifier + 'record?.status', // JavaScript optional chaining, not CEL + ]), + ).toEqual([]); + }); }); describe('listViewPredicates', () => { @@ -79,6 +114,18 @@ describe('listViewPredicates', () => { ]); }); + it('reaches an optional `record.?x` operand on the action and CRUD-override surfaces', () => { + expect( + collectPredicateFieldRefs( + listViewPredicates({ + rowActionDefs: [{ name: 'a', visible: 'record.?owner_id.orValue("") == os.user.id' }], + objectActions: [{ name: 'c', disabled: 'record.?locked.orValue(false)' }], + userActions: { edit: { visibleWhen: 'data.?editable == true' } }, + }), + ), + ).toEqual(['owner_id', 'locked', 'editable']); + }); + it('returns nothing for a view that declares no predicates', () => { expect(collectPredicateFieldRefs(listViewPredicates({}))).toEqual([]); }); diff --git a/ui/packages/core/src/utils/predicate-fields.ts b/ui/packages/core/src/utils/predicate-fields.ts index dc4acab5390..7b0e29475e3 100644 --- a/ui/packages/core/src/utils/predicate-fields.ts +++ b/ui/packages/core/src/utils/predicate-fields.ts @@ -87,8 +87,15 @@ export function isProjectableField( * and there is no way to tell without resolving. Authors are already directed * to the prefixed form on every surface (a bare field reference throws on the * record-header path), so this covers the documented spelling. + * + * The CEL optional-field spelling `record.?x` / `data.?x` reads the same key and + * is harvested the same way. It is exactly the spelling an author reaches for on + * a sparse row, so a harvest that skipped it would leave that predicate's + * operand outside `$select` with nothing pointing at the projection. The `?` + * belongs to the selector only: `record.?` with no name, `record.?.x` and the + * JavaScript `record?.x` name no CEL field here and yield nothing. */ -const RECORD_REF = /\b(?:record|data)\.([A-Za-z_][A-Za-z0-9_]*)/g; +const RECORD_REF = /\b(?:record|data)\.\??([A-Za-z_][A-Za-z0-9_]*)/g; /** * A whole bare identifier — what a field name is, and the only shape that can be