diff options
| author | Adam Malczewski <[email protected]> | 2026-09-07 17:08:52 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-09-07 17:08:52 +0900 |
| commit | c43250c16356be523201c2a685d527936aeea0bd (patch) | |
| tree | e3f6eeff9e2a6fec2f094df8591df0b038b5c5cd /src/features/workspaces/tile-layout-store.test.ts | |
| parent | 6ae43c119ad51e99d1f4a4ede21b6ba4eb44c3b1 (diff) | |
| download | dispatch-web-c43250c16356be523201c2a685d527936aeea0bd.tar.gz dispatch-web-c43250c16356be523201c2a685d527936aeea0bd.zip | |
feat(workspaces): pinned tiles with displacement reflow + FLIP drag preview
Tiles can now be dropped onto specific cells; empty cells are real
estate that persists (notes/pinned-tiles-plan.md).
Data (v2): each tile carries an optional pinned anchor in a UNIVERSAL
4-row frame (tall folds its 2 columns onto frame column 1; a wide col>1
pin reads as unpinned in tall). v1 documents migrate in memory to
all-unpinned (render-identical) and write back as v2 on the next change.
sanitizeLayout drops garbage anchors.
Packing: pinned-first (first-wins on overlap, one-ply), then unpinned
tiles first-fit into the REMAINING cells — holes between pins count and
never compact. displacePlace: the user-specified semantics — the dragged
tile leaves a gap at its origin (nothing slides backward), anchors at
the drop cell, and the contiguous filled run pushes FORWARD until the
first empty cell absorbs it (X_X + Y@1 → YXX; _XZ_ + Y@2 → _YXZ).
placeAtCell commits the displacement and pins every tile; pinAtFirstEmpty
pins the create-flow tile at the first truly-empty slot;
sortByRecentActivity now also clears pins (an explicit arrangement
reset — the menu item says so).
Drag UX: pointer → cell hit-math (empty cells are drop targets); a
faint dot marks every cell center during a drag (CSS radial-gradient
overlay, one extra pitch of end padding for the append slot); the
dragged tile element itself follows the pointer (lifted via transform,
pointer-events none); a dashed tone-colored ghost marks the landing
footprint. The preview recomputes from the COMMITTED layout on every
hover-cell change (as if the user moved straight from the original to
there) while GSAP Flip animates from wherever the tiles currently are
(re-entrant — mid-flight positions captured). Escape/pointercancel
discards the preview and flies the tile home; a drop settles it onto
its new cell.
Tests: displacement (both user examples + internal moves + spans + tall
stream), pinned pack (anchors, gaps kept, first-wins, universal-frame
mapping, deleted-leaves-holes), v1→v2 migration + anchor sanitization,
store placeAtCell/no-op write guard, Escape-cancels-drag component test.
1689 green.
Diffstat (limited to 'src/features/workspaces/tile-layout-store.test.ts')
| -rw-r--r-- | src/features/workspaces/tile-layout-store.test.ts | 77 |
1 files changed, 61 insertions, 16 deletions
diff --git a/src/features/workspaces/tile-layout-store.test.ts b/src/features/workspaces/tile-layout-store.test.ts index f8b30f7..be6a14e 100644 --- a/src/features/workspaces/tile-layout-store.test.ts +++ b/src/features/workspaces/tile-layout-store.test.ts @@ -29,12 +29,21 @@ function tile(id: string, order: number, colSpan = 1, rowSpan = 1): TileLayoutEn } describe("createTileLayoutStore", () => { - it("loads the persisted document at creation", () => { + it("loads the persisted document at creation (v1 migrates to v2 in memory)", () => { const persistence = memoryStore({ version: 1, tiles: [tile("a", 0, 2, 2)] }); const store = createTileLayoutStore(persistence); expect(store.entries).toEqual([tile("a", 0, 2, 2)]); }); + it("loads a v2 document with anchors intact", () => { + const persistence = memoryStore({ + version: 2, + tiles: [{ ...tile("a", 0), col: 2, row: 3 }], + }); + const store = createTileLayoutStore(persistence); + expect(store.entries).toEqual([{ ...tile("a", 0), col: 2, row: 3 }]); + }); + it("sanitizes a corrupt persisted document to empty", () => { const persistence = memoryStore(null); // The adapter's load is untyped at the boundary — simulate garbage. @@ -57,11 +66,26 @@ describe("createTileLayoutStore", () => { expect(persistence.saved).toHaveLength(1); // The max order counts KEPT entries (the dropped one doesn't reserve a slot). expect(persistence.saved[0]).toEqual({ - version: 1, + version: 2, tiles: [tile("a", 0), { id: "b", colSpan: 1, rowSpan: 1, order: 1 }], }); }); + it("sync keeps pinned anchors (a deleted tile's cells become gaps)", () => { + const persistence = memoryStore({ + version: 2, + tiles: [ + { ...tile("a", 0), col: 1, row: 1 }, + { ...tile("gone", 1), col: 1, row: 2 }, + ], + }); + const store = createTileLayoutStore(persistence); + + store.sync(["a"]); + + expect(store.entries).toEqual([{ ...tile("a", 0), col: 1, row: 1 }]); + }); + it("sync is a no-op (no write) when the merge changes nothing", () => { const persistence = memoryStore({ version: 1, tiles: [tile("a", 0)] }); const store = createTileLayoutStore(persistence); @@ -82,20 +106,34 @@ describe("createTileLayoutStore", () => { expect(persistence.saved.at(-1)?.tiles).toEqual([tile("a", 0, 4, 2)]); }); - it("move persists the reordered layout", () => { + it("placeAtCell commits the displacement and pins EVERY tile", () => { const persistence = memoryStore({ version: 1, - tiles: [tile("a", 0), tile("b", 1), tile("c", 2)], + tiles: [tile("a", 0), tile("b", 1)], }); const store = createTileLayoutStore(persistence); - store.move("c", 0); + // a@(1,1), b@(1,2) → drag a to (2,1): a pins there, b keeps its cell. + store.placeAtCell("a", { col: 2, row: 1 }, WIDE); + + const entries = store.entries; + expect(entries.find((t) => t.id === "a")).toMatchObject({ col: 2, row: 1 }); + expect(entries.find((t) => t.id === "b")).toMatchObject({ col: 1, row: 2 }); + // Written as v2. + expect(persistence.saved.at(-1)?.version).toBe(2); + }); + + it("placeAtCell with an unknown id is a no-op (no write)", () => { + const persistence = memoryStore({ version: 1, tiles: [tile("a", 0)] }); + const store = createTileLayoutStore(persistence); + + store.placeAtCell("nope", { col: 1, row: 1 }, WIDE); - expect(store.entries.map((t) => t.id)).toEqual(["c", "a", "b"]); - expect(store.entries.map((t) => t.order)).toEqual([0, 1, 2]); + expect(persistence.saved).toHaveLength(0); + expect(store.entries).toEqual([tile("a", 0)]); }); - it("placeFirstEmpty puts a new id in the first empty slot's fill position", () => { + it("placeFirstEmpty PINS the new id at the first empty slot", () => { const persistence = memoryStore({ version: 1, tiles: [tile("a", 0, 1, 2), tile("b", 1)], @@ -104,27 +142,33 @@ describe("createTileLayoutStore", () => { store.placeFirstEmpty("new", WIDE); - // a packs rows 1-2, b row 3 → the first empty slot is (col1,row4) → appended. - expect(store.entries.map((t) => t.id)).toEqual(["a", "b", "new"]); + // a packs rows 1-2, b row 3 → the first empty slot is (col1,row4) → pinned. + expect(store.entries.find((t) => t.id === "new")).toMatchObject({ col: 1, row: 4 }); expect(persistence.saved.at(-1)?.tiles.map((t) => t.id)).toEqual(["a", "b", "new"]); }); it("placeFirstEmpty is idempotent for an id sync already appended (the refresh race)", () => { const persistence = memoryStore({ version: 1, tiles: [tile("a", 0)] }); const store = createTileLayoutStore(persistence); - store.sync(["a", "x"]); // the refresh appended the new workspace at the end - const before = store.entries; + store.sync(["a", "x"]); // the refresh appended the new workspace (unpinned) store.placeFirstEmpty("x", WIDE); + const pinned = store.entries; + store.placeFirstEmpty("x", WIDE); // the create flow's re-call must be stable - expect(store.entries).toEqual(before); + expect(store.entries).toEqual(pinned); expect(store.entries.filter((t) => t.id === "x")).toHaveLength(1); + // x is PINNED at the first empty slot: a@(1,1) → (1,2). + expect(store.entries.find((t) => t.id === "x")).toMatchObject({ col: 1, row: 2 }); }); - it("sortByRecentActivity re-applies recent-activity order and persists", () => { + it("sortByRecentActivity re-applies recent-activity order, CLEARs pins, persists", () => { const persistence = memoryStore({ - version: 1, - tiles: [tile("a", 0), tile("b", 1)], + version: 2, + tiles: [ + { ...tile("a", 0), col: 1, row: 1 }, + { ...tile("b", 1), col: 2, row: 3 }, + ], }); const store = createTileLayoutStore(persistence); @@ -136,5 +180,6 @@ describe("createTileLayoutStore", () => { ); expect(store.entries.map((t) => t.id)).toEqual(["b", "a"]); + expect(store.entries.every((t) => t.col === undefined && t.row === undefined)).toBe(true); }); }); |
