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

An issue page's side cards (labels, assignee, teams, dependencies) speak the home page's language #9868

Merged opened this

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

Merged

scout/forge:scout/9816-an-issue-page-s-side-cards-labels-assign → main 2 commits fork

  • Merged. scout/9816-an-issue-page-s-side-cards-labels-assign was merged into main as ee60892.

Review

Sign in to review this change.
3 files changed +80 −2
docs/design/areas/issues-list.md +7
@@ -17,3 +17,10 @@ sideways. No new class. The owner's phone look of `/preview-admin/sample/issues`
1717
1818 The list is rows on the page, not a card: the list card is `.card.bare` (no box, no hairlines between rows), and
1919 space does the separating. Its title is the system's row title, the same type the home page's rows use (no size of its own, no padding literal); the one link is the title's row. Filters, labels, the word and the pillar are unchanged. No new class.
20
21 ## An issue's side cards (#9816)
22
23 The side column of an issue (`labelsCard`, `assigneeCard`, `issueTeamsCard`, `dependenciesCard` in
24 `src/web/pages/thread.ts`) reads like the pull page's side: a small heading over its rows, `.card.bare`, no box and
25 no hairline. Chips, forms and empty lines are unchanged; titles stay escaped. The pull page draws the same label and
26 assignee cards, so it follows. No new class. `test/issue-side-bare.test.ts` holds the shape.
src/web/pages/thread.ts +7 −2
@@ -666,6 +666,7 @@ export function labelsCard(
666666 return card(chips, {
667667 head: html`${icon("tag")}
668668 <h2>Labels</h2>`,
669 extra: "bare",
669670 });
670671
671672 const applies = new Set(applied.map((l) => l.id));
@@ -694,6 +695,7 @@ export function labelsCard(
694695 {
695696 head: html`${icon("tag")}
696697 <h2>Labels</h2>`,
698 extra: "bare",
697699 },
698700 );
699701 }
@@ -762,6 +764,7 @@ export function assigneeCard(
762764 return card(current, {
763765 head: html`${icon("user")}
764766 <h2>Assignee</h2>`,
767 extra: "bare",
765768 });
766769 const candidates = assigneeCandidates(ctx, repo, issue);
767770 return card(
@@ -790,6 +793,7 @@ export function assigneeCard(
790793 {
791794 head: html`${icon("user")}
792795 <h2>Assignee</h2>`,
796 extra: "bare",
793797 },
794798 );
795799 }
@@ -1027,7 +1031,7 @@ export function issueTeamsCard(ctx: Ctx, repo: Repo, issue: Issue, action: strin
10271031 ? html`<p class="muted small">None named.</p>`
10281032 : html`<p>${named.map((slug) => all.find((t) => t.slug === slug)?.name ?? slug).join(", ")}</p>`;
10291033 if (!canWrite(ctx.app.db, repo, ctx.auth.user) || repo.origin !== null)
1030 return card(chips, { head });
1034 return card(chips, { head, extra: "bare" });
10311035 return card(
10321036 html`${chips}
10331037 <form method="post" action="${action}" class="mt">
@@ -1042,7 +1046,7 @@ export function issueTeamsCard(ctx: Ctx, repo: Repo, issue: Issue, action: strin
10421046 </fieldset>
10431047 <div class="form-actions">${btn("Name teams", { size: "sm", variant: "primary" })}</div>
10441048 </form>`,
1045 { head },
1049 { head, extra: "bare" },
10461050 );
10471051 }
10481052
@@ -1165,6 +1169,7 @@ export function dependenciesCard(
11651169 return card(body, {
11661170 head: html`${icon("map")}
11671171 <h2>Depends on</h2>`,
1172 extra: "bare",
11681173 });
11691174 }
11701175
test/issue-side-bare.test.ts added +66
@@ -0,0 +1,66 @@
1 /** An issue's side cards speak the home page's language (#9816): headings
2 * over rows, no boxed card. */
3 import { afterAll, beforeAll, expect, test } from "bun:test";
4 import { mkdtemp, rm } from "node:fs/promises";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import { createApp, type App } from "../src/app.ts";
8 import { openIssue } from "../src/actions/issues.ts";
9 import { createRepo } from "../src/actions/repos.ts";
10 import { addDependency } from "../src/core/dependencies.ts";
11 import { createLabel, setIssueLabels } from "../src/core/issues.ts";
12 import { createUser } from "../src/core/users.ts";
13 import { serve, type Servers } from "../src/http/server.ts";
14
15 let dataDir: string;
16 let app: App;
17 let servers: Servers;
18
19 beforeAll(async () => {
20 dataDir = await mkdtemp(join(tmpdir(), "forge-side-"));
21 app = createApp({ dataDir, port: 0 });
22 servers = await serve(app);
23 });
24 afterAll(async () => {
25 await servers.stop?.();
26 await rm(dataDir, { recursive: true, force: true });
27 });
28
29 test("the four side cards are headings over rows, with no boxed card", async () => {
30 const a = await createUser(app.db, { origin: "seed", trusted: true,
31 username: "alice", email: "[email protected]",
32 password: "a-good-passphrase", adminIfFirst: true,
33 });
34 if (!a.ok) throw new Error(a.error);
35 const made = await createRepo(app, a.value, { name: "side", visibility: "private" });
36 if (!made.ok) throw new Error(String(made.error));
37 const repo = made.value;
38 const one = openIssue(app, a.value, repo, { title: "First" });
39 const two = openIssue(app, a.value, repo, { title: "Second" });
40 if (!one.ok || !two.ok) throw new Error("open");
41 const label = createLabel(app.db, repo.id, "bug", "ff0000");
42 if (!label.ok) throw new Error(label.error);
43 setIssueLabels(app.db, two.value.id, [label.value.id]);
44 const dep = addDependency(app.db, two.value, one.value, a.value.id);
45 if (!dep.ok) throw new Error(String(dep.error));
46
47 const cookie = (
48 (
49 await fetch(`${servers.url}/signin`, {
50 method: "POST",
51 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
52 redirect: "manual",
53 })
54 ).headers.get("set-cookie") ?? ""
55 ).split(";")[0]!;
56 const page = await (
57 await fetch(`${servers.url}/alice/side/issues/${two.value.number}`, { headers: { cookie } })
58 ).text();
59 for (const h of ["Labels", "Assignee", "Teams", "Depends on"])
60 expect(page).toContain(`<h2>${h}</h2>`);
61 // The composer is another issue's work; every other .card must be bare.
62 const boxed = [...page.matchAll(/class="([^"]*)"/g)]
63 .map((m) => m[1]!.split(/\s+/))
64 .filter((c) => c.includes("card") && !c.includes("bare") && !c.includes("comment-form"));
65 expect(boxed).toEqual([]);
66 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs