diff --git a/.changeset/approval-dynamic-outputs-expression.md b/.changeset/approval-dynamic-outputs-expression.md new file mode 100644 index 000000000..b66f38902 --- /dev/null +++ b/.changeset/approval-dynamic-outputs-expression.md @@ -0,0 +1,10 @@ +--- +"@object-ui/app-shell": minor +"@object-ui/console": minor +--- + +feat(approvals): dynamic decision-output fields + expression approver editing (framework#3447 P2) + +- The approve/reject dialogs now render one input per author-declared decision-output key (the row's `decision_outputs`, per-request so it can't be a static action param). DeclaredActionsBar synthesizes `outputs.` params; the api handler folds them into the nested `outputs` body the decide route expects. Blank optional outputs are omitted. +- The flow designer's approval-node approver list renders an `expression`-type approver's value as a CEL expression input (mono + syntax check) instead of a dead free-text reference box, with a placeholder teaching the three legal roots (`current.*` / `trigger.*` / `vars.*`). Flow-scope pickers are deliberately not wired in — approval expressions have their own closed root set, and offering flow-scope paths would teach exactly the spelling the runtime rejects. +- Static fallback descriptor gains the `Expression (CEL)` approver type, the expression-only `resolveAs` column, and the node-level `onEmptyApprovers` policy select (the online form derives all of these from the engine's published configSchema). diff --git a/apps/console/src/services/approvalsApi.ts b/apps/console/src/services/approvalsApi.ts index cb93122ca..7f4d630a2 100644 --- a/apps/console/src/services/approvalsApi.ts +++ b/apps/console/src/services/approvalsApi.ts @@ -104,6 +104,14 @@ export interface ApprovalRequestRow { }; /** ADR-0044 revision round on this (run, node): absent/1 = first round. */ round?: number; + /** + * framework#3447 — the node's author-declared decision-output keys + * (`config.decisionOutputs`). DeclaredActionsBar synthesizes one input per + * key on the approve/reject dialogs (`outputs.` params, folded into a + * nested `outputs` body by the api handler); the flow receives them as + * `.` variables. Absent when the node declares none. + */ + decision_outputs?: string[]; } /** diff --git a/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx b/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx index 17e90080c..ad19cd204 100644 --- a/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx +++ b/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx @@ -297,6 +297,21 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons : undefined; const body: Record = wrap ? { [wrap]: resolvedParams } : { ...resolvedParams }; + // #3447: decision outputs. DeclaredActionsBar synthesizes one param per + // author-declared output key, named `outputs.` (the key set is + // per-request, so it can't be a static action param). Fold the dotted + // params into the nested `outputs` object the approvals decide route + // expects. Scoped to the `outputs.` prefix — a generic dotted-key fold + // could reinterpret existing actions' literal param names. + for (const k of Object.keys(body)) { + if (k.startsWith('outputs.') && k.length > 'outputs.'.length) { + const value = body[k]; + delete body[k]; + if (value === undefined || value === '') continue; // blank optional output → omit + (body.outputs ??= {})[k.slice('outputs.'.length)] = value; + } + } + if (rowRecord && action.recordIdParam) { const rowField = action.recordIdField || 'id'; const rowValue = rowRecord[rowField]; diff --git a/packages/app-shell/src/views/DeclaredActionsBar.tsx b/packages/app-shell/src/views/DeclaredActionsBar.tsx index 26a87936e..0e66942b1 100644 --- a/packages/app-shell/src/views/DeclaredActionsBar.tsx +++ b/packages/app-shell/src/views/DeclaredActionsBar.tsx @@ -123,6 +123,26 @@ const DeclaredActionButton: React.FC<{ // input), and reserve `params` for the `_rowRecord` stash the api handler // reads for `{id}` interpolation + record-id injection. const { params: rawParams, ...rest } = action as ActionDef & { params?: unknown }; + // #3447: an approval decision may carry author-declared structured + // outputs. The key set is PER-REQUEST (each approval node declares its + // own `decisionOutputs`, surfaced on the row as `decision_outputs`), so + // it cannot be a static action param — synthesize one text param per key + // for the decide actions. Params are named `outputs.`; the api + // handler folds them into the nested `outputs` body the decide route + // expects. Comma-separated values are legal (the service accepts CSV for + // multi-id outputs). + const declaredOutputKeys: string[] = + Array.isArray(recordData.decision_outputs) + && /\/(approve|reject)$/.test(String((action as any).target ?? '')) + ? (recordData.decision_outputs as unknown[]).map(String) + : []; + const outputParams = declaredOutputKeys.map((key) => ({ + name: `outputs.${key}`, + label: key.split(/[_-]+/).filter(Boolean).map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '), + type: 'text' as const, + required: false, + helpText: 'Handed to the flow as a decision output. Comma-separate multiple values.', + })); const dispatch: any = { ...rest, // Localized copies ride the dispatch: the runner reads `label` for the @@ -141,8 +161,9 @@ const DeclaredActionButton: React.FC<{ objectName, params: { _rowRecord: record }, }; - if (Array.isArray(rawParams) && rawParams.length > 0) { - dispatch.actionParams = rawParams; + const staticParams = Array.isArray(rawParams) ? rawParams : []; + if (staticParams.length > 0 || outputParams.length > 0) { + dispatch.actionParams = [...staticParams, ...outputParams]; } await execute(dispatch as ActionDef); } finally { diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/FlowObjectListField.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/FlowObjectListField.tsx index 98e9833ad..1e19c79a9 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/FlowObjectListField.tsx +++ b/packages/app-shell/src/views/metadata-admin/inspectors/FlowObjectListField.tsx @@ -261,18 +261,58 @@ export function FlowObjectListField({ disabled={disabled} /> ) : col.kind === 'reference' ? ( -
- row.values[k])} - value={typeof row.values[col.key] === 'string' ? (row.values[col.key] as string) : ''} - onCommit={(v) => setCell(row.id, col.key, typeof v === 'string' ? v : '')} - onBlur={() => flush(rows)} - placeholder={col.placeholder} - disabled={disabled} - context={context} - showHint={false} - /> -
+ (() => { + const resolved = resolveRefKind(col.ref, (k) => row.values[k]); + const disc = col.ref?.kindFrom + ? String(row.values[col.ref.kindFrom] ?? '') + : ''; + // #3447: `expression` is a discriminator value, not a + // reference kind — an approver whose sibling `type` is + // 'expression' authors a CEL expression over the approval + // roots (current/trigger/vars). Render the expression + // input (mono + syntax check) instead of a dead free-text + // reference box. The flow-scope picker/roots are + // deliberately NOT wired in: approval expressions have + // their own root set, and offering flow-scope paths here + // (record.x, bare fields) would teach exactly the + // spelling the runtime rejects; root validation runs + // server-side (os lint + node-entry pre-check). + if (resolved === undefined && disc === 'expression') { + const raw = typeof row.values[col.key] === 'string' ? (row.values[col.key] as string) : ''; + return ( +
+ setCell(row.id, col.key, v)} + onBlur={() => flush(rows)} + onKeyDown={(e) => { + if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); + }} + groups={[]} + placeholder={col.placeholder ?? 'current. · trigger. · vars..'} + disabled={disabled} + /> + +
+ ); + } + return ( +
+ setCell(row.id, col.key, typeof v === 'string' ? v : '')} + onBlur={() => flush(rows)} + placeholder={col.placeholder} + disabled={disabled} + context={context} + showHint={false} + /> +
+ ); + })() ) : col.kind === 'select' ? ( (() => { const current = diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts b/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts index b824f3b42..b0e9a10db 100644 --- a/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts +++ b/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts @@ -539,12 +539,18 @@ const FLOW_NODE_CONFIG: Record = { { value: 'manager', label: 'Manager' }, { value: 'field', label: 'Field' }, { value: 'queue', label: 'Queue' }, + // #3447: CEL over current.* / trigger.* / vars.*, resolved at node + // entry — the value cell switches to the expression input. + { value: 'expression', label: 'Expression (CEL)' }, ], }, { // Polymorphic: the picker follows the row's `type`. `manager` takes no // value (resolved from the submitter's manager_id) so it stays unmapped - // → free text; unmapped/empty types likewise fall back to free text. + // → free text; unmapped/empty types likewise fall back to free text — + // except `expression` (#3447), which the cell special-cases into the + // CEL expression input (it is a discriminator value, not a reference + // kind, so it deliberately has no `map` entry). key: 'value', label: 'Value', kind: 'reference', @@ -568,6 +574,19 @@ const FLOW_NODE_CONFIG: Record = { }, }, }, + { + // #3447: expression-only — how the expression's resolved ids expand + // into people. Dead config on other types (linted server-side). + key: 'resolveAs', + label: 'Resolve as', + kind: 'select', + options: [ + { value: 'user', label: 'User ids (default)' }, + { value: 'department', label: 'Department ids → members' }, + { value: 'position', label: 'Position names → holders' }, + { value: 'team', label: 'Team ids → members' }, + ], + }, { // Group label for `per_group` sign-off (#3266): approvers sharing a // label form one group; the node advances when EACH group reaches @@ -601,6 +620,17 @@ const FLOW_NODE_CONFIG: Record = { placeholder: 'approval_status', help: 'Business-object field to mirror request status onto (pending/approved/rejected). Should be readonly.', }), + // #3447: empty-slate policy — load-bearing for expression approvers, whose + // slate is runtime data and may legitimately resolve to nobody. + cfg('onEmptyApprovers', 'If no approver resolves', 'select', { + options: [ + { value: 'admin_rescue', label: 'Hold for admin takeover (default)' }, + { value: 'fail', label: 'Fail the node (config bug)' }, + { value: 'auto_approve', label: 'Auto-approve (waves through!)' }, + ], + defaultValue: 'admin_rescue', + help: 'What an empty resolved approver slate does at node entry. Auto-approve silently waves the record through — opt in deliberately.', + }), // Per-node SLA escalation (spec ApprovalEscalationSchema, nested under // config.escalation). Sub-fields reveal once escalation is enabled. { id: 'escalation.enabled', path: ['config', 'escalation', 'enabled'], label: 'SLA escalation', kind: 'boolean', defaultValue: 'false', help: 'Escalate when a decision is not recorded within the timeout.' },