From d542712e71763c717b98f4a4d3d476ddbbab64a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 04:06:06 +0000 Subject: [PATCH] =?UTF-8?q?test(app-showcase):=20B3=20cascading-select=20f?= =?UTF-8?q?ixture=20=E2=80=94=20per-option=20visibleWhen=20+=20role-gated?= =?UTF-8?q?=20option=20(#2559)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `showcase_cascade`, a served fixture that exercises the B3 server-side option-value enforcement (objectql `evaluateOptionVisibility`, #2915 / #1583): - country → province cascade — `province` declares `dependsOn: ['country']` and each option gates itself with a `visibleWhen` CEL predicate. The client narrows the offered set; the rule-validator rejects an out-of-set submit (`{ field, code: 'invalid_option' }`). - a role-gated `tier.restricted` option (`'admin' in current_user.positions`), enforced server-side the same way. `public_read_write` (no permission set → absent from the ADR-0090 access-matrix), wired into the Showcase app's Data Model nav. Verified live against a fresh backend: POST /api/v1/data/showcase_cascade rejects country=us+province=zj (400 invalid_option) and accepts country=cn+province=zj (201); GET /api/v1/meta/object/showcase_cascade serves the per-option visibleWhen to the client. Drives objectui e2e/live/cascading-options.spec.ts. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01S91NyYJURiQTKmF9q3AXxg --- .../data/objects/cascading-select.object.ts | 89 +++++++++++++++++++ .../app-showcase/src/data/objects/index.ts | 1 + examples/app-showcase/src/ui/apps/index.ts | 3 + 3 files changed, 93 insertions(+) create mode 100644 examples/app-showcase/src/data/objects/cascading-select.object.ts diff --git a/examples/app-showcase/src/data/objects/cascading-select.object.ts b/examples/app-showcase/src/data/objects/cascading-select.object.ts new file mode 100644 index 0000000000..ba0f66f5ff --- /dev/null +++ b/examples/app-showcase/src/data/objects/cascading-select.object.ts @@ -0,0 +1,89 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { P } from '@objectstack/spec'; + +/** + * Cascading Select — the B3 (#1583) dynamic-field-options runtime fixture. + * + * Demonstrates per-option `SelectOption.visibleWhen` end to end, through the + * SERVED pipeline (defineStack → REST metadata → form renderer + the objectql + * write path), with the dual-side guarantee B3 is built on: the client narrows + * the offered set for UX, but the SERVER is the boundary. + * + * - `country` → `province` CASCADE. `province` declares `dependsOn: ['country']` + * (so the form gates it until a country is chosen and re-filters as it + * changes) and each of its options carries a `visibleWhen` cascade predicate + * (`record.country == 'cn'` …). The client offers only the matching options; + * the objectql rule-validator (`evaluateOptionVisibility`, objectui#2284) + * re-evaluates the SUBMITTED value against the merged record and rejects an + * out-of-set one with `{ field, code: 'invalid_option' }` — client hiding is + * UX, not a security boundary. + * + * - `tier` carries one ROLE-GATED option: `restricted` is offered only when + * `'admin' in current_user.positions`. The same rule-validator rejects a + * non-admin who submits it anyway; it fails open only when `current_user` is + * unbound (a system write) — the acting user is bound from the request on + * authenticated writes (engine `buildEvalUser`). + * + * `sharingModel: 'public_read_write'` so the seeded admin (and the live e2e, + * objectui `e2e/live/cascading-options.spec.ts`) can create records without a + * bespoke permission set; belonging to no permission set, it is intentionally + * absent from the ADR-0090 access-matrix snapshot. + * + * The server verdict is unit-covered by objectql + * `rule-validator.option-visibility.test.ts`; this object is the served fixture + * the browser/API e2e drives. Predicates use the `P` (CEL) tag — the same + * authoring shape as the field-level `visibleWhen` on showcase_invoice. + */ +export const CascadingSelect = ObjectSchema.create({ + name: 'showcase_cascade', + // [ADR-0090 D1] Explicit grandfather stamp: this demo object is intentionally + // world-writable (the live e2e creates against it); without the explicit OWD + // the secure default (unset => private) would owner-filter it. + sharingModel: 'public_read_write', + label: 'Cascading Select', + pluralLabel: 'Cascading Selects', + icon: 'git-fork', + description: + 'B3 dynamic-options fixture: a country → province cascade (per-option visibleWhen + dependsOn) plus a role-gated tier, enforced client-side (offered set) AND server-side (objectql rejects an out-of-set submit).', + + fields: { + name: Field.text({ label: 'Label', required: true, searchable: true, maxLength: 120 }), + + country: Field.select({ + label: 'Country', + options: [ + { label: 'China', value: 'cn' }, + { label: 'United States', value: 'us' }, + ], + }), + + // Dependent (cascading) options: gated until `country` is set, then filtered + // by each option's `visibleWhen`. `dependsOn` drives the form's gate + + // refresh; the per-option `visibleWhen` is the actual rule the client filters + // on AND the server enforces. Same predicate shape as the objectui catalog + // example (`fields-select/cascading-options`). + province: Field.select({ + label: 'Province / State', + dependsOn: ['country'], + options: [ + { label: 'Zhejiang', value: 'zj', visibleWhen: P`record.country == 'cn'` }, + { label: 'Guangdong', value: 'gd', visibleWhen: P`record.country == 'cn'` }, + { label: 'California', value: 'ca', visibleWhen: P`record.country == 'us'` }, + { label: 'Texas', value: 'tx', visibleWhen: P`record.country == 'us'` }, + ], + }), + + // Role-gated option: `restricted` is offered only to admins. The client hides + // it for everyone else; the server rejects a non-admin who submits it anyway + // (`current_user` is bound from the request on authenticated writes). + tier: Field.select({ + label: 'Tier', + options: [ + { label: 'Standard', value: 'standard', default: true }, + { label: 'Restricted (admin only)', value: 'restricted', visibleWhen: P`'admin' in current_user.positions` }, + ], + }), + }, +}); diff --git a/examples/app-showcase/src/data/objects/index.ts b/examples/app-showcase/src/data/objects/index.ts index 9b2f2b558b..2487c2b029 100644 --- a/examples/app-showcase/src/data/objects/index.ts +++ b/examples/app-showcase/src/data/objects/index.ts @@ -8,6 +8,7 @@ export { BusinessUnit } from './business-unit.object.js'; export { Team, ProjectMembership } from './team.object.js'; export { Product, Invoice, InvoiceLine } from './invoice.object.js'; export { FieldZoo } from './field-zoo.object.js'; +export { CascadingSelect } from './cascading-select.object.js'; export { Preference } from './preference.object.js'; export { PrivateNote } from './private-note.object.js'; export { Announcement } from './announcement.object.js'; diff --git a/examples/app-showcase/src/ui/apps/index.ts b/examples/app-showcase/src/ui/apps/index.ts index 21e57d7023..721f67d61a 100644 --- a/examples/app-showcase/src/ui/apps/index.ts +++ b/examples/app-showcase/src/ui/apps/index.ts @@ -57,6 +57,9 @@ export const ShowcaseApp = App.create({ { id: 'nav_categories', type: 'object', objectName: 'showcase_category', label: 'Categories', icon: 'list-tree' }, { id: 'nav_business_units', type: 'object', objectName: 'showcase_business_unit', label: 'Business Units', icon: 'network' }, { id: 'nav_field_zoo', type: 'object', objectName: 'showcase_field_zoo', label: 'Field Zoo', icon: 'shapes' }, + // B3 (#1583) dynamic-options fixture: country → province cascade + a + // role-gated tier, enforced server-side by the objectql rule path. + { id: 'nav_cascade', type: 'object', objectName: 'showcase_cascade', label: 'Cascading Select', icon: 'git-fork' }, ], }, {