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

Settings › Origins speaks the home page's language #9802

Merged opened this

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

Merged

scout/forge:scout/9787-settings-origins-speaks-the-home-page-s → main 8 commits fork 2 threads · 2 unresolved

  • Merged. scout/9787-settings-origins-speaks-the-home-page-s was merged into main as 973d568.

Review

Sign in to review this change.
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
9494 (Sign-ins, Verify, Suspend, Remove) in the row's `after` slot; the mail, ownerless-organization, sign-in reach and
9595 confirm/refusal notices are plain `<section class="settings-block">` blocks with an `<h2>`. `test/accounts-rows.test.ts` holds the shape.
9696
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
97104 `/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.
98105
99106 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";
7676 import {
7777 badge,
7878 btn,
79 card,
8079 confirmAction,
8180 empty,
8281 icon,
83 listCard,
8482 row,
8583 time,
8684 field as uiField,
@@ -275,28 +273,22 @@ function pausedNote(origin: Origin): Html {
275273
276274 function originCard(origin: Origin, mirrors: number, paused = false): Html {
277275 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({
outdated

Principle 7 (rows over boxes): this wraps each origin in an origin-item div, 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 .list and drop the wrapper.

Principle 3 and the Clean rule: the row({ body, the after template and the closing }); are not indented as the surrounding source is. Re-indent the whole function.

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)}`}
299286 ${when(
287 isTrustedHost(URL.parse(origin.webUrl)?.hostname ?? ""),
288 () => html` · trusted internal host`,
289 )}`,
290 trail: healthBadge(origin),
291 after: html`${when(
300292 origin.lastError !== "",
301293 () => html`<div class="field mt" data-invalid>
302294 <span class="error" role="alert" id="origin-${origin.id}-err"
@@ -335,7 +327,7 @@ function originCard(origin: Origin, mirrors: number, paused = false): Html {
335327 size: "sm",
336328 })}
337329 </div>`,
338 );
330 });
339331 }
340332
341333 // ---- the connect card ----
@@ -685,8 +677,8 @@ function forgeSection(ctx: Ctx, values: ConnectValues): Html {
685677 /** The admin's list of hosts an origin may reach inside the network. */
686678 function trustedHostsCard(text: string, error?: string): Html {
687679 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>
690682 <p class="muted small">
691683 Origins on these exact host names may resolve to a private address
692684 (your own Forgejo, reached inside the cluster). Webhooks never do.
@@ -713,8 +705,8 @@ function trustedHostsCard(text: string, error?: string): Html {
713705 ...(error ? { error } : {}),
714706 })}
715707 <div class="form-actions">${btn("Save", { variant: "primary" })}</div>
716 </form>`,
717 );
708 </form>
709 </section>`;
718710 }
719711
720712 /**
@@ -778,20 +770,25 @@ function originsView(
778770 written through to the origin first.
779771 </p>
780772 ${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 web-origins tests still match.

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>`}
795792 ${frozenCard(frozen)}
796793 ${mayEditTrustedHosts(user)
797794 ? trustedHostsCard(
@@ -799,8 +796,9 @@ function originsView(
799796 values.trustedError,
800797 )
801798 : 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">
804802 ${connectPath(
805803 "connect-forge",
806804 "globe",
@@ -839,19 +837,14 @@ function originsView(
839837 },
840838 ),
841839 )}
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>`;
855848 }
856849
857850 /**
@@ -875,32 +868,28 @@ function readFrozen(ctx: Ctx) {
875868
876869 function frozenCard({ frozen, repos }: ReturnType<typeof readFrozen>): Html | null {
877870 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}#frozen`,
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>`;
904893 }
905894
906895 export function shell(
src/web/style/components.ts +2
@@ -476,6 +476,8 @@ a.row:hover .row-title { color: var(--accent); }
476476 /* A settings row's acts (Accounts): always visible, wrapping, a full line under 640px. */
477477 .setting-list > li.has-after > .row-after { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 8px; }
478478 @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; }
479481 .list > li.has-after > .row-children { flex-basis: 100%; }
480482 .archived-list .row-title, .archived-list .row-meta { white-space: normal; overflow-wrap: anywhere; }
481483 @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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs