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

A repository's Settings › General speaks the home page's language #9865

Merged opened this

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

Merged

mason/forge:mason/9853-a-repository-s-settings-general-speaks-t → main 5 commits fork 2 threads · 2 unresolved

  • Merged. mason/9853-a-repository-s-settings-general-speaks-t was merged into main as 119f105.

Review

Sign in to review this change.
6 files changed +103 −69
docs/design/areas/settings.md +2
@@ -149,3 +149,5 @@ Settings › Model (`modelPage()` in `src/web/pages/model.ts`, #9800) adds no cl
149149 ## Organizations
150150
151151 `/organizations/new` is one muted sentence (what an organization is) over the single name field, whose hint says it becomes the URL; "first owner" is said once, in the landing flash. A team's page (`/:owner/-/teams/:team`) is three `<section class="settings-block">`s, each an `<h2>` — Members, Add a member, Delete this team (not on Owners) — over a `list setting-list` of `row()`s or a plain form. No card, no empty-state box (an empty team is one muted line), no new class. Every change stays a plain POST form or a confirm page, so it works with JavaScript off. `test/org-team-rows.test.ts` holds the shape.
152
153 Settings › General (`settingsPage()` and `provenanceCard()` in `src/web/pages/settings.ts`, #9853) adds no class and holds no `.card`: About, Your team, On its own and Held files (`src/web/studio/crew.ts`, `file_holds.ts`), Branch and gates, Mirror, Show where this came from, Follow upstream and Followed sources (`upstream-follow.ts`), and Cut a wave and Archive are each a `<section class="settings-block">` with an `<h2>`, one plain sentence and the form. The crew-readiness notices (`crewReadiness()` in `src/web/pages/crew.ts`) are `.toast` rows with `data-tone="danger"`. Every form keeps its action, method, field names and ids. Pinned by `test/settings.test.ts`, which asserts the whole page has no `class="card`.
src/web/pages/settings.ts +28 −25
@@ -269,8 +269,12 @@ export async function settingsPage(ctx: Ctx): Promise<Response> {
269269
270270 // The lead's About card owns the description and visibility; this form
271271 // sends them back unchanged, as that one does the gates below.
272 const body = card(
273 html`<form method="post" action="${base}/settings">
272 const body = html`<section class="settings-block">
outdated

Principle 1: Branch and gates is the only section here with no sentence saying what it is for. Add one muted line, and break the <section>, <h2> and <form> onto their own lines.

273 <h2>Branch and gates</h2>
274 <p class="muted">
275 Where the repository opens, and what must hold before a change lands on it.
276 </p>
277 <form method="post" action="${base}/settings">
274278 <input type="hidden" name="card" value="gates" />
275279 ${selectField({
276280 label: "Default branch",
@@ -372,14 +376,14 @@ export async function settingsPage(ctx: Ctx): Promise<Response> {
372376 <div class="form-actions">
373377 ${btn("Save changes", { variant: "primary" })}
374378 </div>
375 </form>`,
376 { head: html`${icon("branch")}<h2>Branch and gates</h2>` },
377 );
379 </form>
380 </section>`;
378381 const secrets = secretsSection(ctx, "repo", `${repo.ownerName}/${repo.name}`);
379382 const upstream = followUpstreamCard(ctx, repo);
380383 const sources = followSourcesCard(ctx, repo);
381 const wave = card(
382 html`<form method="post" action="${base}/settings/wave">
384 const wave = html`<section class="settings-block">
385 <h2>Cut a wave</h2>
386 <form method="post" action="${base}/settings/wave">
383387 <p class="muted">
384388 Opens a pull that adds the next <code>waves/&lt;version&gt;.md</code>,
385389 listing every pull merged since the last wave. The headline and
@@ -387,13 +391,13 @@ export async function settingsPage(ctx: Ctx): Promise<Response> {
387391 <a href="${base}/waves">See the waves so far</a>.
388392 </p>
389393 <div class="form-actions">${btn("Cut a wave", { variant: "primary" })}</div>
390 </form>`,
391 { head: html`${icon("rocket")}<h2>Cut a wave</h2>` },
392 );
394 </form>
395 </section>`;
393396 const proposals = proposalTokensSection(ctx, repo);
394397 const trail = provenanceCard(ctx, repo);
395 const archive = card(
396 html`<form method="post" action="${base}/settings/archive">
398 const archive = html`<section class="settings-block">
399 <h2>Archive</h2>
400 <form method="post" action="${base}/settings/archive">
397401 <input type="hidden" name="archived" value="${repo.archivedAt === null ? "1" : "0"}" />
398402 <p class="muted">
399403 ${repo.archivedAt === null
@@ -405,17 +409,17 @@ export async function settingsPage(ctx: Ctx): Promise<Response> {
405409 variant: "primary",
406410 })}
407411 </div>
408 </form>`,
409 { head: html`${icon("folder")}<h2>Archive</h2>` },
410 );
412 </form>
413 </section>`;
411414 renderMs += performance.now() - since;
412415 const held = timePhase(ctx.request, "mirror", () => getMirrorTarget(ctx.app.db, repo.id));
413416 since = performance.now();
414417 const mirror =
415418 held === undefined
416419 ? null
417 : card(
418 html`<p class="muted" data-mirror-line>${mirrorLine(held)}</p>
420 : html`<section class="settings-block">
421 <h2>Mirror</h2>
422 <p class="muted" data-mirror-line>${mirrorLine(held)}</p>
419423 <p class="muted small">
420424 Stopping leaves ${held.fullName} as it is; nothing more is pushed.
421425 </p>
@@ -424,9 +428,8 @@ export async function settingsPage(ctx: Ctx): Promise<Response> {
424428 action: `${base}/settings/mirror/stop`,
425429 verb: "Stop mirroring",
426430 })}
427 </div>`,
428 { head: html`${icon("branch")}<h2>Mirror</h2>` },
429 );
431 </div>
432 </section>`;
430433 const response = settingsPageResponse(
431434 ctx,
432435 repo,
@@ -550,8 +553,9 @@ function provenanceCard(ctx: Ctx, repo: Repo): Html | null {
550553 const trail = getProvenance(ctx.app.db, repo.id);
551554 if (trail === undefined) return null;
552555 const base = repoBase(repo);
553 return card(
554 html`<form method="post" action="${base}/settings/provenance">
556 return html`<section class="settings-block">
557 <h2>Show where this came from</h2>
558 <form method="post" action="${base}/settings/provenance">
555559 <input type="hidden" name="shown" value="${trail.shown ? "0" : "1"}" />
556560 <p class="muted">
557561 Migrated from
@@ -574,9 +578,8 @@ function provenanceCard(ctx: Ctx, repo: Repo): Html | null {
574578 variant: "outline",
575579 })}
576580 </div>
577 </form>`,
578 { head: html`${icon("fork")}<h2>Show where this came from</h2>` },
579 );
581 </form>
582 </section>`;
580583 }
581584
582585 /** POST /:owner/:name/settings/provenance — show or hide the trail. */
src/web/pages/upstream-follow.ts +11 −11
@@ -38,7 +38,7 @@ import { html, when, type Html } from "../html.ts";
3838 import { icon } from "../icons.ts";
3939 import { isLocalPath } from "../flash.ts";
4040 import { flashTo } from "../layout.ts";
41 import { btn, card, field as formField, row, timeAgo } from "../ui.ts";
41 import { btn, field as formField, row, timeAgo } from "../ui.ts";
4242
4343 /** Where to land: a validated same-origin path the form names as `back`, else the settings card. */
4444 function backTo(form: Awaited<ReturnType<typeof readForm>> & object, fallback: string): string {
@@ -130,8 +130,9 @@ export function followUpstreamCard(ctx: Ctx, repo: Repo): Html | null {
130130 if (!canAdmin(ctx.app.db, repo, ctx.auth.user)) return null;
131131 const follow = getFollow(ctx.app.db, repo.id);
132132 const base = repoBase(repo);
133 return card(
134 html`<form method="post" action="${base}/settings/upstream">
133 return html`<section class="settings-block" id="upstream">
134 <h2>Follow upstream</h2>
135 <form method="post" action="${base}/settings/upstream">
135136 <p class="muted">
136137 New upstream commits arrive as a pull; never a push to
137138 <span class="mono">${repo.defaultBranch}</span>. A check runs every 30 minutes.
@@ -172,9 +173,8 @@ export function followUpstreamCard(ctx: Ctx, repo: Repo): Html | null {
172173 () => html`<form method="post" action="${base}/settings/upstream/check" class="mt">
173174 ${btn("Check upstream now", { size: "sm", icon: "sync" })}
174175 </form>`,
175 )}`,
176 { head: html`${icon("sync")}<h2 id="upstream">Follow upstream</h2>` },
177 );
176 )}
177 </section>`;
178178 }
179179
180180 // POST /:owner/:name/settings/upstream
@@ -241,8 +241,9 @@ export function followSourcesCard(ctx: Ctx, repo: Repo): Html | null {
241241 const base = repoBase(repo);
242242 // A migrated repository's link back to where it came from is offered as the first line (#3293).
243243 const from = sources.length === 0 ? getProvenance(ctx.app.db, repo.id)?.sourceUrl : undefined;
244 return card(
245 html`<ul class="list" data-follow-sources>
244 return html`<section class="settings-block" id="sources">
245 <h2>Followed sources</h2>
246 <ul class="list" data-follow-sources>
246247 ${sources.length === 0
247248 ? html`<li class="muted small">Following no other sources.</li>`
248249 : sources.map((s) => sourceLine(base, s))}
@@ -270,9 +271,8 @@ export function followSourcesCard(ctx: Ctx, repo: Repo): Html | null {
270271 () => html`<form method="post" action="${base}/settings/sources/check" class="mt">
271272 ${btn("Check sources now", { size: "sm", icon: "sync" })}
272273 </form>`,
273 )}`,
274 { head: html`${icon("sync")}<h2 id="sources">Followed sources</h2>`, pad: sources.length === 0 },
275 );
274 )}
275 </section>`;
276276 }
277277
278278 // POST /:owner/:name/settings/sources
src/web/studio/crew.ts +16 −22
@@ -49,7 +49,6 @@ import {
4949 badge,
5050 btn,
5151 type Tone,
52 card,
5352 field,
5453 row,
5554 selectField,
@@ -70,8 +69,9 @@ function aboutCard(
7069 ): Html | null {
7170 if (!admin) return null;
7271 const noun = isApp ? "app" : "repository";
73 return card(
74 html`<form method="post" action="${base}/settings">
72 return html`<section class="settings-block">
73 <h2>About</h2>
74 <form method="post" action="${base}/settings">
7575 ${field({
7676 label: "Description",
7777 name: "description",
@@ -100,9 +100,8 @@ function aboutCard(
100100 })}
101101 <input type="hidden" name="card" value="about" />
102102 <div class="form-actions">${btn("Save", { variant: "primary" })}</div>
103 </form>`,
104 { head: html`${icon("pencil")}<h2>About</h2>` },
105 );
103 </form>
104 </section>`;
106105 }
107106
108107 const SEAT_LEAD: Record<Seat["state"], "check" | "alert" | "plus"> = {
@@ -220,20 +219,16 @@ function teamCard(
220219 ? `Auto mode is on (${team.mode}): the team works on its own.`
221220 : "Auto mode is off: the team works when asked. Turn it on under On its own. A list's number is how many work at once; 0 pauses it.";
222221 const nobody = crew.length > 0 ? null : nobodyLine(everyone.length > 0, ctx.auth.user?.isAdmin === true);
223 return card(
224 html`<p class="small" id="auto-mode" data-auto="${team.auto ? "on" : "off"}">
222 return html`<section class="settings-block" id="team">
223 <h2>Your team</h2>
224 <p class="small" id="auto-mode" data-auto="${team.auto ? "on" : "off"}">
225225 ${intro}
226226 </p>
227227 ${nobody}
228228 <ul class="list setting-list">
229229 ${rows}
230 </ul>`,
231 {
232 id: "team",
233 head: html`${icon("users")}
234 <h2>Your team</h2>`,
235 },
236 );
230 </ul>
231 </section>`;
237232 }
238233
239234 /**
@@ -391,11 +386,10 @@ ${repo.autonomyDirection}</textarea
391386 })}
392387 <div class="form-actions">${btn("Save", { variant: "primary" })}</div>
393388 </form>`;
394 return card(html`${state}${form}`, {
395 id: "autonomy",
396 head: html`${icon("play")}
397 <h2>On its own</h2>`,
398 });
389 return html`<section class="settings-block" id="autonomy">
390 <h2>On its own</h2>
391 ${state}${form}
392 </section>`;
399393 }
400394
401395 /**
@@ -410,12 +404,12 @@ export function settingsLead(ctx: Ctx, repo: Repo): Html {
410404 const admin = ownerFacts && canAdmin(ctx.app.db, repo, ctx.auth.user);
411405 const isApp = getAppByRepo(ctx.app.db, repo.id) !== undefined;
412406 if (!ownerFacts)
413 return html`<div class="card">
407 return html`<section class="settings-block">
414408 <p class="muted small">
415409 The crew and how this ${isApp ? "app" : "repository"} runs itself are
416410 visible to people who can write here.
417411 </p>
418 </div>`;
412 </section>`;
419413 return html`<div class="stack">
420414 ${aboutCard(repo, base, admin, isApp)}
421415 ${crewCard(ctx, repo, base, admin)}
src/web/studio/file_holds.ts +6 −11
@@ -11,8 +11,7 @@ import { heldNow } from "../../crew/held.ts";
1111 import { MAX_HOLD_PATH } from "../../crew/file_holds.ts";
1212 import type { Ctx } from "../../http/context.ts";
1313 import { html, type Html } from "../html.ts";
14 import { icon } from "../icons.ts";
15 import { btn, card, field, row, time } from "../ui.ts";
14 import { btn, field, row, time } from "../ui.ts";
1615
1716 /** Null unless the viewer is an admin of `repo`: the holds are the owner's book-keeping. */
1817 export function fileHoldsCard(ctx: Ctx, repo: Repo, base: string): Html | null {
@@ -24,8 +23,9 @@ export function fileHoldsCard(ctx: Ctx, repo: Repo, base: string): Html | null {
2423 .filter(([, h]) => h.path === path)
2524 .map(([n]) => `#${n}`)
2625 .join(" ");
27 return card(
28 html`<p class="muted small">
26 return html`<section class="settings-block" id="file-holds">
27 <h2>Held files</h2>
28 <p class="muted small">
2929 No build starts on an issue that names a held path. Nothing already
3030 running is cut.
3131 </p>
@@ -58,11 +58,6 @@ export function fileHoldsCard(ctx: Ctx, repo: Repo, base: string): Html | null {
5858 hint: `A path in the repository, relative, at most ${MAX_HOLD_PATH} characters.`,
5959 })}
6060 <div class="form-actions">${btn("Hold file", { variant: "primary" })}</div>
61 </form>`,
62 {
63 id: "file-holds",
64 head: html`${icon("alert")}
65 <h2>Held files</h2>`,
66 },
67 );
61 </form>
62 </section>`;
6863 }
test/settings.test.ts +40
@@ -4,6 +4,8 @@ import { tmpdir } from "node:os";
44 import { join } from "node:path";
55 import { createApp, type App } from "../src/app.ts";
66 import { getRepo } from "../src/core/repos.ts";
7 import { setMirrorTarget } from "../src/core/migrate_mirror.ts";
8 import { recordProvenance } from "../src/core/provenance.ts";
79 import { listLabels } from "../src/core/issues.ts";
810 import { serve, type Servers } from "../src/http/server.ts";
911 import { adminCookie } from "./helpers/admin.ts";
@@ -52,6 +54,44 @@ describe("repo settings", () => {
5254 await rm(dataDir, { recursive: true, force: true });
5355 });
5456
57 test("the General page has no card and each form posts where it did", async () => {
58 const repo = getRepo(app.db, "olive", "proj")!;
59 app.db.run("PRAGMA foreign_keys = OFF");
outdated

Principle 6/7 and issue box 3: this comment excuses the cards the page still has. Assert expect(page).not.toContain("class=\"card") for the whole page and convert the remaining cards, rather than testing only the headings. Also add Mirror and provenance cases.

60 setMirrorTarget(app.db, {
61 repoId: repo.id,
62 originId: "origin-1",
63 fullName: "olive/proj-mirror",
64 cloneUrl: "https://example.test/olive/proj-mirror.git",
65 tips: {},
66 });
67 recordProvenance(app.db, repo.id, { url: "https://github.com/olive/proj", parents: [] });
68 const res = await fetch(`${base}/olive/proj/settings`, { headers: { cookie: ownerCookie } });
69 const page = await res.text();
70 expect(res.status).toBe(200);
71 expect(page).not.toContain('class="card');
72 const posts = (path: string) => `method="post" action="/olive/proj${path}"`;
73 for (const path of [
74 "/settings",
75 "/settings/wave",
76 "/settings/archive",
77 "/settings/provenance",
78 ])
79 expect(page).toContain(posts(path));
80 // Stopping the mirror asks first: the page links to the confirm, which posts.
81 expect(page).toContain(`/-/confirm?to=${encodeURIComponent("/olive/proj/settings/mirror/stop")}`);
82 for (const h of [
83 "About",
84 "Branch and gates",
85 "Cut a wave",
86 "Archive",
87 "Mirror",
88 "Show where this came from",
89 "Follow upstream",
90 "Followed sources",
91 ])
92 expect(page).toMatch(new RegExp(`<section class="settings-block"[^>]*>\\s*<h2>${h}</h2>`));
93 });
94
5595 test("only the owner can reach settings", async () => {
5696 expect(
5797 (

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs