Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/approval-dynamic-outputs-expression.md
Original file line number Diff line number Diff line change
@@ -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.<key>` 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).
8 changes: 8 additions & 0 deletions apps/console/src/services/approvalsApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<key>` params, folded into a
* nested `outputs` body by the api handler); the flow receives them as
* `<nodeId>.<key>` variables. Absent when the node declares none.
*/
decision_outputs?: string[];
}

/**
Expand Down
15 changes: 15 additions & 0 deletions packages/app-shell/src/hooks/useConsoleActionRuntime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,21 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
: undefined;
const body: Record<string, any> = wrap ? { [wrap]: resolvedParams } : { ...resolvedParams };

// #3447: decision outputs. DeclaredActionsBar synthesizes one param per
// author-declared output key, named `outputs.<key>` (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];
Expand Down
25 changes: 23 additions & 2 deletions packages/app-shell/src/views/DeclaredActionsBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.<key>`; 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
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -261,18 +261,58 @@ export function FlowObjectListField({
disabled={disabled}
/>
) : col.kind === 'reference' ? (
<div className="flex-1">
<ReferenceCombobox
resolved={resolveRefKind(col.ref, (k) => 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}
/>
</div>
(() => {
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 (
<div className="flex-1 space-y-1">
<VariableTextInput
mode="expression"
mono
value={raw}
onValueChange={(v) => 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.<field> · trigger.<field> · vars.<node>.<key>'}
disabled={disabled}
/>
<FlowExprIssue value={raw} role="value" />
</div>
);
}
return (
<div className="flex-1">
<ReferenceCombobox
resolved={resolved}
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}
/>
</div>
);
})()
) : col.kind === 'select' ? (
(() => {
const current =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -539,12 +539,18 @@ const FLOW_NODE_CONFIG: Record<string, FlowConfigField[]> = {
{ 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',
Expand All @@ -568,6 +574,19 @@ const FLOW_NODE_CONFIG: Record<string, FlowConfigField[]> = {
},
},
},
{
// #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
Expand Down Expand Up @@ -601,6 +620,17 @@ const FLOW_NODE_CONFIG: Record<string, FlowConfigField[]> = {
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.' },
Expand Down
Loading