Skip to content

Commit 2c876f0

Browse files
fix(app-shell): the inbox opens on the first tab with items (objectui#11698) (#11743)
Fixes #11698 Clause-②: no ## What changed `@object-ui/app-shell` `InboxPopover`: the bell's popover no longer always opens on Notifications. The tab was a `useState('notifications')` that nothing moved, while the badge above it counts `unreadTopics + pendingApprovalsCount`. With 0 unread notifications and 3 pending approvals the bell read "3" and the click showed "You're all caught up". On every open the popover now picks its tab, in this order: 1. the tab the user last picked in this browser-tab session, if that tab has something in it; 2. otherwise the first tab that has something in it: Notifications, then Approvals, then Activity; 3. otherwise the user's last pick; 4. otherwise Notifications. "Has something in it" means what each tab shows by default: an unread notification topic for Notifications (the Unread filter is the default, and unread topics are the badge's first addend), a pending approval for Approvals (the badge's second addend), and an activity row for Activity. The pick happens in the popover's open handler (`handleOpenChange`), never while it is open, so a count that changes under an open popover does not move the tab. Only a tab the user selects (`handleTabChange`) is remembered. The tab the popover picks for them on open is not remembered. No new export, no new locale key, no new prop. `InboxPopover` is not exported from the package. The other caller, the `global:notifications` page block, gets the same behaviour. ## "Keeps the user's choice within a session": the definition pinned here Once the user selects a tab, every later open of an inbox bell in the same browser tab reopens on it, across navigation and reload. A new browser tab starts from the opening pick again. The pick is held in `sessionStorage` (key `inbox-popover-tab`). Nothing goes to localStorage or to the server. Why not component state: the header bell is a different React instance on Home, Organizations, an organization's pages, the AI page and inside an app. `AppHeader` is mounted separately by `HomeLayout`, `OrganizationsLayout`, `OrganizationLayout`, `AiChatPage` and `ConsoleLayout`, so component state would forget the pick the first time the user moved between Home and an app. `sessionStorage` is the scope the ChatDock's expanded flag already uses for the same kind of per-tab UI posture (`chatDockState.ts`). Every storage touch is guarded, so private mode degrades to "nothing picked". This survives a reload, which goes further than the dispatch's example ("until reload"). The reason is above. If the seat prefers page lifetime, the change is to hold the pick in a module variable instead. ## Two acceptance texts, one edge where they disagree The triage grade says the popover "keeps the user's choice within a session". The card's Expected says "'You're all caught up' is never the first thing shown under a non-zero badge". They disagree in one case: the user picked Notifications, later reads the notification elsewhere, and 3 approvals are still pending. Keeping the pick would show "You're all caught up" under a "3". Step 1 above settles it: a pick is kept only while its tab has something in it. Otherwise the first tab with items wins. If every tab is empty, the pick is kept, since the badge is then 0. This keeps both texts true everywhere they can both be true. It is pinned by the case named "a picked tab that is now empty yields to the first tab with items". ## The dispatch's mechanism hypotheses, measured - **H1 (constant initial tab; badge = unread topics + approvals; `Tabs value` is `tab`).** Confirmed on `origin/main` `6be0f7a`. - **H2 (the counts may load after the popover opens).** Falsified. The popover fetches nothing. Its counts are props from `useInboxBell`. That hook reads the shared polled feeds (`useSharedInboxFeed`, `useSharedPendingApprovalsCount`), which start when the header mounts and do not depend on the popover being open. The badge renders from those same props, so the pick at open time reads the numbers the bell showed when it was clicked. If the user opens before the first poll lands, the badge is 0 too, and the popover opens on Notifications or Activity. When counts arrive while the popover is open, the tab does not move. That is pinned. - **H3 ("within a session" is ambiguous).** Defined above and pinned. ## Tests New suite `packages/app-shell/src/layout/__tests__/InboxPopover.openingTab-11698.test.tsx`, 11 cases. It mocks nothing: the real Radix popover and tabs, the real router and the real English i18n pack. The selected tab is read from Radix's `aria-selected`. The cases: - the card's pin: 0 notifications and 3 approvals, so the badge reads 3 and the popover opens on Approvals, with no "You're all caught up" anywhere in the popover; - 2 notifications and 3 approvals open on Notifications; - activity only opens on Activity; - notifications that are all read are not items; - all empty, with nothing picked, opens on Notifications; - the popover's own pick is not remembered; - the tab does not move while the popover is open; - a pick is kept on reopen; - a pick survives a remounted bell; - an empty pick yields to the first tab with items; - with every tab empty, the pick is kept. `InboxPopover.displayLocale-10668.test.tsx` changed in its test helper only. It read "the Notifications panel" as whatever the bell opened on. Its first case selects Activity, and that pick now persists in `sessionStorage` into the en-US control, so the control read the Activity panel twice: 1 of 2 timestamps, red. The helper now selects Notifications explicitly before reading it. Its assertions are unchanged. **Reverse verification**, on committed `770c375`. A trap-guarded script wrote the base version of `InboxPopover.tsx` over the fix. Landing was proven on disk: `onOpenChange={setOpen}` was found once, and `openingTab(` and `handleOpenChange` zero times. The new suite then went red with 7 failed and 4 passed, the same split as the pre-fix run, and the card's pin failed with `expected 'Notifications' to match /approvals/i`. The file was restored with `git checkout HEAD`, and the restore was proven by its blob hash equalling the HEAD blob and an empty `git diff HEAD`. The 4 cases that pass on the base version are the ones the old constant default already satisfied: 2 and 3 open on Notifications, all empty with nothing picked opens on Notifications, a reopen within one mount keeps a pick, and an all-empty pick is kept. ## Gates (all on `770c375`, run from the worktree root) | Command | Exit | The gate's own verdict line | |---|---|---| | `pnpm exec vitest run --maxWorkers=2` over the narrowed set below (via `os-verify-lock`) | 0 | `Test Files 56 passed (56)` · `Tests 414 passed (414)` | | `pnpm exec turbo run build --filter='@object-ui/app-shell^...' --concurrency=2` (the dependency closure the type-check reads) | 0 | `Tasks: 28 successful, 28 total` | | `pnpm --filter @object-ui/app-shell type-check` (the script name `type-check` is echoed; it chains `tsc --noEmit` and `tsc -p tsconfig.test.json`, and the test project's file list includes both touched suites) | 0 | zero `error TS` lines | | `pnpm --filter @object-ui/console exec vite build` | 0 | the bundle is written to `apps/console/dist/index.html` | | `pnpm exec eslint --format json` over the 3 touched `.ts`/`.tsx` files | 0 | 3 files, 0 errors, 0 warnings | | `pnpm check:control-bytes` | 0 | `check-control-bytes: OK (scanned 7691 tracked text file(s); skipped 85 binary).` | | `pnpm check:test-path-roots` | 0 | `check-test-path-roots: OK` | | `pnpm check:changeset-claims` | 0 | `No pending changeset names a file this change touches.` | | `pnpm check:pending-changeset-literals` | 0 | `No test source names a pending changeset.` | | `pnpm check:vi-mock-specifiers` | 0 | `check-vi-mock-specifiers: OK` | | `pnpm check:new-line-citations` | 0 | `VERDICT new-cross-file-line-citations: 0 new citation(s)` | | `node scripts/check-changeset-presence.mjs` | 0 | `3 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)` | Narrowed, and declared as narrowed. The whole app-shell package would hold the shared verify lock for about 30 minutes, so the vitest run covers every test directory and file that mounts the popover. That is every file under `packages/app-shell/src/layout/`, which holds all the `InboxPopover*`, `AppHeader*` and `ConsoleLayout*` suites, plus `global-page-blocks.render` (the `global:notifications` block), `sharedInboxFeed.twoSurfaces`, `HomePage.approvalsTarget`, `KeyboardShortcutsDialog.wiredOnly-11674` (it mounts `ConsoleLayout`) and the console's `approvalsInboxComponentRef`. The set was chosen with `git grep` for `InboxPopover`, `AppHeader` and `global:notifications` over test files. Like any grep, it cannot see a test that mounts the header through a path that names none of them. The full farm belongs to CI. ESLint was narrowed and proven. The population comes from `eslint.config.js` itself: the touched `.ts`/`.tsx` files fall under its `**/*.{ts,tsx}` blocks. The file count is 3, read from `--format json`, with 0 errors and 0 warnings. The invariance holds because `--print-config` shows empty `parserOptions` (no `project` or `projectService`). Type-aware linting is off, so this diff cannot move a verdict on any untouched file. The console compiles `InboxPopover.tsx` through its `@object-ui/app-shell` alias to `src/`. That compile was measured with the same command CI's E2E job uses, `pnpm --filter @object-ui/console exec vite build`. It exited 0 and wrote `apps/console/dist/index.html`. The diff adds no import, so the bundle graph the console's build checks read is unchanged. ## Acceptance notes - `@object-ui/app-shell` patch changeset `.changeset/11698-inbox-opens-on-items.md`. `node scripts/check-changeset-presence.mjs`: 3 published source files of 1 released package, 1 changeset. Not governed: `check-governed-queue-guard --test` over the 4 paths printed NOT GOVERNED. - No out-of-scope findings filed. Session: `https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8` --- _Generated by [Claude Code](https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d39ac2e commit 2c876f0

4 files changed

Lines changed: 324 additions & 7 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
The inbox bell opens on the tab that has something in it (objectui#11698).
6+
7+
The popover always opened on Notifications. With no unread notifications and three pending approvals, the bell read "3" and the click showed "You're all caught up", with the three approvals behind the next tab.
8+
9+
On each open the popover now picks the first tab with items, in the order Notifications (an unread notification), Approvals (a pending approval), Activity (an activity row). When every tab is empty it opens on the tab the user last selected, or on Notifications if none was selected. A tab the user selects is remembered for the browser tab's session and reopens first, unless it is empty while another tab has items. The tab does not change while the popover is open.

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

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,61 @@ import type { InboxNotification, NotificationGroup } from './inboxGrouping.js';
4646
// through this module (the pure logic itself lives in inboxGrouping.ts).
4747
export type { InboxNotification, NotificationGroup } from './inboxGrouping.js';
4848

49+
type InboxTab = 'notifications' | 'approvals' | 'activity';
50+
51+
/** Tab order, which is also the order the opening pick walks. */
52+
const INBOX_TABS: readonly InboxTab[] = ['notifications', 'approvals', 'activity'];
53+
54+
const isInboxTab = (value: unknown): value is InboxTab =>
55+
typeof value === 'string' && (INBOX_TABS as readonly string[]).includes(value);
56+
57+
/**
58+
* sessionStorage key holding the tab the user last PICKED (objectui#11698).
59+
*
60+
* sessionStorage, not component state: the header bell is a different
61+
* instance on Home, Organizations, the AI page and inside an app (each layout
62+
* mounts its own `AppHeader`), so component state would forget the pick at the
63+
* first move between them. Not localStorage either: a fresh browser tab starts
64+
* from the opening pick again — the same per-tab scope the ChatDock's expanded
65+
* flag uses (`chatDockState.ts`). Every touch is guarded so private mode and a
66+
* DOM-less runner degrade to "nothing picked".
67+
*/
68+
const INBOX_TAB_STORAGE_KEY = 'inbox-popover-tab';
69+
70+
function recallPickedTab(): InboxTab | null {
71+
try {
72+
const raw = window.sessionStorage.getItem(INBOX_TAB_STORAGE_KEY);
73+
return isInboxTab(raw) ? raw : null;
74+
} catch {
75+
return null;
76+
}
77+
}
78+
79+
function rememberPickedTab(tab: InboxTab): void {
80+
try {
81+
window.sessionStorage.setItem(INBOX_TAB_STORAGE_KEY, tab);
82+
} catch {
83+
/* storage unavailable: the pick lasts until the popover closes */
84+
}
85+
}
86+
87+
/**
88+
* The tab the popover opens on (objectui#11698). It was a fixed
89+
* `'notifications'`, so a bell reading "3" for three pending approvals opened
90+
* on "You're all caught up" with the three items one tab to the right.
91+
*
92+
* In order: the user's last pick if that tab has something in it; else the
93+
* first tab that does (Notifications, Approvals, Activity); else the user's
94+
* last pick; else Notifications. "Has something in it" is what each tab shows
95+
* by default — an unread topic (the Unread filter is the default, and it is the
96+
* badge's first addend), a pending approval (the second addend), an activity
97+
* row — so a non-zero badge never opens on an empty tab, a pick included.
98+
*/
99+
function openingTab(hasItems: Record<InboxTab, boolean>, picked: InboxTab | null): InboxTab {
100+
if (picked && hasItems[picked]) return picked;
101+
return INBOX_TABS.find((t) => hasItems[t]) ?? picked ?? 'notifications';
102+
}
103+
49104
export interface InboxPopoverProps {
50105
notifications: InboxNotification[];
51106
unreadCount: number;
@@ -79,7 +134,9 @@ export function InboxPopover({
79134
const { currentAppName } = useNavigationContext();
80135
const { apps } = useMetadata();
81136
const [open, setOpen] = useState(false);
82-
const [tab, setTab] = useState<'notifications' | 'approvals' | 'activity'>('notifications');
137+
// Chosen on every open by `handleOpenChange` below, never while open, so a
138+
// count that changes under an open popover does not move the tab.
139+
const [tab, setTab] = useState<InboxTab>('notifications');
83140
// Sub-filter inside Notifications: default to Unread so users see what
84141
// actually needs their attention first. The popover caps at 20 rows from
85142
// the server (`?view=mine` already scopes to current user), so we filter
@@ -120,6 +177,32 @@ export function InboxPopover({
120177
if (!pulse) prevTotalRef.current = totalBadge;
121178
}, [pulse, totalBadge]);
122179

180+
// The counts are props the shared feeds already polled for the badge (the
181+
// popover fetches nothing on open), so the pick reads the same numbers the
182+
// bell showed when it was clicked.
183+
const handleOpenChange = (next: boolean) => {
184+
if (next) {
185+
setTab(
186+
openingTab(
187+
{
188+
notifications: unreadTopics > 0,
189+
approvals: pendingApprovalsCount > 0,
190+
activity: activities.length > 0,
191+
},
192+
recallPickedTab(),
193+
),
194+
);
195+
}
196+
setOpen(next);
197+
};
198+
199+
// Only a tab the user selects is remembered; the opening pick is not.
200+
const handleTabChange = (value: string) => {
201+
if (!isInboxTab(value)) return;
202+
setTab(value);
203+
rememberPickedTab(value);
204+
};
205+
123206
const goToApprovals = () => {
124207
setOpen(false);
125208
const app = currentAppName ?? params.appName;
@@ -241,7 +324,7 @@ export function InboxPopover({
241324
);
242325

243326
return (
244-
<Popover open={open} onOpenChange={setOpen}>
327+
<Popover open={open} onOpenChange={handleOpenChange}>
245328
<PopoverTrigger asChild>
246329
<Button
247330
variant="ghost"
@@ -312,7 +395,7 @@ export function InboxPopover({
312395
</span>
313396
</div>
314397
)}
315-
<Tabs value={tab} onValueChange={(v) => setTab(v as typeof tab)} className="w-full">
398+
<Tabs value={tab} onValueChange={handleTabChange} className="w-full">
316399
<TabsList className="w-full justify-start rounded-none border-b bg-transparent px-1 h-9">
317400
<TabsTrigger value="notifications" className="text-xs gap-1.5 data-[state=active]:bg-transparent">
318401
<Bell className="h-3.5 w-3.5" />

‎packages/app-shell/src/layout/__tests__/InboxPopover.displayLocale-10668.test.tsx‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -83,11 +83,17 @@ const count = (haystack: string, needle: string) => haystack.split(needle).lengt
8383
function openAndRead(): { notificationsTab: string; activityTab: string } {
8484
const [bell] = screen.getAllByRole('button');
8585
fireEvent.click(bell);
86+
// Radix tabs activate on a primary-button mouse down. Both tabs are selected
87+
// explicitly: the tab the bell opens on follows the counts and the user's
88+
// last pick in this browser-tab session (objectui#11698), and an earlier
89+
// case's pick of Activity is still in sessionStorage.
90+
const select = (name: RegExp) =>
91+
act(() => {
92+
fireEvent.mouseDown(screen.getByRole('tab', { name }), { button: 0, ctrlKey: false });
93+
});
94+
select(/notifications/i);
8695
const notificationsTab = screen.getByRole('dialog').textContent ?? '';
87-
// Radix tabs activate on a primary-button mouse down.
88-
act(() => {
89-
fireEvent.mouseDown(screen.getByRole('tab', { name: /activity/i }), { button: 0, ctrlKey: false });
90-
});
96+
select(/activity/i);
9197
const activityTab = screen.getByRole('dialog').textContent ?? '';
9298
return { notificationsTab, activityTab };
9399
}
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The inbox opens on the tab that has something in it (objectui#11698).
5+
*
6+
* The popover's tab was a `useState('notifications')` that nothing ever moved,
7+
* while the bell badge it sits under is `unreadTopics + pendingApprovalsCount`.
8+
* So with 0 unread notifications and 3 pending approvals the bell read "3" and
9+
* the first thing the click showed was "You're all caught up" — the three
10+
* items were one tab to the right.
11+
*
12+
* On open the popover now picks, in order: the tab the user last chose in this
13+
* browser-tab session if that tab has something in it, otherwise the first tab
14+
* that does (Notifications, then Approvals, then Activity), otherwise the
15+
* user's last choice, otherwise Notifications. "Has something in it" is what
16+
* each tab shows by default: an unread notification topic (the Unread filter
17+
* is the default, and it is the badge's first addend), a pending approval (the
18+
* badge's second addend), an activity row. Only a tab the user picks counts as
19+
* a choice; the tab the popover picked for them on open does not.
20+
*
21+
* Nothing is mocked: the real Radix popover and tabs, the real router and the
22+
* real i18n provider, so the selected tab is the one Radix reports as
23+
* `aria-selected`, and the panel text is the English pack's.
24+
*/
25+
26+
import React from 'react';
27+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
28+
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react';
29+
import { MemoryRouter } from 'react-router-dom';
30+
import { I18nProvider } from '@object-ui/i18n';
31+
32+
import { InboxPopover, type InboxNotification } from '../InboxPopover';
33+
import type { ActivityItem } from '../ActivityFeed';
34+
35+
beforeEach(() => window.sessionStorage.clear());
36+
afterEach(() => {
37+
cleanup();
38+
window.sessionStorage.clear();
39+
});
40+
41+
const CAUGHT_UP = "You're all caught up";
42+
43+
const unread = (n: number): InboxNotification[] =>
44+
Array.from({ length: n }, (_, i) => ({
45+
id: `n${i}`,
46+
type: `topic.${i}`,
47+
title: `Topic ${i}`,
48+
is_read: false,
49+
created_at: '2026-10-01T10:00:00Z',
50+
}));
51+
52+
const activity = (n: number): ActivityItem[] =>
53+
Array.from({ length: n }, (_, i) => ({
54+
id: `a${i}`,
55+
type: 'update',
56+
objectName: 'account',
57+
user: 'Priya',
58+
description: `updated record ${i}`,
59+
timestamp: '2026-10-01T10:00:00Z',
60+
}));
61+
62+
interface Inbox {
63+
notifications: InboxNotification[];
64+
pendingApprovalsCount: number;
65+
activities: ActivityItem[];
66+
}
67+
68+
function bell(inbox: Inbox) {
69+
return (
70+
<MemoryRouter>
71+
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }} persistLanguage={false}>
72+
<InboxPopover
73+
notifications={inbox.notifications}
74+
unreadCount={inbox.notifications.filter((n) => !n.is_read).length}
75+
pendingApprovalsCount={inbox.pendingApprovalsCount}
76+
activities={inbox.activities}
77+
onMarkAllRead={() => {}}
78+
onMarkRead={() => {}}
79+
/>
80+
</I18nProvider>
81+
</MemoryRouter>
82+
);
83+
}
84+
85+
const trigger = () => screen.getByRole('button', { name: 'Open inbox' });
86+
87+
/** Click the bell open and report the selected tab and the visible panel text. */
88+
function open(): { selected: string; panel: string } {
89+
fireEvent.click(trigger());
90+
const dialog = screen.getByRole('dialog');
91+
const selected = within(dialog).getByRole('tab', { selected: true }).textContent ?? '';
92+
const panel = within(dialog).getByRole('tabpanel').textContent ?? '';
93+
return { selected, panel };
94+
}
95+
96+
function close() {
97+
fireEvent.click(trigger());
98+
expect(screen.queryByRole('dialog')).toBeNull();
99+
}
100+
101+
/** Pick a tab the way a pointer does (Radix tabs activate on a primary mouse down). */
102+
function choose(name: RegExp) {
103+
act(() => {
104+
fireEvent.mouseDown(within(screen.getByRole('dialog')).getByRole('tab', { name }), {
105+
button: 0,
106+
ctrlKey: false,
107+
});
108+
});
109+
expect(within(screen.getByRole('dialog')).getByRole('tab', { selected: true }).textContent).toMatch(name);
110+
}
111+
112+
describe('InboxPopover — opens on the first tab with items (objectui#11698)', () => {
113+
/** THE PIN from the card: 0 notifications, 3 pending approvals. */
114+
it('0 notifications and 3 pending approvals: the badge reads 3 and opening shows Approvals', () => {
115+
render(bell({ notifications: [], pendingApprovalsCount: 3, activities: activity(2) }));
116+
expect(screen.getByTestId('inbox-bell-badge')).toHaveTextContent('3');
117+
118+
const { selected, panel } = open();
119+
expect(selected).toMatch(/approvals/i);
120+
expect(panel).toContain('3 pending approvals');
121+
expect(panel).toContain('View approvals');
122+
expect(screen.getByRole('dialog').textContent).not.toContain(CAUGHT_UP);
123+
});
124+
125+
it('2 notifications and 3 pending approvals: Notifications comes first', () => {
126+
render(bell({ notifications: unread(2), pendingApprovalsCount: 3, activities: activity(2) }));
127+
const { selected, panel } = open();
128+
expect(selected).toMatch(/notifications/i);
129+
expect(panel).toContain('Topic 0');
130+
});
131+
132+
it('only activity: opening shows Activity, not an empty Notifications tab', () => {
133+
render(bell({ notifications: [], pendingApprovalsCount: 0, activities: activity(2) }));
134+
const { selected, panel } = open();
135+
expect(selected).toMatch(/activity/i);
136+
expect(panel).toContain('updated record 0');
137+
});
138+
139+
it('read notifications are not items: the Unread filter would show them as caught up', () => {
140+
const allRead = unread(2).map((n) => ({ ...n, is_read: true }));
141+
render(bell({ notifications: allRead, pendingApprovalsCount: 3, activities: [] }));
142+
expect(open().selected).toMatch(/approvals/i);
143+
});
144+
145+
it('everything empty: falls back to Notifications', () => {
146+
render(bell({ notifications: [], pendingApprovalsCount: 0, activities: [] }));
147+
const { selected, panel } = open();
148+
expect(selected).toMatch(/notifications/i);
149+
expect(panel).toContain(CAUGHT_UP);
150+
});
151+
152+
it('re-picks on every open while the user has chosen nothing (the popover pick is not a choice)', () => {
153+
const { rerender } = render(bell({ notifications: [], pendingApprovalsCount: 3, activities: [] }));
154+
expect(open().selected).toMatch(/approvals/i);
155+
close();
156+
157+
// A notification arrives while the popover is closed.
158+
rerender(bell({ notifications: unread(1), pendingApprovalsCount: 3, activities: [] }));
159+
expect(open().selected).toMatch(/notifications/i);
160+
});
161+
162+
it('does not move the tab under the user while the popover stays open', () => {
163+
const { rerender } = render(bell({ notifications: [], pendingApprovalsCount: 3, activities: [] }));
164+
expect(open().selected).toMatch(/approvals/i);
165+
166+
rerender(bell({ notifications: unread(1), pendingApprovalsCount: 3, activities: [] }));
167+
expect(within(screen.getByRole('dialog')).getByRole('tab', { selected: true }).textContent).toMatch(
168+
/approvals/i,
169+
);
170+
});
171+
});
172+
173+
describe("InboxPopover — keeps the user's tab within the session (objectui#11698)", () => {
174+
it('reopening keeps the tab the user picked, over the first tab with items', () => {
175+
render(bell({ notifications: unread(2), pendingApprovalsCount: 3, activities: activity(2) }));
176+
expect(open().selected).toMatch(/notifications/i);
177+
choose(/activity/i);
178+
close();
179+
180+
const { selected, panel } = open();
181+
expect(selected).toMatch(/activity/i);
182+
expect(panel).toContain('updated record 0');
183+
});
184+
185+
it('a remounted bell in the same browser-tab session keeps the pick (Home and an app mount different bells)', () => {
186+
render(bell({ notifications: unread(2), pendingApprovalsCount: 3, activities: activity(2) }));
187+
open();
188+
choose(/approvals/i);
189+
cleanup();
190+
191+
render(bell({ notifications: unread(2), pendingApprovalsCount: 3, activities: activity(2) }));
192+
expect(open().selected).toMatch(/approvals/i);
193+
});
194+
195+
it('a picked tab that is now empty yields to the first tab with items: never "caught up" under a non-zero badge', () => {
196+
const { rerender } = render(bell({ notifications: unread(1), pendingApprovalsCount: 3, activities: [] }));
197+
open();
198+
choose(/approvals/i);
199+
choose(/notifications/i);
200+
close();
201+
202+
// The user read the notification elsewhere; the approvals are still pending.
203+
rerender(bell({ notifications: [], pendingApprovalsCount: 3, activities: [] }));
204+
expect(screen.getByTestId('inbox-bell-badge')).toHaveTextContent('3');
205+
const { selected } = open();
206+
expect(selected).toMatch(/approvals/i);
207+
expect(screen.getByRole('dialog').textContent).not.toContain(CAUGHT_UP);
208+
});
209+
210+
it('with every tab empty, the picked tab is kept', () => {
211+
const { rerender } = render(bell({ notifications: [], pendingApprovalsCount: 0, activities: activity(1) }));
212+
open();
213+
choose(/approvals/i);
214+
close();
215+
216+
rerender(bell({ notifications: [], pendingApprovalsCount: 0, activities: [] }));
217+
expect(open().selected).toMatch(/approvals/i);
218+
});
219+
});

0 commit comments

Comments
 (0)