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

A fork's following panel draws §5's card, row and btn, and the .following-* classes leave the markup #4209

Merged opened this

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

Merged

scout/forge:scout/4152-a-fork-s-following-panel-draws-5-s-card → main 3 commits fork 3 threads · 3 unresolved

  • Merged. scout/4152-a-fork-s-following-panel-draws-5-s-card was merged into main as 0c25cfd.

Review

Sign in to review this change.
2 files changed +106 −67
src/web/pages/following.ts +57 −58
@@ -42,7 +42,7 @@ import { openProposal, proposeUpstream } from "../../actions/upstream.ts";
4242 import { field, readForm } from "../form.ts";
4343 import { html, raw, when, type Html } from "../html.ts";
4444 import { flashOrJson } from "../layout.ts";
45 import { badge, btn, card, icon, type Tone } from "../ui.ts";
45 import { badge, btn, card, icon, row, type Tone } from "../ui.ts";
4646 import { disconnectedOn } from "./frozen.ts";
4747
4848 /**
@@ -140,25 +140,17 @@ export async function followingOf(
140140 * parent cannot be read, that it cannot and how old the last copy is.
141141 * Every field comes from another server and is escaped by `html`.
142142 */
143 export function releasesBlock(parentUrl: string, releases: ParentReleases): Html {
143 export function releasesBlock(
144 parentUrl: string,
145 releases: ParentReleases,
146 ): { sentence: Html; links: Html[] } {
144147 const host = new URL(parentUrl).host;
145148 const version = running().version;
146149 const { releases: news } = parentForgeOf(parentUrl, releases, version);
147 return html`<div class="following-releases">
148 <p class="following-sentence">
149 ${releasesSentence(releases, version, host)}
150 </p>
151 ${news.length === 0
152 ? null
153 : html`<ul class="list">
154 ${news.map(
155 (r) =>
156 html`<li>
157 <a href="${r.url}" rel="noopener noreferrer">${r.name}</a>
158 </li>`,
159 )}
160 </ul>`}
161 </div>`;
150 return {
151 sentence: html`<p>${releasesSentence(releases, version, host)}</p>`,
152 links: news.map((r) => row({ href: r.url, title: r.name })),
153 };
162154 }
163155
164156 function plural(n: number, one: string, many = `${one}s`): string {
@@ -325,47 +317,54 @@ export function followingCard(
325317 >; say there what to do.`
326318 : null;
327319
328 const body = html`<div class="following-head">
329 <span class="following-eyebrow"
330 >${icon("fork")} following
331 <a href="/${parent.ownerName}/${parent.name}">${parentName}</a></span
332 >
333 ${converge?.kind === "proposed"
334 ? badge("proposal ready", { tone: "merged" })
335 : converge?.kind === "working"
336 ? badge("converging", { tone: "attn" })
337 : relationBadge(relation)}
338 </div>
339 <p class="following-sentence">
340 ${parent.detached === null
341 ? relationSentence(relation)
342 : html`${parentName} is frozen: its origin
343 ${disconnectedOn(parent.detached)}, so what is here may be behind.
344 <a href="/${parent.ownerName}/${parent.name}#frozen"
345 >Reconnect it.</a
346 >`}
347 </p>
348 ${following.releases === undefined
349 ? null
350 : releasesBlock(following.releases.url, following.releases.view)}
351 ${when(
352 actions.length > 0,
353 () => html`<div class="following-actions">${actions}</div>`,
354 )}
355 ${when(foot, (f) => html`<p class="muted small following-foot">${f}</p>`)}
356 ${when(
357 followers > 0,
358 () =>
359 html`<p class="muted small following-foot">
360 <a href="${base}/forks"
361 >${followers === 1
362 ? "One fork follows"
363 : `${followers} forks follow`}
364 this one.</a
365 >
366 </p>`,
367 )}`;
368 return card(body, { id: "following", extra: "following" });
320 const head = html`${icon("fork")} following
321 <a href="/${parent.ownerName}/${parent.name}">${parentName}</a>
322 ${converge?.kind === "proposed"
323 ? badge("proposal ready", { tone: "merged" })
324 : converge?.kind === "working"
325 ? badge("converging", { tone: "attn" })
326 : relationBadge(relation)}`;
327 // Sentences wrap in plain paragraphs: a row title is one clipped line,
328 // and these carry the links a person needs.
329 const prose: Html[] = [
330 html`<p>
outdated

The frozen sentence and its Reconnect link sit in a nowrap, ellipsised .row-title; on 360px the link is clipped away. Render prose as wrapping text, not a row title.

331 ${parent.detached === null
332 ? relationSentence(relation)
333 : html`${parentName} is frozen: its origin
334 ${disconnectedOn(parent.detached)}, so what is here may be behind.
335 <a href="/${parent.ownerName}/${parent.name}#frozen"
336 >Reconnect it.</a
337 >`}
338 </p>`,
339 ];
outdated

The crew note carries links (Settings → Crew, #issue, pull) and is long; as a row title it is clipped to one line. Same fix: wrapping text.

340 const rows: Html[] = [];
341 if (following.releases !== undefined) {
342 const block = releasesBlock(following.releases.url, following.releases.view);
343 prose.push(block.sentence);
344 rows.push(...block.links);
345 }
346 if (foot !== null) prose.push(html`<p class="muted small">${foot}</p>`);
347 if (followers > 0)
348 rows.push(
349 row({
350 href: `${base}/forks`,
351 title: `${followers === 1 ? "One fork follows" : `${followers} forks follow`} this one.`,
352 }),
353 );
354 return card(
355 html`<div class="card-body stack">${prose}</div>${when(
356 rows.length > 0,
357 () => html`<ul class="list">
358 ${rows}
359 </ul>`,
360 )}`,
361 {
362 id: "following",
363 head,
364 ...(actions.length > 0 ? { foot: html`${actions}` } : {}),
365 pad: false,
366 },
367 );
369368 }
370369
371370 /**
test/following-card.test.ts +49 −9
@@ -9,7 +9,9 @@ import { tmpdir } from "node:os";
99 import { join } from "node:path";
1010 import { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts";
1111 import { createApp, type App } from "../src/app.ts";
12 import { createUser } from "../src/core/users.ts";
12 import { getRepo } from "../src/core/repos.ts";
13 import { createSession } from "../src/core/sessions.ts";
14 import { createUser, getUserByName } from "../src/core/users.ts";
1315 import { serve, type Servers } from "../src/http/server.ts";
1416 import { promote } from "./helpers/members.ts";
1517
@@ -56,14 +58,52 @@ afterAll(async () => {
5658 await rm(dataDir, { recursive: true, force: true });
5759 });
5860
59 test("a fork's page has the following card, built by card()", async () => {
60 const path = "/bob/lib";
61 const res = await fetch(`${servers.url}${path}`, { headers: { cookie: "forge_mode=dev" } });
61 test("a fork's page draws the following panel with card, row and btn", async () => {
62 const res = await fetch(`${servers.url}/bob/lib`, { headers: { cookie: "forge_mode=dev" } });
6263 const page = await res.text();
63 const m = page.match(/<div\s+class="card following"\s+id="following"[^>]*>[\s\S]*?<div class="card-body">([\s\S]*?)<\/div>\s*<\/div>/);
64 expect(page).not.toContain("<section class=\"card following\"");
64 const m = page.match(/<div\s+class="card"\s+id="following"[\s\S]*?<\/ul>/);
6565 expect(m).not.toBeNull();
66 expect(m![0]).toContain("following-eyebrow");
67 expect(m![0]).toContain('class="badge');
68 expect(m![0]).toContain("following-sentence");
66 const panel = m![0];
67 for (const old of ["following-actions", "following-eyebrow", "following-foot", "following-head", "following-sentence"])
68 expect(page).not.toContain(old);
69 expect(page).not.toMatch(/class="[^"]*\bfollowing\b[^"]*"/);
70 expect(panel).toContain("card-head");
71 expect(panel).toContain('class="badge');
72 expect(panel).toMatch(/<div class="card-body stack">\s*<p>\s*Everything here is up to date with alice\/lib\./);
73 expect(panel).toContain('href="/alice/lib"');
74 expect(panel).toContain("following");
75 }, 30_000);
76
77 test("the card foot holds the take and propose forms, each a post form with a btn", async () => {
78 const alice = getUserByName(app.db, "alice")!;
79 const bob = getUserByName(app.db, "bob")!;
80 // The fork has work of its own first, or following would fast-forward it.
81 for (const [user, repo, path] of [
82 [bob, "lib", "mine.txt"],
83 [alice, "lib", "upstream.txt"],
84 ] as const) {
85 const r = getRepo(app.db, user.username, repo)!;
86 const done = await commitToBranch(app, user, r, {
87 message: `Add ${path}`,
88 files: [{ path, content: `${path}\n` }],
89 });
90 if (!done.ok) throw new Error(JSON.stringify(done.error));
91 }
92 const cookie = `forge_session=${createSession(app.db, bob.id).token}`;
93 // Following happens on the bus after the push; wait for it to land.
94 let page = "";
95 for (let i = 0; i < 125 && !page.includes('value="take"'); i++) {
96 page = await (await fetch(`${servers.url}/bob/lib`, { headers: { cookie } })).text();
97 if (!page.includes('value="take"')) await Bun.sleep(40);
98 }
99 const foot = page.match(/<div class="card-foot">([\s\S]*?)<\/div>\s*<\/div>/);
100 expect(foot).not.toBeNull();
101 // Prose wraps: sentences and notes are paragraphs, never a (nowrap) row title.
102 const body = page.match(/id="following"[\s\S]*?<div class="card-body stack">([\s\S]*?)<\/div>/);
103 expect(body).not.toBeNull();
104 expect(body![1]).toContain("<p>");
105 expect(body![1]).not.toContain("row-title");
106 const forms = [...foot![1]!.matchAll(/<form method="post" action="\/bob\/lib\/following">([\s\S]*?)<\/form>/g)];
107 expect(forms.map((f) => f[1]!.match(/name="do" value="(\w+)"/)![1])).toEqual(["take", "propose"]);
108 for (const f of forms) expect(f[1]).toMatch(/<button[^>]*type="submit"[^>]*class="btn|<button[^>]*class="btn[^>]*type="submit"/);
69109 }, 30_000);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs