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
scout/tempo:scout/48-a-board-page-stops-growing-with-the-squa → main 1 commit fork
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 | ||
| 137 | 137 | |
| 138 | 138 | const card = document.querySelector(".card") as unknown as HTMLElement & { dataset: DOMStringMap }; |
| 139 | 139 | card.focus(); |
| 140 | const cardId = card.dataset.cardId; | |
| 140 | const cardId = card.id.slice(5); | |
| 141 | 141 | |
| 142 | 142 | const created = await post(`/b/${boardId}/columns/${columnId}/cards`, { title: "From elsewhere" }); |
| 143 | 143 | 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 | ||
| 145 | 145 | await new Promise((resolve) => setTimeout(resolve, 20)); |
| 146 | 146 | |
| 147 | 147 | 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); | |
| 149 | 149 | |
| 150 | 150 | await window.close(); |
| 151 | 151 | }); |
| @@ -187,48 +187,22 @@ test("this tab's own write is not re-applied when its echo comes back over the s | ||
| 187 | 187 | |
| 188 | 188 | // A delete form has no named field, so submitting it in happy-dom doesn't |
| 189 | 189 | // 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`)!; | |
| 191 | 191 | deleteForm.dispatchEvent(new window.Event("submit", { bubbles: true, cancelable: true })); |
| 192 | 192 | await new Promise((resolve) => setTimeout(resolve, 20)); |
| 193 | 193 | expect(clientId).toBeTruthy(); |
| 194 | expect(document.querySelector(`.card[data-card-id="${keepId}"]`)).toBeNull(); | |
| 194 | expect(document.querySelector(`#card-${keepId}`)).toBeNull(); | |
| 195 | 195 | |
| 196 | 196 | // This tab's own delete echoes back over the stream with this tab's own |
| 197 | 197 | // client id; a second, unrelated card's deletion must still be ignored |
| 198 | 198 | // when it wrongly carries that same id, and applied when it doesn't. |
| 199 | 199 | deliver({ kind: "card.deleted", boardId, by: null, client: clientId, ids: { cardId: otherId } }); |
| 200 | 200 | 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(); | |
| 202 | 202 | |
| 203 | 203 | deliver({ kind: "card.deleted", boardId, by: null, client: "a-different-tab", ids: { cardId: otherId } }); |
| 204 | 204 | 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(); | |
| 232 | 206 | |
| 233 | 207 | await window.close(); |
| 234 | 208 | }); |
src/pages/board.ts +16 −20
| @@ -64,8 +64,9 @@ const styles = ` | ||
| 64 | 64 | .card.dragging { opacity: .4; } |
| 65 | 65 | .card .edit input { width: 100%; border: 0; background: transparent; padding: .2rem; } |
| 66 | 66 | .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; } | |
| 69 | 70 | .add-card { display: flex; gap: .3rem; margin-top: .5rem; } |
| 70 | 71 | .add-card input { flex: 1; } |
| 71 | 72 | .new-column { flex: none; width: 14rem; } |
| @@ -106,7 +107,7 @@ const shortcutsDialog = (extra: [string, string][]): string => { | ||
| 106 | 107 | // that a plain form navigation just hit. It renders straight into the |
| 107 | 108 | // toast, already shown — no JavaScript required to see why the action |
| 108 | 109 | // 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> | |
| 110 | 111 | <meta charset="utf-8"> |
| 111 | 112 | <script src="/static/${THEME_JS}"></script> |
| 112 | 113 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| @@ -126,7 +127,7 @@ ${shortcutsDialog(shortcuts)} | ||
| 126 | 127 | // is asking comes back on every later request as a signed header (who.ts). |
| 127 | 128 | // The name is wrapped and truncated with an ellipsis (full value in a |
| 128 | 129 | // 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 { | |
| 130 | 131 | if (who.role === "anon") return `<span class="door"><a class="ghost" href="/__forge/signin?to=${encodeURIComponent(path)}">Sign in</a></span>`; |
| 131 | 132 | const name = escape(who.name); |
| 132 | 133 | 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 | ||
| 183 | 184 | const matches = (c: Card) => q === "" || c.title.toLowerCase().includes(q); |
| 184 | 185 | const cardsFor = (columnId: string) => cards.filter((c) => c.column_id === columnId); |
| 185 | 186 | |
| 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"}> | |
| 193 | 196 | <form class="edit" method="post" action="/b/${board.id}/cards/${c.id}"> |
| 194 | 197 | <input name="title" value="${escape(c.title)}" maxlength="200" required aria-label="Card title"> |
| 195 | 198 | </form> |
| 196 | 199 | <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> | |
| 205 | 201 | <form class="delete" method="post" action="/b/${board.id}/cards/${c.id}/delete"> |
| 206 | 202 | <button aria-label="Delete card">✕</button> |
| 207 | 203 | </form> |
| @@ -289,6 +285,6 @@ const boardShortcuts: [string, string][] = [ | ||
| 289 | 285 | |
| 290 | 286 | // Named by content — a change to static/board.js gets a new hash here, |
| 291 | 287 | // 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"; | |
| 293 | 289 | export const THEME_JS = "theme.1025564933.js"; |
| 294 | 290 | 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("<script>bad</script>"); | |
| 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}">← ${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"; | ||
| 10 | 10 | import { boardCreationBucket, MAX_CARDS_PER_BOARD, MAX_COLUMNS_PER_BOARD, MAX_SUBSCRIBERS_PER_BOARD } from "./limits.ts"; |
| 11 | 11 | import { rpc } from "./mcp.ts"; |
| 12 | 12 | import { boardPage, landingPage } from "./pages/board.ts"; |
| 13 | import { cardPage } from "./pages/card.ts"; | |
| 13 | 14 | import type { Who } from "./who.ts"; |
| 14 | 15 | |
| 15 | 16 | const BOARD_ID = /^[a-z0-9]{8}$/; |
| @@ -66,9 +67,12 @@ class HttpError extends Error { | ||
| 66 | 67 | function wantsJson(request: Request): boolean { |
| 67 | 68 | return (request.headers.get("accept") ?? "").includes("application/json"); |
| 68 | 69 | } |
| 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 { | |
| 70 | 74 | 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 }); | |
| 72 | 76 | } |
| 73 | 77 | // Every other page on this board hears about a write that actually happened. |
| 74 | 78 | function donePublish( |
| @@ -79,9 +83,10 @@ function donePublish( | ||
| 79 | 83 | kind: EventKind, |
| 80 | 84 | ids: Record<string, string> = {}, |
| 81 | 85 | extra: Record<string, unknown> = {}, |
| 86 | anchor?: string, | |
| 82 | 87 | ): Response { |
| 83 | 88 | if (ok) publish(boardId, request, who, kind, ids); |
| 84 | return done(request, boardId, ok, extra); | |
| 89 | return done(request, boardId, ok, extra, anchor); | |
| 85 | 90 | } |
| 86 | 91 | |
| 87 | 92 | // 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, | ||
| 101 | 106 | ); |
| 102 | 107 | } |
| 103 | 108 | |
| 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 | ||
| 104 | 120 | function cleanText(value: FormDataEntryValue | null, max: number): string | null { |
| 105 | 121 | const text = String(value ?? "").trim().slice(0, max); |
| 106 | 122 | return text === "" ? null : text; |
| @@ -311,28 +327,41 @@ async function dispatch(request: Request, url: URL, env: string, who: Who): Prom | ||
| 311 | 327 | if (parts.length >= 4 && parts[2] === "cards") { |
| 312 | 328 | const cardId = parts[3]; |
| 313 | 329 | 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 | } | |
| 314 | 337 | |
| 315 | 338 | if (method === "POST" && parts.length === 4) { |
| 316 | 339 | const data = await form(request); |
| 317 | 340 | const title = cleanText(data.get("title"), 200); |
| 318 | 341 | 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); | |
| 320 | 343 | } |
| 321 | 344 | |
| 322 | 345 | if (method === "POST" && parts.length === 5 && parts[4] === "move") { |
| 323 | 346 | const data = await form(request); |
| 324 | 347 | const columnId = String(data.get("column_id") ?? ""); |
| 325 | 348 | 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 | ); | |
| 332 | 361 | } |
| 333 | 362 | |
| 334 | 363 | 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); | |
| 336 | 365 | } |
| 337 | 366 | } |
| 338 | 367 |
src/server.test.ts +49 −1
| @@ -90,7 +90,7 @@ test("adds, moves, edits and deletes a card, and undo reverses the last change", | ||
| 90 | 90 | const moved = await post(`${boardUrl}/cards/${cardId}/move`, { column_id: doingId, index: "0" }); |
| 91 | 91 | expect(moved.status).toBe(200); |
| 92 | 92 | 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}"`)); | |
| 94 | 94 | |
| 95 | 95 | const edited = await post(`${boardUrl}/cards/${cardId}`, { title: "Ship the README" }); |
| 96 | 96 | expect(edited.status).toBe(200); |
| @@ -234,6 +234,54 @@ test("cannot move a card into another board's column", async () => { | ||
| 234 | 234 | expect(response.status).toBe(404); |
| 235 | 235 | }); |
| 236 | 236 | |
| 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 | ||
| 237 | 285 | test("a guest sees a Sign in link, never a name", async () => { |
| 238 | 286 | const page = await (await fetch(base + "/")).text(); |
| 239 | 287 | 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 | ||
| 163 | 163 | await window.close(); |
| 164 | 164 | }); |
| 165 | 165 | |
| 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 @@ | ||
| 39 | 39 | const indexOf = (cardEl) => cardsOf(cardEl.closest(".column")).indexOf(cardEl); |
| 40 | 40 | const columnsList = () => Array.from(root.querySelectorAll(".column")); |
| 41 | 41 | 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)}`); | |
| 43 | 47 | |
| 44 | 48 | function updateCounts(...columns) { |
| 45 | 49 | for (const col of columns.length ? columns : columnsList()) { |
| @@ -71,15 +75,12 @@ | ||
| 71 | 75 | const columnEl = cardEl.closest(".column"); |
| 72 | 76 | const toColumnId = columnEl.dataset.columnId; |
| 73 | 77 | const index = indexOf(cardEl); |
| 74 | cardEl.dataset.columnId = toColumnId; | |
| 75 | for (const select of cardEl.querySelectorAll("select[name=column_id]")) select.value = toColumnId; | |
| 76 | 78 | 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( | |
| 78 | 80 | (r) => { |
| 79 | 81 | if (r.ok) return; |
| 80 | 82 | const fromColumn = root.querySelector(`.column[data-column-id="${CSS.escape(fromColumnId)}"] .cards`); |
| 81 | 83 | if (fromColumn) fromColumn.appendChild(cardEl); |
| 82 | cardEl.dataset.columnId = fromColumnId; | |
| 83 | 84 | updateCounts(); |
| 84 | 85 | showError("Could not move the card."); |
| 85 | 86 | }, |
| @@ -105,7 +106,7 @@ | ||
| 105 | 106 | root.addEventListener("dragstart", (e) => { |
| 106 | 107 | const card = e.target.closest(".card"); |
| 107 | 108 | if (!card) return; |
| 108 | dragging = { el: card, from: card.dataset.columnId }; | |
| 109 | dragging = { el: card, from: card.closest(".column").dataset.columnId }; | |
| 109 | 110 | card.classList.add("dragging"); |
| 110 | 111 | e.dataTransfer.effectAllowed = "move"; |
| 111 | 112 | }); |
| @@ -190,7 +191,15 @@ | ||
| 190 | 191 | // ordinary swipe still scrolls the board, same as before this held. |
| 191 | 192 | card.style.touchAction = "none"; |
| 192 | 193 | }, 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 | }; | |
| 194 | 203 | }); |
| 195 | 204 | |
| 196 | 205 | root.addEventListener("pointermove", (e) => { |
| @@ -227,7 +236,7 @@ | ||
| 227 | 236 | if (!card || e.target !== card) return; |
| 228 | 237 | if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) return; |
| 229 | 238 | e.preventDefault(); |
| 230 | const fromColumnId = card.dataset.columnId; | |
| 239 | const fromColumnId = card.closest(".column").dataset.columnId; | |
| 231 | 240 | if (e.key === "ArrowUp" || e.key === "ArrowDown") { |
| 232 | 241 | const siblings = cardsOf(card.closest(".column")); |
| 233 | 242 | const i = siblings.indexOf(card); |
| @@ -260,7 +269,7 @@ | ||
| 260 | 269 | const previous = card.dataset.title; |
| 261 | 270 | card.dataset.title = title; |
| 262 | 271 | if (filterInput) applyFilter(); |
| 263 | post(`/b/${boardId}/cards/${card.dataset.cardId}`, { title }).then((r) => { | |
| 272 | post(`/b/${boardId}/cards/${idOf(card)}`, { title }).then((r) => { | |
| 264 | 273 | if (r.ok) return; |
| 265 | 274 | card.dataset.title = previous; |
| 266 | 275 | form.title.value = previous; |
| @@ -270,21 +279,9 @@ | ||
| 270 | 279 | return; |
| 271 | 280 | } |
| 272 | 281 | |
| 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 | ||
| 285 | 282 | if (form.classList.contains("delete") && card) { |
| 286 | 283 | e.preventDefault(); |
| 287 | const cardId = card.dataset.cardId; | |
| 284 | const cardId = idOf(card); | |
| 288 | 285 | const parent = card.closest(".cards"); |
| 289 | 286 | const next = card.nextSibling; |
| 290 | 287 | card.remove(); |
| @@ -306,15 +303,16 @@ | ||
| 306 | 303 | const columnEl = form.closest(".column"); |
| 307 | 304 | const columnId = columnEl.dataset.columnId; |
| 308 | 305 | const tempId = `temp-${Math.random().toString(36).slice(2)}`; |
| 309 | const li = buildCard(tempId, title, columnId); | |
| 306 | const li = buildCard(tempId, title); | |
| 310 | 307 | columnEl.querySelector(".cards").appendChild(li); |
| 311 | 308 | updateCounts(); |
| 312 | 309 | post(`/b/${boardId}/columns/${columnId}/cards`, { title }) |
| 313 | 310 | .then(async (r) => { |
| 314 | 311 | const data = await r.json(); |
| 315 | 312 | 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}`; | |
| 317 | 314 | 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); | |
| 318 | 316 | }) |
| 319 | 317 | .catch((err) => { |
| 320 | 318 | li.remove(); |
| @@ -324,13 +322,12 @@ | ||
| 324 | 322 | } |
| 325 | 323 | }); |
| 326 | 324 | |
| 327 | function buildCard(id, title, columnId) { | |
| 325 | function buildCard(id, title) { | |
| 328 | 326 | const li = document.createElement("li"); |
| 329 | 327 | li.className = "card"; |
| 328 | li.id = `card-${id}`; | |
| 330 | 329 | li.draggable = true; |
| 331 | 330 | li.tabIndex = 0; |
| 332 | li.dataset.cardId = id; | |
| 333 | li.dataset.columnId = columnId; | |
| 334 | 331 | li.dataset.title = title; |
| 335 | 332 | |
| 336 | 333 | const editForm = document.createElement("form"); |
| @@ -348,34 +345,9 @@ | ||
| 348 | 345 | const row = document.createElement("div"); |
| 349 | 346 | row.className = "row"; |
| 350 | 347 | |
| 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"; | |
| 379 | 351 | |
| 380 | 352 | const deleteForm = document.createElement("form"); |
| 381 | 353 | deleteForm.className = "delete"; |
| @@ -386,7 +358,7 @@ | ||
| 386 | 358 | delBtn.textContent = "✕"; |
| 387 | 359 | deleteForm.appendChild(delBtn); |
| 388 | 360 | |
| 389 | row.append(moveForm, deleteForm); | |
| 361 | row.append(moveLink, deleteForm); | |
| 390 | 362 | li.append(editForm, row); |
| 391 | 363 | return li; |
| 392 | 364 | } |
| @@ -447,7 +419,7 @@ | ||
| 447 | 419 | function apply(event) { |
| 448 | 420 | if ( |
| 449 | 421 | (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) || | |
| 451 | 423 | (event.kind === "column.renamed" && isRenamingColumn(event.ids.columnId)) || |
| 452 | 424 | (event.kind === "board.renamed" && isRenamingBoard()) || |
| 453 | 425 | (event.kind === "undo" && (dragging || isTypingSomewhere())) |
| @@ -477,7 +449,7 @@ | ||
| 477 | 449 | const current = root.querySelector(".board"); |
| 478 | 450 | if (!next || !current) return; |
| 479 | 451 | 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; | |
| 481 | 453 | current.replaceWith(document.importNode(next, true)); |
| 482 | 454 | for (const list of root.querySelectorAll(".cards")) attachDropListenersTo(list); |
| 483 | 455 | if (focusedCardId) cardEl(focusedCardId)?.focus(); |
| @@ -497,7 +469,6 @@ | ||
| 497 | 469 | } |
| 498 | 470 | if (kind === "column.deleted") { |
| 499 | 471 | columnEl(ids.columnId)?.remove(); |
| 500 | for (const opt of root.querySelectorAll(`select[name=column_id] option[value="${CSS.escape(ids.columnId)}"]`)) opt.remove(); | |
| 501 | 472 | return; |
| 502 | 473 | } |
| 503 | 474 | if (kind === "undo") return reconcile(); |
| @@ -513,14 +484,15 @@ | ||
| 513 | 484 | } |
| 514 | 485 | |
| 515 | 486 | 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)}`); | |
| 517 | 488 | if (!freshCard) return; |
| 518 | const targetColumn = columnEl(freshCard.dataset.columnId); | |
| 489 | const freshColumnId = freshCard.closest(".column").dataset.columnId; | |
| 490 | const targetColumn = columnEl(freshColumnId); | |
| 519 | 491 | if (!targetColumn) return; |
| 520 | 492 | const previousColumn = cardEl(ids.cardId)?.closest(".column"); |
| 521 | 493 | 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); | |
| 524 | 496 | const liveSiblings = cardsOf(targetColumn); |
| 525 | 497 | targetColumn.querySelector(".cards").insertBefore(document.importNode(freshCard, true), liveSiblings[at] || null); |
| 526 | 498 | updateCounts(targetColumn, previousColumn); |
| @@ -530,7 +502,7 @@ | ||
| 530 | 502 | |
| 531 | 503 | if (kind === "card.edited") { |
| 532 | 504 | 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)}`); | |
| 534 | 506 | if (!card || !freshCard) return; |
| 535 | 507 | card.dataset.title = freshCard.dataset.title; |
| 536 | 508 | const input = card.querySelector(".edit input"); |
| @@ -549,15 +521,6 @@ | ||
| 549 | 521 | if (!node) { |
| 550 | 522 | node = document.importNode(freshColumn, true); |
| 551 | 523 | 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 | } | |
| 561 | 524 | } |
| 562 | 525 | const live = columnsList().filter((c) => c !== node); |
| 563 | 526 | board.insertBefore(node, live[at] || board.querySelector(".new-column")); |
| @@ -573,7 +536,6 @@ | ||
| 573 | 536 | const name = freshInput.value; |
| 574 | 537 | const input = col.querySelector("header form.rename input"); |
| 575 | 538 | 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; | |
| 577 | 539 | return; |
| 578 | 540 | } |
| 579 | 541 | } |