Skip to content

Commit 946395d

Browse files
fix(app-shell): the bell header breakdown names its unit, unread topics (#27775)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgDFH4S5pR1C8YvYCWWsZfXx)_ ## 维护者速读 - 改了什么:铃铛弹层顶部明细的第一项从「N 条通知」改成「N 个未读主题」(十个语言包同键改文案),数字算法不变。 - 为什么改:同一主题 3 条未读 + 3 个待审批时,顶部写「1 条通知」,下面唯一的合并行写「×3 · 3 条未读」,读者分不清等着的是一件事还是三件。现在顶部说「1 个未读主题」,正是那一行。 - 风险与回滚:只动文案和测试;没有新 i18n 键、没有新导出,徽标算术(#7233)不变。回滚 revert 本 PR 即可。 - 先看:`ui/packages/i18n/src/locales/en.ts`(badgeNotifications 三行)、`ui/packages/app-shell/src/layout/__tests__/InboxPopover.oneStory-27704.test.tsx`、`InboxPopover.tsx` 默认文案一行。 - 两分钟验证:`cd ui && pnpm exec vitest run packages/app-shell/src/layout/__tests__/InboxPopover.oneStory-27704.test.tsx`,预期 2 个测试通过。 - 需要你做的:无,席位落地。 Fixes #27704 Clause-②: no Metadata-protocol: none ## What changes - `notifications.badgeNotifications` (the first addend of the bell header breakdown) now names its unit in all ten packs: en "1 unread topic / 3 unread topics", zh "N 个未读主题", and the matching wording in de/es/fr/pt/ru/ja/ko/ar. Same key, same plural family (every slot keeps `{{count}}`, as `inboxBadgeBreakdown-i18n-7233.test.ts` pins). - `InboxPopover.tsx`: the `defaultValue` follows the new copy; the breakdown comment says why the unit is named. - The badge arithmetic is untouched: `unreadTopics + pendingApprovalsCount`. - The grouped row's other ask (expand to list its records) was measured first: `toggleGroup` already expands a coalesced row to one line per record. The new test pins that too; no code change was needed there. ## Tests - New `InboxPopover.oneStory-27704.test.tsx` (real i18next + shipped `en` pack): 3 unread rows of one topic + 3 pending approvals read "4 total · 1 unread topic + 3 pending approvals", and the one row reads "×3 … 3 unread"; clicking the row lists D-1, D-2, D-3. - Updated string pins: `AppHeader.inboxVariant.test.tsx` (×2), `InboxPopover.badgeBreakdown.test.tsx` (×2), `InboxPopover.badgePlural-13133.test.tsx` (×2). A grep of `ui/` (apps and packages) for the old strings and for `inbox-badge-breakdown-notifications` found no other pin. ## Acceptance notes - Wording chose "topic", the unit the badge already counts (`groupNotifications` groups by `(topic, title)`), over counting the same unit in both places: counting messages in the header would change the #7233 badge arithmetic, which is ruled. - ar uses one counter-style form ("مواضيع غير مقروءة: N") in every plural slot, as that pack already does for `groupUnread`. ## Verification All readings at head `d86a60a2`. <details><summary>Tests (local, under the shared lock)</summary> - `pnpm --filter '@object-ui/app-shell^...' build` → VERDICT command-exit 0. - `cd ui && pnpm exec vitest run --maxWorkers=2 packages/app-shell/src/layout/__tests__/ packages/i18n/src/__tests__/ apps/console/src/__tests__/approvalsInboxComponentRef.test.tsx` → Test Files 210 passed (210), Tests 2155 passed / 13 skipped; VERDICT command-exit 0. - `pnpm --filter @object-ui/i18n run type-check && pnpm --filter @object-ui/app-shell run type-check` → VERDICT command-exit 0. - Ablation (one-shot, not kept): `node scripts/ablation-replace.mjs` set en `badgeNotifications_one` back to `{{count}} notification` (anchor 1 → 0, blob e66ea04 → cd754a52); the new test went red (1 failed, 1 passed: "names the unit"); restored, blob == HEAD and `git diff HEAD` empty. </details> <details><summary>Derived gates (34 + 2 extra), each exit recorded before any pipe</summary> - All 34 derived families exit 0; `dispatch-gates --ran` reconciles 34 derived, 34 run, 0 NOT-MEASURED. Extra: `pnpm check:changeset-fixed` and `pnpm check:i18n-stale-fill` exit 0. `node scripts/check-sdui-manifest.mjs` exit 0. - `pnpm --dir ui exec vitest run scripts/` exited 1 on its first run (`check-i18n-en-drift`: `badgeNotifications_one` "wrong") because it overlapped my own ablation of `en.ts`; a second run overlapped other gates rebuilding `packages/spec/dist`. Re-run alone: Test Files 169 passed, Tests 4902 passed, exit 0. - Type-check lanes, the artifact rosters and the wide-population families are CI's; see this PR's check-runs. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Utp6Ca8dn8NpfgEtvP4VTt Co-authored-by: Claude <noreply@anthropic.com>
1 parent 03cc871 commit 946395d

16 files changed

Lines changed: 170 additions & 49 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
fix(app-shell): the bell's header breakdown names its unit, unread topics, so it agrees with the grouped row it counts
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
With three unread notifications about one topic and three pending approvals, the inbox header read "4 total · 1 notification + 3 pending approvals" while the one coalesced row read "×3 · 3 unread". Both numbers were right, in different units. The first addend of the breakdown now reads "1 unread topic" (`notifications.badgeNotifications`, reworded in all ten packs, same key and plural family). The badge arithmetic is unchanged: unread topics plus pending approvals.

‎ui/packages/app-shell/src/layout/InboxPopover.tsx‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -406,7 +406,10 @@ export function InboxPopover({
406406
Spell the addends out here, unclamped, so a user seeing "9+" can
407407
read exactly which N notifications and which M pending approvals it
408408
is made of (the M is the same count Home's approvals card and the
409-
Approvals Inbox tab show — one source, `pendingApprovalsCount`). */}
409+
Approvals Inbox tab show — one source, `pendingApprovalsCount`).
410+
The first addend names its unit — unread *topics* (objectstack#27704):
411+
one coalesced row reading "×3 · 3 unread" is one topic, so the line
412+
and the row tell one story instead of "1 notification" against "3". */}
410413
{totalBadge > 0 && (
411414
<div
412415
data-testid="inbox-badge-breakdown"
@@ -421,7 +424,7 @@ export function InboxPopover({
421424
<span aria-hidden>·</span>
422425
<span data-testid="inbox-badge-breakdown-notifications">
423426
{t('notifications.badgeNotifications', {
424-
defaultValue: '{{count}} notifications',
427+
defaultValue: '{{count}} unread topics',
425428
count: unreadTopics,
426429
})}
427430
</span>

‎ui/packages/app-shell/src/layout/__tests__/AppHeader.inboxVariant.test.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -437,7 +437,7 @@ describe('AppHeader — Approvals + Activity fill in every variant (#4197)', ()
437437
);
438438
expect(screen.getByTestId('inbox-badge-breakdown-total')).toHaveTextContent('3 total');
439439
expect(screen.getByTestId('inbox-badge-breakdown-notifications')).toHaveTextContent(
440-
'1 notifications',
440+
'1 unread topics',
441441
);
442442
});
443443
}
@@ -757,7 +757,7 @@ describe('AppHeader — the bell panel renders what the inbox returns, under BOT
757757
// the breakdown line spells that out unclamped next to the listed rows.
758758
await waitFor(() => expect(screen.getByTestId('inbox-bell-badge')).toHaveTextContent('4'));
759759
expect(screen.getByTestId('inbox-badge-breakdown-notifications')).toHaveTextContent(
760-
'4 notifications',
760+
'4 unread topics',
761761
);
762762
expect(screen.getByTestId('inbox-badge-breakdown-approvals')).toHaveTextContent(
763763
'0 pending approvals',

‎ui/packages/app-shell/src/layout/__tests__/InboxPopover.badgeBreakdown.test.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ describe('InboxPopover — bell badge breakdown (#7233)', () => {
109109
// …and the popover spells out what that "9+" is made of, unclamped.
110110
expect(screen.getByTestId('inbox-badge-breakdown-total')).toHaveTextContent('15 total');
111111
expect(screen.getByTestId('inbox-badge-breakdown-notifications')).toHaveTextContent(
112-
'12 notifications',
112+
'12 unread topics',
113113
);
114114
expect(screen.getByTestId('inbox-badge-breakdown-approvals')).toHaveTextContent(
115115
'3 pending approvals',
@@ -158,7 +158,7 @@ describe('InboxPopover — bell badge breakdown (#7233)', () => {
158158
});
159159

160160
expect(screen.getByTestId('inbox-badge-breakdown-notifications')).toHaveTextContent(
161-
'3 notifications',
161+
'3 unread topics',
162162
);
163163
expect(screen.getByTestId('inbox-badge-breakdown-total')).toHaveTextContent('4 total');
164164
});

‎ui/packages/app-shell/src/layout/__tests__/InboxPopover.badgePlural-13133.test.tsx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,15 +82,15 @@ function renderPopover(unread: number, pendingApprovalsCount: number) {
8282
}
8383

8484
describe('InboxPopover — badge breakdown agrees in number (objectui#13133)', () => {
85-
it('reads "1 notification" for one unread topic, and "1 pending approval" for one approval', () => {
85+
it('reads "1 unread topic" for one unread topic, and "1 pending approval" for one approval', () => {
8686
renderPopover(1, 1);
87-
expect(screen.getByTestId('inbox-badge-breakdown-notifications')).toHaveTextContent(/^1 notification$/);
87+
expect(screen.getByTestId('inbox-badge-breakdown-notifications')).toHaveTextContent(/^1 unread topic$/);
8888
expect(screen.getByTestId('inbox-badge-breakdown-approvals')).toHaveTextContent(/^1 pending approval$/);
8989
});
9090

91-
it('reads "3 notifications" and "3 pending approvals" for three', () => {
91+
it('reads "3 unread topics" and "3 pending approvals" for three', () => {
9292
renderPopover(3, 3);
93-
expect(screen.getByTestId('inbox-badge-breakdown-notifications')).toHaveTextContent(/^3 notifications$/);
93+
expect(screen.getByTestId('inbox-badge-breakdown-notifications')).toHaveTextContent(/^3 unread topics$/);
9494
expect(screen.getByTestId('inbox-badge-breakdown-approvals')).toHaveTextContent(/^3 pending approvals$/);
9595
});
9696

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* objectstack#27704 — three unread notifications about one topic plus three
6+
* pending approvals: the header read "4 total · 1 notification + 3 pending
7+
* approvals" while the one coalesced row read "×3 · 3 unread", so the reader
8+
* could not tell whether one thing or three were waiting. The badge arithmetic
9+
* (#7233: unread topics + pending approvals) is unchanged; the header addend
10+
* now names its unit, and the row it counts is the topic holding the three.
11+
*
12+
* Rendered against a REAL i18next instance and the shipped `en` pack, like
13+
* the sibling `InboxPopover.badgePlural-13133.test.tsx`.
14+
*/
15+
import '@testing-library/jest-dom/vitest';
16+
import type { ButtonHTMLAttributes, ReactNode } from 'react';
17+
import { describe, it, expect, vi } from 'vitest';
18+
import { fireEvent, render, screen, within } from '@testing-library/react';
19+
import { createI18n, I18nProvider } from '@object-ui/i18n';
20+
21+
vi.mock('react-router-dom', () => ({
22+
useNavigate: () => vi.fn(),
23+
useParams: () => ({ appName: 'setup' }),
24+
}));
25+
26+
vi.mock('../../context/NavigationContext', () => ({
27+
useNavigationContext: () => ({ currentAppName: 'setup' }),
28+
}));
29+
30+
type Slot = { children?: ReactNode };
31+
32+
// Passthrough primitives so the popover body renders without driving Radix
33+
// open/close in jsdom (the pattern InboxPopover.badgeBreakdown.test.tsx uses).
34+
vi.mock('@object-ui/components', async (importOriginal) => ({
35+
...(await importOriginal<Record<string, unknown>>()),
36+
Button: ({ children, ...p }: ButtonHTMLAttributes<HTMLButtonElement>) => (
37+
<button type="button" {...p}>
38+
{children}
39+
</button>
40+
),
41+
Popover: ({ children }: Slot) => <div>{children}</div>,
42+
PopoverTrigger: ({ children }: Slot) => <div>{children}</div>,
43+
PopoverContent: ({ children }: Slot) => <div>{children}</div>,
44+
Tabs: ({ children }: Slot) => <div>{children}</div>,
45+
TabsList: ({ children }: Slot) => <div>{children}</div>,
46+
TabsTrigger: ({ children }: Slot) => <button type="button">{children}</button>,
47+
TabsContent: ({ children }: Slot) => <div>{children}</div>,
48+
}));
49+
50+
import { InboxPopover, type InboxNotification } from '../InboxPopover';
51+
52+
/** Three unread rows of one `(topic, title)` pair: one coalesced group. */
53+
const oneTopicThreeUnread: InboxNotification[] = [1, 2, 3].map((i) => ({
54+
id: `n${i}`,
55+
type: 'deal.approved',
56+
title: 'Deal approved',
57+
body: `Deal D-${i} was approved`,
58+
is_read: false,
59+
created_at: `2026-10-29T10:0${i}:00Z`,
60+
}));
61+
62+
function renderPopover() {
63+
const i18n = createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false });
64+
const wrapper = ({ children }: { children: ReactNode }) => (
65+
<I18nProvider instance={i18n} persistLanguage={false}>
66+
{children}
67+
</I18nProvider>
68+
);
69+
return render(
70+
<InboxPopover
71+
notifications={oneTopicThreeUnread}
72+
unreadCount={oneTopicThreeUnread.length}
73+
pendingApprovalsCount={3}
74+
activity={{ value: [], status: 'ready', readable: true }}
75+
onMarkAllRead={vi.fn()}
76+
onMarkRead={vi.fn()}
77+
/>,
78+
{ wrapper },
79+
);
80+
}
81+
82+
describe('InboxPopover — the header breakdown and the grouped row tell one story (objectstack#27704)', () => {
83+
it('names the unit: 1 unread topic, whose one row holds the 3 unread', () => {
84+
renderPopover();
85+
// #7233 arithmetic untouched: 1 unread topic + 3 pending approvals.
86+
expect(screen.getByTestId('inbox-badge-breakdown-total')).toHaveTextContent(/^4 total$/);
87+
expect(screen.getByTestId('inbox-badge-breakdown-notifications')).toHaveTextContent(/^1 unread topic$/);
88+
expect(screen.getByTestId('inbox-badge-breakdown-approvals')).toHaveTextContent(/^3 pending approvals$/);
89+
90+
// Exactly one row: the topic the header counted, carrying its 3 unread.
91+
const row = screen.getByRole('button', { expanded: false });
92+
expect(within(row).getByText('Deal approved')).toBeInTheDocument();
93+
expect(within(row).getByText('×3')).toBeInTheDocument();
94+
expect(row).toHaveTextContent('3 unread');
95+
});
96+
97+
it('the grouped row expands to list each of its records', () => {
98+
renderPopover();
99+
expect(screen.queryByText('Deal D-2 was approved')).not.toBeInTheDocument();
100+
fireEvent.click(screen.getByRole('button', { expanded: false }));
101+
expect(screen.getByRole('button', { expanded: true })).toBeInTheDocument();
102+
// D-1 is also the collapsed row's preview line, so it now reads twice.
103+
expect(screen.getAllByText('Deal D-1 was approved')).toHaveLength(2);
104+
expect(screen.getByText('Deal D-2 was approved')).toBeInTheDocument();
105+
expect(screen.getByText('Deal D-3 was approved')).toBeInTheDocument();
106+
});
107+
});

‎ui/packages/i18n/src/locales/ar.ts‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3715,13 +3715,13 @@ const ar = {
37153715
noPendingApprovals: "لا توجد موافقات معلقة",
37163716
openApprovalsInbox: "فتح صندوق الموافقات",
37173717
badgeTotal: "{{total}} إجمالاً",
3718-
badgeNotifications: "{{count}} إشعارات",
3719-
badgeNotifications_zero: "{{count}} إشعار",
3720-
badgeNotifications_one: "{{count}} إشعار",
3721-
badgeNotifications_two: "إشعاران ({{count}})",
3722-
badgeNotifications_few: "{{count}} إشعارات",
3723-
badgeNotifications_many: "{{count}} إشعار",
3724-
badgeNotifications_other: "{{count}} إشعار",
3718+
badgeNotifications: "مواضيع غير مقروءة: {{count}}",
3719+
badgeNotifications_zero: "مواضيع غير مقروءة: {{count}}",
3720+
badgeNotifications_one: "مواضيع غير مقروءة: {{count}}",
3721+
badgeNotifications_two: "مواضيع غير مقروءة: {{count}}",
3722+
badgeNotifications_few: "مواضيع غير مقروءة: {{count}}",
3723+
badgeNotifications_many: "مواضيع غير مقروءة: {{count}}",
3724+
badgeNotifications_other: "مواضيع غير مقروءة: {{count}}",
37253725
badgeApprovals: "{{count}} موافقات معلقة",
37263726
badgeApprovals_zero: "{{count}} موافقة معلقة",
37273727
badgeApprovals_one: "{{count}} موافقة معلقة",

‎ui/packages/i18n/src/locales/de.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3450,9 +3450,9 @@ const de = {
34503450
noPendingApprovals: "Keine ausstehenden Genehmigungen",
34513451
openApprovalsInbox: "Genehmigungs-Posteingang öffnen",
34523452
badgeTotal: "{{total}} insgesamt",
3453-
badgeNotifications: "{{count}} Benachrichtigungen",
3454-
badgeNotifications_one: "{{count}} Benachrichtigung",
3455-
badgeNotifications_other: "{{count}} Benachrichtigungen",
3453+
badgeNotifications: "{{count}} ungelesene Themen",
3454+
badgeNotifications_one: "{{count}} ungelesenes Thema",
3455+
badgeNotifications_other: "{{count}} ungelesene Themen",
34563456
badgeApprovals: "{{count}} ausstehende Genehmigungen",
34573457
badgeApprovals_one: "{{count}} ausstehende Genehmigung",
34583458
badgeApprovals_other: "{{count}} ausstehende Genehmigungen",

‎ui/packages/i18n/src/locales/en.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4193,9 +4193,9 @@ const en = {
41934193
// and pending approvals, then clamps at "9+". These three spell the sum
41944194
// out inside the popover so the number is explainable.
41954195
badgeTotal: '{{total}} total',
4196-
badgeNotifications: '{{count}} notifications',
4197-
badgeNotifications_one: '{{count}} notification',
4198-
badgeNotifications_other: '{{count}} notifications',
4196+
badgeNotifications: '{{count}} unread topics',
4197+
badgeNotifications_one: '{{count}} unread topic',
4198+
badgeNotifications_other: '{{count}} unread topics',
41994199
badgeApprovals: '{{count}} pending approvals',
42004200
badgeApprovals_one: '{{count}} pending approval',
42014201
badgeApprovals_other: '{{count}} pending approvals',

‎ui/packages/i18n/src/locales/es.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3517,10 +3517,10 @@ const es = {
35173517
noPendingApprovals: "Sin aprobaciones pendientes",
35183518
openApprovalsInbox: "Abrir bandeja de aprobaciones",
35193519
badgeTotal: "{{total}} en total",
3520-
badgeNotifications: "{{count}} notificaciones",
3521-
badgeNotifications_one: "{{count}} notificación",
3522-
badgeNotifications_many: "{{count}} de notificaciones",
3523-
badgeNotifications_other: "{{count}} notificaciones",
3520+
badgeNotifications: "{{count}} temas sin leer",
3521+
badgeNotifications_one: "{{count}} tema sin leer",
3522+
badgeNotifications_many: "{{count}} de temas sin leer",
3523+
badgeNotifications_other: "{{count}} temas sin leer",
35243524
badgeApprovals: "{{count}} aprobaciones pendientes",
35253525
badgeApprovals_one: "{{count}} aprobación pendiente",
35263526
badgeApprovals_many: "{{count}} de aprobaciones pendientes",

0 commit comments

Comments
 (0)