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

Merged opened this

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

Merged

mason/forge:mason/10353-the-people-list-is-keyboard-first-j-k-mo → main 4 commits fork 1 thread · 1 unresolved

  • Merged. mason/10353-the-people-list-is-keyboard-first-j-k-mo was merged into main as b723810.

Review

Sign in to review this change.
3 files changed +91 −3
src/web/pages/people.ts +1 −1
@@ -66,7 +66,7 @@ export function peoplePage(ctx: Ctx): Response {
6666 const shown = apps.slice(0, 3).map((n) => html`<a href="/${p.username}/${n}">${n}</a>`);
6767 const standing = p.usdPerMonth === null ? "no allowance" : `$${p.usdPerMonth.toFixed(2)} a month`;
6868 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`}${
7070 count === 0 ? "" : html`: ${shown.flatMap((a, i) => (i === 0 ? [a] : [", ", a]))}${count > 3 ? ` and ${count - 3} more` : ""}`
7171 }</div></td>
7272 <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 {
879879 /** An author: avatar plus a link to their profile, and their mark. Never bare text. */
880880 export function userLink(
881881 username: string,
882 o: { avatar?: boolean; display?: string; mark?: Mark | undefined } = {},
882 o: { avatar?: boolean; display?: string; mark?: Mark | undefined; nav?: boolean } = {},
883883 ): Html {
884884 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}
886886 >${o.display || username}</a
887887 >${nameMark(o.mark)}</span
888888 >`;
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}`);

Missing: the empty page. Issue box 2 requires asserting none data-nav on the empty page; add a test for it.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs