From 7d324d3351ea49a7fdef4a15b30bd1cfaf8e600f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 15:19:52 +0000 Subject: [PATCH] feat(plugin-approvals): expose per-group membership of pending approvers (objectui#2807) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit per_group (会签) requests now carry pending_approver_groups on the enriched row — each still-pending approver id mapped to the group key(s) it fills — so a client can label each 'waiting on' chip with its group instead of showing duplicate, context-free names. - Resolved in attachDecisionProgress from the same open-time __approverGroups snapshot decision_progress already uses, so the two never disagree - Only pending slots; synthetic (unnamed, #N) group keys dropped - Absent for non-per_group behaviors; display-only - Added to the ApprovalRequestRow contract in @objectstack/spec Closes objectui#2807 (framework half) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Cu48mLFUdRBmMh8Z8R3CVz --- .../approval-pending-approver-groups.md | 22 +++++++++++++++ .../src/approval-service.test.ts | 27 +++++++++++++++++++ .../plugin-approvals/src/approval-service.ts | 15 +++++++++++ .../spec/src/contracts/approval-service.ts | 11 ++++++++ 4 files changed, 75 insertions(+) create mode 100644 .changeset/approval-pending-approver-groups.md diff --git a/.changeset/approval-pending-approver-groups.md b/.changeset/approval-pending-approver-groups.md new file mode 100644 index 0000000000..4b19811add --- /dev/null +++ b/.changeset/approval-pending-approver-groups.md @@ -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`. diff --git a/packages/plugins/plugin-approvals/src/approval-service.test.ts b/packages/plugins/plugin-approvals/src/approval-service.test.ts index c789108c15..1ffb110f3d 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.test.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.test.ts @@ -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 = {}) => ({ + ...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); diff --git a/packages/plugins/plugin-approvals/src/approval-service.ts b/packages/plugins/plugin-approvals/src/approval-service.ts index d52a449fdb..b357631461 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.ts @@ -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 = {}; + 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 */ } diff --git a/packages/spec/src/contracts/approval-service.ts b/packages/spec/src/contracts/approval-service.ts index f5e57047b2..999b3ee3e9 100644 --- a/packages/spec/src/contracts/approval-service.ts +++ b/packages/spec/src/contracts/approval-service.ts @@ -74,6 +74,17 @@ export interface ApprovalRequestRow { * they are already human-readable. */ pending_approver_names?: Record; + /** + * 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; /** * Display values for lookup fields in `payload` (field key → referenced * record's display name), so inbox summaries never show foreign-key ids.