From 3846c2ccc2dc64e0975fd3f2966e2613f8e3217e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 00:15:30 +0000 Subject: [PATCH 1/3] test(showcase): specimens for the inline picker and the record-backed approvers (#3405, #3508) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both issues shipped their platform fix but left their last acceptance item on a real backend, and showcase had no specimen for either shape. **#3405 — `p_assignee`, an inline `lookup` at `sys_user`.** The gallery already covered an inline picker aimed at one of the app's own objects (`p_account` → `showcase_account`). The reported bug (PLAT-DEF-005: "assign an inspector" handed the supervisor a box wanting a pasted UUID) was a picker at a SYSTEM object, and a person is the reference a human is least able to identify by id — `sys_user` ids here are opaque 32-char tokens. That earns its own specimen rather than riding on the account one. **#3508 — `showcase_approver_bindings`, one approval node per record-backed approver kind.** The existing flows cover `position` heavily and `dynamic-approval` covers `org_membership_level` + `expression`; `user`, `team`, `department`, `manager` and `field` had no specimen at all, which is why the designer's Value control could regress unnoticed. `queue` is deliberately absent — #3536 made it non-authorable. The flow is `draft` on purpose, not by omission: the record-backed kinds point at rows a fresh boot does not have (`sys_team` is empty, and unit membership / position assignment are runtime admin actions by design — see the seed notes). An `active` flow over them would resolve to nobody, which is the exact silent dead slot #3508 exists to remove; shipping that as a demo would re-teach the bug. `draft` keeps it out of the runtime while still loading in the designer, which is the surface under test. Verified on a real backend (showcase :5411 + objectui HEAD console :5412, own DB): - the server publishes `reference` for both inline picker params; - the param dialog renders Assignee as a searchable picker — typing "Dev" issues `GET /api/v1/data/sys_user?top=50&search=Dev` and offers "Dev Admin / admin@objectos.ai", with no degrade warning logged; - the designer renders a record picker per kind off the published `xRef.sources`, listing the real seeded org tree for `department` (`sys_business_unit`), `manager` as a disabled "Resolved automatically" cell, `field` as the trigger-object field selector, and no `queue` in the Type dropdown. No `/api/v1/meta/:type` registry calls — the old broken path. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LkvDs4y4gveyB5NJZKxaZt --- .../flows/approver-bindings.flow.ts | 143 ++++++++++++++++++ .../src/automation/flows/index.ts | 5 + examples/app-showcase/src/ui/actions/index.ts | 9 ++ 3 files changed, 157 insertions(+) create mode 100644 examples/app-showcase/src/automation/flows/approver-bindings.flow.ts diff --git a/examples/app-showcase/src/automation/flows/approver-bindings.flow.ts b/examples/app-showcase/src/automation/flows/approver-bindings.flow.ts new file mode 100644 index 0000000000..338e72ed57 --- /dev/null +++ b/examples/app-showcase/src/automation/flows/approver-bindings.flow.ts @@ -0,0 +1,143 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineFlow } from '@objectstack/spec'; + +/** + * #3508 dogfood: one node per RECORD-BACKED approver kind. + * + * The designer's Approvers → Value control used to query the metadata registry + * (`GET /api/v1/meta/:type`) for every kind. `user` / `team` / `department` / + * `position` are DATA rows in the platform's directory objects, not registry + * entries, so candidates came back empty and the control degraded to a free-text + * box — the author could only hand-type a userid. `APPROVER_VALUE_BINDINGS` + * now publishes the data contract per kind (`xRef.sources`), and the designer + * renders a real record lookup off it. + * + * This flow is the authoring specimen for that surface: every kind whose control + * changed gets a node, so opening it in the flow designer exercises each one. + * The existing approval flows in `./index.ts` already cover `position` heavily + * (it is the kind showcase actually routes on) and `./dynamic-approval.flow.ts` + * covers `org_membership_level` + `expression`; the kinds below are the ones no + * other flow declares. + * + * ## Why `draft` + * + * Deliberate, not an oversight. The record-backed kinds point at rows a fresh + * boot does not have: `sys_user` holds only the seeded admin, `sys_team` is + * empty (the seeded `Team` rows are the DEMO `showcase_team` object), and both + * unit membership (`sys_business_unit_member`) and position assignment + * (`sys_user_position`) are runtime admin actions by design — see the seed + * notes in `src/data/seed/index.ts`. An `active` flow over them would resolve + * to nobody, which is precisely the silent dead slot #3508 exists to remove. + * Shipping that as a demo would re-teach the bug. `draft` keeps it out of the + * runtime while still loading in the designer, which is the surface under test. + * + * `queue` is absent on purpose: #3536 made it non-authorable + * (`NON_AUTHORABLE_APPROVER_TYPES` → `xEnumDeprecated`) because the engine has + * no queue branch, so the designer no longer offers it. + */ +export const ApproverBindingsFlow = defineFlow({ + name: 'showcase_approver_bindings', + label: 'Approver Binding Specimens (#3508)', + description: + 'Authoring specimen: one approval node per record-backed approver kind, so the designer ' + + 'renders each Value control. Draft on purpose — a fresh org has no rows for these kinds.', + type: 'autolaunched', + status: 'draft', + nodes: [ + { + id: 'start', + type: 'start', + label: 'On Field Zoo Updated', + config: { + objectName: 'showcase_field_zoo', + triggerType: 'record-after-update', + }, + }, + { + id: 'by_user', + type: 'approval', + label: 'Named user', + config: { + // `sys_user` row id. The most portable-hostile choice there is — an id + // does not survive an environment migration and dies with the person — + // which is why the designer's Type dropdown lists it LAST. + approvers: [{ type: 'user', value: '' }], + behavior: 'first_response', + }, + }, + { + id: 'by_team', + type: 'approval', + label: 'Team members', + config: { + // `sys_team` row id; the engine expands it through `sys_team_member`. + approvers: [{ type: 'team', value: '' }], + behavior: 'first_response', + }, + }, + { + id: 'by_department', + type: 'approval', + label: 'Department members', + config: { + // `sys_business_unit` row id — NOT `sys_department`. The seeded org tree + // (bu_acme / bu_field_ops / bu_west_coast / …) is what this picker lists. + approvers: [{ type: 'department', value: '' }], + behavior: 'first_response', + }, + }, + { + id: 'by_position', + type: 'approval', + label: 'Position holders', + config: { + // Commits the position's machine NAME, not its row id: the engine routes + // on `sys_user_position.position`, and a machine name is what stays + // valid across environments. `manager` is a showcase position. + approvers: [{ type: 'position', value: 'manager' }], + behavior: 'first_response', + }, + }, + { + id: 'by_manager', + type: 'approval', + label: "Submitter's manager", + config: { + // Resolved at run time from the submitter — there is no value to author, + // so the designer disables the cell instead of offering a free-text box. + approvers: [{ type: 'manager' }], + behavior: 'first_response', + }, + }, + { + id: 'by_field', + type: 'approval', + label: 'User in a trigger field', + config: { + // Value is a FIELD NAME on the trigger object, not a record reference — + // so this one keeps the object-field selector rather than a record + // lookup. `f_user` is Field Zoo's single-value `sys_user` reference. + approvers: [{ type: 'field', value: 'f_user' }], + behavior: 'first_response', + }, + }, + { id: 'approved', type: 'end', label: 'Approved' }, + { id: 'rejected', type: 'end', label: 'Rejected' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'by_user' }, + { id: 'e2', source: 'by_user', target: 'by_team', label: 'approve' }, + { id: 'e3', source: 'by_team', target: 'by_department', label: 'approve' }, + { id: 'e4', source: 'by_department', target: 'by_position', label: 'approve' }, + { id: 'e5', source: 'by_position', target: 'by_manager', label: 'approve' }, + { id: 'e6', source: 'by_manager', target: 'by_field', label: 'approve' }, + { id: 'e7', source: 'by_field', target: 'approved', label: 'approve' }, + { id: 'e8', source: 'by_user', target: 'rejected', label: 'reject' }, + { id: 'e9', source: 'by_team', target: 'rejected', label: 'reject' }, + { id: 'e10', source: 'by_department', target: 'rejected', label: 'reject' }, + { id: 'e11', source: 'by_position', target: 'rejected', label: 'reject' }, + { id: 'e12', source: 'by_manager', target: 'rejected', label: 'reject' }, + { id: 'e13', source: 'by_field', target: 'rejected', label: 'reject' }, + ], +}); diff --git a/examples/app-showcase/src/automation/flows/index.ts b/examples/app-showcase/src/automation/flows/index.ts index 2d6be803fe..49176ba157 100644 --- a/examples/app-showcase/src/automation/flows/index.ts +++ b/examples/app-showcase/src/automation/flows/index.ts @@ -2,6 +2,7 @@ import { defineFlow } from '@objectstack/spec'; import { DynamicApprovalFlow } from './dynamic-approval.flow'; +import { ApproverBindingsFlow } from './approver-bindings.flow'; /** * Task Completed → Notify — an autolaunched, record-triggered flow that fires @@ -1635,4 +1636,8 @@ export const allFlows = [ InboundTaskWebhookFlow, // #3447 P2 dogfood: expression approvers + decision outputs, end to end. DynamicApprovalFlow, + // #3508 dogfood: one approval node per record-backed approver kind, so the + // designer's Value control has a specimen for each. Draft on purpose — see + // the module docstring. + ApproverBindingsFlow, ]; diff --git a/examples/app-showcase/src/ui/actions/index.ts b/examples/app-showcase/src/ui/actions/index.ts index 50a6bf4a2e..fe44e37a6a 100644 --- a/examples/app-showcase/src/ui/actions/index.ts +++ b/examples/app-showcase/src/ui/actions/index.ts @@ -212,6 +212,15 @@ export const ActionParamGalleryAction = defineAction({ name: 'p_account', type: 'lookup', reference: 'showcase_account', label: 'Related account', helpText: 'Inline lookup param — searchable record picker, no UUID typing.', }, + // #3405 — the same inline picker aimed at a SYSTEM object. This is the + // shape the bug was reported against (PLAT-DEF-005: "assign an inspector" + // handed the supervisor a box wanting a pasted UUID), so it earns its own + // specimen: `sys_user` is not one of the app's own objects, and a person is + // the reference a human is least able to identify by id. + { + name: 'p_assignee', type: 'lookup', reference: 'sys_user', label: 'Assignee', + helpText: 'Inline lookup at a system object — searchable by name/email, never a UUID.', + }, { name: 'p_color', type: 'color', label: 'Accent color', defaultValue: '#7C3AED' }, // Spec `autonumber` param → the AutoNumber widget (read-only, auto-assigned). { name: 'p_reference', type: 'autonumber', label: 'Reference #' }, From 13ecb3755087b69da467200980e0b9d35906bbf8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 00:18:34 +0000 Subject: [PATCH 2/3] chore: add an empty changeset for the showcase specimens (#3405, #3508) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Check Changeset" gate requires one on every PR, not only on PRs that touch a published package — and it sanctions an empty changeset for a change that releases nothing, which this is: both specimens live in `@objectstack/example-showcase`, which is `private: true`, so changesets would skip it regardless. Matches the existing empty-changeset entries (`adr-0104-design-doc-only.md` and friends). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LkvDs4y4gveyB5NJZKxaZt --- .changeset/showcase-approver-and-picker-specimens.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .changeset/showcase-approver-and-picker-specimens.md diff --git a/.changeset/showcase-approver-and-picker-specimens.md b/.changeset/showcase-approver-and-picker-specimens.md new file mode 100644 index 0000000000..9d589ee077 --- /dev/null +++ b/.changeset/showcase-approver-and-picker-specimens.md @@ -0,0 +1,4 @@ +--- +--- + +test(showcase): dogfood specimens for the inline record picker (#3405) and the record-backed approver kinds (#3508) — releases nothing. Both live in `@objectstack/example-showcase`, which is private. From f738ddbf7605e5e83744d58506fbfb2233c65e80 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 00:33:10 +0000 Subject: [PATCH 3/3] i18n(showcase): translate the new inline picker param (#3405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check-i18n-coverage` ratchets each example at its current untranslated count, so a newly declared label that skips a locale the example claims to support pushes the count up and fails the gate — 456 → 460 here (`p_assignee`'s `label` and `helpText`, each counted on two surfaces). Translated rather than ratcheting the baseline up: ratcheting is what the gate exists to prevent, and the script only sanctions `--update` for a count that went DOWN. The gallery's older params sit inside the 456 baseline; this one is not allowed to widen it. `负责人` is what the bundle already renders for `showcase_task.assignee`, so the same idea keeps one word. The flow specimen's own label/description are not flagged — the `i18n/missing-action` rule covers action params, not flow nodes. `check-i18n-coverage` now reports "none new"; validate, typecheck and the 58 example tests all pass. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LkvDs4y4gveyB5NJZKxaZt --- .../src/system/translations/index.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/examples/app-showcase/src/system/translations/index.ts b/examples/app-showcase/src/system/translations/index.ts index a40ffaaeaa..1a3de0e90e 100644 --- a/examples/app-showcase/src/system/translations/index.ts +++ b/examples/app-showcase/src/system/translations/index.ts @@ -267,6 +267,23 @@ export const ShowcaseTranslationBundle = { showcase_field_zoo: { label: '字段动物园', pluralLabel: '字段动物园', fields: { f_lookups: { 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 + // count up and fails the gate. (The gallery's older params sit inside + // 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. + _actions: { + showcase_action_param_gallery: { + params: { + p_assignee: { + label: '负责人', + helpText: '指向系统对象的内联查找 —— 可按姓名或邮箱搜索,无需填写 UUID。', + }, + }, + }, + }, }, }, },