Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/29409-predicate-optional-field-harvest.md
Original file line number Diff line number Diff line change
@@ -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.
47 changes: 47 additions & 0 deletions ui/packages/core/src/utils/__tests__/predicate-fields.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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([]);
});
Expand Down
9 changes: 8 additions & 1 deletion ui/packages/core/src/utils/predicate-fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading