Skip to content

Commit 18620bc

Browse files
authored
feat(ux): URL-addressable sub-tabs (default-first) for Inventory + Admin (closes #902) (#905)
Resolve QA A.4: Inventory & Coverage remembered its last sub-tab via hidden in-memory session state while Admin always read its sub-tab from the /admin/<subtab> URL. The inconsistency was the defect. Make Inventory sub-tabs URL-addressable as /inventory/<subtab>, matching the existing Admin convention: - loadInventory() now derives the sub-section from the URL path (getInventorySubTabFromPath), not the module-level currentSubSection, so a fresh /inventory lands on the default (active-commitments) and a /inventory/<subtab> deep link lands on that sub-tab. - A sub-nav click routes through navigation.switchInventorySubTab, which pushes /inventory/<subtab> via history.pushState so the view is shareable/bookmarkable and browser back/forward works. - The history push lives in navigation.ts alongside switchSettingsSubTab so the single historyId counter stays authoritative for the back/forward dirty-guard; inventory.ts keeps the pure DOM view switch. Supersedes the partial PR #757 session-memory behavior with explicit URL state. Both pages now behave identically; existing /admin/* deep links are unaffected. Tests assert, for each page: no param -> default sub-tab, explicit param -> that sub-tab, switching updates the URL (query/hash preserved, no duplicate entry), and unknown param -> default.
1 parent 73b3ac9 commit 18620bc

4 files changed

Lines changed: 285 additions & 17 deletions

File tree

‎frontend/src/__tests__/inventory.test.ts‎

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,21 @@ jest.mock('../state', () => ({
3434
getCurrentAccountIDs: jest.fn(() => []),
3535
}));
3636

37+
// inventory.ts routes sub-nav clicks through navigation.switchInventorySubTab
38+
// so the click both switches the view AND pushes /inventory/<subtab>
39+
// (QA A.4). Mock it to delegate to the real (pure) view switcher: that
40+
// keeps the click->DOM behaviour these tests assert, while letting us spy
41+
// on the router call. The URL-push half of switchInventorySubTab is tested
42+
// in navigation.test.ts where the router owns history.
43+
jest.mock('../navigation', () => {
44+
const actual = jest.requireActual('../inventory');
45+
return {
46+
switchInventorySubTab: jest.fn((name: string) => actual.switchInventorySubSection(name)),
47+
};
48+
});
49+
3750
import { loadInventory, switchInventorySubSection, loadActiveCommitments, loadCoverageBreakdown } from '../inventory';
51+
import { switchInventorySubTab } from '../navigation';
3852
import { loadRIExchange } from '../riexchange';
3953
import * as api from '../api';
4054
import * as state from '../state';
@@ -202,13 +216,67 @@ describe('Inventory & Coverage sub-section switching', () => {
202216
expect(document.getElementById('inventory-active-commitments')?.classList.contains('hidden')).toBe(false);
203217
expect(document.getElementById('inventory-ri-exchange')?.classList.contains('hidden')).toBe(true);
204218

205-
// Clicking a sub-tab button switches the section.
219+
// Clicking a sub-tab button routes through the router (QA A.4) so the
220+
// click both switches the view AND pushes /inventory/<subtab>. The
221+
// mocked router delegates to the real view switcher, so the DOM flips.
206222
const coverageBtn = document.querySelector<HTMLButtonElement>('[data-inv-subtab="coverage"]')!;
207223
coverageBtn.click();
224+
expect(switchInventorySubTab).toHaveBeenCalledWith('coverage');
225+
expect(document.getElementById('inventory-coverage')?.classList.contains('hidden')).toBe(false);
226+
expect(document.getElementById('inventory-active-commitments')?.classList.contains('hidden')).toBe(true);
227+
});
228+
229+
});
230+
231+
// QA A.4: Inventory sub-tabs are URL-addressable (/inventory/<subtab>),
232+
// default-first, and shareable. inventory.ts owns the view switch + the
233+
// click->router wiring; the URL push itself lives in navigation.ts and is
234+
// covered in navigation.test.ts. Here we assert:
235+
// - the pure switcher returns the resolved (validated) sub-section,
236+
// - loadInventory honours an explicit sub-section, defaults when absent,
237+
// and falls back when unknown (default-first),
238+
// - a sub-nav click routes through navigation.switchInventorySubTab so
239+
// the URL gets updated (no hidden session state).
240+
describe('Inventory & Coverage sub-tab addressing (QA A.4)', () => {
241+
beforeEach(() => {
242+
buildInventoryDOM();
243+
(loadRIExchange as jest.Mock).mockClear();
244+
(switchInventorySubTab as jest.Mock).mockClear();
245+
(api.listActiveCommitments as jest.Mock).mockReset().mockResolvedValue([]);
246+
(api.getCoverageBreakdown as jest.Mock).mockReset().mockResolvedValue({ providers: [] });
247+
(state.subscribeProvider as jest.Mock).mockReset().mockReturnValue(jest.fn());
248+
(state.subscribeAccount as jest.Mock).mockReset().mockReturnValue(jest.fn());
249+
(state.getCurrentProvider as jest.Mock).mockReturnValue('');
250+
(state.getCurrentAccountIDs as jest.Mock).mockReturnValue([]);
251+
});
252+
253+
afterEach(() => {
254+
clearDOM();
255+
});
256+
257+
test('switchInventorySubSection returns the resolved sub-section', () => {
258+
expect(switchInventorySubSection('coverage')).toBe('coverage');
259+
// Unknown input resolves to the default (default-first).
260+
expect(switchInventorySubSection('bogus')).toBe('active-commitments');
261+
});
262+
263+
test('(a) loadInventory(undefined) -> default sub-section (active-commitments)', () => {
264+
loadInventory(undefined);
265+
expect(document.getElementById('inventory-active-commitments')?.classList.contains('hidden')).toBe(false);
266+
expect(api.listActiveCommitments).toHaveBeenCalled();
267+
});
268+
269+
test('(b) loadInventory(<subtab>) -> that sub-section', () => {
270+
loadInventory('coverage');
208271
expect(document.getElementById('inventory-coverage')?.classList.contains('hidden')).toBe(false);
209272
expect(document.getElementById('inventory-active-commitments')?.classList.contains('hidden')).toBe(true);
273+
expect(api.getCoverageBreakdown).toHaveBeenCalled();
210274
});
211275

276+
test('(d) loadInventory(<unknown>) -> falls back to default', () => {
277+
loadInventory('bogus-subtab');
278+
expect(document.getElementById('inventory-active-commitments')?.classList.contains('hidden')).toBe(false);
279+
});
212280
});
213281

214282
describe('loadActiveCommitments — fetch + render flow', () => {

‎frontend/src/__tests__/navigation.test.ts‎

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* Navigation module tests
33
*/
4-
import { switchTab, switchSettingsSubTab, getSettingsSubTabFromPath } from '../navigation';
4+
import { switchTab, switchSettingsSubTab, switchInventorySubTab, getSettingsSubTabFromPath, getInventorySubTabFromPath } from '../navigation';
55

66
// Mock the dependent modules
77
jest.mock('../dashboard', () => ({
@@ -33,6 +33,21 @@ jest.mock('../riexchange', () => ({
3333
jest.mock('../auth', () => ({
3434
isAdmin: jest.fn().mockReturnValue(true),
3535
}));
36+
// Mock inventory so navigation tests stay focused on routing/history and
37+
// don't pull in the real fetch/render machinery. switchInventorySubSection
38+
// must still resolve+return the sub-section (default-first) because
39+
// navigation.switchInventorySubTab uses the return value to build the URL.
40+
jest.mock('../inventory', () => {
41+
const VALID = ['active-commitments', 'coverage', 'ri-exchange'];
42+
const DEFAULT = 'active-commitments';
43+
const isValid = (n: string): boolean => VALID.includes(n);
44+
return {
45+
DEFAULT_INVENTORY_SUB_SECTION: DEFAULT,
46+
isValidInventorySubSection: isValid,
47+
switchInventorySubSection: jest.fn((n: string) => (isValid(n) ? n : DEFAULT)),
48+
loadInventory: jest.fn(),
49+
};
50+
});
3651

3752
import { loadDashboard } from '../dashboard';
3853
import { loadRecommendations } from '../recommendations';
@@ -164,6 +179,28 @@ describe('Navigation Module', () => {
164179
expect(homeBtn?.classList.contains('active')).toBe(false);
165180
});
166181

182+
// QA A.4: a bare inventory switch lands on the default sub-tab and the
183+
// canonical URL carries the sub-tab segment (/inventory/active-commitments),
184+
// mirroring how the admin switch pushes /admin/<subtab>.
185+
test('switching to inventory pushes /inventory/<default-subtab>', () => {
186+
// currentTab is module state that may already be 'inventory' from a
187+
// prior test; switch away first so the inventory switch is genuine
188+
// (a self-switch would correctly skip the push).
189+
switchTab('home');
190+
window.history.replaceState(null, '', '/');
191+
switchTab('inventory');
192+
expect(window.location.pathname).toBe('/inventory/active-commitments');
193+
});
194+
195+
// A deep link to a specific inventory sub-tab is honoured: switchTab
196+
// reads the path and the canonical URL keeps that sub-tab.
197+
test('switching to inventory honours a /inventory/<subtab> deep link', () => {
198+
switchTab('home');
199+
window.history.replaceState(null, '', '/inventory/coverage');
200+
switchTab('inventory');
201+
expect(window.location.pathname).toBe('/inventory/coverage');
202+
});
203+
167204
test('deactivates previously active tab', () => {
168205
// Dashboard is initially active
169206
const dashboardBtn = document.querySelector('[data-tab="home"]');
@@ -300,6 +337,50 @@ describe('Navigation Module', () => {
300337
});
301338
});
302339

340+
// QA A.4: switchInventorySubTab owns the /inventory/<subtab> history push,
341+
// mirroring switchSettingsSubTab. The DOM switch is delegated to the
342+
// (mocked) inventory module. Placed BEFORE the *FromPath describes, which
343+
// destructively replace window.location with a plain object and would
344+
// otherwise break the real history.pushState these tests rely on.
345+
describe('switchInventorySubTab', () => {
346+
beforeEach(() => {
347+
window.history.replaceState(null, '', '/inventory/active-commitments');
348+
});
349+
350+
test('(c) pushes /inventory/<subtab> on a real switch', () => {
351+
switchInventorySubTab('coverage');
352+
expect(window.location.pathname).toBe('/inventory/coverage');
353+
});
354+
355+
test('(c) preserves existing query params and hash', () => {
356+
window.history.replaceState(null, '', '/inventory/active-commitments?provider=aws#frag');
357+
switchInventorySubTab('ri-exchange');
358+
expect(window.location.pathname).toBe('/inventory/ri-exchange');
359+
expect(window.location.search).toBe('?provider=aws');
360+
expect(window.location.hash).toBe('#frag');
361+
});
362+
363+
test('(d) an unknown sub-tab resolves to the default in the URL', () => {
364+
window.history.replaceState(null, '', '/inventory/coverage');
365+
switchInventorySubTab('bogus');
366+
expect(window.location.pathname).toBe('/inventory/active-commitments');
367+
});
368+
369+
test('does NOT push a duplicate entry when already on the target sub-tab', () => {
370+
window.history.replaceState(null, '', '/inventory/coverage');
371+
const before = window.history.length;
372+
switchInventorySubTab('coverage');
373+
expect(window.location.pathname).toBe('/inventory/coverage');
374+
expect(window.history.length).toBe(before);
375+
});
376+
377+
test('push: false switches the view without touching history', () => {
378+
switchInventorySubTab('coverage', { push: false });
379+
// URL unchanged: the caller (initial load / popstate) owns the URL.
380+
expect(window.location.pathname).toBe('/inventory/active-commitments');
381+
});
382+
});
383+
303384
describe('getSettingsSubTabFromPath', () => {
304385
// Canonical /admin/* paths (issue #340 IA rename)
305386
test('returns general for root admin path', () => {
@@ -351,4 +432,32 @@ describe('Navigation Module', () => {
351432
expect(getSettingsSubTabFromPath()).toBe('general');
352433
});
353434
});
435+
436+
// QA A.4: Inventory sub-tabs become URL-addressable (/inventory/<subtab>),
437+
// matching the Admin /admin/<subtab> convention.
438+
describe('getInventorySubTabFromPath', () => {
439+
test('returns the default (active-commitments) for a bare /inventory path', () => {
440+
delete (window as unknown as Record<string, unknown>).location;
441+
(window as unknown as Record<string, unknown>).location = { pathname: '/inventory' } as Location;
442+
expect(getInventorySubTabFromPath()).toBe('active-commitments');
443+
});
444+
445+
test('returns coverage for /inventory/coverage', () => {
446+
delete (window as unknown as Record<string, unknown>).location;
447+
(window as unknown as Record<string, unknown>).location = { pathname: '/inventory/coverage' } as Location;
448+
expect(getInventorySubTabFromPath()).toBe('coverage');
449+
});
450+
451+
test('returns ri-exchange for /inventory/ri-exchange', () => {
452+
delete (window as unknown as Record<string, unknown>).location;
453+
(window as unknown as Record<string, unknown>).location = { pathname: '/inventory/ri-exchange' } as Location;
454+
expect(getInventorySubTabFromPath()).toBe('ri-exchange');
455+
});
456+
457+
test('falls back to the default for an unknown sub-tab', () => {
458+
delete (window as unknown as Record<string, unknown>).location;
459+
(window as unknown as Record<string, unknown>).location = { pathname: '/inventory/bogus' } as Location;
460+
expect(getInventorySubTabFromPath()).toBe('active-commitments');
461+
});
462+
});
354463
});

‎frontend/src/inventory.ts‎

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { loadRIExchange } from './riexchange';
1616
import { showSkeletonRows, teardownSkeleton } from './lib/skeleton';
1717
import { formatCurrency, formatDate } from './utils';
1818
import * as state from './state';
19+
import { switchInventorySubTab } from './navigation';
1920

2021
type InventorySubSection = 'active-commitments' | 'coverage' | 'ri-exchange';
2122

@@ -25,22 +26,38 @@ const SUB_SECTION_IDS: Record<InventorySubSection, string> = {
2526
'ri-exchange': 'inventory-ri-exchange',
2627
};
2728

28-
const DEFAULT_SUB_SECTION: InventorySubSection = 'active-commitments';
29+
export const DEFAULT_INVENTORY_SUB_SECTION: InventorySubSection = 'active-commitments';
2930

3031
let currentSubSection: InventorySubSection | undefined;
3132
let listenersWired = false;
3233

33-
function isValidSubSection(name: string): name is InventorySubSection {
34+
/**
35+
* Type guard for the Inventory sub-section identifiers. Exported so the
36+
* router (navigation.ts) can validate the `/inventory/<subtab>` path
37+
* segment without duplicating the closed set.
38+
*/
39+
export function isValidInventorySubSection(name: string): name is InventorySubSection {
3440
return name === 'active-commitments' || name === 'coverage' || name === 'ri-exchange';
3541
}
3642

3743
/**
3844
* Show one sub-section, hide the others. Activates the matching sub-nav
3945
* button and (for ri-exchange) triggers the RI exchange data load so the
4046
* existing flow stays identical to its pre-#340 behaviour.
47+
*
48+
* This is the pure view switcher: it does NOT touch the URL. URL history
49+
* (the `/inventory/<subtab>` addressing from QA A.4) is owned by
50+
* navigation.ts' switchInventorySubTab, mirroring how switchSettingsSubTab
51+
* owns the `/admin/<subtab>` history so a single counter (historyId) stays
52+
* authoritative for the back/forward dirty-guard.
53+
*
54+
* Returns the resolved (validated, default-substituted) sub-section so the
55+
* caller can reflect the same value in the URL.
4156
*/
42-
export function switchInventorySubSection(name: string): void {
43-
const target: InventorySubSection = isValidSubSection(name) ? name : DEFAULT_SUB_SECTION;
57+
export function switchInventorySubSection(name: string): InventorySubSection {
58+
const target: InventorySubSection = isValidInventorySubSection(name)
59+
? name
60+
: DEFAULT_INVENTORY_SUB_SECTION;
4461

4562
document.querySelectorAll<HTMLButtonElement>('#inventory-tab .sub-tab-btn').forEach((btn) => {
4663
const isActive = btn.dataset['invSubtab'] === target;
@@ -62,6 +79,7 @@ export function switchInventorySubSection(name: string): void {
6279
}
6380

6481
currentSubSection = target;
82+
return target;
6583
}
6684

6785
// ──────────────────────────────────────────────
@@ -417,8 +435,11 @@ function wireSubNavListeners(): void {
417435
if (buttons.length === 0) return;
418436
buttons.forEach((btn) => {
419437
btn.addEventListener('click', () => {
420-
const name = btn.dataset['invSubtab'] ?? DEFAULT_SUB_SECTION;
421-
switchInventorySubSection(name);
438+
const name = btn.dataset['invSubtab'] ?? DEFAULT_INVENTORY_SUB_SECTION;
439+
// Route through the router so the click both switches the view AND
440+
// pushes /inventory/<subtab> (QA A.4), keeping history consistent
441+
// with the Admin sub-tab flow.
442+
switchInventorySubTab(name);
422443
});
423444
});
424445
listenersWired = true;
@@ -485,11 +506,22 @@ function wireChipSubscriptions(): void {
485506

486507
/**
487508
* Initialize the Inventory & Coverage section. Called by navigation.ts'
488-
* switchTab when 'inventory' is selected. Defaults to active-commitments
489-
* if the user hasn't selected a sub-section this session.
509+
* switchTab when 'inventory' is selected, passing the sub-section parsed
510+
* from the `/inventory/<subtab>` URL path (QA A.4).
511+
*
512+
* The sub-section comes from the URL, not hidden session state: a fresh
513+
* `/inventory` with no sub-segment lands on the default (active-commitments)
514+
* and a `/inventory/<subtab>` deep link lands on that sub-section. The
515+
* switch is URL-driven (push: false) so re-entering the tab doesn't stack
516+
* a redundant history entry on top of the one switchTab already pushed.
490517
*/
491-
export function loadInventory(): void {
518+
export function loadInventory(subSection?: string): void {
492519
wireSubNavListeners();
493520
wireChipSubscriptions();
494-
switchInventorySubSection(currentSubSection ?? DEFAULT_SUB_SECTION);
521+
const target = subSection !== undefined && isValidInventorySubSection(subSection)
522+
? subSection
523+
: DEFAULT_INVENTORY_SUB_SECTION;
524+
// Pure view switch (no history push): switchTab already pushed the
525+
// canonical /inventory/<subtab> URL when this tab was entered.
526+
switchInventorySubSection(target);
495527
}

0 commit comments

Comments
 (0)