diff --git a/.changeset/27615-approver-slots-chip.md b/.changeset/27615-approver-slots-chip.md new file mode 100644 index 00000000000..cf6ebebc96e --- /dev/null +++ b/.changeset/27615-approver-slots-chip.md @@ -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. diff --git a/ui/apps/console/src/services/approvalRequestsDataSource.servedPageApprovers-12482.test.tsx b/ui/apps/console/src/services/approvalRequestsDataSource.servedPageApprovers-12482.test.tsx index 01859d5ce47..2fe3328d60a 100644 --- a/ui/apps/console/src/services/approvalRequestsDataSource.servedPageApprovers-12482.test.tsx +++ b/ui/apps/console/src/services/approvalRequestsDataSource.servedPageApprovers-12482.test.tsx @@ -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'; @@ -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); }); diff --git a/ui/packages/app-shell/src/views/RecordApprovalsPanel.eligibleApprovers-12781.test.tsx b/ui/packages/app-shell/src/views/RecordApprovalsPanel.eligibleApprovers-12781.test.tsx index 41d1dd31266..5c9681d075d 100644 --- a/ui/packages/app-shell/src/views/RecordApprovalsPanel.eligibleApprovers-12781.test.tsx +++ b/ui/packages/app-shell/src/views/RecordApprovalsPanel.eligibleApprovers-12781.test.tsx @@ -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(); + }); +}); diff --git a/ui/packages/app-shell/src/views/RecordApprovalsPanel.test.tsx b/ui/packages/app-shell/src/views/RecordApprovalsPanel.test.tsx index 5879cb08434..d8f9f9ff3df 100644 --- a/ui/packages/app-shell/src/views/RecordApprovalsPanel.test.tsx +++ b/ui/packages/app-shell/src/views/RecordApprovalsPanel.test.tsx @@ -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>()), @@ -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.) diff --git a/ui/packages/app-shell/src/views/RecordApprovalsPanel.tsx b/ui/packages/app-shell/src/views/RecordApprovalsPanel.tsx index 655291b2d11..9ad41c4e521 100644 --- a/ui/packages/app-shell/src/views/RecordApprovalsPanel.tsx +++ b/ui/packages/app-shell/src/views/RecordApprovalsPanel.tsx @@ -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; /** @@ -460,7 +466,14 @@ export const RecordApprovalsPanel: React.FC = ({ {chip.label} {chip.detail && · {chip.detail}} {chip.group && · {chip.group}} - {chip.count > 1 && ×{chip.count}} + {chip.count > 1 && ( + + · {String(t('approvalsInbox.approverSlots', { + defaultValue: 'holds {{count}} approver slot(s)', + count: chip.count, + }))} + + )} ))} {isSubmitter && ( diff --git a/ui/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.eligibleApprovers-12781.test.tsx b/ui/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.eligibleApprovers-12781.test.tsx index 63607a961d5..fa06ef1a415 100644 --- a/ui/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.eligibleApprovers-12781.test.tsx +++ b/ui/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.eligibleApprovers-12781.test.tsx @@ -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'; @@ -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; + const en = builtInLocales.en.approvalsInbox as Record; + // 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( + + + + + , + ); + 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(); + }); +}); diff --git a/ui/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.test.tsx b/ui/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.test.tsx index afc13be13f3..3e71f6c70c4 100644 --- a/ui/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.test.tsx +++ b/ui/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.test.tsx @@ -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'); }); diff --git a/ui/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.tsx b/ui/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.tsx index 086737ce03c..80c0bdadbe4 100644 --- a/ui/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.tsx +++ b/ui/packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.tsx @@ -213,7 +213,14 @@ function PendingApprovers({ request }: { request: ApprovalRequestLite }) { {chip.label} {chip.detail && · {chip.detail}} {chip.group && · {chip.group}} - {chip.count > 1 && ×{chip.count}} + {chip.count > 1 && ( + + · {String(t('approvalsInbox.approverSlots', { + defaultValue: 'holds {{count}} approver slot(s)', + count: chip.count, + }))} + + )} ))} diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 11d695b4a79..e799b70a4f4 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -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: 'لوحة قرار الموافقة تعمل فقط في صفحات طلبات الموافقة.', diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 8a63b6ebaba..f71a6be992b 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -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.', diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index b65395d54b7..a37a51bded2 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -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.', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index f0ea8ead95f..162f92088d8 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -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.', diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index 51340d9097f..fa3500b61cb 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -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.', diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 30a17e31b81..9d0dd4ec853 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -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: '承認の決定パネルは承認リクエストのページでのみ機能します。', diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 0361dd580b5..9e52a430ded 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -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: '승인 결정 패널은 승인 요청 페이지에서만 작동합니다.', diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index 34644461485..432ee9b2cb9 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -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.', diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index 1b3659831a2..367ee649d55 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -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: 'Панель решения по согласованию работает только на страницах запросов на согласование.', diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index 67b6b4f393d..4e6a27fe587 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -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: '审批决策面板仅在审批请求页面上可用。',