summaryrefslogtreecommitdiffhomepage
path: root/src/features/views/ui
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/ui
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/ui')
-rw-r--r--src/features/views/ui/ViewSidebar.svelte87
-rw-r--r--src/features/views/ui/ViewSidebar.test.ts58
2 files changed, 145 insertions, 0 deletions
diff --git a/src/features/views/ui/ViewSidebar.svelte b/src/features/views/ui/ViewSidebar.svelte
new file mode 100644
index 0000000..c4b466f
--- /dev/null
+++ b/src/features/views/ui/ViewSidebar.svelte
@@ -0,0 +1,87 @@
+<script lang="ts">
+ import { type Snippet, untrack } from "svelte";
+ import {
+ addPanel,
+ initialPanels,
+ type PanelsState,
+ removePanel,
+ selectKind,
+ } from "../logic/panels";
+
+ interface ViewKind {
+ readonly id: string;
+ readonly label: string;
+ }
+
+ let {
+ kinds,
+ content,
+ initial,
+ }: {
+ /** The view kinds offered in every panel's dropdown. */
+ kinds: readonly ViewKind[];
+ /** Renders a panel body for the given (non-null) view-kind id. */
+ content: Snippet<[string]>;
+ /** Optional seed of panel kinds; defaults to one panel of the first kind. */
+ initial?: readonly (string | null)[];
+ } = $props();
+
+ // Local UI composition state, owned by this unit and folded through the pure
+ // reducer — never reached from elsewhere (no ambient store). Seeded ONCE from
+ // the props (untrack makes that one-time read explicit, not reactive).
+ let state = $state<PanelsState>(
+ untrack(() => initialPanels(initial ?? [kinds[0]?.id ?? null])),
+ );
+</script>
+
+<div class="flex min-h-0 flex-col gap-2">
+ {#each state.panels as panel, idx (panel.id)}
+ <div class="flex flex-col rounded-lg bg-base-200 p-3">
+ <div class="flex items-center gap-1">
+ <select
+ class="select select-bordered select-sm flex-1"
+ aria-label="Select a view"
+ value={panel.kind ?? ""}
+ onchange={(e) => {
+ const v = e.currentTarget.value;
+ state = selectKind(state, panel.id, v === "" ? null : v);
+ }}
+ >
+ <option value="" disabled>Select a view</option>
+ {#each kinds as kind (kind.id)}
+ <option value={kind.id}>{kind.label}</option>
+ {/each}
+ </select>
+ {#if idx > 0}
+ <button
+ type="button"
+ class="btn btn-square btn-ghost btn-sm shrink-0"
+ aria-label="Remove view"
+ onclick={() => {
+ state = removePanel(state, panel.id);
+ }}
+ >
+ ✕
+ </button>
+ {/if}
+ </div>
+
+ {#if panel.kind !== null}
+ <div class="mt-2">
+ {@render content(panel.kind)}
+ </div>
+ {/if}
+ </div>
+ {/each}
+
+ <button
+ type="button"
+ class="btn w-full border-none bg-base-200 text-lg hover:bg-base-300"
+ aria-label="Add view"
+ onclick={() => {
+ state = addPanel(state);
+ }}
+ >
+ +
+ </button>
+</div>
diff --git a/src/features/views/ui/ViewSidebar.test.ts b/src/features/views/ui/ViewSidebar.test.ts
new file mode 100644
index 0000000..8a0049c
--- /dev/null
+++ b/src/features/views/ui/ViewSidebar.test.ts
@@ -0,0 +1,58 @@
+import { render, screen } from "@testing-library/svelte";
+import userEvent from "@testing-library/user-event";
+import { createRawSnippet } from "svelte";
+import { describe, expect, it } from "vitest";
+import ViewSidebar from "./ViewSidebar.svelte";
+
+const kinds = [
+ { id: "surfaces", label: "Surfaces" },
+ { id: "tasks", label: "Tasks" },
+];
+
+// A raw snippet that echoes the kind it was rendered for, so tests can assert
+// which view-kind content each panel shows.
+const content = createRawSnippet<[string]>((kind) => ({
+ render: () => `<div data-testid="view-content">kind:${kind()}</div>`,
+}));
+
+describe("ViewSidebar", () => {
+ it("opens one panel seeded with the first kind and renders its content", () => {
+ render(ViewSidebar, { props: { kinds, content } });
+ expect(screen.getAllByRole("combobox")).toHaveLength(1);
+ expect(screen.getByTestId("view-content")).toHaveTextContent("kind:surfaces");
+ });
+
+ it("the first panel has no remove button", () => {
+ render(ViewSidebar, { props: { kinds, content } });
+ expect(screen.queryByRole("button", { name: "Remove view" })).toBeNull();
+ });
+
+ it("the add button appends a new empty panel", async () => {
+ const user = userEvent.setup();
+ render(ViewSidebar, { props: { kinds, content } });
+ await user.click(screen.getByRole("button", { name: "Add view" }));
+ expect(screen.getAllByRole("combobox")).toHaveLength(2);
+ // the new panel is empty → only the first panel renders content
+ expect(screen.getAllByTestId("view-content")).toHaveLength(1);
+ });
+
+ it("non-first panels can be removed", async () => {
+ const user = userEvent.setup();
+ render(ViewSidebar, { props: { kinds, content } });
+ await user.click(screen.getByRole("button", { name: "Add view" }));
+ const removeButtons = screen.getAllByRole("button", { name: "Remove view" });
+ expect(removeButtons).toHaveLength(1);
+ const target = removeButtons[0];
+ if (target === undefined) throw new Error("expected a remove button");
+ await user.click(target);
+ expect(screen.getAllByRole("combobox")).toHaveLength(1);
+ });
+
+ it("selecting a kind renders that kind's content", async () => {
+ const user = userEvent.setup();
+ render(ViewSidebar, { props: { kinds, content, initial: [null] } });
+ expect(screen.queryByTestId("view-content")).toBeNull();
+ await user.selectOptions(screen.getByRole("combobox"), "tasks");
+ expect(screen.getByTestId("view-content")).toHaveTextContent("kind:tasks");
+ });
+});