Skip to content

Commit 3a6ec4f

Browse files
baozhoutaoclaude
andcommitted
feat(approvals): block a decision on a required output (#2955)
The platform half of this issue gives `decisionOutputs` a `required` flag: the server rejects an approve that carries no value for one, before any write, so the run can never resume past the node with the key a downstream `expression` approver reads still missing. The console mirrors it where the approver actually is. A required output is marked required on the param it synthesizes, so the dialog flags the empty field and refuses Confirm instead of sending a decision the server will 400. APPROVE only, matching the server: a reject leaves down the reject edge where nothing reads the outputs, so the reject dialog offers the same fields without blocking on them — the two dialogs now differ in exactly that flag. Both decision surfaces get it from the one shared helper, and a backend that predates the field requires nothing, which is the behaviour of the previous commit unchanged. Verified against the showcase runtime (its dynamic-approval flow declares the flag): the record header's Approve refuses with "Next Reviewers 为必填项" and sends no request, its Reject offers the field unmarked, the Approval Center drawer agrees on both, and a filled approve routes the co-sign node to the picked user. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent ce4112b commit 3a6ec4f

7 files changed

Lines changed: 122 additions & 19 deletions

.changeset/decision-outputs-reach-both-decision-surfaces.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,13 @@ The widget mapping now lives in one place (`utils/decisionOutputParams`), so
4141
the two surfaces cannot drift apart again, and the round trip through param
4242
resolution — the stage that actually broke — is pinned by tests.
4343

44-
Not fixed here: `DecisionOutputDef` has no `required`, so a flow author still
45-
cannot demand that an approver fill an output before approving. That needs the
46-
spec-side field first (framework), and `onEmptyApprovers` remains the only
47-
backstop until then.
44+
**And a `required` output is now enforced at the field.** The spec grew
45+
`decisionOutputs[].required` (the platform half of this issue, shipping in
46+
`@objectstack/spec` + `@objectstack/plugin-approvals`) — the server rejects an
47+
approve carrying no value for one, before any write. The dialog marks those params
48+
required, so the approver is stopped at the empty field with the Confirm button
49+
refusing rather than by a 400 after the round trip. Only on APPROVE: the server
50+
never requires them on a reject (the run leaves down the reject edge, where
51+
nothing reads the outputs), so the two dialogs differ in exactly that flag. On a
52+
backend that predates the field nothing is required, which is the behavior above
53+
unchanged.

packages/app-shell/src/utils/decisionOutputParams.test.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,10 +93,31 @@ describe('decisionOutputParams — declaration → param', () => {
9393
expect(free.helpText).toBe('t:actions.decisionOutput.helpMultiValue');
9494
});
9595

96-
it('declares no output as required — the flow author cannot demand one yet', () => {
97-
// `DecisionOutputDef` carries no `required` flag (objectui#2955 follow-up);
98-
// until it does, a hard-required picker would be unreachable metadata.
99-
expect(decisionOutputParams([{ key: 'r', type: 'user' }], t)[0].required).toBe(false);
96+
it('leaves an undeclared output optional', () => {
97+
expect(decisionOutputParams([{ key: 'r', type: 'user' }], t, { decision: 'approve' })[0].required)
98+
.toBe(false);
99+
});
100+
101+
it('marks a `required` output required on the APPROVE dialog', () => {
102+
// The server rejects a blank required output before any write; marking it
103+
// here stops the approver at the field instead of at a 400.
104+
expect(
105+
decisionOutputParams([{ key: 'r', type: 'position', required: true }], t, { decision: 'approve' })[0].required,
106+
).toBe(true);
107+
});
108+
109+
it('never marks it required on the REJECT dialog', () => {
110+
// Mirrors the server: a reject leaves down the reject edge where nothing
111+
// reads the outputs, so demanding them would trap the rejection.
112+
expect(
113+
decisionOutputParams([{ key: 'r', type: 'position', required: true }], t, { decision: 'reject' })[0].required,
114+
).toBe(false);
115+
});
116+
117+
it('requires nothing when the caller names no decision', () => {
118+
// A surface that has not said which decision it is collecting for must not
119+
// block — and a pre-`required` backend sends the flag on nothing anyway.
120+
expect(decisionOutputParams([{ key: 'r', required: true }], t)[0].required).toBe(false);
100121
});
101122
});
102123

@@ -122,6 +143,16 @@ describe('decisionOutputParams — the params survive resolution (objectui#2955)
122143
expect(widgetFor(param)).toMatchObject({ type: 'user', multiple: true });
123144
});
124145

146+
it('the required flag survives resolution, so the dialog can block on it', () => {
147+
// `paramToField` feeds `ActionParamDialog`'s required check — if the flag
148+
// were dropped in resolution the dialog would submit a blank value and let
149+
// the server 400 instead.
150+
const [param] = decisionOutputParams(
151+
[{ key: 'k', type: 'position', required: true }], t, { decision: 'approve' },
152+
);
153+
expect(widgetFor(param)).toMatchObject({ type: 'lookup', required: true });
154+
});
155+
125156
it('an untyped output stays free text', () => {
126157
const [param] = decisionOutputParams([{ key: 'note' }], t);
127158
expect(widgetFor(param)).toMatchObject({ type: 'text' });

packages/app-shell/src/utils/decisionOutputParams.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ export interface DecisionOutputDef {
4141
/** Record kind to pick (`user` / `department` / `position` / `team`); absent → free text. */
4242
type?: string;
4343
multiple?: boolean;
44+
/**
45+
* The approver must supply this one to APPROVE. The server enforces it
46+
* (`decide()` rejects a blank required output before any write); the dialog
47+
* mirrors it so the approver is stopped at the field rather than by a 400.
48+
* Absent on a backend that predates the flag — then nothing is required.
49+
*/
50+
required?: boolean;
4451
}
4552

4653
/** Param-name prefix the api handler folds back into the nested `outputs` body. */
@@ -91,19 +98,23 @@ function humanizeKey(key: string): string {
9198
* so no `_actions.<action>.params.*` bundle entry can ever match them and the
9299
* literal we pass IS what renders (objectui#2762 P0-3).
93100
*
94-
* Every output is optional: `DecisionOutputDef` carries no `required` flag, so
95-
* a flow author cannot yet demand one (objectui#2955 follow-up) — an omitted
96-
* output simply isn't sent.
101+
* A `required` output is marked required on the APPROVE dialog only — pass
102+
* `{ decision: 'approve' }`. That mirrors the server, which enforces `required`
103+
* on approve and never on reject (the run leaves down the reject edge, where
104+
* nothing reads the outputs, so demanding routing data to say "no" would trap
105+
* the rejection). Marking it here is what turns the server's 400 into a
106+
* blocked Confirm button with the field flagged, instead of a round trip.
97107
*/
98108
export function decisionOutputParams(
99109
defs: DecisionOutputDef[],
100110
t: (key: string) => string,
111+
opts: { decision?: 'approve' | 'reject' } = {},
101112
): Array<Record<string, unknown>> {
102113
return defs.filter((d) => d && d.key).map((d) => {
103114
const base = {
104115
name: `${DECISION_OUTPUT_PARAM_PREFIX}${d.key}`,
105116
label: d.label ?? humanizeKey(d.key),
106-
required: false,
117+
required: opts.decision === 'approve' && d.required === true,
107118
};
108119
if (d.type === 'user') {
109120
return {

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

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,12 +145,19 @@ const DeclaredActionButton: React.FC<{
145145
// handler folds them into the nested `outputs` body the decide route
146146
// expects. A free-text output accepts comma-separated values (the
147147
// service accepts CSV for multi-id outputs).
148-
const isDecideAction = /\/(approve|reject)$/.test(String((action as any).target ?? ''));
148+
// Which decision this action records — `required` outputs are enforced
149+
// on approve only (server and dialog agree), so the reject dialog offers
150+
// the same fields without blocking on them.
151+
const decision = /\/approve$/.test(String((action as any).target ?? ''))
152+
? 'approve' as const
153+
: /\/reject$/.test(String((action as any).target ?? ''))
154+
? 'reject' as const
155+
: undefined;
149156
// Widget mapping (typed picker vs free text) lives in the shared helper,
150157
// so the record header's Approve/Reject renders the same controls
151158
// (objectui#2955).
152-
const outputParams = isDecideAction
153-
? decisionOutputParams(decisionOutputDefs(recordData), t)
159+
const outputParams = decision
160+
? decisionOutputParams(decisionOutputDefs(recordData), t, { decision })
154161
: [];
155162
const dispatch: any = {
156163
...rest,

packages/app-shell/src/views/RecordDetailView.approvalDecisionActions.test.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,25 @@ describe('buildApprovalDecisionActions — decision outputs (objectui#2955)', ()
103103
});
104104
});
105105

106+
it('requires a `required` output to approve, but never to reject', () => {
107+
const actions = byName(
108+
buildApprovalDecisionActions(
109+
pending({
110+
decision_output_defs: [
111+
{ key: 'parallel_positions', type: 'position', multiple: true, required: true },
112+
],
113+
}),
114+
t,
115+
) as any[],
116+
);
117+
const paramOf = (name: string) =>
118+
(actions[name].actionParams as any[]).find((p) => p.name === 'outputs.parallel_positions');
119+
expect(paramOf('approve_request').required).toBe(true);
120+
// Mirrors the server, which enforces `required` on approve only — a reject
121+
// blocked on routing data the reject edge never reads would trap it.
122+
expect(paramOf('reject_request').required).toBe(false);
123+
});
124+
106125
it('collects nothing extra when there is no pending request', () => {
107126
const [approve] = buildApprovalDecisionActions(null, t) as any[];
108127
expect((approve.actionParams as any[]).map((p) => p.name)).toEqual(['comment']);

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

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -172,9 +172,13 @@ export function buildApprovalDecisionActions(
172172
// intent has to ride the type.
173173
type: 'textarea',
174174
};
175-
const decisionParams = [
175+
// `required` outputs are enforced on approve only — the server rejects a
176+
// blank one there and never on reject, so the two dialogs differ in exactly
177+
// that flag and nothing else.
178+
const defs = decisionOutputDefs(pendingRequest);
179+
const decisionParams = (decision: 'approve' | 'reject') => [
176180
commentParam,
177-
...decisionOutputParams(decisionOutputDefs(pendingRequest), (key: string) => t(key)),
181+
...decisionOutputParams(defs, (key: string) => t(key), { decision }),
178182
];
179183
// A pending approval is THE decision the approver came to make, so the
180184
// decision buttons must outrank app `record_header` actions rather than being
@@ -194,7 +198,7 @@ export function buildApprovalDecisionActions(
194198
order: -100,
195199
locations: ['record_header'],
196200
refreshAfter: true,
197-
actionParams: decisionParams,
201+
actionParams: decisionParams('approve'),
198202
successMessage: t('approvals.approveSuccess', { defaultValue: 'Approved' }),
199203
},
200204
{
@@ -208,7 +212,7 @@ export function buildApprovalDecisionActions(
208212
locations: ['record_header'],
209213
refreshAfter: true,
210214
confirmText: t('approvals.rejectConfirm', { defaultValue: 'Reject this approval request?' }),
211-
actionParams: decisionParams,
215+
actionParams: decisionParams('reject'),
212216
successMessage: t('approvals.rejectSuccess', { defaultValue: 'Rejected' }),
213217
},
214218
] as unknown as ActionDef[];

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,31 @@ describe('DeclaredActionsBar chrome localization (objectui#2762)', () => {
269269
expect(screen.getByRole('toolbar')).toHaveAttribute('aria-label', '决策');
270270
});
271271

272+
it.each([
273+
['/api/v1/approvals/requests/{id}/approve', true],
274+
['/api/v1/approvals/requests/{id}/reject', false],
275+
])('marks a required output required on %s → %s', async (target, expected) => {
276+
// The server enforces `required` on approve and never on reject; the two
277+
// dialogs must differ in exactly that flag (objectui#2955).
278+
render(
279+
<DeclaredActionsBar
280+
objectName="sys_approval_request"
281+
record={{
282+
...REQUEST,
283+
decision_output_defs: [{ key: 'parallel_positions', type: 'position', required: true }],
284+
}}
285+
location="record_section"
286+
actions={[{
287+
name: 'decide', type: 'api', label: 'Decide', target, locations: ['record_section'],
288+
}] as any}
289+
/>,
290+
);
291+
fireEvent.click(screen.getByText('Decide'));
292+
await waitFor(() => expect(executeSpy).toHaveBeenCalled());
293+
const params = executeSpy.mock.calls[0][0].actionParams as Array<Record<string, unknown>>;
294+
expect(params[0].required).toBe(expected);
295+
});
296+
272297
it('hands the picker target under `reference`, the key that survives resolution', async () => {
273298
// objectui#2955: the params are spelled for `resolveActionParams`, which
274299
// rebuilds an inline param from a fixed key list and reads the target from

0 commit comments

Comments
 (0)