summaryrefslogtreecommitdiffhomepage
path: root/src/features/workspaces/ui/WorkspaceCard.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/WorkspaceCard.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/WorkspaceCard.test.ts')
-rw-r--r--src/features/workspaces/ui/WorkspaceCard.test.ts305
1 files changed, 0 insertions, 305 deletions
diff --git a/src/features/workspaces/ui/WorkspaceCard.test.ts b/src/features/workspaces/ui/WorkspaceCard.test.ts
deleted file mode 100644
index 72f4e60..0000000
--- a/src/features/workspaces/ui/WorkspaceCard.test.ts
+++ /dev/null
@@ -1,305 +0,0 @@
-import type { WorkspaceEntry } from "@dispatch/wire";
-import { render, screen } from "@testing-library/svelte";
-import userEvent from "@testing-library/user-event";
-import { describe, expect, it, vi } from "vitest";
-import type { WorkspaceResult } from "../adapter/http";
-import type { WorkspaceStore } from "../store.svelte";
-import WorkspaceCard from "./WorkspaceCard.svelte";
-
-function fakeEntry(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry {
- return {
- id: "my-ws",
- title: "My Workspace",
- defaultCwd: null,
- defaultComputerId: null,
- starred: false,
- createdAt: 1,
- lastActivityAt: 2,
- conversationCount: 3,
- ...overrides,
- };
-}
-
-/** A fake store that records calls + resolves ok. */
-function fakeStore() {
- return {
- rename: vi.fn(
- async (id: string, _title: string): Promise<WorkspaceResult<WorkspaceEntry>> => ({
- ok: true,
- value: fakeEntry({ id, title: _title }),
- }),
- ),
- setDefaultCwd: vi.fn(
- async (id: string, defaultCwd: string | null): Promise<WorkspaceResult<WorkspaceEntry>> => ({
- ok: true,
- value: fakeEntry({ id, defaultCwd }),
- }),
- ),
- setDefaultComputer: vi.fn(
- async (id: string, computerId: string | null): Promise<WorkspaceResult<WorkspaceEntry>> => ({
- ok: true,
- value: fakeEntry({ id, defaultComputerId: computerId }),
- }),
- ),
- setStarred: vi.fn(
- async (id: string, starred: boolean): Promise<WorkspaceResult<WorkspaceEntry>> => ({
- ok: true,
- value: fakeEntry({ id, starred }),
- }),
- ),
- remove: vi.fn(
- async (): Promise<WorkspaceResult<{ closedCount: number }>> => ({
- ok: true,
- value: { closedCount: 0 },
- }),
- ),
- };
-}
-
-describe("WorkspaceCard", () => {
- it("renders the title, slug, and an Open link", () => {
- const store = fakeStore() as unknown as WorkspaceStore;
- render(WorkspaceCard, {
- props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] },
- });
- expect(screen.getByText("My Workspace")).toBeInTheDocument();
- expect(screen.getByText("/my-ws")).toBeInTheDocument();
- expect(screen.getByRole("link", { name: "Open" })).toHaveAttribute("href", "/my-ws");
- });
-
- it("double-clicking the title reveals an edit input", async () => {
- const user = userEvent.setup();
- const store = fakeStore() as unknown as WorkspaceStore;
- render(WorkspaceCard, {
- props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] },
- });
-
- await user.dblClick(screen.getByText("My Workspace"));
- expect(screen.getByLabelText("Workspace title")).toHaveValue("My Workspace");
- });
-
- it("renames via the store on Enter", async () => {
- const user = userEvent.setup();
- const store = fakeStore() as unknown as WorkspaceStore;
- render(WorkspaceCard, {
- props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] },
- });
-
- await user.dblClick(screen.getByText("My Workspace"));
- const input = screen.getByLabelText("Workspace title");
- await user.clear(input);
- await user.type(input, "Renamed{Enter}");
-
- expect(store.rename).toHaveBeenCalledWith("my-ws", "Renamed");
- });
-
- it("enables Set only when the cwd differs, then saves it", async () => {
- const user = userEvent.setup();
- const store = fakeStore() as unknown as WorkspaceStore;
- render(WorkspaceCard, {
- props: { ws: fakeEntry({ defaultCwd: "/old" }), store, onNavigate: vi.fn(), computers: [] },
- });
-
- const input = screen.getByLabelText("Default working directory");
- expect(input).toHaveValue("/old");
- expect(screen.getByRole("button", { name: "Set" })).toBeDisabled();
-
- await user.clear(input);
- await user.type(input, "/new/path");
- expect(screen.getByRole("button", { name: "Set" })).toBeEnabled();
-
- await user.click(screen.getByRole("button", { name: "Set" }));
- expect(store.setDefaultCwd).toHaveBeenCalledWith("my-ws", "/new/path");
- });
-
- it("clears the cwd to null when saved empty (inherits the server default)", async () => {
- const user = userEvent.setup();
- const store = fakeStore() as unknown as WorkspaceStore;
- render(WorkspaceCard, {
- props: { ws: fakeEntry({ defaultCwd: "/old" }), store, onNavigate: vi.fn(), computers: [] },
- });
-
- const input = screen.getByLabelText("Default working directory");
- await user.clear(input);
- await user.click(screen.getByRole("button", { name: "Set" }));
-
- expect(store.setDefaultCwd).toHaveBeenCalledWith("my-ws", null);
- });
-
- it("the Open link navigates to the workspace in the same tab (SPA navigation, no new tab)", async () => {
- const user = userEvent.setup();
- const store = fakeStore() as unknown as WorkspaceStore;
- const onNavigate = vi.fn();
- render(WorkspaceCard, { props: { ws: fakeEntry(), store, onNavigate, computers: [] } });
-
- const open = screen.getByRole("link", { name: "Open" });
- // Still a real link (progressive enhancement): href points at the workspace.
- expect(open).toHaveAttribute("href", "/my-ws");
- // But it no longer opens a new browser tab.
- expect(open).not.toHaveAttribute("target", "_blank");
- // Clicking navigates in-place via the SPA callback.
- await user.click(open);
- expect(onNavigate).toHaveBeenCalledTimes(1);
- expect(onNavigate).toHaveBeenCalledWith("/my-ws");
- });
-
- // ── Active indicator (loading dots) ──────────────────────────────────────
-
- it("shows no loading-dots when no hasActive port is given", () => {
- const store = fakeStore() as unknown as WorkspaceStore;
- const { container } = render(WorkspaceCard, {
- props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] },
- });
- expect(container.querySelector(".loading-dots")).toBeNull();
- });
-
- it("shows no loading-dots when hasActive returns false", () => {
- const store = fakeStore() as unknown as WorkspaceStore;
- const { container } = render(WorkspaceCard, {
- props: {
- ws: fakeEntry(),
- store,
- onNavigate: vi.fn(),
- computers: [],
- hasActive: () => false,
- },
- });
- expect(container.querySelector(".loading-dots")).toBeNull();
- });
-
- it("shows loading-dots when hasActive returns true", () => {
- const store = fakeStore() as unknown as WorkspaceStore;
- const { container } = render(WorkspaceCard, {
- props: {
- ws: fakeEntry(),
- store,
- onNavigate: vi.fn(),
- computers: [],
- hasActive: () => true,
- },
- });
- const dots = container.querySelector(".loading-dots");
- expect(dots).not.toBeNull();
- // Accessible label ties the indicator to the workspace-active concept.
- expect(dots?.getAttribute("aria-label")).toBe("Workspace has active conversations");
- });
-
- it("forwards the workspace id to hasActive", () => {
- const store = fakeStore() as unknown as WorkspaceStore;
- const seen: string[] = [];
- render(WorkspaceCard, {
- props: {
- ws: fakeEntry({ id: "proj-x" }),
- store,
- onNavigate: vi.fn(),
- computers: [],
- hasActive: (id: string) => {
- seen.push(id);
- return false;
- },
- },
- });
- expect(seen).toEqual(["proj-x"]);
- });
-
- it("renders an outline star button for an unstarred workspace", () => {
- const store = fakeStore() as unknown as WorkspaceStore;
- render(WorkspaceCard, {
- props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] },
- });
- const star = screen.getByRole("button", { name: "Star workspace" });
- expect(star).toHaveAttribute("aria-pressed", "false");
- });
-
- it("renders a filled star button for a starred workspace", () => {
- const store = fakeStore() as unknown as WorkspaceStore;
- render(WorkspaceCard, {
- props: { ws: fakeEntry({ starred: true }), store, onNavigate: vi.fn(), computers: [] },
- });
- const star = screen.getByRole("button", { name: "Unstar workspace" });
- expect(star).toHaveAttribute("aria-pressed", "true");
- });
-
- it("toggles the star via the store on click", async () => {
- const user = userEvent.setup();
- const store = fakeStore() as unknown as WorkspaceStore;
- render(WorkspaceCard, {
- props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] },
- });
-
- await user.click(screen.getByRole("button", { name: "Star workspace" }));
- expect(store.setStarred).toHaveBeenCalledWith("my-ws", true);
- });
-
- it("clicking a starred workspace's star calls setStarred(id, false)", async () => {
- const user = userEvent.setup();
- const store = fakeStore() as unknown as WorkspaceStore;
- render(WorkspaceCard, {
- props: { ws: fakeEntry({ starred: true }), store, onNavigate: vi.fn(), computers: [] },
- });
-
- await user.click(screen.getByRole("button", { name: "Unstar workspace" }));
- expect(store.setStarred).toHaveBeenCalledWith("my-ws", false);
- });
-
- it("renders no star error on a successful toggle", async () => {
- const user = userEvent.setup();
- const store = fakeStore() as unknown as WorkspaceStore;
- render(WorkspaceCard, {
- props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] },
- });
-
- await user.click(screen.getByRole("button", { name: "Star workspace" }));
- expect(screen.queryByText(/Star toggle failed/i)).not.toBeInTheDocument();
- });
-
- it("shows an inline error when setStarred fails (result.ok false)", async () => {
- const user = userEvent.setup();
- const store = fakeStore() as unknown as WorkspaceStore;
- // The store reverts the optimistic flip on failure, so the entry's
- // `starred` stays false — fake the revert by returning ok:false unchanged.
- store.setStarred = vi.fn(
- async (): Promise<WorkspaceResult<WorkspaceEntry>> => ({ ok: false, error: "boom" }),
- );
- render(WorkspaceCard, {
- props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] },
- });
-
- await user.click(screen.getByRole("button", { name: "Star workspace" }));
-
- expect(screen.getByText("boom")).toBeInTheDocument();
- });
-
- it("re-enables the star button after a failure (savingStar resets)", async () => {
- const user = userEvent.setup();
- const store = fakeStore() as unknown as WorkspaceStore;
- store.setStarred = vi.fn(
- async (): Promise<WorkspaceResult<WorkspaceEntry>> => ({ ok: false, error: "boom" }),
- );
- render(WorkspaceCard, {
- props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] },
- });
-
- const star = screen.getByRole("button", { name: "Star workspace" });
- await user.click(star);
- // After the failed toggle, the button is NOT disabled (savingStar reset).
- expect(star).not.toBeDisabled();
- });
-
- it("re-enables the star button even when setStarred throws", async () => {
- const user = userEvent.setup();
- const store = fakeStore() as unknown as WorkspaceStore;
- store.setStarred = vi.fn(async (): Promise<WorkspaceResult<WorkspaceEntry>> => {
- throw new Error("network");
- });
- render(WorkspaceCard, {
- props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] },
- });
-
- const star = screen.getByRole("button", { name: "Star workspace" });
- await user.click(star);
- // savingStar must reset via try/finally even on a throw.
- expect(star).not.toBeDisabled();
- expect(screen.getByText("network")).toBeInTheDocument();
- });
-});