From 0be6371ffcd5dcdac1cf35b0fb8599d277b23fd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Thu, 6 Aug 2026 22:27:45 -0700 Subject: [PATCH 1/4] =?UTF-8?q?feat(showcase):=20=E5=8A=A8=E4=BD=9C?= =?UTF-8?q?=E6=98=BE=E9=9A=90=E7=9F=A9=E9=98=B5=E2=80=94=E2=80=94=E5=9B=9B?= =?UTF-8?q?=E4=B8=AA=E5=8A=A8=E4=BD=9C=E9=9D=A2=20=C3=97=20=E4=B8=89?= =?UTF-8?q?=E7=A7=8D=20visible=20=E5=86=99=E6=B3=95=20=C3=97=20requiredPer?= =?UTF-8?q?missions=20=C3=97=20=E6=AF=8F=E7=A7=8D=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E7=9A=84=E8=B0=93=E8=AF=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 给 `showcase_field_zoo` 加一整套可证伪的动作门控夹具,覆盖决定「按钮出不出现」的两件事: `visible`(记录上的 CEL 谓词)与 `requiredPermissions`(ADR-0066 D4 能力门)。 两条种子记录是可证伪性的来源:Specimen — Full(字段全填)必须出现, Specimen — Minimal(多数为 null)必须消失——写错的表现是按钮出现在错误的地方, 而不是一个悄悄变绿的测试。 - **四面一致性**:同一个动作声明在行内 kebab / 记录页头 / ⋯ 菜单,并被视图的 `bulkActions` 提升进批量栏。关系比关系(`f_lookup == f_lookups[0]`)是最利的 一条:两个操作数都是 lookup,谁被展开谁就变成对象,比较就静默为假。 `gated_columns` 视图把 `f_lookup` 设为列(于是被 `populate=`),默认列表两个都不展开 —— 同样的记录、同样的谓词,两个视图必须给出同一结论(objectui#3501)。 - **三种 visible 写法**:裸 CEL 字符串 / `P` 标签模板 / `{ dialect, source }` 信封, 同一条谓词并排钉住;三者若在同一条记录上分歧,就是某个渲染器把某一种当特例了。 布尔 visible 不在此列——`ExpressionInput` 不接受它,`objectstack build` 发不出这种 形状,由 objectui 自己的单测钉(objectui#3492)。 - **requiredPermissions**:持有 / 不持有 / 两个取 AND / 空声明四种,四个面同一结论。 新增 `showcase.restricted_ops` 能力:**定义了但不授予任何权限集**——门的可证伪一半, 正是批量栏当初整个忽略 `requiredPermissions` 时无人察觉的原因。 - **字段类型谓词园**:每种字段类型一条 `visible`,含 lookup(单/多/空)、master_detail、 tree、user、文本族、数值族、时间族、布尔/选项族、json/location/address/composite/ repeater/vector,以及 AND/OR/NOT/三元。全部对着真实 runtime 的 CEL 引擎实测过, 不是照着规范猜的。 顺带钉住三条实测得到的作者规则:必须 `record.` 前缀;遍历/调方法前必须判空 (`null` 没有成员也没有方法,不判空在 Minimal 上直接 fault);用 `contains()` / `matches()` 而不是 `startsWith()` / `endsWith()`(后者不是 CEL,objectui 会降级到遗留 JS 引擎并告警)。 `showcase_zoo_dialect_split` 是故意留下的**红色夹具**:同一个 `.contains()` 谓词在行内 kebab 里正常、在记录页头被判抛错并隐藏——页头不走 CEL 引擎(objectui#3521)。 按仓库规矩不粉饰,改成一屏可对比的现场。 Co-Authored-By: Claude Opus 5 --- examples/app-showcase/objectstack.config.ts | 4 +- .../app-showcase/src/security/capabilities.ts | 25 +- examples/app-showcase/src/ui/actions/index.ts | 8 + .../src/ui/actions/predicate-matrix.action.ts | 458 ++++++++++++++++++ .../src/ui/views/field-zoo.view.ts | 162 +++++++ examples/app-showcase/src/ui/views/index.ts | 1 + 6 files changed, 655 insertions(+), 3 deletions(-) create mode 100644 examples/app-showcase/src/ui/actions/predicate-matrix.action.ts create mode 100644 examples/app-showcase/src/ui/views/field-zoo.view.ts 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 fe1c9bb5fb..e0e4f53789 100644 --- a/examples/app-showcase/src/security/capabilities.ts +++ b/examples/app-showcase/src/security/capabilities.ts @@ -57,4 +57,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/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..9b09ad05e8 --- /dev/null +++ b/examples/app-showcase/src/ui/actions/predicate-matrix.action.ts @@ -0,0 +1,458 @@ +// 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 identity spelling 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 one that most resembles what + * an app actually writes. + * + * Note what it is NOT written against: `record.owner_id`, the platform-injected + * ownership column. That is the predicate every app reaches for first, and the + * author-time validator rejects it — `unknown field owner_id` — because + * injected columns are absent from published object metadata. Filed upstream + * rather than worked around here. + */ +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'), + + // ── 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, + 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..5438d3e3fc --- /dev/null +++ b/examples/app-showcase/src/ui/views/field-zoo.view.ts @@ -0,0 +1,162 @@ +// 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_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_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_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'; From a49562e00c13da406f2b83e41ba0daf858126e8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Fri, 7 Aug 2026 01:44:05 -0700 Subject: [PATCH 2/4] =?UTF-8?q?fix(showcase):=20=E6=96=B0=E5=A3=B0?= =?UTF-8?q?=E6=98=8E=E7=9A=84=E5=8A=A8=E4=BD=9C/=E8=A7=86=E5=9B=BE?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=E5=BD=93=E5=9C=BA=E8=A1=A5=20zh-CN=20?= =?UTF-8?q?=E7=BF=BB=E8=AF=91=EF=BC=8C=E4=B8=8D=E6=8A=AC=20i18n=20?= =?UTF-8?q?=E6=A3=98=E8=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check-i18n-coverage` 把本示例的未翻译计数冻结在 451,新声明的标签一旦跳过 zh-CN 就把计数顶上去、门变红 —— 我加了 ~56 个动作标签和 3 个视图标签却一条没译, CI 报 451 → 572。 翻译而不是抬基线:那个脚本的抬头写明「debt is FROZEN, and the build fails the moment it grows」,本文件里既有的两条注释也各自写着「Translated at birth ... this example is ratcheted at its current untranslated count」。抬基线正是它防的 那件事。 补齐 `zh-CN.objects.showcase_field_zoo` 下的 `_actions`(12 条门控动作 + 44 条 字段类型谓词,含 3 条 successMessage)与 `_views`(3 个视图标签)。标签本身是覆盖 夹具的说明文字,中文照写同一句技术陈述,不做意译。 顺带修 `field-zoo.view.ts` 里一处缩进(上一笔批量替换留下的)。 现在 `check-i18n-coverage: OK (none new)`,基线仍是 451。 Co-Authored-By: Claude Opus 5 --- .../src/system/translations/index.ts | 68 +++++++++++++++++++ .../src/ui/views/field-zoo.view.ts | 2 +- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/examples/app-showcase/src/system/translations/index.ts b/examples/app-showcase/src/system/translations/index.ts index 4fed39c841..d3cdd81f5c 100644 --- a/examples/app-showcase/src/system/translations/index.ts +++ b/examples/app-showcase/src/system/translations/index.ts @@ -409,6 +409,13 @@ export const ShowcaseTranslationBundle = { showcase_field_zoo: { label: '字段动物园', pluralLabel: '字段动物园', fields: { f_lookups: { label: '查找 → 客户(多值)' } }, + // 三个视图共用一套门控动作,差别只在「被门控的字段是否也作为列显示」—— + // 即客户端是否展开它、投影是否本来就会带上它。见 ui/views/field-zoo.view.ts。 + _views: { + list: { 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 @@ -416,6 +423,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: { @@ -425,6 +436,63 @@ export const ShowcaseTranslationBundle = { }, }, }, + showcase_zoo_relation_gate: { label: '查找 == 多值第一项', successMessage: '两个关系都解析到同一个 id。' }, + showcase_zoo_user_gate: { label: '指派给我(user 字段)', 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_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/views/field-zoo.view.ts b/examples/app-showcase/src/ui/views/field-zoo.view.ts index 5438d3e3fc..5c9b6f0868 100644 --- a/examples/app-showcase/src/ui/views/field-zoo.view.ts +++ b/examples/app-showcase/src/ui/views/field-zoo.view.ts @@ -118,7 +118,7 @@ export const FieldZooViews = defineView({ ], bulkActions: [ 'showcase_zoo_relation_gate', - 'showcase_zoo_user_gate', + 'showcase_zoo_user_gate', 'showcase_zoo_visible_string', 'showcase_zoo_visible_tagged', 'showcase_zoo_visible_envelope', From d99e262616775e4a6873ec9f294ae8b4adb5b68b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Fri, 7 Aug 2026 02:01:01 -0700 Subject: [PATCH 3/4] =?UTF-8?q?fix(showcase):=20`=5Fviews`=20=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E5=88=97=E8=A1=A8=E6=8C=89=E8=BF=90=E8=A1=8C=E6=97=B6?= =?UTF-8?q?=E9=94=AE=20`default`=20=E7=BF=BB=E8=AF=91=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E6=98=AF=20`list`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 合入 main 后 CI 仍红 451 → 452,本地却绿 —— 差别在于 CI 跑的是**分支与 main 的 合并结果**,而分支落后 main 136 个提交,其中 b3c1f3cd5(#5164)改了 `_views` 的 键法:不带 `name` 声明的容器默认列表被 composer 注册为 `.default`, bundle 的键就是这个裸运行时键。我写的 `_views.list` 是没有任何查找能到达的拼法, 正是 #5164 修掉的那个。 `ContactViews` 的注释里已经把这条定案写清楚了,我照它对齐。 顺带记下这次的教训并写进注释:**落后 main 的分支上「本地绿」不作数** —— 这条 只有在合并结果上才暴露。 合并后复跑:`check-i18n-coverage: OK (none new)`、`pnpm validate` exit 0、 `typecheck` 通过、146 tests passed。 Co-Authored-By: Claude Opus 5 --- examples/app-showcase/src/system/translations/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/examples/app-showcase/src/system/translations/index.ts b/examples/app-showcase/src/system/translations/index.ts index c6e6d5b1f8..1536da8dd3 100644 --- a/examples/app-showcase/src/system/translations/index.ts +++ b/examples/app-showcase/src/system/translations/index.ts @@ -421,8 +421,12 @@ export const ShowcaseTranslationBundle = { 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: { - list: { label: '字段动物园' }, + default: { label: '字段动物园' }, gated_columns: { label: '被门控字段作为列' }, inline_bulk_defs: { label: '内联批量定义' }, }, From 1071c86bc17341d6530b3320ab899e1e2b59ca6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8C=85=E5=91=A8=E6=B6=9B?= Date: Fri, 7 Aug 2026 02:03:36 -0700 Subject: [PATCH 4/4] =?UTF-8?q?feat(showcase):=20=E5=8A=A0=E5=9B=9E=20`rec?= =?UTF-8?q?ord.owner=5Fid=20=3D=3D=20os.user.id`=20=E5=BD=92=E5=B1=9E?= =?UTF-8?q?=E9=97=A8=EF=BC=8C=E5=B9=B6=E7=BA=A0=E6=AD=A3=E4=B8=80=E6=AE=B5?= =?UTF-8?q?=E5=B7=B2=E7=BB=8F=E8=BF=87=E6=9C=9F=E7=9A=84=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 合 main 之后重新实测:这条谓词现在**校验通过**。#5378 已经把答案放进 `packages/spec/src/data/injected-system-columns.ts`,让作者时校验能推导出平台注入 列 —— 我原先注释里写的「校验器拒绝它,已上报」在合并后的树上是**假话**,不能就 这么随 PR 发出去。 于是不只是改注释,而是把这条谓词作为正式标本加回来(`showcase_zoo_owner_gate` + 类型园里的 `showcase_zoo_t_owner`,三个记录面 + 两个视图的 `bulkActions`)。 它值得独立一条,因为 `owner_id` 是注入列而非声明字段,同时压两个面: - 作者时:它就是 #5378 落地的活证据; - 运行时:列表 `$select` 按对象声明字段构造,消费方若不认识平台列就会把它当拼错 丢掉、让谓词在缺席键上 fault —— 即 objectui#3501 的 `PLATFORM_RECORD_COLUMNS`。 与既有的 `showcase_zoo_user_gate`(声明的 `user` 字段)成对:注入列与声明字段必须 门控一致。 标签当场补 zh-CN,棘轮仍是 451、`check-i18n-coverage: OK (none new)`; `pnpm validate` exit 0、`typecheck` 通过、146 tests passed。 Co-Authored-By: Claude Opus 5 --- .../src/system/translations/index.ts | 2 + .../src/ui/actions/predicate-matrix.action.ts | 45 ++++++++++++++----- .../src/ui/views/field-zoo.view.ts | 7 +++ 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/examples/app-showcase/src/system/translations/index.ts b/examples/app-showcase/src/system/translations/index.ts index 1536da8dd3..aca0cb46b7 100644 --- a/examples/app-showcase/src/system/translations/index.ts +++ b/examples/app-showcase/src/system/translations/index.ts @@ -452,6 +452,7 @@ 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:字符串' }, @@ -469,6 +470,7 @@ export const ShowcaseTranslationBundle = { 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' }, diff --git a/examples/app-showcase/src/ui/actions/predicate-matrix.action.ts b/examples/app-showcase/src/ui/actions/predicate-matrix.action.ts index 9b09ad05e8..e59c32be3c 100644 --- a/examples/app-showcase/src/ui/actions/predicate-matrix.action.ts +++ b/examples/app-showcase/src/ui/actions/predicate-matrix.action.ts @@ -137,18 +137,41 @@ export const ZooRelationGateAction = defineAction({ }); /** - * The identity spelling over a declared `user` relation. `f_user` is unseeded - * by design — `sys_user` rows come from sign-up, not seeds (see the note on + * **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 one that most resembles what - * an app actually writes. - * - * Note what it is NOT written against: `record.owner_id`, the platform-injected - * ownership column. That is the predicate every app reaches for first, and the - * author-time validator rejects it — `unknown field owner_id` — because - * injected columns are absent from published object metadata. Filed upstream - * rather than worked around here. + * 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', @@ -389,6 +412,7 @@ export const ZooTypeGates = [ 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'), @@ -443,6 +467,7 @@ export const ZooTypeGates = [ export const allPredicateMatrixActions = [ ZooRelationGateAction, + ZooOwnerGateAction, ZooUserIdentityGateAction, ZooDialectSplitAction, ZooToolbarGateAction, diff --git a/examples/app-showcase/src/ui/views/field-zoo.view.ts b/examples/app-showcase/src/ui/views/field-zoo.view.ts index 5c9b6f0868..fa47bd2f19 100644 --- a/examples/app-showcase/src/ui/views/field-zoo.view.ts +++ b/examples/app-showcase/src/ui/views/field-zoo.view.ts @@ -68,6 +68,11 @@ export const FieldZooViews = defineView({ * • `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 @@ -83,6 +88,7 @@ export const FieldZooViews = defineView({ */ bulkActions: [ 'showcase_zoo_relation_gate', + 'showcase_zoo_owner_gate', 'showcase_zoo_user_gate', 'showcase_zoo_visible_string', 'showcase_zoo_visible_tagged', @@ -118,6 +124,7 @@ export const FieldZooViews = defineView({ ], bulkActions: [ 'showcase_zoo_relation_gate', + 'showcase_zoo_owner_gate', 'showcase_zoo_user_gate', 'showcase_zoo_visible_string', 'showcase_zoo_visible_tagged',