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

A repository's builds page speaks the home page's language #9795

Merged opened this

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

Merged

scout/forge:scout/9794-a-repository-s-builds-page-speaks-the-ho → main 3 commits fork 4 threads · 4 unresolved

  • Merged. scout/9794-a-repository-s-builds-page-speaks-the-ho was merged into main as cdfbcd4.

Review

Sign in to review this change.
4 files changed +170 −114
docs/design/areas/builds.md added +3
@@ -0,0 +1,3 @@
1 # Builds
2
3 The builds page (`src/web/pages/builds.ts`) is an `<h2>` and a plain `.list` of rows, indented by `data-depth` under the pin each grew from. The label is the row's one accent link (to its commit); Screenshots, the cart and Preview are quiet links in the trail, and `.build-del` is the Delete link, muted until hover or focus turns it `--danger`. With no builds, one sentence says how to make one. The pin form is the new issue form's markup: `<h1>`, `.narrow-wide`, `.form-actions`, Cancel a link. On a phone the trail drops under the row's text and wraps. The shots and cart pages under `/builds/:label` are separate.
src/web/pages/builds.ts +81 −112
@@ -27,7 +27,7 @@ import { backTo, field as formValue, readForm } from "../form.ts";
2727 import { isLocalPath } from "../flash.ts";
2828 import { html, when, type Html } from "../html.ts";
2929 import { flashTo, page } from "../layout.ts";
30 import { btn, card, field, icon, selectField, sha, time } from "../ui.ts";
30 import { btn, field, row, selectField, time } from "../ui.ts";
3131
3232 /**
3333 * The label a new pin is offered. An iteration branch (`iter-3`) pinned
@@ -53,7 +53,6 @@ export function nextLabel(pins: readonly BuildPin[]): string {
5353 return `${last}-2`;
5454 }
5555
56 /** The lineage as a card: one line per pin, indented under its parent. */
5756 const hidden = (error: string): boolean => error === "unauthorized" || error === "not_found" || error === "forbidden";
outdated

The /** The lineage as a card ... */ comment was deleted and left a double blank line. Put the blank line back to one.

5857
5958 // GET /:owner/:name/builds — the pins as a lineage, each under the one it grew from.
@@ -66,7 +65,7 @@ export async function buildsPage(ctx: Ctx): Promise<Response> {
6665 const writer = ctx.auth.user !== undefined && canWrite(ctx.app.db, repo, ctx.auth.user);
6766 const isApp = getAppByRepo(ctx.app.db, repo.id) !== undefined;
6867 const body = html`${repoHeader(ctx, repo, "branches", { compact: true })}
69 <div class="mt">${buildsCard(base, nodes, writer, writer && isApp, writer)}</div>`;
68 ${buildsList(base, nodes, writer, writer && isApp, writer)}`;
7069 return timePhase(ctx.request, "render", () =>
7170 page({ ctx, title: `Builds · ${repo.name}`, crumbs: crumbsFor(repo), palette: repoPalette(ctx, repo), body, styleCss: BUILDS_CSS }),
7271 );
@@ -85,7 +84,7 @@ export async function deletePinSubmit(ctx: Ctx): Promise<Response> {
8584 return flashTo(ctx, to, gone.ok ? { message: `Deleted pin ${gone.value.label}.` } : { tone: "danger", message: "Delete failed." });
8685 }
8786
88 export function buildsCard(
87 export function buildsList(
8988 base: string,
9089 nodes: readonly BuildNode[],
9190 canPin: boolean,
@@ -94,78 +93,57 @@ export function buildsCard(
9493 /** Whether each pin of this repository offers Delete: a writer. */
9594 canDelete = false,
9695 ): Html {
97 return card(
98 nodes.length === 0
99 ? html`<p class="muted small builds-none">No pinned builds</p>`
100 : html`<ol class="builds">
101 ${nodes.map(
102 (n) => html`<li data-depth="${String(Math.min(n.depth, 8))}" class="build-row">
103 ${when(n.edge === "concept", () => html`<span class="build-concept" title="Concept edge: parent set by hand, not by commit history">┄</span>`)}
104 <span class="build-label mono">${n.from === undefined ? null : html`<span class="muted">${n.from}:</span>`}${n.pin.label}</span>
105 ${sha(n.pin.oid, `${n.from === undefined ? base : `/${n.from}`}/commit/${n.pin.oid}`)}
106 <span class="build-note truncate" title="${n.pin.note}">${n.pin.note === n.pin.label ? "" : n.pin.note}</span>
107 <span class="muted small build-meta"
108 >${n.pin.source} · ${n.pin.pinnedBy} · ${time(n.pin.pinnedAt)}</span
109 >
110 ${when(
111 n.from === undefined && n.pin.demo?.launch === "file",
112 () => html`<a class="build-cart small" href="${base}/builds/${encodeURIComponent(n.pin.label)}/cart" title="Play the captured page of ${n.pin.demo?.name ?? ""}"
113 >▶ ${n.pin.demo?.name ?? ""} ${n.pin.demo?.version ?? ""}</a
114 >`,
115 )}
116 ${when(
117 n.from === undefined,
118 () => html`<a class="build-shots small" href="${base}/builds/${encodeURIComponent(n.pin.label)}/shots">Screenshots</a>`,
119 )}
120 ${when(
121 canPreview && n.from === undefined,
122 () => html`<form method="post" action="${base}/builds/preview" class="build-preview">
123 <input type="hidden" name="sha" value="${n.pin.oid}" />
124 ${btn("Preview now", { size: "sm", variant: "ghost", icon: "eye" })}
125 </form>`,
126 )}
127 ${when(
128 canDelete && n.from === undefined,
129 () => btn("Delete", {
130 href: `/-/confirm?to=${encodeURIComponent(`${base}/builds/${encodeURIComponent(n.pin.label)}/delete`)}`,
131 size: "sm",
132 variant: "danger",
133 label: `Delete pin ${n.pin.label}`,
134 }),
135 )}
136 </li>`,
137 )}
138 </ol>`,
139 {
140 head: html`${icon("pin")}
141 <h2>Builds</h2>
142 <span class="spacer"></span>
143 ${when(nodes.length > 0, () => btn("Iterations", { href: `${base}/iterations?layout=tree`, size: "sm", variant: "ghost", icon: "map" }))}
144 ${when(canPin, () =>
145 html`${btn("Pin a build", {
146 href: `${base}/builds/new`,
147 size: "sm",
148 icon: "plus",
149 })}`,
150 )}`,
151 extra: "builds-card",
152 },
153 );
96 return html`<h2 class="mt">Builds</h2>
97 <p class="small builds-links">
outdated

buildsCard is not a card now (it is also imported elsewhere). Rename it, e.g. buildsList. Also, Pin a build and Iterations in .builds-links are accent links next to the h2 and compete with each row's label (principle 5). Mute them, or put them in the h2's trail.

98 ${when(canPin, () => html`<a href="${base}/builds/new">Pin a build</a>`)}
99 ${when(nodes.length > 0, () => html`<a href="${base}/iterations?layout=tree">Iterations</a>`)}
100 </p>
101 ${nodes.length === 0
102 ? html`<p class="muted">No builds yet.${canPin ? html` Pin one from <a href="${base}/branches">a branch</a> to make it.` : null}</p>`
103 : html`<ul class="list builds">${nodes.map((n) => buildRow(base, n, canPreview, canDelete))}</ul>`}`;
104 }
105
106 /** One pin: its label is the one accent link; the rest is quiet, in the trail. */
107 function buildRow(base: string, n: BuildNode, canPreview: boolean, canDelete: boolean): Html {
108 const own = n.from === undefined;
109 const label = encodeURIComponent(n.pin.label);
110 const note = n.pin.note === n.pin.label ? "" : `${n.pin.note} · `;
111 return row({
112 title: html`${when(n.edge === "concept", () => html`<span class="build-concept" title="Concept edge: parent set by hand, not by commit history">┄ </span>`)}<a class="mono" href="${own ? base : `/${n.from}`}/commit/${n.pin.oid}">${n.from === undefined ? null : html`<span class="muted">${n.from}:</span>`}${n.pin.label}</a>`,
113 meta: html`${note}${n.pin.source} · ${n.pin.pinnedBy} · ${time(n.pin.pinnedAt)}`,
114 data: { depth: String(Math.min(n.depth, 8)) },
115 trail: html`${when(
116 own && n.pin.demo?.launch === "file",
117 () => html`<a class="small" href="${base}/builds/${label}/cart" title="Play the captured page of ${n.pin.demo?.name ?? ""}">▶ ${n.pin.demo?.name ?? ""} ${n.pin.demo?.version ?? ""}</a>`,
118 )}
119 ${when(own, () => html`<a class="small" href="${base}/builds/${label}/shots">Screenshots</a>`)}
120 ${when(
121 canPreview && own,
122 () => html`<form method="post" action="${base}/builds/preview" class="build-preview">
123 <input type="hidden" name="sha" value="${n.pin.oid}" />
124 ${btn("Preview", { size: "sm", variant: "link", label: `Preview build ${n.pin.label}` })}
125 </form>`,
126 )}
127 ${when(
128 canDelete && own,
129 () => html`<a class="small build-del" href="/-/confirm?to=${encodeURIComponent(`${base}/builds/${label}/delete`)}" aria-label="Delete pin ${n.pin.label}">Delete</a>`,
130 )}`,
131 });
154132 }
155133
outdated

Screenshots and the cart link are class="small" accent links, while Preview and Delete are muted. The trail must be quiet as a whole (principle 5). Give .builds .row-trail a the muted colour with an accent hover, as .build-preview .btn already has.

156134 export const BUILDS_CSS = `
157 .builds{list-style:none;margin:0;padding:6px 12px}
158 .build-row{display:flex;align-items:center;gap:8px;min-height:30px;min-width:0}
159 ${[1, 2, 3, 4, 5, 6, 7, 8].map((d) => `.build-row[data-depth="${d}"]{padding-left:${d * 20}px}`).join("\n")}
160 .build-row + .build-row{border-top:1px solid var(--border)}
161 .build-label{font-weight:600;flex:none}
162 .build-concept{color:var(--muted-fg);flex:none}
163 .build-note{min-width:0;flex:1}
164 .build-meta{flex:none;margin-left:auto}
165 .build-preview{flex:none;margin:0}
166 .build-shots{flex:none}
167 .builds-none{padding:10px 14px;margin:0}
168 @media (max-width:640px){.build-meta{display:none}}
135 ${[1, 2, 3, 4, 5, 6, 7, 8].map((d) => `.builds > li[data-depth="${d}"]{padding-left:${d * 20}px}`).join("\n")}
136 .builds .row-meta{white-space:normal;overflow-wrap:anywhere}
137 .builds .row-trail{flex:0 1 auto;flex-wrap:wrap;gap:4px 12px;align-items:center}
138 .build-concept{color:var(--muted-fg)}
139 .build-preview{margin:0}
140 .build-preview .btn{color:var(--muted-fg);font-size:inherit}
141 .build-preview .btn:hover,.build-preview .btn:focus-visible{color:var(--accent)}
142 .builds .row-trail a,.builds-links a{color:var(--muted-fg)}
143 .builds .row-trail a:hover,.builds .row-trail a:focus-visible,.builds-links a:hover,.builds-links a:focus-visible{color:var(--accent)}
144 .builds .row-trail a.build-del:hover,.builds .row-trail a.build-del:focus-visible{color:var(--danger)}
145 .builds-links{display:flex;gap:12px;margin:0 0 8px}
146 @media (max-width:719px){.builds .row{flex-wrap:wrap}.builds .row-trail{flex:1 1 100%;justify-content:flex-start}}
169147 `;
170148
171149 // GET /:owner/:name/builds/new?sha=&source= — the pin form.
@@ -190,38 +168,33 @@ export async function newBuildPage(ctx: Ctx): Promise<Response> {
190168 const askedBack = ctx.url.searchParams.get("back") ?? "";
191169 const back = isLocalPath(askedBack) ? askedBack : undefined;
192170 const body = html`${repoHeader(ctx, repo, "branches", { compact: true })}
193 <div class="mt">
194 ${card(
195 html`<form method="post" action="${base}/builds" class="stack">
196 ${field({ label: "Commit", name: "sha", value: isSafeOid(want) ? want : "", required: true, mono: true, hint: "Full commit SHA." })}
197 <input type="hidden" name="source" value="${source}" />
198 ${when(back, (b) => html`<input type="hidden" name="back" value="${b}" />`)}
199 ${field({ label: "Label", name: "label", value: suggestLabel(pins, repo.forkParentId !== null, source), required: true, mono: true, hint: "Letters, digits, dot, dash or underscore. Example: iter-3, v3." })}
200 ${field({ label: "Note", name: "note", rows: 3, hint: "Optional." })}
201 ${selectField({
202 label: "Parent",
203 name: "parent",
204 hint: "Automatic: the nearest pin in the commit history. A pin in another repository creates a concept edge.",
205 options: [
206 { value: "", label: "Automatic", selected: true },
207 // Any pin of the family: one in another repository is a
208 // concept edge, an idea shared without history.
209 ...family.map((n) => {
210 const value = n.from === undefined ? n.pin.label : `${n.from}:${n.pin.label}`;
211 return { value, label: value };
212 }),
213 ],
214 })}
215 <div class="bar">
216 ${btn("Pin build", { variant: "primary", icon: "pin" })}
217 ${btn("Cancel", { href: back ?? `${base}/branches`, variant: "ghost" })}
218 </div>
219 </form>`,
220 {
221 head: html`${icon("pin")}
222 <h2>Pin a build</h2>`,
223 },
224 )}
171 <h1 class="mt">Pin a build</h1>
172 <div class="narrow-wide mt">
173 <form method="post" action="${base}/builds">
174 ${field({ label: "Commit", name: "sha", value: isSafeOid(want) ? want : "", required: true, mono: true, hint: "Full commit SHA." })}
175 <input type="hidden" name="source" value="${source}" />
176 ${when(back, (b) => html`<input type="hidden" name="back" value="${b}" />`)}
177 ${field({ label: "Label", name: "label", value: suggestLabel(pins, repo.forkParentId !== null, source), required: true, mono: true, hint: "Letters, digits, dot, dash or underscore. Example: iter-3, v3." })}
178 ${field({ label: "Note", name: "note", type: "textarea", rows: 3, hint: "Optional." })}
179 ${selectField({
180 label: "Parent",
181 name: "parent",
182 hint: "Automatic: the nearest pin in the commit history. A pin in another repository creates a concept edge.",
183 options: [
184 { value: "", label: "Automatic", selected: true },
185 // Any pin of the family: one in another repository is a
186 // concept edge, an idea shared without history.
187 ...family.map((n) => {
188 const value = n.from === undefined ? n.pin.label : `${n.from}:${n.pin.label}`;
189 return { value, label: value };
190 }),
191 ],
192 })}
193 <div class="form-actions">
194 ${btn("Pin build", { variant: "primary", icon: "pin" })}
195 ${btn("Cancel", { href: back ?? `${base}/branches`, variant: "link" })}
196 </div>
197 </form>
225198 </div>`;
226199 return timePhase(ctx.request, "render", () =>
227200 page({
@@ -314,12 +287,8 @@ export async function previewBuildSubmit(ctx: Ctx): Promise<Response> {
314287 title: `Opening ${short} · ${repo.name}`,
315288 crumbs: crumbsFor(repo),
316289 scriptJs: `location.replace(${JSON.stringify(url).replace(/</g, "\\u003c")});`,
317 body: html`<div class="mt">
318 ${card(
319 html`<p>Starting build <span class="mono">${short}</span>.</p>
320 <p><a class="btn" data-variant="primary" href="${url}">Open ${url}</a></p>`,
321 { head: html`${icon("eye")}<h2>Opening preview</h2>` },
322 )}
323 </div>`,
290 body: html`<h2 class="mt">Opening preview</h2>
291 <p>Starting build <span class="mono">${short}</span>.</p>
292 <p><a class="btn" data-variant="primary" href="${url}">Open ${url}</a></p>`,
324293 });
325294 }
test/builds-rows.test.ts added +84
@@ -0,0 +1,84 @@
1 import { afterAll, beforeAll, expect, test } from "bun:test";
2 import { adminCookie } from "./helpers/admin.ts";
3 import { buildsList } from "../src/web/pages/builds.ts";
4 import type { BuildNode } from "../src/core/lineage.ts";
5 import { mkdtemp, rm } from "node:fs/promises";
6 import { tmpdir } from "node:os";
7 import { join } from "node:path";
8 import { createApp, type App } from "../src/app.ts";
9 import { serve, type Servers } from "../src/http/server.ts";
10 import { seedAdmin } from "./helpers/admin.ts";
11
12 let dataDir: string;
13 let app: App;
14 let servers: Servers;
15 let token = "";
16 const send = (path: string, method = "GET", body?: unknown) =>
17 fetch(`${servers.url}${path}`, {
18 method,
19 headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
20 ...(body === undefined ? {} : { body: JSON.stringify(body) }),
21 });
22
23 beforeAll(async () => {
24 dataDir = await mkdtemp(join(tmpdir(), "forge-builds-rows-"));
25 app = createApp({ dataDir, port: 0 });
26 servers = await serve(app);
27 await seedAdmin(app, { username: "dev", email: "[email protected]", password: "rows-pass-1" });
28 token = await fetch(`${servers.url}/api/v1/tokens`, {
29 method: "POST",
30 headers: { "content-type": "application/json" },
31 body: JSON.stringify({ adminConfirm: true, username: "dev", password: "rows-pass-1", name: "c", scope: "rw" }),
32 })
33 .then((r) => r.json())
34 .then((j) => (j as { token: string }).token);
35 await send("/api/v1/repos", "POST", { name: "lib", visibility: "public" });
36 });
37 afterAll(async () => {
38 await servers.stop();
39 await rm(dataDir, { recursive: true, force: true });
40 });
41
42 test("builds: no builds is a sentence, a pin is a row in a list, and no .card", async () => {
43 const empty = await (await fetch(`${servers.url}/dev/lib/builds`)).text();
44 expect(empty).toContain("<h2");
45 expect(empty).toContain("No builds yet.");
46 expect(empty).not.toMatch(/class="card/);
47
48 const c = await send("/api/v1/repos/dev/lib/contents", "POST", { branch: "main", message: "one", files: [{ path: "a.txt", content: "x\n" }] });
49 const made = (await c.json()) as { commitOid?: string; oid?: string; sha?: string };
50 const oid = made.commitOid ?? made.oid ?? made.sha;
51 expect((await send("/api/v1/repos/dev/lib/builds", "POST", { label: "v1", sha: oid })).ok).toBe(true);
52
53 const list = await (await fetch(`${servers.url}/dev/lib/builds`)).text();
54 expect(list).toMatch(/<ul class="list builds">/);
55 expect(list).toMatch(/<li data-depth="0">[\s\S]*?class="row"[\s\S]*?>v1</);
56 expect(list).toContain("Screenshots");
outdated

Only the read-only list is tested. Add the writer rows (Preview form, Delete link to /-/confirm) and the /builds/new markup (h1, .narrow-wide, .form-actions, no .card), so that box 2 and the trail are covered by a test that fails without the change.

57 expect(list).not.toMatch(/class="card/);
58 expect(list).not.toContain("build-row");
59 });
60
61 test("builds: a writer's rows carry Preview in a post form and Delete as a link to /-/confirm; the trail is one quiet group", () => {
62 const node = {
63 pin: { label: "v1", oid: "a".repeat(40), note: "v1", source: "commit", pinnedBy: "dev", pinnedAt: 0 },
64 depth: 0,
65 edge: "git",
66 } as unknown as BuildNode;
67 const out = buildsList("/dev/lib", [node], true, true, true).toString();
68 expect(out).toMatch(/<form method="post" action="\/dev\/lib\/builds\/preview"[\s\S]*?<button[^>]*>[\s\S]*?Preview/);
69 expect(out).toMatch(/<a class="small build-del" href="\/-\/confirm\?to=[^"]*delete"/);
70 expect(out).not.toMatch(/class="card/);
71 const read = buildsList("/dev/lib", [node], false, false, false).toString();
72 expect(read).not.toContain("Preview");
73 expect(read).not.toContain("/-/confirm");
74 });
75
76 test("builds/new: the pin form is the issue form's markup", async () => {
77 const cookie = await adminCookie(app, { username: "ed", email: "[email protected]", password: "rows-pass-2" });
78 const form = await (await fetch(`${servers.url}/dev/lib/builds/new`, { headers: { cookie } })).text();
79 expect(form).toContain('<h1 class="mt">Pin a build</h1>');
80 expect(form).toContain("narrow-wide");
81 expect(form).toContain('class="form-actions"');
82 expect(form).toMatch(/<a\s[^>]*data-variant="link"[^>]*>Cancel<\/a/);
83 expect(form).not.toMatch(/class="card/);
84 });
test/iterations.test.ts +2 −2
@@ -350,8 +350,8 @@ describe("iterations page", () => {
350350
351351 test("Builds draws the family's pins as a lineage, each under its parent, and the pin form offers them as Parent", async () => {
352352 const list = await (await get("/alice/game/builds")).text();
353 expect(list).toMatch(/data-depth="0"[^>]*class="build-row">[\s\S]*?>v1</);
354 expect(list).toMatch(/data-depth="1"[^>]*class="build-row">[\s\S]*?>v2</);
353 expect(list).toMatch(/data-depth="0"[^>]*>[\s\S]*?>v1</);
354 expect(list).toMatch(/data-depth="1"[^>]*>[\s\S]*?>v2</);
355355 expect(list).toMatch(/<span class="muted">bob\/game:<\/span>b1/);
356356 const cookie = await signIn("alice");
357357 const form = await (await get("/alice/game/builds/new", cookie)).text();

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs