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

The /people page speaks the home page's language, with looks before and after #9702

Merged opened this

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

Merged

mason/forge:mason/9646-the-people-page-speaks-the-home-page-s-l → main 3 commits fork 3 threads · 3 unresolved

  • Merged. mason/9646-the-people-page-speaks-the-home-page-s-l was merged into main as 55b0c29.

Review

Sign in to review this change.
2 files changed +107 −19
src/web/pages/people.ts +41 −19
@@ -17,15 +17,32 @@ import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts";
1717 import { field, readForm } from "../form.ts";
1818 import { html } from "../html.ts";
1919 import { flashTo, page } from "../layout.ts";
20 import { badge, btn, card, empty, userLink } from "../ui.ts";
20 import { badge, btn, empty, userLink } from "../ui.ts";
2121 import { accountSections } from "./origins.ts";
2222
23 /**
outdated

Add a test for appsMade. It counts every app joined to a repo the user owns, so say whether private repos or apps of other environments should appear in an admin's count. If they should not, filter them out here.

24 * The apps each username made, by repo name. An admin sees every app, private
25 * ones included: this page is admin-only and an app is one row per repo, so
26 * no environment is counted twice.
27 */
28 export function appsMade(ctx: Ctx): Map<string, string[]> {
29 const rows = ctx.app.db
30 .query<{ username: string; name: string }, []>(
31 `SELECT u.username, r.name FROM app a JOIN repo r ON r.id = a.repo_id JOIN user u ON u.id = r.owner_id ORDER BY r.name COLLATE NOCASE`,
32 )
33 .all();
34 const made = new Map<string, string[]>();
35 for (const r of rows) made.set(r.username, [...(made.get(r.username) ?? []), r.name]);
36 return made;
37 }
38
2339 // GET /settings/people
2440 export function peoplePage(ctx: Ctx): Response {
2541 if (!ctx.auth.user) return signInFirst(ctx);
2642 if (!ctx.auth.user.isAdmin) return notFoundPage(ctx);
2743 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
2844 const rows = timePhase(ctx.request, "people", () => listPeople(ctx.app.db));
45 const made = appsMade(ctx);
2946 const rendering = performance.now();
3047 const line = (p: Person) => {
3148 const role = p.role;
@@ -44,10 +61,15 @@ export function peoplePage(ctx: Ctx): Response {
4461 <input type="hidden" name="role" value="guest" />
4562 ${btn("Make guest", { type: "submit", size: "sm" })}
outdated

Principle 2 and the issue's 'a line of what they made': the muted 'N apps' count is only a number. Show what they made, for example the app names as links, or at least a count that links to their apps. Also, this behaviour is untested: add a test that fails without it (0, 1, N apps; 'no allowance').

4663 </form>`;
64 const apps = made.get(p.username) ?? [];
65 const count = apps.length;
66 const shown = apps.slice(0, 3).map((n) => html`<a href="/${p.username}/${n}">${n}</a>`);
67 const standing = p.usdPerMonth === null ? "no allowance" : `$${p.usdPerMonth.toFixed(2)} a month`;
4768 return html`<tr>
48 <td>${userLink(p.username)}</td>
49 <td>${badge(role, { tone: role === "guest" ? "default" : "success" })}</td>
50 <td>${p.usdPerMonth === null ? "none" : `$${p.usdPerMonth.toFixed(2)} a month`}</td>
69 <td>${userLink(p.username)}<div class="small muted">${count === 0 ? "No apps yet" : count === 1 ? "1 app" : `${count} apps`}${
70 count === 0 ? "" : html`: ${shown.flatMap((a, i) => (i === 0 ? [a] : [", ", a]))}${count > 3 ? ` and ${count - 3} more` : ""}`
71 }</div></td>
72 <td>${badge(role, { tone: role === "guest" ? "default" : "success" })} <span class="small muted">${standing}</span></td>
5173 <td>${act}</td>
outdated

Principle 1: the heading 'Guests, members and admins' and then a paragraph of allowance and role-limit explanation under it. One confident sentence should say what the page is for. Cut the paragraph down to one plain sentence, in the words of docs/WRITING.md, and let the role limits live where they are acted on.

5274 </tr>`;
5375 };
@@ -57,21 +79,21 @@ export function peoplePage(ctx: Ctx): Response {
5779 body: html`<div class="page-head"><h1>People</h1></div>
5880 ${settingsLayout(
5981 accountSections("people", { admin: true }),
60 card(
61 rows.length === 0
62 ? empty({ icon: "users", title: "Nobody has joined yet." })
63 : html`<div class="table-scroll" data-people>
64 <table class="table" id="people">
65 <thead><tr><th>Account</th><th>Role</th><th>Allowance</th><th></th></tr></thead>
66 <tbody>${rows.map(line)}</tbody>
67 </table>
68 </div>`,
69 {
70 head: html`<h2>Guests, members and admins</h2>
71 <p class="muted">A member may be given a starter model allowance (up to
72 $${GRANT_MAX_USD} a month). ${roleLimits()}</p>`,
73 },
74 ),
82 html`<section class="settings-block">
83 <h2>Guests, members and admins</h2>
84 <p class="muted">Make a guest a member, with an allowance if you like.</p>
85 ${
86 rows.length === 0
87 ? empty({ icon: "users", title: "Nobody has joined yet." })
88 : html`<div class="table-scroll" data-people>
89 <table class="table" id="people">
90 <thead><tr><th>Person</th><th>Standing</th><th></th></tr></thead>
91 <tbody>${rows.map(line)}</tbody>
92 </table>
93 </div>
94 <p class="small muted">Allowances go up to $${GRANT_MAX_USD} a month. ${roleLimits()}</p>`
95 }
96 </section>`,
7597 "Account settings",
7698 )}`,
7799 });
test/people-apps-made.test.ts added +66
@@ -0,0 +1,66 @@
1 /** `/settings/people` says what each person made: 0, 1 and many apps as links, and "no allowance" for a member without one. */
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 { createApp, type App } from "../src/app.ts";
7 import { createUser } from "../src/core/users.ts";
8 import { serve, type Servers } from "../src/http/server.ts";
9 import { appsMade } from "../src/web/pages/people.ts";
10 import { quietLogs } from "./helpers/globals.ts";
11
12 quietLogs();
13
14 let root = "";
15 let app: App;
16 let servers: Servers;
17 let cookie = "";
18
19 const seed = async (username: string, admin = false) => {
20 const u = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst: admin });
21 if (!u.ok) throw new Error(u.error);
22 return u.value;
23 };
24 const makeApp = (ownerId: string, name: string, visibility: "public" | "private") => {
25 const repo = `r-${name}`;
26 app.db.query("INSERT INTO repo (id, owner_id, name, visibility) VALUES (?1, ?2, ?3, ?4)").run(repo, ownerId, name, visibility);
27 app.db.query("INSERT INTO app (id, repo_id, slug, access) VALUES (?1, ?2, ?3, ?4)").run(`a-${name}`, repo, name, visibility);
28 };
29
30 beforeAll(async () => {
31 root = mkdtempSync(join(tmpdir(), "forge-people-apps-"));
32 app = createApp({ dataDir: root, port: 0 });
33 await seed("alice", true);
34 const one = await seed("one-app");
35 const two = await seed("two-apps");
36 await seed("no-apps");
37 makeApp(one.id, "solo", "public");
38 makeApp(two.id, "zeta", "private");
39 makeApp(two.id, "alpha", "public");
40 servers = await serve(app);
41 const signin = await fetch(`${servers.url}/signin`, { method: "POST", headers: { origin: servers.url }, body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), redirect: "manual" });
42 cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!;
43 });
44 afterAll(() => {
45 servers.stop();
46 app.close();
47 rmSync(root, { recursive: true, force: true });
48 });
49
50 test("appsMade lists every owner's apps by name, private ones too", () => {
51 const made = appsMade({ app } as never);
52 expect(made.get("one-app")).toEqual(["solo"]);
53 expect(made.get("two-apps")).toEqual(["alpha", "zeta"]);
54 expect(made.has("no-apps")).toBe(false);
55 });
56
57 test("each person's row says how many apps and links them; no allowance is said plainly", async () => {
58 const text = await (await fetch(`${servers.url}/settings/people`, { headers: { cookie } })).text();
59 const row = (name: string) => text.split("<tr>").find((r) => r.includes(`<a href="/${name}"`))!.replace(/\s+/g, " ");
60 expect(row("no-apps")).toContain("No apps yet");
61 expect(row("one-app")).toContain("1 app");
62 expect(row("one-app")).toContain('<a href="/one-app/solo">solo</a>');
63 expect(row("two-apps")).toContain("2 apps");
64 expect(row("two-apps")).toContain('<a href="/two-apps/alpha">alpha</a>, <a href="/two-apps/zeta">zeta</a>');
65 expect(row("two-apps")).toContain("no allowance");
66 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs