diff --git a/extension/src/popup/components/__tests__/org-switcher.test.ts b/extension/src/popup/components/__tests__/org-switcher.test.ts index b64205d..d4fd1d1 100644 --- a/extension/src/popup/components/__tests__/org-switcher.test.ts +++ b/extension/src/popup/components/__tests__/org-switcher.test.ts @@ -40,8 +40,9 @@ describe('popup/org-switcher', () => { const sel = host.querySelector('select') as HTMLSelectElement; sel.value = 'org-1'; sel.dispatchEvent(new Event('change')); - // Two awaits: one for org_switch, one for the subsequent list load. - // navigate('list') fires after both resolve. + // Three awaits: org_switch, list load, then org_list_collections fetch. + // navigate('list') fires after all three resolve. + await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); diff --git a/extension/src/popup/components/org-switcher.ts b/extension/src/popup/components/org-switcher.ts index 6d71a21..a2e5a0c 100644 --- a/extension/src/popup/components/org-switcher.ts +++ b/extension/src/popup/components/org-switcher.ts @@ -6,7 +6,7 @@ // org renders a read-only banner; Dev-C's write UI reads `orgOffline` to // disable writes. -import type { OrgConfigSummary, OrgManifestEntry, ManifestEntry, ItemId } from '../../shared/types'; +import type { OrgConfigSummary, OrgManifestEntry, ManifestEntry, ItemId, Collection } from '../../shared/types'; import { sendMessage, setState, navigate, getState } from '../../shared/state'; import { messageForList, normalizeOrgEntries } from '../../shared/org-context'; @@ -73,6 +73,16 @@ export async function renderOrgSwitcher(host: HTMLElement): Promise { setState({ entries }); } + // Fetch (or clear) the collection list for the new context. Any existing + // collection filter is also cleared on personal switch so the next org + // switch starts with no stale filter. + if (data.context === 'personal') { + setState({ orgCollections: [], collectionFilter: undefined }); + } else { + const colResp = await sendMessage({ type: 'org_list_collections' }); + if (colResp.ok) setState({ orgCollections: colResp.data as Collection[] }); + } + navigate('list', {}); }; diff --git a/extension/src/shared/popup-state.ts b/extension/src/shared/popup-state.ts index 777d2bc..3b384db 100644 --- a/extension/src/shared/popup-state.ts +++ b/extension/src/shared/popup-state.ts @@ -57,6 +57,7 @@ export interface PopupState { orgOffline?: boolean; orgConfigs?: OrgConfigSummary[]; orgCollections?: Collection[]; + collectionFilter?: string; // Vault-tab-only fields. The popup surface leaves these at their defaults // (unlocked=false implicit via separate lock-screen view, drawer/panel false). // Kept on the shared shape so VaultState satisfies StateHost.getState() diff --git a/extension/src/vault/__tests__/collection-facet.test.ts b/extension/src/vault/__tests__/collection-facet.test.ts index 9702df8..2c0a235 100644 --- a/extension/src/vault/__tests__/collection-facet.test.ts +++ b/extension/src/vault/__tests__/collection-facet.test.ts @@ -15,9 +15,10 @@ vi.mock('../../shared/state', () => ({ })); import { renderCollectionFacet } from '../vault-sidebar'; -import { getState } from '../../shared/state'; +import { getState, setState } from '../../shared/state'; const mockGetState = getState as ReturnType; +const mockSetState = setState as ReturnType; describe('vault collection facet (org context)', () => { beforeEach(() => { @@ -38,4 +39,31 @@ describe('vault collection facet (org context)', () => { expect(el.textContent).toContain('Production Infra'); }); + + it('clicking a collection row calls setState with the collection filter', () => { + mockGetState.mockReturnValue({ + orgContext: 'org-1', + orgCollections: [{ slug: 'prod-infra', display_name: 'Production Infra' }], + collectionFilter: undefined, + }); + const el = document.getElementById('facet')!; + renderCollectionFacet(el); + + const btn = el.querySelector('[data-slug="prod-infra"]')!; + expect(btn).not.toBeNull(); + btn.click(); + + expect(mockSetState).toHaveBeenCalledWith({ collectionFilter: 'prod-infra' }); + }); + + it('renders nothing in personal context', () => { + mockGetState.mockReturnValue({ + orgContext: 'personal', + orgCollections: [], + }); + const el = document.getElementById('facet')!; + renderCollectionFacet(el); + + expect(el.innerHTML).toBe(''); + }); }); diff --git a/extension/src/vault/vault-context.ts b/extension/src/vault/vault-context.ts index 8d38d0a..b84004b 100644 --- a/extension/src/vault/vault-context.ts +++ b/extension/src/vault/vault-context.ts @@ -41,6 +41,7 @@ export interface VaultState { capturedTabId: number | null; capturedUrl: string; historyItemId: ItemId | null; + collectionFilter?: string; } // The controller passed to every vault-* module. vault.ts builds one instance @@ -117,6 +118,11 @@ export function getFilteredEntries( return false; }); } + if (state.collectionFilter) { + filtered = filtered.filter( + ([, e]) => (e as { collection?: string }).collection === state.collectionFilter, + ); + } filtered.sort((a, b) => a[1].title.localeCompare(b[1].title)); return filtered; } diff --git a/extension/src/vault/vault-sidebar.ts b/extension/src/vault/vault-sidebar.ts index 5158997..11e51d3 100644 --- a/extension/src/vault/vault-sidebar.ts +++ b/extension/src/vault/vault-sidebar.ts @@ -11,9 +11,9 @@ import { } from '../shared/glyphs'; import { renderStatusIndicator, type VaultStatus } from './vault-status'; import { - type VaultController, typeIcon, typeLabel, getFilteredEntries, + type VaultController, typeIcon, typeLabel, getFilteredEntries, escapeHtml, } from './vault-context'; -import { getState } from '../shared/state'; +import { getState, setState } from '../shared/state'; const SEARCH_DEBOUNCE_MS = 80; @@ -33,6 +33,7 @@ export function renderSidebarShell(): string { +
@@ -189,6 +190,14 @@ export function renderSidebarCategories(ctx: VaultController): void { ctx.closeDrawer(); }); }); + + // Keep the collection facet in sync — it must (re)render whenever + // orgCollections / orgContext / collectionFilter change, and + // renderSidebarCategories is already the re-render hook that runs after + // each of those state mutations (via the vault host's setState → renderPane + // → render → renderShell → ctx.renderSidebarCategories chain). + const collSlot = document.getElementById('vault-collections'); + if (collSlot) renderCollectionFacet(collSlot, ctx); } // --------------------------------------------------------------------------- @@ -197,18 +206,52 @@ export function renderSidebarCategories(ctx: VaultController): void { /// Render a collection nav (parallel to the type-category nav) into `host`, /// shown only in org context. Selecting a collection filters the org list to -/// that slug. +/// that slug. Hidden (host cleared) in personal context. /// -/// SCAFFOLD STATE (HOLD until Dev-A merges): renders the empty container only. -/// Populating from getState().orgCollections, the click-to-filter wiring, and -/// the org_list_collections fetch on org_switch are held until Dev-A's org -/// messages land. -export function renderCollectionFacet(host: HTMLElement): void { - // Inline shape until orgCollections joins PopupState at integration (it needs - // Dev-A's Collection type). HOLD(dev-a): render each as a vault-category-row - // mirroring renderSidebarCategories, wired to set a collection filter. - const collections = - (getState() as { orgCollections?: Array<{ slug: string; display_name: string }> }).orgCollections ?? []; - host.innerHTML = - ``; +/// `ctx` is provided when called from renderSidebarCategories (production); it +/// is absent in tests that mount the facet in isolation via happy-dom. +export function renderCollectionFacet(host: HTMLElement, ctx?: VaultController): void { + const s = getState(); + const orgContext = s.orgContext; + const orgCollections = s.orgCollections ?? []; + const collectionFilter = s.collectionFilter; + + if (!orgContext || orgContext === 'personal') { + host.innerHTML = ''; + return; + } + + const isAllActive = !collectionFilter; + let html = ` + `; + + for (const col of orgCollections) { + const isActive = collectionFilter === col.slug; + html += ` + `; + } + + host.innerHTML = html; + + host.querySelectorAll('.vault-category-row').forEach((btn) => { + btn.addEventListener('click', () => { + const slug = btn.dataset.slug || undefined; + setState({ collectionFilter: slug }); + if (ctx) { + // Re-render categories (updates type counts for the new filter) which + // in turn re-renders the facet to update the active highlight. + renderSidebarCategories(ctx); + ctx.renderListPane(); + } else { + // Test path (no ctx): just re-render the facet for the highlight update. + renderCollectionFacet(host, ctx); + } + }); + }); }