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
15 changes: 15 additions & 0 deletions .changeset/27615-approver-slots-chip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@object-ui/app-shell': patch
'@object-ui/i18n': patch
---

fix(app-shell): an approver chip that stands for several of a step's slots says so in words, so it reads with the eligible-approver count beside the tally

Clause-②: yes (widening)
Metadata-protocol: none

When one person holds several of a pending step's approver slots (the stock showcase's committee quorum routes manager, finance and legal to the seeded admin), the "Waiting on" chip collapsed those slots into one chip with a bare `×3`, drawn beside "1 eligible approver". Both numbers were right, but side by side they read as a contradiction.

- **The chip.** Where it drew `×N`, it now draws the pack's words for what N counts: "Dev Admin · holds 3 approver slots". The same copy is drawn on the business record's approvals panel and on the approval request page.
- **The counts.** Unchanged. The chip still stands for every slot its person fills, and the eligible-approver count still counts that person once.
- **`@object-ui/i18n`.** A new count family, `approvalsInbox.approverSlots`, in all ten language packs, with every plural category each language selects.
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ vi.mock('sonner', () => ({

import { AdapterCtx, MetadataCtx, useAdapter } from '@object-ui/react';
import { I18nProvider, type I18nConfig } from '@object-ui/i18n';
import { builtInLocales } from '@object-ui/i18n/locales';
import { RecordDetailView } from '@object-ui/app-shell';
import { PageSchema } from '@objectstack/spec/ui';
import { APPROVAL_ACTION_OBJECT, APPROVAL_REQUEST_OBJECT } from './approvalRequestsDataSource';
Expand Down Expand Up @@ -353,7 +354,11 @@ describe('the served request page names who the pending step waits on (objectui#

it("objectui#12077's case: one approver in three quorum slots is named once, with a count, and the id is printed nowhere", async () => {
const panel = await openServedPage(QUORUM_ROW);
expect(chips(panel).map((chip) => chip.textContent)).toEqual(['Ada Lovelace×3']);
// The count is drawn in the en pack's words for it (objectstack#27615):
// the `approverSlots` family's plural row at 3, never a bare "×3".
const words = builtInLocales.en.approvalsInbox.approverSlots_other.replace('{{count}}', '3');
expect(chips(panel).map((chip) => chip.textContent)).toEqual([`Ada Lovelace· ${words}`]);
expect(pageText()).not.toContain('×3');
expect(pageText()).not.toContain('qFpo');
expect(pageText().match(/Ada Lovelace/g) ?? []).toHaveLength(1);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,37 @@ describe('RecordApprovalsPanel — eligible approvers count distinct users (obje
expect(bar.children).toHaveLength(2);
});
});

/**
* The chip and the count read as one story when one principal holds several
* of a step's slots (objectstack#27615). Neither number changes: the chip
* still collapses every slot its person fills, and the eligible count still
* counts that person once. The chip now says, in the pack's words, what its
* number counts, where it drew a bare "×3" beside "1 eligible approver".
*/
describe('RecordApprovalsPanel — a principal holding several slots: the chip and the count read as one story (objectstack#27615)', () => {
const chips = (panel: HTMLElement) => within(panel).getAllByTestId('approver-chip');

it('the stock committee quorum: one chip that says its person holds three slots, beside one eligible approver', async () => {
const { panel } = await renderPanel(pendingRequest(['u_dev_admin', 'u_dev_admin', 'u_dev_admin']));
const words = row('approverSlots', { count: 3 });

// The row resolved from the pack's count family, not from the key itself.
expect(words).not.toBe('approvalsInbox.approverSlots');
expect(words).toContain('3');
expect(chips(panel).map((chip) => chip.textContent)).toEqual([`Dev Admin· ${words}`]);
expect(panel.textContent).not.toContain('×');
expect(within(panel).getByText(row('progressEligible', { count: 1 }))).toBeTruthy();
});

it('one person in two slots among others: only that chip carries the slot words', async () => {
const { panel } = await renderPanel(pendingRequest(['u_nina', 'u_dev_admin', 'u_dev_admin', 'u_lee']));

expect(chips(panel).map((chip) => chip.textContent)).toEqual([
'Nina Novak',
`Dev Admin· ${row('approverSlots', { count: 2 })}`,
'Lee Lin',
]);
expect(within(panel).getByText(row('progressEligible', { count: 3 }))).toBeTruthy();
});
});
10 changes: 8 additions & 2 deletions ui/packages/app-shell/src/views/RecordApprovalsPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import * as React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor, cleanup, fireEvent } from '@testing-library/react';
import type { ApprovalRequestLite } from '../hooks/useRecordApprovals';
import { builtInLocales } from '@object-ui/i18n/locales';

vi.mock('@object-ui/auth', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
Expand Down Expand Up @@ -160,9 +161,14 @@ describe('RecordApprovalsPanel — read-gated approval visibility (objectui#3461
expect(screen.getByText('Qian Hua')).toBeTruthy();
expect(screen.getByText('Li Lei')).toBeTruthy();
expect(screen.queryByText('u_qa_head')).toBeNull();
// Group label + the collapsed ×2 count for the double-slot approver.
// Group label + the collapsed count for the double-slot approver, in the
// words of the slot family's count-invariant row (this render runs with no
// i18next instance, so the inline default, which is that row, is drawn).
expect(screen.getByText('· production')).toBeTruthy();
expect(screen.getByText('×2')).toBeTruthy();
expect(
screen.getByText(`· ${builtInLocales.en.approvalsInbox.approverSlots.replace('{{count}}', '2')}`),
).toBeTruthy();
expect(screen.queryByText('×2')).toBeNull();
// The per-group branch of the tally copy. (The literal counts are
// interpolated by i18next, which this render intentionally runs without —
// the group badges below carry the numbers.)
Expand Down
15 changes: 14 additions & 1 deletion ui/packages/app-shell/src/views/RecordApprovalsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,12 @@ export { formatIdentity };
export interface ApproverChip {
label: string;
group?: string;
/**
* How many of the step's slots this chip stands for. Above one, both panels
* draw it in words (`approvalsInbox.approverSlots`, "holds 3 approver
* slots"), so it reads with the eligible-approver count beside the tally,
* which counts that person once (objectui#12781).
*/
count: number;
title: string;
/**
Expand Down Expand Up @@ -460,7 +466,14 @@ export const RecordApprovalsPanel: React.FC<RecordApprovalsPanelProps> = ({
{chip.label}
{chip.detail && <span className="ml-1 text-muted-foreground">· {chip.detail}</span>}
{chip.group && <span className="ml-1 text-muted-foreground">· {chip.group}</span>}
{chip.count > 1 && <span className="ml-1 text-muted-foreground">×{chip.count}</span>}
{chip.count > 1 && (
<span className="ml-1 text-muted-foreground">
· {String(t('approvalsInbox.approverSlots', {
defaultValue: 'holds {{count}} approver slot(s)',
count: chip.count,
}))}
</span>
)}
</Badge>
))}
{isSubmitter && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ vi.mock('../DeclaredActionsBar.js', () => ({

import { RecordContextProvider } from '@object-ui/react';
import { createI18n, I18nProvider } from '@object-ui/i18n';
import { builtInLocales } from '@object-ui/i18n/locales';
import { ApprovalDecisionRenderer } from './ApprovalDecisionPanel';
import { resetApproverDirectoryCache } from '../../hooks/useApproverDirectory';

Expand Down Expand Up @@ -109,3 +110,64 @@ describe('ApprovalDecisionPanel — eligible approvers count distinct users (obj
expect(bar.children).toHaveLength(2);
});
});

/**
* The chip and the count read as one story when one principal holds several
* of a step's slots (objectstack#27615). Both numbers stay as they were: the
* chip still stands for every slot its person fills, and the eligible count
* still counts that person once. What changed is that the chip says, in the
* pack's words, what its number counts, instead of a bare "×3" beside
* "1 eligible approver".
*/
describe('ApprovalDecisionPanel — a principal holding several slots: the chip and the count read as one story (objectstack#27615)', () => {
const chips = (panel: HTMLElement) => within(panel).getAllByTestId('approver-chip');

it('the stock committee quorum: one chip that says its person holds three slots, beside one eligible approver', () => {
const { panel } = mount(requestRow(['u_dev_admin', 'u_dev_admin', 'u_dev_admin']));
const words = row('approverSlots', { count: 3 });

// The row resolved from the pack's count family, not from the key itself.
expect(words).not.toBe('approvalsInbox.approverSlots');
expect(words).toContain('3');
expect(chips(panel).map((chip) => chip.textContent)).toEqual([`Dev Admin· ${words}`]);
expect(panel.textContent).not.toContain('×');
expect(within(panel).getByText(row('progressEligible', { count: 1 }))).toBeTruthy();
});

it('one person in two slots among others: only that chip carries the slot words', () => {
const { panel } = mount(requestRow(['u_nina', 'u_dev_admin', 'u_dev_admin', 'u_lee']));

expect(chips(panel).map((chip) => chip.textContent)).toEqual([
'Nina Novak',
`Dev Admin· ${row('approverSlots', { count: 2 })}`,
'Lee Lin',
]);
expect(within(panel).getByText(row('progressEligible', { count: 3 }))).toBeTruthy();
});

it('the zh pack draws its own words for the chip, beside its own eligible count', async () => {
const zh = builtInLocales.zh.approvalsInbox as Record<string, string>;
const en = builtInLocales.en.approvalsInbox as Record<string, string>;
// zh selects `other` at every count.
const zhWords = zh.approverSlots_other.replace('{{count}}', '3');
expect(zhWords).not.toBe(en.approverSlots_other.replace('{{count}}', '3'));

render(
<I18nProvider config={{ defaultLanguage: 'zh', detectBrowserLanguage: false }} persistLanguage={false}>
<RecordContextProvider
objectName="sys_approval_request"
recordId="areq_12781"
data={requestRow(['u_dev_admin', 'u_dev_admin', 'u_dev_admin'])}
>
<ApprovalDecisionRenderer />
</RecordContextProvider>
</I18nProvider>,
);
const panel = screen.getByTestId('approval-decision-panel');

expect(
(await within(panel).findAllByTestId('approver-chip')).map((chip) => chip.textContent),
).toEqual([`Dev Admin· ${zhWords}`]);
expect(within(panel).getByText(zh.progressEligible_other.replace('{{count}}', '1'))).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,12 @@ describe('names who the pending step waits on (objectui#12482)', () => {
pending_approvers: [id, id, id],
pending_approver_names: { [id]: 'Ada Lovelace' },
});
expect(chips().map((chip) => chip.textContent)).toEqual(['Ada Lovelace×3']);
// The count is drawn in words (objectstack#27615). This render runs with no
// i18next instance, so the inline default is drawn: the slot family's
// count-invariant row.
expect(chips().map((chip) => chip.textContent)).toEqual([
`Ada Lovelace· ${builtInLocales.en.approvalsInbox.approverSlots.replace('{{count}}', '3')}`,
]);
expect(chips()[0].getAttribute('title')).toBe(id);
expect(panelText()).not.toContain('qFpo');
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,14 @@ function PendingApprovers({ request }: { request: ApprovalRequestLite }) {
{chip.label}
{chip.detail && <span className="ml-1 text-muted-foreground">· {chip.detail}</span>}
{chip.group && <span className="ml-1 text-muted-foreground">· {chip.group}</span>}
{chip.count > 1 && <span className="ml-1 text-muted-foreground">×{chip.count}</span>}
{chip.count > 1 && (
<span className="ml-1 text-muted-foreground">
· {String(t('approvalsInbox.approverSlots', {
defaultValue: 'holds {{count}} approver slot(s)',
count: chip.count,
}))}
</span>
)}
</Badge>
))}
</div>
Expand Down
7 changes: 7 additions & 0 deletions ui/packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4503,6 +4503,13 @@ const ar = {
progressEligible_few: '{{count}} معتمِدين مؤهلين',
progressEligible_many: '{{count}} معتمِدًا مؤهلًا',
progressEligible_other: '{{count}} معتمِد مؤهل',
approverSlots: 'يشغل {{count}} من مقاعد الاعتماد',
approverSlots_zero: 'يشغل {{count}} من مقاعد الاعتماد',
approverSlots_one: 'يشغل {{count}} من مقاعد الاعتماد',
approverSlots_two: 'يشغل {{count}} من مقاعد الاعتماد',
approverSlots_few: 'يشغل {{count}} من مقاعد الاعتماد',
approverSlots_many: 'يشغل {{count}} من مقاعد الاعتماد',
approverSlots_other: 'يشغل {{count}} من مقاعد الاعتماد',
progressBar: 'تقدم القرار',
declaredActions: 'الإجراءات',
decisionPanelOffRequestPage: 'لوحة قرار الموافقة تعمل فقط في صفحات طلبات الموافقة.',
Expand Down
3 changes: 3 additions & 0 deletions ui/packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4166,6 +4166,9 @@ const de = {
progressEligible: '{{count}} berechtigte Genehmiger',
progressEligible_one: '{{count}} berechtigter Genehmiger',
progressEligible_other: '{{count}} berechtigte Genehmiger',
approverSlots: 'belegt {{count}} Genehmigerplätze',
approverSlots_one: 'belegt {{count}} Genehmigerplatz',
approverSlots_other: 'belegt {{count}} Genehmigerplätze',
progressBar: 'Entscheidungsfortschritt',
declaredActions: 'Aktionen',
decisionPanelOffRequestPage: 'Der Entscheidungsbereich für Genehmigungen funktioniert nur auf Seiten von Genehmigungsanfragen.',
Expand Down
5 changes: 5 additions & 0 deletions ui/packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4952,6 +4952,11 @@ const en = {
progressEligible: '{{count}} eligible approver(s)',
progressEligible_one: '{{count}} eligible approver',
progressEligible_other: '{{count}} eligible approvers',
// The chip beside an approver who holds several of the step's slots; the
// eligible-approver count beside the tally counts that person once.
approverSlots: 'holds {{count}} approver slot(s)',
approverSlots_one: 'holds {{count}} approver slot',
approverSlots_other: 'holds {{count}} approver slots',
progressBar: 'Decision progress',
declaredActions: 'Actions',
decisionPanelOffRequestPage: 'The approval decision panel only works on approval request pages.',
Expand Down
4 changes: 4 additions & 0 deletions ui/packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4251,6 +4251,10 @@ const es = {
progressEligible_one: '{{count}} aprobador elegible',
progressEligible_many: '{{count}} de aprobadores elegibles',
progressEligible_other: '{{count}} aprobadores elegibles',
approverSlots: 'ocupa {{count}} puesto(s) de aprobador',
approverSlots_one: 'ocupa {{count}} puesto de aprobador',
approverSlots_many: 'ocupa {{count}} de puestos de aprobador',
approverSlots_other: 'ocupa {{count}} puestos de aprobador',
progressBar: 'Progreso de la decisión',
declaredActions: 'Acciones',
decisionPanelOffRequestPage: 'El panel de decisión de aprobación solo funciona en las páginas de solicitudes de aprobación.',
Expand Down
4 changes: 4 additions & 0 deletions ui/packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4247,6 +4247,10 @@ const fr = {
progressEligible_one: '{{count}} approbateur éligible',
progressEligible_many: "{{count}} d'approbateurs éligibles",
progressEligible_other: '{{count}} approbateurs éligibles',
approverSlots: "occupe {{count}} siège(s) d'approbateur",
approverSlots_one: "occupe {{count}} siège d'approbateur",
approverSlots_many: "occupe {{count}} de sièges d'approbateur",
approverSlots_other: "occupe {{count}} sièges d'approbateur",
progressBar: 'Avancement de la décision',
declaredActions: 'Actions',
decisionPanelOffRequestPage: 'Le panneau de décision d\'approbation ne fonctionne que sur les pages de demande d\'approbation.',
Expand Down
3 changes: 3 additions & 0 deletions ui/packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4168,6 +4168,9 @@ const ja = {
progressEligible: '承認可能者 {{count}} 名',
progressEligible_one: '承認可能者 {{count}} 名',
progressEligible_other: '承認可能者 {{count}} 名',
approverSlots: '承認枠 {{count}} 件を兼任',
approverSlots_one: '承認枠 {{count}} 件を兼任',
approverSlots_other: '承認枠 {{count}} 件を兼任',
progressBar: '承認の進捗',
declaredActions: 'アクション',
decisionPanelOffRequestPage: '承認の決定パネルは承認リクエストのページでのみ機能します。',
Expand Down
3 changes: 3 additions & 0 deletions ui/packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4165,6 +4165,9 @@ const ko = {
progressEligible: '승인 가능자 {{count}}명',
progressEligible_one: '승인 가능자 {{count}}명',
progressEligible_other: '승인 가능자 {{count}}명',
approverSlots: '승인 자리 {{count}}개 겸임',
approverSlots_one: '승인 자리 {{count}}개 겸임',
approverSlots_other: '승인 자리 {{count}}개 겸임',
progressBar: '결정 진행 상황',
declaredActions: '작업',
decisionPanelOffRequestPage: '승인 결정 패널은 승인 요청 페이지에서만 작동합니다.',
Expand Down
4 changes: 4 additions & 0 deletions ui/packages/i18n/src/locales/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4246,6 +4246,10 @@ const pt = {
progressEligible_one: '{{count}} aprovador elegível',
progressEligible_many: '{{count}} de aprovadores elegíveis',
progressEligible_other: '{{count}} aprovadores elegíveis',
approverSlots: 'ocupa {{count}} posto(s) de aprovador',
approverSlots_one: 'ocupa {{count}} posto de aprovador',
approverSlots_many: 'ocupa {{count}} de postos de aprovador',
approverSlots_other: 'ocupa {{count}} postos de aprovador',
progressBar: 'Progresso da decisão',
declaredActions: 'Ações',
decisionPanelOffRequestPage: 'O painel de decisão de aprovação só funciona nas páginas de solicitações de aprovação.',
Expand Down
5 changes: 5 additions & 0 deletions ui/packages/i18n/src/locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4345,6 +4345,11 @@ const ru = {
progressEligible_few: 'Доступных согласующих: {{count}}',
progressEligible_many: 'Доступных согласующих: {{count}}',
progressEligible_other: 'Доступных согласующих: {{count}}',
approverSlots: 'Мест согласующего: {{count}}',
approverSlots_one: 'занимает {{count}} место согласующего',
approverSlots_few: 'занимает {{count}} места согласующего',
approverSlots_many: 'занимает {{count}} мест согласующего',
approverSlots_other: 'занимает {{count}} места согласующего',
progressBar: 'Ход решения',
declaredActions: 'Действия',
decisionPanelOffRequestPage: 'Панель решения по согласованию работает только на страницах запросов на согласование.',
Expand Down
3 changes: 3 additions & 0 deletions ui/packages/i18n/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4359,6 +4359,9 @@ const zh = {
progressEligible: '{{count}} 位可审批人',
progressEligible_one: '{{count}} 位可审批人',
progressEligible_other: '{{count}} 位可审批人',
approverSlots: '兼任 {{count}} 个审批席位',
approverSlots_one: '兼任 {{count}} 个审批席位',
approverSlots_other: '兼任 {{count}} 个审批席位',
progressBar: '决策进度',
declaredActions: '操作',
decisionPanelOffRequestPage: '审批决策面板仅在审批请求页面上可用。',
Expand Down
Loading