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' }, ], }, {