Public: anyone can read this; the crew can change it.
An organization's profile, its teams and the ownerless notice, speaks the home page's language #9895
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9890-an-organization-s-profile-its-teams-and → main 6 commits fork
4 files
changed +254
−125
src/web/pages/org.ts +117 −117
| @@ -58,10 +58,8 @@ import { | ||
| 58 | 58 | avatar, |
| 59 | 59 | badge, |
| 60 | 60 | btn, |
| 61 | card, | |
| 62 | 61 | confirmAction, |
| 63 | empty, | |
| 64 | listCard, | |
| 62 | dot, | |
| 65 | 63 | pager, |
| 66 | 64 | repoCounts, |
| 67 | 65 | row, |
| @@ -72,6 +70,19 @@ import { | ||
| 72 | 70 | |
| 73 | 71 | const PER_PAGE = 20; |
| 74 | 72 | |
| 73 | /** Rows as a plain list under a heading: the page's lists are sections, never boxes. */ | |
| 74 | function plainList(rows: Html[]): Html { | |
| 75 | return html`<ul class="list setting-list"> | |
| 76 | ${rows} | |
| 77 | </ul>`; | |
| 78 | } | |
| 79 | ||
| 80 | /** An empty state as one muted line (a title, and a note when there is one). */ | |
| 81 | function nothing(title: string, note?: string, action?: Html): Html { | |
| 82 | return html`<p class="muted">${title}${note ? html`. ${note}` : null}</p> | |
| 83 | ${action ? html`<p class="form-actions">${action}</p>` : null}`; | |
| 84 | } | |
| 85 | ||
| 75 | 86 | // ---- profile ---- |
| 76 | 87 | |
| 77 | 88 | /** |
| @@ -126,53 +137,46 @@ function repoSection( | ||
| 126 | 137 | </form> |
| 127 | 138 | </div> |
| 128 | 139 | ${shown.length === 0 |
| 129 | ? empty({ | |
| 130 | icon: "repo", | |
| 131 | title: | |
| 132 | query !== "" | |
| 133 | ? "No matches" | |
| 134 | : anyApps | |
| 135 | ? "No other repositories" | |
| 136 | : "No repositories yet", | |
| 137 | note: | |
| 138 | query !== "" | |
| 139 | ? "Try a different search." | |
| 140 | : anyApps | |
| 141 | ? `Every repository ${owner.username} has is already listed as an app above.` | |
| 142 | : `${owner.username} has not published anything you can see.`, | |
| 143 | ...(isSelf && query === "" | |
| 144 | ? { | |
| 145 | action: btn("New repository", { | |
| 146 | href: `/new?owner=${owner.username}`, | |
| 147 | variant: "primary", | |
| 148 | }), | |
| 149 | } | |
| 150 | : {}), | |
| 151 | }) | |
| 152 | : listCard( | |
| 153 | shown.map((repo) => | |
| 154 | row({ | |
| 155 | href: `/${repo.ownerName}/${repo.name}`, | |
| 156 | lead: lead(repo), | |
| 157 | title: repo.name, | |
| 158 | nav: true, | |
| 159 | ...(repo.description !== "" | |
| 160 | ? { meta: html`${repo.description}` } | |
| 161 | : {}), | |
| 162 | trail: html`${repoCounts({ | |
| 163 | openIssues: repo.counts?.openIssues, | |
| 164 | openPulls: repo.counts?.openPulls, | |
| 165 | })}${repo.visibility === "private" | |
| 166 | ? badge("Private", { icon: "lock", variant: "secondary" }) | |
| 167 | : badge("Public", { variant: "secondary" })}`, | |
| 168 | }), | |
| 169 | ), | |
| 170 | { | |
| 171 | head: html`${icon("repo")} | |
| 172 | <h2>${anyApps ? "Other repositories" : "Repositories"}</h2>`, | |
| 173 | extra: "bare", | |
| 174 | }, | |
| 175 | )} | |
| 140 | ? nothing( | |
| 141 | query !== "" | |
| 142 | ? "No matches" | |
| 143 | : anyApps | |
| 144 | ? "No other repositories" | |
| 145 | : "No repositories yet", | |
| 146 | query !== "" | |
| 147 | ? "Try a different search." | |
| 148 | : anyApps | |
| 149 | ? `Every repository ${owner.username} has is already listed as an app above.` | |
| 150 | : `${owner.username} has not published anything you can see.`, | |
| 151 | isSelf && query === "" | |
| 152 | ? btn("New repository", { | |
| 153 | href: `/new?owner=${owner.username}`, | |
| 154 | variant: "primary", | |
| 155 | }) | |
| 156 | : undefined, | |
| 157 | ) | |
| 158 | : html`<section class="settings-block"> | |
| 159 | <h2>${anyApps ? "Other repositories" : "Repositories"}</h2> | |
| 160 | ${plainList( | |
| 161 | shown.map((repo) => | |
| 162 | row({ | |
| 163 | href: `/${repo.ownerName}/${repo.name}`, | |
| 164 | lead: lead(repo), | |
| 165 | title: repo.name, | |
| 166 | nav: true, | |
| 167 | ...(repo.description !== "" | |
| 168 | ? { meta: html`${repo.description}` } | |
| 169 | : {}), | |
| 170 | trail: html`${repoCounts({ | |
| 171 | openIssues: repo.counts?.openIssues, | |
| 172 | openPulls: repo.counts?.openPulls, | |
| 173 | })}${repo.visibility === "private" | |
| 174 | ? badge("Private", { icon: "lock", variant: "secondary" }) | |
| 175 | : badge("Public", { variant: "secondary" })}`, | |
| 176 | }), | |
| 177 | ), | |
| 178 | )} | |
| 179 | </section>`} | |
| 176 | 180 | ${pager( |
| 177 | 181 | repos.length > PER_PAGE && last |
| 178 | 182 | ? { next: `/${owner.username}?${nextParams.toString()}` } |
| @@ -207,30 +211,25 @@ async function appsSection( | ||
| 207 | 211 | return { |
| 208 | 212 | count: 0, |
| 209 | 213 | html: when(isSelf, () => |
| 210 | empty({ | |
| 211 | icon: "rocket", | |
| 212 | title: "No apps yet", | |
| 213 | note: "Make one, tell the crew what it is, and watch it come up.", | |
| 214 | action: btn("New app", { href: newAppHref, variant: "primary" }), | |
| 215 | }), | |
| 214 | nothing( | |
| 215 | "No apps yet", | |
| 216 | "Make one, tell the crew what it is, and watch it come up.", | |
| 217 | btn("New app", { href: newAppHref, variant: "primary" }), | |
| 218 | ), | |
| 216 | 219 | ), |
| 217 | 220 | }; |
| 218 | 221 | |
| 219 | 222 | const rows = await shelfRows(ctx, appRepos); |
| 220 | return { count: appRepos.length, html: listCard(rows, { | |
| 221 | extra: "bare", | |
| 222 | head: html`${icon("rocket")} | |
| 223 | return { | |
| 224 | count: appRepos.length, | |
| 225 | html: html`<section class="settings-block"> | |
| 223 | 226 | <h2>Apps</h2> |
| 224 | ${when( | |
| 225 | isSelf, | |
| 226 | () => | |
| 227 | html`<span class="spacer"></span>${btn("New app", { | |
| 228 | href: newAppHref, | |
| 229 | size: "sm", | |
| 230 | icon: "plus", | |
| 231 | })}`, | |
| 232 | )}`, | |
| 233 | }) }; | |
| 227 | ${plainList(rows)} | |
| 228 | ${when(isSelf, () => | |
| 229 | html`<p class="form-actions">${btn("New app", { href: newAppHref, size: "sm", icon: "plus" })}</p>`, | |
| 230 | )} | |
| 231 | </section>`, | |
| 232 | }; | |
| 234 | 233 | } |
| 235 | 234 | |
| 236 | 235 | /** Everyone on any of the org's teams, once each. */ |
| @@ -252,42 +251,47 @@ function memberCount(n: number): Html { | ||
| 252 | 251 | function teamsSection(ctx: Ctx, org: User, canManage: boolean): Html { |
| 253 | 252 | const teams = listTeams(ctx.app.db, org.id); |
| 254 | 253 | const members = orgMembers(ctx, teams); |
| 255 | return html`<h2 class="mt">Members</h2> | |
| 256 | ${members.length === 0 | |
| 257 | ? empty({ icon: "users", title: "No members yet" }) | |
| 258 | : listCard( | |
| 259 | members.map((m) => | |
| 260 | row({ | |
| 261 | href: `/${m.username}`, | |
| 262 | lead: avatar(m.username), | |
| 263 | title: m.displayName || m.username, | |
| 264 | nav: true, | |
| 265 | ...(m.displayName ? { meta: html`@${m.username}` } : {}), | |
| 266 | }), | |
| 267 | ), | |
| 268 | { extra: "bare" }, | |
| 269 | )} | |
| 270 | ||
| 271 | <h2 class="mt" id="teams">Teams</h2> | |
| 272 | ${listCard( | |
| 273 | teams.map((t) => | |
| 274 | row({ | |
| 275 | lead: icon("users"), | |
| 276 | title: t.name, | |
| 277 | meta: memberCount(listTeamMembers(ctx.app.db, t.id).length), | |
| 278 | trail: html`${badge(t.permission)}${when(canManage, () => | |
| 279 | btn("Manage", { | |
| 280 | href: `/${org.username}/-/teams/${t.id}`, | |
| 281 | size: "sm", | |
| 282 | }), | |
| 283 | )}`, | |
| 284 | }), | |
| 285 | ), | |
| 286 | { extra: "bare" }, | |
| 287 | )} | |
| 288 | ${when(canManage, () => | |
| 289 | card( | |
| 290 | html`<form method="post" action="/${org.username}/-/teams"> | |
| 254 | return html`<section class="settings-block"> | |
| 255 | <h2>Members</h2> | |
| 256 | ${members.length === 0 | |
| 257 | ? nothing("No members yet") | |
| 258 | : plainList( | |
| 259 | members.map((m) => | |
| 260 | row({ | |
| 261 | href: `/${m.username}`, | |
| 262 | lead: avatar(m.username), | |
| 263 | title: m.displayName || m.username, | |
| 264 | nav: true, | |
| 265 | ...(m.displayName ? { meta: html`@${m.username}` } : {}), | |
| 266 | }), | |
| 267 | ), | |
| 268 | )} | |
| 269 | </section> | |
| 270 | <section class="settings-block"> | |
| 271 | <h2 id="teams">Teams</h2> | |
| 272 | ${teams.length === 0 | |
| 273 | ? nothing("No teams yet") | |
| 274 | : plainList( | |
| 275 | teams.map((t) => | |
| 276 | row({ | |
| 277 | lead: icon("users"), | |
| 278 | title: t.name, | |
| 279 | meta: memberCount(listTeamMembers(ctx.app.db, t.id).length), | |
| 280 | trail: html`${badge(t.permission)}${when(canManage, () => | |
| 281 | btn("Manage", { | |
| 282 | href: `/${org.username}/-/teams/${t.id}`, | |
| 283 | size: "sm", | |
| 284 | }), | |
| 285 | )}`, | |
| 286 | }), | |
| 287 | ), | |
| 288 | )} | |
| 289 | </section> | |
| 290 | ${when( | |
| 291 | canManage, | |
| 292 | () => html`<section class="settings-block"> | |
| 293 | <h2>New team</h2> | |
| 294 | <form method="post" action="/${org.username}/-/teams"> | |
| 291 | 295 | ${uiField({ |
| 292 | 296 | label: "Team name", |
| 293 | 297 | name: "name", |
| @@ -306,9 +310,8 @@ function teamsSection(ctx: Ctx, org: User, canManage: boolean): Html { | ||
| 306 | 310 | <div class="form-actions"> |
| 307 | 311 | ${btn("Create team", { variant: "primary" })} |
| 308 | 312 | </div> |
| 309 | </form>`, | |
| 310 | { head: html`<h3>New team</h3>`, extra: "bare" }, | |
| 311 | ), | |
| 313 | </form> | |
| 314 | </section>`, | |
| 312 | 315 | )}`; |
| 313 | 316 | } |
| 314 | 317 | |
| @@ -359,15 +362,12 @@ export async function ownerProfile(ctx: Ctx): Promise<Response> { | ||
| 359 | 362 | )} |
| 360 | 363 | </div> |
| 361 | 364 | ${ownerless |
| 362 | ? card( | |
| 363 | html`<p> | |
| 364 | Nobody can administer this organization right now: everyone on | |
| 365 | its Owners team is suspended or removed. What it holds stays as | |
| 366 | it is; the forge's owner can unsuspend an owner or | |
| 367 | <a href="/${owner.username}/-/teams/${OWNERS_TEAM}">add one</a>. | |
| 368 | </p>`, | |
| 369 | { tone: "attn" }, | |
| 370 | ) | |
| 365 | ? html`<p class="small"> | |
| 366 | ${dot("attn")} Nobody can administer this organization right now: | |
| 367 | everyone on its Owners team is suspended or removed. What it holds | |
| 368 | stays as it is; the forge's owner can unsuspend an owner or | |
| 369 | <a href="/${owner.username}/-/teams/${OWNERS_TEAM}">add one</a>. | |
| 370 | </p>` | |
| 371 | 371 | : null} |
| 372 | 372 | ${apps.html} |
| 373 | 373 | ${repos} |
test/org-language.test.ts added +130
| @@ -0,0 +1,130 @@ | ||
| 1 | /** An org's profile speaks the home page's language (#9890): plain sections under headings, no card. */ | |
| 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 } from "../src/app.ts"; | |
| 7 | import { addTeamMember, createOrg, createTeam } from "../src/core/orgs.ts"; | |
| 8 | import { createUser } from "../src/core/users.ts"; | |
| 9 | import { serve } from "../src/http/server.ts"; | |
| 10 | import { quietLogs } from "./helpers/globals.ts"; | |
| 11 | ||
| 12 | quietLogs(); | |
| 13 | ||
| 14 | let dir = ""; | |
| 15 | let app: ReturnType<typeof createApp>; | |
| 16 | let servers: Awaited<ReturnType<typeof serve>>; | |
| 17 | ||
| 18 | const PASS = "a-good-passphrase"; | |
| 19 | ||
| 20 | async function mk(username: string, first = false) { | |
| 21 | const made = await createUser(app.db, { | |
| 22 | origin: "seed", | |
| 23 | trusted: true, | |
| 24 | username, | |
| 25 | email: `${username}@forge.test`, | |
| 26 | password: PASS, | |
| 27 | adminIfFirst: first, | |
| 28 | }); | |
| 29 | if (!made.ok) throw new Error(String(made.error)); | |
| 30 | return made.value; | |
| 31 | } | |
| 32 | ||
| 33 | async function get(path: string, as: string): Promise<string> { | |
| 34 | const res = await fetch(`${servers.url}/signin`, { | |
| 35 | method: "POST", | |
| 36 | redirect: "manual", | |
| 37 | headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url }, | |
| 38 | body: new URLSearchParams({ username: as, password: PASS }), | |
| 39 | }); | |
| 40 | const cookie = (res.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; | |
| 41 | const got = await fetch(`${servers.url}${path}`, { headers: { cookie } }); | |
| 42 | expect(got.status).toBe(200); | |
| 43 | return got.text(); | |
| 44 | } | |
| 45 | ||
| 46 | const noBoxes = (body: string) => { | |
| 47 | expect(body).not.toContain('class="card'); | |
| 48 | expect(body).not.toContain("card-body"); | |
| 49 | }; | |
| 50 | const h1s = (body: string) => body.match(/<h1[\s>]/g)?.length ?? 0; | |
| 51 | ||
| 52 | beforeAll(async () => { | |
| 53 | dir = await mkdtemp(join(tmpdir(), "forge-org-lang-")); | |
| 54 | app = createApp({ dataDir: dir, port: 0 }); | |
| 55 | servers = await serve(app); | |
| 56 | await mk("alice", true); | |
| 57 | const bob = await mk("bob"); | |
| 58 | const carol = await mk("carol"); | |
| 59 | const dave = await mk("dave"); | |
| 60 | const acme = createOrg(app.db, "acme", bob.id); | |
| 61 | if (!acme.ok) throw new Error("org"); | |
| 62 | const readers = createTeam(app.db, acme.value.id, "Readers", "read"); | |
| 63 | if (!readers.ok) throw new Error("team"); | |
| 64 | addTeamMember(app.db, readers.value, carol.id); | |
| 65 | const orphan = createOrg(app.db, "orphan", dave.id); | |
| 66 | if (!orphan.ok) throw new Error("org"); | |
| 67 | const watchers = createTeam(app.db, orphan.value.id, "Watchers", "read"); | |
| 68 | if (!watchers.ok) throw new Error("team"); | |
| 69 | addTeamMember(app.db, watchers.value, carol.id); | |
| 70 | app.db.run("UPDATE user SET suspended_at = ? WHERE id = ?", [new Date().toISOString(), dave.id]); | |
| 71 | }); | |
| 72 | ||
| 73 | afterAll(async () => { | |
| 74 | servers.stop(); | |
| 75 | app.close(); | |
| 76 | await rm(dir, { recursive: true, force: true }); | |
| 77 | }); | |
| 78 | ||
| 79 | test("an org with teams: one h1, plain sections, the form for its owner", async () => { | |
| 80 | const body = await get("/acme", "bob"); | |
| 81 | noBoxes(body); | |
| 82 | expect(h1s(body)).toBe(1); | |
| 83 | expect(body).toContain(">Teams</h2>"); | |
| 84 | expect(body).toContain(">Members</h2>"); | |
| 85 | expect(body).toContain("<h2>New team</h2>"); | |
| 86 | expect(body).toContain('action="/acme/-/teams"'); | |
| 87 | expect(body).toContain("Readers"); | |
| 88 | }); | |
| 89 | ||
| 90 | test("a member who cannot manage sees the teams and no form", async () => { | |
| 91 | const body = await get("/acme", "carol"); | |
| 92 | noBoxes(body); | |
| 93 | expect(body).toContain(">Teams</h2>"); | |
| 94 | expect(body).not.toContain("New team"); | |
| 95 | expect(body).not.toContain('action="/acme/-/teams"'); | |
| 96 | }); | |
| 97 | ||
| 98 | test("an ownerless org says so in one plain line, not a box", async () => { | |
| 99 | const body = await get("/orphan", "carol"); | |
| 100 | noBoxes(body); | |
| 101 | expect(h1s(body)).toBe(1); | |
| 102 | expect(body).toContain("Nobody can administer this organization"); | |
| 103 | expect(body).toContain('data-tone="attn"'); | |
| 104 | expect(body).toContain('href="/orphan/-/teams/Owners"'); | |
| 105 | }); | |
| 106 | ||
| 107 | test("a forge admin sees no box on an org page, grants and secrets included", async () => { | |
| 108 | const body = await get("/acme", "alice"); | |
| 109 | noBoxes(body); | |
| 110 | expect(body).toContain("Secrets"); | |
| 111 | expect(body).toContain("Subscriptions for every repository here"); | |
| 112 | expect(body).not.toContain('class="mt"><h2'); | |
| 113 | }); | |
| 114 | ||
| 115 | test("an org with no teams says so in one muted line", async () => { | |
| 116 | const eve = await mk("eve"); | |
| 117 | const empty = createOrg(app.db, "hollow", eve.id); | |
| 118 | if (!empty.ok) throw new Error("org"); | |
| 119 | app.db.run("DELETE FROM team WHERE org_id = ?", [empty.value.id]); | |
| 120 | const body = await get("/hollow", "alice"); | |
| 121 | expect(body).toContain("No teams yet"); | |
| 122 | }); | |
| 123 | ||
| 124 | test("a person's profile shows what it did, without boxes", async () => { | |
| 125 | const body = await get("/bob", "carol"); | |
| 126 | noBoxes(body); | |
| 127 | expect(h1s(body)).toBe(1); | |
| 128 | expect(body).toContain("@bob"); | |
| 129 | expect(body).not.toContain(">Teams</h2>"); | |
| 130 | }); |
test/profile-rows.test.ts +5 −6
| @@ -55,15 +55,14 @@ function cards(page: string): { cls: string; heading: string | undefined }[] { | ||
| 55 | 55 | /** A card the profile may keep: bare, an empty state, or a toned notice. */ |
| 56 | 56 | const allowed = (cls: string) => /\b(bare|empty)\b/.test(cls); |
| 57 | 57 | |
| 58 | test("an admin sees apps, repositories, members, teams and the new-team form as bare lists", async () => { | |
| 58 | test("an admin sees apps, repositories, members, teams and the new-team form as plain sections (#9890)", async () => { | |
| 59 | 59 | const page = await get("/acme"); |
| 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"); | |
| 60 | expect(cards(page)).toEqual([]); | |
| 61 | for (const heading of ["Apps", "Other repositories", "New team", "Members"]) | |
| 62 | expect(page).toMatch(new RegExp(`<section class="settings-block">\\s*<h2>${heading}</h2>`)); | |
| 62 | 63 | expect(page).toContain('href="/acme/notes"'); |
| 63 | 64 | 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"`)); | |
| 65 | expect(page).toMatch(/<section class="settings-block">\s*<h2 id="teams">Teams<\/h2>\s*<ul class="list setting-list"/); | |
| 67 | 66 | }); |
| 68 | 67 | |
| 69 | 68 | test("no boxed card is left on an org's page, for an admin or a visitor", async () => { |
test/studio-door.test.ts +2 −2
| @@ -385,8 +385,8 @@ describe("an owner's page lists an app once, not once in each vocabulary (#221)" | ||
| 385 | 385 | ).text(); |
| 386 | 386 | expect(occurrences(studio, 'href="/vega/anvil"')).toBe(1); |
| 387 | 387 | expect(occurrences(studio, 'href="/vega/ledger"')).toBe(1); |
| 388 | expect(studio).toContain('<h2 class="mt">Members</h2>'); | |
| 389 | expect(studio).toContain('<h2 class="mt" id="teams">Teams</h2>'); | |
| 388 | expect(studio).toContain('<h2>Members</h2>'); | |
| 389 | expect(studio).toContain('<h2 id="teams">Teams</h2>'); | |
| 390 | 390 | |
| 391 | 391 | const dev = await ( |
| 392 | 392 | await fetch(`${base}/vega`, { |