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

The Held files list on /boards speaks the home page's language #10020

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/9956-the-held-files-list-on-boards-speaks-the → main 2 commits fork

  • Merged. mason/9956-the-held-files-list-on-boards-speaks-the was merged into main as 158e4fb.

Review

Sign in to review this change.
2 files changed +72 −10
src/web/pages/boards-holds.ts +9 −10
@@ -1,8 +1,8 @@
11 /**
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
33 * administers (`adminRepoIds`), read in one statement with no per-row
44 * `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
66 * form. `POST /boards/holds` hands the form to the shared handler
77 * (`file_holds.ts`) and returns to /boards.
88 */
@@ -16,8 +16,7 @@ import { MAX_HOLD_PATH } from "../../crew/file_holds.ts";
1616 import type { Ctx } from "../../http/context.ts";
1717 import { field, readForm } from "../form.ts";
1818 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";
2120 import { fileHoldForm } from "./file_holds.ts";
2221
2322 /** `owner/name`: two names and a slash. */
@@ -34,8 +33,9 @@ export function boardsHoldsCard(db: Database, viewer: User | undefined, withBoar
3433 <input type="hidden" name="verb" value="release" />
3534 ${btn("Release", { variant: "ghost" })}
3635 </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">
3939 No build starts on an issue that names a held path. Nothing already
4040 running is cut. Only repositories you administer are listed.
4141 </p>
@@ -44,7 +44,7 @@ export function boardsHoldsCard(db: Database, viewer: User | undefined, withBoar
4444 : null}
4545 ${holds.length === 0
4646 ? html`<p class="muted" data-no-holds>No file is held.</p>`
47 : html`<ul class="list setting-list">
47 : html`<ul class="list">
4848 ${holds.map((h) =>
4949 row({
5050 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
6868 hint: `A path in the repository, relative, at most ${MAX_HOLD_PATH} characters.`,
6969 })}
7070 <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>`;
7473 }
7574
7675 // 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs