Public: anyone can read this; the crew can change it.
The waves pages mark no repository tab as Files, and an empty waves page says what a wave is and how to cut the first #6161
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6149-the-waves-pages-mark-no-repository-tab-a → main 2 commits fork
3 files
changed +39
−6
src/web/chrome.ts +3 −1
| @@ -119,7 +119,9 @@ export type RepoTab = | ||
| 119 | 119 | | "branches" |
| 120 | 120 | | "app" |
| 121 | 121 | | "runs" |
| 122 | | "settings"; | |
| 122 | | "settings" | |
| 123 | /** A page that belongs to no tab (waves): nothing is marked current. */ | |
| 124 | | "none"; | |
| 123 | 125 | |
| 124 | 126 | export interface RepoHeaderOptions { |
| 125 | 127 | /** Rendered at the right of the title line (fork, clone, watch). */ |
src/web/pages/waves.ts +18 −5
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | * it does for any other page of a private repository. |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | import { canRead, getRepo } from "../../core/repos.ts"; | |
| 9 | import { canAdmin, canRead, getRepo } from "../../core/repos.ts"; | |
| 10 | 10 | import { notFound } from "../../http/respond.ts"; |
| 11 | 11 | import { holds, stamp, xml } from "../feeds.ts"; |
| 12 | 12 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| @@ -19,9 +19,12 @@ import { NO_WAVES } from "../../story/words.ts"; | ||
| 19 | 19 | import { loadRepo, notFoundPage, repoBase, scopeHeader } from "../chrome.ts"; |
| 20 | 20 | import { escapeHtml, html } from "../html.ts"; |
| 21 | 21 | import { page } from "../layout.ts"; |
| 22 | import { empty, listCard, row } from "../ui.ts"; | |
| 22 | import { btn, empty, listCard, row } from "../ui.ts"; | |
| 23 | 23 | import { localScope } from "../scope.ts"; |
| 24 | 24 | |
| 25 | const WHAT_A_WAVE = | |
| 26 | "A wave is a release note in waves/<version>.md that names its highlights and the pulls merged since the last one."; | |
| 27 | ||
| 25 | 28 | /** A wave's row title: `<version> · <headline> · N pulls`, N being the pulls this viewer can see. */ |
| 26 | 29 | export function waveLine(w: Wave, visible: number): string { |
| 27 | 30 | return `${w.version} · ${w.headline} · ${visible} ${visible === 1 ? "pull" : "pulls"}`; |
| @@ -58,10 +61,20 @@ export async function wavesPage(ctx: Ctx): Promise<Response> { | ||
| 58 | 61 | }), |
| 59 | 62 | ); |
| 60 | 63 | |
| 61 | const body = html`${scopeHeader(ctx, scope, "code", { compact: true })} | |
| 64 | const body = html`${scopeHeader(ctx, scope, "none", { compact: true })} | |
| 62 | 65 | <div class="mt"> |
| 63 | 66 | ${entries.length === 0 |
| 64 | ? empty({ title: "No waves yet", note: NO_WAVES }) | |
| 67 | ? empty({ | |
| 68 | title: "No waves yet", | |
| 69 | note: `${WHAT_A_WAVE} ${NO_WAVES}`, | |
| 70 | ...(canAdmin(ctx.app.db, repo, ctx.auth.user) | |
| 71 | ? { | |
| 72 | action: html`<form method="post" action="${repoBase(repo)}/settings/wave"> | |
| 73 | ${btn("Cut a wave", { variant: "primary" })} | |
| 74 | </form>`, | |
| 75 | } | |
| 76 | : {}), | |
| 77 | }) | |
| 65 | 78 | : listCard(rows, { head: html`<h2>Waves</h2>` })} |
| 66 | 79 | </div>`; |
| 67 | 80 | |
| @@ -189,7 +202,7 @@ export async function wavePage(ctx: Ctx): Promise<Response> { | ||
| 189 | 202 | : html`<h1>${entry.file}</h1> |
| 190 | 203 | <p class="muted">Not a wave: ${entry.reason}</p>`; |
| 191 | 204 | |
| 192 | const body = html`${scopeHeader(ctx, scope, "code", { compact: true })} | |
| 205 | const body = html`${scopeHeader(ctx, scope, "none", { compact: true })} | |
| 193 | 206 | <div class="mt">${content}<p>${back}</p></div>`; |
| 194 | 207 | |
| 195 | 208 | const response = page({ |
test/waves-page.test.ts +18
| @@ -149,6 +149,24 @@ describe("waves page", () => { | ||
| 149 | 149 | expect(await res.text()).toContain("No waves yet"); |
| 150 | 150 | }); |
| 151 | 151 | |
| 152 | test("the empty state says what a wave is; an admin gets Cut a wave, a reader does not", async () => { | |
| 153 | const admin = await (await get("/alice/bare/waves")).text(); | |
| 154 | expect(admin).toContain("A wave is a release note"); | |
| 155 | expect(admin).toContain('action="/alice/bare/settings/wave"'); | |
| 156 | expect(admin).toContain("Cut a wave"); | |
| 157 | const reader = await (await get("/alice/bare/waves", false)).text(); | |
| 158 | expect(reader).toContain("A wave is a release note"); | |
| 159 | expect(reader).not.toContain("Cut a wave"); | |
| 160 | expect(reader).not.toContain("/settings/wave"); | |
| 161 | }); | |
| 162 | ||
| 163 | test("the waves pages mark no tab as current", async () => { | |
| 164 | for (const path of ["/alice/pub/waves", "/alice/bare/waves", "/alice/pub/waves/0.10.0"]) { | |
| 165 | const body = await (await get(path)).text(); | |
| 166 | expect(body).not.toContain('aria-current="page"'); | |
| 167 | } | |
| 168 | }); | |
| 169 | ||
| 152 | 170 | test("a private repository is refused to an outsider as any other page is", async () => { |
| 153 | 171 | const waves = await get("/alice/priv/waves", false); |
| 154 | 172 | const other = await get("/alice/priv/branches", false); |
new above
offline