Public: anyone can read this; the crew can change it.
A variants deck speaks the home page's language #9984
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9958-a-variants-deck-speaks-the-home-page-s-l → main 4 commits fork 2 threads · 2 unresolved
3 files
changed +124
−5
src/web/pages/apps.ts +28
| @@ -680,6 +680,34 @@ export function previewLook(env: EnvStatus): { | ||
| 680 | 680 | |
| 681 | 681 | /** Where this pull's preview stands, with the way in. */ |
| 682 | 682 | 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 | |
| 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 { | |
| 683 | 711 | const view = appView(ctx.app, ctx.auth.user, repo); |
| 684 | 712 | if (!view.ok) return html``; |
| 685 | 713 | const base = repoBase(repo); |
src/web/pages/variants.ts +5 −5
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | /** |
| 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 | |
| 3 | 3 | * group, with its look and its preview, and a like and a dislike that are |
| 4 | 4 | * plain form posts. A swipe on touch is the same post, sent by a few lines |
| 5 | 5 | * of script. A reader sees the cards and no buttons; their post is refused. |
| @@ -25,8 +25,8 @@ import { field, readForm } from "../form.ts"; | ||
| 25 | 25 | import { html, when, type Html } from "../html.ts"; |
| 26 | 26 | import { flashTo, page } from "../layout.ts"; |
| 27 | 27 | 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"; | |
| 30 | 30 | import { lookCard } from "./looks.ts"; |
| 31 | 31 | import { parseNumber } from "./thread.ts"; |
| 32 | 32 | |
| @@ -63,7 +63,7 @@ function deckCard( | ||
| 63 | 63 | return html`<section class="variant-card" data-swipe> |
| 64 | 64 | <h2><a href="${changeHref(base, pull.number)}">#${pull.number} ${pull.title}</a></h2> |
| 65 | 65 | ${record === undefined ? null : lookCard(ctx, repo, record, pull, pull.headOid)} |
| 66 | ${previewCard(ctx, repo, pull)} | |
| 66 | ${previewBlock(ctx, repo, pull)} | |
| 67 | 67 | ${when(writer, () => |
| 68 | 68 | html`<div class="bar mt"> |
| 69 | 69 | <form class="like" method="post" action="${vote}/like"> |
| @@ -106,7 +106,7 @@ export function variantsPage(ctx: Ctx): Response { | ||
| 106 | 106 | scriptJs: SWIPE_JS, |
| 107 | 107 | body: html`<h1>Choose among ${pulls.length} variants</h1> |
| 108 | 108 | ${open.length === 0 |
| 109 | ? card(html`<p class="muted">Every variant is decided.</p>`) | |
| 109 | ? html`<p class="muted">Every variant is decided.</p>` | |
| 110 | 110 | : open.map((p) => |
| 111 | 111 | deckCard(ctx, repo, p, group, writer, open.length === 1), |
| 112 | 112 | )}`, |
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 ( |
||
| 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 | }); |
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.