Public: anyone can read this; the crew can change it.
Settings › Origins speaks the home page's language #9802
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9787-settings-origins-speaks-the-home-page-s → main 8 commits fork 2 threads · 2 unresolved
4 files
changed +235
−83
docs/design/areas/settings.md +7
| @@ -94,6 +94,13 @@ accounts are `row()`s in `<ul class="list setting-list" id="accounts-list">` und | ||
| 94 | 94 | (Sign-ins, Verify, Suspend, Remove) in the row's `after` slot; the mail, ownerless-organization, sign-in reach and |
| 95 | 95 | confirm/refusal notices are plain `<section class="settings-block">` blocks with an `<h2>`. `test/accounts-rows.test.ts` holds the shape. |
| 96 | 96 | |
| 97 | Settings › Origins (`src/web/pages/origins.ts`) adds no component class. As on the home page it is headings over rows: Connected | |
| 98 | origins, Frozen mirrors, Trusted internal hosts (admin) and Connect an origin are each a `<section class="settings-block">` | |
| 99 | with an `<h2>`; each origin and each frozen mirror is a `row()` in a `.list`; with no origins, one plain sentence says how to | |
| 100 | connect one. An origin's error, notes (re-authorize, another forge, writes paused) and acts sit in the row's `after` | |
| 101 | slot; the list carries the attribute `data-origins` (not a class), which stacks that slot as a full-width block under | |
| 102 | the row at every width, so nothing squeezes beside the title on a 390 px phone. `test/origins-page-shape.test.ts` holds the shape. | |
| 103 | ||
| 97 | 104 | `/oauth/authorize` (`src/web/pages/oauth.ts`) adds no component class. The consent, sign-in and can't-connect parts are each a `<section class="settings-block">` with an `<h2>` (What it gets, What it learns; the can't-connect part is the message alone) under the `pageHead`, no `.card`. The client's name, the return host, the token kind, name and lifetime fields are as they were. `test/oauth-page-shape.test.ts` holds the shape. |
| 98 | 105 | |
| 99 | 106 | Settings › Cloud (`src/web/pages/cloud.ts`) adds no component class. As on the home page it is headings over rows: Cloud accounts, |
src/web/pages/origins.ts +72 −83
| @@ -76,11 +76,9 @@ import { flashTo, page, redirect } from "../layout.ts"; | ||
| 76 | 76 | import { |
| 77 | 77 | badge, |
| 78 | 78 | btn, |
| 79 | card, | |
| 80 | 79 | confirmAction, |
| 81 | 80 | empty, |
| 82 | 81 | icon, |
| 83 | listCard, | |
| 84 | 82 | row, |
| 85 | 83 | time, |
| 86 | 84 | field as uiField, |
| @@ -275,28 +273,22 @@ function pausedNote(origin: Origin): Html { | ||
| 275 | 273 | |
| 276 | 274 | function originCard(origin: Origin, mirrors: number, paused = false): Html { |
| 277 | 275 | const base = `/settings/origins/${origin.id}`; |
| 278 | return card( | |
| 279 | html`<ul class="list setting-list"> | |
| 280 | ${row({ | |
| 281 | lead: icon(originIcon(origin.kind)), | |
| 282 | title: html`${origin.login}<span class="muted"> @ </span>${hostLabel( | |
| 283 | origin.webUrl, | |
| 284 | )}`, | |
| 285 | meta: html`${origin.authKind === "oauth" | |
| 286 | ? "OAuth" | |
| 287 | : "personal access token"} | |
| 288 | · | |
| 289 | ${origin.lastSyncAt === null | |
| 290 | ? html`never synced` | |
| 291 | : html`synced ${time(origin.lastSyncAt)}`} | |
| 292 | ${when( | |
| 293 | isTrustedHost(URL.parse(origin.webUrl)?.hostname ?? ""), | |
| 294 | () => html` · trusted internal host`, | |
| 295 | )}`, | |
| 296 | trail: healthBadge(origin), | |
| 297 | })} | |
| 298 | </ul> | |
| 276 | return row({ | |
Principle 3 and the Clean rule: the |
||
| 277 | lead: icon(originIcon(origin.kind)), | |
| 278 | title: html`${origin.login}<span class="muted"> @ </span>${hostLabel( | |
| 279 | origin.webUrl, | |
| 280 | )}`, | |
| 281 | meta: html`${origin.authKind === "oauth" ? "OAuth" : "personal access token"} | |
| 282 | · | |
| 283 | ${origin.lastSyncAt === null | |
| 284 | ? html`never synced` | |
| 285 | : html`synced ${time(origin.lastSyncAt)}`} | |
| 299 | 286 | ${when( |
| 287 | isTrustedHost(URL.parse(origin.webUrl)?.hostname ?? ""), | |
| 288 | () => html` · trusted internal host`, | |
| 289 | )}`, | |
| 290 | trail: healthBadge(origin), | |
| 291 | after: html`${when( | |
| 300 | 292 | origin.lastError !== "", |
| 301 | 293 | () => html`<div class="field mt" data-invalid> |
| 302 | 294 | <span class="error" role="alert" id="origin-${origin.id}-err" |
| @@ -335,7 +327,7 @@ function originCard(origin: Origin, mirrors: number, paused = false): Html { | ||
| 335 | 327 | size: "sm", |
| 336 | 328 | })} |
| 337 | 329 | </div>`, |
| 338 | ); | |
| 330 | }); | |
| 339 | 331 | } |
| 340 | 332 | |
| 341 | 333 | // ---- the connect card ---- |
| @@ -685,8 +677,8 @@ function forgeSection(ctx: Ctx, values: ConnectValues): Html { | ||
| 685 | 677 | /** The admin's list of hosts an origin may reach inside the network. */ |
| 686 | 678 | function trustedHostsCard(text: string, error?: string): Html { |
| 687 | 679 | const fixed = envHosts(); |
| 688 | return card( | |
| 689 | html`<h3>${icon("lock")}Trusted internal hosts</h3> | |
| 680 | return html`<section class="settings-block"> | |
| 681 | <h2>Trusted internal hosts</h2> | |
| 690 | 682 | <p class="muted small"> |
| 691 | 683 | Origins on these exact host names may resolve to a private address |
| 692 | 684 | (your own Forgejo, reached inside the cluster). Webhooks never do. |
| @@ -713,8 +705,8 @@ function trustedHostsCard(text: string, error?: string): Html { | ||
| 713 | 705 | ...(error ? { error } : {}), |
| 714 | 706 | })} |
| 715 | 707 | <div class="form-actions">${btn("Save", { variant: "primary" })}</div> |
| 716 | </form>`, | |
| 717 | ); | |
| 708 | </form> | |
| 709 | </section>`; | |
| 718 | 710 | } |
| 719 | 711 | |
| 720 | 712 | /** |
| @@ -778,20 +770,25 @@ function originsView( | ||
| 778 | 770 | written through to the origin first. |
| 779 | 771 | </p> |
| 780 | 772 | ${origins.length === 0 |
| 781 | ? empty({ | |
| 782 | icon: "globe", | |
| 783 | title: "No origins connected", | |
| 784 | note: "Connect another forge, GitHub, a Forgejo instance or a Gitea instance and its repositories become mirrorable here.", | |
| 785 | }) | |
| 786 | : html`<div class="stack"> | |
| 787 | ${origins.map((o) => | |
| 788 | originCard( | |
| 789 | o, | |
| 790 | mirrors.get(o.id)?.count ?? 0, | |
| 791 | writesPaused(ctx.app, o.id) !== undefined, | |
| 792 | ), | |
| 793 | )} | |
| 794 | </div>`} | |
| 773 | ? html`<section class="settings-block"> | |
| 774 | <h2>Connected origins</h2> | |
| 775 | <p class="small"> | |
| 776 | No origins connected: connect another forge, GitHub, a Forgejo | |
| 777 | instance or a Gitea instance below. | |
outdated
Box 1 asks for one plain sentence. This is two. Merge them into one, keeping the lead "No origins connected" so the |
||
| 778 | </p> | |
| 779 | </section>` | |
| 780 | : html`<section class="settings-block"> | |
| 781 | <h2>Connected origins</h2> | |
| 782 | <ul class="list setting-list" data-origins> | |
| 783 | ${origins.map((o) => | |
| 784 | originCard( | |
| 785 | o, | |
| 786 | mirrors.get(o.id)?.count ?? 0, | |
| 787 | writesPaused(ctx.app, o.id) !== undefined, | |
| 788 | ), | |
| 789 | )} | |
| 790 | </ul> | |
| 791 | </section>`} | |
| 795 | 792 | ${frozenCard(frozen)} |
| 796 | 793 | ${mayEditTrustedHosts(user) |
| 797 | 794 | ? trustedHostsCard( |
| @@ -799,8 +796,9 @@ function originsView( | ||
| 799 | 796 | values.trustedError, |
| 800 | 797 | ) |
| 801 | 798 | : null} |
| 802 | ${card( | |
| 803 | html`<ul class="list"> | |
| 799 | <section class="settings-block" id="connect"> | |
| 800 | <h2>Connect an origin</h2> | |
| 801 | <ul class="list"> | |
| 804 | 802 | ${connectPath( |
| 805 | 803 | "connect-forge", |
| 806 | 804 | "globe", |
| @@ -839,19 +837,14 @@ function originsView( | ||
| 839 | 837 | }, |
| 840 | 838 | ), |
| 841 | 839 | )} |
| 842 | </ul>`, | |
| 843 | { | |
| 844 | id: "connect", | |
| 845 | pad: false, | |
| 846 | head: html`${icon("plus")} | |
| 847 | <h2>Connect an origin</h2>`, | |
| 848 | foot: html`<span class="muted small" | |
| 849 | ><strong>One-click sign-in.</strong> Another forge needs nothing set up. | |
| 850 | GitHub, Forgejo and Gitea need somebody to register an application | |
| 851 | once: for GitHub, per forge; for Forgejo and Gitea, per instance.</span | |
| 852 | >`, | |
| 853 | }, | |
| 854 | )}`; | |
| 840 | </ul> | |
| 841 | <p class="muted small"> | |
| 842 | <strong>One-click sign-in.</strong> Another forge needs nothing set | |
| 843 | up. GitHub, Forgejo and Gitea need somebody to register an | |
| 844 | application once: for GitHub, per forge; for Forgejo and Gitea, per | |
| 845 | instance. | |
| 846 | </p> | |
| 847 | </section>`; | |
| 855 | 848 | } |
| 856 | 849 | |
| 857 | 850 | /** |
| @@ -875,32 +868,28 @@ function readFrozen(ctx: Ctx) { | ||
| 875 | 868 | |
| 876 | 869 | function frozenCard({ frozen, repos }: ReturnType<typeof readFrozen>): Html | null { |
| 877 | 870 | if (frozen.length === 0) return null; |
| 878 | return listCard( | |
| 879 | frozen.flatMap((m) => { | |
| 880 | const repo = repos.get(m.repoId); | |
| 881 | if (repo === undefined) return []; | |
| 882 | const host = hostLabel(m.htmlUrl); | |
| 883 | return [ | |
| 884 | row({ | |
| 885 | href: `/${repo.ownerName}/${repo.name}`, | |
| 886 | lead: icon("repo"), | |
| 887 | title: `${repo.ownerName}/${repo.name}`, | |
| 888 | meta: html`mirrors <span class="mono">${m.fullName}</span>${host === | |
| 889 | "" | |
| 890 | ? null | |
| 891 | : html` at ${host}`}`, | |
| 892 | }), | |
| 893 | ]; | |
| 894 | }), | |
| 895 | { | |
| 896 | head: html`${icon("alert")} | |
| 897 | <h2>Frozen mirrors</h2> | |
| 898 | <span class="spacer"></span> | |
| 899 | <span class="muted small" | |
| 900 | >Connect an account where they came from to bring them back.</span | |
| 901 | >`, | |
| 902 | }, | |
| 903 | ); | |
| 871 | const rows = frozen.flatMap((m) => { | |
| 872 | const repo = repos.get(m.repoId); | |
| 873 | if (repo === undefined) return []; | |
| 874 | const host = hostLabel(m.htmlUrl); | |
| 875 | return [ | |
| 876 | row({ | |
| 877 | href: `/${repo.ownerName}/${repo.name}#frozen`, | |
| 878 | lead: icon("repo"), | |
| 879 | title: `${repo.ownerName}/${repo.name}`, | |
| 880 | meta: html`mirrors <span class="mono">${m.fullName}</span>${host === "" | |
| 881 | ? null | |
| 882 | : html` at ${host}`}`, | |
| 883 | }), | |
| 884 | ]; | |
| 885 | }); | |
| 886 | return html`<section class="settings-block"> | |
| 887 | <h2>Frozen mirrors</h2> | |
| 888 | <p class="muted small">Connect an account where they came from to bring them back.</p> | |
| 889 | <ul class="list setting-list"> | |
| 890 | ${rows} | |
| 891 | </ul> | |
| 892 | </section>`; | |
| 904 | 893 | } |
| 905 | 894 | |
| 906 | 895 | export function shell( |
src/web/style/components.ts +2
| @@ -476,6 +476,8 @@ a.row:hover .row-title { color: var(--accent); } | ||
| 476 | 476 | /* A settings row's acts (Accounts): always visible, wrapping, a full line under 640px. */ |
| 477 | 477 | .setting-list > li.has-after > .row-after { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 8px; } |
| 478 | 478 | @media (max-width: 640px) { .setting-list > li.has-after > .row-after { flex-basis: 100%; justify-content: flex-start; padding: 0 14px 10px; } } |
| 479 | /* An origin's error, notes and acts (Settings › Origins): a block of their own under the row, at every width. */ | |
| 480 | .setting-list[data-origins] > li.has-after > .row-after { flex-basis: 100%; display: block; min-width: 0; padding: 0 14px 12px; overflow-wrap: anywhere; } | |
| 479 | 481 | .list > li.has-after > .row-children { flex-basis: 100%; } |
| 480 | 482 | .archived-list .row-title, .archived-list .row-meta { white-space: normal; overflow-wrap: anywhere; } |
| 481 | 483 | @media (max-width: 640px) { |
test/origins-page-shape.test.ts added +154
| @@ -0,0 +1,154 @@ | ||
| 1 | /** /settings/origins is headings over rows: settings blocks, no `.card`. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtempSync, rmSync } from "node:fs"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { createRepo } from "../src/core/repos.ts"; | |
| 8 | import { createOrigin, linkRepoToOrigin } from "../src/core/origins.ts"; | |
| 9 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 10 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 11 | import { components } from "../src/web/style/components.ts"; | |
| 12 | import { quietLogs } from "./helpers/globals.ts"; | |
| 13 | ||
| 14 | quietLogs(); | |
| 15 | ||
| 16 | let root = ""; | |
| 17 | let app: App; | |
| 18 | let servers: Servers; | |
| 19 | let cookie = ""; | |
| 20 | ||
| 21 | beforeAll(async () => { | |
| 22 | root = mkdtempSync(join(tmpdir(), "forge-origins-shape-")); | |
| 23 | app = createApp({ dataDir: root, port: 0 }); | |
| 24 | const u = await createUser(app.db, { | |
| 25 | origin: "seed", | |
| 26 | trusted: true, | |
| 27 | username: "alice", | |
| 28 | email: "[email protected]", | |
| 29 | password: "a-good-passphrase", | |
| 30 | adminIfFirst: true, | |
| 31 | }); | |
| 32 | if (!u.ok) throw new Error(u.error); | |
| 33 | servers = await serve(app); | |
| 34 | const r = await fetch(`${servers.url}/signin`, { | |
| 35 | method: "POST", | |
| 36 | headers: { origin: servers.url }, | |
| 37 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), | |
| 38 | redirect: "manual", | |
| 39 | }); | |
| 40 | cookie = (r.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 41 | }); | |
| 42 | afterAll(() => { | |
| 43 | servers.stop(); | |
| 44 | app.close(); | |
| 45 | rmSync(root, { recursive: true, force: true }); | |
| 46 | }); | |
| 47 | ||
| 48 | test("the origins page has settings blocks with headings and no card", async () => { | |
| 49 | const res = await fetch(`${servers.url}/settings/origins`, { headers: { cookie } }); | |
| 50 | expect(res.status).toBe(200); | |
| 51 | const body = await res.text(); | |
| 52 | expect(body).not.toMatch(/class="(?:[^"]*\s)?card(?:\s[^"]*)?"/); | |
| 53 | for (const h of ["Connected origins", "Trusted internal hosts", "Connect an origin"]) { | |
| 54 | expect(body).toMatch(new RegExp(`<section class="settings-block"[^>]*>\\s*<h2>${h}</h2>`)); | |
| 55 | } | |
| 56 | expect(body).toContain( | |
| 57 | "No origins connected: connect another forge, GitHub, a Forgejo\n instance or a Gitea instance below.", | |
| 58 | ); | |
| 59 | // One sentence: no full stop before its end. | |
| 60 | const empty = body.split("<h2>Connected origins</h2>")[1]!.split("</p>")[0]!; | |
| 61 | expect(empty.replace(/<[^>]+>/g, "").trim().split(". ").length).toBe(1); | |
| 62 | expect(body).toContain('id="connect"'); | |
| 63 | }); | |
| 64 | ||
| 65 | test("connected origins are rows of one list, with no wrapper box", async () => { | |
| 66 | const alice = getUserByName(app.db, "alice")!; | |
| 67 | for (const login of ["one", "two"]) { | |
| 68 | const o = createOrigin(app.db, root, { | |
| 69 | userId: alice.id, | |
| 70 | kind: "forgejo", | |
| 71 | apiUrl: `https://git.example.com/api/v1/${login}`, | |
| 72 | webUrl: "https://git.example.com", | |
| 73 | login, | |
| 74 | token: "t0ken", | |
| 75 | }); | |
| 76 | expect(o.ok).toBe(true); | |
| 77 | } | |
| 78 | const body = await (await fetch(`${servers.url}/settings/origins`, { headers: { cookie } })).text(); | |
| 79 | expect(body).not.toContain("origin-item"); | |
| 80 | const block = body.split("<h2>Connected origins</h2>")[1]!.split("</section>")[0]!; | |
| 81 | expect(block.match(/<ul class="list setting-list" data-origins>/g)?.length).toBe(1); | |
| 82 | expect(block.match(/<li[ >]/g)!.length).toBeGreaterThanOrEqual(2); | |
| 83 | expect(block).toContain("Sync now"); | |
| 84 | expect(block).toContain("Disconnect"); | |
| 85 | }); | |
| 86 | ||
| 87 | test("an origin's error and notes render inside its own row", async () => { | |
| 88 | const alice = getUserByName(app.db, "alice")!; | |
| 89 | const o = createOrigin(app.db, root, { | |
| 90 | userId: alice.id, | |
| 91 | kind: "forgejo", | |
| 92 | apiUrl: "https://broken.example.com/api/v1", | |
| 93 | webUrl: "https://broken.example.com", | |
| 94 | login: "broken", | |
| 95 | token: "t0ken", | |
| 96 | software: "forge", | |
| 97 | }); | |
| 98 | if (!o.ok) throw new Error("origin"); | |
| 99 | app.db | |
| 100 | .query("UPDATE origin SET last_error = ?1 WHERE id = ?2") | |
| 101 | .run("upstream said 502", o.value.id); | |
| 102 | const body = await (await fetch(`${servers.url}/settings/origins`, { headers: { cookie } })).text(); | |
| 103 | const block = body.split("<h2>Connected origins</h2>")[1]!.split("</section>")[0]!; | |
| 104 | const li = block | |
| 105 | .split(/<li[ >]/) | |
| 106 | .slice(1) | |
| 107 | .find((part) => part.includes("broken"))! | |
| 108 | .split("</li>")[0]!; | |
| 109 | const after = li.split('<div class="row-after">')[1]!; | |
| 110 | expect(after).toContain(`id="origin-${o.value.id}-err"`); | |
| 111 | expect(after).toContain("upstream said 502"); | |
| 112 | expect(after).toContain("Another forge."); | |
| 113 | expect(after).toContain("Sync now"); | |
| 114 | }); | |
| 115 | ||
| 116 | test("a frozen mirror is listed under a Frozen mirrors heading", async () => { | |
| 117 | const alice = getUserByName(app.db, "alice")!; | |
| 118 | const repo = await createRepo(app.db, app.reposRoot, { | |
| 119 | ownerId: alice.id, | |
| 120 | name: "frozen1", | |
| 121 | visibility: "public", | |
| 122 | }); | |
| 123 | if (!repo.ok) throw new Error("repo"); | |
| 124 | const o = createOrigin(app.db, root, { | |
| 125 | userId: alice.id, | |
| 126 | kind: "forgejo", | |
| 127 | apiUrl: "https://gone.example.com/api/v1", | |
| 128 | webUrl: "https://gone.example.com", | |
| 129 | login: "gone", | |
| 130 | token: "t0ken", | |
| 131 | }); | |
| 132 | if (!o.ok) throw new Error("origin"); | |
| 133 | linkRepoToOrigin(app.db, repo.value.id, { | |
| 134 | originId: o.value.id, | |
| 135 | fullName: "them/up", | |
| 136 | remoteId: "1", | |
| 137 | htmlUrl: "https://gone.example.com/them/up", | |
| 138 | }); | |
| 139 | app.db.query("DELETE FROM origin WHERE id = ?1").run(o.value.id); | |
| 140 | const body = await (await fetch(`${servers.url}/settings/origins`, { headers: { cookie } })).text(); | |
| 141 | expect(body).toMatch(/<section class="settings-block"[^>]*>\s*<h2>Frozen mirrors<\/h2>/); | |
| 142 | expect(body).toContain("them/up"); | |
| 143 | }); | |
| 144 | ||
| 145 | test("an origin's after slot is a full-width block under the row, so it fits a 390 px phone", () => { | |
| 146 | const rule = components | |
| 147 | .split("\n") | |
| 148 | .find((l) => l.startsWith(".setting-list[data-origins] > li.has-after > .row-after")); | |
| 149 | expect(rule).toBeDefined(); | |
| 150 | expect(rule).toContain("flex-basis: 100%"); | |
| 151 | expect(rule).toContain("display: block"); | |
| 152 | expect(rule).toContain("min-width: 0"); | |
| 153 | expect(rule).toContain("overflow-wrap: anywhere"); | |
| 154 | }); |
Principle 7 (rows over boxes): this wraps each origin in an
origin-itemdiv, a new class with no CSS and no entry in docs/design/areas, and gives every origin its own one-row list. Render all origins as rows of one.listand drop the wrapper.