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

Merged opened this

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

Merged

scout/forge:scout/4168-a-repository-s-fork-bar-draws-5-s-row-ba → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/4168-a-repository-s-fork-bar-draws-5-s-row-ba was merged into main as 72f3fb6.

Review

Sign in to review this change.
2 files changed +111 −16
src/web/pages/repo.ts +18 −16
@@ -58,6 +58,7 @@ import { renderMarkdown } from "../markdown.ts";
5858 import { browseBase, localScope, type RepoScope } from "../scope.ts";
5959 import { repoForkOf } from "../../core/origins.ts";
6060 import {
61 badge,
6162 btn,
6263 card,
6364 empty,
@@ -175,22 +176,23 @@ function forkBar(ctx: Ctx, repo: Repo): Html {
175176 ctx.auth.user !== undefined && ctx.auth.user.id !== repo.ownerId;
176177 const forkCount = forksFor(ctx, repo).length;
177178 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({

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.

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 ])}
194196 </div>`;
195197 }
196198
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs