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;
|
const sel = host.querySelector('select') as HTMLSelectElement;
|
||||||
sel.value = 'org-1';
|
sel.value = 'org-1';
|
||||||
sel.dispatchEvent(new Event('change'));
|
sel.dispatchEvent(new Event('change'));
|
||||||
// Two awaits: one for org_switch, one for the subsequent list load.
|
// Three awaits: org_switch, list load, then org_list_collections fetch.
|
||||||
// navigate('list') fires after both resolve.
|
// navigate('list') fires after all three resolve.
|
||||||
|
await Promise.resolve();
|
||||||
await Promise.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
|
// org renders a read-only banner; Dev-C's write UI reads `orgOffline` to
|
||||||
// disable writes.
|
// 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 { sendMessage, setState, navigate, getState } from '../../shared/state';
|
||||||
import { messageForList, normalizeOrgEntries } from '../../shared/org-context';
|
import { messageForList, normalizeOrgEntries } from '../../shared/org-context';
|
||||||
|
|
||||||
@@ -73,6 +73,16 @@ export async function renderOrgSwitcher(host: HTMLElement): Promise<void> {
|
|||||||
setState({ entries });
|
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', {});
|
navigate('list', {});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ export interface PopupState {
|
|||||||
orgOffline?: boolean;
|
orgOffline?: boolean;
|
||||||
orgConfigs?: OrgConfigSummary[];
|
orgConfigs?: OrgConfigSummary[];
|
||||||
orgCollections?: Collection[];
|
orgCollections?: Collection[];
|
||||||
|
collectionFilter?: string;
|
||||||
// Vault-tab-only fields. The popup surface leaves these at their defaults
|
// Vault-tab-only fields. The popup surface leaves these at their defaults
|
||||||
// (unlocked=false implicit via separate lock-screen view, drawer/panel false).
|
// (unlocked=false implicit via separate lock-screen view, drawer/panel false).
|
||||||
// Kept on the shared shape so VaultState satisfies StateHost.getState()
|
// Kept on the shared shape so VaultState satisfies StateHost.getState()
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ vi.mock('../../shared/state', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { renderCollectionFacet } from '../vault-sidebar';
|
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 mockGetState = getState as ReturnType<typeof vi.fn>;
|
||||||
|
const mockSetState = setState as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
describe('vault collection facet (org context)', () => {
|
describe('vault collection facet (org context)', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -38,4 +39,31 @@ describe('vault collection facet (org context)', () => {
|
|||||||
|
|
||||||
expect(el.textContent).toContain('Production Infra');
|
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;
|
capturedTabId: number | null;
|
||||||
capturedUrl: string;
|
capturedUrl: string;
|
||||||
historyItemId: ItemId | null;
|
historyItemId: ItemId | null;
|
||||||
|
collectionFilter?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The controller passed to every vault-* module. vault.ts builds one instance
|
// The controller passed to every vault-* module. vault.ts builds one instance
|
||||||
@@ -117,6 +118,11 @@ export function getFilteredEntries(
|
|||||||
return false;
|
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));
|
filtered.sort((a, b) => a[1].title.localeCompare(b[1].title));
|
||||||
return filtered;
|
return filtered;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ import {
|
|||||||
} from '../shared/glyphs';
|
} from '../shared/glyphs';
|
||||||
import { renderStatusIndicator, type VaultStatus } from './vault-status';
|
import { renderStatusIndicator, type VaultStatus } from './vault-status';
|
||||||
import {
|
import {
|
||||||
type VaultController, typeIcon, typeLabel, getFilteredEntries,
|
type VaultController, typeIcon, typeLabel, getFilteredEntries, escapeHtml,
|
||||||
} from './vault-context';
|
} from './vault-context';
|
||||||
import { getState } from '../shared/state';
|
import { getState, setState } from '../shared/state';
|
||||||
|
|
||||||
const SEARCH_DEBOUNCE_MS = 80;
|
const SEARCH_DEBOUNCE_MS = 80;
|
||||||
|
|
||||||
@@ -33,6 +33,7 @@ export function renderSidebarShell(): string {
|
|||||||
<input type="text" id="vault-search" placeholder="/ search…" />
|
<input type="text" id="vault-search" placeholder="/ search…" />
|
||||||
</div>
|
</div>
|
||||||
<nav class="vault-sidebar__categories" id="vault-categories" aria-label="Item types"></nav>
|
<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">
|
<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 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>
|
<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();
|
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`,
|
/// 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
|
/// 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.
|
/// `ctx` is provided when called from renderSidebarCategories (production); it
|
||||||
/// Populating from getState().orgCollections, the click-to-filter wiring, and
|
/// is absent in tests that mount the facet in isolation via happy-dom.
|
||||||
/// the org_list_collections fetch on org_switch are held until Dev-A's org
|
export function renderCollectionFacet(host: HTMLElement, ctx?: VaultController): void {
|
||||||
/// messages land.
|
const s = getState();
|
||||||
export function renderCollectionFacet(host: HTMLElement): void {
|
const orgContext = s.orgContext;
|
||||||
// Inline shape until orgCollections joins PopupState at integration (it needs
|
const orgCollections = s.orgCollections ?? [];
|
||||||
// Dev-A's Collection type). HOLD(dev-a): render each as a vault-category-row
|
const collectionFilter = s.collectionFilter;
|
||||||
// mirroring renderSidebarCategories, wired to set a collection filter.
|
|
||||||
const collections =
|
if (!orgContext || orgContext === 'personal') {
|
||||||
(getState() as { orgCollections?: Array<{ slug: string; display_name: string }> }).orgCollections ?? [];
|
host.innerHTML = '';
|
||||||
host.innerHTML =
|
return;
|
||||||
`<nav class="vault-sidebar__collections" aria-label="Collections" data-count="${collections.length}"></nav>`;
|
}
|
||||||
|
|
||||||
|
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