diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index 97efa6c206..0adf2045b5 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -20,7 +20,7 @@ import { setupShowcaseExternalDatasource } from './src/system/datasources/extern import { registerRecalcEndpoint } from './src/system/server/recalc-endpoint.js'; import { registerShowcasePositionBindings } from './src/security/bind-position-sets.js'; import { registerShowcaseApprovalDemo } from './src/security/seed-approval-demo.js'; -import { TaskViews, ProjectViews, InquiryViews, BusinessUnitViews, ContactViews } from './src/ui/views/index.js'; +import { TaskViews, ProjectViews, InquiryViews, BusinessUnitViews, ContactViews, FieldZooViews } from './src/ui/views/index.js'; import { ShowcaseApp } from './src/ui/apps/index.js'; import { ChartGalleryDashboard, OpsDashboard, RevenuePulseDashboard } from './src/ui/dashboards/index.js'; import { ShowcaseTaskDataset, ShowcaseProjectDataset, ShowcaseInvoiceDataset, ShowcaseAccountDataset } from './src/ui/datasets/index.js'; @@ -193,7 +193,7 @@ export default defineStack({ // UI apps: [ShowcaseApp], - views: [TaskViews, ProjectViews, InquiryViews, BusinessUnitViews, ContactViews], + views: [TaskViews, ProjectViews, InquiryViews, BusinessUnitViews, ContactViews, FieldZooViews], pages: [CapabilityMapPage, StartHerePage, ComponentGalleryPage, ProjectWorkspacePage, ProjectDetailPage, TaskWorkbenchPage, TaskTriagePage, TaskBoardPage, TaskCalendarPage, TaskGalleryPage, TaskSchedulePage, TaskTimelinePage, TaskMapPage, TaskAllViewsPage, ActiveProjectsPage, TaskDetailPage, ReviewQueuePage, NewProjectWizardPage, MyWorkPage, SettingsPage, StylingGalleryPage, CommandCenterPage, CommandCenterJsxPage, CrmWorkbenchPage, TaskDeskPage, PageVariablesPage, ContactFormPage, RenewalsPipelinePage], dashboards: [ChartGalleryDashboard, OpsDashboard, RevenuePulseDashboard], books: allBooks, diff --git a/examples/app-showcase/src/security/capabilities.ts b/examples/app-showcase/src/security/capabilities.ts index f83713fdc0..7fcdc7935e 100644 --- a/examples/app-showcase/src/security/capabilities.ts +++ b/examples/app-showcase/src/security/capabilities.ts @@ -56,4 +56,27 @@ export const ExportDataCapability = defineCapability({ packageId: 'com.example.showcase', }); -export const allCapabilities = [ExportDataCapability]; +/** + * A capability that is DEFINED and granted to NOBODY — the falsifiable half of + * the ADR-0066 three-way separation, and the one a demo usually leaves out. + * + * Its whole job is to be required and never held: `showcase_zoo_perm_missing` + * (and the AND-gate specimen next to it, see ui/actions/predicate-matrix.action.ts) + * lists it in `requiredPermissions`, so those buttons must be absent for every + * caller on every one of the four action surfaces. Without a capability nobody + * holds there is nothing to notice when a surface stops applying the gate — + * which is exactly how the selection bar shipped ignoring `requiredPermissions` + * outright (objectui#3492) while three other surfaces honoured it. + * + * Deliberately absent from every permission set. If a future set grants it, the + * two specimens stop testing anything. + */ +export const RestrictedOpsCapability = defineCapability({ + name: 'showcase.restricted_ops', + label: 'Restricted Showcase Operations', + description: 'Reserved for the capability-gate specimens — intentionally granted to no permission set.', + scope: 'org', + packageId: 'com.example.showcase', +}); + +export const allCapabilities = [ExportDataCapability, RestrictedOpsCapability]; diff --git a/examples/app-showcase/src/system/translations/index.ts b/examples/app-showcase/src/system/translations/index.ts index c9a0e91748..aca0cb46b7 100644 --- a/examples/app-showcase/src/system/translations/index.ts +++ b/examples/app-showcase/src/system/translations/index.ts @@ -419,6 +419,17 @@ export const ShowcaseTranslationBundle = { showcase_field_zoo: { label: '字段动物园', pluralLabel: '字段动物园', fields: { f_lookups: { label: '查找 → 客户(多值)' } }, + // 三个视图共用一套门控动作,差别只在「被门控的字段是否也作为列显示」—— + // 即客户端是否展开它、投影是否本来就会带上它。见 ui/views/field-zoo.view.ts。 + // `_views.default` —— 容器的默认列表:它声明时不带 `name`,composer 把它 + // 注册为 `showcase_field_zoo.default`,bundle 的键就是这个裸运行时键 + // (#5164,2026-08-06 定案)。写成 `list` 是没有任何查找能到达的拼法 —— + // 本条正是这么错过一次的:分支落后 main 时本地绿、合进 main 后 CI 红。 + _views: { + default: { label: '字段动物园' }, + gated_columns: { label: '被门控字段作为列' }, + inline_bulk_defs: { label: '内联批量定义' }, + }, // #3405 — the inline system-object picker specimen. Translated at birth // because `check-i18n-coverage` ratchets this example at its current // untranslated count: a newly declared label that skips zh-CN pushes the @@ -426,6 +437,10 @@ export const ShowcaseTranslationBundle = { // that baseline; this one is not allowed to widen it.) `负责人` matches // what the bundle already renders for `showcase_task.assignee`, rather // than introducing a second word for the same idea. + // 动作显隐矩阵(objectui#3492 / #3501)。同上:本示例被 + // `check-i18n-coverage` 按当前未翻译计数上了棘轮,新声明的标签必须当场 + // 翻译,否则计数上涨、门变红。标签本身是覆盖夹具的说明文字,中文照写同 + // 一句技术陈述,不做意译。 _actions: { showcase_action_param_gallery: { params: { @@ -435,6 +450,65 @@ export const ShowcaseTranslationBundle = { }, }, }, + showcase_zoo_relation_gate: { label: '查找 == 多值第一项', successMessage: '两个关系都解析到同一个 id。' }, + showcase_zoo_user_gate: { label: '指派给我(user 字段)', successMessage: '你是这条标本的指派用户。' }, + showcase_zoo_owner_gate: { label: '归我所有(owner_id)', successMessage: '这条标本归你所有。' }, + showcase_zoo_dialect_split: { label: '仅 CEL:contains()' }, + showcase_zoo_toolbar_gate: { label: '仅登录可见(工具栏)', successMessage: '工具栏门通过。' }, + showcase_zoo_visible_string: { label: 'visible:字符串' }, + showcase_zoo_visible_tagged: { label: 'visible:P`…` 标签模板' }, + showcase_zoo_visible_envelope: { label: 'visible:{ dialect, source } 信封' }, + showcase_zoo_disabled_gate: { label: '未评分则禁用' }, + showcase_zoo_perm_held: { label: '需要导出能力' }, + showcase_zoo_perm_missing: { label: '需要受限能力' }, + showcase_zoo_perm_and: { label: '需要同时具备两项能力' }, + showcase_zoo_perm_empty: { label: 'requiredPermissions:[]' }, + // 字段类型谓词园 —— 每种字段类型一条 `visible`。 + showcase_zoo_t_lookup: { label: 'lookup —— 已设置' }, + showcase_zoo_t_lookup_multi: { label: 'lookup 多值 —— 多于 1 项' }, + showcase_zoo_t_master_detail: { label: 'master_detail —— 已设置' }, + showcase_zoo_t_tree: { label: 'tree —— 未设置' }, + showcase_zoo_t_user: { label: 'user —— 未设置' }, + showcase_zoo_t_user_identity: { label: 'user —— 是我' }, + showcase_zoo_t_owner: { label: 'owner_id(注入列)—— 属于我' }, + showcase_zoo_t_text: { label: 'text —— name 非空' }, + showcase_zoo_t_textarea: { label: 'textarea —— contains' }, + showcase_zoo_t_email: { label: 'email —— matches' }, + showcase_zoo_t_url: { label: 'url —— contains' }, + showcase_zoo_t_phone: { label: 'phone —— contains' }, + showcase_zoo_t_markdown: { label: 'markdown —— contains' }, + showcase_zoo_t_html: { label: 'html —— contains' }, + showcase_zoo_t_code: { label: 'code —— contains' }, + showcase_zoo_t_number: { label: 'number —— > 100' }, + showcase_zoo_t_currency: { label: 'currency —— > 1000' }, + showcase_zoo_t_percent: { label: 'percent —— >= 75' }, + showcase_zoo_t_rating: { label: 'rating —— >= 4' }, + showcase_zoo_t_slider: { label: 'slider —— > 50' }, + showcase_zoo_t_progress: { label: 'progress —— >= 80' }, + showcase_zoo_t_formula: { label: 'formula —— > 100' }, + showcase_zoo_t_autonumber: { label: 'autonumber —— 等于 0001' }, + showcase_zoo_t_date: { label: 'date —— 早于今天' }, + showcase_zoo_t_datetime: { label: 'datetime —— 已设置' }, + showcase_zoo_t_time: { label: 'time —— 等于 14:30' }, + showcase_zoo_t_boolean: { label: 'boolean —— 真' }, + showcase_zoo_t_toggle: { label: 'toggle —— 真' }, + showcase_zoo_t_select: { label: 'select —— high' }, + showcase_zoo_t_radio: { label: 'radio —— yes' }, + showcase_zoo_t_multiselect: { label: 'multiselect —— 含 red' }, + showcase_zoo_t_checkboxes: { label: 'checkboxes —— 含 email' }, + showcase_zoo_t_tags: { label: 'tags —— 非空' }, + showcase_zoo_t_json: { label: 'json —— 嵌套键' }, + showcase_zoo_t_location: { label: 'location —— 纬度 > 40' }, + showcase_zoo_t_address: { label: 'address —— 美国' }, + showcase_zoo_t_color: { label: 'color —— 等于 #2563EB' }, + showcase_zoo_t_composite: { label: 'composite —— width 为 10' }, + showcase_zoo_t_repeater: { label: 'repeater —— 2 行' }, + showcase_zoo_t_record: { label: 'record 套 record —— score 为 9' }, + showcase_zoo_t_vector: { label: 'vector —— 4 维' }, + showcase_zoo_t_and: { label: 'AND —— boolean && number' }, + showcase_zoo_t_or: { label: 'OR —— select || rating' }, + showcase_zoo_t_not: { label: 'NOT —— !boolean' }, + showcase_zoo_t_ternary: { label: '三元 —— rating ? :' }, }, }, }, diff --git a/examples/app-showcase/src/ui/actions/index.ts b/examples/app-showcase/src/ui/actions/index.ts index 622e58dd72..ab39ac9228 100644 --- a/examples/app-showcase/src/ui/actions/index.ts +++ b/examples/app-showcase/src/ui/actions/index.ts @@ -2,6 +2,13 @@ import { defineAction } from '@objectstack/spec/ui'; +// The action-GATING matrix (`visible` / `requiredPermissions` across all four +// action surfaces, and one predicate per field type) lives in its own file — +// it is a coverage fixture for the gates, not another ActionType specimen. +import { allPredicateMatrixActions } from './predicate-matrix.action.js'; + +export * from './predicate-matrix.action.js'; + const task = 'showcase_task'; const invoice = 'showcase_invoice'; const fieldZoo = 'showcase_field_zoo'; @@ -375,4 +382,5 @@ export const allActions = [ ActionParamGalleryAction, ArchiveTaskAction, PortfolioSnapshotAction, + ...allPredicateMatrixActions, ]; diff --git a/examples/app-showcase/src/ui/actions/predicate-matrix.action.ts b/examples/app-showcase/src/ui/actions/predicate-matrix.action.ts new file mode 100644 index 0000000000..e59c32be3c --- /dev/null +++ b/examples/app-showcase/src/ui/actions/predicate-matrix.action.ts @@ -0,0 +1,483 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * **Action-gating matrix** — the live specimen for the two things that decide + * whether a button is offered: `visible` (a CEL predicate over the record) and + * `requiredPermissions` (the ADR-0066 D4 capability gate). + * + * Everything here hangs off `showcase_field_zoo`, the "one specimen of + * everything" object, and off its two seeded records — **Specimen — Full** + * (every field populated) and **Specimen — Minimal** (most fields `null`). + * Those two records are what make each gate falsifiable in a browser: an + * action gated on a populated field must appear on Full and vanish on Minimal, + * and a mistake shows up as a button in the wrong place rather than as a + * passing test. + * + * ## What this exists to catch + * + * A gate is evaluated by FOUR different surfaces, each of which filters its own + * action list: + * + * | surface | where it renders | + * |---|---| + * | list toolbar | above the list — no record in scope | + * | row kebab (`list_item`) | the `⋮` menu on each row | + * | record header (`record_header` / `record_more`) | the detail page | + * | selection bar | after ticking rows, from the view's `bulkActions` | + * + * They have drifted before, in both directions: the selection bar ignored + * `requiredPermissions` entirely, so an action the row kebab hid from an + * unentitled user reappeared the moment they ticked a checkbox + * (objectui#3492); and a relation field bound as the expanded RECORD on one + * surface and as its foreign KEY on another, so the same `record. == + * ` was true on one and false on the next (objectui#3501). Both are + * invisible to a unit test of any single surface. Declaring one action across + * all four is what makes a disagreement visible. + * + * ## Authoring rules these specimens encode + * + * Measured against this runtime's CEL engine, not assumed: + * + * - **Prefix with `record.`** A bare `f_boolean` is an undeclared variable on + * the record-header path and throws (fail-closed hide). + * - **Null-guard before you traverse or call.** `record.f_json.nested.k` and + * `record.f_tags.size()` FAULT on a record where the field is `null` — + * `null` has no members and no methods. `record.f_json != null && + * record.f_json.nested.k == "v"` is the portable form, and it is why the + * Minimal specimen is worth having. + * - **`contains()` / `matches()`, never `startsWith()` / `endsWith()`.** The + * latter two are not CEL — objectui routes them to its legacy JS evaluator + * with a deprecation warning, and the SERVER's engine has no answer for + * them at all, so a predicate using one silently stops being portable. + * - **A relation field is its FOREIGN KEY**, on every surface and on the + * server: `record.f_lookup == ""`, never `record.f_lookup.id`. + * - **The RECORD HEADER does not speak CEL** (objectui#3521). It evaluates + * header-action predicates on objectui's legacy JS evaluator, so `.size()`, + * `.contains()`, `.matches()`, the `in` operator and stdlib calls like + * `today()` all THROW there and fail-closed hide the action, while the same + * predicate is correct in the row kebab and the selection bar. Every gate + * below that uses one of those is therefore absent from the `⋯` menu today + * and present in the list — see `ZooDialectSplitAction` for the one-screen + * comparison. Predicates built only from `==` / `!=` / `<` / `&&` / `||` / + * `!` agree on both. + * + * ## `visible` has three authoring forms, one meaning + * + * `ActionSchema.visible` is `ExpressionInput` — a bare CEL **string**, the + * explicit **`{ dialect, source }` envelope**, or the **`P` tagged template**. + * All three parse to the identical envelope, which is exactly why they are + * worth pinning side by side: if the three specimens below ever disagree on + * one record, a renderer is treating one form differently from the others. + * + * Note the form that is NOT here: a **boolean** `visible`. `ExpressionInput` + * does not admit one, so `objectstack build` cannot emit it and no authored + * app can reach that path — it is reachable only from hand-written view JSON + * and in-process callers, and it is pinned by objectui's own unit tests + * (objectui#3492) rather than faked here. + */ + +import { defineAction, P } from '@objectstack/spec'; + +const zoo = 'showcase_field_zoo'; + +/** + * Every specimen shares one inert body: the value of these actions is WHERE + * they appear, never what they do, so clicking one must be safe to do + * repeatedly on a demo record. It echoes the record it was invoked on so the + * result dialog confirms the dispatch actually happened. + */ +const echo = { + language: 'js' as const, + source: + "var id = ctx.recordId || (ctx.record && ctx.record.id) || input.recordId || null;" + + "return { ok: true, record: id, selected: (input && input._selectedIds) || null };", + capabilities: [], +}; + +/** The four record-scoped surfaces, so one action can be compared across them. */ +const RECORD_SURFACES = ['list_item', 'record_header', 'record_more'] as const; + +// ─────────────────────────────────────────────────────────────────────────── +// 1 — Surface parity: ONE predicate over a relation field, on every surface +// ─────────────────────────────────────────────────────────────────────────── + +/** + * **Relation == relation** — the sharpest specimen in this file, because both + * operands are lookups and neither is a literal. + * + * `f_lookup` and `f_lookups[0]` hold the SAME account id on Specimen — Full. + * The predicate is therefore true there and false on Minimal (both null) — but + * only if both sides are bound as the stored foreign KEY. Expand either one and + * the comparison is an object against a string, which is a clean, silent + * `false`, on the record the author wrote it for. + * + * That asymmetry is not hypothetical: a view expands the relations it shows as + * COLUMNS, so `gated_columns` (which shows `f_lookup` and not `f_lookups`) + * expands exactly one side of this comparison while the default list expands + * neither. Same records, same predicate, and before objectui#3501 two different + * answers. Declared on all three record surfaces and named in both views' + * `bulkActions`, so a disagreement shows up as a button in one place and not + * another. + * + * Neither field is a column on the default list, which also puts the `$select` + * projection under test: a list asks the server only for what it DISPLAYS, and + * CEL treats an absent key as a FAULT rather than as null. + */ +export const ZooRelationGateAction = defineAction({ + name: 'showcase_zoo_relation_gate', + label: 'Lookup == first of multi', + icon: 'link', + objectName: zoo, + type: 'script', + body: echo, + successMessage: 'Both relations resolved to the same id.', + visible: 'record.f_lookup != null && record.f_lookups != null && record.f_lookup == record.f_lookups[0]', + locations: [...RECORD_SURFACES], + refreshAfter: false, +}); + +/** + * **The ownership gate every app writes first**, over the platform-injected + * `owner_id` column. Both specimens are owned by the seeding user, so the + * honest reading is: this appears on all four surfaces for whoever seeded the + * workspace, and on none of them for anyone else. + * + * `owner_id` is injected by `applySystemFields` and is NOT among the object's + * declared fields, which is what makes it worth a specimen of its own on two + * counts. Author-time: it was rejected as `unknown field owner_id` until #5378 + * taught the linter to derive the injected set (`packages/spec/src/data/ + * injected-system-columns.ts`) — this predicate is the live proof that landed. + * Run time: a list's `$select` is built from the object's declared fields, so + * a consumer harvesting the fields a predicate reads has to know the platform + * columns too, or drop this one as a typo and leave the predicate faulting on + * an absent key (objectui#3501's `PLATFORM_RECORD_COLUMNS`). + */ +export const ZooOwnerGateAction = defineAction({ + name: 'showcase_zoo_owner_gate', + label: 'Mine (owner_id)', + icon: 'user-check', + objectName: zoo, + type: 'script', + body: echo, + successMessage: 'You own this specimen.', + visible: 'record.owner_id == os.user.id', + locations: [...RECORD_SURFACES], + refreshAfter: false, +}); + +/** + * The same idea over a DECLARED `user` relation. `f_user` is unseeded by design + * — `sys_user` rows come from sign-up, not seeds (see the note on + * field-zoo.object.ts) — so this is HIDDEN on both specimens out of the box and + * becomes visible on whichever record you assign to yourself in the UI. That + * makes it the manual half of the matrix, and the pair with `owner_id` above is + * the point: an injected column and a declared field must gate identically. + */ +export const ZooUserIdentityGateAction = defineAction({ + name: 'showcase_zoo_user_gate', + label: 'Assigned to me (user field)', + icon: 'user-check', + objectName: zoo, + type: 'script', + body: echo, + successMessage: 'You are the assigned user on this specimen.', + visible: 'record.f_user == os.user.id', + locations: [...RECORD_SURFACES], + refreshAfter: false, +}); + +/** + * **The dialect split.** One action, one predicate, two surfaces — and, as of + * this writing, two different answers. + * + * `.contains()` is ordinary CEL and evaluates correctly wherever the canonical + * engine runs: the row `⋮` menu, the selection bar, conditional formatting. The + * RECORD HEADER does not run that engine — it evaluates header-action + * predicates on objectui's legacy JS evaluator — so the same predicate throws + * there (`.contains` is not a JS string method) and the action is fail-closed + * hidden. Measured, not assumed: the browser console carries + * `[page:header] action "…" hidden: its predicate threw`. + * + * The same split hides every CEL-only construct on that one surface — + * `.size()`, `.matches()`, the `in` operator, and stdlib calls like `today()` + * (`"today" is not a function`). Filed as objectui#3521; kept here as a LIVE + * fixture rather than papered over, because "works in the list, silently gone + * on the detail page" is invisible to any test that exercises one surface. + * + * Compare it against `ZooVisibleStringAction` above, whose predicate uses only + * operators BOTH dialects share and therefore agrees on every surface. When + * #3521 lands, this action should appear in the header too — that is the test. + */ +export const ZooDialectSplitAction = defineAction({ + name: 'showcase_zoo_dialect_split', + label: 'CEL-only: contains()', + icon: 'search', + objectName: zoo, + type: 'script', + body: echo, + visible: 'record.f_textarea != null && record.f_textarea.contains("Line two")', + locations: [...RECORD_SURFACES], + refreshAfter: false, +}); + +/** + * The toolbar twin. A `list_toolbar` action is evaluated with NO record in + * scope — there is no row it belongs to — so a record-scoped predicate here + * would be an authoring error, not a demo. This one gates on identity alone, + * which is the only kind of predicate the toolbar can honestly answer. + */ +export const ZooToolbarGateAction = defineAction({ + name: 'showcase_zoo_toolbar_gate', + label: 'Signed-in only (toolbar)', + icon: 'shield-check', + objectName: zoo, + type: 'script', + body: echo, + successMessage: 'Toolbar gate passed.', + visible: 'os.user.id != null', + locations: ['list_toolbar'], + refreshAfter: false, +}); + +// ─────────────────────────────────────────────────────────────────────────── +// 2 — The three `visible` authoring forms, one meaning +// ─────────────────────────────────────────────────────────────────────────── + +/** Form 1 — a bare CEL **string**. Normalized to the envelope at build time. */ +export const ZooVisibleStringAction = defineAction({ + name: 'showcase_zoo_visible_string', + label: 'visible: string', + icon: 'quote', + objectName: zoo, + type: 'script', + body: echo, + visible: 'record.f_boolean == true', + locations: [...RECORD_SURFACES], + refreshAfter: false, +}); + +/** Form 2 — the `P` tagged template (the `cel` alias for predicates). */ +export const ZooVisibleTaggedAction = defineAction({ + name: 'showcase_zoo_visible_tagged', + label: 'visible: P`…`', + icon: 'code', + objectName: zoo, + type: 'script', + body: echo, + visible: P`record.f_boolean == true`, + locations: [...RECORD_SURFACES], + refreshAfter: false, +}); + +/** Form 3 — the explicit `{ dialect, source }` envelope. */ +export const ZooVisibleEnvelopeAction = defineAction({ + name: 'showcase_zoo_visible_envelope', + label: 'visible: { dialect, source }', + icon: 'braces', + objectName: zoo, + type: 'script', + body: echo, + visible: { dialect: 'cel', source: 'record.f_boolean == true' }, + locations: [...RECORD_SURFACES], + refreshAfter: false, +}); + +/** + * The `disabled` counterpart, for the same reason `visible` has three forms: + * where `visible` HIDES, `disabled` keeps the button on screen and greys it. + * Same scope, same dialect, same null rules — greyed on Minimal, live on Full. + */ +export const ZooDisabledAction = defineAction({ + name: 'showcase_zoo_disabled_gate', + label: 'Disabled unless rated', + icon: 'star', + objectName: zoo, + type: 'script', + body: echo, + disabled: 'record.f_rating < 4', + locations: ['record_header', 'record_section'], + refreshAfter: false, +}); + +// ─────────────────────────────────────────────────────────────────────────── +// 3 — `requiredPermissions` (ADR-0066 D4), on all four surfaces +// ─────────────────────────────────────────────────────────────────────────── + +/** + * Requires a capability the Operations set GRANTS (`showcase.export_data`, see + * security/capabilities.ts). Visible to a caller holding it, hidden otherwise + * — and identically on all four surfaces. + */ +export const ZooPermHeldAction = defineAction({ + name: 'showcase_zoo_perm_held', + label: 'Needs export capability', + icon: 'download', + objectName: zoo, + type: 'script', + body: echo, + requiredPermissions: ['showcase.export_data'], + locations: [...RECORD_SURFACES], + refreshAfter: false, +}); + +/** + * Requires a capability this app DEFINES but grants to nobody + * (`showcase.restricted_ops`). It should therefore be invisible to every + * caller, on every surface — the falsifiable half of the gate, and the case + * that used to leak: the selection bar never read `requiredPermissions` at all, + * so this button reappeared the moment a row was ticked (objectui#3492). + */ +export const ZooPermMissingAction = defineAction({ + name: 'showcase_zoo_perm_missing', + label: 'Needs restricted capability', + icon: 'lock', + objectName: zoo, + type: 'script', + body: echo, + requiredPermissions: ['showcase.restricted_ops'], + locations: [...RECORD_SURFACES], + refreshAfter: false, +}); + +/** + * Two capabilities — the gate is an AND, not an OR. A caller holding + * `showcase.export_data` but not `showcase.restricted_ops` must NOT see this, + * which is what distinguishes an AND gate from an OR one in the browser. + */ +export const ZooPermAndAction = defineAction({ + name: 'showcase_zoo_perm_and', + label: 'Needs BOTH capabilities', + icon: 'shield', + objectName: zoo, + type: 'script', + body: echo, + requiredPermissions: ['showcase.export_data', 'showcase.restricted_ops'], + locations: [...RECORD_SURFACES], + refreshAfter: false, +}); + +/** + * An EMPTY declaration always passes — "declares nothing" is not "denies + * everything". Kept as a specimen because the empty array is the shape most + * likely to be mishandled by a gate written as `required.length ? … : …` + * inverted. + */ +export const ZooPermEmptyAction = defineAction({ + name: 'showcase_zoo_perm_empty', + label: 'requiredPermissions: []', + icon: 'circle-check', + objectName: zoo, + type: 'script', + body: echo, + requiredPermissions: [], + locations: [...RECORD_SURFACES], + refreshAfter: false, +}); + +// ─────────────────────────────────────────────────────────────────────────── +// 4 — The field-type predicate zoo +// ─────────────────────────────────────────────────────────────────────────── + +/** + * One action per field-type family, each gated on THAT type, all parked in the + * record `⋯` menu so the Field Zoo detail page shows the whole matrix at once. + * On **Specimen — Full** every one of these should be offered; on **Specimen — + * Minimal** only the ones whose field is populated there + * (`f_number` / `f_select` / `f_radio` / `f_multiselect` / `f_checkboxes` / + * `f_time` / `f_master_detail` / `f_percent` / `f_rating` / `f_autonumber`). + * + * Every predicate that touches a nullable non-scalar is null-guarded — see the + * authoring rules at the top of this file. That is not defensive padding: the + * unguarded form FAULTS on Minimal, and a fault is fail-closed on the row and + * selection surfaces and fail-OPEN on the lenient ones, so the same typo shows + * the button to everybody on one surface and to nobody on the next. + */ +const zooTypeGate = (name: string, label: string, visible: string) => + defineAction({ + name: `showcase_zoo_t_${name}`, + label, + objectName: zoo, + type: 'script', + body: echo, + visible, + locations: ['record_more'], + refreshAfter: false, + }); + +export const ZooTypeGates = [ + // ── Relational: the id, never the expanded record ──────────────────────── + zooTypeGate('lookup', 'lookup — set', 'record.f_lookup != null'), + zooTypeGate('lookup_multi', 'lookup multiple — >1', 'record.f_lookups != null && record.f_lookups.size() > 1'), + zooTypeGate('master_detail', 'master_detail — set', 'record.f_master_detail != null'), + zooTypeGate('tree', 'tree — unset', 'record.f_tree == null'), + zooTypeGate('user', 'user — unset', 'record.f_user == null'), + zooTypeGate('user_identity', 'user — is me', 'record.f_user == os.user.id'), + zooTypeGate('owner', 'owner_id (injected) — mine', 'record.owner_id == os.user.id'), + + // ── Text family: contains() / matches(), never startsWith() ────────────── + zooTypeGate('text', 'text — name non-empty', 'record.name != null && record.name.size() > 0'), + zooTypeGate('textarea', 'textarea — contains', 'record.f_textarea != null && record.f_textarea.contains("Line two")'), + zooTypeGate('email', 'email — matches', 'record.f_email != null && record.f_email.matches(".*@example[.]com")'), + zooTypeGate('url', 'url — contains', 'record.f_url != null && record.f_url.contains("objectstack")'), + zooTypeGate('phone', 'phone — contains', 'record.f_phone != null && record.f_phone.contains("555")'), + zooTypeGate('markdown', 'markdown — contains', 'record.f_markdown != null && record.f_markdown.contains("Heading")'), + zooTypeGate('html', 'html — contains', 'record.f_html != null && record.f_html.contains("bold")'), + zooTypeGate('code', 'code — contains', 'record.f_code != null && record.f_code.contains("ok")'), + + // ── Numeric ────────────────────────────────────────────────────────────── + zooTypeGate('number', 'number — > 100', 'record.f_number > 100'), + zooTypeGate('currency', 'currency — > 1000', 'record.f_currency != null && record.f_currency > 1000.0'), + zooTypeGate('percent', 'percent — >= 75', 'record.f_percent >= 75'), + zooTypeGate('rating', 'rating — >= 4', 'record.f_rating >= 4'), + zooTypeGate('slider', 'slider — > 50', 'record.f_slider > 50'), + zooTypeGate('progress', 'progress — >= 80', 'record.f_progress >= 80'), + zooTypeGate('formula', 'formula — > 100', 'record.f_formula > 100.0'), + zooTypeGate('autonumber', 'autonumber — is 0001', 'record.f_autonumber == "0001"'), + + // ── Temporal: `today()` is the CEL stdlib's, evaluated identically here ─── + zooTypeGate('date', 'date — before today', 'record.f_date != null && record.f_date < today()'), + zooTypeGate('datetime', 'datetime — set', 'record.f_datetime != null'), + zooTypeGate('time', 'time — is 14:30', 'record.f_time == "14:30:00"'), + + // ── Boolean / choice ───────────────────────────────────────────────────── + zooTypeGate('boolean', 'boolean — true', 'record.f_boolean == true'), + zooTypeGate('toggle', 'toggle — true', 'record.f_toggle == true'), + zooTypeGate('select', 'select — high', 'record.f_select == "high"'), + zooTypeGate('radio', 'radio — yes', 'record.f_radio == "yes"'), + zooTypeGate('multiselect', 'multiselect — has red', '"red" in record.f_multiselect'), + zooTypeGate('checkboxes', 'checkboxes — has email', '"email" in record.f_checkboxes'), + zooTypeGate('tags', 'tags — any', 'record.f_tags.size() > 0'), + + // ── Structured ─────────────────────────────────────────────────────────── + zooTypeGate('json', 'json — nested key', 'record.f_json != null && record.f_json.nested.k == "v"'), + zooTypeGate('location', 'location — lat > 40', 'record.f_location != null && record.f_location.lat > 40.0'), + zooTypeGate('address', 'address — US', 'record.f_address != null && record.f_address.country == "US"'), + zooTypeGate('color', 'color — is #2563EB', 'record.f_color == "#2563EB"'), + zooTypeGate('composite', 'composite — width 10', 'record.f_composite != null && record.f_composite.width == 10'), + zooTypeGate('repeater', 'repeater — 2 rows', 'record.f_repeater != null && record.f_repeater.size() == 2'), + zooTypeGate('record', 'record-of-records — score 9', 'record.f_record != null && record.f_record.primary.score == 9'), + zooTypeGate('vector', 'vector — 4 dims', 'record.f_vector != null && record.f_vector.size() == 4'), + + // ── Operators, not field types: the shapes a real predicate combines ───── + zooTypeGate('and', 'AND — boolean && number', 'record.f_boolean == true && record.f_number > 100'), + zooTypeGate('or', 'OR — select || rating', 'record.f_select == "high" || record.f_rating >= 4'), + zooTypeGate('not', 'NOT — !boolean', '!(record.f_boolean)'), + zooTypeGate('ternary', 'ternary — rating ? :', 'record.f_rating >= 4 ? true : false'), +]; + +export const allPredicateMatrixActions = [ + ZooRelationGateAction, + ZooOwnerGateAction, + ZooUserIdentityGateAction, + ZooDialectSplitAction, + ZooToolbarGateAction, + ZooVisibleStringAction, + ZooVisibleTaggedAction, + ZooVisibleEnvelopeAction, + ZooDisabledAction, + ZooPermHeldAction, + ZooPermMissingAction, + ZooPermAndAction, + ZooPermEmptyAction, + ...ZooTypeGates, +]; diff --git a/examples/app-showcase/src/ui/views/field-zoo.view.ts b/examples/app-showcase/src/ui/views/field-zoo.view.ts new file mode 100644 index 0000000000..fa47bd2f19 --- /dev/null +++ b/examples/app-showcase/src/ui/views/field-zoo.view.ts @@ -0,0 +1,169 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineView, P } from '@objectstack/spec'; + +const data = { provider: 'object' as const, object: 'showcase_field_zoo' }; + +/** + * Field Zoo views — the LIST-side half of the action-gating matrix declared in + * `ui/actions/predicate-matrix.action.ts`. + * + * The actions there carry the gates; this file is what puts them on a screen + * where a disagreement between surfaces is visible. Two seeded records do the + * work: **Specimen — Full** (every field populated) and **Specimen — Minimal** + * (most fields `null`), so every gate has a record it must pass and a record it + * must fail. + */ +export const FieldZooViews = defineView({ + /** + * The default list. Columns stay deliberately narrow — `name` plus a handful + * of scalars — because the interesting thing about this view is what it does + * NOT show. + * + * Almost every gate in the matrix reads a field that is not a column here: + * `owner_id`, `f_lookup`, `f_json`, `f_location`, `f_tags` … A list's + * `$select` is built from its COLUMNS, so before objectui#3501 the server was + * never asked for those fields, and CEL treats an absent key as a FAULT + * rather than as null — fail-closed on the row kebab and the selection bar, + * fail-open on the lenient paths. The symptom was a row action that worked on + * the detail page (which fetches the whole record) and silently vanished in + * the list. Keeping the columns narrow is what keeps that regression + * detectable here. + */ + list: { + label: 'Field Zoo', + type: 'grid', + data, + columns: [ + { field: 'name' }, + { field: 'f_select' }, + { field: 'f_number' }, + { field: 'f_boolean' }, + { field: 'f_rating' }, + ], + + /** + * Row-level conditional formatting over a RELATION field — the display-side + * twin of the action gates, and the same trap. `f_lookup` IS a column + * nowhere in this view, and even when a relational field IS shown the + * client expands it for its label, replacing the stored id with the whole + * related record. A rule comparing it therefore had to be written one way + * for an expanded surface and another way for a plain one; binding the + * relation as its foreign key everywhere (objectui#3501) is what makes this + * single rule correct on both. + */ + conditionalFormatting: [ + { condition: P`record.f_lookup != null`, style: { backgroundColor: 'rgba(37, 99, 235, 0.08)' } }, + ], + + /** + * The selection-bar half of the matrix. Each name resolves against the + * object's declared actions and is promoted into a selection-bar button + * carrying that action's label, icon, `visible` AND `requiredPermissions` — + * the last of which the bar used to drop on the floor (objectui#3492), so + * an action hidden in the row kebab reappeared here the moment a row was + * ticked. + * + * What to look for, with BOTH specimens selected: + * • `showcase_zoo_relation_gate` — offered (Full's two lookups resolve + * to the same account id), and offered identically in the row `⋮` menu + * and the record header. Three surfaces, one verdict. + * • `showcase_zoo_owner_gate` — offered to whoever seeded the workspace + * (both specimens are theirs). `owner_id` is a platform-INJECTED column + * that object metadata never publishes, so it is also what proves a + * `$select` harvest knows the platform columns and does not drop it as + * a typo (objectui#3501). + * • `showcase_zoo_user_gate` — absent until you assign `f_user` to + * yourself on a record; `sys_user` rows cannot be seeded. + * • `showcase_zoo_visible_*` — the three authoring forms of one + * predicate. All three appear, and the run reports ONE skipped record: + * Minimal's `f_boolean` is false, so it is excluded from the run rather + * than silently acted on. + * • `showcase_zoo_perm_held` — present only for a caller holding + * `showcase.export_data`. + * • `showcase_zoo_perm_missing` / `_and` — absent for EVERYONE. A button + * here is the objectui#3492 regression, on screen. + * • `showcase_zoo_perm_empty` — always present; an empty declaration + * passes. + */ + bulkActions: [ + 'showcase_zoo_relation_gate', + 'showcase_zoo_owner_gate', + 'showcase_zoo_user_gate', + 'showcase_zoo_visible_string', + 'showcase_zoo_visible_tagged', + 'showcase_zoo_visible_envelope', + 'showcase_zoo_perm_held', + 'showcase_zoo_perm_missing', + 'showcase_zoo_perm_and', + 'showcase_zoo_perm_empty', + ], + }, + + listViews: { + /** + * The same matrix with the predicate fields PROMOTED TO COLUMNS. + * + * The pair is the point: this view and the default one above declare the + * same gates over the same records and must reach the same verdicts. They + * differ only in whether the gated field is also displayed — which is to + * say, only in whether the client expands it and whether the projection + * would have included it anyway. Any button that appears in one and not the + * other is a relation-binding or projection bug, and nothing else. + */ + gated_columns: { + label: 'Gated fields as columns', + type: 'grid', + data, + columns: [ + { field: 'name' }, + { field: 'f_lookup' }, + { field: 'f_master_detail' }, + { field: 'f_boolean' }, + { field: 'f_rating' }, + ], + bulkActions: [ + 'showcase_zoo_relation_gate', + 'showcase_zoo_owner_gate', + 'showcase_zoo_user_gate', + 'showcase_zoo_visible_string', + 'showcase_zoo_visible_tagged', + 'showcase_zoo_visible_envelope', + 'showcase_zoo_perm_held', + 'showcase_zoo_perm_missing', + 'showcase_zoo_perm_and', + 'showcase_zoo_perm_empty', + ], + }, + + /** + * `bulkActionDefs` — the OTHER bulk vocabulary, authored inline rather than + * resolved from a name. It carries the per-record `visible` directly, which + * is the form whose eligibility split the dialog reports: over both + * specimens it acts on Full and reports Minimal as skipped, rather than + * quietly including it. + * + * `execution: 'aggregate'` is not decoration — a `custom` def without it is + * a no-op the parser refuses outright ("the button runs, reports success + * for every selected record, and does nothing"). Aggregate means ONE + * dispatch for the whole selection, with the eligible ids in + * `params._selectedIds`; the def resolves its `name` against the object's + * declared actions to find something to dispatch. + */ + inline_bulk_defs: { + label: 'Inline bulk defs', + type: 'grid', + data, + columns: [{ field: 'name' }, { field: 'f_boolean' }, { field: 'f_rating' }], + bulkActionDefs: [ + { + name: 'showcase_zoo_visible_string', + operation: 'custom', + execution: 'aggregate', + label: 'Rated 4+ only', + visible: P`record.f_rating >= 4`, + }, + ], + }, + }, +}); diff --git a/examples/app-showcase/src/ui/views/index.ts b/examples/app-showcase/src/ui/views/index.ts index ef8f99f0f4..731665cf3b 100644 --- a/examples/app-showcase/src/ui/views/index.ts +++ b/examples/app-showcase/src/ui/views/index.ts @@ -5,3 +5,4 @@ export { ProjectViews } from './project.view.js'; export { InquiryViews } from './inquiry.view.js'; export { BusinessUnitViews } from './business-unit.view.js'; export { ContactViews } from './contact.view.js'; +export { FieldZooViews } from './field-zoo.view.js';