Public: anyone can read this; the crew can change it.
The people list is keyboard-first: j/k move between rows, enter opens one #10367
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10353-the-people-list-is-keyboard-first-j-k-mo → main 4 commits fork 1 thread · 1 unresolved
3 files
changed +91
−3
src/web/pages/people.ts +1 −1
| @@ -66,7 +66,7 @@ export function peoplePage(ctx: Ctx): Response { | ||
| 66 | 66 | const shown = apps.slice(0, 3).map((n) => html`<a href="/${p.username}/${n}">${n}</a>`); |
| 67 | 67 | const standing = p.usdPerMonth === null ? "no allowance" : `$${p.usdPerMonth.toFixed(2)} a month`; |
| 68 | 68 | return html`<tr> |
| 69 | <td>${userLink(p.username)}<div class="small muted">${count === 0 ? "No apps yet" : count === 1 ? "1 app" : `${count} apps`}${ | |
| 69 | <td>${userLink(p.username, { nav: true })}<div class="small muted">${count === 0 ? "No apps yet" : count === 1 ? "1 app" : `${count} apps`}${ | |
| 70 | 70 | count === 0 ? "" : html`: ${shown.flatMap((a, i) => (i === 0 ? [a] : [", ", a]))}${count > 3 ? ` and ${count - 3} more` : ""}` |
| 71 | 71 | }</div></td> |
| 72 | 72 | <td>${badge(role, { tone: role === "guest" ? "default" : "success" })} <span class="small muted">${standing}</span></td> |
src/web/ui.ts +2 −2
| @@ -879,10 +879,10 @@ export function nameMark(mark: Mark | undefined): Html | null { | ||
| 879 | 879 | /** An author: avatar plus a link to their profile, and their mark. Never bare text. */ |
| 880 | 880 | export function userLink( |
| 881 | 881 | username: string, |
| 882 | o: { avatar?: boolean; display?: string; mark?: Mark | undefined } = {}, | |
| 882 | o: { avatar?: boolean; display?: string; mark?: Mark | undefined; nav?: boolean } = {}, | |
| 883 | 883 | ): Html { |
| 884 | 884 | return html`<span class="byline" |
| 885 | >${o.avatar === false ? null : avatar(username)}<a href="/${username}" | |
| 885 | >${o.avatar === false ? null : avatar(username)}<a href="/${username}"${o.nav ? raw(" data-nav") : null} | |
| 886 | 886 | >${o.display || username}</a |
| 887 | 887 | >${nameMark(o.mark)}</span |
| 888 | 888 | >`; |
test/keys-people-list.test.ts added +88
| @@ -0,0 +1,88 @@ | ||
| 1 | /** The people list joins the shell's j/k row traversal (#10353): one data-nav per person, none on the empty page. */ | |
| 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 { createUser } from "../src/core/users.ts"; | |
| 8 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 9 | ||
| 10 | let dataDir: string; | |
| 11 | let app: App; | |
| 12 | let servers: Servers; | |
| 13 | let cookie = ""; | |
| 14 | ||
| 15 | async function signIn(username: string): Promise<string> { | |
| 16 | const res = await fetch(`${servers.url}/signin`, { | |
| 17 | method: "POST", | |
| 18 | redirect: "manual", | |
| 19 | headers: { origin: servers.url }, | |
| 20 | body: new URLSearchParams({ username, password: "a-good-passphrase" }), | |
| 21 | }); | |
| 22 | const set = res.headers.getSetCookie().map((c) => c.split(";")[0]).join("; "); | |
| 23 | if (!set) throw new Error(`no session: ${res.status}`); | |
| 24 | return set; | |
| 25 | } | |
| 26 | ||
| 27 | beforeAll(async () => { | |
| 28 | dataDir = await mkdtemp(join(tmpdir(), "forge-keys-people-")); | |
| 29 | app = createApp({ dataDir, port: 0 }); | |
| 30 | servers = await serve(app); | |
| 31 | for (const username of ["alice", "bob", "carol"]) { | |
| 32 | const made = await createUser(app.db, { | |
| 33 | origin: "seed", trusted: true, username, email: `${username}@forge.local`, | |
| 34 | password: "a-good-passphrase", adminIfFirst: true, | |
| 35 | }); | |
| 36 | if (!made.ok) throw new Error(made.error); | |
| 37 | } | |
| 38 | cookie = await signIn("alice"); | |
| 39 | }); | |
| 40 | ||
| 41 | afterAll(async () => { | |
| 42 | await servers.stop(); | |
| 43 | await rm(dataDir, { recursive: true, force: true }); | |
| 44 | }); | |
| 45 | ||
| 46 | test("each person row is one data-nav row whose first link is the person's page", async () => { | |
| 47 | const res = await fetch(`${servers.url}/settings/people`, { headers: { cookie } }); | |
| 48 | expect(res.status).toBe(200); | |
| 49 | const text = await res.text(); | |
| 50 | const rows = [...text.matchAll(/<tr>([\s\S]*?)<\/tr>/g)].filter((m) => m[1]!.includes("data-nav")).map((m) => m[1]!); | |
| 51 | expect(rows.length).toBe(3); | |
| 52 | const table = text.slice(text.indexOf(`id="people"`)); | |
| 53 | expect(table.match(/data-nav/g)!.length).toBe(3); | |
| 54 | const firsts = rows.map((r) => /<a href="([^"]+)" data-nav/.exec(r)![1]); | |
| 55 | for (const name of ["alice", "bob", "carol"]) expect(firsts).toContain(`/${name}`); | |
| 56 | }); | |
| 57 | ||
| 58 | test("the empty page has no data-nav: nobody listed, nothing to traverse", async () => { | |
| 59 | const emptyDir = await mkdtemp(join(tmpdir(), "forge-keys-people-empty-")); | |
| 60 | const fresh = createApp({ dataDir: emptyDir, port: 0 }); | |
| 61 | const up = await serve(fresh); | |
| 62 | try { | |
| 63 | const made = await createUser(fresh.db, { | |
| 64 | origin: "seed", trusted: true, username: "ada", email: "[email protected]", | |
| 65 | password: "a-good-passphrase", adminIfFirst: true, | |
| 66 | }); | |
| 67 | if (!made.ok) throw new Error(made.error); | |
| 68 | const res0 = await fetch(`${up.url}/signin`, { | |
| 69 | method: "POST", redirect: "manual", headers: { origin: up.url }, | |
| 70 | body: new URLSearchParams({ username: "ada", password: "a-good-passphrase" }), | |
| 71 | }); | |
| 72 | const own = res0.headers.getSetCookie().map((c) => c.split(";")[0]).join("; "); | |
| 73 | // The list hides agents; an admin session outlives the flag for this one request. | |
| 74 | fresh.db.run("UPDATE user SET is_agent = 1 WHERE username = 'ada'"); | |
| 75 | const res = await fetch(`${up.url}/settings/people`, { headers: { cookie: own } }); | |
| 76 | const text = await res.text(); | |
| 77 | expect(res.status).toBe(200); | |
| 78 | expect(text).toContain("Nobody has joined yet."); | |
| 79 | // The settings sidebar has its own data-nav links; the people block is what follows its heading. | |
| 80 | const block = text.slice(text.indexOf("Guests, members and admins")); | |
| 81 | expect(block.length).toBeLessThan(text.length); | |
| 82 | expect(block).not.toContain("data-nav"); | |
| 83 | expect(block).not.toContain(`id="people"`); | |
| 84 | } finally { | |
| 85 | await up.stop(); | |
| 86 | await rm(emptyDir, { recursive: true, force: true }); | |
| 87 | } | |
| 88 | }); |
Missing: the empty page. Issue box 2 requires asserting none data-nav on the empty page; add a test for it.