feat(ext): org collection facet + collection filter in the vault sidebar

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3b8DA2mXmLWkd4zDbHXHg
This commit is contained in:
adlee-was-taken
2026-06-27 12:17:36 -04:00
parent dd0b415cdf
commit e370b06f85
6 changed files with 108 additions and 19 deletions

View File

@@ -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();

View File

@@ -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<void> {
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', {});
};

View File

@@ -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()

View File

@@ -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<typeof vi.fn>;
const mockSetState = setState as ReturnType<typeof vi.fn>;
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<HTMLButtonElement>('[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('');
});
});

View File

@@ -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;
}

View File

@@ -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 {
<input type="text" id="vault-search" placeholder="/ search…" />
</div>
<nav class="vault-sidebar__categories" id="vault-categories" aria-label="Item types"></nav>
<nav class="vault-sidebar__collections" id="vault-collections" aria-label="Collections"></nav>
<div class="vault-sidebar__nav">
<button class="vault-sidebar__nav-item vault-sidebar__nav-item--primary" data-nav="add" title="New item">+ new item</button>
<button class="vault-sidebar__nav-item" data-nav="trash" title="Trash">${GLYPH_TRASH} <span class="vault-sidebar__nav-label">trash</span></button>
@@ -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 =
`<nav class="vault-sidebar__collections" aria-label="Collections" data-count="${collections.length}"></nav>`;
/// `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 = `
<button class="vault-category-row ${isAllActive ? 'vault-category-row--active' : ''}" data-slug="">
<span class="vault-category-row__icon">⊞</span>
<span class="vault-category-row__label vault-sidebar__category-label">All collections</span>
</button>`;
for (const col of orgCollections) {
const isActive = collectionFilter === col.slug;
html += `
<button class="vault-category-row ${isActive ? 'vault-category-row--active' : ''}" data-slug="${escapeHtml(col.slug)}">
<span class="vault-category-row__icon">◈</span>
<span class="vault-category-row__label vault-sidebar__category-label">${escapeHtml(col.display_name)}</span>
</button>`;
}
host.innerHTML = html;
host.querySelectorAll<HTMLButtonElement>('.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);
}
});
});
}