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

A variants deck speaks the home page's language #9984

Merged opened this

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

Merged

mason/forge:mason/9958-a-variants-deck-speaks-the-home-page-s-l → main 4 commits fork 2 threads · 2 unresolved

  • Merged. mason/9958-a-variants-deck-speaks-the-home-page-s-l was merged into main as 5ef5fe6.

Review

Sign in to review this change.
3 files changed +124 −5
src/web/pages/apps.ts +28
@@ -680,6 +680,34 @@ export function previewLook(env: EnvStatus): {
680680
681681 /** Where this pull's preview stands, with the way in. */
682682 export function previewCard(ctx: Ctx, repo: Repo, pull: Pull): Html {
683 return previewWith(ctx, repo, pull, card);
684 }
685
686 /** The same preview facts as a block under an h2, the home page's language. */
687 export function previewBlock(ctx: Ctx, repo: Repo, pull: Pull): Html {
688 return previewWith(ctx, repo, pull, (body, o) =>
689 html`<section
outdated

Principles 3 and 7: the head (icon, h2, badge) is placed straight in the section with no .bar, so it will not lay out in one line and the h2 gets no block style. Wrap ${o.head} in <div class="bar">. The tone/extra from previewLook is also ignored here, so a failed preview loses its danger marking.

690 class="settings-block"
691 data-preview-block
692 ${o.tone ? html`data-tone="${o.tone}"` : null}
693 >
694 <div class="bar">${o.head}</div>
695 ${o.tone === "danger"
696 ? html`<p class="small" role="status">This preview has failed.</p>`
697 : o.tone === "attn"
698 ? html`<p class="muted small" role="status">The last deploy of this preview failed.</p>`
699 : null}
700 ${body}
701 </section>`,
702 );
703 }
704
705 function previewWith(
706 ctx: Ctx,
707 repo: Repo,
708 pull: Pull,
709 card: (body: Html, o: { head: Html; extra?: string; tone?: "danger" | "attn" }) => Html,
710 ): Html {
683711 const view = appView(ctx.app, ctx.auth.user, repo);
684712 if (!view.ok) return html``;
685713 const base = repoBase(repo);
src/web/pages/variants.ts +5 −5
@@ -1,5 +1,5 @@
11 /**
2 * A deck chooses among sibling pulls: one card per open pull in a variant
2 * A deck chooses among sibling pulls: one section per open pull in a variant
33 * group, with its look and its preview, and a like and a dislike that are
44 * plain form posts. A swipe on touch is the same post, sent by a few lines
55 * of script. A reader sees the cards and no buttons; their post is refused.
@@ -25,8 +25,8 @@ import { field, readForm } from "../form.ts";
2525 import { html, when, type Html } from "../html.ts";
2626 import { flashTo, page } from "../layout.ts";
2727 import { changeHref } from "../studio/nouns.ts";
28 import { btn, card } from "../ui.ts";
29 import { previewCard } from "./apps.ts";
28 import { btn } from "../ui.ts";
29 import { previewBlock } from "./apps.ts";
3030 import { lookCard } from "./looks.ts";
3131 import { parseNumber } from "./thread.ts";
3232
@@ -63,7 +63,7 @@ function deckCard(
6363 return html`<section class="variant-card" data-swipe>
6464 <h2><a href="${changeHref(base, pull.number)}">#${pull.number} ${pull.title}</a></h2>
6565 ${record === undefined ? null : lookCard(ctx, repo, record, pull, pull.headOid)}
66 ${previewCard(ctx, repo, pull)}
66 ${previewBlock(ctx, repo, pull)}
6767 ${when(writer, () =>
6868 html`<div class="bar mt">
6969 <form class="like" method="post" action="${vote}/like">
@@ -106,7 +106,7 @@ export function variantsPage(ctx: Ctx): Response {
106106 scriptJs: SWIPE_JS,
107107 body: html`<h1>Choose among ${pulls.length} variants</h1>
108108 ${open.length === 0
109 ? card(html`<p class="muted">Every variant is decided.</p>`)
109 ? html`<p class="muted">Every variant is decided.</p>`
110110 : open.map((p) =>
111111 deckCard(ctx, repo, p, group, writer, open.length === 1),
112112 )}`,
test/variants-language.test.ts added +91
@@ -0,0 +1,91 @@
1 /** A variants deck speaks the home page's language: sections, not cards (#9958). */
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 { createRepo } from "../src/actions/repos.ts";
8 import { createIssue } from "../src/core/issues.ts";
9 import type { Repo } from "../src/core/repos.ts";
10 import { createSession } from "../src/core/sessions.ts";
11 import { createUser, type User } from "../src/core/users.ts";
12 import { serve, type Servers } from "../src/http/server.ts";
13 import { promote } from "./helpers/members.ts";
14
15 let dataDir = "";
16 let app: App, servers: Servers, alice: User, repo: Repo, cookie = "";
17
18 beforeAll(async () => {
19 dataDir = await mkdtemp(join(tmpdir(), "forge-variants-lang-"));
20 app = createApp({ dataDir, port: 0 });
21 servers = await serve(app);
22 const r = await createUser(app.db, {
23 origin: "seed", trusted: true, username: "alice", allowReserved: true,
24 email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true,
25 });
26 if (!r.ok) throw new Error(r.error);
27 alice = r.value;
28 promote(app.db, alice);
29 cookie = `forge_session=${createSession(app.db, alice.id).token}`;
30 const c = await createRepo(app, alice, { name: "garden", visibility: "public" });
31 if (!c.ok) throw new Error(JSON.stringify(c));
32 repo = c.value;
33 });
34 afterAll(async () => {
35 servers.stop();
36 app.close();
37 await rm(dataDir, { recursive: true, force: true });
38 });
39
40 function group(name: string, count: number): number[] {
41 const out: number[] = [];
42 for (let i = 0; i < count; i++) {
43 const issue = createIssue(app.db, { repoId: repo.id, authorId: alice.id, title: `${name} ${i}`, kind: "pull" });
44 if (!issue.ok) throw new Error(issue.error);
45 app.db
46 .query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, variant_group) VALUES (?1, 'main', ?2, 'a', 'b', ?3)`)
47 .run(issue.value.id, `${name}-${i}`, name);
48 out.push(issue.value.number);
49 }
50 return out;
51 }
52 const main = (page: string) => page.slice(page.indexOf("<main"), page.indexOf("</main>"));
53 const get = async (g: string) =>
54 main(await (await fetch(`${servers.url}/alice/garden/variants/${g}`, { headers: { cookie } })).text());
55
56 test("a deck is plain sections with their forms, no card", async () => {
57 const nums = group("plain", 2);

This test passes on main when no preview renders, so it does not prove the change. Assert the preview section (data-preview-block) is present, which needs an environment, or drop this claim.

58 app.db
59 .query(`INSERT INTO app (id, repo_id, slug, access, preview_data) VALUES (?1, ?2, 'garden', 'public', 'copy')`)
60 .run(crypto.randomUUID(), repo.id);
61 const m = await get("plain");
62 expect(m).not.toContain('class="card');
63 expect((m.match(/class="variant-card"/g) ?? []).length).toBe(2);
64 // the preview is a block whose head sits on one .bar line, not a card
65 expect((m.match(/data-preview-block/g) ?? []).length).toBe(2);
66 expect(m).toMatch(/<section\s+class="settings-block"\s+data-preview-block\s*>\s*<div class="bar">[\s\S]*?<h2>Preview<\/h2>/);
67 for (const n of nums) {
68 expect(m).toContain(`action="/alice/garden/variants/plain/${n}/like"`);
69 expect(m).toContain(`action="/alice/garden/variants/plain/${n}/dislike"`);
70 }
71 });
72
73 test("the all-decided state is one line, no card", async () => {
74 const nums = group("done", 2);
75 app.db.query("UPDATE issue SET state = 'closed' WHERE repo_id = ?1 AND number IN (?2, ?3)").run(repo.id, nums[0]!, nums[1]!);
76 const m = await get("done");
77 expect(m).not.toContain('class="card');
78 expect(m).toContain("Every variant is decided.");
79 });
80
81 test("like still records through the plain form post", async () => {
82 const [n] = group("likes", 2);
83 const r = await fetch(`${servers.url}/alice/garden/variants/likes/${n}/like`, {
84 method: "POST", headers: { cookie, origin: servers.url }, body: new URLSearchParams(), redirect: "manual",
85 });
86 expect(r.status).toBe(303);
87 const rows = app.db
88 .query("SELECT answer FROM decision WHERE kind = 'lean' AND issue_id = (SELECT id FROM issue WHERE repo_id = ?1 AND number = ?2)")
89 .all(repo.id, n!);
90 expect(rows.length).toBe(1);
91 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs