Public: anyone can read this; the crew can change it.
A repository's fork bar draws §5's row, badge and btn, and .fork-bar leaves the markup #4210
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/4168-a-repository-s-fork-bar-draws-5-s-row-ba → main 1 commit fork 1 thread · 1 unresolved
2 files
changed +111
−16
src/web/pages/repo.ts +18 −16
| @@ -58,6 +58,7 @@ import { renderMarkdown } from "../markdown.ts"; | ||
| 58 | 58 | import { browseBase, localScope, type RepoScope } from "../scope.ts"; |
| 59 | 59 | import { repoForkOf } from "../../core/origins.ts"; |
| 60 | 60 | import { |
| 61 | badge, | |
| 61 | 62 | btn, |
| 62 | 63 | card, |
| 63 | 64 | empty, |
| @@ -175,22 +176,23 @@ function forkBar(ctx: Ctx, repo: Repo): Html { | ||
| 175 | 176 | ctx.auth.user !== undefined && ctx.auth.user.id !== repo.ownerId; |
| 176 | 177 | const forkCount = forksFor(ctx, repo).length; |
| 177 | 178 | if (!canFork && forkCount === 0) return html``; |
| 178 | return html`<div class="fork-bar"> | |
| 179 | ${when( | |
| 180 | forkCount > 0, | |
| 181 | () => | |
| 182 | html`<a class="muted small" href="${repoBase(repo)}/forks" | |
| 183 | >${icon("fork")} ${forkCount} fork${forkCount === 1 ? "" : "s"}</a | |
| 184 | >`, | |
| 185 | )} | |
| 186 | <span class="spacer"></span> | |
| 187 | ${when( | |
| 188 | canFork, | |
| 189 | () => | |
| 190 | html`<form method="post" action="${repoBase(repo)}/fork"> | |
| 191 | ${btn("Fork", { size: "sm", icon: "fork" })} | |
| 192 | </form>`, | |
| 193 | )} | |
| 179 | return html`<div class="mt"> | |
| 180 | ${listCard([ | |
| 181 | row({ | |
| 182 | lead: icon("fork"), | |
| 183 | title: | |
| 184 | forkCount > 0 | |
| 185 | ? html`<a href="${repoBase(repo)}/forks" | |
| 186 | >${badge(`${forkCount} fork${forkCount === 1 ? "" : "s"}`)}</a | |
| 187 | >` | |
| 188 | : "Forks", | |
| 189 | trail: canFork | |
| 190 | ? html`<form method="post" action="${repoBase(repo)}/fork"> | |
| 191 | ${btn("Fork", { size: "sm", icon: "fork" })} | |
| 192 | </form>` | |
| 193 | : html``, | |
| 194 | }), | |
| 195 | ])} | |
| 194 | 196 | </div>`; |
| 195 | 197 | } |
| 196 | 198 |
test/repo-fork-bar.test.ts added +93
| @@ -0,0 +1,93 @@ | ||
| 1 | /** | |
| 2 | * The fork bar on a repository's home is drawn with the design's row, badge | |
| 3 | * and btn: none of `.fork-bar` in the markup, and the count link and the Fork | |
| 4 | * form (same action, no fields) still there. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { setRole } from "../src/actions/roles.ts"; | |
| 11 | import { commitToBranch, createRepo, forkRepo } from "../src/actions/repos.ts"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { createUser } from "../src/core/users.ts"; | |
| 14 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 15 | import { quietLogs } from "./helpers/globals.ts"; | |
| 16 | ||
| 17 | quietLogs(); | |
| 18 | ||
| 19 | let dataDir: string; | |
| 20 | let app: App; | |
| 21 | let servers: Servers; | |
| 22 | ||
| 23 | const signIn = async (username: string): Promise<string> => { | |
| 24 | const r = await fetch(`${servers.url}/signin`, { | |
| 25 | method: "POST", | |
| 26 | body: new URLSearchParams({ username, password: "a-good-passphrase" }), | |
| 27 | redirect: "manual", | |
| 28 | }); | |
| 29 | return (r.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 30 | }; | |
| 31 | ||
| 32 | const page = async (path: string, cookie = ""): Promise<string> => { | |
| 33 | const r = await fetch(`${servers.url}${path}`, { | |
| 34 | headers: cookie ? { cookie } : {}, | |
| 35 | }); | |
| 36 | expect(r.status).toBe(200); | |
| 37 | return r.text(); | |
| 38 | }; | |
| 39 | ||
| 40 | beforeAll(async () => { | |
| 41 | dataDir = await mkdtemp(join(tmpdir(), "forge-fork-bar-")); | |
| 42 | app = createApp({ dataDir, port: 0, host: "forge.test" }); | |
| 43 | servers = await serve(app); | |
| 44 | const users = []; | |
| 45 | for (const [username, first] of [["hank", true], ["olive", false], ["ravi", false]] as const) { | |
| 46 | const made = await createUser(app.db, { | |
| 47 | origin: "seed", | |
| 48 | trusted: true, | |
| 49 | username, | |
| 50 | email: `${username}@forge.local`, | |
| 51 | password: "a-good-passphrase", | |
| 52 | adminIfFirst: first, | |
| 53 | }); | |
| 54 | if (!made.ok) throw new Error(made.error); | |
| 55 | users.push(made.value); | |
| 56 | } | |
| 57 | const promoted = setRole(app, users[0]!, "olive", "member"); | |
| 58 | if (!promoted.ok) throw new Error(promoted.error); | |
| 59 | const promoted2 = setRole(app, users[0]!, "ravi", "member"); | |
| 60 | if (!promoted2.ok) throw new Error(promoted2.error); | |
| 61 | const r = await createRepo(app, users[1]!, { name: "yard", visibility: "public" }); | |
| 62 | if (!r.ok) throw new Error(String(r.error)); | |
| 63 | await commitToBranch(app, users[1]!, r.value, { | |
| 64 | message: "first", | |
| 65 | files: [{ path: "a.txt", content: "a\n" }], | |
| 66 | }); | |
| 67 | const f = await forkRepo(app, users[2]!, r.value); | |
| 68 | if (!f.ok) throw new Error(String(f.error)); | |
| 69 | }); | |
| 70 | ||
| 71 | afterAll(async () => { | |
| 72 | await servers.stop(); | |
| 73 | await rm(dataDir, { recursive: true, force: true }); | |
| 74 | }); | |
| 75 | ||
| 76 | test("a visitor who can fork sees the count link and the Fork form in the design's classes", async () => { | |
| 77 | const h = await page("/olive/yard", await signIn("hank")); | |
| 78 | expect(h).not.toContain("fork-bar"); | |
| 79 | expect(h).toContain('href="/olive/yard/forks"'); | |
| 80 | expect(h).toMatch(/class="badge"[^>]*>\s*1 fork\s*</); | |
| 81 | const form = h.match(/<form method="post" action="\/olive\/yard\/fork">[\s\S]*?<\/form>/)?.[0] ?? ""; | |
| 82 | expect(form).toContain("Fork"); | |
| 83 | expect(form).toContain('class="btn'); | |
| 84 | expect(form).not.toContain("<input"); | |
| 85 | expect(h).toContain('class="row"'); | |
| 86 | }); | |
| 87 | ||
| 88 | test("the owner sees the count but no Fork form", async () => { | |
| 89 | const h = await page("/olive/yard", await signIn("olive")); | |
| 90 | expect(h).not.toContain("fork-bar"); | |
| 91 | expect(h).toContain('href="/olive/yard/forks"'); | |
| 92 | expect(h).not.toContain('action="/olive/yard/fork"'); | |
| 93 | }); |
new above
offline
A repository with no forks now shows this row to anyone who can fork, where before the bar was minimal. That is fine, but it has no test. Add one for a zero-fork repo viewed by a non-owner.