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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/10345-the-milestones-list-is-keyboard-first-j → main 3 commits fork
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 => | ||
| 189 | 189 | function limbLine(base: string, limb: Limb, move: Html | null = null): Html { |
| 190 | 190 | const w = limb.weight; |
| 191 | 191 | return row({ |
| 192 | nav: true, | |
| 192 | 193 | title: html`<a href="${milestoneHref(base, limb.issue.number)}" |
| 193 | 194 | >${limb.name}</a |
| 194 | 195 | >`, |
test/fixtures/milestones-golden.html +20 −20
| @@ -9,7 +9,7 @@ | ||
| 9 | 9 | <ul class="list setting-list"><li> |
| 10 | 10 | <div class="row" |
| 11 | 11 | |
| 12 | > | |
| 12 | data-nav tabindex="-1"> | |
| 13 | 13 | <span class="row-main"> |
| 14 | 14 | <span |
| 15 | 15 | class="row-title" |
| @@ -47,7 +47,7 @@ | ||
| 47 | 47 | </li><li> |
| 48 | 48 | <div class="row" |
| 49 | 49 | |
| 50 | > | |
| 50 | data-nav tabindex="-1"> | |
| 51 | 51 | <span class="row-main"> |
| 52 | 52 | <span |
| 53 | 53 | class="row-title" |
| @@ -97,7 +97,7 @@ | ||
| 97 | 97 | </li><li> |
| 98 | 98 | <div class="row" |
| 99 | 99 | |
| 100 | > | |
| 100 | data-nav tabindex="-1"> | |
| 101 | 101 | <span class="row-main"> |
| 102 | 102 | <span |
| 103 | 103 | class="row-title" |
| @@ -147,7 +147,7 @@ | ||
| 147 | 147 | </li><li> |
| 148 | 148 | <div class="row" |
| 149 | 149 | |
| 150 | > | |
| 150 | data-nav tabindex="-1"> | |
| 151 | 151 | <span class="row-main"> |
| 152 | 152 | <span |
| 153 | 153 | class="row-title" |
| @@ -197,7 +197,7 @@ | ||
| 197 | 197 | </li><li> |
| 198 | 198 | <div class="row" |
| 199 | 199 | |
| 200 | > | |
| 200 | data-nav tabindex="-1"> | |
| 201 | 201 | <span class="row-main"> |
| 202 | 202 | <span |
| 203 | 203 | class="row-title" |
| @@ -247,7 +247,7 @@ | ||
| 247 | 247 | </li><li> |
| 248 | 248 | <div class="row" |
| 249 | 249 | |
| 250 | > | |
| 250 | data-nav tabindex="-1"> | |
| 251 | 251 | <span class="row-main"> |
| 252 | 252 | <span |
| 253 | 253 | class="row-title" |
| @@ -297,7 +297,7 @@ | ||
| 297 | 297 | </li><li> |
| 298 | 298 | <div class="row" |
| 299 | 299 | |
| 300 | > | |
| 300 | data-nav tabindex="-1"> | |
| 301 | 301 | <span class="row-main"> |
| 302 | 302 | <span |
| 303 | 303 | class="row-title" |
| @@ -347,7 +347,7 @@ | ||
| 347 | 347 | </li><li> |
| 348 | 348 | <div class="row" |
| 349 | 349 | |
| 350 | > | |
| 350 | data-nav tabindex="-1"> | |
| 351 | 351 | <span class="row-main"> |
| 352 | 352 | <span |
| 353 | 353 | class="row-title" |
| @@ -397,7 +397,7 @@ | ||
| 397 | 397 | </li><li> |
| 398 | 398 | <div class="row" |
| 399 | 399 | |
| 400 | > | |
| 400 | data-nav tabindex="-1"> | |
| 401 | 401 | <span class="row-main"> |
| 402 | 402 | <span |
| 403 | 403 | class="row-title" |
| @@ -447,7 +447,7 @@ | ||
| 447 | 447 | </li><li> |
| 448 | 448 | <div class="row" |
| 449 | 449 | |
| 450 | > | |
| 450 | data-nav tabindex="-1"> | |
| 451 | 451 | <span class="row-main"> |
| 452 | 452 | <span |
| 453 | 453 | class="row-title" |
| @@ -497,7 +497,7 @@ | ||
| 497 | 497 | </li><li> |
| 498 | 498 | <div class="row" |
| 499 | 499 | |
| 500 | > | |
| 500 | data-nav tabindex="-1"> | |
| 501 | 501 | <span class="row-main"> |
| 502 | 502 | <span |
| 503 | 503 | class="row-title" |
| @@ -547,7 +547,7 @@ | ||
| 547 | 547 | </li><li> |
| 548 | 548 | <div class="row" |
| 549 | 549 | |
| 550 | > | |
| 550 | data-nav tabindex="-1"> | |
| 551 | 551 | <span class="row-main"> |
| 552 | 552 | <span |
| 553 | 553 | class="row-title" |
| @@ -597,7 +597,7 @@ | ||
| 597 | 597 | </li><li> |
| 598 | 598 | <div class="row" |
| 599 | 599 | |
| 600 | > | |
| 600 | data-nav tabindex="-1"> | |
| 601 | 601 | <span class="row-main"> |
| 602 | 602 | <span |
| 603 | 603 | class="row-title" |
| @@ -647,7 +647,7 @@ | ||
| 647 | 647 | </li><li> |
| 648 | 648 | <div class="row" |
| 649 | 649 | |
| 650 | > | |
| 650 | data-nav tabindex="-1"> | |
| 651 | 651 | <span class="row-main"> |
| 652 | 652 | <span |
| 653 | 653 | class="row-title" |
| @@ -697,7 +697,7 @@ | ||
| 697 | 697 | </li><li> |
| 698 | 698 | <div class="row" |
| 699 | 699 | |
| 700 | > | |
| 700 | data-nav tabindex="-1"> | |
| 701 | 701 | <span class="row-main"> |
| 702 | 702 | <span |
| 703 | 703 | class="row-title" |
| @@ -747,7 +747,7 @@ | ||
| 747 | 747 | </li><li> |
| 748 | 748 | <div class="row" |
| 749 | 749 | |
| 750 | > | |
| 750 | data-nav tabindex="-1"> | |
| 751 | 751 | <span class="row-main"> |
| 752 | 752 | <span |
| 753 | 753 | class="row-title" |
| @@ -797,7 +797,7 @@ | ||
| 797 | 797 | </li><li> |
| 798 | 798 | <div class="row" |
| 799 | 799 | |
| 800 | > | |
| 800 | data-nav tabindex="-1"> | |
| 801 | 801 | <span class="row-main"> |
| 802 | 802 | <span |
| 803 | 803 | class="row-title" |
| @@ -847,7 +847,7 @@ | ||
| 847 | 847 | </li><li> |
| 848 | 848 | <div class="row" |
| 849 | 849 | |
| 850 | > | |
| 850 | data-nav tabindex="-1"> | |
| 851 | 851 | <span class="row-main"> |
| 852 | 852 | <span |
| 853 | 853 | class="row-title" |
| @@ -897,7 +897,7 @@ | ||
| 897 | 897 | </li><li> |
| 898 | 898 | <div class="row" |
| 899 | 899 | |
| 900 | > | |
| 900 | data-nav tabindex="-1"> | |
| 901 | 901 | <span class="row-main"> |
| 902 | 902 | <span |
| 903 | 903 | class="row-title" |
| @@ -947,7 +947,7 @@ | ||
| 947 | 947 | </li><li> |
| 948 | 948 | <div class="row" |
| 949 | 949 | |
| 950 | > | |
| 950 | data-nav tabindex="-1"> | |
| 951 | 951 | <span class="row-main"> |
| 952 | 952 | <span |
| 953 | 953 | 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 | }); |