Skip to content

Commit 084c618

Browse files
os-zhuangclaude
andauthored
test(app-showcase): B3 cascading-select fixture — per-option visibleWhen + role-gated option (#2559) (#3006)
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. Claude-Session: https://claude.ai/code/session_01S91NyYJURiQTKmF9q3AXxg Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4c46ee0 commit 084c618

3 files changed

Lines changed: 93 additions & 0 deletions

File tree

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
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+
});

examples/app-showcase/src/data/objects/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export { BusinessUnit } from './business-unit.object.js';
88
export { Team, ProjectMembership } from './team.object.js';
99
export { Product, Invoice, InvoiceLine } from './invoice.object.js';
1010
export { FieldZoo } from './field-zoo.object.js';
11+
export { CascadingSelect } from './cascading-select.object.js';
1112
export { Preference } from './preference.object.js';
1213
export { PrivateNote } from './private-note.object.js';
1314
export { Announcement } from './announcement.object.js';

examples/app-showcase/src/ui/apps/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ export const ShowcaseApp = App.create({
5757
{ id: 'nav_categories', type: 'object', objectName: 'showcase_category', label: 'Categories', icon: 'list-tree' },
5858
{ id: 'nav_business_units', type: 'object', objectName: 'showcase_business_unit', label: 'Business Units', icon: 'network' },
5959
{ id: 'nav_field_zoo', type: 'object', objectName: 'showcase_field_zoo', label: 'Field Zoo', icon: 'shapes' },
60+
// B3 (#1583) dynamic-options fixture: country → province cascade + a
61+
// role-gated tier, enforced server-side by the objectql rule path.
62+
{ id: 'nav_cascade', type: 'object', objectName: 'showcase_cascade', label: 'Cascading Select', icon: 'git-fork' },
6063
],
6164
},
6265
{

0 commit comments

Comments
 (0)