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
30 changes: 30 additions & 0 deletions .changeset/inbox-bell-badge-breakdown-os7233.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
"@object-ui/app-shell": patch
"@object-ui/i18n": patch
---

The inbox popover now spells out what the bell badge is made of

The bell badge is `unread notification topics + pending approvals`, clamped to
"9+" above nine. As one number it is unexplainable: objectstack#7213 measured
Home's "pending approvals" card saying 8 while the bell said "9+", and read that
as the two counts disagreeing — they never did, the bell was simply carrying a
second addend the user could not see.

The popover already tabs the two streams and puts a count pill on each tab, so
the split was partly visible — but those pills clamp at "9+" too. A loaded
console therefore showed three "9+"s that reconcile to nothing, which is why
sectioning alone did not close this.

A breakdown line under the popover header now states the exact, unclamped
addends beside the exact total — `15 total · 12 notifications + 3 pending
approvals`. The approvals half is the same `pendingApprovalsCount` the Home card
and the Approvals Inbox tab read, so the number a user reconciles against is
literally the one they see elsewhere.

The badge formula, the counting APIs and the "9+" clamp on the badge itself are
unchanged — this is a display fix. Three new keys
(`notifications.badgeTotal` / `badgeNotifications` / `badgeApprovals`) land in
all ten locale packs. They interpolate named placeholders (`{{total}}`,
`{{unread}}`, `{{approvals}}`) rather than i18next's `{{count}}`, which would
additionally drive plural-key resolution these packs carry no forms for.
36 changes: 36 additions & 0 deletions packages/app-shell/src/layout/InboxPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ export function InboxPopover({
{totalBadge > 0 && (
<span
key={totalBadge}
data-testid="inbox-bell-badge"
className="absolute -top-0.5 -right-0.5 h-4 min-w-[16px] rounded-full bg-red-500 text-[10px] leading-4 text-white text-center px-1 motion-safe:animate-in motion-safe:zoom-in-50 motion-safe:fade-in-0 motion-safe:duration-200"
>
{totalBadge > 9 ? '9+' : totalBadge}
Expand All @@ -248,6 +249,41 @@ export function InboxPopover({
</button>
)}
</div>
{/* Badge breakdown (#7233). The bell badge is `unreadTopics +
pendingApprovalsCount` and clamps at "9+", so the number on its own
is unexplainable — and the two tab pills clamp at "9+" too, which
means a loaded inbox can show three "9+"s that reconcile to nothing.
Spell the addends out here, unclamped, so a user seeing "9+" can
read exactly which N notifications and which M pending approvals it
is made of (the M is the same count Home's approvals card and the
Approvals Inbox tab show — one source, `pendingApprovalsCount`). */}
{totalBadge > 0 && (
<div
data-testid="inbox-badge-breakdown"
className="flex flex-wrap items-center gap-x-2 gap-y-1 px-3 pb-2 text-xs text-muted-foreground"
>
<span data-testid="inbox-badge-breakdown-total" className="font-medium text-foreground">
{t('notifications.badgeTotal', {
defaultValue: '{{total}} total',
total: totalBadge,
})}
</span>
<span aria-hidden>·</span>
<span data-testid="inbox-badge-breakdown-notifications">
{t('notifications.badgeNotifications', {
defaultValue: '{{unread}} notifications',
unread: unreadTopics,
})}
</span>
<span aria-hidden>+</span>
<span data-testid="inbox-badge-breakdown-approvals">
{t('notifications.badgeApprovals', {
defaultValue: '{{approvals}} pending approvals',
approvals: pendingApprovalsCount,
})}
</span>
</div>
)}
<Tabs value={tab} onValueChange={(v) => setTab(v as typeof tab)} className="w-full">
<TabsList className="w-full justify-start rounded-none border-b bg-transparent px-1 h-9">
<TabsTrigger value="notifications" className="text-xs gap-1.5 data-[state=active]:bg-transparent">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* #7233 — the bell badge is `unread notification topics + pending approvals`
* and clamps at "9+", so on a loaded console it is one opaque number that a
* user cannot reconcile against Home's "pending approvals" card (which reads
* the same `pendingApprovalsCount`). The popover already *tabs* the two
* streams and puts a count pill on each tab, but those pills clamp at "9+"
* too — three "9+"s that add up to nothing.
*
* These pin the breakdown line: the exact, unclamped addends, next to the
* exact total, so "9+" is explainable as N notifications + M pending
* approvals.
*/
import '@testing-library/jest-dom/vitest';
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';

// Interpolating stub: the real packs carry `{{unread}}` / `{{approvals}}` /
// `{{total}}`, so a stub that returned `defaultValue` verbatim would make every
// numeric assertion below vacuous (it would assert on the literal "{{total}}").
vi.mock('@object-ui/i18n', async (importOriginal) => ({
// `formatRelativeTime` is reached through `utils/relativeTime` for every
// rendered row — keep the real module and override only the hook.
...(await importOriginal<Record<string, unknown>>()),
useObjectTranslation: () => ({
language: 'en',
t: (key: string, options?: Record<string, unknown>) =>
String(options?.defaultValue ?? key).replace(/\{\{(\w+)\}\}/g, (_m, name: string) =>
String(options?.[name] ?? ''),
),
}),
}));

vi.mock('react-router-dom', () => ({
useNavigate: () => vi.fn(),
useParams: () => ({ appName: 'setup' }),
}));

vi.mock('../../context/NavigationContext', () => ({
useNavigationContext: () => ({ currentAppName: 'setup' }),
}));

// Passthrough primitives so the popover body renders without driving Radix
// open/close in jsdom (the pattern WorkspaceSwitcher.test.tsx uses).
vi.mock('@object-ui/components', () => ({
Button: ({ children, ...p }: any) => <button type="button" {...p}>{children}</button>,
Popover: ({ children }: any) => <div>{children}</div>,
PopoverTrigger: ({ children }: any) => <div>{children}</div>,
PopoverContent: ({ children }: any) => <div>{children}</div>,
Tabs: ({ children }: any) => <div>{children}</div>,
TabsList: ({ children }: any) => <div>{children}</div>,
TabsTrigger: ({ children }: any) => <button type="button">{children}</button>,
TabsContent: ({ children }: any) => <div>{children}</div>,
}));

vi.mock('lucide-react', () => ({
Bell: () => <span />,
CheckSquare: () => <span />,
Activity: () => <span />,
ChevronRight: () => <span />,
}));

import { InboxPopover, type InboxNotification } from '../InboxPopover';

const notif = (over: Partial<InboxNotification> & { id: string }): InboxNotification => ({
type: 'project.digest',
title: 'Scheduled project digest',
is_read: false,
created_at: '2026-08-10T10:00:00Z',
...over,
});

/** N distinct unread topics (each its own `(topic, title)` pair). */
const distinctUnread = (n: number): InboxNotification[] =>
Array.from({ length: n }, (_, i) =>
notif({ id: `t${i}`, type: `topic.${i}`, title: `Topic ${i}` }),
);

function renderPopover(props: {
notifications: InboxNotification[];
pendingApprovalsCount: number;
unreadCount?: number;
}) {
return render(
<InboxPopover
notifications={props.notifications}
unreadCount={props.unreadCount ?? props.notifications.filter((n) => !n.is_read).length}
pendingApprovalsCount={props.pendingApprovalsCount}
activities={[]}
onMarkAllRead={vi.fn()}
onMarkRead={vi.fn()}
/>,
);
}

describe('InboxPopover — bell badge breakdown (#7233)', () => {
it('explains a clamped "9+" badge as exact notifications + pending approvals', () => {
renderPopover({ notifications: distinctUnread(12), pendingApprovalsCount: 3 });

// The badge itself still clamps — the formula is unchanged by this fix.
expect(screen.getByTestId('inbox-bell-badge')).toHaveTextContent('9+');

// …and the popover spells out what that "9+" is made of, unclamped.
expect(screen.getByTestId('inbox-badge-breakdown-total')).toHaveTextContent('15 total');
expect(screen.getByTestId('inbox-badge-breakdown-notifications')).toHaveTextContent(
'12 notifications',
);
expect(screen.getByTestId('inbox-badge-breakdown-approvals')).toHaveTextContent(
'3 pending approvals',
);
});

it('states the two addends and the total consistently (total === N + M)', () => {
renderPopover({ notifications: distinctUnread(4), pendingApprovalsCount: 2 });

const read = (id: string) => {
const text = screen.getByTestId(id).textContent ?? '';
const n = Number(text.match(/\d+/)?.[0]);
expect(Number.isNaN(n)).toBe(false);
return n;
};
const total = read('inbox-badge-breakdown-total');
const unread = read('inbox-badge-breakdown-notifications');
const approvals = read('inbox-badge-breakdown-approvals');

expect(unread + approvals).toBe(total);
expect(screen.getByTestId('inbox-bell-badge')).toHaveTextContent(String(total));
});

it('reports the approvals half straight from pendingApprovalsCount (the count Home shows)', () => {
// Home's approvals card and the Approvals Inbox tab read the same number;
// #7213 measured home saying 8 while the bell said "9+". The breakdown has
// to show that 8 verbatim, never the notification-inflated total.
renderPopover({ notifications: distinctUnread(2), pendingApprovalsCount: 8 });

expect(screen.getByTestId('inbox-badge-breakdown-approvals')).toHaveTextContent(
'8 pending approvals',
);
expect(screen.getByTestId('inbox-badge-breakdown-total')).toHaveTextContent('10 total');
});

it('counts coalesced repeats as one topic, matching the badge formula (#2765)', () => {
// 10 identical digests + 2 distinct topics = 3 unread topics, not 12.
const repeats = Array.from({ length: 10 }, (_, i) => notif({ id: `d${i}` }));
renderPopover({
notifications: [
...repeats,
notif({ id: 'a1', type: 'task.assigned', title: 'New task assigned' }),
notif({ id: 'm1', type: 'comment.mention', title: 'You were mentioned' }),
],
pendingApprovalsCount: 1,
});

expect(screen.getByTestId('inbox-badge-breakdown-notifications')).toHaveTextContent(
'3 notifications',
);
expect(screen.getByTestId('inbox-badge-breakdown-total')).toHaveTextContent('4 total');
});

it('renders no breakdown when there is no badge to explain', () => {
renderPopover({
notifications: [notif({ id: 'r1', is_read: true })],
pendingApprovalsCount: 0,
});

expect(screen.queryByTestId('inbox-bell-badge')).not.toBeInTheDocument();
expect(screen.queryByTestId('inbox-badge-breakdown')).not.toBeInTheDocument();
});
});
44 changes: 44 additions & 0 deletions packages/i18n/src/__tests__/inboxBadgeBreakdown-i18n-7233.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* `notifications.badge*` — the bell-badge breakdown strings (#7233).
*
* These three are interpolated with **named** placeholders (`{{total}}`,
* `{{unread}}`, `{{approvals}}`) rather than i18next's `{{count}}`, because
* `count` additionally drives plural-key resolution and these packs carry no
* plural forms. A pack that spells the placeholder differently does not fail
* loudly — i18next renders the literal `{{unread}}`, or an empty string — so
* the placeholder name is pinned here alongside the key's existence.
*
* All ten packs are asserted: `all-locales-key-parity` already owns the key
* SET, but not which placeholder a value spells, and a translated value is
* exactly where `{{unread}}` quietly becomes `{{count}}`.
*/
import { describe, it, expect } from 'vitest';
import { builtInLocales } from '../locales';

const readPath = (node: unknown, path: string): unknown =>
path
.split('.')
.reduce<unknown>(
(acc, seg) =>
acc && typeof acc === 'object' ? (acc as Record<string, unknown>)[seg] : undefined,
node,
);

const CASES = [
{ key: 'notifications.badgeTotal', placeholder: 'total' },
{ key: 'notifications.badgeNotifications', placeholder: 'unread' },
{ key: 'notifications.badgeApprovals', placeholder: 'approvals' },
] as const;

const LOCALES = Object.keys(builtInLocales) as (keyof typeof builtInLocales)[];

describe.each(LOCALES)('%s notifications.badge* (#7233)', (code) => {
it.each(CASES)('$key is a non-empty string carrying {{$placeholder}}', ({ key, placeholder }) => {
const value = readPath(builtInLocales[code], key);
expect(typeof value).toBe('string');
expect((value as string).trim().length).toBeGreaterThan(0);
expect(value as string).toContain(`{{${placeholder}}}`);
// `count` would send i18next looking for `<key>_one` / `<key>_other`.
expect(value as string).not.toContain('{{count}}');
});
});
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2758,6 +2758,9 @@ const ar = {
viewApprovals: "عرض الموافقات",
noPendingApprovals: "لا توجد موافقات معلقة",
openApprovalsInbox: "فتح صندوق الموافقات",
badgeTotal: "{{total}} إجمالاً",
badgeNotifications: "{{unread}} إشعارات",
badgeApprovals: "{{approvals}} موافقات معلقة",
emptyUnread: "كل شيء مقروء",
filterUnread: "غير مقروء",
filterAll: "الكل",
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2754,6 +2754,9 @@ const de = {
viewApprovals: "Genehmigungen anzeigen",
noPendingApprovals: "Keine ausstehenden Genehmigungen",
openApprovalsInbox: "Genehmigungs-Posteingang öffnen",
badgeTotal: "{{total}} insgesamt",
badgeNotifications: "{{unread}} Benachrichtigungen",
badgeApprovals: "{{approvals}} ausstehende Genehmigungen",
emptyUnread: "Alles gelesen",
filterUnread: "Ungelesen",
filterAll: "Alle",
Expand Down
6 changes: 6 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2966,6 +2966,12 @@ const en = {
viewApprovals: 'View approvals',
noPendingApprovals: 'No pending approvals',
openApprovalsInbox: 'Open Approvals Inbox',
// Bell-badge breakdown (#7233): the badge sums unread notification topics
// and pending approvals, then clamps at "9+". These three spell the sum
// out inside the popover so the number is explainable.
badgeTotal: '{{total}} total',
badgeNotifications: '{{unread}} notifications',
badgeApprovals: '{{approvals}} pending approvals',
},
publicForm: {
submit: 'Submit',
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2758,6 +2758,9 @@ const es = {
viewApprovals: "Ver aprobaciones",
noPendingApprovals: "Sin aprobaciones pendientes",
openApprovalsInbox: "Abrir bandeja de aprobaciones",
badgeTotal: "{{total}} en total",
badgeNotifications: "{{unread}} notificaciones",
badgeApprovals: "{{approvals}} aprobaciones pendientes",
emptyUnread: "Todo al día",
filterUnread: "No leídos",
filterAll: "Todos",
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2754,6 +2754,9 @@ const fr = {
viewApprovals: "Voir les approbations",
noPendingApprovals: "Aucune approbation en attente",
openApprovalsInbox: "Ouvrir la boîte d'approbations",
badgeTotal: "{{total}} au total",
badgeNotifications: "{{unread}} notifications",
badgeApprovals: "{{approvals}} approbations en attente",
emptyUnread: "Tout est lu",
filterUnread: "Non lus",
filterAll: "Tous",
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2754,6 +2754,9 @@ const ja = {
viewApprovals: "承認を表示",
noPendingApprovals: "承認待ちはありません",
openApprovalsInbox: "承認ボックスを開く",
badgeTotal: "合計 {{total}} 件",
badgeNotifications: "通知 {{unread}} 件",
badgeApprovals: "承認待ち {{approvals}} 件",
emptyUnread: "既読にしました",
filterUnread: "未読",
filterAll: "すべて",
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2753,6 +2753,9 @@ const ko = {
viewApprovals: "승인 보기",
noPendingApprovals: "대기 중인 승인 없음",
openApprovalsInbox: "승인 보관함 열기",
badgeTotal: "총 {{total}}건",
badgeNotifications: "알림 {{unread}}건",
badgeApprovals: "승인 대기 {{approvals}}건",
emptyUnread: "모두 읽음",
filterUnread: "읽지 않음",
filterAll: "전체",
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2753,6 +2753,9 @@ const pt = {
viewApprovals: "Ver aprovações",
noPendingApprovals: "Sem aprovações pendentes",
openApprovalsInbox: "Abrir caixa de aprovações",
badgeTotal: "{{total}} no total",
badgeNotifications: "{{unread}} notificações",
badgeApprovals: "{{approvals}} aprovações pendentes",
emptyUnread: "Tudo lido",
filterUnread: "Não lidos",
filterAll: "Todos",
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/src/locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2761,6 +2761,9 @@ const ru = {
viewApprovals: "Показать утверждения",
noPendingApprovals: "Нет ожидающих утверждений",
openApprovalsInbox: "Открыть входящие утверждений",
badgeTotal: "Всего: {{total}}",
badgeNotifications: "{{unread}} уведомлений",
badgeApprovals: "{{approvals}} ожидающих утверждений",
emptyUnread: "Всё прочитано",
filterUnread: "Непрочитанные",
filterAll: "Все",
Expand Down
Loading
Loading