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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4152-a-fork-s-following-panel-draws-5-s-card → main 3 commits fork 3 threads · 3 unresolved
2 files
changed +106
−67
src/web/pages/following.ts +57 −58
| @@ -42,7 +42,7 @@ import { openProposal, proposeUpstream } from "../../actions/upstream.ts"; | ||
| 42 | 42 | import { field, readForm } from "../form.ts"; |
| 43 | 43 | import { html, raw, when, type Html } from "../html.ts"; |
| 44 | 44 | 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"; | |
| 46 | 46 | import { disconnectedOn } from "./frozen.ts"; |
| 47 | 47 | |
| 48 | 48 | /** |
| @@ -140,25 +140,17 @@ export async function followingOf( | ||
| 140 | 140 | * parent cannot be read, that it cannot and how old the last copy is. |
| 141 | 141 | * Every field comes from another server and is escaped by `html`. |
| 142 | 142 | */ |
| 143 | export function releasesBlock(parentUrl: string, releases: ParentReleases): Html { | |
| 143 | export function releasesBlock( | |
| 144 | parentUrl: string, | |
| 145 | releases: ParentReleases, | |
| 146 | ): { sentence: Html; links: Html[] } { | |
| 144 | 147 | const host = new URL(parentUrl).host; |
| 145 | 148 | const version = running().version; |
| 146 | 149 | 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 | }; | |
| 162 | 154 | } |
| 163 | 155 | |
| 164 | 156 | function plural(n: number, one: string, many = `${one}s`): string { |
| @@ -325,47 +317,54 @@ export function followingCard( | ||
| 325 | 317 | >; say there what to do.` |
| 326 | 318 | : null; |
| 327 | 319 | |
| 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> | |
| 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 | ); | |
| 369 | 368 | } |
| 370 | 369 | |
| 371 | 370 | /** |
test/following-card.test.ts +49 −9
| @@ -9,7 +9,9 @@ import { tmpdir } from "node:os"; | ||
| 9 | 9 | import { join } from "node:path"; |
| 10 | 10 | import { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts"; |
| 11 | 11 | 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"; | |
| 13 | 15 | import { serve, type Servers } from "../src/http/server.ts"; |
| 14 | 16 | import { promote } from "./helpers/members.ts"; |
| 15 | 17 | |
| @@ -56,14 +58,52 @@ afterAll(async () => { | ||
| 56 | 58 | await rm(dataDir, { recursive: true, force: true }); |
| 57 | 59 | }); |
| 58 | 60 | |
| 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" } }); | |
| 62 | 63 | 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>/); | |
| 65 | 65 | 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"/); | |
| 69 | 109 | }, 30_000); |
new above
offline
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.