Public: anyone can read this; the crew can change it.
A repository's Settings › General speaks the home page's language #9865
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9853-a-repository-s-settings-general-speaks-t → main 5 commits fork 2 threads · 2 unresolved
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 | ||
| 149 | 149 | ## Organizations |
| 150 | 150 | |
| 151 | 151 | `/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> { | ||
| 269 | 269 | |
| 270 | 270 | // The lead's About card owns the description and visibility; this form |
| 271 | 271 | // 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"> | |
| 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"> | |
| 274 | 278 | <input type="hidden" name="card" value="gates" /> |
| 275 | 279 | ${selectField({ |
| 276 | 280 | label: "Default branch", |
| @@ -372,14 +376,14 @@ export async function settingsPage(ctx: Ctx): Promise<Response> { | ||
| 372 | 376 | <div class="form-actions"> |
| 373 | 377 | ${btn("Save changes", { variant: "primary" })} |
| 374 | 378 | </div> |
| 375 | </form>`, | |
| 376 | { head: html`${icon("branch")}<h2>Branch and gates</h2>` }, | |
| 377 | ); | |
| 379 | </form> | |
| 380 | </section>`; | |
| 378 | 381 | const secrets = secretsSection(ctx, "repo", `${repo.ownerName}/${repo.name}`); |
| 379 | 382 | const upstream = followUpstreamCard(ctx, repo); |
| 380 | 383 | 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"> | |
| 383 | 387 | <p class="muted"> |
| 384 | 388 | Opens a pull that adds the next <code>waves/<version>.md</code>, |
| 385 | 389 | listing every pull merged since the last wave. The headline and |
| @@ -387,13 +391,13 @@ export async function settingsPage(ctx: Ctx): Promise<Response> { | ||
| 387 | 391 | <a href="${base}/waves">See the waves so far</a>. |
| 388 | 392 | </p> |
| 389 | 393 | <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>`; | |
| 393 | 396 | const proposals = proposalTokensSection(ctx, repo); |
| 394 | 397 | 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"> | |
| 397 | 401 | <input type="hidden" name="archived" value="${repo.archivedAt === null ? "1" : "0"}" /> |
| 398 | 402 | <p class="muted"> |
| 399 | 403 | ${repo.archivedAt === null |
| @@ -405,17 +409,17 @@ export async function settingsPage(ctx: Ctx): Promise<Response> { | ||
| 405 | 409 | variant: "primary", |
| 406 | 410 | })} |
| 407 | 411 | </div> |
| 408 | </form>`, | |
| 409 | { head: html`${icon("folder")}<h2>Archive</h2>` }, | |
| 410 | ); | |
| 412 | </form> | |
| 413 | </section>`; | |
| 411 | 414 | renderMs += performance.now() - since; |
| 412 | 415 | const held = timePhase(ctx.request, "mirror", () => getMirrorTarget(ctx.app.db, repo.id)); |
| 413 | 416 | since = performance.now(); |
| 414 | 417 | const mirror = |
| 415 | 418 | held === undefined |
| 416 | 419 | ? 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> | |
| 419 | 423 | <p class="muted small"> |
| 420 | 424 | Stopping leaves ${held.fullName} as it is; nothing more is pushed. |
| 421 | 425 | </p> |
| @@ -424,9 +428,8 @@ export async function settingsPage(ctx: Ctx): Promise<Response> { | ||
| 424 | 428 | action: `${base}/settings/mirror/stop`, |
| 425 | 429 | verb: "Stop mirroring", |
| 426 | 430 | })} |
| 427 | </div>`, | |
| 428 | { head: html`${icon("branch")}<h2>Mirror</h2>` }, | |
| 429 | ); | |
| 431 | </div> | |
| 432 | </section>`; | |
| 430 | 433 | const response = settingsPageResponse( |
| 431 | 434 | ctx, |
| 432 | 435 | repo, |
| @@ -550,8 +553,9 @@ function provenanceCard(ctx: Ctx, repo: Repo): Html | null { | ||
| 550 | 553 | const trail = getProvenance(ctx.app.db, repo.id); |
| 551 | 554 | if (trail === undefined) return null; |
| 552 | 555 | 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"> | |
| 555 | 559 | <input type="hidden" name="shown" value="${trail.shown ? "0" : "1"}" /> |
| 556 | 560 | <p class="muted"> |
| 557 | 561 | Migrated from |
| @@ -574,9 +578,8 @@ function provenanceCard(ctx: Ctx, repo: Repo): Html | null { | ||
| 574 | 578 | variant: "outline", |
| 575 | 579 | })} |
| 576 | 580 | </div> |
| 577 | </form>`, | |
| 578 | { head: html`${icon("fork")}<h2>Show where this came from</h2>` }, | |
| 579 | ); | |
| 581 | </form> | |
| 582 | </section>`; | |
| 580 | 583 | } |
| 581 | 584 | |
| 582 | 585 | /** 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"; | ||
| 38 | 38 | import { icon } from "../icons.ts"; |
| 39 | 39 | import { isLocalPath } from "../flash.ts"; |
| 40 | 40 | 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"; | |
| 42 | 42 | |
| 43 | 43 | /** Where to land: a validated same-origin path the form names as `back`, else the settings card. */ |
| 44 | 44 | function backTo(form: Awaited<ReturnType<typeof readForm>> & object, fallback: string): string { |
| @@ -130,8 +130,9 @@ export function followUpstreamCard(ctx: Ctx, repo: Repo): Html | null { | ||
| 130 | 130 | if (!canAdmin(ctx.app.db, repo, ctx.auth.user)) return null; |
| 131 | 131 | const follow = getFollow(ctx.app.db, repo.id); |
| 132 | 132 | 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"> | |
| 135 | 136 | <p class="muted"> |
| 136 | 137 | New upstream commits arrive as a pull; never a push to |
| 137 | 138 | <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 { | ||
| 172 | 173 | () => html`<form method="post" action="${base}/settings/upstream/check" class="mt"> |
| 173 | 174 | ${btn("Check upstream now", { size: "sm", icon: "sync" })} |
| 174 | 175 | </form>`, |
| 175 | )}`, | |
| 176 | { head: html`${icon("sync")}<h2 id="upstream">Follow upstream</h2>` }, | |
| 177 | ); | |
| 176 | )} | |
| 177 | </section>`; | |
| 178 | 178 | } |
| 179 | 179 | |
| 180 | 180 | // POST /:owner/:name/settings/upstream |
| @@ -241,8 +241,9 @@ export function followSourcesCard(ctx: Ctx, repo: Repo): Html | null { | ||
| 241 | 241 | const base = repoBase(repo); |
| 242 | 242 | // A migrated repository's link back to where it came from is offered as the first line (#3293). |
| 243 | 243 | 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> | |
| 246 | 247 | ${sources.length === 0 |
| 247 | 248 | ? html`<li class="muted small">Following no other sources.</li>` |
| 248 | 249 | : sources.map((s) => sourceLine(base, s))} |
| @@ -270,9 +271,8 @@ export function followSourcesCard(ctx: Ctx, repo: Repo): Html | null { | ||
| 270 | 271 | () => html`<form method="post" action="${base}/settings/sources/check" class="mt"> |
| 271 | 272 | ${btn("Check sources now", { size: "sm", icon: "sync" })} |
| 272 | 273 | </form>`, |
| 273 | )}`, | |
| 274 | { head: html`${icon("sync")}<h2 id="sources">Followed sources</h2>`, pad: sources.length === 0 }, | |
| 275 | ); | |
| 274 | )} | |
| 275 | </section>`; | |
| 276 | 276 | } |
| 277 | 277 | |
| 278 | 278 | // POST /:owner/:name/settings/sources |
src/web/studio/crew.ts +16 −22
| @@ -49,7 +49,6 @@ import { | ||
| 49 | 49 | badge, |
| 50 | 50 | btn, |
| 51 | 51 | type Tone, |
| 52 | card, | |
| 53 | 52 | field, |
| 54 | 53 | row, |
| 55 | 54 | selectField, |
| @@ -70,8 +69,9 @@ function aboutCard( | ||
| 70 | 69 | ): Html | null { |
| 71 | 70 | if (!admin) return null; |
| 72 | 71 | 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"> | |
| 75 | 75 | ${field({ |
| 76 | 76 | label: "Description", |
| 77 | 77 | name: "description", |
| @@ -100,9 +100,8 @@ function aboutCard( | ||
| 100 | 100 | })} |
| 101 | 101 | <input type="hidden" name="card" value="about" /> |
| 102 | 102 | <div class="form-actions">${btn("Save", { variant: "primary" })}</div> |
| 103 | </form>`, | |
| 104 | { head: html`${icon("pencil")}<h2>About</h2>` }, | |
| 105 | ); | |
| 103 | </form> | |
| 104 | </section>`; | |
| 106 | 105 | } |
| 107 | 106 | |
| 108 | 107 | const SEAT_LEAD: Record<Seat["state"], "check" | "alert" | "plus"> = { |
| @@ -220,20 +219,16 @@ function teamCard( | ||
| 220 | 219 | ? `Auto mode is on (${team.mode}): the team works on its own.` |
| 221 | 220 | : "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."; |
| 222 | 221 | 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"}"> | |
| 225 | 225 | ${intro} |
| 226 | 226 | </p> |
| 227 | 227 | ${nobody} |
| 228 | 228 | <ul class="list setting-list"> |
| 229 | 229 | ${rows} |
| 230 | </ul>`, | |
| 231 | { | |
| 232 | id: "team", | |
| 233 | head: html`${icon("users")} | |
| 234 | <h2>Your team</h2>`, | |
| 235 | }, | |
| 236 | ); | |
| 230 | </ul> | |
| 231 | </section>`; | |
| 237 | 232 | } |
| 238 | 233 | |
| 239 | 234 | /** |
| @@ -391,11 +386,10 @@ ${repo.autonomyDirection}</textarea | ||
| 391 | 386 | })} |
| 392 | 387 | <div class="form-actions">${btn("Save", { variant: "primary" })}</div> |
| 393 | 388 | </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>`; | |
| 399 | 393 | } |
| 400 | 394 | |
| 401 | 395 | /** |
| @@ -410,12 +404,12 @@ export function settingsLead(ctx: Ctx, repo: Repo): Html { | ||
| 410 | 404 | const admin = ownerFacts && canAdmin(ctx.app.db, repo, ctx.auth.user); |
| 411 | 405 | const isApp = getAppByRepo(ctx.app.db, repo.id) !== undefined; |
| 412 | 406 | if (!ownerFacts) |
| 413 | return html`<div class="card"> | |
| 407 | return html`<section class="settings-block"> | |
| 414 | 408 | <p class="muted small"> |
| 415 | 409 | The crew and how this ${isApp ? "app" : "repository"} runs itself are |
| 416 | 410 | visible to people who can write here. |
| 417 | 411 | </p> |
| 418 | </div>`; | |
| 412 | </section>`; | |
| 419 | 413 | return html`<div class="stack"> |
| 420 | 414 | ${aboutCard(repo, base, admin, isApp)} |
| 421 | 415 | ${crewCard(ctx, repo, base, admin)} |
src/web/studio/file_holds.ts +6 −11
| @@ -11,8 +11,7 @@ import { heldNow } from "../../crew/held.ts"; | ||
| 11 | 11 | import { MAX_HOLD_PATH } from "../../crew/file_holds.ts"; |
| 12 | 12 | import type { Ctx } from "../../http/context.ts"; |
| 13 | 13 | 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"; | |
| 16 | 15 | |
| 17 | 16 | /** Null unless the viewer is an admin of `repo`: the holds are the owner's book-keeping. */ |
| 18 | 17 | 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 { | ||
| 24 | 23 | .filter(([, h]) => h.path === path) |
| 25 | 24 | .map(([n]) => `#${n}`) |
| 26 | 25 | .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"> | |
| 29 | 29 | No build starts on an issue that names a held path. Nothing already |
| 30 | 30 | running is cut. |
| 31 | 31 | </p> |
| @@ -58,11 +58,6 @@ export function fileHoldsCard(ctx: Ctx, repo: Repo, base: string): Html | null { | ||
| 58 | 58 | hint: `A path in the repository, relative, at most ${MAX_HOLD_PATH} characters.`, |
| 59 | 59 | })} |
| 60 | 60 | <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>`; | |
| 68 | 63 | } |
test/settings.test.ts +40
| @@ -4,6 +4,8 @@ import { tmpdir } from "node:os"; | ||
| 4 | 4 | import { join } from "node:path"; |
| 5 | 5 | import { createApp, type App } from "../src/app.ts"; |
| 6 | 6 | import { getRepo } from "../src/core/repos.ts"; |
| 7 | import { setMirrorTarget } from "../src/core/migrate_mirror.ts"; | |
| 8 | import { recordProvenance } from "../src/core/provenance.ts"; | |
| 7 | 9 | import { listLabels } from "../src/core/issues.ts"; |
| 8 | 10 | import { serve, type Servers } from "../src/http/server.ts"; |
| 9 | 11 | import { adminCookie } from "./helpers/admin.ts"; |
| @@ -52,6 +54,44 @@ describe("repo settings", () => { | ||
| 52 | 54 | await rm(dataDir, { recursive: true, force: true }); |
| 53 | 55 | }); |
| 54 | 56 | |
| 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 |
||
| 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 | ||
| 55 | 95 | test("only the owner can reach settings", async () => { |
| 56 | 96 | expect( |
| 57 | 97 | ( |
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.