Public: anyone can read this; the crew can change it.
Settings › Labels speaks the home page's language #9809
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9747-settings-labels-speaks-the-home-page-s-l → main 2 commits fork
4 files
changed +66
−31
docs/design/areas/settings.md +4 −5
| @@ -22,12 +22,11 @@ the text wrap and gives the control a place that is always visible. Use `.row-ac | ||
| 22 | 22 | |
| 23 | 23 | ## Labels |
| 24 | 24 | |
| 25 | - `.preview` — the label chip at the head of a label's edit row on a repository's Labels settings, kept from | |
| 26 | shrinking. Written by `labelEditor()` in `src/web/pages/labels.ts`; styled in `src/web/style/components.ts` as | |
| 27 | `.edit-row > .preview`. | |
| 25 | Each label is a `row()` in a `.setting-list`: its chip is the title, `N open` the meta, and a `.row-fields` form (name, | |
| 26 | colour, Save, Delete) the trail — `labelRow()` in `src/web/pages/labels.ts`. The new-label form sits in a `.settings-block`. | |
| 28 | 27 | |
| 29 | - `.label-open` — a label's `N open` link beside its chip in `labelEditor()` (`unused` stays a `.muted` span). Underlined so it | |
| 30 | reads as a link; at the phone breakpoint a 44px tap target. Styled under `.edit-row` in `src/web/style/components.ts`. | |
| 28 | - `.label-open` — a label's `N open` link in its row's meta (`unused` stays a `.muted` span). Underlined so it | |
| 29 | reads as a link; at the phone breakpoint a 44px tap target. Styled in `src/web/style/components.ts`. | |
| 31 | 30 | |
| 32 | 31 | The terminal's Labels screen (`src/tui/screens/labels.ts`) shows the same counts to a repository admin: `N open` or `unused` after each hex, and its delete confirm says how many open issues and pulls carry the label. A reader sees neither. |
| 33 | 32 |
src/web/pages/labels.ts +27 −22
| @@ -14,7 +14,7 @@ import { repoBase, repoNotFound } from "../chrome.ts"; | ||
| 14 | 14 | import { field, type Form } from "../form.ts"; |
| 15 | 15 | import { html, type Html } from "../html.ts"; |
| 16 | 16 | import { flashTo } from "../layout.ts"; |
| 17 | import { btn, card, confirmAction, empty, labelChip, labelCss } from "../ui.ts"; | |
| 17 | import { btn, confirmAction, empty, labelChip, labelCss, row } from "../ui.ts"; | |
| 18 | 18 | import { adminForm, settingsPageResponse, timedAdminRepo } from "./settings.ts"; |
| 19 | 19 | |
| 20 | 20 | /** `<input type="color">` submits `#rrggbb`; the store keeps six hex digits. */ |
| @@ -46,21 +46,23 @@ function usage(base: string, label: Label, open: number): Html { | ||
| 46 | 46 | : html`<span class="muted">unused</span>`; |
| 47 | 47 | } |
| 48 | 48 | |
| 49 | function labelEditor(base: string, label: Label, open: number): Html { | |
| 50 | return html`<li> | |
| 51 | <form | |
| 52 | class="edit-row" | |
| 49 | /** One label: its chip and open count at the head, the edit form in the trail. */ | |
| 50 | function labelRow(base: string, label: Label, open: number): Html { | |
| 51 | return row({ | |
| 52 | id: `label-${label.id}`, | |
| 53 | title: labelChip(label), | |
| 54 | meta: usage(base, label, open), | |
| 55 | trail: html`<form | |
| 56 | class="row-fields edit-row" | |
| 53 | 57 | method="post" |
| 54 | 58 | action="${base}/settings/labels/${label.id}" |
| 55 | 59 | > |
| 56 | <span class="preview">${labelChip(label)}</span> | |
| 57 | ${usage(base, label, open)} | |
| 58 | 60 | <input |
| 59 | 61 | class="input" |
| 60 | 62 | type="text" |
| 61 | 63 | name="name" |
| 62 | 64 | value="${label.name}" |
| 63 | aria-label="Label name" | |
| 65 | aria-label="Name of the label ${label.name}" | |
| 64 | 66 | required |
| 65 | 67 | /> |
| 66 | 68 | ${colorField(label.color, `Colour of the label ${label.name}`)} |
| @@ -69,9 +71,10 @@ function labelEditor(base: string, label: Label, open: number): Html { | ||
| 69 | 71 | action: `${base}/settings/labels/${label.id}/delete`, |
| 70 | 72 | verb: "Delete", |
| 71 | 73 | size: "sm", |
| 74 | label: `Delete the label ${label.name}`, | |
| 72 | 75 | })} |
| 73 | </form> | |
| 74 | </li>`; | |
| 76 | </form>`, | |
| 77 | }); | |
| 75 | 78 | } |
| 76 | 79 | |
| 77 | 80 | export function labelsPage(ctx: Ctx): Response { |
| @@ -89,14 +92,17 @@ export function labelsPage(ctx: Ctx): Response { | ||
| 89 | 92 | title: "No labels yet", |
| 90 | 93 | note: "Labels are this repository's work queue — a handful is usually enough.", |
| 91 | 94 | }) |
| 92 | : html`<div class="card"> | |
| 93 | <ul class="list"> | |
| 94 | ${labels.map((l) => labelEditor(base, l, counts.get(l.id) ?? 0))} | |
| 95 | </ul> | |
| 96 | </div>`; | |
| 95 | : html`<ul class="list setting-list"> | |
| 96 | ${labels.map((l) => labelRow(base, l, counts.get(l.id) ?? 0))} | |
| 97 | </ul>`; | |
| 97 | 98 | |
| 98 | const create = card( | |
| 99 | html`<form class="edit-row" method="post" action="${base}/settings/labels"> | |
| 99 | const create = html`<section class="settings-block"> | |
| 100 | <h2>New label</h2> | |
| 101 | <form | |
| 102 | class="row-fields edit-row mt" | |
| 103 | method="post" | |
| 104 | action="${base}/settings/labels" | |
| 105 | > | |
| 100 | 106 | <input |
| 101 | 107 | class="input" |
| 102 | 108 | type="text" |
| @@ -107,12 +113,11 @@ export function labelsPage(ctx: Ctx): Response { | ||
| 107 | 113 | /> |
| 108 | 114 | ${colorField("6b7280", "Colour of the new label")} |
| 109 | 115 | ${btn("Add label", { variant: "primary", size: "sm" })} |
| 110 | </form>`, | |
| 111 | { head: html`<h2>New label</h2>`, pad: false }, | |
| 112 | ); | |
| 116 | </form> | |
| 117 | </section>`; | |
| 113 | 118 | |
| 114 | const body = html`${list} | |
| 115 | <div class="mt">${create}</div>`; | |
| 119 | const body = html`<section class="settings-block">${list}</section> | |
| 120 | ${create}`; | |
| 116 | 121 | const response = settingsPageResponse( |
| 117 | 122 | ctx, |
| 118 | 123 | repo, |
src/web/style/components.ts +4 −4
| @@ -524,11 +524,11 @@ details[open] > summary.row > .row-lead { transform: rotate(90deg); } | ||
| 524 | 524 | display: flex; align-items: center; gap: 8px; flex-wrap: wrap; |
| 525 | 525 | padding: 8px 14px; |
| 526 | 526 | } |
| 527 | .edit-row > .preview { flex: none; } | |
| 527 | .row-trail > .edit-row, .settings-block > .edit-row { padding: 0; } | |
| 528 | 528 | .edit-row > .input { flex: 1 1 160px; width: auto; min-width: 0; } |
| 529 | 529 | .edit-row .btn { flex: none; } |
| 530 | .edit-row .label-open { color: var(--muted-fg); font-size: 13px; text-decoration: underline; text-underline-offset: 2px; } | |
| 531 | .edit-row .label-open:hover { color: var(--fg); } | |
| 530 | .label-open { color: var(--muted-fg); font-size: 13px; text-decoration: underline; text-underline-offset: 2px; } | |
| 531 | .label-open:hover { color: var(--fg); } | |
| 532 | 532 | /* A swatch and its hex, as one control-shaped thing. */ |
| 533 | 533 | .color-field { |
| 534 | 534 | display: inline-flex; align-items: center; gap: 8px; flex: none; |
| @@ -550,7 +550,7 @@ details[open] > summary.row > .row-lead { transform: rotate(90deg); } | ||
| 550 | 550 | .edit-row { align-items: stretch; } |
| 551 | 551 | .edit-row > .input { flex-basis: 100%; } |
| 552 | 552 | .edit-row .color-field, .edit-row .btn { flex: 1 1 auto; justify-content: center; } |
| 553 | .edit-row .label-open { display: inline-flex; align-items: center; min-height: 44px; padding: 0 8px; } | |
| 553 | .label-open { display: inline-flex; align-items: center; min-height: 44px; padding: 0 8px; } | |
| 554 | 554 | } |
| 555 | 555 | |
| 556 | 556 | /* ---- table ---- */ |
test/labels-page-counts.test.ts +31
| @@ -94,3 +94,34 @@ describe("labels page counts", () => { | ||
| 94 | 94 | expect(mcp.value.items.find((l: any) => l.id === bugId).open).toBe(2); |
| 95 | 95 | }); |
| 96 | 96 | }); |
| 97 | ||
| 98 | describe("labels page rows", () => { | |
| 99 | test("each label is a setting-list row with a plain POST form and a confirm link", async () => { | |
| 100 | const dir = await mkdtemp(join(tmpdir(), "forge-label-rows-")); | |
| 101 | const app = createApp({ dataDir: dir, port: 0 }); | |
| 102 | const servers = await serve(app); | |
| 103 | try { | |
| 104 | const cookie = await adminCookie(app, { username: "olive", email: "[email protected]", password: "label-rows-password" }); | |
| 105 | await fetch(`${servers.url}/new`, { | |
| 106 | method: "POST", | |
| 107 | headers: { cookie }, | |
| 108 | body: new URLSearchParams({ name: "proj", visibility: "private", description: "" }), | |
| 109 | redirect: "manual", | |
| 110 | }); | |
| 111 | const repo = app.db.query<{ id: string }, []>("SELECT id FROM repo").get()!; | |
| 112 | const made = createLabel(app.db, repo.id, "bug", "d73a4a"); | |
| 113 | if (!made.ok) throw new Error("label"); | |
| 114 | const text = await (await fetch(`${servers.url}/olive/proj/settings/labels`, { headers: { cookie } })).text(); | |
| 115 | expect(text).toContain("setting-list"); | |
| 116 | expect(text).toContain(`id="label-${made.value.id}"`); | |
| 117 | expect(text).toContain(`action="/olive/proj/settings/labels/${made.value.id}"`); | |
| 118 | expect(text).toContain(`value="#d73a4a"`); | |
| 119 | expect(text).toContain("/-/confirm?to="); | |
| 120 | expect(text).not.toContain('class="card"><ul'); | |
| 121 | } finally { | |
| 122 | servers.stop(); | |
| 123 | app.close(); | |
| 124 | await rm(dir, { recursive: true, force: true }); | |
| 125 | } | |
| 126 | }, 30_000); | |
| 127 | }); |