Public: anyone can read this; the crew can change it.
The Held files list on /boards speaks the home page's language #10020
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9956-the-held-files-list-on-boards-speaks-the → main 2 commits fork
2 files
changed +72
−10
src/web/pages/boards-holds.ts +9 −10
| @@ -1,8 +1,8 @@ | ||
| 1 | 1 | /** |
| 2 | * The Held files card on /boards: every file held in a repository the viewer | |
| 2 | * The Held files section on /boards: every file held in a repository the viewer | |
| 3 | 3 | * administers (`adminRepoIds`), read in one statement with no per-row |
| 4 | 4 | * `canRead` — the rule `listFileHolds` keeps, so a private repository's hold |
| 5 | * shows to its admins only. The card shows with no holds yet, for it holds the | |
| 5 | * shows to its admins only. The section shows with no holds yet, for it holds the | |
| 6 | 6 | * form. `POST /boards/holds` hands the form to the shared handler |
| 7 | 7 | * (`file_holds.ts`) and returns to /boards. |
| 8 | 8 | */ |
| @@ -16,8 +16,7 @@ import { MAX_HOLD_PATH } from "../../crew/file_holds.ts"; | ||
| 16 | 16 | import type { Ctx } from "../../http/context.ts"; |
| 17 | 17 | import { field, readForm } from "../form.ts"; |
| 18 | 18 | import { html, type Html } from "../html.ts"; |
| 19 | import { icon } from "../icons.ts"; | |
| 20 | import { btn, card, field as formField, row, time } from "../ui.ts"; | |
| 19 | import { btn, field as formField, row, time } from "../ui.ts"; | |
| 21 | 20 | import { fileHoldForm } from "./file_holds.ts"; |
| 22 | 21 | |
| 23 | 22 | /** `owner/name`: two names and a slash. */ |
| @@ -34,8 +33,9 @@ export function boardsHoldsCard(db: Database, viewer: User | undefined, withBoar | ||
| 34 | 33 | <input type="hidden" name="verb" value="release" /> |
| 35 | 34 | ${btn("Release", { variant: "ghost" })} |
| 36 | 35 | </form>`; |
| 37 | return card( | |
| 38 | html`<p class="muted small"> | |
| 36 | return html`<section class="settings-block" id="board-holds"> | |
| 37 | <h2>Held files</h2> | |
| 38 | <p class="muted small"> | |
| 39 | 39 | No build starts on an issue that names a held path. Nothing already |
| 40 | 40 | running is cut. Only repositories you administer are listed. |
| 41 | 41 | </p> |
| @@ -44,7 +44,7 @@ export function boardsHoldsCard(db: Database, viewer: User | undefined, withBoar | ||
| 44 | 44 | : null} |
| 45 | 45 | ${holds.length === 0 |
| 46 | 46 | ? html`<p class="muted" data-no-holds>No file is held.</p>` |
| 47 | : html`<ul class="list setting-list"> | |
| 47 | : html`<ul class="list"> | |
| 48 | 48 | ${holds.map((h) => |
| 49 | 49 | row({ |
| 50 | 50 | title: html`<span class="mono">${h.path}</span> <span class="muted small">${h.repo}</span>`, |
| @@ -68,9 +68,8 @@ export function boardsHoldsCard(db: Database, viewer: User | undefined, withBoar | ||
| 68 | 68 | hint: `A path in the repository, relative, at most ${MAX_HOLD_PATH} characters.`, |
| 69 | 69 | })} |
| 70 | 70 | <div class="form-actions">${btn("Hold file", { variant: withBoards ? "secondary" : "primary" })}</div> |
| 71 | </form>`, | |
| 72 | { id: "board-holds", head: html`${icon("alert")}<h2>Held files</h2>` }, | |
| 73 | ); | |
| 71 | </form> | |
| 72 | </section>`; | |
| 74 | 73 | } |
| 75 | 74 | |
| 76 | 75 | // POST /boards/holds |
test/boards-holds-language.test.ts added +63
| @@ -0,0 +1,63 @@ | ||
| 1 | /** The Held files list on /boards speaks the home page's language: a section, not a card (#9956). */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { setRole } from "../src/actions/roles.ts"; | |
| 9 | import { holdFileAs } from "../src/actions/file_holds.ts"; | |
| 10 | import { createUser, type User } from "../src/core/users.ts"; | |
| 11 | import { createSession } from "../src/core/sessions.ts"; | |
| 12 | import { boardsHoldsCard } from "../src/web/pages/boards-holds.ts"; | |
| 13 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 14 | ||
| 15 | let dir = ""; | |
| 16 | let app: App, servers: Servers; | |
| 17 | const users: Record<string, User> = {}; | |
| 18 | const cookie = (n: string) => `forge_session=${createSession(app.db, users[n]!.id).token}`; | |
| 19 | const person = async (username: string, first = false): Promise<User> => { | |
| 20 | const made = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: first }); | |
| 21 | if (!made.ok) throw new Error(made.error); | |
| 22 | return made.value; | |
| 23 | }; | |
| 24 | const main = (page: string) => page.slice(page.indexOf("<main"), page.indexOf("</main>")); | |
| 25 | ||
| 26 | beforeAll(async () => { | |
| 27 | dir = await mkdtemp(join(tmpdir(), "forge-holds-lang-")); | |
| 28 | app = createApp({ dataDir: dir, port: 0 }); | |
| 29 | users.alice = await person("alice", true); | |
| 30 | users.bob = await person("bob"); | |
| 31 | users.carol = await person("carol"); | |
| 32 | for (const u of ["bob", "carol"]) setRole(app, users.alice!, u, "member"); | |
| 33 | servers = await serve(app); | |
| 34 | const pub = await createRepo(app, users.carol!, { name: "cs", visibility: "public" }); | |
| 35 | const priv = await createRepo(app, users.bob!, { name: "hidden", visibility: "private" }); | |
| 36 | if (!pub.ok || !priv.ok) throw new Error("repo"); | |
| 37 | holdFileAs(app, users.carol!, pub.value, "src/public-hold.ts"); | |
| 38 | holdFileAs(app, users.bob!, priv.value, "src/private-hold.ts"); | |
| 39 | }); | |
| 40 | afterAll(async () => { | |
| 41 | servers.stop(); | |
| 42 | app.close(); | |
| 43 | await rm(dir, { recursive: true, force: true }); | |
| 44 | }); | |
| 45 | ||
| 46 | test("held files are rows in a .list under an h2, no card", async () => { | |
| 47 | const m = main(await (await fetch(`${servers.url}/boards`, { headers: { cookie: cookie("carol") } })).text()); | |
| 48 | expect(m).not.toContain('class="card'); | |
| 49 | expect(m).toMatch(/<section class="settings-block" id="board-holds">\s*<h2>Held files<\/h2>/); | |
| 50 | expect(m).toMatch(/<ul class="list[^"]*">/); | |
| 51 | expect(m).toContain("src/public-hold.ts"); | |
| 52 | expect(m).toContain('action="/boards/holds"'); | |
| 53 | expect(m).toContain('value="release"'); | |
| 54 | }); | |
| 55 | ||
| 56 | test("a private repository's hold never shows to a non-admin of it", async () => { | |
| 57 | const carol = main(await (await fetch(`${servers.url}/boards`, { headers: { cookie: cookie("carol") } })).text()); | |
| 58 | expect(carol).not.toContain("src/private-hold.ts"); | |
| 59 | const bob = main(await (await fetch(`${servers.url}/boards`, { headers: { cookie: cookie("bob") } })).text()); | |
| 60 | expect(bob).toContain("src/private-hold.ts"); | |
| 61 | expect(bob).not.toContain("src/public-hold.ts"); | |
| 62 | expect(String(boardsHoldsCard(app.db, users.carol!))).not.toContain("src/private-hold.ts"); | |
| 63 | }); |