Public: anyone can read this; the crew can change it.
The forge's following page speaks the home page's language #9777
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9714-the-forge-s-following-page-speaks-the-ho → main 2 commits fork
2 files
changed +46
−51
src/web/pages/chain.ts +42 −51
| @@ -19,7 +19,7 @@ import { loadRepo, notFoundPage, pageHead, repoBase, signInFirst } from "../chro | ||
| 19 | 19 | import { field, readForm } from "../form.ts"; |
| 20 | 20 | import { html, type Html } from "../html.ts"; |
| 21 | 21 | import { flashOrJson, page, redirect } from "../layout.ts"; |
| 22 | import { badge, btn, card, row } from "../ui.ts"; | |
| 22 | import { badge, btn, row } from "../ui.ts"; | |
| 23 | 23 | |
| 24 | 24 | /** The events that change what the chain page says. */ |
| 25 | 25 | export const CHAIN_LIVE = "forge.severed,forge.proposal,forge.follow_mode,forge.upstream,forge.updates_taken"; |
| @@ -45,8 +45,8 @@ export function modeForm(base: string, current: FollowMode | null, legend: strin | ||
| 45 | 45 | </form>`; |
| 46 | 46 | } |
| 47 | 47 | |
| 48 | function modeCard(base: string, current: FollowMode | null): Html { | |
| 49 | return card(modeForm(base, current, "How this forge follows its parent"), { head: html`<h2>Follow mode</h2>` }); | |
| 48 | function modeSection(base: string, current: FollowMode | null): Html { | |
| 49 | return html`<h2 class="mt">Follow mode</h2>${modeForm(base, current, "How this forge follows its parent")}`; | |
| 50 | 50 | } |
| 51 | 51 | |
| 52 | 52 | const MODE_WORDS = { "tell-me": "tell me", "take-updates": "take updates (as a pull you merge)" } as const; |
| @@ -79,31 +79,27 @@ async function carriesChain(ctx: Ctx): Promise<boolean> { | ||
| 79 | 79 | |
| 80 | 80 | const STATE_WORDS = { open: "open", merged: "merged", closed: "closed" } as const; |
| 81 | 81 | |
| 82 | function proposalCard(base: string, proposals: readonly ForgeProposal[], writer: boolean): Html { | |
| 83 | return card( | |
| 84 | html`${proposals.length === 0 | |
| 85 | ? html`<p class="muted">Nothing proposed to the parent forge yet.</p>` | |
| 86 | : html`<ul class="list" aria-label="Proposals to the parent forge"> | |
| 87 | ${proposals.map( | |
| 88 | (p) => html`<li> | |
| 89 | <div class="row"> | |
| 90 | <span class="row-main"> | |
| 91 | <a class="row-title" href="${p.url}" rel="noopener">#${p.number} ${p.title}</a> | |
| 92 | <span class="row-meta" | |
| 93 | >${STATE_WORDS[p.state]}${p.reachable ? null : html` · parent unreachable`}</span | |
| 94 | > | |
| 95 | </span> | |
| 96 | </div> | |
| 97 | </li>`, | |
| 98 | )} | |
| 99 | </ul>`} | |
| 100 | ${writer | |
| 101 | ? html`<form method="post" action="${base}/following/propose" class="mt"> | |
| 102 | <div class="form-actions">${btn("Propose to the parent forge", { type: "submit", icon: "merge" })}</div> | |
| 103 | </form>` | |
| 104 | : null}`, | |
| 105 | { head: html`<h2>Proposals</h2>` }, | |
| 106 | ); | |
| 82 | function proposalSection(base: string, proposals: readonly ForgeProposal[], writer: boolean): Html { | |
| 83 | return html`<h2 class="mt">Proposals</h2> | |
| 84 | ${proposals.length === 0 | |
| 85 | ? html`<p class="muted">Nothing proposed to the parent forge yet.</p>` | |
| 86 | : html`<ul class="list" aria-label="Proposals to the parent forge"> | |
| 87 | ${proposals.map( | |
| 88 | (p) => html`<li> | |
| 89 | <div class="row"> | |
| 90 | <span class="row-main"> | |
| 91 | <a class="row-title" href="${p.url}" rel="noopener">#${p.number} ${p.title}</a> | |
| 92 | <span class="row-meta">${STATE_WORDS[p.state]}${p.reachable ? null : html` · parent unreachable`}</span> | |
| 93 | </span> | |
| 94 | </div> | |
| 95 | </li>`, | |
| 96 | )} | |
| 97 | </ul>`} | |
| 98 | ${writer | |
| 99 | ? html`<form method="post" action="${base}/following/propose" class="mt"> | |
| 100 | <div class="form-actions">${btn("Propose to the parent forge", { type: "submit", icon: "merge" })}</div> | |
| 101 | </form>` | |
| 102 | : null}`; | |
| 107 | 103 | } |
| 108 | 104 | |
| 109 | 105 | // GET /:owner/:name/following |
| @@ -137,12 +133,9 @@ export async function chainPage(ctx: Ctx): Promise<Response> { | ||
| 137 | 133 | data-live="${CHAIN_LIVE}" |
| 138 | 134 | data-live-repo="${repo.ownerName}/${repo.name}" |
| 139 | 135 | > |
| 140 | ${card( | |
| 141 | html`<ul class="list" aria-label="Forges, root first"> | |
| 142 | ${view.lines.map((l) => lineRow(l, l.here && l.mode !== null ? upstreamWords(state) : undefined))} | |
| 143 | </ul>`, | |
| 144 | { pad: false }, | |
| 145 | )} | |
| 136 | <ul class="list" aria-label="Forges, root first"> | |
| 137 | ${view.lines.map((l) => lineRow(l, l.here && l.mode !== null ? upstreamWords(state) : undefined))} | |
| 138 | </ul> | |
| 146 | 139 | ${writer && view.severedAt === null |
| 147 | 140 | ? html`<form method="post" action="${base}/following/check" class="mt"> |
| 148 | 141 | <div class="form-actions">${btn("Check the parent now", { type: "submit" })}</div> |
| @@ -155,29 +148,27 @@ export async function chainPage(ctx: Ctx): Promise<Response> { | ||
| 155 | 148 | </div> |
| 156 | 149 | </form>` |
| 157 | 150 | : null} |
| 158 | ${owner && view.severedAt === null ? modeCard(base, view.lines[view.lines.length - 1]?.mode ?? null) : null} | |
| 159 | ${proposing ? proposalCard(base, proposals, writer) : null} | |
| 151 | ${owner && view.severedAt === null ? modeSection(base, view.lines[view.lines.length - 1]?.mode ?? null) : null} | |
| 152 | ${proposing ? proposalSection(base, proposals, writer) : null} | |
| 160 | 153 | ${view.severedAt !== null |
| 161 | 154 | ? html`<p class="muted" id="severed"> |
| 162 | 155 | This forge follows nothing since ${view.severedAt.slice(0, 10)}. Its code and history are |
| 163 | 156 | all still here. |
| 164 | 157 | </p>` |
| 165 | 158 | : owner |
| 166 | ? card( | |
| 167 | html`<p> | |
| 168 | Severing cuts this forge's link to the one above it. Nothing is deleted and | |
| 169 | nothing leaves; the fork stays yours, and the tree says it follows nothing | |
| 170 | from today. | |
| 171 | </p> | |
| 172 | <form method="post" action="${base}/following/sever" class="mt"> | |
| 173 | <label class="form-field" | |
| 174 | ><span>Type sever to continue</span> | |
| 175 | <input name="confirm" required autocomplete="off" class="mono" | |
| 176 | /></label> | |
| 177 | <div class="form-actions">${btn("Sever", { variant: "danger", type: "submit" })}</div> | |
| 178 | </form>`, | |
| 179 | { head: html`<h2>Sever</h2>` }, | |
| 180 | ) | |
| 159 | ? html`<h2 class="mt">Sever</h2> | |
| 160 | <p> | |
| 161 | Severing cuts this forge's link to the one above it. Nothing is deleted and | |
| 162 | nothing leaves; the fork stays yours, and the tree says it follows nothing | |
| 163 | from today. | |
| 164 | </p> | |
| 165 | <form method="post" action="${base}/following/sever" class="mt"> | |
| 166 | <label class="form-field" | |
| 167 | ><span>Type sever to continue</span> | |
| 168 | <input name="confirm" required autocomplete="off" class="mono" | |
| 169 | /></label> | |
| 170 | <div class="form-actions">${btn("Sever", { variant: "danger", type: "submit" })}</div> | |
| 171 | </form>` | |
| 181 | 172 | : null} |
| 182 | 173 | </div> |
| 183 | 174 | </div>`, |
test/spawn-chain-mode.test.ts +4
| @@ -129,6 +129,10 @@ describe("choosing how a forge follows its parent", () => { | ||
| 129 | 129 | expect(html).toContain('action="/kid/forge/following/mode"'); |
| 130 | 130 | expect(html).toMatch(/value="tell-me" checked/); |
| 131 | 131 | expect(html).not.toContain("<script>"); |
| 132 | // Rows and headings, not boxes. | |
| 133 | expect(html).not.toContain('class="card'); | |
| 134 | expect(html).toContain('<ul class="list" aria-label="Forges, root first">'); | |
| 135 | expect(html).toContain("<h2"); | |
| 132 | 136 | |
| 133 | 137 | expect((await form(cookie, "take-updates")).status).toBe(303); |
| 134 | 138 | expect((await forgeLinkOf(child))?.mode).toBe("take-updates"); |