Public: anyone can read this; the crew can change it.
/flow's pipes and Valves read as plain sections under headings, not boxed cards #10025
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10011-flow-s-pipes-and-valves-read-as-plain-se → main 2 commits fork
2 files
changed +73
−5
src/web/pages/flow.ts +6 −5
| @@ -17,7 +17,7 @@ import { pipesSvg } from "../flow_pipes.ts"; | ||
| 17 | 17 | import { field, readForm } from "../form.ts"; |
| 18 | 18 | import { html, when } from "../html.ts"; |
| 19 | 19 | import { flashTo, page } from "../layout.ts"; |
| 20 | import { btn, card, icon, row, time } from "../ui.ts"; | |
| 20 | import { btn, icon, row, time } from "../ui.ts"; | |
| 21 | 21 | |
| 22 | 22 | const money = (usd: number): string => `$${usd.toFixed(2)}`; |
| 23 | 23 | const plural = (n: number, one: string, many = `${one}s`): string => `${String(n)} ${n === 1 ? one : many}`; |
| @@ -84,11 +84,12 @@ export function flowPage(ctx: Ctx): Response { | ||
| 84 | 84 | const p = readPipes(ctx.app.db, user.id); |
| 85 | 85 | const body = html`<div class="page-head"><h1>Flow</h1>${btn("Boards", { href: "/boards", variant: "ghost" })}</div> |
| 86 | 86 | <p class="mb">Money flows from the left to shipped on the right, as wide as this week's real spend. Every repository has a valve: drag it to give the repository a share of the week, all the way down to shut. The crew does exactly what the valves say, from the next tick, and nothing but you turns one.</p> |
| 87 | ${card(html`<div class="card-body">${pipesSvg(p)} | |
| 88 | <p class="muted small">${money(p.usd)} spent in the last 7 days; ${plural(p.shipped, "pull")} shipped.</p></div>`)} | |
| 87 | ${pipesSvg(p)} | |
| 88 | <p class="muted small">${money(p.usd)} spent in the last 7 days; ${plural(p.shipped, "pull")} shipped.</p> | |
| 89 | 89 | ${p.valves.length === 0 |
| 90 | ? card(html`<div class="card-body"><p class="muted">No repository has spent, queued or shipped anything this week, so there is no pipe to turn yet.</p></div>`) | |
| 91 | : card(html`<ul class="list">${p.valves.map(valveRow)}</ul>`, { pad: false, head: html`${icon("activity")}<h2>Valves</h2>` })}`; | |
| 90 | ? html`<p class="muted">No repository has spent, queued or shipped anything this week, so there is no pipe to turn yet.</p>` | |
| 91 | : html`<h2>Valves</h2> | |
| 92 | <ul class="list">${p.valves.map(valveRow)}</ul>`}`; | |
| 92 | 93 | const rendering = performance.now(); |
| 93 | 94 | const response = page({ ctx, title: "Flow", body, scriptJs: valveJs }); |
| 94 | 95 | notePhase(ctx.request, "render", performance.now() - rendering); |
test/flow-language.test.ts added +67
| @@ -0,0 +1,67 @@ | ||
| 1 | /** /flow reads as plain sections under headings, no card boxes (#10011). */ | |
| 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 { createRun } from "../src/core/runs.ts"; | |
| 9 | import { createUser } from "../src/core/users.ts"; | |
| 10 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 11 | ||
| 12 | let dir = ""; | |
| 13 | let app: App; | |
| 14 | let servers: Servers; | |
| 15 | let repoId = ""; | |
| 16 | ||
| 17 | const login = async (username: string): Promise<string> => { | |
| 18 | const res = await fetch(`${servers.url}/signin`, { | |
| 19 | method: "POST", | |
| 20 | redirect: "manual", | |
| 21 | headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url }, | |
| 22 | body: new URLSearchParams({ username, password: "a-good-passphrase" }), | |
| 23 | }); | |
| 24 | return (res.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; | |
| 25 | }; | |
| 26 | const get = (cookie: string) => fetch(`${servers.url}/flow`, { headers: { cookie }, redirect: "manual" }); | |
| 27 | ||
| 28 | beforeAll(async () => { | |
| 29 | dir = await mkdtemp(join(tmpdir(), "forge-flow-language-")); | |
| 30 | app = createApp({ dataDir: dir, port: 0 }); | |
| 31 | servers = await serve(app); | |
| 32 | const make = async (username: string, adminIfFirst: boolean) => { | |
| 33 | const u = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst }); | |
| 34 | if (!u.ok) throw new Error(u.error); | |
| 35 | return u.value; | |
| 36 | }; | |
| 37 | const alice = await make("alice", true); | |
| 38 | await make("bob", false); | |
| 39 | const made = await createRepo(app, alice, { name: "shop" }); | |
| 40 | if (!made.ok) throw new Error(String(made.error)); | |
| 41 | repoId = made.value.id; | |
| 42 | }); | |
| 43 | afterAll(async () => { | |
| 44 | servers.stop(); | |
| 45 | await rm(dir, { recursive: true, force: true }); | |
| 46 | }); | |
| 47 | ||
| 48 | test("with no valves: pipes, then one muted line, no card", async () => { | |
| 49 | const text = await (await get(await login("alice"))).text(); | |
| 50 | expect(text).not.toContain('class="card'); | |
| 51 | expect(text).toContain('class="pipes'); | |
| 52 | expect(text).toContain('<p class="muted">No repository has spent'); | |
| 53 | expect(text).not.toContain("<h2>Valves</h2>"); | |
| 54 | }); | |
| 55 | ||
| 56 | test("with valves: an h2 over a plain list whose forms valveJs finds, no card", async () => { | |
| 57 | createRun(app.db, { repoId, kind: "agent", reason: "assigned" }); | |
| 58 | const text = await (await get(await login("alice"))).text(); | |
| 59 | expect(text).not.toContain('class="card'); | |
| 60 | expect(text).toMatch(/<h2>Valves<\/h2>\s*<ul class="list">/); | |
| 61 | expect(text).toContain('class="valve-form"'); | |
| 62 | expect(text).toContain('type="range"'); | |
| 63 | }); | |
| 64 | ||
| 65 | test("non-admins still get 404", async () => { | |
| 66 | expect((await get(await login("bob"))).status).toBe(404); | |
| 67 | }); |