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

The milestones list is keyboard-first: j/k move between milestones, enter opens one #10362

Merged opened this

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

Merged

mason/forge:mason/10345-the-milestones-list-is-keyboard-first-j → main 3 commits fork

  • Merged. mason/10345-the-milestones-list-is-keyboard-first-j was merged into main as 7175c97.

Review

Sign in to review this change.
3 files changed +109 −20
src/web/pages/milestones.ts +1
@@ -189,6 +189,7 @@ export const progress = (done: number, total: number, label: string): Html =>
189189 function limbLine(base: string, limb: Limb, move: Html | null = null): Html {
190190 const w = limb.weight;
191191 return row({
192 nav: true,
192193 title: html`<a href="${milestoneHref(base, limb.issue.number)}"
193194 >${limb.name}</a
194195 >`,
test/fixtures/milestones-golden.html +20 −20
@@ -9,7 +9,7 @@
99 <ul class="list setting-list"><li>
1010 <div class="row"
1111
12 >
12 data-nav tabindex="-1">
1313 <span class="row-main">
1414 <span
1515 class="row-title"
@@ -47,7 +47,7 @@
4747 </li><li>
4848 <div class="row"
4949
50 >
50 data-nav tabindex="-1">
5151 <span class="row-main">
5252 <span
5353 class="row-title"
@@ -97,7 +97,7 @@
9797 </li><li>
9898 <div class="row"
9999
100 >
100 data-nav tabindex="-1">
101101 <span class="row-main">
102102 <span
103103 class="row-title"
@@ -147,7 +147,7 @@
147147 </li><li>
148148 <div class="row"
149149
150 >
150 data-nav tabindex="-1">
151151 <span class="row-main">
152152 <span
153153 class="row-title"
@@ -197,7 +197,7 @@
197197 </li><li>
198198 <div class="row"
199199
200 >
200 data-nav tabindex="-1">
201201 <span class="row-main">
202202 <span
203203 class="row-title"
@@ -247,7 +247,7 @@
247247 </li><li>
248248 <div class="row"
249249
250 >
250 data-nav tabindex="-1">
251251 <span class="row-main">
252252 <span
253253 class="row-title"
@@ -297,7 +297,7 @@
297297 </li><li>
298298 <div class="row"
299299
300 >
300 data-nav tabindex="-1">
301301 <span class="row-main">
302302 <span
303303 class="row-title"
@@ -347,7 +347,7 @@
347347 </li><li>
348348 <div class="row"
349349
350 >
350 data-nav tabindex="-1">
351351 <span class="row-main">
352352 <span
353353 class="row-title"
@@ -397,7 +397,7 @@
397397 </li><li>
398398 <div class="row"
399399
400 >
400 data-nav tabindex="-1">
401401 <span class="row-main">
402402 <span
403403 class="row-title"
@@ -447,7 +447,7 @@
447447 </li><li>
448448 <div class="row"
449449
450 >
450 data-nav tabindex="-1">
451451 <span class="row-main">
452452 <span
453453 class="row-title"
@@ -497,7 +497,7 @@
497497 </li><li>
498498 <div class="row"
499499
500 >
500 data-nav tabindex="-1">
501501 <span class="row-main">
502502 <span
503503 class="row-title"
@@ -547,7 +547,7 @@
547547 </li><li>
548548 <div class="row"
549549
550 >
550 data-nav tabindex="-1">
551551 <span class="row-main">
552552 <span
553553 class="row-title"
@@ -597,7 +597,7 @@
597597 </li><li>
598598 <div class="row"
599599
600 >
600 data-nav tabindex="-1">
601601 <span class="row-main">
602602 <span
603603 class="row-title"
@@ -647,7 +647,7 @@
647647 </li><li>
648648 <div class="row"
649649
650 >
650 data-nav tabindex="-1">
651651 <span class="row-main">
652652 <span
653653 class="row-title"
@@ -697,7 +697,7 @@
697697 </li><li>
698698 <div class="row"
699699
700 >
700 data-nav tabindex="-1">
701701 <span class="row-main">
702702 <span
703703 class="row-title"
@@ -747,7 +747,7 @@
747747 </li><li>
748748 <div class="row"
749749
750 >
750 data-nav tabindex="-1">
751751 <span class="row-main">
752752 <span
753753 class="row-title"
@@ -797,7 +797,7 @@
797797 </li><li>
798798 <div class="row"
799799
800 >
800 data-nav tabindex="-1">
801801 <span class="row-main">
802802 <span
803803 class="row-title"
@@ -847,7 +847,7 @@
847847 </li><li>
848848 <div class="row"
849849
850 >
850 data-nav tabindex="-1">
851851 <span class="row-main">
852852 <span
853853 class="row-title"
@@ -897,7 +897,7 @@
897897 </li><li>
898898 <div class="row"
899899
900 >
900 data-nav tabindex="-1">
901901 <span class="row-main">
902902 <span
903903 class="row-title"
@@ -947,7 +947,7 @@
947947 </li><li>
948948 <div class="row"
949949
950 >
950 data-nav tabindex="-1">
951951 <span class="row-main">
952952 <span
953953 class="row-title"
test/keys-milestones.test.ts added +88
@@ -0,0 +1,88 @@
1 /** The milestones page joins the shell's j/k row traversal: one `data-nav` row per limb, none when empty (#10345). */
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 { linkIssue } from "../src/actions/dependencies.ts";
7 import { openIssue } from "../src/actions/issues.ts";
8 import { createRepo } from "../src/actions/repos.ts";
9 import { createApp, type App } from "../src/app.ts";
10 import { getIssueById } from "../src/core/issues.ts";
11 import { getRepoById } from "../src/core/repos.ts";
12 import { createUser, type User } from "../src/core/users.ts";
13 import { serve, type Servers } from "../src/http/server.ts";
14 import { proveAll } from "./helpers/verified.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16
17 quietLogs();
18
19 let root = "";
20 let app: App;
21 let servers: Servers;
22 let cookie = "";
23 let alice: User;
24 const limbs: number[] = [];
25
26 beforeAll(async () => {
27 root = mkdtempSync(join(tmpdir(), "forge-milestones-keys-"));
28 app = createApp({ dataDir: root, port: 0, forgeRepo: "alice/forge" });
29 const u = await createUser(app.db, {
30 origin: "seed",
31 trusted: true,
32 username: "alice",
33 email: "[email protected]",
34 password: "a-good-passphrase",
35 adminIfFirst: true,
36 });
37 if (!u.ok) throw new Error(u.error);
38 proveAll(app);
39 alice = u.value;
40 for (const name of ["full", "empty"]) {
41 const made = await createRepo(app, alice, { name });
42 if (!made.ok) throw new Error(made.error);
43 }
44 const repo = () => getRepoById(app.db, app.db.query<{ id: string }, []>("SELECT id FROM repo WHERE name='full'").get()!.id)!;
45 const open = (title: string) => {
46 const o = openIssue(app, alice, repo(), { title });
47 if (!o.ok) throw new Error(String(o.error));
48 return o.value;
49 };
50 for (const title of ["Alpha limb", "Beta limb"]) {
51 const limb = open(title);
52 const leaf = open(`${title} step`);
53 const linked = linkIssue(app, alice, repo(), getIssueById(app.db, limb.id)!, getIssueById(app.db, leaf.id)!);
54 if (!linked.ok) throw new Error(String(linked.error));
55 limbs.push(limb.number);
56 }
57 servers = await serve(app);
58 const signin = await fetch(`${servers.url}/signin`, {
59 method: "POST",
60 headers: { origin: servers.url },
61 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
62 redirect: "manual",
63 });
64 cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!;
65 });
66 afterAll(() => {
67 servers.stop();
68 app.close();
69 rmSync(root, { recursive: true, force: true });
70 });
71
72 const get = async (path: string) =>
73 (await fetch(`${servers.url}${path}`, { headers: { cookie } })).text();
74
75 test("each limb row is a data-nav row whose first link is the milestone's own page", async () => {
76 const text = await get("/alice/full/milestones");
77 const rows = text.match(/<div class="row"[^>]*data-nav[^>]*>[\s\S]*?<\/div>/g) ?? [];
78 expect(rows.length).toBe(2);
79 expect(rows.every((r) => r.includes('tabindex="-1"'))).toBe(true);
80 for (const [i, n] of limbs.entries())
81 expect(rows[i]!.match(/<a [^>]*href="([^"]*)"/)?.[1]).toBe(`/alice/full/milestones/${n}`);
82 });
83
84 test("a page with no rows has no data-nav", async () => {
85 const text = await get("/alice/empty/milestones");
86 expect(text).toContain("Nothing here waits for anything yet");
87 expect(text).not.toContain("data-nav");
88 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs