Public: anyone can read this; the crew can change it.
The profile page at /:owner speaks the home page's language #9769
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9694-the-profile-page-at-owner-speaks-the-hom → main 3 commits fork 2 threads · 2 unresolved
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 | ||
| 39 | 39 | frame, no tone and no icon before the heading. The state is a badge's word ("contract unsound", "1 door disobeys", |
| 40 | 40 | "at the wall"), so dropping the tone loses nothing. The table, the `id` each app section is linked by |
| 41 | 41 | (`/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( | ||
| 170 | 170 | { |
| 171 | 171 | head: html`${icon("repo")} |
| 172 | 172 | <h2>${anyApps ? "Other repositories" : "Repositories"}</h2>`, |
| 173 | extra: "bare", | |
| 173 | 174 | }, |
| 174 | 175 | )} |
| 175 | 176 | ${pager( |
| @@ -217,6 +218,7 @@ async function appsSection( | ||
| 217 | 218 | |
| 218 | 219 | const rows = await shelfRows(ctx, appRepos); |
| 219 | 220 | return { count: appRepos.length, html: listCard(rows, { |
| 221 | extra: "bare", | |
| 220 | 222 | head: html`${icon("rocket")} |
| 221 | 223 | <h2>Apps</h2> |
| 222 | 224 | ${when( |
| @@ -263,6 +265,7 @@ function teamsSection(ctx: Ctx, org: User, canManage: boolean): Html { | ||
| 263 | 265 | ...(m.displayName ? { meta: html`@${m.username}` } : {}), |
| 264 | 266 | }), |
| 265 | 267 | ), |
| 268 | { extra: "bare" }, | |
| 266 | 269 | )} |
| 267 | 270 | |
| 268 | 271 | <h2 class="mt" id="teams">Teams</h2> |
| @@ -280,6 +283,7 @@ function teamsSection(ctx: Ctx, org: User, canManage: boolean): Html { | ||
| 280 | 283 | )}`, |
| 281 | 284 | }), |
| 282 | 285 | ), |
| 286 | { extra: "bare" }, | |
| 283 | 287 | )} |
| 284 | 288 | ${when(canManage, () => |
| 285 | 289 | card( |
| @@ -303,7 +307,7 @@ function teamsSection(ctx: Ctx, org: User, canManage: boolean): Html { | ||
| 303 | 307 | ${btn("Create team", { variant: "primary" })} |
| 304 | 308 | </div> |
| 305 | 309 | </form>`, |
| 306 | { head: html`<h3>New team</h3>` }, | |
| 310 | { head: html`<h3>New team</h3>`, extra: "bare" }, | |
| 307 | 311 | ), |
| 308 | 312 | )}`; |
| 309 | 313 | } |
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 |
||
| 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 | }); |
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.