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

A repository's reports pages speak the home page's language #9828

Merged opened this

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

Merged

mason/forge:mason/9761-a-repository-s-reports-pages-speak-the-h → main 4 commits fork 6 threads · 6 unresolved

  • Merged. mason/9761-a-repository-s-reports-pages-speak-the-h was merged into main as b013eca.

Review

Sign in to review this change.
5 files changed +177 −58
docs/design/areas/reports.md added +10
@@ -0,0 +1,10 @@
1 # Reports
2
3 `/:owner/:name/reports` (`src/web/pages/reports.ts`) is headings over rows: each filer is a
4 `<section class="settings-block">` with an `<h2>`, each report a `row()` in `<ul class="list setting-list">` whose meta keeps
5 "From outside, …, not checked" and whose open `fold` holds a `.report-body`; none is a `card()`.
6 The "Report sent" and "Nothing waiting" notices are plain text. `test/reports-page-shape.test.ts` holds the shape.
7
8 - `.report-body`: the open body of a report row. Its message, hint, accept form (title field and button) and dismiss form
9 stack as one grid column in reading order, so nothing shares a flex line and nothing scrolls sideways at 390px.
10 Its 14px inset matches `.row` padding, so it lines up under the row title (a report row has no lead glyph) at every width. Styled in `src/web/style/components.ts`.
src/web/pages/reports.ts +42 −55
@@ -45,7 +45,7 @@ import { field, readForm } from "../form.ts";
4545 import { pageAddress } from "../studio/toolbar.ts";
4646 import { html, when, type Html } from "../html.ts";
4747 import { flashTo, page } from "../layout.ts";
48 import { btn, card, empty, field as formField, messageText, time } from "../ui.ts";
48 import { btn, field as formField, messageText, row, time } from "../ui.ts";
4949
5050 /** The repository, when it takes reports: public, and so seen by anyone. */
5151 function publicRepo(ctx: Ctx): Repo | undefined {
@@ -155,11 +155,10 @@ function reportBody(ctx: Ctx, repo: Repo): Response {
155155 compact: true,
156156 ownTitle: true,
157157 })}
158 ${empty({
159 icon: "check",
160 title: "Report sent",
161 note: "A person who keeps this repository will read it.",
162 })}`,
158 <section class="settings-block">
159 <h1>Report sent</h1>
160 <p>A person who keeps this repository will read it.</p>
161 </section>`,
163162 });
164163 return reportForm(ctx, repo, {
165164 title: "",
@@ -230,45 +229,37 @@ export async function reportSubmit(ctx: Ctx): Promise<Response> {
230229 );
231230 }
232231
233 function reportCard(base: string, report: Report): Html {
232 function reportRow(base: string, report: Report): Html {
234233 const action = (verb: "accept" | "dismiss") =>
235234 `${base}/reports/${report.id}/${verb}`;
236 const accept = `accept-${report.id}`;
237 return card(
238 html`<p><strong>${report.title}</strong></p>
239 ${when(
240 report.body !== "",
241 () => messageText(report.body),
242 )}
243 ${when(report.page, (p) => html`<p class="hint">Sent from ${p}</p>`)}
244 <form
245 class="mt"
246 id="${accept}"
247 method="post"
248 action="${action("accept")}"
249 >
250 ${formField({
251 label: "Title of the issue",
252 name: "title",
253 id: `title-${report.id}`,
254 value: outsideWords(report.title),
255 required: true,
256 hint: `It opens as “${OUTSIDE_TITLE.trim()}” and then these words. Change the words if you like; the mark stays.`,
257 })}
258 </form>`,
259 {
260 tone: "attn",
261 head: html`From outside, ${filerWords(report.filedBy)}, not checked · ${time(report.createdAt)}`,
262 foot: html`${btn("Accept as an issue", {
263 variant: "primary",
264 icon: "issue",
265 form: accept,
266 })}
267 <form method="post" action="${action("dismiss")}">
235 return row({
236 title: report.title,
237 meta: html`From outside, ${filerWords(report.filedBy)}, not checked · ${time(report.createdAt)}`,
238 fold: {
outdated

Principles 3 and 7: after renders inside .row-after, which .setting-list styles as a right-aligned wrapping flex line (components.ts:481) for one small control. Here it holds a message div, a hint, a form with a text input, the Accept button and a second form. They become flex siblings, jammed right on desktop and wrapped unpredictably at 390px (below 640px the padding is 0 14px 10px, with no room for the input). Give the report body and the accept field their own block (for example the body and hint in meta, or a fold with the form in its body) so each action is reachable by thumb and keyboard in reading order. Open the preview at 390px and confirm no sideways scroll; the builder said this was never checked.

239 id: `report-${report.id}`,
240 open: true,
241 body: html`<div class="report-body">
242 ${when(report.body !== "", () => messageText(report.body))}
243 ${when(report.page, (p) => html`<p class="hint">Sent from ${p}</p>`)}
244 <form id="accept-${report.id}" method="post" action="${action("accept")}">
245 ${formField({
246 label: "Title of the issue",
247 name: "title",
248 id: `title-${report.id}`,
249 value: outsideWords(report.title),
250 required: true,
251 hint: `It opens as “${OUTSIDE_TITLE.trim()}” and then these words. Change the words if you like; the mark stays.`,
252 })}
253 <div class="form-actions">
254 ${btn("Accept as an issue", { variant: "primary", icon: "issue" })}
255 </div>
256 </form>
257 <form class="form-actions" method="post" action="${action("dismiss")}">
268258 ${btn("Dismiss", { variant: "ghost" })}
269 </form>`,
259 </form>
260 </div>`,
270261 },
271 );
262 });
272263 }
273264
274265 const sameFiler = (a: FiledBy, b: FiledBy): boolean =>
@@ -313,22 +304,18 @@ export function reportsPage(ctx: Ctx): Response {
313304 sees one until you accept it; then it is your issue, with the words
314305 quoted.
315306 </p>
316 <div class="mt">
317 ${waiting.value.length === 0
318 ? empty({
319 icon: "check",
320 title: "Nothing waiting",
321 note: `Anyone can report a problem at ${base}/report.`,
322 })
323 : byFiler(waiting.value).map(
324 (g) => html`<section class="mt">
325 <h2>${groupTitle(g)} · ${g.count}</h2>
307 ${waiting.value.length === 0
308 ? html`<p class="muted small mt">Nothing is waiting at <a href="${base}/report">${base}/report</a>.</p>`
outdated

Principle 1 (nothing explains itself twice): the intro paragraph above already says what the page is for. Also, ${base}/report is plain text, so make it an <a>: anything that navigates is a link.

309 : byFiler(waiting.value).map(
310 (g) => html`<section class="settings-block">
311 <h2>${groupTitle(g)} · ${g.count}</h2>
312 <ul class="list setting-list">
326313 ${waiting.value
327314 .filter((r) => sameFiler(r.filedBy, g))
328 .map((r) => reportCard(base, r))}
329 </section>`,
330 )}
331 </div>`,
315 .map((r) => reportRow(base, r))}
316 </ul>
317 </section>`,
318 )}`,
332319 });
333320 notePhase(ctx.request, "render", performance.now() - rendering);
334321 return response;
src/web/style/components.ts +3
@@ -492,6 +492,9 @@ a.row:hover .row-title { color: var(--accent); }
492492 summary.row { cursor: pointer; list-style: none; }
493493 summary.row:hover { background: var(--muted); }
494494 details[open] > summary.row > .row-lead { transform: rotate(90deg); }
495 /* A report's open body: block children in reading order, never one flex line. */
496 .report-body { padding: 0 14px 14px; display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; min-width: 0; overflow-wrap: anywhere; }
497 .report-body form { margin: 0; }
495498 /* A count with its glyph: comments on a row, members on a team. */
outdated

The 390 px behaviour rests on this one media query and nobody has looked at it. Verify it on a seeded page; .report-body also sets its own padding-left: 40px to line up under the row title, so check it still lines up with the row's lead glyph.

496499 .count {
497500 display: inline-flex; align-items: center; gap: 4px;
test/reports-page-shape.test.ts added +116
@@ -0,0 +1,116 @@
1 /** The reports pages are headings over rows, not cards (#9761). */
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 { createRepo } from "../src/actions/repos.ts";
7 import { createApp, type App } from "../src/app.ts";
8 import { createToken } from "../src/core/tokens.ts";
9 import { createUser } from "../src/core/users.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11 import { components } from "../src/web/style/components.ts";
12 import { quietLogs } from "./helpers/globals.ts";
13 import { statements } from "./helpers/statements.ts";
14
15 quietLogs();
16 let dir = "";
17 let app: App;
18 let servers: Servers;
19 let token = "";
20
21 beforeAll(async () => {
22 dir = await mkdtemp(join(tmpdir(), "forge-reports-shape-"));
23 app = createApp({ dataDir: dir, port: 0 });
24 servers = await serve(app);
25 const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
26 if (!a.ok) throw new Error(a.error);
27 const made = await createRepo(app, a.value, { name: "game", visibility: "public" });
28 if (!made.ok) throw new Error(String(made.error));
29 token = createToken(app.db, a.value.id, "t", "rw").token;
30 });
31
32 afterAll(async () => {
33 servers.stop();
34 app.close();
35 await rm(dir, { recursive: true, force: true });
36 });
37
38 /** Statements the warm reports page ran as cards (902f53c7), measured with one report waiting. */
39 const BUDGET = 34;
40 const main = (page: string) => page.slice(page.indexOf("<main"), page.indexOf("</main>"));
41
42 test("the queue is a heading over rows, escaped, with its forms and no card", async () => {
43 const res = await fetch(`${servers.url}/alice/game/report`, {
44 method: "POST",
45 headers: { origin: servers.url },
46 body: new URLSearchParams({ title: "<b>broken</b>", body: "it <i>fails</i>" }),
47 redirect: "manual",
48 });
49 expect(res.status).toBe(303);
50 const page = await (await fetch(`${servers.url}/alice/game/reports`, { headers: { authorization: `Bearer ${token}` } })).text();
51 const m = main(page);
52 expect(m).toContain('<section class="settings-block">');
53 expect(m).toContain("<h2>Anonymous · 1</h2>");
54 expect(m).toContain('<ul class="list setting-list">');
55 expect(m).toContain("From outside, anonymous, not checked");
56 expect(m).toContain("&lt;b&gt;broken&lt;/b&gt;");
57 expect(m).not.toContain("<b>broken");
58 expect(m).toMatch(/action="\/alice\/game\/reports\/[^"]+\/accept"/);
59 expect(m).toMatch(/action="\/alice\/game\/reports\/[^"]+\/dismiss"/);
60 expect(m).not.toContain('class="card');
61 expect(m).not.toContain("card-body");
62 // Nothing rides in the one-line right-aligned slot; the actions live in the
63 // row's own block, accept's button inside its form, dismiss a POST button.
64 expect(m).not.toContain("row-after");
65 const li = m.slice(m.indexOf("<li"), m.indexOf("</li>"));
66 const accept = li.match(/<form[^>]*action="[^"]*\/accept"[^>]*>([\s\S]*?)<\/form>/);
67 expect(accept).not.toBeNull();
outdated

The test asserts only the absence of card and the presence of strings. It does not check that the accept form and its button stay inside the same <li>, or that the dismiss button is a <button> in a post form. Add those, since a restructure for the layout fix will move them.

68 expect(accept![1]).toContain('name="title"');
69 expect(accept![1]).toMatch(/<button[^>]*>[\s\S]*Accept as an issue/);
70 const dismiss = li.match(/<form[^>]*method="post"[^>]*action="[^"]*\/dismiss"[^>]*>([\s\S]*?)<\/form>/);
71 expect(dismiss).not.toBeNull();
72 expect(dismiss![1]).toMatch(/<button[^>]*>[\s\S]*Dismiss/);
73 expect(li).toContain('class="report-body"');
74 });
75
76 test("an empty queue and the sent notice are plain text, not cards", async () => {
77 const sent = main(await (await fetch(`${servers.url}/alice/game/report?sent=1`)).text());
78 expect(sent).toContain("Report sent");
79 expect(sent).not.toContain('class="card');
80 const form = main(await (await fetch(`${servers.url}/alice/game/report`)).text());
81 expect(form).toContain('action="/alice/game/report"');
82 expect(form).not.toContain('class="card');
83 });
84
85 test("a report's body is one shrinkable column: nothing in it can push the page sideways at 390px", () => {
86 const rules = [...components.matchAll(/(^|\n)([^\n{}]*\.report-body[^{]*)\{([^}]*)\}/g)].map((m) => ({
87 selector: m[2]!.trim(),
88 body: m[3]!,
89 }));
90 const own = rules.find((r) => r.selector === ".report-body");
91 expect(own).toBeDefined();
92 // A grid's implicit `auto` column grows to its widest child; minmax(0, 1fr)
93 // lets it shrink to the viewport, and the children wrap instead.
94 expect(own!.body).toContain("display: grid");
95 expect(own!.body).toContain("grid-template-columns: minmax(0, 1fr)");
96 expect(own!.body).toContain("min-width: 0");
97 expect(own!.body).toContain("overflow-wrap: anywhere");
98 // Its inset is the row's own 14px, so it lines up under the row title.
99 expect(own!.body).toContain("padding: 0 14px 14px");
100 // No rule on it sets a fixed width or puts its parts on one flex line.
101 for (const r of rules) {
102 expect(r.body).not.toMatch(/(^|[;\s])(min-)?width:\s*\d+px/);
103 expect(r.body).not.toMatch(/display:\s*(inline-)?flex/);
104 expect(r.body).not.toMatch(/white-space:\s*nowrap/);
105 }
106 });
107
108 test("the warm reports page runs no more statements than it did as cards", async () => {
109 await fetch(`${servers.url}/alice/game/reports`, { headers: { authorization: `Bearer ${token}` } });
110 const used = await statements(app.db, async () => {
111 const res = await fetch(`${servers.url}/alice/game/reports`, { headers: { authorization: `Bearer ${token}` } });
112 expect(res.status).toBe(200);
113 await res.text();
114 });
115 expect(used).toBeLessThanOrEqual(BUDGET);
116 });
test/reports.test.ts +6 −3
@@ -418,9 +418,9 @@ describe("reports from outside", () => {
418418 expect(page).toContain(
419419 '<div class="prose" data-text="message"><pre>Menu &lt;b&gt;breaks&lt;/b&gt;\n on phones</pre></div>',
420420 );
421 // The card of the bodiless report, from its opening to its accept form.
421 // The row of the bodiless report, from its opening to its accept form.
422422 const end = page.indexOf(`id="accept-${without}"`);
423 const card = page.slice(page.lastIndexOf('class="card', end), end);
423 const card = page.slice(page.lastIndexOf('<li', end), end);
424424 expect(card).toContain("silent bug");
425425 expect(card).not.toContain('data-text="message"');
426426 expect(page.indexOf(`id="accept-${withBody}"`)).toBeGreaterThan(-1);
@@ -431,7 +431,10 @@ describe("reports from outside", () => {
431431 const id = (await waiting("menu bad"))!;
432432 const page = await (await as(aliceToken, "/alice/game/reports")).text();
433433 expect(page).toContain(`id="accept-${id}"`);
434 expect(page).toContain(`form="accept-${id}"`);
434 // The Accept button sits inside its own form, not beside it.
435 const acceptForm = page.match(new RegExp(`<form id="accept-${id}"[^>]*>([\\s\\S]*?)</form>`));
436 expect(acceptForm).not.toBeNull();
outdated

This regex is loose: [^>]>[\s\S]?Accept as an issue would still match if the button sat outside the form. Pin it to the form, as the new shape test does with its <form> capture.

437 expect(acceptForm![1]).toMatch(/<button[^>]*>[\s\S]*Accept as an issue[\s\S]*<\/button>/);
435438 expect(page).toMatch(
436439 new RegExp(`id="title-${id}"[^>]*name="title"[^>]*value="menu bad"`),
437440 );

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs