summaryrefslogtreecommitdiffhomepage
path: root/src/features/workspaces/ui/HomeHeader.test.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-09-07 11:47:18 +0900
committerAdam Malczewski <[email protected]>2026-09-07 11:47:18 +0900
commit754450b539818f9ad94c9586888973b0edd74b68 (patch)
treed852f9a8910f223e15a5e11ad04868b0d0a29804 /src/features/workspaces/ui/HomeHeader.test.ts
parent719f0038dbb09f0fe9b073c3dc8e4f403815223d (diff)
downloaddispatch-web-754450b539818f9ad94c9586888973b0edd74b68.tar.gz
dispatch-web-754450b539818f9ad94c9586888973b0edd74b68.zip
feat(workspaces): Win8 Start-style tile-grid home page
Rebuild the workspace home as a Windows 8 Start-screen-style tile grid, replacing the max-w-3xl vertical card list. Pure core (zero DOM/Svelte/fetch, fully unit-tested): - logic/tile-layout.ts — geometryFor(width, height) resolves WIDE (4 fixed ~120px rows, column-wise flow, 4x2 span cap) or TALL (2 fixed columns, row-wise flow, 2x2 cap); pack() is the ONLY order->coordinates transform (greedy first-fit in fill order, hole-free, render-clamped per geometry); firstEmptySlot/insertAtFirstEmptySlot/mergeLayout/resizeTile/moveTile/ sortByRecentActivity/sanitizeLayout. Persisted data is orientation-independent {version:1, tiles:[{id,colSpan,rowSpan,order}]} — order + spans only, never positions; the same data renders in either geometry (spans clamp for the render, persisted values untouched). - logic/drag-controller.ts — pure pointer/press state machine: mouse threshold-drag + tap-open; touch long-press lifts + selects, pre-lift movement cancels so vertical scrolling never fights the gesture. Shell: - tile-layout-store.svelte.ts — thin reactive wrapper persisting through the injected createLocalStore adapter (dispatch.workspaces.tiles), ops: sync/resize/move/placeFirstEmpty/sortByRecentActivity. - ui/HomeTileGrid.svelte — CSS-grid tile canvas (wide: horizontal scroll + snap + wheel pan + pan arrow + scroll indicator; tall: native vertical scroll), FLIP-animated reflow, drag ghost, transient resize preview. - ui/HomeHeader.svelte — fixed chrome: Dispatch display title, New workspace popover (same slug validation; creates via create-on-miss and does NOT navigate — the new tile lands in the first empty slot in fill order and enters rename mode), global Plugins button, sort-by-recent overflow; compact icon buttons + bottom sheet on tall screens. - ui/WorkspaceTile.svelte — tile face (title, /slug, conversation count, relative last-activity, live active dots, starred tint); inline rename with the old card's semantics. - ui/TileActionBar.svelte — slides up on select (right-click / long-press): Open, Rename, Resize, Star/Unstar, Details, Plugins, Delete (old confirm + store call preserved). - ui/ResizePicker.svelte — Word-style insert-table hover grid (wide 4x2 / tall 2x2) with arrow/Enter/Escape support and ghost preview on commit. - ui/WorkspaceDetails.svelte — flyout holding the old card's default-cwd input + default-computer select (same store calls + error states). - WorkspacesHome.svelte orchestrates; App.svelte untouched (same props). - WorkspaceCard.svelte is removed (replaced by the tile system). Manual tile order rules; starred workspaces get a tint (star is no longer a sort key); workspaces unknown to the layout append at the end. vitest: jsdom gains PointerEvent (MouseEvent-based) + Element.animate stubs — the pointer logic is the outermost edge for the drag tests. Verification: svelte-check 0/0, vitest 1655 passed (102 files, twice), biome clean, vite build OK.
Diffstat (limited to 'src/features/workspaces/ui/HomeHeader.test.ts')
-rw-r--r--src/features/workspaces/ui/HomeHeader.test.ts90
1 files changed, 90 insertions, 0 deletions
diff --git a/src/features/workspaces/ui/HomeHeader.test.ts b/src/features/workspaces/ui/HomeHeader.test.ts
new file mode 100644
index 0000000..a0afc52
--- /dev/null
+++ b/src/features/workspaces/ui/HomeHeader.test.ts
@@ -0,0 +1,90 @@
+import { render, screen } from "@testing-library/svelte";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import HomeHeader from "./HomeHeader.svelte";
+
+function props() {
+ return {
+ onCreate: vi.fn(async (_slug: string): Promise<{ ok: true }> => ({ ok: true })),
+ onOpenPlugins: vi.fn(),
+ onSort: vi.fn(),
+ };
+}
+
+describe("HomeHeader", () => {
+ it("renders the display title and the primary New workspace button", () => {
+ const p = props();
+ render(HomeHeader, { props: p });
+ expect(screen.getByRole("heading", { name: "Dispatch" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "New workspace" })).toBeInTheDocument();
+ });
+
+ it("the Plugins button renders only with the plugins port", () => {
+ const p = props();
+ const without = render(HomeHeader, { props: p });
+ expect(without.queryByRole("button", { name: "Plugins" })).toBeNull();
+ without.unmount();
+
+ render(HomeHeader, { props: { ...p, withPlugins: true } });
+ screen.getByRole("button", { name: "Plugins" }).click();
+ expect(p.onOpenPlugins).toHaveBeenCalledTimes(1);
+ });
+
+ it("the Sort overflow renders only when asked, and dispatches", async () => {
+ const user = userEvent.setup();
+ const p = props();
+ render(HomeHeader, { props: { ...p, withSort: true } });
+
+ await user.click(screen.getByRole("button", { name: "More actions" }));
+ await user.click(screen.getByRole("button", { name: "Sort by recent activity" }));
+ expect(p.onSort).toHaveBeenCalledTimes(1);
+ });
+
+ it("the Create button disables for an invalid slug (validation before submit)", async () => {
+ const user = userEvent.setup();
+ const p = props();
+ render(HomeHeader, { props: p });
+
+ await user.click(screen.getByRole("button", { name: "New workspace" }));
+ const input = screen.getByRole("textbox", { name: "New workspace" });
+ await user.type(input, "Invalid Slug!");
+ expect(screen.getByRole("button", { name: "Create" })).toBeDisabled();
+ expect(p.onCreate).not.toHaveBeenCalled();
+
+ // A valid slug enables it.
+ await user.clear(input);
+ await user.type(input, "my-workspace");
+ expect(screen.getByRole("button", { name: "Create" })).toBeEnabled();
+ });
+
+ it("submitting the form with Enter creates via the callback and closes the popover", async () => {
+ const user = userEvent.setup();
+ const p = props();
+ render(HomeHeader, { props: p });
+
+ await user.click(screen.getByRole("button", { name: "New workspace" }));
+ await user.type(screen.getByRole("textbox", { name: "New workspace" }), "my-workspace{Enter}");
+
+ expect(p.onCreate).toHaveBeenCalledWith("my-workspace");
+ });
+
+ it("a backend error keeps the form open with the message", async () => {
+ const user = userEvent.setup();
+ const onCreate = vi.fn(
+ async (): Promise<{ ok: false; error: string }> => ({ ok: false, error: "taken" }),
+ );
+ render(HomeHeader, { props: { ...props(), onCreate } });
+
+ await user.click(screen.getByRole("button", { name: "New workspace" }));
+ await user.type(screen.getByRole("textbox", { name: "New workspace" }), "my-workspace");
+ await user.click(screen.getByRole("button", { name: "Create" }));
+
+ expect(await screen.findByText("taken")).toBeInTheDocument();
+ });
+
+ it("compact mode collapses the primary button to an icon", () => {
+ const p = props();
+ render(HomeHeader, { props: { ...p, compact: true } });
+ expect(screen.getByRole("button", { name: "New workspace" })).toHaveTextContent("+");
+ });
+});