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
7 changes: 7 additions & 0 deletions .changeset/12033-approval-decision-progress.md
Original file line number Diff line number Diff line change
@@ -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).
69 changes: 6 additions & 63 deletions packages/app-shell/src/views/RecordApprovalsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -439,68 +440,10 @@ export const RecordApprovalsPanel: React.FC<RecordApprovalsPanelProps> = ({
</ol>
)}

{/* Aggregation progress — server-computed tally (quorum/unanimous
approvals, per-group 会签 groups), never re-derived client-side. */}
{dp && (
<div>
<div className="flex items-baseline justify-between gap-2 mb-1.5">
<span className="text-[11px] text-muted-foreground">
{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 })}
</span>
{dp.behavior !== 'per_group' && eligible > 0 && (
<span className="text-[11px] text-muted-foreground">
{tr('progressEligible', '{{count}} eligible approver(s)', { count: eligible })}
</span>
)}
</div>
<div
role="progressbar"
aria-valuemin={0}
aria-valuemax={dp.need}
aria-valuenow={Math.min(dp.got, dp.need)}
aria-label={tr('progressBar', 'Decision progress')}
className="flex gap-1"
>
{dp.need > 0 && dp.need <= 12 ? (
Array.from({ length: dp.need }).map((_, i) => (
<div
key={i}
className={cn('h-1.5 flex-1 rounded-full', i < dp.got ? 'bg-emerald-500' : 'bg-muted')}
/>
))
) : (
<div className="h-1.5 flex-1 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-emerald-500"
style={{ width: `${dp.need > 0 ? Math.min(100, (dp.got / dp.need) * 100) : 0}%` }}
/>
</div>
)}
</div>
{dp.groups && (
<div className="flex flex-wrap gap-1 mt-2">
{dp.groups.map((g) => (
<Badge
key={g.group}
variant="outline"
className={cn(
'text-[11px] gap-1',
g.satisfied
? 'border-emerald-300 text-emerald-700 dark:border-emerald-700 dark:text-emerald-400'
: 'text-muted-foreground',
)}
title={`${g.got}/${g.need}`}
>
{g.satisfied ? <Check className="h-3 w-3" /> : <Circle className="h-2.5 w-2.5" />}
{g.group} {g.got}/{g.need}
</Badge>
))}
</div>
)}
</div>
)}
{/* 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 && <DecisionProgressIndicator progress={dp} eligibleApprovers={eligible} />}

{/* Who the pending step waits on — THE read this panel exists for:
server-resolved names (group approvers labeled with their group),
Expand Down
Original file line number Diff line number Diff line change
@@ -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, unknown>): string {
return String(i18n.t(`approvalsInbox.${key}`, args));
}

async function renderIndicator(progress: ApprovalDecisionProgress, eligibleApprovers?: number) {
render(
<I18nProvider instance={i18n}>
<DecisionProgressIndicator progress={progress} eligibleApprovers={eligibleApprovers} />
</I18nProvider>,
);
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);
});
});
Original file line number Diff line number Diff line change
@@ -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<DecisionProgressIndicatorProps> = ({
progress: dp,
eligibleApprovers = 0,
}) => {
const { t } = useObjectTranslation();
const perGroup = dp.behavior === 'per_group';

return (
<div>
<div className="flex items-baseline justify-between gap-2 mb-1.5">
<span className="text-[11px] text-muted-foreground">
{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,
}))}
</span>
{!perGroup && eligibleApprovers > 0 && (
<span className="text-[11px] text-muted-foreground">
{String(t('approvalsInbox.progressEligible', {
defaultValue: '{{count}} eligible approver(s)',
count: eligibleApprovers,
}))}
</span>
)}
</div>
<div
role="progressbar"
aria-valuemin={0}
aria-valuemax={dp.need}
aria-valuenow={Math.min(dp.got, dp.need)}
aria-label={String(t('approvalsInbox.progressBar', { defaultValue: 'Decision progress' }))}
className="flex gap-1"
>
{dp.need > 0 && dp.need <= MAX_SEGMENTS ? (
Array.from({ length: dp.need }).map((_, i) => (
<div
key={i}
className={cn('h-1.5 flex-1 rounded-full', i < dp.got ? 'bg-emerald-500' : 'bg-muted')}
/>
))
) : (
<div className="h-1.5 flex-1 rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-emerald-500"
style={{ width: `${dp.need > 0 ? Math.min(100, (dp.got / dp.need) * 100) : 0}%` }}
/>
</div>
)}
</div>
{dp.groups && (
<div className="flex flex-wrap gap-1 mt-2">
{dp.groups.map((g) => (
<Badge
key={g.group}
variant="outline"
className={cn(
'text-[11px] gap-1',
g.satisfied
? 'border-emerald-300 text-emerald-700 dark:border-emerald-700 dark:text-emerald-400'
: 'text-muted-foreground',
)}
title={`${g.got}/${g.need}`}
>
{g.satisfied ? <Check className="h-3 w-3" /> : <Circle className="h-2.5 w-2.5" />}
{g.group} {g.got}/{g.need}
</Badge>
))}
</div>
)}
</div>
);
};
Loading