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
22 changes: 22 additions & 0 deletions .changeset/approval-pending-approver-groups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
"@objectstack/spec": patch
"@objectstack/plugin-approvals": patch
---

feat(plugin-approvals): expose per-group membership of pending approvers (objectui#2807)

`per_group` (会签) requests now carry `pending_approver_groups` on the
enriched row — a map from each still-pending approver id to the group key(s)
it fills (e.g. `{ "u_devadmin": ["finance", "legal"] }`). A client can label
each "waiting on" chip with the group it represents instead of showing
duplicate, context-free names.

- Resolved in `attachDecisionProgress` from the same open-time
`__approverGroups` snapshot the `decision_progress` groups already use, so
the two never disagree.
- Only the **pending** slots are mapped (a resolved approver has left
`pending_approvers`), and **synthetic** (unnamed, `#N`) group keys are
dropped — a `· #0` sub-tag would be noise.
- Absent for non-`per_group` behaviors. Display-only; the engine's
finalization tally stays authoritative.
- Added to the `ApprovalRequestRow` contract in `@objectstack/spec`.
27 changes: 27 additions & 0 deletions packages/plugins/plugin-approvals/src/approval-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1451,6 +1451,33 @@ describe('ApprovalService — decision_progress & deep links (#2678 P1.5)', () =
expect(row.decision_progress.groups.find((g: any) => g.group === 'finance')).toMatchObject({ got: 0, satisfied: false });
});

it('per_group: pending_approver_groups maps each pending approver to its group (objectui#2807)', async () => {
const req = await svc.openNodeRequest(cfg([U('l1', 'legal'), U('f1', 'finance')], 'per_group'), CTX);
let row: any = await svc.getRequest(req.id, SYS);
// Every pending slot is labeled with the group it fills.
expect(row.pending_approver_groups).toEqual({ l1: ['legal'], f1: ['finance'] });
// Once legal signs off, l1 drops out of pending — and out of the map.
await svc.decideNode(req.id, { decision: 'approve', actorId: 'l1' }, SYS);
row = await svc.getRequest(req.id, SYS);
expect(row.pending_approver_groups).toEqual({ f1: ['finance'] });
});

it('per_group with unnamed groups omits synthetic keys; non-per_group omits the map (objectui#2807)', async () => {
// Distinct (record, run) so the two opens aren't a duplicate-pending clash.
const mk = (approvers: any[], behavior: string, recordId: string, runId: string, extra: Record<string, any> = {}) => ({
...openInput([], { recordId, runId }),
config: { approvers, behavior, lockRecord: true, ...extra },
});
// Unnamed approvers → synthetic `#N` group keys, which are not surfaced.
const unnamed = await svc.openNodeRequest(mk([U('u1'), U('u2')], 'per_group', 'opp_u', 'run_u'), CTX);
const uRow: any = await svc.getRequest(unnamed.id, SYS);
expect(uRow.pending_approver_groups).toBeUndefined();
// Quorum aggregates approvals, not groups — no approver→group map.
const q = await svc.openNodeRequest(mk([U('a1'), U('a2')], 'quorum', 'opp_q', 'run_q', { minApprovals: 2 }), CTX);
const qRow: any = await svc.getRequest(q.id, SYS);
expect(qRow.pending_approver_groups).toBeUndefined();
});

it('quorum: progress reports approvals against the clamped threshold', async () => {
const req = await svc.openNodeRequest(cfg([U('u1'), U('u2'), U('u3')], 'quorum', { minApprovals: 2 }), CTX);
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u1' }, SYS);
Expand Down
15 changes: 15 additions & 0 deletions packages/plugins/plugin-approvals/src/approval-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2267,6 +2267,21 @@ export class ApprovalService implements IApprovalService {
});
progress.got = progress.groups.filter((g: any) => g.satisfied).length;
progress.need = progress.groups.length;

// Approver→group(s) for the STILL-PENDING slots (objectui#2807), so the
// console can label each "waiting on" chip with the group it represents
// rather than showing duplicate, context-free names. Only pending slots
// matter — a resolved approver has dropped out of `pending_approvers`.
// Synthetic (unnamed, `#N`) group keys are dropped: a `· #0` sub-tag is
// noise, and the client would have to filter it anyway.
const pendingGroups: Record<string, string[]> = {};
for (const a of (row.pending_approvers ?? [])) {
const named = (snapshot[a] ?? []).filter((g) => !/^#\d+$/.test(g));
if (named.length) pendingGroups[a] = named;
}
if (Object.keys(pendingGroups).length) {
(row as any).pending_approver_groups = pendingGroups;
}
}
(row as any).decision_progress = progress;
} catch { /* display-only enrichment */ }
Expand Down
11 changes: 11 additions & 0 deletions packages/spec/src/contracts/approval-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,17 @@ export interface ApprovalRequestRow {
* they are already human-readable.
*/
pending_approver_names?: Record<string, string>;
/**
* Group membership of each STILL-PENDING approver, for `per_group` (会签)
* requests only (objectui#2807). Maps an approver id in `pending_approvers`
* to the group key(s) it fills — e.g. `{ "u_devadmin": ["finance", "legal"] }`
* — so a client can label each "waiting on" chip with the group it represents
* instead of showing duplicate, context-free names. Resolved from the same
* open-time `__approverGroups` snapshot the `decision_progress` groups use, so
* the two never disagree. Absent for non-`per_group` behaviors and for slots
* whose group was synthetic (unnamed). Display-only.
*/
pending_approver_groups?: Record<string, string[]>;
/**
* Display values for lookup fields in `payload` (field key → referenced
* record's display name), so inbox summaries never show foreign-key ids.
Expand Down