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

The new-organization page and a team's page speak the home page's language #9834

Merged opened this

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

Merged

mason/forge:mason/9763-the-new-organization-page-and-a-team-s-p → main 4 commits fork 2 threads · 2 unresolved

  • Merged. mason/9763-the-new-organization-page-and-a-team-s-p was merged into main as c89baea.

Review

Sign in to review this change.
3 files changed +133 −41
docs/design/areas/settings.md +4
@@ -127,3 +127,7 @@ title, each with a glyph (Trusted with a check, Ends soon with a clock, Lapsed w
127127 before. `test/trusted-sources.test.ts` holds the shape.
128128
129129 Settings › Hooks (`hooksPage()` in `src/web/pages/hooks.ts`, #9762) adds no component class and has no card or table: Webhooks and Add a webhook are each a `<section class="settings-block">` with an `<h2>`. Each hook is a `row()` in `<ul class="list setting-list">` (URL as title; events, state, added date and "unsigned" as meta; Send ping and a confirmed Delete in the trail), with its recent deliveries as nested child rows, Redeliver in the trail of a failed one. With no hook, one plain sentence. The secret is never drawn. Pinned by `test/hooks-rows.test.ts`.
130
131 ## Organizations
132
133 `/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.
src/web/pages/org.ts +41 −41
@@ -445,7 +445,6 @@ function orgForm(ctx: Ctx, value: string, error?: string): Response {
445445 ${pageHead("New organization")}
446446 <p class="muted small mb">
447447 An organization owns repositories and hands out access through teams.
448 You become its first owner.
449448 </p>
450449 <form method="post" action="/organizations/new">
451450 ${uiField({
@@ -546,32 +545,37 @@ export function teamPage(ctx: Ctx): Response {
546545 lead: icon("users"),
547546 sub: html`${badge(team.permission)} on every repository ${org.username} owns`,
548547 })}
549 ${members.length === 0
550 ? empty({ icon: "users", title: "Nobody on this team yet" })
551 : listCard(
552 members.map((m) =>
553 row({
554 lead: avatar(m.username),
555 title: html`<a href="/${m.username}"
556 >${m.displayName || m.username}</a
557 >`,
558 meta: html`@${m.username}`,
559 trail: when(
560 !protectedTeam || members.length > 1,
561 () =>
562 confirmAction({
563 action: `${teamBase}/remove?user=${encodeURIComponent(m.username)}`,
564 verb: "Remove",
565 size: "sm",
566 }),
567 ),
568 }),
569 ),
570 )}
571 ${card(
572 html`<form method="post" action="${teamBase}/add">
548 <section class="settings-block">
549 <h2>Members</h2>
550 ${members.length === 0
551 ? html`<p class="muted">Nobody is on this team yet.</p>`
552 : html`<ul class="list setting-list">
553 ${members.map((m) =>
554 row({
555 lead: avatar(m.username),
556 title: html`<a href="/${m.username}"
557 >${m.displayName || m.username}</a
558 >`,
559 meta: html`@${m.username}`,
560 trail: when(
561 !protectedTeam || members.length > 1,
562 () =>
563 confirmAction({
564 action: `${teamBase}/remove?user=${encodeURIComponent(m.username)}`,
565 verb: "Remove",
566 size: "sm",
567 label: `Remove ${m.username}`,
568 }),
569 ),
570 }),
571 )}
572 </ul>`}
573 </section>
574 <section class="settings-block">
575 <h2>Add a member</h2>
576 <form method="post" action="${teamBase}/add">
573577 ${uiField({
574 label: "Add a member",
578 label: "Username",
575579 name: "user",
576580 required: true,
577581 hint: "Their username on this forge.",
@@ -579,22 +583,18 @@ export function teamPage(ctx: Ctx): Response {
579583 <div class="form-actions">
580584 ${btn("Add member", { variant: "primary" })}
581585 </div>
582 </form>`,
583 { head: html`<h3>Membership</h3>` },
584 )}
586 </form>
587 </section>
585588 ${when(!protectedTeam, () =>
586 card(
587 html`<p class="muted small mb">
588 Deleting ${team.name} revokes its ${team.permission} access for
589 ${members.length} ${members.length === 1 ? "person" : "people"}.
590 Repositories are untouched.
591 </p>
592 ${confirmAction({
593 action: `${teamBase}/delete`,
594 verb: "Delete team",
595 })}`,
596 { head: html`<h3>Danger zone</h3>`, tone: "danger" },
597 ),
589 html`<section class="settings-block">
590 <h2>Delete this team</h2>
591 <p class="muted small mb">
592 Deleting ${team.name} revokes its ${team.permission} access for
593 ${members.length} ${members.length === 1 ? "person" : "people"}.
594 Repositories are untouched.
595 </p>
596 ${confirmAction({ action: `${teamBase}/delete`, verb: "Delete team" })}
597 </section>`,
598598 )}`;
599599
600600 const response = page({
test/org-team-rows.test.ts added +88
@@ -0,0 +1,88 @@
1 /** The new-organization page and a team's page are sections and rows, not cards (#9763). */

The new-organization test is a regression guard that passes on the base markup. Assert the one-sentence intro so it fails without the change.

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 { createOrg, createTeam } from "../src/actions/orgs.ts";
7 import { listTeams } from "../src/core/orgs.ts";
8 import { createApp, type App } from "../src/app.ts";
9 import { createUser } from "../src/core/users.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11 import { proveAll } from "./helpers/verified.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 let owners = "";
21 let crew = "";
22 let empty = "";
23
24 beforeAll(async () => {
25 root = mkdtempSync(join(tmpdir(), "forge-org-team-rows-"));
26 app = createApp({ dataDir: root, port: 0, forgeRepo: "alice/forge" });
27 const u = await createUser(app.db, {
28 origin: "seed", trusted: true, username: "alice", email: "[email protected]",
29 password: "a-good-passphrase", adminIfFirst: true,
30 });
31 if (!u.ok) throw new Error(u.error);
32 proveAll(app);
33 const made = createOrg(app, u.value, "acme");
34 if (!made.ok) throw new Error(made.error);
35 owners = listTeams(app.db, made.value.id).find((x) => x.name === "Owners")!.id;
36 const t = createTeam(app, u.value, made.value, "crew", "write");
37 if (!t.ok) throw new Error("team");
38 crew = t.value.id;
39 const e = createTeam(app, u.value, made.value, "empty", "read");
40 if (!e.ok) throw new Error("team");
41 empty = e.value.id;
42 servers = await serve(app);
43 const signin = await fetch(`${servers.url}/signin`, {
44 method: "POST",
45 headers: { origin: servers.url },
46 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
47 redirect: "manual",
48 });
49 cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!;
50 });
51 afterAll(() => {
52 servers.stop();
53 app.close();
54 rmSync(root, { recursive: true, force: true });
55 });
56
57 const main = (body: string) => body.slice(body.indexOf("<h1"));
58
59 test("a team's page is headed sections over rows, with no card", async () => {
60 const body = main(await (await fetch(`${servers.url}/acme/-/teams/${crew}`, { headers: { cookie } })).text());
61 expect(body).not.toMatch(/class="[^"]*\bcard\b/);
62 expect(body).toContain('<section class="settings-block">');
63 for (const h of ["Members", "Add a member", "Delete this team"]) expect(body).toContain(`<h2>${h}</h2>`);
64 expect(body).toContain('action="/acme/-/teams/' + crew + '/add"');
65 expect(body).not.toContain("Danger zone");
66 });
67
68 test("Owners lists its member as a row with a Remove link, and cannot be deleted", async () => {
69 const body = main(await (await fetch(`${servers.url}/acme/-/teams/${owners}`, { headers: { cookie } })).text());
70 expect(body).toContain('<ul class="list setting-list">');
71 expect(body).not.toContain("Delete this team");
72 expect(body).not.toMatch(/class="[^"]*\bcard\b/);
73 });
74
75 test("an empty team is one muted line, not an empty-state box", async () => {
76 const body = main(await (await fetch(`${servers.url}/acme/-/teams/${empty}`, { headers: { cookie } })).text());
77 expect(body).toContain('<p class="muted">Nobody is on this team yet.</p>');
78 expect(body).not.toContain("empty-title");
79 });
80
81 test("the new-organization page opens with one sentence, then the form", async () => {
82 const body = main(await (await fetch(`${servers.url}/organizations/new`, { headers: { cookie } })).text());
83 expect(body).toContain('action="/organizations/new"');
84 expect(body).not.toMatch(/class="[^"]*\bcard\b/);
85 expect(body).toContain("hands out access through teams.");
86 expect(body).not.toContain("first owner");
87 expect(body.match(/<p class="muted small mb">/g)?.length).toBe(1);
88 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs