summaryrefslogtreecommitdiffhomepage
path: root/src/features/views/logic
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-10 16:29:01 +0900
committerAdam Malczewski <[email protected]>2026-06-10 16:29:01 +0900
commit871957b930203c019e631c4606cfdf8266d222fa (patch)
tree50c522018c3ce4127ffa76f4b3b6c7843e90db43 /src/features/views/logic
parent7b345f132763fa6405ae858b74e46229629c19d9 (diff)
downloaddispatch-web-871957b930203c019e631c4606cfdf8266d222fa.tar.gz
dispatch-web-871957b930203c019e631c4606cfdf8266d222fa.zip
feat(views,surface-host): Extensions sidebar view — auto-expanded surfaces + tables
views (new feature): - pure panel-stack reducer + thin generic ViewSidebar (dropdown picker + add/remove), switches on view KIND, never a surface id Extensions view (composition root): - folds frontend modules + backend surfaces into one "Extensions" view - frontend module list AGGREGATED from each feature's public `manifest` export (can't drift); no per-module version (FE features are internal to dispatch-web) - surfaces are AUTO-SUBSCRIBED on catalog + rendered expanded (no catalog buttons) surface-host: - consecutive `stat` fields coalesce into one aligned label/value table (StatTable) - generic custom-field renderer: dispatch on rendererId === "table" → SurfaceTable (pure parseTablePayload), so a backend `custom`/table field renders generically - shared presentational components/Table.svelte (used by both, neither feature depends on the other) store: - auto-subscribe every catalog entry, unsubscribe vanished ones, re-subscribe all on reconnect; expose all received specs via `surfaces` (drops single-selection) backend-handoff: CR-1 — emit Loaded Extensions as a custom/table field; notes what's already covered FE-side (renderer shipped, stat-table fallback works).
Diffstat (limited to 'src/features/views/logic')
-rw-r--r--src/features/views/logic/panels.test.ts55
-rw-r--r--src/features/views/logic/panels.ts49
2 files changed, 104 insertions, 0 deletions
diff --git a/src/features/views/logic/panels.test.ts b/src/features/views/logic/panels.test.ts
new file mode 100644
index 0000000..edd7d9e
--- /dev/null
+++ b/src/features/views/logic/panels.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it } from "vitest";
+import { addPanel, initialPanels, removePanel, selectKind } from "./panels";
+
+describe("view panels reducer", () => {
+ it("seeds one empty panel by default", () => {
+ const s = initialPanels();
+ expect(s.panels).toHaveLength(1);
+ expect(s.panels[0]?.kind).toBeNull();
+ });
+
+ it("seeds a panel per provided kind, in order, with unique ids", () => {
+ const s = initialPanels(["surfaces", null]);
+ expect(s.panels.map((p) => p.kind)).toEqual(["surfaces", null]);
+ expect(new Set(s.panels.map((p) => p.id)).size).toBe(2);
+ });
+
+ it("addPanel appends an empty panel with a fresh id", () => {
+ const seed = initialPanels(["surfaces"]);
+ const s = addPanel(seed);
+ expect(s.panels).toHaveLength(2);
+ expect(s.panels[1]?.kind).toBeNull();
+ expect(s.panels[1]?.id).not.toBe(s.panels[0]?.id);
+ });
+
+ it("addPanel can seed a kind", () => {
+ const s = addPanel(initialPanels([null]), "surfaces");
+ expect(s.panels[1]?.kind).toBe("surfaces");
+ });
+
+ it("removePanel drops the matching id only", () => {
+ const seed = initialPanels(["surfaces", null]);
+ const firstId = seed.panels[0]?.id ?? -1;
+ const s = removePanel(seed, firstId);
+ expect(s.panels).toHaveLength(1);
+ expect(s.panels[0]?.kind).toBeNull();
+ });
+
+ it("selectKind updates only the targeted panel", () => {
+ const seed = initialPanels([null, null]);
+ const targetId = seed.panels[1]?.id ?? -1;
+ const s = selectKind(seed, targetId, "surfaces");
+ expect(s.panels[0]?.kind).toBeNull();
+ expect(s.panels[1]?.kind).toBe("surfaces");
+ });
+
+ it("is pure — never mutates the input state", () => {
+ const seed = initialPanels(["surfaces"]);
+ const snapshot = JSON.stringify(seed);
+ const id = seed.panels[0]?.id ?? -1;
+ addPanel(seed);
+ removePanel(seed, id);
+ selectKind(seed, id, null);
+ expect(JSON.stringify(seed)).toBe(snapshot);
+ });
+});
diff --git a/src/features/views/logic/panels.ts b/src/features/views/logic/panels.ts
new file mode 100644
index 0000000..38c28fb
--- /dev/null
+++ b/src/features/views/logic/panels.ts
@@ -0,0 +1,49 @@
+/**
+ * Pure reducer for the view sidebar's panel stack.
+ *
+ * A "view" is the RESERVED Dispatch sidebar affordance (see GLOSSARY): the user
+ * stacks panels, each showing one view KIND chosen from a dropdown, and adds
+ * more with a `+` button. This module is the pure model — zero DOM, zero Svelte.
+ * The component (`ViewSidebar.svelte`) is a thin runes wrapper over it.
+ *
+ * `id` is a per-session stable key for `{#each}` only; it is never persisted.
+ */
+
+export interface ViewPanel {
+ readonly id: number;
+ /** Selected view-kind id, or `null` while the panel still reads "Select a view". */
+ readonly kind: string | null;
+}
+
+export interface PanelsState {
+ readonly panels: readonly ViewPanel[];
+ readonly nextId: number;
+}
+
+/**
+ * Seed state. Each entry becomes one panel in order; pass `["surfaces"]` to open
+ * a single preset panel, or `[null]` for one empty "Select a view" panel.
+ */
+export function initialPanels(kinds: readonly (string | null)[] = [null]): PanelsState {
+ let nextId = 0;
+ const panels = kinds.map((kind) => ({ id: nextId++, kind }));
+ return { panels, nextId };
+}
+
+export function addPanel(state: PanelsState, kind: string | null = null): PanelsState {
+ return {
+ panels: [...state.panels, { id: state.nextId, kind }],
+ nextId: state.nextId + 1,
+ };
+}
+
+export function removePanel(state: PanelsState, id: number): PanelsState {
+ return { ...state, panels: state.panels.filter((p) => p.id !== id) };
+}
+
+export function selectKind(state: PanelsState, id: number, kind: string | null): PanelsState {
+ return {
+ ...state,
+ panels: state.panels.map((p) => (p.id === id ? { ...p, kind } : p)),
+ };
+}