summaryrefslogtreecommitdiffhomepage
path: root/src/features/workspaces/tile-layout-store.test.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-09-07 17:08:52 +0900
committerAdam Malczewski <[email protected]>2026-09-07 17:08:52 +0900
commitc43250c16356be523201c2a685d527936aeea0bd (patch)
treee3f6eeff9e2a6fec2f094df8591df0b038b5c5cd /src/features/workspaces/tile-layout-store.test.ts
parent6ae43c119ad51e99d1f4a4ede21b6ba4eb44c3b1 (diff)
downloaddispatch-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.ts77
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);
});
});