Public: anyone can read this; the crew can change it.

A board page stops growing with the square of the board: the per-card Move control gets a card page #113

Closed opened this
Closed without merging

scout/tempo:scout/48-a-board-page-stops-growing-with-the-squa → main 1 commit fork

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
8 files changed +240 −180
src/live-refresh.test.ts +6 −32
@@ -137,7 +137,7 @@ test("a live move restores focus to a keyboard-focused card instead of losing it
137137
138138 const card = document.querySelector(".card") as unknown as HTMLElement & { dataset: DOMStringMap };
139139 card.focus();
140 const cardId = card.dataset.cardId;
140 const cardId = card.id.slice(5);
141141
142142 const created = await post(`/b/${boardId}/columns/${columnId}/cards`, { title: "From elsewhere" });
143143 const { id: newCardId } = (await created.json()) as { id: string };
@@ -145,7 +145,7 @@ test("a live move restores focus to a keyboard-focused card instead of losing it
145145 await new Promise((resolve) => setTimeout(resolve, 20));
146146
147147 expect(document.querySelector('.card[data-title="From elsewhere"]')).not.toBeNull();
148 expect(document.querySelector(`[data-card-id="${cardId}"]`)).toBe(document.activeElement as unknown as Element);
148 expect(document.querySelector(`#card-${cardId}`)).toBe(document.activeElement as unknown as Element);
149149
150150 await window.close();
151151 });
@@ -187,48 +187,22 @@ test("this tab's own write is not re-applied when its echo comes back over the s
187187
188188 // A delete form has no named field, so submitting it in happy-dom doesn't
189189 // need the named-form-control access the edit/move forms rely on.
190 const deleteForm = document.querySelector(`.card[data-card-id="${keepId}"] .delete`)!;
190 const deleteForm = document.querySelector(`#card-${keepId} .delete`)!;
191191 deleteForm.dispatchEvent(new window.Event("submit", { bubbles: true, cancelable: true }));
192192 await new Promise((resolve) => setTimeout(resolve, 20));
193193 expect(clientId).toBeTruthy();
194 expect(document.querySelector(`.card[data-card-id="${keepId}"]`)).toBeNull();
194 expect(document.querySelector(`#card-${keepId}`)).toBeNull();
195195
196196 // This tab's own delete echoes back over the stream with this tab's own
197197 // client id; a second, unrelated card's deletion must still be ignored
198198 // when it wrongly carries that same id, and applied when it doesn't.
199199 deliver({ kind: "card.deleted", boardId, by: null, client: clientId, ids: { cardId: otherId } });
200200 await new Promise((resolve) => setTimeout(resolve, 20));
201 expect(document.querySelector(`.card[data-card-id="${otherId}"]`)).not.toBeNull();
201 expect(document.querySelector(`#card-${otherId}`)).not.toBeNull();
202202
203203 deliver({ kind: "card.deleted", boardId, by: null, client: "a-different-tab", ids: { cardId: otherId } });
204204 await new Promise((resolve) => setTimeout(resolve, 20));
205 expect(document.querySelector(`.card[data-card-id="${otherId}"]`)).toBeNull();
206
207 await window.close();
208 });
209
210 test("a column added or deleted elsewhere updates every card's Move to column dropdown, not just column.renamed", async () => {
211 const boardId = await newBoard();
212 const columnId = (await (await fetch(`${base}/b/${boardId}`)).text()).match(/data-column-id="([a-z0-9]{12})"/)![1];
213 await post(`/b/${boardId}/columns/${columnId}/cards`, { title: "Has a Move to dropdown" });
214 const { window, document } = await run(boardId);
215 const optionValues = () =>
216 Array.from(document.querySelectorAll("select[name=column_id]")).map((select) =>
217 Array.from((select as unknown as HTMLSelectElement).options).map((o) => o.value),
218 );
219
220 const created = await post(`/b/${boardId}/columns`, { name: "Blocked" });
221 const { id: newColumnId } = (await created.json()) as { id: string };
222 deliver({ kind: "column.added", boardId, by: null, client: "someone-else", ids: { columnId: newColumnId } });
223 await new Promise((resolve) => setTimeout(resolve, 20));
224 for (const values of optionValues()) expect(values).toContain(newColumnId);
225 const addedOption = document.querySelector(`select[name=column_id] option[value="${newColumnId}"]`)!;
226 expect(addedOption.textContent).toBe("Blocked");
227
228 await post(`/b/${boardId}/columns/${newColumnId}/delete`);
229 deliver({ kind: "column.deleted", boardId, by: null, client: "someone-else", ids: { columnId: newColumnId } });
230 await new Promise((resolve) => setTimeout(resolve, 20));
231 for (const values of optionValues()) expect(values).not.toContain(newColumnId);
205 expect(document.querySelector(`#card-${otherId}`)).toBeNull();
232206
233207 await window.close();
234208 });
src/pages/board.ts +16 −20
@@ -64,8 +64,9 @@ const styles = `
6464 .card.dragging { opacity: .4; }
6565 .card .edit input { width: 100%; border: 0; background: transparent; padding: .2rem; }
6666 .card .row { display: flex; flex-wrap: wrap; gap: .25rem; margin-top: .3rem; align-items: center; }
67 .card .row select { flex: 1; font-size: .8rem; padding: .4rem; min-height: 32px; }
68 .card .row button { padding: .4rem .6rem; font-size: .8rem; min-height: 32px; min-width: 32px; }
67 .card .row a, .card .row button { padding: .4rem .6rem; font-size: .8rem; min-height: 32px; min-width: 32px;
68 display: inline-flex; align-items: center; justify-content: center; text-decoration: none; }
69 .card .row a { border: 1px solid #8884; border-radius: .4rem; color: inherit; }
6970 .add-card { display: flex; gap: .3rem; margin-top: .5rem; }
7071 .add-card input { flex: 1; }
7172 .new-column { flex: none; width: 14rem; }
@@ -106,7 +107,7 @@ const shortcutsDialog = (extra: [string, string][]): string => {
106107 // that a plain form navigation just hit. It renders straight into the
107108 // toast, already shown — no JavaScript required to see why the action
108109 // didn't happen. static/board.js's own toast calls reuse the same element.
109 const shell = (title: string, body: string, shortcuts: [string, string][] = [], error?: string): string => `<!doctype html>
110 export const shell = (title: string, body: string, shortcuts: [string, string][] = [], error?: string): string => `<!doctype html>
110111 <meta charset="utf-8">
111112 <script src="/static/${THEME_JS}"></script>
112113 <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -126,7 +127,7 @@ ${shortcutsDialog(shortcuts)}
126127 // is asking comes back on every later request as a signed header (who.ts).
127128 // The name is wrapped and truncated with an ellipsis (full value in a
128129 // title attribute) so a long forge display name can't overprint the page.
129 function doorLink(who: Who, path: string): string {
130 export function doorLink(who: Who, path: string): string {
130131 if (who.role === "anon") return `<span class="door"><a class="ghost" href="/__forge/signin?to=${encodeURIComponent(path)}">Sign in</a></span>`;
131132 const name = escape(who.name);
132133 return `<span class="door"><span class="who"><span class="name" title="${name}">${name}</span>${who.role === "admin" ? " · admin" : ""}</span><a class="ghost" href="/__forge/signout?to=${encodeURIComponent(path)}">Sign out</a></span>`;
@@ -183,25 +184,20 @@ export function boardPage({ board, columns, cards, query, who, error }: BoardDat
183184 const matches = (c: Card) => q === "" || c.title.toLowerCase().includes(q);
184185 const cardsFor = (columnId: string) => cards.filter((c) => c.column_id === columnId);
185186
186 const columnOptions = (currentId: string) =>
187 columns
188 .map((c) => `<option value="${escape(c.id)}"${c.id === currentId ? " selected" : ""}>${escape(c.name)}</option>`)
189 .join("");
190
191 const cardHtml = (c: Card) => `<li class="card" draggable="true" tabindex="0" data-card-id="${escape(c.id)}"
192 data-column-id="${escape(c.column_id)}" data-title="${escape(c.title)}"${matches(c) ? "" : " hidden"}>
187 // A card's id lives only in its element id ("card-<id>") — the id a
188 // card-page redirect's #fragment lands on (routes.ts) — rather than
189 // duplicated into a data attribute too; static/board.js reads it back
190 // with the same "card-" prefix stripped (idOf). Likewise, which column
191 // a card is in is always just which .cards list it's sitting in — the
192 // DOM position itself, not a second attribute that would need updating
193 // every time a card moves.
194 const cardHtml = (c: Card) => `<li class="card" id="card-${escape(c.id)}" draggable="true" tabindex="0"
195 data-title="${escape(c.title)}"${matches(c) ? "" : " hidden"}>
193196 <form class="edit" method="post" action="/b/${board.id}/cards/${c.id}">
194197 <input name="title" value="${escape(c.title)}" maxlength="200" required aria-label="Card title">
195198 </form>
196199 <div class="row">
197 <form class="move" method="post" action="/b/${board.id}/cards/${c.id}/move">
198 <select name="column_id" aria-label="Move to column">${columnOptions(c.column_id)}</select>
199 <select name="index" aria-label="Position in column">
200 <option value="0">Top</option>
201 <option value="999999" selected>Bottom</option>
202 </select>
203 <button>Move</button>
204 </form>
200 <a href="/b/${board.id}/cards/${c.id}">Move</a>
205201 <form class="delete" method="post" action="/b/${board.id}/cards/${c.id}/delete">
206202 <button aria-label="Delete card">✕</button>
207203 </form>
@@ -289,6 +285,6 @@ const boardShortcuts: [string, string][] = [
289285
290286 // Named by content — a change to static/board.js gets a new hash here,
291287 // so the year-long cache on /static/ never serves a stale script.
292 export const BOARD_JS = "board.822340210.js";
288 export const BOARD_JS = "board.506724103.js";
293289 export const THEME_JS = "theme.1025564933.js";
294290 export const BOARDS_JS = "boards.608180649.js";
src/pages/card.test.ts added +42
@@ -0,0 +1,42 @@
1 import { expect, test } from "bun:test";
2 import { ANON } from "../who.ts";
3 import { cardPage } from "./card.ts";
4
5 const board = { id: "abcd1234", name: "Board", owner_id: null, owner_name: null, created_at: "" };
6 const columns = [
7 { id: "aaaaaaaaaaaa", board_id: board.id, name: "To do", position: 1 },
8 { id: "bbbbbbbbbbbb", board_id: board.id, name: "Doing", position: 2 },
9 ];
10 const card = {
11 id: "cccccccccccc",
12 board_id: board.id,
13 column_id: "aaaaaaaaaaaa",
14 title: "Write the README",
15 position: 1,
16 created_at: "",
17 updated_at: "",
18 };
19
20 test("cardPage shows the title, its column, and a Move form listing every column once", () => {
21 const shown = cardPage({ board, columns, card, who: ANON });
22 expect(shown).toContain("Write the README");
23 expect(shown).toContain("To do");
24 expect(shown).toContain(`action="/b/${board.id}/cards/${card.id}/move"`);
25 expect(shown).toContain(`<option value="${columns[0].id}" selected>To do</option>`);
26 expect(shown).toContain(`<option value="${columns[1].id}">Doing</option>`);
27 });
28
29 test("cardPage escapes the title and every column name", () => {
30 const dirty = { ...card, title: "<script>bad</script>" };
31 const dirtyColumns = [{ ...columns[0], name: "<img onerror=bad>" }];
32 const shown = cardPage({ board, columns: dirtyColumns, card: dirty, who: ANON });
33 expect(shown).not.toContain("<script>bad</script>");
34 expect(shown).not.toContain("<img onerror=bad>");
35 expect(shown).toContain("&lt;script&gt;bad&lt;/script&gt;");
36 });
37
38 test("cardPage's refusal message is shown, already, like the board page's own", () => {
39 const shown = cardPage({ board, columns, card, who: ANON, error: "column_id is required" });
40 expect(shown).toContain('id="toast" role="status" aria-live="polite" class="show"');
41 expect(shown).toContain("column_id is required");
42 });
src/pages/card.ts added +47
@@ -0,0 +1,47 @@
1 /**
2 * HTML for one card's own page — the Move control lives here instead of
3 * once per card on the board, so a board's page size stops growing with
4 * columns × cards (see routes.ts). Every form here works with JavaScript
5 * off: a plain POST and a 303 back to the board, landed on this card.
6 */
7
8 import type { Board, Card, Column } from "../db.ts";
9 import type { Who } from "../who.ts";
10 import { doorLink, escape, shell } from "./board.ts";
11
12 export type CardPageData = { board: Board; columns: Column[]; card: Card; who: Who; error?: string };
13
14 const cardShortcuts: [string, string][] = [["Enter", "Save the title"]];
15
16 export function cardPage({ board, columns, card, who, error }: CardPageData): string {
17 const column = columns.find((c) => c.id === card.column_id);
18 const columnOptions = columns
19 .map((c) => `<option value="${escape(c.id)}"${c.id === card.column_id ? " selected" : ""}>${escape(c.name)}</option>`)
20 .join("");
21
22 return shell(
23 card.title,
24 `<header class="top">
25 <h1>tempo</h1>
26 ${doorLink(who, `/b/${board.id}/cards/${card.id}`)}
27 </header>
28 <p class="hint"><a href="/b/${board.id}#card-${card.id}">&larr; ${escape(board.name)}</a></p>
29 <form class="edit" method="post" action="/b/${board.id}/cards/${card.id}">
30 <input name="title" value="${escape(card.title)}" maxlength="200" required aria-label="Card title" autofocus>
31 </form>
32 <p class="hint">In <strong>${escape(column?.name ?? "")}</strong></p>
33 <form class="toolbar" method="post" action="/b/${board.id}/cards/${card.id}/move">
34 <select name="column_id" aria-label="Move to column">${columnOptions}</select>
35 <select name="index" aria-label="Position in column">
36 <option value="0">Top</option>
37 <option value="999999" selected>Bottom</option>
38 </select>
39 <button>Move</button>
40 </form>
41 <form method="post" action="/b/${board.id}/cards/${card.id}/delete">
42 <button class="ghost" aria-label="Delete card">Delete card</button>
43 </form>`,
44 cardShortcuts,
45 error,
46 );
47 }
src/routes.ts +40 −11
@@ -10,6 +10,7 @@ import * as db from "./db.ts";
1010 import { boardCreationBucket, MAX_CARDS_PER_BOARD, MAX_COLUMNS_PER_BOARD, MAX_SUBSCRIBERS_PER_BOARD } from "./limits.ts";
1111 import { rpc } from "./mcp.ts";
1212 import { boardPage, landingPage } from "./pages/board.ts";
13 import { cardPage } from "./pages/card.ts";
1314 import type { Who } from "./who.ts";
1415
1516 const BOARD_ID = /^[a-z0-9]{8}$/;
@@ -66,9 +67,12 @@ class HttpError extends Error {
6667 function wantsJson(request: Request): boolean {
6768 return (request.headers.get("accept") ?? "").includes("application/json");
6869 }
69 function done(request: Request, boardId: string, ok: boolean, extra: Record<string, unknown> = {}): Response {
70 // `anchor`, when given, is a fragment (e.g. `card-<id>`) so a plain-form
71 // POST from a card's own page (routes.ts's cardPage handlers) lands the
72 // browser back on that same card in the board, not just at its top.
73 function done(request: Request, boardId: string, ok: boolean, extra: Record<string, unknown> = {}, anchor?: string): Response {
7074 if (wantsJson(request)) return ok ? json({ ok, ...extra }) : json({ ok }, 404);
71 return ok ? Response.redirect(`/b/${boardId}`, 303) : new Response("not found", { status: 404 });
75 return ok ? Response.redirect(`/b/${boardId}${anchor ? `#${anchor}` : ""}`, 303) : new Response("not found", { status: 404 });
7276 }
7377 // Every other page on this board hears about a write that actually happened.
7478 function donePublish(
@@ -79,9 +83,10 @@ function donePublish(
7983 kind: EventKind,
8084 ids: Record<string, string> = {},
8185 extra: Record<string, unknown> = {},
86 anchor?: string,
8287 ): Response {
8388 if (ok) publish(boardId, request, who, kind, ids);
84 return done(request, boardId, ok, extra);
89 return done(request, boardId, ok, extra, anchor);
8590 }
8691
8792 // A rejected form field or a limit hit on a board (a full column list, a
@@ -101,6 +106,17 @@ function boardBad(request: Request, who: Who, boardId: string, message: string,
101106 );
102107 }
103108
109 // The Move form lives only on a card's own page now, so its refusals
110 // (an unrecognised column, a bad index) land back on that page, the same
111 // way boardBad lands a board-level refusal back on the board.
112 function cardBad(request: Request, who: Who, boardId: string, cardId: string, message: string, status = 400): Response {
113 if (wantsJson(request)) return json({ error: message }, status);
114 const board = db.getBoard(boardId);
115 const card = board && db.getCard(boardId, cardId);
116 if (!board || !card) return new Response(message, { status });
117 return html(cardPage({ board, columns: db.listColumns(boardId), card, who, error: message }), status);
118 }
119
104120 function cleanText(value: FormDataEntryValue | null, max: number): string | null {
105121 const text = String(value ?? "").trim().slice(0, max);
106122 return text === "" ? null : text;
@@ -311,28 +327,41 @@ async function dispatch(request: Request, url: URL, env: string, who: Who): Prom
311327 if (parts.length >= 4 && parts[2] === "cards") {
312328 const cardId = parts[3];
313329 if (!ENTITY_ID.test(cardId)) return undefined;
330 const anchor = `card-${cardId}`;
331
332 if (method === "GET" && parts.length === 4) {
333 const card = db.getCard(boardId, cardId);
334 if (!card) return new Response("card not found", { status: 404 });
335 return html(cardPage({ board: db.getBoard(boardId)!, columns: db.listColumns(boardId), card, who }));
336 }
314337
315338 if (method === "POST" && parts.length === 4) {
316339 const data = await form(request);
317340 const title = cleanText(data.get("title"), 200);
318341 if (!title) return boardBad(request, who, boardId, "a card needs a title");
319 return donePublish(request, who, boardId, db.editCard(boardId, cardId, title), "card.edited", { cardId });
342 return donePublish(request, who, boardId, db.editCard(boardId, cardId, title), "card.edited", { cardId }, {}, anchor);
320343 }
321344
322345 if (method === "POST" && parts.length === 5 && parts[4] === "move") {
323346 const data = await form(request);
324347 const columnId = String(data.get("column_id") ?? "");
325348 const index = Number(data.get("index"));
326 if (!ENTITY_ID.test(columnId)) return boardBad(request, who, boardId, "column_id is required");
327 if (!Number.isFinite(index) || index < 0) return boardBad(request, who, boardId, "index must be a non-negative number");
328 return donePublish(request, who, boardId, db.moveCard(boardId, cardId, columnId, Math.floor(index)), "card.moved", {
329 cardId,
330 columnId,
331 });
349 if (!ENTITY_ID.test(columnId)) return cardBad(request, who, boardId, cardId, "column_id is required");
350 if (!Number.isFinite(index) || index < 0) return cardBad(request, who, boardId, cardId, "index must be a non-negative number");
351 return donePublish(
352 request,
353 who,
354 boardId,
355 db.moveCard(boardId, cardId, columnId, Math.floor(index)),
356 "card.moved",
357 { cardId, columnId },
358 {},
359 anchor,
360 );
332361 }
333362
334363 if (method === "POST" && parts.length === 5 && parts[4] === "delete") {
335 return donePublish(request, who, boardId, db.deleteCard(boardId, cardId), "card.deleted", { cardId });
364 return donePublish(request, who, boardId, db.deleteCard(boardId, cardId), "card.deleted", { cardId }, {}, anchor);
336365 }
337366 }
338367
src/server.test.ts +49 −1
@@ -90,7 +90,7 @@ test("adds, moves, edits and deletes a card, and undo reverses the last change",
9090 const moved = await post(`${boardUrl}/cards/${cardId}/move`, { column_id: doingId, index: "0" });
9191 expect(moved.status).toBe(200);
9292 const afterMove = await (await fetch(base + boardUrl)).text();
93 expect(afterMove).toMatch(new RegExp(`data-column-id="${doingId}"[\\s\\S]*data-card-id="${cardId}"`));
93 expect(afterMove).toMatch(new RegExp(`data-column-id="${doingId}"[\\s\\S]*id="card-${cardId}"`));
9494
9595 const edited = await post(`${boardUrl}/cards/${cardId}`, { title: "Ship the README" });
9696 expect(edited.status).toBe(200);
@@ -234,6 +234,54 @@ test("cannot move a card into another board's column", async () => {
234234 expect(response.status).toBe(404);
235235 });
236236
237 test("a card's own page renders its title, its column, and a working Move form", async () => {
238 const id = db.createBoard("Card page test").id; // see the note above on why not POST /boards
239 const boardUrl = `/b/${id}`;
240 const [todoId, doingId] = db.listColumns(id).map((c) => c.id);
241 const card = db.createCard(id, todoId, "Write the README");
242 if (card === "full" || card === "not_found") throw new Error("unreachable");
243
244 const cardPageUrl = `${boardUrl}/cards/${card.id}`;
245 const rendered = await (await fetch(base + cardPageUrl)).text();
246 expect(rendered).toContain("Write the README");
247 expect(rendered).toContain("To do");
248 expect(rendered).toContain(`action="${boardUrl}/cards/${card.id}/move"`);
249
250 const moved = await fetch(base + `${cardPageUrl}/move`, {
251 method: "POST",
252 body: new URLSearchParams({ column_id: doingId, index: "0" }),
253 redirect: "manual",
254 });
255 expect(moved.status).toBe(303);
256 expect(moved.headers.get("location")).toBe(`${boardUrl}#card-${card.id}`);
257 const afterMove = await (await fetch(base + boardUrl)).text();
258 expect(afterMove).toMatch(new RegExp(`data-column-id="${doingId}"[\\s\\S]*id="card-${card.id}"`));
259 });
260
261 test("a card page 404s for a card id from another board, and for an unknown card id", async () => {
262 const a = db.createBoard("Board A").id; // see the note above on why not POST /boards
263 const b = db.createBoard("Board B").id;
264 const columnA = db.listColumns(a)[0].id;
265 const card = db.createCard(a, columnA, "Card A");
266 if (card === "full" || card === "not_found") throw new Error("unreachable");
267
268 expect((await fetch(base + `/b/${b}/cards/${card.id}`)).status).toBe(404);
269 expect((await fetch(base + `/b/${a}/cards/zzzzzzzzzzzz`)).status).toBe(404);
270 });
271
272 test("a board's rendered page stays well under a per-card byte budget as columns and cards grow", async () => {
273 const id = db.createBoard("Byte budget test").id; // see the note above on why not POST /boards
274 const boardUrl = `/b/${id}`;
275 const columnId = db.listColumns(id)[0].id;
276 for (let i = 0; i < 20; i++) db.createCard(id, columnId, `Card number ${i}`);
277
278 const bytes = new TextEncoder().encode(await (await fetch(base + boardUrl)).text()).length;
279 // 3 columns × 20 cards: well under the ~350 bytes/card this change
280 // targets (a select-per-column Move form grew with column count; a
281 // single link to the card's own page does not).
282 expect(bytes).toBeLessThan(20_000);
283 });
284
237285 test("a guest sees a Sign in link, never a name", async () => {
238286 const page = await (await fetch(base + "/")).text();
239287 expect(page).toContain("Sign in");
src/touch.test.ts +4 −42
@@ -163,45 +163,7 @@ test("a mouse pointer sequence posts nothing — the mouse keeps using the HTML5
163163 await window.close();
164164 });
165165
166 test("the move form's Top control reorders a card above its column's existing cards", async () => {
167 await cardTitled("Card-Top-A");
168 await cardTitled("Card-Top-B");
169 const { window, document } = await load(board.id);
170
171 const second = document.querySelector(`[data-title="Card-Top-B"]`) as unknown as HTMLElement;
172 const moveForm = second.querySelector("form.move") as unknown as HTMLFormElement & {
173 column_id: HTMLSelectElement;
174 index: HTMLSelectElement;
175 };
176 moveForm.column_id.value = board.columns[0];
177 moveForm.index.value = "0";
178 moveForm.dispatchEvent(new window.Event("submit", { bubbles: true, cancelable: true }) as unknown as never);
179
180 await new Promise((resolve) => setTimeout(resolve, 20));
181 const page = await (await fetch(`${base}/b/${board.id}`)).text();
182 expect(page).toMatch(
183 new RegExp(`data-column-id="${board.columns[0]}"[\\s\\S]*data-title="Card-Top-B"[\\s\\S]*data-title="Card-Top-A"`),
184 );
185
186 await window.close();
187 });
188
189 test("the move form's Bottom control posts the end of the target column, unlike Top's index 0", async () => {
190 await cardTitled("Card-Bottom");
191 const { window, document } = await load(board.id);
192
193 const card = document.querySelector(`[data-title="Card-Bottom"]`) as unknown as HTMLElement;
194 const moveForm = card.querySelector("form.move") as unknown as HTMLFormElement & {
195 column_id: HTMLSelectElement;
196 index: HTMLSelectElement;
197 };
198 moveForm.column_id.value = board.columns[1];
199 moveForm.index.value = "999999";
200 moveForm.dispatchEvent(new window.Event("submit", { bubbles: true, cancelable: true }) as unknown as never);
201
202 await new Promise((resolve) => setTimeout(resolve, 20));
203 const page = await (await fetch(`${base}/b/${board.id}`)).text();
204 expect(page).toMatch(new RegExp(`data-column-id="${board.columns[1]}"[\\s\\S]*data-title="Card-Bottom"`));
205
206 await window.close();
207 });
166 // The Move form itself (its Top/Bottom control included) now lives on the
167 // card's own page, a plain server-rendered form with no board.js
168 // enhancement — see src/pages/card.test.ts and the card-page tests in
169 // server.test.ts for its coverage.
static/board.822340210.js → static/board.506724103.js renamed +36 −74
@@ -39,7 +39,11 @@
3939 const indexOf = (cardEl) => cardsOf(cardEl.closest(".column")).indexOf(cardEl);
4040 const columnsList = () => Array.from(root.querySelectorAll(".column"));
4141 const columnEl = (id) => root.querySelector(`.column[data-column-id="${CSS.escape(id)}"]`);
42 const cardEl = (id) => root.querySelector(`.card[data-card-id="${CSS.escape(id)}"]`);
42 // A card's id is carried only in its element id ("card-<id>") — not
43 // duplicated into a data attribute too, which would cost every card on
44 // the board the same bytes again for no reader that isn't already here.
45 const idOf = (card) => card.id.slice(5);
46 const cardEl = (id) => root.querySelector(`#card-${CSS.escape(id)}`);
4347
4448 function updateCounts(...columns) {
4549 for (const col of columns.length ? columns : columnsList()) {
@@ -71,15 +75,12 @@
7175 const columnEl = cardEl.closest(".column");
7276 const toColumnId = columnEl.dataset.columnId;
7377 const index = indexOf(cardEl);
74 cardEl.dataset.columnId = toColumnId;
75 for (const select of cardEl.querySelectorAll("select[name=column_id]")) select.value = toColumnId;
7678 updateCounts();
77 post(`/b/${boardId}/cards/${cardEl.dataset.cardId}/move`, { column_id: toColumnId, index: String(index) }).then(
79 post(`/b/${boardId}/cards/${idOf(cardEl)}/move`, { column_id: toColumnId, index: String(index) }).then(
7880 (r) => {
7981 if (r.ok) return;
8082 const fromColumn = root.querySelector(`.column[data-column-id="${CSS.escape(fromColumnId)}"] .cards`);
8183 if (fromColumn) fromColumn.appendChild(cardEl);
82 cardEl.dataset.columnId = fromColumnId;
8384 updateCounts();
8485 showError("Could not move the card.");
8586 },
@@ -105,7 +106,7 @@
105106 root.addEventListener("dragstart", (e) => {
106107 const card = e.target.closest(".card");
107108 if (!card) return;
108 dragging = { el: card, from: card.dataset.columnId };
109 dragging = { el: card, from: card.closest(".column").dataset.columnId };
109110 card.classList.add("dragging");
110111 e.dataTransfer.effectAllowed = "move";
111112 });
@@ -190,7 +191,15 @@
190191 // ordinary swipe still scrolls the board, same as before this held.
191192 card.style.touchAction = "none";
192193 }, LONG_PRESS_MS);
193 press = { el: card, from: card.dataset.columnId, pointerId: e.pointerId, startX: e.clientX, startY: e.clientY, timer, active: false };
194 press = {
195 el: card,
196 from: card.closest(".column").dataset.columnId,
197 pointerId: e.pointerId,
198 startX: e.clientX,
199 startY: e.clientY,
200 timer,
201 active: false,
202 };
194203 });
195204
196205 root.addEventListener("pointermove", (e) => {
@@ -227,7 +236,7 @@
227236 if (!card || e.target !== card) return;
228237 if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) return;
229238 e.preventDefault();
230 const fromColumnId = card.dataset.columnId;
239 const fromColumnId = card.closest(".column").dataset.columnId;
231240 if (e.key === "ArrowUp" || e.key === "ArrowDown") {
232241 const siblings = cardsOf(card.closest(".column"));
233242 const i = siblings.indexOf(card);
@@ -260,7 +269,7 @@
260269 const previous = card.dataset.title;
261270 card.dataset.title = title;
262271 if (filterInput) applyFilter();
263 post(`/b/${boardId}/cards/${card.dataset.cardId}`, { title }).then((r) => {
272 post(`/b/${boardId}/cards/${idOf(card)}`, { title }).then((r) => {
264273 if (r.ok) return;
265274 card.dataset.title = previous;
266275 form.title.value = previous;
@@ -270,21 +279,9 @@
270279 return;
271280 }
272281
273 if (form.classList.contains("move") && card) {
274 e.preventDefault();
275 const fromColumnId = card.dataset.columnId;
276 const target = root.querySelector(`.column[data-column-id="${CSS.escape(form.column_id.value)}"]`);
277 if (!target) return;
278 const list = target.querySelector(".cards");
279 if (form.index.value === "0") list.insertBefore(card, list.firstChild);
280 else list.appendChild(card);
281 commitMove(card, fromColumnId);
282 return;
283 }
284
285282 if (form.classList.contains("delete") && card) {
286283 e.preventDefault();
287 const cardId = card.dataset.cardId;
284 const cardId = idOf(card);
288285 const parent = card.closest(".cards");
289286 const next = card.nextSibling;
290287 card.remove();
@@ -306,15 +303,16 @@
306303 const columnEl = form.closest(".column");
307304 const columnId = columnEl.dataset.columnId;
308305 const tempId = `temp-${Math.random().toString(36).slice(2)}`;
309 const li = buildCard(tempId, title, columnId);
306 const li = buildCard(tempId, title);
310307 columnEl.querySelector(".cards").appendChild(li);
311308 updateCounts();
312309 post(`/b/${boardId}/columns/${columnId}/cards`, { title })
313310 .then(async (r) => {
314311 const data = await r.json();
315312 if (!r.ok) throw new Error(data.error || "Could not add the card.");
316 li.dataset.cardId = data.id;
313 li.id = `card-${data.id}`;
317314 for (const el of li.querySelectorAll("[action]")) el.action = el.action.replace(tempId, data.id);
315 for (const el of li.querySelectorAll("[href]")) el.href = el.href.replace(tempId, data.id);
318316 })
319317 .catch((err) => {
320318 li.remove();
@@ -324,13 +322,12 @@
324322 }
325323 });
326324
327 function buildCard(id, title, columnId) {
325 function buildCard(id, title) {
328326 const li = document.createElement("li");
329327 li.className = "card";
328 li.id = `card-${id}`;
330329 li.draggable = true;
331330 li.tabIndex = 0;
332 li.dataset.cardId = id;
333 li.dataset.columnId = columnId;
334331 li.dataset.title = title;
335332
336333 const editForm = document.createElement("form");
@@ -348,34 +345,9 @@
348345 const row = document.createElement("div");
349346 row.className = "row";
350347
351 const moveForm = document.createElement("form");
352 moveForm.className = "move";
353 moveForm.method = "post";
354 moveForm.action = `/b/${boardId}/cards/${id}/move`;
355 const select = document.createElement("select");
356 select.name = "column_id";
357 select.setAttribute("aria-label", "Move to column");
358 for (const col of columnsList()) {
359 const opt = document.createElement("option");
360 opt.value = col.dataset.columnId;
361 opt.textContent = col.querySelector("input[name=name]").value;
362 if (col.dataset.columnId === columnId) opt.selected = true;
363 select.appendChild(opt);
364 }
365 const indexSelect = document.createElement("select");
366 indexSelect.name = "index";
367 indexSelect.setAttribute("aria-label", "Position in column");
368 const top = document.createElement("option");
369 top.value = "0";
370 top.textContent = "Top";
371 const bottom = document.createElement("option");
372 bottom.value = "999999";
373 bottom.textContent = "Bottom";
374 bottom.selected = true;
375 indexSelect.append(top, bottom);
376 const moveBtn = document.createElement("button");
377 moveBtn.textContent = "Move";
378 moveForm.append(select, indexSelect, moveBtn);
348 const moveLink = document.createElement("a");
349 moveLink.href = `/b/${boardId}/cards/${id}`;
350 moveLink.textContent = "Move";
379351
380352 const deleteForm = document.createElement("form");
381353 deleteForm.className = "delete";
@@ -386,7 +358,7 @@
386358 delBtn.textContent = "✕";
387359 deleteForm.appendChild(delBtn);
388360
389 row.append(moveForm, deleteForm);
361 row.append(moveLink, deleteForm);
390362 li.append(editForm, row);
391363 return li;
392364 }
@@ -447,7 +419,7 @@
447419 function apply(event) {
448420 if (
449421 (event.kind === "card.edited" && isEditingCard(event.ids.cardId)) ||
450 (event.kind === "card.moved" && dragging && dragging.el.dataset.cardId === event.ids.cardId) ||
422 (event.kind === "card.moved" && dragging && idOf(dragging.el) === event.ids.cardId) ||
451423 (event.kind === "column.renamed" && isRenamingColumn(event.ids.columnId)) ||
452424 (event.kind === "board.renamed" && isRenamingBoard()) ||
453425 (event.kind === "undo" && (dragging || isTypingSomewhere()))
@@ -477,7 +449,7 @@
477449 const current = root.querySelector(".board");
478450 if (!next || !current) return;
479451 const focused = root.querySelector(".board :focus");
480 const focusedCardId = focused && focused.classList.contains("card") ? focused.dataset.cardId : null;
452 const focusedCardId = focused && focused.classList.contains("card") ? idOf(focused) : null;
481453 current.replaceWith(document.importNode(next, true));
482454 for (const list of root.querySelectorAll(".cards")) attachDropListenersTo(list);
483455 if (focusedCardId) cardEl(focusedCardId)?.focus();
@@ -497,7 +469,6 @@
497469 }
498470 if (kind === "column.deleted") {
499471 columnEl(ids.columnId)?.remove();
500 for (const opt of root.querySelectorAll(`select[name=column_id] option[value="${CSS.escape(ids.columnId)}"]`)) opt.remove();
501472 return;
502473 }
503474 if (kind === "undo") return reconcile();
@@ -513,14 +484,15 @@
513484 }
514485
515486 if (kind === "card.added" || kind === "card.moved") {
516 const freshCard = fresh.querySelector(`.card[data-card-id="${CSS.escape(ids.cardId)}"]`);
487 const freshCard = fresh.querySelector(`#card-${CSS.escape(ids.cardId)}`);
517488 if (!freshCard) return;
518 const targetColumn = columnEl(freshCard.dataset.columnId);
489 const freshColumnId = freshCard.closest(".column").dataset.columnId;
490 const targetColumn = columnEl(freshColumnId);
519491 if (!targetColumn) return;
520492 const previousColumn = cardEl(ids.cardId)?.closest(".column");
521493 cardEl(ids.cardId)?.remove();
522 const freshSiblings = Array.from(fresh.querySelectorAll(`.column[data-column-id="${CSS.escape(freshCard.dataset.columnId)}"] .card`));
523 const at = freshSiblings.findIndex((c) => c.dataset.cardId === ids.cardId);
494 const freshSiblings = Array.from(fresh.querySelectorAll(`.column[data-column-id="${CSS.escape(freshColumnId)}"] .card`));
495 const at = freshSiblings.findIndex((c) => idOf(c) === ids.cardId);
524496 const liveSiblings = cardsOf(targetColumn);
525497 targetColumn.querySelector(".cards").insertBefore(document.importNode(freshCard, true), liveSiblings[at] || null);
526498 updateCounts(targetColumn, previousColumn);
@@ -530,7 +502,7 @@
530502
531503 if (kind === "card.edited") {
532504 const card = cardEl(ids.cardId);
533 const freshCard = fresh.querySelector(`.card[data-card-id="${CSS.escape(ids.cardId)}"]`);
505 const freshCard = fresh.querySelector(`#card-${CSS.escape(ids.cardId)}`);
534506 if (!card || !freshCard) return;
535507 card.dataset.title = freshCard.dataset.title;
536508 const input = card.querySelector(".edit input");
@@ -549,15 +521,6 @@
549521 if (!node) {
550522 node = document.importNode(freshColumn, true);
551523 attachDropListenersTo(node.querySelector(".cards"));
552 const nameInput = freshColumn.querySelector("input[name=name]");
553 if (nameInput) {
554 for (const select of root.querySelectorAll("select[name=column_id]")) {
555 const opt = document.createElement("option");
556 opt.value = ids.columnId;
557 opt.textContent = nameInput.value;
558 select.appendChild(opt);
559 }
560 }
561524 }
562525 const live = columnsList().filter((c) => c !== node);
563526 board.insertBefore(node, live[at] || board.querySelector(".new-column"));
@@ -573,7 +536,6 @@
573536 const name = freshInput.value;
574537 const input = col.querySelector("header form.rename input");
575538 if (input) input.value = name;
576 for (const opt of root.querySelectorAll(`select[name=column_id] option[value="${CSS.escape(ids.columnId)}"]`)) opt.textContent = name;
577539 return;
578540 }
579541 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs