Skip to content

Commit 7f153de

Browse files
os-zhuangclaude
andauthored
feat(approvals): typed output pickers, quick-path guard, expression completion (framework#3447, #2829) (#2831)
Typed decision_output_defs render record pickers (user people-picker / department/position/team lookups; multiple → id array). Quick decision paths (inline a/r, hover, mobile, bulk) are guarded off requests that declare decision outputs — only the drawer dialog collects them; buttons disable with an explanation. The expression approver input gains a three-group scope picker (current/trigger/vars) with inline root validation; nodeOutputRefs models approval outputs (decision + declared keys) so the previous stage's outputs are pickable, and vars.previous is always listed so legitimate vars.* references never flag as unknown. Browser-verified end to end (picker multi-select → co-sign slate). Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 072330d commit 7f153de

9 files changed

Lines changed: 193 additions & 39 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@object-ui/app-shell": minor
3+
"@object-ui/console": minor
4+
---
5+
6+
feat(approvals): typed decision-output pickers, quick-path guard, and approval-expression completion (framework#3447 follow-ups, #2829)
7+
8+
- Decision dialogs render TYPED decision outputs as record pickers: `decision_output_defs` (`{ key, label?, type, multiple? }`) maps `user` to the sys_user people picker and `department`/`position`/`team` to the matching system-object lookup; `multiple` collects an id array. Bare keys keep the text input.
9+
- Quick decision paths (inline a/r keyboard, hover buttons, mobile card buttons, bulk apply) no longer decide a request whose node declares decision outputs (#2829) — only the drawer dialog collects those fields. Buttons render disabled with an explanation; bulk selection excludes such rows via the existing "N actionable" messaging.
10+
- The approval `expression` approver input now has a scope-aware data picker and inline root validation: three groups — `current.<field>` (live at node entry), `trigger.<field>` (submit snapshot), `vars.*` (flow variables) — built by `useFlowScope` from the same materials as the condition picker but with the approval root set. `nodeOutputRefs` now models approval nodes (`<nodeId>.decision` + declared `decisionOutputs` keys), so the previous stage's outputs are pickable, and `vars.previous` is always listed so a legitimate `vars.*` reference is never flagged as out of scope.

apps/console/src/pages/system/ApprovalsInboxPage.tsx

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -803,13 +803,29 @@ export function ApprovalsInboxPage() {
803803
return (r.pending_approvers || []).some(a => idSet.has(a));
804804
}, [identities]);
805805

806+
/**
807+
* #2829 / framework#3447: a node that declares decision outputs expects the
808+
* approver to fill them — only the drawer's declared-action dialog collects
809+
* them, so the quick paths (inline a/r, hover buttons, bulk) must not decide
810+
* such a request: a quick approve would silently hand the flow nothing and
811+
* the next stage's expression approver would resolve an empty slate.
812+
*/
813+
const needsDecisionInputs = useCallback((r: ApprovalRequestRow): boolean =>
814+
Array.isArray(r.decision_outputs) && r.decision_outputs.length > 0, []);
815+
816+
/** Quick-decidable = actionable AND no declared decision outputs (#2829). */
817+
const quickDecidable = useCallback((r: ApprovalRequestRow): boolean =>
818+
isActionable(r) && !needsDecisionInputs(r), [isActionable, needsDecisionInputs]);
819+
806820
/**
807821
* Rows the user is actually allowed to bulk-act on:
808-
* status=pending AND one of the user's identities is in pending_approvers.
822+
* status=pending AND one of the user's identities is in pending_approvers
823+
* AND the node declares no decision outputs (#2829 — those need the drawer
824+
* dialog; they surface in the bulk bar as skipped).
809825
*/
810826
const actionableSelectedRows = useMemo(
811-
() => filteredRows.filter(r => selectedRowIds.has(r.id) && isActionable(r)),
812-
[filteredRows, selectedRowIds, isActionable],
827+
() => filteredRows.filter(r => selectedRowIds.has(r.id) && quickDecidable(r)),
828+
[filteredRows, selectedRowIds, quickDecidable],
813829
);
814830

815831
const allFilteredSelectable = filteredRows.filter(r => r.status === 'pending');
@@ -942,17 +958,17 @@ export function ApprovalsInboxPage() {
942958
} else if ((e.key === 'x' || e.key === ' ') && idx >= 0 && list[idx] && tab === 'pending') {
943959
e.preventDefault();
944960
toggleRow(list[idx].id);
945-
} else if (e.key === 'a' && idx >= 0 && list[idx] && isActionable(list[idx])) {
961+
} else if (e.key === 'a' && idx >= 0 && list[idx] && quickDecidable(list[idx])) {
946962
e.preventDefault();
947963
setApproveTarget(list[idx]);
948-
} else if (e.key === 'r' && idx >= 0 && list[idx] && isActionable(list[idx])) {
964+
} else if (e.key === 'r' && idx >= 0 && list[idx] && quickDecidable(list[idx])) {
949965
e.preventDefault();
950966
setRejectTarget(list[idx]);
951967
}
952968
};
953969
window.addEventListener('keydown', onKey);
954970
return () => window.removeEventListener('keydown', onKey);
955-
}, [selectedId, approveTarget, rejectTarget, tab, openDrawer, toggleRow, isActionable]);
971+
}, [selectedId, approveTarget, rejectTarget, tab, openDrawer, toggleRow, quickDecidable]);
956972

957973
// Drawer keyboard: ←/→ walk the visible list without going back to it.
958974
useEffect(() => {
@@ -1033,28 +1049,37 @@ export function ApprovalsInboxPage() {
10331049
function InlineActions({ r }: { r: ApprovalRequestRow }) {
10341050
if (!isActionable(r)) return null;
10351051
const busy = inlineActing === r.id;
1052+
// #2829: a request whose node declares decision outputs must go through
1053+
// the drawer's dialog (the only place those fields are collected) — render
1054+
// the quick buttons disabled with an explanation instead of hiding them.
1055+
const needsInputs = needsDecisionInputs(r);
1056+
const needsInputsHint = tr(
1057+
'needsDecisionInputs',
1058+
'This approval collects decision outputs — open it to decide.',
1059+
);
10361060
return (
10371061
<div
10381062
className="flex items-center gap-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity"
10391063
onClick={(e) => e.stopPropagation()}
1064+
title={needsInputs ? needsInputsHint : undefined}
10401065
>
10411066
<Button
10421067
size="sm"
10431068
variant="ghost"
10441069
className="h-7 px-2 text-emerald-700 hover:text-emerald-800 hover:bg-emerald-50 dark:text-emerald-400"
1045-
disabled={busy}
1070+
disabled={busy || needsInputs}
10461071
onClick={() => setApproveTarget(r)}
1047-
aria-label={tr('approve', 'Approve')}
1072+
aria-label={needsInputs ? needsInputsHint : tr('approve', 'Approve')}
10481073
>
10491074
<CheckCircle2 className="h-4 w-4" />
10501075
</Button>
10511076
<Button
10521077
size="sm"
10531078
variant="ghost"
10541079
className="h-7 px-2 text-red-600 hover:text-red-700 hover:bg-red-50 dark:text-red-400"
1055-
disabled={busy}
1080+
disabled={busy || needsInputs}
10561081
onClick={() => setRejectTarget(r)}
1057-
aria-label={tr('reject', 'Reject')}
1082+
aria-label={needsInputs ? needsInputsHint : tr('reject', 'Reject')}
10581083
>
10591084
<XCircle className="h-4 w-4" />
10601085
</Button>
@@ -1419,13 +1444,21 @@ export function ApprovalsInboxPage() {
14191444
</span>
14201445
</div>
14211446
{isActionable(r) && (
1422-
<div className="flex gap-2 pt-1" onClick={(e) => e.stopPropagation()}>
1423-
<Button size="sm" className="h-7 flex-1" disabled={inlineActing === r.id} onClick={() => setApproveTarget(r)}>
1447+
// #2829: outputs-declaring requests decide only via the
1448+
// drawer dialog — quick buttons disable with the hint.
1449+
<div
1450+
className="flex gap-2 pt-1"
1451+
onClick={(e) => e.stopPropagation()}
1452+
title={needsDecisionInputs(r)
1453+
? tr('needsDecisionInputs', 'This approval collects decision outputs — open it to decide.')
1454+
: undefined}
1455+
>
1456+
<Button size="sm" className="h-7 flex-1" disabled={inlineActing === r.id || needsDecisionInputs(r)} onClick={() => setApproveTarget(r)}>
14241457
<CheckCircle2 className="h-3.5 w-3.5 mr-1" />{tr('approve', 'Approve')}
14251458
</Button>
14261459
<Button
14271460
size="sm" variant="outline" className="h-7 flex-1 border-destructive text-destructive"
1428-
disabled={inlineActing === r.id} onClick={() => setRejectTarget(r)}
1461+
disabled={inlineActing === r.id || needsDecisionInputs(r)} onClick={() => setRejectTarget(r)}
14291462
>
14301463
<XCircle className="h-3.5 w-3.5 mr-1" />{tr('reject', 'Reject')}
14311464
</Button>

apps/console/src/services/approvalsApi.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,19 @@ export interface ApprovalRequestRow {
112112
* `<nodeId>.<key>` variables. Absent when the node declares none.
113113
*/
114114
decision_outputs?: string[];
115+
/**
116+
* framework#3447 follow-up: the normalized TYPED declarations behind
117+
* `decision_outputs` — `{ key, label?, type?, multiple? }` — so the decide
118+
* dialog renders a record picker (user / department / position / team; id
119+
* values, `multiple` → id array) instead of free text. Prefer this and fall
120+
* back to the bare key list (older backend).
121+
*/
122+
decision_output_defs?: Array<{
123+
key: string;
124+
label?: string;
125+
type?: 'text' | 'user' | 'department' | 'position' | 'team';
126+
multiple?: boolean;
127+
}>;
115128
}
116129

117130
/**

packages/app-shell/src/views/DeclaredActionsBar.tsx

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -131,18 +131,44 @@ const DeclaredActionButton: React.FC<{
131131
// handler folds them into the nested `outputs` body the decide route
132132
// expects. Comma-separated values are legal (the service accepts CSV for
133133
// multi-id outputs).
134-
const declaredOutputKeys: string[] =
135-
Array.isArray(recordData.decision_outputs)
136-
&& /\/(approve|reject)$/.test(String((action as any).target ?? ''))
137-
? (recordData.decision_outputs as unknown[]).map(String)
138-
: [];
139-
const outputParams = declaredOutputKeys.map((key) => ({
140-
name: `outputs.${key}`,
141-
label: key.split(/[_-]+/).filter(Boolean).map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),
142-
type: 'text' as const,
143-
required: false,
144-
helpText: 'Handed to the flow as a decision output. Comma-separate multiple values.',
145-
}));
134+
const isDecideAction = /\/(approve|reject)$/.test(String((action as any).target ?? ''));
135+
// Prefer the typed declarations (framework#3447 follow-up); fall back to
136+
// the bare key list from an older backend.
137+
const declaredOutputs: Array<{ key: string; label?: string; type?: string; multiple?: boolean }> =
138+
isDecideAction && Array.isArray(recordData.decision_output_defs) && recordData.decision_output_defs.length
139+
? (recordData.decision_output_defs as Array<{ key: string; label?: string; type?: string; multiple?: boolean }>)
140+
: isDecideAction && Array.isArray(recordData.decision_outputs)
141+
? (recordData.decision_outputs as unknown[]).map((k) => ({ key: String(k) }))
142+
: [];
143+
// Widget mapping: `user` has a dedicated sys_user picker widget; the
144+
// other record kinds ride a lookup param pointed at the system object.
145+
const OUTPUT_LOOKUP_OBJECTS: Record<string, string> = {
146+
department: 'sys_business_unit',
147+
position: 'sys_position',
148+
team: 'sys_team',
149+
};
150+
const outputParams = declaredOutputs.filter((d) => d.key).map((d) => {
151+
const label = d.label
152+
?? d.key.split(/[_-]+/).filter(Boolean).map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
153+
const base = { name: `outputs.${d.key}`, label, required: false } as Record<string, unknown>;
154+
if (d.type === 'user') {
155+
return {
156+
...base, type: 'user', multiple: d.multiple === true,
157+
helpText: 'Handed to the flow as a decision output.',
158+
};
159+
}
160+
const lookupObject = d.type ? OUTPUT_LOOKUP_OBJECTS[d.type] : undefined;
161+
if (lookupObject) {
162+
return {
163+
...base, type: 'lookup', referenceTo: lookupObject, multiple: d.multiple === true,
164+
helpText: 'Handed to the flow as a decision output.',
165+
};
166+
}
167+
return {
168+
...base, type: 'text',
169+
helpText: 'Handed to the flow as a decision output. Comma-separate multiple values.',
170+
};
171+
});
146172
const dispatch: any = {
147173
...rest,
148174
// Localized copies ride the dispatch: the runner reads `label` for the

packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeConfigField.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,11 @@ export interface FlowNodeConfigFieldProps {
3434
context?: FlowReferenceContext;
3535
/** In-scope variable references for the data-picker (#1934). */
3636
scopeGroups?: ScopeGroup[];
37+
/** #3447: approval-expression picker groups (current/trigger/vars roots). */
38+
approvalScopeGroups?: ScopeGroup[];
3739
}
3840

39-
export function FlowNodeConfigField({ field, value, onCommit, disabled, locale, context, scopeGroups }: FlowNodeConfigFieldProps) {
41+
export function FlowNodeConfigField({ field, value, onCommit, disabled, locale, context, scopeGroups, approvalScopeGroups }: FlowNodeConfigFieldProps) {
4042
const refMode: 'expression' | 'template' =
4143
field.refMode ?? (field.kind === 'expression' ? 'expression' : 'template');
4244
const control = (() => {
@@ -114,6 +116,7 @@ export function FlowNodeConfigField({ field, value, onCommit, disabled, locale,
114116
itemLabel={t('engine.inspector.flowNode.list.item', locale)}
115117
context={context}
116118
scopeGroups={scopeGroups}
119+
approvalScopeGroups={approvalScopeGroups}
117120
/>
118121
);
119122
case 'number':

packages/app-shell/src/views/metadata-admin/inspectors/FlowNodeInspector.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ export function FlowNodeInspector({ selection, draft, onPatch, onClearSelection,
136136
[loc],
137137
);
138138
// In-scope variable references for this node, for the data-picker (#1934).
139-
const { groups: scopeGroups } = useFlowScope(draft as Record<string, unknown>, loc?.scopeAnchorId, nestedLoopRefs);
139+
const { groups: scopeGroups, approvalExpressionGroups } = useFlowScope(draft as Record<string, unknown>, loc?.scopeAnchorId, nestedLoopRefs);
140140
const fields = React.useMemo(() => {
141141
const schema = node?.type ? configSchemas[node.type] : undefined;
142142
const serverFields = schema !== undefined ? jsonSchemaToFlowFields(schema) : null;
@@ -364,6 +364,7 @@ export function FlowNodeInspector({ selection, draft, onPatch, onClearSelection,
364364
locale={locale}
365365
context={{ draft, node }}
366366
scopeGroups={scopeGroups}
367+
approvalScopeGroups={approvalExpressionGroups}
367368
/>
368369
);
369370
})}

packages/app-shell/src/views/metadata-admin/inspectors/FlowObjectListField.tsx

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,13 @@ export interface FlowObjectListFieldProps {
103103
context?: FlowReferenceContext;
104104
/** In-scope variable references for `expression` columns (#1934). */
105105
scopeGroups?: ScopeGroup[];
106+
/**
107+
* #3447: picker groups for approval `expression` approver cells — the
108+
* closed current/trigger/vars root set. Regular flow scopeGroups must NOT
109+
* be offered there (record.* / bare-field spellings are rejected at
110+
* runtime), which is why this rides as its own prop.
111+
*/
112+
approvalScopeGroups?: ScopeGroup[];
106113
}
107114

108115
export function FlowObjectListField({
@@ -117,6 +124,7 @@ export function FlowObjectListField({
117124
itemLabel,
118125
context,
119126
scopeGroups,
127+
approvalScopeGroups,
120128
}: FlowObjectListFieldProps) {
121129
const external = React.useMemo(
122130
() =>
@@ -271,12 +279,12 @@ export function FlowObjectListField({
271279
// 'expression' authors a CEL expression over the approval
272280
// roots (current/trigger/vars). Render the expression
273281
// input (mono + syntax check) instead of a dead free-text
274-
// reference box. The flow-scope picker/roots are
275-
// deliberately NOT wired in: approval expressions have
276-
// their own root set, and offering flow-scope paths here
277-
// (record.x, bare fields) would teach exactly the
278-
// spelling the runtime rejects; root validation runs
279-
// server-side (os lint + node-entry pre-check).
282+
// reference box, with the APPROVAL scope groups — never
283+
// the regular flow scopeGroups, whose record.x /
284+
// bare-field spellings the runtime rejects. The same
285+
// groups feed FlowExprIssue, so an out-of-contract root
286+
// warns inline with a "did you mean" before os lint /
287+
// the node-entry pre-check reject it server-side.
280288
if (resolved === undefined && disc === 'expression') {
281289
const raw = typeof row.values[col.key] === 'string' ? (row.values[col.key] as string) : '';
282290
return (
@@ -290,11 +298,11 @@ export function FlowObjectListField({
290298
onKeyDown={(e) => {
291299
if (e.key === 'Enter') (e.target as HTMLInputElement).blur();
292300
}}
293-
groups={[]}
301+
groups={approvalScopeGroups ?? []}
294302
placeholder={col.placeholder ?? 'current.<field> · trigger.<field> · vars.<node>.<key>'}
295303
disabled={disabled}
296304
/>
297-
<FlowExprIssue value={raw} role="value" />
305+
<FlowExprIssue value={raw} role="value" scopeGroups={approvalScopeGroups} />
298306
</div>
299307
);
300308
}

packages/app-shell/src/views/metadata-admin/inspectors/flow-scope.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,13 @@
3131
*/
3232

3333
/** Which group a reference belongs to (drives the picker's section headers). */
34-
export type ScopeGroupId = 'variables' | 'outputs' | 'loop' | 'trigger';
34+
export type ScopeGroupId =
35+
| 'variables' | 'outputs' | 'loop' | 'trigger'
36+
// #3447: approval `expression` approvers see a DIFFERENT root set than flow
37+
// conditions (current/trigger/vars — never `record`/bare fields). Their
38+
// picker groups carry their own ids so they can never leak into the regular
39+
// condition picker.
40+
| 'approval_current' | 'approval_trigger' | 'approval_vars';
3541

3642
/**
3743
* One pickable reference. `token` is the BARE form (no braces); the picker
@@ -186,6 +192,18 @@ export function nodeOutputRefs(node: FlowNodeLike): ScopeRef[] {
186192
for (const f of asArray(cfg.fields)) add(str(asRecord(f).name));
187193
}
188194

195+
// Approval (framework#3447): the resume envelope writes `<nodeId>.decision`,
196+
// and each author-declared decision output (`decisionOutputs` — bare key or
197+
// typed `{ key, … }`) lands as `<nodeId>.<key>` — the values a later
198+
// approval node's `expression` approver reads as `vars.<nodeId>.<key>`.
199+
if (type === 'approval' && nodeId) {
200+
add(`${nodeId}.decision`);
201+
for (const entry of asArray(cfg.decisionOutputs)) {
202+
const key = typeof entry === 'string' ? entry : str(asRecord(entry).key);
203+
if (key) add(`${nodeId}.${key}`);
204+
}
205+
}
206+
189207
return out;
190208
}
191209

0 commit comments

Comments
 (0)