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

Every list page's empty state says what to do next in one line with one link, proven by a test that walks the lists #10944

Merged opened this

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

Merged

scout/forge:scout/10938-every-list-page-s-empty-state-says-what → main 8 commits fork 3 threads · 3 unresolved

  • Merged. scout/10938-every-list-page-s-empty-state-says-what was merged into main as 314b476.

Review

Sign in to review this change.
5 files changed +124 −11
src/web/studio/lists.ts +21 −5
@@ -126,6 +126,19 @@ function emptyNote(
126126 return `${subject} is ${filter} yet.`;
127127 }
128128
129 /** The one link under an empty list. A set filter points back at the whole
130 * list; none set points at what fills it: for issues, telling the crew (or
131 * signing in to), for pulls and runs, the issues that start them. */
132 function nextStep(
133 list: string,
134 filter: WordFilter | undefined,
135 start: { label: string; href: string },
136 ): Html {
137 return filter === undefined
138 ? btn(start.label, { href: start.href, variant: "primary" })
139 : btn("Show all", { href: list, variant: "ghost" });
140 }
141
129142 /** A filter bar of five words, each a link carrying `?word=` — never a
130143 * form, so the filter lives in the address and can be shared. No filter
131144 * reads as "Open": what is open and moving. `trail` sits at the bar's far
@@ -450,10 +463,11 @@ export async function tellsList(ctx: Ctx): Promise<Response> {
450463 title: emptyNote(
451464 f.word,
452465 "No issue",
453 record === undefined
454 ? "Nothing open."
455 : "Nothing open. Tell the crew what you want.",
466 "Nothing open.",
456467 ),
468 action: nextStep(`${base}/issues`, f.word, ctx.auth.user === undefined
469 ? { label: "Sign in", href: `/signin?next=${encodeURIComponent(`${base}/issues`)}` }
470 : { label: "Tell the crew", href: `${base}/issues/new` }),
457471 })
458472 : listCard(
459473 rows.map(({ issue, info }) =>
@@ -620,8 +634,9 @@ export async function changesList(ctx: Ctx): Promise<Response> {
620634 title: emptyNote(
621635 f.word,
622636 "No pull request",
623 "Nothing open.",
637 "No pull request is open.",
624638 ),
639 action: nextStep(`${base}/pulls`, f.word, { label: "See open issues", href: `${base}/issues` }),
625640 })
626641 : listCard(
627642 rows.map(({ pull, info }) =>
@@ -808,8 +823,9 @@ export async function runsListPage(ctx: Ctx, repo: Repo): Promise<Response> {
808823 title: emptyNote(
809824 filter,
810825 "No run",
811 "Nothing open.",
826 "No run is open.",
812827 ),
828 action: nextStep(`${base}/runs`, filter, { label: "Start with an issue", href: `${base}/issues` }),
813829 })
814830 : listCard(
815831 rows.map(({ run, info }) => {
test/empty-states.test.ts added +97
@@ -0,0 +1,97 @@
1 /** A fresh account's blank list pages are never a dead end: every empty state carries a link (#10938). */
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 { createRepo } from "../src/actions/repos.ts";
7 import { createApp, type App } from "../src/app.ts";
8 import { createSession } from "../src/core/sessions.ts";
9 import { createUser } from "../src/core/users.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11
12 let dir: string;
13 let app: App;
14 let servers: Servers;
15 let cookie: string;
16
17 beforeAll(async () => {
18 dir = await mkdtemp(join(tmpdir(), "forge-empty-states-"));
19 app = createApp({ dataDir: dir, port: 0 });
20 servers = await serve(app);
21 const u = await createUser(app.db, { origin: "seed", trusted: true, username: "root", allowReserved: true, email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
22 if (!u.ok) throw new Error(u.error);
23 cookie = `forge_session=${createSession(app.db, u.value.id).token}`;
24 const made = await createRepo(app, u.value, { name: "fresh", visibility: "public" });
25 if (!made.ok) throw new Error("repo");
26 });
27 afterAll(async () => {
28 servers.stop();
29 app.close();
30 await rm(dir, { recursive: true, force: true });
31 });
32
33 const LISTS = ["/root/fresh/issues", "/root/fresh/pulls", "/root/fresh/runs", "/apps", "/settings/people", "/boards", "/notes"];
34
35 /** Every `.empty` block in a page, whole: matched by div depth, so a nested
36 * div in the part never cuts it short. */
37 function emptyBlocks(page: string): string[] {
38 const out: string[] = [];
39 let at = page.indexOf('<div class="empty">');
40 while (at !== -1) {
41 const tag = /<(\/?)div\b/g;
42 tag.lastIndex = at;
43 let depth = 0;
44 let end = page.length;
45 for (let m = tag.exec(page); m; m = tag.exec(page)) {
46 depth += m[1] ? -1 : 1;
47 if (depth === 0) {
48 end = page.indexOf(">", m.index) + 1;
49 break;
50 }
outdated

Add a signed-out fetch and a ?word= filtered fetch of each list so nextStep's other branches are proven.

51 }
52 out.push(page.slice(at, end));
53 at = page.indexOf('<div class="empty">', end);
54 }
55 return out;
56 }
57
58 const links = (block: string): string[] => [...block.matchAll(/<a\s[^>]*href="([^"]+)"[^>]*>([^<]*)</g)].map((m) => `${m[1]}|${m[2]!.trim()}`);
59
60 for (const path of LISTS) {
61 test(`${path}: a fresh account's empty state has a link`, async () => {
62 const res = await fetch(servers.url + path, { headers: { cookie } });
63 const blocks = emptyBlocks(await res.text());
64 const bare = blocks.filter((b) => links(b).length === 0);
65 expect(bare).toEqual([]);
66 });
67 }
68
69 const REPO_LISTS = ["issues", "pulls", "runs"];
70 for (const list of REPO_LISTS) {
71 test(`/root/fresh/${list}: signed out, the empty state still has one link`, async () => {
72 const blocks = emptyBlocks(await (await fetch(`${servers.url}/root/fresh/${list}`)).text());
73 expect(blocks.length).toBe(1);
74 expect(links(blocks[0]!).length).toBe(1);
75 });
76 test(`/root/fresh/${list}?word=shipped: a filtered empty state links back to the whole list`, async () => {
77 for (const headers of [{ cookie }, {}]) {
78 const blocks = emptyBlocks(await (await fetch(`${servers.url}/root/fresh/${list}?word=shipped`, { headers })).text());
79 expect(blocks.length).toBe(1);
80 expect(links(blocks[0]!)).toEqual([`/root/fresh/${list}|Show all`]);
81 }
82 });
83 }
84
85 test("each list's one link says its own next step, in one sentence", async () => {
86 const one = async (list: string, headers: Record<string, string>) => {
87 const [b] = emptyBlocks(await (await fetch(`${servers.url}/root/fresh/${list}`, { headers })).text());
88 return links(b!);
89 };
90 expect(await one("issues", { cookie })).toEqual(["/root/fresh/issues/new|Tell the crew"]);
91 expect(await one("issues", {})).toEqual(["/signin?next=%2Froot%2Ffresh%2Fissues|Sign in"]);
92 expect(await one("pulls", { cookie })).toEqual(["/root/fresh/issues|See open issues"]);
93 expect(await one("runs", { cookie })).toEqual(["/root/fresh/issues|Start with an issue"]);
94 const [b] = emptyBlocks(await (await fetch(`${servers.url}/root/fresh/pulls`, { headers: { cookie } })).text());
95 expect(b).not.toContain("Tell the crew");
96 expect((b!.match(/<p\b/g) ?? []).length).toBe(1);
97 });
test/studio-search.test.ts +4 −4
@@ -1247,9 +1247,9 @@ describe("the studio's empty lists", () => {
12471247 test("a repository with nothing in it says what to do, and blames no filter", async () => {
12481248 await setup("blank");
12491249 for (const [path, words] of [
1250 ["issues", "Tell the crew what you want"],
1251 ["pulls", "Nothing open."],
1252 ["runs", "Nothing open."],
1250 ["issues", "Nothing open."],
1251 ["pulls", "No pull request is open."],
1252 ["runs", "No run is open."],
12531253 ] as const) {
12541254 const page = await get(`/alice/blank/${path}`);
12551255 expect(page).toContain(words);
@@ -1263,7 +1263,7 @@ describe("the studio's empty lists", () => {
12631263 if (!told.ok) throw new Error(String(told.error));
12641264 const issues = await get("/alice/busy/issues?word=shipped");
12651265 expect(issues).toContain("No issue is shipped yet.");
1266 expect(issues).not.toContain("Tell the crew what you want");
1266 expect(issues).not.toContain("Nothing open.");
12671267 const pulls = await get("/alice/busy/pulls?word=look");
12681268 expect(pulls).toContain("No pull request is look yet.");
12691269 const runs = await get("/alice/busy/runs?word=stopped");
test/web-apps.test.ts +1 −1
@@ -483,7 +483,7 @@ describe("apps, runs and the crew on the web", () => {
483483 expect(list).toContain("Deploy production");
484484 expect(list).toContain(`href="/alice/hello/runs/${run!.id}"`);
485485 expect(await bodyOf(alice, "/alice/hello/runs?kind=agent")).toContain(
486 "Nothing open.",
486 "No run is open.",
487487 );
488488
489489 const page = await bodyOf(alice, `/alice/hello/runs/${run!.id}`);
test/web-ui.test.ts +1 −1
@@ -239,7 +239,7 @@ describe("web ui", () => {
239239 "Closed without merging.",
240240 );
241241 expect(await (await fetch(`${base}/mel/proj/pulls`)).text()).toContain(
242 "Nothing open.",
242 "No pull request is open.",
243243 );
244244
245245 await fetch(`${at}/state`, {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs