From a42606c46c81074437ee4628edf837eb415c764c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 23:48:30 +0000 Subject: [PATCH 1/2] fix(app-shell): eligible approvers count distinct people, not pending_approvers slots The record approvals panel and the approval decision panel handed DecisionProgressIndicator the length of the request's pending_approvers list. The approvals service keeps one slot per resolved approver entry, so a person holding two of a node's positions was counted twice: the stock showcase committee quorum read "3 eligible approvers" beside one "Dev Admin x3" chip. Both callers now use one module-local helper beside the indicator, eligibleApproverCount, which counts the distinct addresses of the slate as served. Nothing is resolved in the console and the server tally (got / need) is untouched. Claude-Session: https://claude.ai/code/session_01Vk15bz8xHtKGUfXfCm16h4 Co-authored-by: Claude --- .../12781-eligible-approvers-distinct.md | 5 + ...valsPanel.eligibleApprovers-12781.test.tsx | 130 ++++++++++++++++++ .../src/views/RecordApprovalsPanel.tsx | 3 +- ...sionPanel.eligibleApprovers-12781.test.tsx | 111 +++++++++++++++ .../ApprovalDecisionPanel.tsx | 3 +- .../eligibleApprovers.test.ts | 39 ++++++ .../approval-progress/eligibleApprovers.ts | 37 +++++ 7 files changed, 326 insertions(+), 2 deletions(-) create mode 100644 .changeset/12781-eligible-approvers-distinct.md create mode 100644 packages/app-shell/src/views/RecordApprovalsPanel.eligibleApprovers-12781.test.tsx create mode 100644 packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.eligibleApprovers-12781.test.tsx create mode 100644 packages/app-shell/src/views/approval-progress/eligibleApprovers.test.ts create mode 100644 packages/app-shell/src/views/approval-progress/eligibleApprovers.ts diff --git a/.changeset/12781-eligible-approvers-distinct.md b/.changeset/12781-eligible-approvers-distinct.md new file mode 100644 index 0000000000..179f94cd07 --- /dev/null +++ b/.changeset/12781-eligible-approvers-distinct.md @@ -0,0 +1,5 @@ +--- +'@object-ui/app-shell': patch +--- + +The "N eligible approver(s)" figure beside an approval's decision progress counts people, not slots (objectui#12781). The record page's approvals panel and the approval request page's decision panel used to count the request's `pending_approvers` slots. The approvals service keeps one slot per resolved approver entry, so a person who holds two of the node's positions was counted twice. On the stock showcase, the committee quorum showed "3 eligible approvers" beside a single "Dev Admin ×3" chip. Both panels now count the distinct principals in the slate as the server serves it. Nothing is resolved in the console, and the server's tally (`got` of `need`) is drawn exactly as before. diff --git a/packages/app-shell/src/views/RecordApprovalsPanel.eligibleApprovers-12781.test.tsx b/packages/app-shell/src/views/RecordApprovalsPanel.eligibleApprovers-12781.test.tsx new file mode 100644 index 0000000000..41d1dd3126 --- /dev/null +++ b/packages/app-shell/src/views/RecordApprovalsPanel.eligibleApprovers-12781.test.tsx @@ -0,0 +1,130 @@ +/** + * 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. + */ + +/** + * The record approvals panel's "N eligible approver(s)" counts people, not + * `pending_approvers` slots (objectui#12781). + * + * The approvals service keeps one slot per resolved approver entry, so one + * person holding two of a node's positions fills two slots under the same + * user id. The panel handed the slot count to `DecisionProgressIndicator`, so + * the stock showcase's committee quorum read "3 eligible approvers" beside a + * single "Dev Admin ×3" chip. The fixtures below are in the shape the + * approvals get serves: user-id slots, a name for each id, the server's tally. + * + * Each label is asserted as the pack row the indicator reads, rendered by the + * same i18next instance the panel renders with, so a pin names the row and + * its count rather than restating English prose. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { render, screen, cleanup, within } from '@testing-library/react'; +import { createI18n, I18nProvider } from '@object-ui/i18n'; +import type { ApprovalRequestLite } from '../hooks/useRecordApprovals'; + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + createAuthenticatedFetch: () => vi.fn(), +})); + +vi.mock('sonner', () => ({ toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }) })); + +import { RecordApprovalsPanel } from './RecordApprovalsPanel'; + +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)); +} + +const NAMES: Record = { + u_nina: 'Nina Novak', + u_pat: 'Pat Price', + u_lee: 'Lee Lin', + u_dev_admin: 'Dev Admin', +}; + +function pendingRequest(pending_approvers: string[], got = 1, need = 2): ApprovalRequestLite { + return { + id: 'req_12781', + process_name: 'flow:showcase_expense_committee', + process_label: 'High-Value Expense — Committee Quorum', + object_name: 'showcase_expense_report', + record_id: 'rec_1', + status: 'pending', + current_step: 'committee', + step_label: 'Committee Sign-off', + submitter_id: 'u_submitter', + submitted_at: '2026-10-16T08:00:00Z', + pending_approvers, + pending_approver_names: Object.fromEntries(pending_approvers.map((id) => [id, NAMES[id]])), + decision_progress: { behavior: 'quorum', got, need }, + viewer: { can_act: false, is_submitter: false }, + }; +} + +beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string) => + /\/approvals\/requests\/req_12781\/actions$/.test(String(url)) + ? ({ ok: true, json: async () => ({ data: [] }) } as unknown as Response) + : ({ ok: false, status: 404, json: async () => ({}) } as unknown as Response), + ), + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +async function renderPanel(request: ApprovalRequestLite) { + render( + + + , + ); + const panel = await screen.findByTestId('record-approvals-panel'); + return { panel, bar: within(panel).getByRole('progressbar') }; +} + +describe('RecordApprovalsPanel — eligible approvers count distinct users (objectui#12781)', () => { + it.each([ + ['one user in two slots among others', ['u_nina', 'u_dev_admin', 'u_dev_admin', 'u_lee'], 3], + ['the stock showcase committee quorum: one user in all three slots', ['u_dev_admin', 'u_dev_admin', 'u_dev_admin'], 1], + ])('%s: the label counts distinct users, not slots', async (_name, slots, people) => { + const { panel } = await renderPanel(pendingRequest(slots)); + + expect(within(panel).getByText(row('progressEligible', { count: people }))).toBeTruthy(); + expect(within(panel).queryByText(row('progressEligible', { count: slots.length }))).toBeNull(); + }); + + it('distinct users only: the count is the one the slot length always gave', async () => { + const slots = ['u_nina', 'u_pat', 'u_lee']; + const { panel } = await renderPanel(pendingRequest(slots)); + + expect(within(panel).getByText(row('progressEligible', { count: slots.length }))).toBeTruthy(); + }); + + it("the server's tally is drawn as served: got and need are untouched by the distinct count", async () => { + const { panel, bar } = await renderPanel( + pendingRequest(['u_nina', 'u_dev_admin', 'u_dev_admin', 'u_lee'], 1, 2), + ); + + expect(within(panel).getByText(row('progressApprovals', { got: 1, need: 2 }))).toBeTruthy(); + expect(bar.getAttribute('aria-valuemax')).toBe('2'); + expect(bar.getAttribute('aria-valuenow')).toBe('1'); + expect(bar.children).toHaveLength(2); + }); +}); diff --git a/packages/app-shell/src/views/RecordApprovalsPanel.tsx b/packages/app-shell/src/views/RecordApprovalsPanel.tsx index 8ccb5cf4b6..dc2700c1f5 100644 --- a/packages/app-shell/src/views/RecordApprovalsPanel.tsx +++ b/packages/app-shell/src/views/RecordApprovalsPanel.tsx @@ -36,6 +36,7 @@ import { type ApproverDirectory, } from '../hooks/useApproverDirectory.js'; import { DecisionProgressIndicator } from './approval-progress/DecisionProgressIndicator.js'; +import { eligibleApproverCount } from './approval-progress/eligibleApprovers.js'; /** * RecordApprovalsPanel — the record page's read-only approval surface @@ -365,7 +366,7 @@ export const RecordApprovalsPanel: React.FC = ({ : a.actor_name ?? formatIdentity(a.actor_id); const dp = pendingRequest?.decision_progress; - const eligible = (pendingRequest?.pending_approvers || []).length; + const eligible = eligibleApproverCount(pendingRequest); const stepText = headline.status === 'pending' ? (headline.step_label || (headline.current_step ? prettifyMachineName(headline.current_step) : null)) : null; diff --git a/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.eligibleApprovers-12781.test.tsx b/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.eligibleApprovers-12781.test.tsx new file mode 100644 index 0000000000..63607a961d --- /dev/null +++ b/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.eligibleApprovers-12781.test.tsx @@ -0,0 +1,111 @@ +/** + * 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. + */ + +/** + * The approval decision panel's "N eligible approver(s)" counts people, not + * `pending_approvers` slots (objectui#12781). + * + * The approvals service keeps one slot per resolved approver entry, so one + * person holding two of a node's positions fills two slots under the same + * user id. The panel handed the slot count to `DecisionProgressIndicator`, so + * the stock showcase's committee quorum read "3 eligible approvers" beside a + * single "Dev Admin ×3" chip. The rows below are in the shape the approvals + * get serves the request page: user-id slots, a name for each id, the + * server's tally. + * + * The declared actions bar is doubled: what is under test is the progress + * line. Each label is asserted as the pack row the indicator reads, rendered + * by the same i18next instance the panel renders with. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { cleanup, render, screen, within } from '@testing-library/react'; + +vi.mock('../DeclaredActionsBar.js', () => ({ + DeclaredActionsBar: () =>
, +})); + +import { RecordContextProvider } from '@object-ui/react'; +import { createI18n, I18nProvider } from '@object-ui/i18n'; +import { ApprovalDecisionRenderer } from './ApprovalDecisionPanel'; +import { resetApproverDirectoryCache } from '../../hooks/useApproverDirectory'; + +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)); +} + +const NAMES: Record = { + u_nina: 'Nina Novak', + u_pat: 'Pat Price', + u_lee: 'Lee Lin', + u_dev_admin: 'Dev Admin', +}; + +function requestRow(pending_approvers: string[], got = 1, need = 2): Record { + return { + id: 'areq_12781', + process_name: 'flow:showcase_expense_committee', + object_name: 'showcase_expense_report', + record_id: 'rec_1', + status: 'pending', + pending_approvers, + pending_approver_names: Object.fromEntries(pending_approvers.map((id) => [id, NAMES[id]])), + decision_progress: { behavior: 'quorum', got, need }, + viewer: { can_act: true, is_submitter: false, can_override: false }, + }; +} + +beforeEach(() => resetApproverDirectoryCache()); + +afterEach(() => { + cleanup(); +}); + +function mount(data: Record) { + render( + + + + + , + ); + const panel = screen.getByTestId('approval-decision-panel'); + return { panel, bar: within(panel).getByRole('progressbar') }; +} + +describe('ApprovalDecisionPanel — eligible approvers count distinct users (objectui#12781)', () => { + it.each([ + ['one user in two slots among others', ['u_nina', 'u_dev_admin', 'u_dev_admin', 'u_lee'], 3], + ['the stock showcase committee quorum: one user in all three slots', ['u_dev_admin', 'u_dev_admin', 'u_dev_admin'], 1], + ])('%s: the label counts distinct users, not slots', (_name, slots, people) => { + const { panel } = mount(requestRow(slots)); + + expect(within(panel).getByText(row('progressEligible', { count: people }))).toBeTruthy(); + expect(within(panel).queryByText(row('progressEligible', { count: slots.length }))).toBeNull(); + }); + + it('distinct users only: the count is the one the slot length always gave', () => { + const slots = ['u_nina', 'u_pat', 'u_lee']; + const { panel } = mount(requestRow(slots)); + + expect(within(panel).getByText(row('progressEligible', { count: slots.length }))).toBeTruthy(); + }); + + it("the server's tally is drawn as served: got and need are untouched by the distinct count", () => { + const { panel, bar } = mount(requestRow(['u_nina', 'u_dev_admin', 'u_dev_admin', 'u_lee'], 1, 2)); + + expect(within(panel).getByText(row('progressApprovals', { got: 1, need: 2 }))).toBeTruthy(); + expect(bar.getAttribute('aria-valuemax')).toBe('2'); + expect(bar.getAttribute('aria-valuenow')).toBe('1'); + expect(bar.children).toHaveLength(2); + }); +}); diff --git a/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.tsx b/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.tsx index e3a379ef85..fdf55aa96a 100644 --- a/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.tsx +++ b/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.tsx @@ -108,6 +108,7 @@ import * as React from 'react'; import { notifyDataChanged, useObjectTranslation, useRecordContext } from '@object-ui/react'; import { cn, Badge } from '@object-ui/components'; import { DecisionProgressIndicator } from '../approval-progress/DecisionProgressIndicator.js'; +import { eligibleApproverCount } from '../approval-progress/eligibleApprovers.js'; import { DeclaredActionsBar } from '../DeclaredActionsBar.js'; import { approverChips } from '../RecordApprovalsPanel.js'; import { RecordSnapshotSummary } from './RecordSnapshotSummary.js'; @@ -187,7 +188,7 @@ function PendingApprovers({ request }: { request: ApprovalRequestLite }) { /** The three parts over one request row. Module-internal: no package export. */ export function ApprovalDecisionPanel({ request, className }: ApprovalDecisionPanelProps) { const progress = request.decision_progress; - const eligible = (request.pending_approvers || []).length; + const eligible = eligibleApproverCount(request); const requestId = String(request.id); const handleDecided = React.useCallback(() => { diff --git a/packages/app-shell/src/views/approval-progress/eligibleApprovers.test.ts b/packages/app-shell/src/views/approval-progress/eligibleApprovers.test.ts new file mode 100644 index 0000000000..16a34e4261 --- /dev/null +++ b/packages/app-shell/src/views/approval-progress/eligibleApprovers.test.ts @@ -0,0 +1,39 @@ +/** + * 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. + */ + +/** + * `eligibleApproverCount` counts the distinct principals of a served slate, + * not its slots (objectui#12781). The callers' labels are pinned beside each + * caller; this pins the count itself, including the slates with nothing to + * count. + */ + +import { describe, it, expect } from 'vitest'; +import { eligibleApproverCount } from './eligibleApprovers'; + +describe('eligibleApproverCount (objectui#12781)', () => { + it('one address in several slots counts once', () => { + expect(eligibleApproverCount({ pending_approvers: ['u_a', 'u_b', 'u_a', 'u_c'] })).toBe(3); + expect(eligibleApproverCount({ pending_approvers: ['u_a', 'u_a', 'u_a'] })).toBe(1); + }); + + it('distinct addresses count as the slots do', () => { + expect(eligibleApproverCount({ pending_approvers: ['u_a', 'u_b', 'u_c'] })).toBe(3); + }); + + it('addresses are counted as served and never resolved: a position and a user id stay two', () => { + expect(eligibleApproverCount({ pending_approvers: ['position:finance', 'u_a'] })).toBe(2); + }); + + it('no request, or no slate, is zero', () => { + expect(eligibleApproverCount(null)).toBe(0); + expect(eligibleApproverCount(undefined)).toBe(0); + expect(eligibleApproverCount({ pending_approvers: null })).toBe(0); + expect(eligibleApproverCount({})).toBe(0); + }); +}); diff --git a/packages/app-shell/src/views/approval-progress/eligibleApprovers.ts b/packages/app-shell/src/views/approval-progress/eligibleApprovers.ts new file mode 100644 index 0000000000..712b0daaa3 --- /dev/null +++ b/packages/app-shell/src/views/approval-progress/eligibleApprovers.ts @@ -0,0 +1,37 @@ +/** + * 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 type { ApprovalRequestLite } from '../../hooks/useRecordApprovals.js'; + +/** + * How many approvers may still decide a pending request: the number of + * distinct principals in the slate the server served (objectui#12781). It is + * the figure `DecisionProgressIndicator` draws as "N eligible approver(s)" + * beside the tally, and both of its callers hand it this count. + * + * `pending_approvers` lists slots, not people. The approvals service keeps one + * slot per resolved approver entry, so a person who holds two of a node's + * positions fills two slots under the same address. Counting slots drew + * "3 eligible approvers" beside a single "Dev Admin ×3" chip on the stock + * showcase's committee quorum. Each slot carries its principal's address as + * the server stores it (a user id, an email, a `position:` reference), so the + * count here is the number of distinct addresses. + * + * Nothing is resolved here: no position, team or group is looked up and no + * second read is made. Two addresses the server left distinct (an email and a + * user id for one person, say) count as two, because the slate is the + * server's answer and the console does not re-derive it. The server's tally + * (`decision_progress.got` / `need`) is not read or changed. + * + * Module-internal: no export from the package entry. + */ +export function eligibleApproverCount( + request: Pick | null | undefined, +): number { + return new Set(request?.pending_approvers || []).size; +} From f132e95e52e460aa47466265136c6a21c1d2f8fa Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 00:12:10 +0000 Subject: [PATCH 2/2] fix(console): the Approval Center drawer counts eligible approvers as distinct people The request drawer's decision-progress block computed its own eligible count from the length of pending_approvers, so one person holding several slots was counted once per slot: the stock showcase committee quorum read "3 eligible approvers" for one person. It now counts the distinct served addresses with the page's own new Set idiom, the rule app-shell's eligibleApproverCount applies (not exported for this). The presence check and the slot list stay as they were. Claude-Session: https://claude.ai/code/session_01Vk15bz8xHtKGUfXfCm16h4 Co-authored-by: Claude --- .../12781-eligible-approvers-distinct.md | 3 + ...InboxPage.eligibleApprovers-12781.test.tsx | 179 ++++++++++++++++++ .../src/pages/system/ApprovalsInboxPage.tsx | 3 +- 3 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 apps/console/src/pages/system/ApprovalsInboxPage.eligibleApprovers-12781.test.tsx diff --git a/.changeset/12781-eligible-approvers-distinct.md b/.changeset/12781-eligible-approvers-distinct.md index 179f94cd07..22a7d376c8 100644 --- a/.changeset/12781-eligible-approvers-distinct.md +++ b/.changeset/12781-eligible-approvers-distinct.md @@ -1,5 +1,8 @@ --- '@object-ui/app-shell': patch +'@object-ui/console': patch --- The "N eligible approver(s)" figure beside an approval's decision progress counts people, not slots (objectui#12781). The record page's approvals panel and the approval request page's decision panel used to count the request's `pending_approvers` slots. The approvals service keeps one slot per resolved approver entry, so a person who holds two of the node's positions was counted twice. On the stock showcase, the committee quorum showed "3 eligible approvers" beside a single "Dev Admin ×3" chip. Both panels now count the distinct principals in the slate as the server serves it. Nothing is resolved in the console, and the server's tally (`got` of `need`) is drawn exactly as before. + +The Approval Center's request drawer applies the same count: its decision-progress line now reads the number of distinct approvers in the served slate, where it read the slot count. diff --git a/apps/console/src/pages/system/ApprovalsInboxPage.eligibleApprovers-12781.test.tsx b/apps/console/src/pages/system/ApprovalsInboxPage.eligibleApprovers-12781.test.tsx new file mode 100644 index 0000000000..1c2389f39b --- /dev/null +++ b/apps/console/src/pages/system/ApprovalsInboxPage.eligibleApprovers-12781.test.tsx @@ -0,0 +1,179 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Approvals Inbox — the request drawer's "N eligible approver(s)" counts + * people, not `pending_approvers` slots (objectui#12781). + * + * The approvals service keeps one slot per resolved approver entry, so one + * person holding two of a node's positions fills two slots under the same + * user id. The drawer's decision-progress block counted slots, so the stock + * showcase's committee quorum read "3 eligible approvers" for one person. + * It now counts the distinct addresses in the served slate, the rule + * app-shell's `eligibleApproverCount` applies on the record and request pages. + * + * The translation stub returns the pack key and its interpolation arguments, + * so each assertion names the row and its count rather than English prose. + * The fixture rows are in the shape the approvals get serves: user-id slots, + * a name for each id, the server's tally. + * + * No build artifact sits between the edit and this test: the root Vitest + * config aliases every `@object-ui` specifier at that package's source, and + * the page under test is this app's own source. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup, within } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions'; + +const APP = 'com.objectstack.account'; + +const { approvalsApiStub, setSlate, row, ADAPTER, AUTH, I18N } = vi.hoisted(() => { + /** The pack key and its arguments (keys sorted), never the inline default. */ + const row = (key: string, args?: Record): string => { + const entries = Object.entries(args ?? {}) + .filter(([k]) => k !== 'defaultValue') + .sort(([a], [b]) => a.localeCompare(b)); + return entries.length ? `${key} ${JSON.stringify(Object.fromEntries(entries))}` : key; + }; + + const NAMES: Record = { + u_nina: 'Nina Novak', + u_pat: 'Pat Price', + u_lee: 'Lee Lin', + u_dev_admin: 'Dev Admin', + }; + + const request: Record = { + id: 'req_12781', + process_name: 'showcase_expense_committee', + process_label: 'High-Value Expense — Committee Quorum', + object_name: 'showcase_expense_report', + object_label: 'Expense Report', + record_id: 'exp_1', + record_title: 'EXP-DEMO', + status: 'pending', + submitter_id: 'u_2', + submitter_name: 'Sam Submitter', + submitted_at: '2026-10-16T00:00:00.000Z', + payload: {}, + }; + + const setSlate = (pending_approvers: string[], got: number, need: number) => { + request.pending_approvers = pending_approvers; + request.pending_approver_names = Object.fromEntries(pending_approvers.map((id) => [id, NAMES[id]])); + request.decision_progress = { behavior: 'quorum', got, need }; + }; + + const approvalsApiStub = { + listRequests: vi.fn(async () => ({ data: [request], total: 1 })), + getRequest: vi.fn(async () => ({ data: request })), + listActions: vi.fn(async () => ({ data: [] })), + approve: vi.fn(async () => ({ data: request, finalized: true })), + reject: vi.fn(async () => ({ data: request, finalized: true })), + }; + + // STABLE singletons: a mocked hook handing back a fresh object per render + // re-runs the page's load effect forever and the drawer never settles. + const ADAPTER = { find: vi.fn(async () => ({ data: [{ id: 'exp_1' }] })) }; + const AUTH = { user: { id: 'u_1', email: 'approver@example.com' } }; + const I18N = { + t: (key: string, options?: Record) => row(key, options), + language: 'en', + }; + return { approvalsApiStub, setSlate, row, ADAPTER, AUTH, I18N }; +}); + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => I18N, +})); + +vi.mock('@object-ui/auth', async (importOriginal) => { + const authFetch = vi.fn(async () => new Response('{}', { status: 200 })); + return { + ...(await importOriginal>()), + useAuth: () => AUTH, + createAuthenticatedFetch: () => authFetch, + TokenStorage: { get: () => null }, + }; +}); + +vi.mock('@object-ui/app-shell', async (importOriginal) => ({ + ...(await importOriginal>()), + useAdapter: () => ADAPTER, + DeclaredActionsBar: () => null, + isViaOverrideRow: () => false, +})); + +vi.mock('../../services/approvalsApi', async (importOriginal) => ({ + ...(await importOriginal>()), + approvalsApi: approvalsApiStub, +})); + +// Imported after the mocks so the page picks them up. +import { ApprovalsInboxPage } from './ApprovalsInboxPage'; + +function permissionsPayload(): MePermissionsResponse { + return { + authenticated: true, + userId: 'u_1', + tenantId: 't_1', + roles: [], + permissionSets: [], + objects: {}, + fields: {}, + systemPermissions: ['setup.access'], + }; +} + +/** + * Open the drawer on the request through the `?request=` deep link, the way a + * notification opens it, and return the decision-progress block. + */ +async function openProgress(slots: string[], got = 1, need = 2) { + setSlate(slots, got, need); + render( + + + + } /> + + + , + ); + const dialog = await screen.findByRole('dialog'); + const bar = await within(dialog).findByRole('progressbar', { name: row('approvalsInbox.progressBar') }); + return { block: bar.parentElement as HTMLElement, bar }; +} + +afterEach(cleanup); + +describe('Approvals Inbox — the drawer counts eligible approvers as distinct users (objectui#12781)', () => { + it.each([ + ['one user in two slots among others', ['u_nina', 'u_dev_admin', 'u_dev_admin', 'u_lee'], 3], + ['the stock showcase committee quorum: one user in all three slots', ['u_dev_admin', 'u_dev_admin', 'u_dev_admin'], 1], + ])('%s: the label counts distinct users, not slots', async (_name, slots, people) => { + const { block } = await openProgress(slots); + + expect(within(block).getByText(row('approvalsInbox.progressEligible', { count: people }))).toBeInTheDocument(); + expect(within(block).queryByText(row('approvalsInbox.progressEligible', { count: slots.length }))).toBeNull(); + }); + + it('distinct users only: the count is the one the slot length always gave', async () => { + const slots = ['u_nina', 'u_pat', 'u_lee']; + const { block } = await openProgress(slots); + + expect(within(block).getByText(row('approvalsInbox.progressEligible', { count: slots.length }))).toBeInTheDocument(); + }); + + it("the server's tally is drawn as served: got and need are untouched by the distinct count", async () => { + const { block, bar } = await openProgress(['u_nina', 'u_dev_admin', 'u_dev_admin', 'u_lee'], 1, 2); + + expect(within(block).getByText(row('approvalsInbox.progressApprovals', { got: 1, need: 2 }))).toBeInTheDocument(); + expect(bar).toHaveAttribute('aria-valuemax', '2'); + expect(bar).toHaveAttribute('aria-valuenow', '1'); + expect(bar.children).toHaveLength(2); + }); +}); diff --git a/apps/console/src/pages/system/ApprovalsInboxPage.tsx b/apps/console/src/pages/system/ApprovalsInboxPage.tsx index f625d43edd..21bd2a8d2b 100644 --- a/apps/console/src/pages/system/ApprovalsInboxPage.tsx +++ b/apps/console/src/pages/system/ApprovalsInboxPage.tsx @@ -2526,7 +2526,8 @@ export function ApprovalsInboxPage() { drawer pager's "Request 2 of 3". */} {selected.decision_progress && (() => { const dp = selected.decision_progress; - const eligible = (selected.pending_approvers || []).length; + // People, not slots: one person can hold several slots, so count the distinct served addresses, as app-shell's eligibleApproverCount does (objectui#12781). + const eligible = new Set(selected.pending_approvers || []).size; return (