From e73fe6aef7d5bb6b9ff124bc3f93d2cea7e65b07 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 07:03:28 +0000 Subject: [PATCH 1/2] feat(app-shell): extract the approval decision-progress indicator (objectui#12033) One module-internal widget, `views/approval-progress/DecisionProgressIndicator`, draws an approval request's `decision_progress` tally: unanimous, quorum (M-of-N) and per-group countersign, as the approval-service contract declares it. `RecordApprovalsPanel` renders it in place of its inline block with the same markup, so the panel's DOM does not change. The copy keys are the same `approvalsInbox.*` rows, now asked for by literal key. No export from the package entry and no SDUI component type; registration is objectui#2763 B1's step. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .../src/views/RecordApprovalsPanel.tsx | 69 +-------- .../DecisionProgressIndicator.test.tsx | 139 +++++++++++++++++ .../DecisionProgressIndicator.tsx | 140 ++++++++++++++++++ 3 files changed, 285 insertions(+), 63 deletions(-) create mode 100644 packages/app-shell/src/views/approval-progress/DecisionProgressIndicator.test.tsx create mode 100644 packages/app-shell/src/views/approval-progress/DecisionProgressIndicator.tsx diff --git a/packages/app-shell/src/views/RecordApprovalsPanel.tsx b/packages/app-shell/src/views/RecordApprovalsPanel.tsx index 41543e6969..8ccb5cf4b6 100644 --- a/packages/app-shell/src/views/RecordApprovalsPanel.tsx +++ b/packages/app-shell/src/views/RecordApprovalsPanel.tsx @@ -9,7 +9,7 @@ import * as React from 'react'; import { useDisplayLocale } from '@object-ui/i18n'; import { cn, Badge, Button } from '@object-ui/components'; -import { Stamp, Check, Circle, Paperclip, Loader2, Send, ShieldAlert } from 'lucide-react'; +import { Stamp, Check, Paperclip, Loader2, Send, ShieldAlert } from 'lucide-react'; import { toast } from 'sonner'; import { createAuthenticatedFetch } from '@object-ui/auth'; import { useObjectTranslation } from '@object-ui/react'; @@ -35,6 +35,7 @@ import { useApproverDirectory, type ApproverDirectory, } from '../hooks/useApproverDirectory.js'; +import { DecisionProgressIndicator } from './approval-progress/DecisionProgressIndicator.js'; /** * RecordApprovalsPanel — the record page's read-only approval surface @@ -439,68 +440,10 @@ export const RecordApprovalsPanel: React.FC = ({ )} - {/* Aggregation progress — server-computed tally (quorum/unanimous - approvals, per-group 会签 groups), never re-derived client-side. */} - {dp && ( -
-
- - {dp.behavior === 'per_group' - ? tr('progressGroups', 'Sign-off progress — {{got}} of {{need}} groups', { got: dp.got, need: dp.need }) - : tr('progressApprovals', 'Approvals — {{got}} of {{need}}', { got: dp.got, need: dp.need })} - - {dp.behavior !== 'per_group' && eligible > 0 && ( - - {tr('progressEligible', '{{count}} eligible approver(s)', { count: eligible })} - - )} -
-
- {dp.need > 0 && dp.need <= 12 ? ( - Array.from({ length: dp.need }).map((_, i) => ( -
- )) - ) : ( -
-
0 ? Math.min(100, (dp.got / dp.need) * 100) : 0}%` }} - /> -
- )} -
- {dp.groups && ( -
- {dp.groups.map((g) => ( - - {g.satisfied ? : } - {g.group} {g.got}/{g.need} - - ))} -
- )} -
- )} + {/* Aggregation progress — the pending node's server-computed tally + (unanimous / quorum approvals, per-group countersign), drawn by + the shared indicator and never re-derived client-side. */} + {dp && } {/* Who the pending step waits on — THE read this panel exists for: server-resolved names (group approvers labeled with their group), diff --git a/packages/app-shell/src/views/approval-progress/DecisionProgressIndicator.test.tsx b/packages/app-shell/src/views/approval-progress/DecisionProgressIndicator.test.tsx new file mode 100644 index 0000000000..5bc7e1de0f --- /dev/null +++ b/packages/app-shell/src/views/approval-progress/DecisionProgressIndicator.test.tsx @@ -0,0 +1,139 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * DecisionProgressIndicator — one pin per `decision_progress` shape + * (objectui#12033, the A3 primitive of objectui#2763). + * + * The approval-service contract declares three behaviors for the tally: + * `unanimous` (approvals of every approver), `quorum` (approvals of the M + * threshold) and `per_group` (satisfied groups, plus per-group detail). Each + * gets its own pin below, plus the continuous-bar branch a long tally takes. + * + * What is asserted is the indicator's structure — tick count, filled ticks, + * the progressbar's value range, the group ticks — and WHICH pack row each + * label is read from. The expected strings are produced by the same i18next + * instance the render uses, asked for the key, so a pin names the row and its + * interpolation arguments rather than restating English prose. + */ + +import * as React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { createI18n, I18nProvider } from '@object-ui/i18n'; +import type { ApprovalDecisionProgress } from '../../hooks/useRecordApprovals'; +import { DecisionProgressIndicator } from './DecisionProgressIndicator'; + +const i18n = createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false }); + +/** The pack row a label must come from, rendered by the instance under test. */ +function row(key: string, args?: Record): string { + return String(i18n.t(`approvalsInbox.${key}`, args)); +} + +async function renderIndicator(progress: ApprovalDecisionProgress, eligibleApprovers?: number) { + render( + + + , + ); + return screen.findByRole('progressbar'); +} + +/** The bar's own children: one per tick, or the single continuous track. */ +function ticks(bar: HTMLElement): Element[] { + return Array.from(bar.children); +} + +function filled(bar: HTMLElement): Element[] { + return ticks(bar).filter((el) => (el.getAttribute('class') ?? '').split(/\s+/).includes('bg-emerald-500')); +} + +afterEach(() => { + cleanup(); +}); + +describe('DecisionProgressIndicator — the decision_progress shapes (objectui#12033)', () => { + it('unanimous: one tick per approver, filled up to the approvals recorded', async () => { + const bar = await renderIndicator({ behavior: 'unanimous', got: 1, need: 3 }, 2); + + expect(screen.getByText(row('progressApprovals', { got: 1, need: 3 }))).toBeTruthy(); + expect(screen.getByText(row('progressEligible', { count: 2 }))).toBeTruthy(); + expect(bar.getAttribute('aria-label')).toBe(row('progressBar')); + expect(bar.getAttribute('aria-valuemin')).toBe('0'); + expect(bar.getAttribute('aria-valuemax')).toBe('3'); + expect(bar.getAttribute('aria-valuenow')).toBe('1'); + expect(ticks(bar)).toHaveLength(3); + expect(filled(bar)).toHaveLength(1); + // Group ticks belong to `per_group` alone. + expect(bar.nextElementSibling).toBeNull(); + }); + + it('quorum (M-of-N): the ticks count the M threshold, the eligible N rides beside it', async () => { + const bar = await renderIndicator({ behavior: 'quorum', got: 1, need: 2 }, 5); + + expect(screen.getByText(row('progressApprovals', { got: 1, need: 2 }))).toBeTruthy(); + expect(screen.getByText(row('progressEligible', { count: 5 }))).toBeTruthy(); + expect(bar.getAttribute('aria-valuemax')).toBe('2'); + expect(bar.getAttribute('aria-valuenow')).toBe('1'); + // M ticks, not N: the bar is the threshold the engine finalizes on. + expect(ticks(bar)).toHaveLength(2); + expect(filled(bar)).toHaveLength(1); + expect(bar.nextElementSibling).toBeNull(); + }); + + it('quorum with no eligible count shows the tally alone', async () => { + await renderIndicator({ behavior: 'quorum', got: 0, need: 2 }); + + expect(screen.getByText(row('progressApprovals', { got: 0, need: 2 }))).toBeTruthy(); + expect(screen.queryByText(row('progressEligible', { count: 0 }))).toBeNull(); + }); + + it('per_group (countersign): a tick per group, the satisfied ones filled, a badge per group, no eligible count', async () => { + const bar = await renderIndicator( + { + behavior: 'per_group', + got: 1, + need: 2, + groups: [ + { group: 'quality', got: 1, need: 1, satisfied: true }, + { group: 'production', got: 0, need: 2, satisfied: false }, + ], + }, + 3, + ); + + expect(screen.getByText(row('progressGroups', { got: 1, need: 2 }))).toBeTruthy(); + // The approver count describes approvals, not groups — never shown here. + expect(screen.queryByText(row('progressEligible', { count: 3 }))).toBeNull(); + expect(ticks(bar)).toHaveLength(2); + expect(filled(bar)).toHaveLength(1); + + const quality = screen.getByText('quality 1/1'); + const production = screen.getByText('production 0/2'); + expect(quality.getAttribute('title')).toBe('1/1'); + expect(production.getAttribute('title')).toBe('0/2'); + // Satisfied groups carry the check, open ones the hollow circle. + expect(quality.querySelector('svg')?.getAttribute('class')).toMatch(/\blucide-check\b/); + expect(production.querySelector('svg')?.getAttribute('class')).toMatch(/\blucide-circle\b/); + // Both badges sit in the one group row that follows the bar. + expect(quality.parentElement).toBe(bar.nextElementSibling); + expect(production.parentElement).toBe(bar.nextElementSibling); + }); + + it('a tally past a dozen decisions is one continuous fill, not hairline ticks', async () => { + const bar = await renderIndicator({ behavior: 'unanimous', got: 5, need: 20 }); + + expect(bar.getAttribute('aria-valuemax')).toBe('20'); + expect(ticks(bar)).toHaveLength(1); + const fill = ticks(bar)[0].firstElementChild as HTMLElement; + expect(fill.style.width).toBe('25%'); + }); + + it('caps the progressbar value at the requirement', async () => { + const bar = await renderIndicator({ behavior: 'quorum', got: 3, need: 2 }); + + expect(bar.getAttribute('aria-valuenow')).toBe('2'); + expect(filled(bar)).toHaveLength(2); + }); +}); diff --git a/packages/app-shell/src/views/approval-progress/DecisionProgressIndicator.tsx b/packages/app-shell/src/views/approval-progress/DecisionProgressIndicator.tsx new file mode 100644 index 0000000000..0ec44e53a2 --- /dev/null +++ b/packages/app-shell/src/views/approval-progress/DecisionProgressIndicator.tsx @@ -0,0 +1,140 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import * as React from 'react'; +import { cn, Badge } from '@object-ui/components'; +import { Check, Circle } from 'lucide-react'; +import { useObjectTranslation } from '@object-ui/react'; +import type { ApprovalDecisionProgress } from '../../hooks/useRecordApprovals.js'; + +/** + * DecisionProgressIndicator — the segmented progress/quorum indicator bound to + * a pending approval request's `decision_progress` (objectui#12033, the A3 + * primitive of objectui#2763). + * + * It draws the three aggregation behaviors the approval-service contract + * declares for `decision_progress`, from the server's tally and nothing else: + * + * - `unanimous` — `got` approvals of `need`, where `need` is every approver; + * - `quorum` (M-of-N) — `got` approvals of the `need` threshold. The N it is + * drawn from is not part of the tally, so the caller may pass the eligible + * approver count to show beside it; + * - `per_group` (countersign) — `got` satisfied groups of `need`, plus one + * tick per entry of `groups`. + * + * `first_response` nodes carry no `decision_progress` (one decision finalizes + * them), so there is no fourth shape: a caller renders this only when the + * tally is present. The counts are never re-derived client-side — the engine's + * finalization tally stays authoritative and this is display only. + * + * Extracted from `RecordApprovalsPanel` so the approval detail page + * (objectui#2763 B1) can draw the same indicator; the panel renders it with no + * change to its DOM. Module-internal: no export from the package entry and no + * SDUI component type — registering it is B1's step. + * + * The copy is the Approval Center's `approvalsInbox.*` rows, the same ones the + * panel always read, asked for by literal key so the call-site i18n gate checks + * each one rather than seeing a forwarded `key: string`. + */ + +/** + * Above this many required decisions the ticks would shrink to hairlines, so + * the bar becomes one continuous fill and the count in the label carries it. + */ +const MAX_SEGMENTS = 12; + +export interface DecisionProgressIndicatorProps { + /** The pending request's server-computed tally, as the contract declares it. */ + progress: ApprovalDecisionProgress; + /** + * How many approvers may still decide the pending node, shown beside an + * approvals tally (`unanimous` / `quorum`) and never beside a group tally. + * Absent or zero shows nothing. + */ + eligibleApprovers?: number; +} + +export const DecisionProgressIndicator: React.FC = ({ + progress: dp, + eligibleApprovers = 0, +}) => { + const { t } = useObjectTranslation(); + const perGroup = dp.behavior === 'per_group'; + + return ( +
+
+ + {perGroup + ? String(t('approvalsInbox.progressGroups', { + defaultValue: 'Sign-off progress — {{got}} of {{need}} groups', + got: dp.got, + need: dp.need, + })) + : String(t('approvalsInbox.progressApprovals', { + defaultValue: 'Approvals — {{got}} of {{need}}', + got: dp.got, + need: dp.need, + }))} + + {!perGroup && eligibleApprovers > 0 && ( + + {String(t('approvalsInbox.progressEligible', { + defaultValue: '{{count}} eligible approver(s)', + count: eligibleApprovers, + }))} + + )} +
+
+ {dp.need > 0 && dp.need <= MAX_SEGMENTS ? ( + Array.from({ length: dp.need }).map((_, i) => ( +
+ )) + ) : ( +
+
0 ? Math.min(100, (dp.got / dp.need) * 100) : 0}%` }} + /> +
+ )} +
+ {dp.groups && ( +
+ {dp.groups.map((g) => ( + + {g.satisfied ? : } + {g.group} {g.got}/{g.need} + + ))} +
+ )} +
+ ); +}; From 0e0fd992bed7a538f8fe5b7bef60638bb42e1942 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 07:11:06 +0000 Subject: [PATCH 2/2] chore(changeset): patch app-shell for the decision-progress indicator (objectui#12033) Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude --- .changeset/12033-approval-decision-progress.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/12033-approval-decision-progress.md diff --git a/.changeset/12033-approval-decision-progress.md b/.changeset/12033-approval-decision-progress.md new file mode 100644 index 0000000000..ba011408de --- /dev/null +++ b/.changeset/12033-approval-decision-progress.md @@ -0,0 +1,7 @@ +--- +'@object-ui/app-shell': patch +--- + +The record approvals panel draws its decision-progress tally through one module-internal indicator, `DecisionProgressIndicator` (objectui#12033, part of objectui#2763). The tally is an approval request's `decision_progress` as the approval-service contract declares it: approvals of every approver on a `unanimous` node, approvals of the threshold on a `quorum` (M-of-N) node, and satisfied groups, with one badge per group, on a `per_group` countersign node. The panel renders the same markup as before and reads the same `approvalsInbox.*` copy rows. + +The indicator is not exported from the package entry and is not registered as an SDUI component type; registering it is the approval detail page's step (objectui#2763 B1).