|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +import { ObjectSchema, Field } from '@objectstack/spec/data'; |
| 4 | +import { P } from '@objectstack/spec'; |
| 5 | + |
| 6 | +/** |
| 7 | + * Cascading Select — the B3 (#1583) dynamic-field-options runtime fixture. |
| 8 | + * |
| 9 | + * Demonstrates per-option `SelectOption.visibleWhen` end to end, through the |
| 10 | + * SERVED pipeline (defineStack → REST metadata → form renderer + the objectql |
| 11 | + * write path), with the dual-side guarantee B3 is built on: the client narrows |
| 12 | + * the offered set for UX, but the SERVER is the boundary. |
| 13 | + * |
| 14 | + * - `country` → `province` CASCADE. `province` declares `dependsOn: ['country']` |
| 15 | + * (so the form gates it until a country is chosen and re-filters as it |
| 16 | + * changes) and each of its options carries a `visibleWhen` cascade predicate |
| 17 | + * (`record.country == 'cn'` …). The client offers only the matching options; |
| 18 | + * the objectql rule-validator (`evaluateOptionVisibility`, objectui#2284) |
| 19 | + * re-evaluates the SUBMITTED value against the merged record and rejects an |
| 20 | + * out-of-set one with `{ field, code: 'invalid_option' }` — client hiding is |
| 21 | + * UX, not a security boundary. |
| 22 | + * |
| 23 | + * - `tier` carries one ROLE-GATED option: `restricted` is offered only when |
| 24 | + * `'admin' in current_user.positions`. The same rule-validator rejects a |
| 25 | + * non-admin who submits it anyway; it fails open only when `current_user` is |
| 26 | + * unbound (a system write) — the acting user is bound from the request on |
| 27 | + * authenticated writes (engine `buildEvalUser`). |
| 28 | + * |
| 29 | + * `sharingModel: 'public_read_write'` so the seeded admin (and the live e2e, |
| 30 | + * objectui `e2e/live/cascading-options.spec.ts`) can create records without a |
| 31 | + * bespoke permission set; belonging to no permission set, it is intentionally |
| 32 | + * absent from the ADR-0090 access-matrix snapshot. |
| 33 | + * |
| 34 | + * The server verdict is unit-covered by objectql |
| 35 | + * `rule-validator.option-visibility.test.ts`; this object is the served fixture |
| 36 | + * the browser/API e2e drives. Predicates use the `P` (CEL) tag — the same |
| 37 | + * authoring shape as the field-level `visibleWhen` on showcase_invoice. |
| 38 | + */ |
| 39 | +export const CascadingSelect = ObjectSchema.create({ |
| 40 | + name: 'showcase_cascade', |
| 41 | + // [ADR-0090 D1] Explicit grandfather stamp: this demo object is intentionally |
| 42 | + // world-writable (the live e2e creates against it); without the explicit OWD |
| 43 | + // the secure default (unset => private) would owner-filter it. |
| 44 | + sharingModel: 'public_read_write', |
| 45 | + label: 'Cascading Select', |
| 46 | + pluralLabel: 'Cascading Selects', |
| 47 | + icon: 'git-fork', |
| 48 | + description: |
| 49 | + '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).', |
| 50 | + |
| 51 | + fields: { |
| 52 | + name: Field.text({ label: 'Label', required: true, searchable: true, maxLength: 120 }), |
| 53 | + |
| 54 | + country: Field.select({ |
| 55 | + label: 'Country', |
| 56 | + options: [ |
| 57 | + { label: 'China', value: 'cn' }, |
| 58 | + { label: 'United States', value: 'us' }, |
| 59 | + ], |
| 60 | + }), |
| 61 | + |
| 62 | + // Dependent (cascading) options: gated until `country` is set, then filtered |
| 63 | + // by each option's `visibleWhen`. `dependsOn` drives the form's gate + |
| 64 | + // refresh; the per-option `visibleWhen` is the actual rule the client filters |
| 65 | + // on AND the server enforces. Same predicate shape as the objectui catalog |
| 66 | + // example (`fields-select/cascading-options`). |
| 67 | + province: Field.select({ |
| 68 | + label: 'Province / State', |
| 69 | + dependsOn: ['country'], |
| 70 | + options: [ |
| 71 | + { label: 'Zhejiang', value: 'zj', visibleWhen: P`record.country == 'cn'` }, |
| 72 | + { label: 'Guangdong', value: 'gd', visibleWhen: P`record.country == 'cn'` }, |
| 73 | + { label: 'California', value: 'ca', visibleWhen: P`record.country == 'us'` }, |
| 74 | + { label: 'Texas', value: 'tx', visibleWhen: P`record.country == 'us'` }, |
| 75 | + ], |
| 76 | + }), |
| 77 | + |
| 78 | + // Role-gated option: `restricted` is offered only to admins. The client hides |
| 79 | + // it for everyone else; the server rejects a non-admin who submits it anyway |
| 80 | + // (`current_user` is bound from the request on authenticated writes). |
| 81 | + tier: Field.select({ |
| 82 | + label: 'Tier', |
| 83 | + options: [ |
| 84 | + { label: 'Standard', value: 'standard', default: true }, |
| 85 | + { label: 'Restricted (admin only)', value: 'restricted', visibleWhen: P`'admin' in current_user.positions` }, |
| 86 | + ], |
| 87 | + }), |
| 88 | + }, |
| 89 | +}); |
0 commit comments