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:
@@ -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();
|
||||
|
||||
|
||||
@@ -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', {});
|
||||
};
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user