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
8 changes: 8 additions & 0 deletions .changeset/12781-eligible-approvers-distinct.md
Original file line number Diff line number Diff line change
@@ -0,0 +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.
Original file line number Diff line number Diff line change
@@ -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, unknown>): 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<string, string> = {
u_nina: 'Nina Novak',
u_pat: 'Pat Price',
u_lee: 'Lee Lin',
u_dev_admin: 'Dev Admin',
};

const request: Record<string, unknown> = {
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<string, unknown>) => row(key, options),
language: 'en',
};
return { approvalsApiStub, setSlate, row, ADAPTER, AUTH, I18N };
});

vi.mock('@object-ui/i18n', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useObjectTranslation: () => I18N,
}));

vi.mock('@object-ui/auth', async (importOriginal) => {
const authFetch = vi.fn(async () => new Response('{}', { status: 200 }));
return {
...(await importOriginal<Record<string, unknown>>()),
useAuth: () => AUTH,
createAuthenticatedFetch: () => authFetch,
TokenStorage: { get: () => null },
};
});

vi.mock('@object-ui/app-shell', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAdapter: () => ADAPTER,
DeclaredActionsBar: () => null,
isViaOverrideRow: () => false,
}));

vi.mock('../../services/approvalsApi', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
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(
<MePermissionsProvider initialPermissions={permissionsPayload()}>
<MemoryRouter initialEntries={[`/apps/${APP}/system/approvals?request=req_12781`]}>
<Routes>
<Route path="/apps/:appName/system/approvals" element={<ApprovalsInboxPage />} />
</Routes>
</MemoryRouter>
</MePermissionsProvider>,
);
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);
});
});
3 changes: 2 additions & 1 deletion apps/console/src/pages/system/ApprovalsInboxPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="border-t pt-3">
<div className="flex items-baseline justify-between gap-2 mb-1.5">
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>()),
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, unknown>): string {
return String(i18n.t(`approvalsInbox.${key}`, args));
}

const NAMES: Record<string, string> = {
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(
<I18nProvider instance={i18n}>
<RecordApprovalsPanel
approvals={{ available: true, requests: [request], pendingRequest: request }}
currentUserId="u_viewer"
/>
</I18nProvider>,
);
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);
});
});
3 changes: 2 additions & 1 deletion packages/app-shell/src/views/RecordApprovalsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -365,7 +366,7 @@ export const RecordApprovalsPanel: React.FC<RecordApprovalsPanelProps> = ({
: 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;
Expand Down
Loading
Loading