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. 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/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。', + }, + }, + }, + }, }, }, }, 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 #' },