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

Merged opened this

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

Merged

mason/forge:mason/9890-an-organization-s-profile-its-teams-and → main 6 commits fork

  • Merged. mason/9890-an-organization-s-profile-its-teams-and was merged into main as f281397.

Review

Sign in to review this change.
4 files changed +254 −125
src/web/pages/org.ts +117 −117
@@ -58,10 +58,8 @@ import {
5858 avatar,
5959 badge,
6060 btn,
61 card,
6261 confirmAction,
63 empty,
64 listCard,
62 dot,
6563 pager,
6664 repoCounts,
6765 row,
@@ -72,6 +70,19 @@ import {
7270
7371 const PER_PAGE = 20;
7472
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
7586 // ---- profile ----
7687
7788 /**
@@ -126,53 +137,46 @@ function repoSection(
126137 </form>
127138 </div>
128139 ${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>`}
176180 ${pager(
177181 repos.length > PER_PAGE && last
178182 ? { next: `/${owner.username}?${nextParams.toString()}` }
@@ -207,30 +211,25 @@ async function appsSection(
207211 return {
208212 count: 0,
209213 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 ),
216219 ),
217220 };
218221
219222 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">
223226 <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 };
234233 }
235234
236235 /** Everyone on any of the org's teams, once each. */
@@ -252,42 +251,47 @@ function memberCount(n: number): Html {
252251 function teamsSection(ctx: Ctx, org: User, canManage: boolean): Html {
253252 const teams = listTeams(ctx.app.db, org.id);
254253 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">
291295 ${uiField({
292296 label: "Team name",
293297 name: "name",
@@ -306,9 +310,8 @@ function teamsSection(ctx: Ctx, org: User, canManage: boolean): Html {
306310 <div class="form-actions">
307311 ${btn("Create team", { variant: "primary" })}
308312 </div>
309 </form>`,
310 { head: html`<h3>New team</h3>`, extra: "bare" },
311 ),
313 </form>
314 </section>`,
312315 )}`;
313316 }
314317
@@ -359,15 +362,12 @@ export async function ownerProfile(ctx: Ctx): Promise<Response> {
359362 )}
360363 </div>
361364 ${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>`
371371 : null}
372372 ${apps.html}
373373 ${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 }[] {
5555 /** A card the profile may keep: bare, an empty state, or a toned notice. */
5656 const allowed = (cls: string) => /\b(bare|empty)\b/.test(cls);
5757
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 () => {
5959 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>`));
6263 expect(page).toContain('href="/acme/notes"');
6364 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"/);
6766 });
6867
6968 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)"
385385 ).text();
386386 expect(occurrences(studio, 'href="/vega/anvil"')).toBe(1);
387387 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>');
390390
391391 const dev = await (
392392 await fetch(`${base}/vega`, {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs