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

The profile page at /:owner speaks the home page's language #9769

Merged opened this

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

Merged

mason/forge:mason/9694-the-profile-page-at-owner-speaks-the-hom → main 3 commits fork 2 threads · 2 unresolved

  • Merged. mason/9694-the-profile-page-at-owner-speaks-the-hom was merged into main as c3cc6c2.

Review

Sign in to review this change.
3 files changed +88 −1
docs/design/areas/lists.md +7
@@ -39,3 +39,10 @@ contract changes, doors over time) is a `.card.bare`: an `<h3>` over its rows on
3939 frame, no tone and no icon before the heading. The state is a badge's word ("contract unsound", "1 door disobeys",
4040 "at the wall"), so dropping the tone loses nothing. The table, the `id` each app section is linked by
4141 (`/audit#owner-name`) and the probe form do not change. No new class. Pinned by `test/audit.test.ts`.
42
43 ## The profile page, rows and space (#9694)
44
45 `/:owner` (`src/web/pages/org.ts`) draws its apps, repositories, members and teams as `.card.bare` lists under
46 their own headings, and the new-team form as a bare card: one background, no frame. The notice for an org with no
47 owner keeps its tone. A person and an org share the template; team changes still go through `src/actions/orgs.ts`.
48 No new class.
src/web/pages/org.ts +5 −1
@@ -170,6 +170,7 @@ function repoSection(
170170 {
171171 head: html`${icon("repo")}
172172 <h2>${anyApps ? "Other repositories" : "Repositories"}</h2>`,
173 extra: "bare",
173174 },
174175 )}
175176 ${pager(
@@ -217,6 +218,7 @@ async function appsSection(
217218
218219 const rows = await shelfRows(ctx, appRepos);
219220 return { count: appRepos.length, html: listCard(rows, {
221 extra: "bare",
220222 head: html`${icon("rocket")}
221223 <h2>Apps</h2>
222224 ${when(
@@ -263,6 +265,7 @@ function teamsSection(ctx: Ctx, org: User, canManage: boolean): Html {
263265 ...(m.displayName ? { meta: html`@${m.username}` } : {}),
264266 }),
265267 ),
268 { extra: "bare" },

Principle 6/7: members and teams are bare now, but the Subscriptions and Secrets cards later on this same page (grantsSection, orgSecrets) are still framed. Make them bare here too.

266269 )}
267270
268271 <h2 class="mt" id="teams">Teams</h2>
@@ -280,6 +283,7 @@ function teamsSection(ctx: Ctx, org: User, canManage: boolean): Html {
280283 )}`,
281284 }),
282285 ),
286 { extra: "bare" },
283287 )}
284288 ${when(canManage, () =>
285289 card(
@@ -303,7 +307,7 @@ function teamsSection(ctx: Ctx, org: User, canManage: boolean): Html {
303307 ${btn("Create team", { variant: "primary" })}
304308 </div>
305309 </form>`,
306 { head: html`<h3>New team</h3>` },
310 { head: html`<h3>New team</h3>`, extra: "bare" },
307311 ),
308312 )}`;
309313 }
test/profile-rows.test.ts added +76
@@ -0,0 +1,76 @@
1 /** The profile page at /:owner speaks the home page's language (#9694): rows on the page, not boxed cards. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { serve, type Servers } from "../src/http/server.ts";
8 import { createApp as makeApp } from "../src/core/apps.ts";
9 import { seedAdmin } from "./helpers/admin.ts";
10
11 let dataDir = "";
12 let app: App, servers: Servers, bearer = "";
13 const auth = () => ({ authorization: `Bearer ${bearer}`, "content-type": "application/json" });
14 const post = (path: string, body: unknown) =>
15 fetch(`${servers.url}${path}`, { method: "POST", headers: auth(), body: JSON.stringify(body) });
16
17 beforeAll(async () => {
18 dataDir = await mkdtemp(join(tmpdir(), "forge-prof-"));
19 app = createApp({ dataDir, port: 0 });
20 servers = await serve(app);
21 await seedAdmin(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" });
22 const minted = await fetch(`${servers.url}/api/v1/tokens`, {
23 method: "POST",
24 headers: { "content-type": "application/json" },
25 body: JSON.stringify({ adminConfirm: true, username: "alice", password: "correct-horse-battery", name: "cli", scope: "admin" }),
26 });
27 bearer = ((await minted.json()) as { token: string }).token;
28 expect((await post("/api/v1/orgs", { name: "acme" })).status).toBe(201);
29 expect((await post("/api/v1/orgs/acme/teams", { name: "dev" })).status).toBe(201);
30 expect((await post("/api/v1/repos", { name: "notes", owner: "acme", visibility: "public" })).status).toBeLessThan(300);
31 expect((await post("/api/v1/repos", { name: "shipped", owner: "acme", visibility: "public" })).status).toBeLessThan(300);
32 const repo = app.db.query("SELECT id FROM repo WHERE name = 'shipped'").get() as { id: string };
33 expect(makeApp(app.db, { repoId: repo.id, slug: "shipped", access: "public" }).ok).toBe(true);
34 });
35
36 afterAll(async () => {
37 servers.stop();
38 app.close();
39 await rm(dataDir, { recursive: true, force: true });
40 });
41
42 const get = async (path: string) => (await fetch(`${servers.url}${path}`, { headers: auth() })).text();
43
44 /** Every `card` on the page, with the heading in its head (if it has one). */
45 function cards(page: string): { cls: string; heading: string | undefined }[] {
46 return page
47 .split(/<div\s+class="card(?=[ "])/)
48 .slice(1)
49 .map((chunk) => ({
50 cls: chunk.slice(0, chunk.indexOf('"')),
51 heading: chunk.match(/^[^]{0,200}?card-head">[^]*?<h[23][^>]*>([^<]+)</)?.[1]?.trim(),
52 }));
53 }
54
55 /** A card the profile may keep: bare, an empty state, or a toned notice. */
56 const allowed = (cls: string) => /\b(bare|empty)\b/.test(cls);
57
58 test("an admin sees apps, repositories, members, teams and the new-team form as bare lists", async () => {
59 const page = await get("/acme");
outdated

Mis-indented, and it only counts the Members/Teams headings. Add Apps, and assert every remaining card on the page is bare, the notice or an empty state, so a boxed list fails the test.

60 const byHeading = new Map(cards(page).map((c) => [c.heading, c.cls]));
61 for (const heading of ["Apps", "Other repositories", "New team"]) expect(byHeading.get(heading)).toContain("bare");
62 expect(page).toContain('href="/acme/notes"');
63 expect(page).toContain('href="/acme/shipped"');
64 // Members and Teams are headings straight over bare lists.
65 for (const heading of ["Members", "Teams"])
66 expect(page).toMatch(new RegExp(`<h2 class="mt"[^>]*>${heading}</h2>\\s*<div\\s+class="card bare"`));
67 });
68
69 test("no boxed card is left on an org's page, for an admin or a visitor", async () => {
70 for (const page of [await get("/acme"), await fetch(`${servers.url}/acme`).then((r) => r.text())])
71 for (const c of cards(page)) expect(allowed(c.cls)).toBe(true);
72 });
73
74 test("no boxed card is left on a person's page", async () => {
75 for (const c of cards(await get("/alice"))) expect(allowed(c.cls)).toBe(true);
76 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs