Files
webmail-client/frontend/src/test/composeWindows.test.ts
T
lukasandClaude Opus 5 9f9e959a05 Compose: a dock of windows that survive the app around them
Clicking Compose with a window already open cleared what you had typed.
MailLayout bumped a React key on every open to force a fresh editor, and
switching folders ran an effect that closed the composer outright. Both
were deliberate when only one composer could exist at a time.

Underneath them sat a sharper constraint: RichTextEditor seeds its
contentEditable exactly once, so ANY remount silently discards the body.
Every part of this change is shaped by keeping a composer mounted.

Windows now live in one ordered list, each with a stable id used as its
React key, rendered by ComposeDock as a single keyed array — open windows
and collapsed title bars together. Splitting the open one into a child
slot of its own would hand its state to whichever window took its place,
which is exactly what happened the first time around.

  - A minimize control, first of the four window icons, collapses a
    window to a title bar showing its subject (or "New message"). The
    subtree is hidden, never unmounted.
  - Clicking Compose parks the open windows and appends a new one, so
    each click pushes the previous window one place left.
  - Restoring a window touches only that window; several can be open at
    once. When they stop fitting, the least recently reached-for ones
    are DRAWN as bars — state untouched, so they spring back open when
    room returns.
  - Expanding matches the reading pane, measured live, and moves the
    window to the end of the row: the row is right-anchored, so a bar
    after it would shove an expanded window off the pane and across the
    list and sidebar.
  - A window is given its height rather than sizing to its content. Left
    to itself it grew with the body until the top ran off the screen,
    and the `flex-1` body never became scrollable because there was no
    leftover space to divide.
  - Leaving a folder un-expands a window instead of closing it.
  - The dock row is pointer-events-none — its box spans the tallest
    window in it, and as a bare div it shielded the mail list beside it.
  - Drafts opened from the Drafts folder now load in the reading pane,
    keyed by draft id, with the open one marked in the list.

Also fixes a blank draft saved for every composer opened: the signature
effect guarded itself with a "first run" flag, which StrictMode's double
invocation spends on the first pass. The second pass rewrote the
signature of an untouched composer, marking it dirty. Keyed on the
account instead, so it is idempotent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
2026-09-21 21:32:41 +02:00

217 lines
8.8 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { ComposerInit } from "../components/compose/Composer";
import {
collapseExpanded,
type ComposeWin,
layoutDock,
minimizeAll,
minimizeWindow,
openWindow,
restoreWindow,
toggleExpand,
} from "../components/compose/windows";
const init = (draftId?: string): ComposerInit => ({ accountId: "a1", kind: "new", draftId });
let clock = 0;
const tick = () => (clock += 1);
/** Open `n` windows the way repeated Compose clicks would. */
function opened(n: number): ComposeWin[] {
clock = 0;
let wins: ComposeWin[] = [];
for (let i = 1; i <= n; i += 1) wins = openWindow(wins, `c${i}`, init(), false, tick());
return wins;
}
const ids = (wins: { id: string }[]) => wins.map((w) => w.id);
const byId = (wins: ComposeWin[], id: string) => wins.find((w) => w.id === id)!;
const openIds = (wins: ComposeWin[]) => ids(wins.filter((w) => !w.minimized));
describe("compose window list", () => {
it("parks the open window instead of replacing it", () => {
const wins = opened(3);
expect(ids(wins)).toEqual(["c1", "c2", "c3"]);
expect(wins.map((w) => w.minimized)).toEqual([true, true, false]);
});
it("keeps every window's identity as the list grows", () => {
const first = opened(1)[0];
const later = openWindow(
openWindow([first], "c2", init(), false, tick()),
"c3",
init(),
false,
tick(),
);
// Same object identity for c1's init: nothing was rebuilt underneath it.
expect(later[0].init).toBe(first.init);
});
it("reopening a draft restores its window rather than adding a second", () => {
const wins = openWindow(opened(2), "c3", init("draft-1"), false, tick());
const again = openWindow(wins, "c4", init("draft-1"), false, tick());
expect(again).toHaveLength(3);
expect(openIds(again)).toEqual(["c3"]);
});
it("restoring a window leaves every other window as it was", () => {
// c3 open, c1 and c2 collapsed; restoring c1 must not park c3.
const wins = restoreWindow(opened(3), "c1", tick());
expect(openIds(wins)).toEqual(["c1", "c3"]);
expect(ids(wins)).toEqual(["c1", "c2", "c3"]); // and nothing moved
});
it("restoring marks the window as the most recently reached for", () => {
const wins = restoreWindow(opened(3), "c1", tick());
const newest = [...wins].sort((a, b) => b.activated - a.activated)[0];
expect(newest.id).toBe("c1");
});
it("expanding claims the row; collapsing out of it leaves the others alone", () => {
const big = toggleExpand(opened(2), "c1", tick());
expect(byId(big, "c1")).toMatchObject({ minimized: false, expanded: true });
expect(byId(big, "c2").minimized).toBe(true);
const back = restoreWindow(big, "c2", tick());
const small = toggleExpand(back, "c1", tick());
expect(openIds(small).sort()).toEqual(["c1", "c2"]);
});
it("expanding moves the window to the end of the row", () => {
// The row is right-anchored: a bar after an expanded window would shove it
// left, off the reading pane and across the list.
const wins = toggleExpand(opened(3), "c1", tick());
expect(ids(wins)).toEqual(["c2", "c3", "c1"]);
expect(byId(wins, "c1").expanded).toBe(true);
});
it("a window already last is not shuffled, and collapsing moves nothing", () => {
const wins = opened(3);
const big = toggleExpand(wins, "c3", tick());
expect(ids(big)).toEqual(["c1", "c2", "c3"]);
// And it stays put on the way back down.
expect(ids(toggleExpand(big, "c3", tick()))).toEqual(["c1", "c2", "c3"]);
expect(ids(toggleExpand(toggleExpand(wins, "c1", tick()), "c1", tick()))).toEqual([
"c2",
"c3",
"c1",
]);
});
it("a folder switch parks an expanded window without closing it", () => {
const big = toggleExpand(opened(2), "c1", tick());
const after = collapseExpanded(big);
expect(byId(after, "c1")).toMatchObject({ minimized: true, expanded: false });
// Nothing expanded -> the same array, so an effect calling this can't loop.
expect(collapseExpanded(after)).toBe(after);
});
it("minimize and Escape park windows without dropping them", () => {
expect(minimizeWindow(opened(2), "c2").every((w) => w.minimized)).toBe(true);
const all = minimizeAll(opened(3));
expect(all).toHaveLength(3);
expect(all.every((w) => w.minimized)).toBe(true);
});
});
describe("dock layout", () => {
it("never reorders the row", () => {
const wins = restoreWindow(opened(4), "c1", tick());
expect(ids(layoutDock(1600, 950, wins).items.map((i) => i.win))).toEqual(["c1", "c2", "c3", "c4"]);
});
it("draws several open windows side by side when they fit", () => {
const wins = restoreWindow(opened(3), "c1", tick());
const { items } = layoutDock(1600, 950, wins);
expect(items.filter((i) => i.open).map((i) => i.win.id)).toEqual(["c1", "c3"]);
});
it("parks the least recently reached-for window when they stop fitting", () => {
// Three open windows can't fit in 1000px at 420 apiece.
let wins = opened(3);
wins = restoreWindow(wins, "c1", tick());
wins = restoreWindow(wins, "c2", tick());
expect(openIds(wins)).toEqual(["c1", "c2", "c3"]);
const { items } = layoutDock(1000, 950, wins);
// c3 was activated first of the three, so it is the one drawn as a bar —
// its state is untouched, so it reopens when there is room again.
expect(items.filter((i) => i.open).map((i) => i.win.id)).toEqual(["c1", "c2"]);
expect(layoutDock(2400, 950, wins).items.every((i) => i.open)).toBe(true);
});
it("moves the oldest bars into the overflow menu when the row fills up", () => {
const { items, overflow } = layoutDock(1600, 950, opened(6));
expect(overflow).toBeGreaterThan(0);
expect(overflow).toBeLessThan(5); // some bars still on screen
expect(items.filter((i) => !i.open && !i.hidden)[0].width).toBeGreaterThanOrEqual(256);
});
it("never lets the row run off the left edge", () => {
for (const vw of [520, 760, 1024, 1280, 1600, 2560]) {
for (const n of [1, 2, 3, 6, 12]) {
let wins = opened(n);
// Worst case: the user has restored everything, so every window wants
// to be a full composer.
for (const w of wins) wins = restoreWindow(wins, w.id, tick());
const { items, overflow } = layoutDock(vw, 950, wins);
const visible = items.filter((i) => !i.hidden);
const chip = overflow > 0 ? 56 + 8 : 0;
const width =
chip + visible.reduce((n2, i) => n2 + i.width + 8, 0) - (visible.length ? 8 : 0);
expect(width, `vw=${vw} n=${n}`).toBeLessThanOrEqual(vw - 32);
}
}
});
it("an expanded window matches the reading pane", () => {
const wins = toggleExpand(opened(2), "c2", tick());
for (const paneW of [640, 980, 1240]) {
const { items } = layoutDock(1600, 950, wins, paneW);
expect(items.find((i) => i.win.id === "c2")?.width).toBe(paneW);
}
// Unmeasured (first paint, or no pane at all) falls back to a fixed width
// rather than collapsing the window to nothing.
expect(layoutDock(1600, 950, wins).items.find((i) => i.win.id === "c2")?.width).toBe(1112);
});
it("an expanded window is the rightmost thing on the row", () => {
// What the alignment in ComposeDock assumes: it slides the last item over
// to sit on the pane, which is only correct if nothing follows it.
for (const id of ["c1", "c2", "c3"]) {
const { items } = layoutDock(1600, 950, toggleExpand(opened(3), id, tick()), 900);
expect(items[items.length - 1].win.id, `expanded ${id}`).toBe(id);
expect(items[items.length - 1].win.expanded).toBe(true);
}
});
it("an expanded window still fits the row when the pane is wider than it", () => {
const wins = toggleExpand(opened(2), "c1", tick());
const { items } = layoutDock(900, 950, wins, 2000);
expect(items.find((i) => i.win.id === "c1")?.width).toBeLessThanOrEqual(900 - 32);
});
it("gives every open window a height, so a long body scrolls instead of growing", () => {
const { items } = layoutDock(1600, 950, opened(2));
expect(items.find((i) => i.open)?.height).toBe(520);
// A title bar is as tall as its own header; the dock doesn't size it.
expect(items.find((i) => !i.open)?.height).toBeNull();
});
it("an expanded window takes the viewport, less a sliver of desktop", () => {
const { items } = layoutDock(1600, 950, toggleExpand(opened(1), "c1", tick()));
expect(items[0].height).toBe(942);
});
it("never stands taller than the screen", () => {
for (const vh of [320, 480, 700, 950, 1440]) {
const plain = layoutDock(1600, vh, opened(1)).items[0].height ?? 0;
const big = layoutDock(1600, vh, toggleExpand(opened(1), "c1", tick())).items[0].height ?? 0;
expect(plain, `vh=${vh}`).toBeLessThanOrEqual(vh - 8);
expect(big, `vh=${vh}`).toBeLessThanOrEqual(vh - 8);
}
});
});