Public: anyone can read this; the crew can change it.
The labels settings page and its forms leave settings.ts for src/web/pages/labels.ts #6312
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/6306-the-labels-settings-page-and-its-forms-l → main 1 commit fork
3 files
changed +200
−189
src/http/server.ts +6 −4
| @@ -375,10 +375,6 @@ import { | ||
| 375 | 375 | crewSettingsPage, |
| 376 | 376 | dangerPage, |
| 377 | 377 | hooksPage, |
| 378 | labelCreate, | |
| 379 | labelDelete, | |
| 380 | labelUpdate, | |
| 381 | labelsPage, | |
| 382 | 378 | promptSourceAdd, |
| 383 | 379 | promptSourceDelete, |
| 384 | 380 | promptSourceRun, |
| @@ -393,6 +389,12 @@ import { | ||
| 393 | 389 | webhookCreate, |
| 394 | 390 | webhookDelete, |
| 395 | 391 | } from "../web/pages/settings.ts"; |
| 392 | import { | |
| 393 | labelCreate, | |
| 394 | labelDelete, | |
| 395 | labelsPage, | |
| 396 | labelUpdate, | |
| 397 | } from "../web/pages/labels.ts"; | |
| 396 | 398 | import { |
| 397 | 399 | newOrgPage, |
| 398 | 400 | newOrgSubmit, |
src/web/pages/labels.ts added +191
| @@ -0,0 +1,191 @@ | ||
| 1 | /** | |
| 2 | * Settings › Labels: the labels that organise a repository's work, each one | |
| 3 | * renamed, recoloured or deleted in place, and a form to add another. Admin | |
| 4 | * only, every mutation a same-origin POST that says what happened. The frame | |
| 5 | * and the gate are settings.ts's; this module holds only what labels need. | |
| 6 | */ | |
| 7 | ||
| 8 | import { LABEL_WORDS } from "../../actions/label_words.ts"; | |
| 9 | import { createLabel, deleteLabel, updateLabel } from "../../actions/repos.ts"; | |
| 10 | import { listLabels, type Label } from "../../core/issues.ts"; | |
| 11 | import { notePhase, timePhase } from "../../core/route_timing.ts"; | |
| 12 | import type { Ctx } from "../../http/context.ts"; | |
| 13 | import { repoBase, repoNotFound } from "../chrome.ts"; | |
| 14 | import { field, type Form } from "../form.ts"; | |
| 15 | import { html, type Html } from "../html.ts"; | |
| 16 | import { flashTo } from "../layout.ts"; | |
| 17 | import { btn, card, confirmAction, empty, labelChip, labelCss } from "../ui.ts"; | |
| 18 | import { adminForm, settingsPageResponse, timedAdminRepo } from "./settings.ts"; | |
| 19 | ||
| 20 | /** `<input type="color">` submits `#rrggbb`; the store keeps six hex digits. */ | |
| 21 | function readColor(form: Form): string { | |
| 22 | return field(form, "color").replace(/^#/, "").toLowerCase(); | |
| 23 | } | |
| 24 | ||
| 25 | /** | |
| 26 | * The colour control is a native `<input type="color">`, so it works with no | |
| 27 | * script at all — the hex beside it is a readout, updated live where there is | |
| 28 | * a script and correct on load where there is not. A browser submits it as | |
| 29 | * `#rrggbb`; `readColor` strips the hash on the way in. | |
| 30 | */ | |
| 31 | function colorField(value: string, label: string): Html { | |
| 32 | return html`<span class="color-field" | |
| 33 | ><input | |
| 34 | type="color" | |
| 35 | name="color" | |
| 36 | value="#${value}" | |
| 37 | aria-label="${label}" | |
| 38 | /><span class="mono muted" data-hex>#${value}</span></span | |
| 39 | >`; | |
| 40 | } | |
| 41 | ||
| 42 | function labelEditor(base: string, label: Label): Html { | |
| 43 | return html`<li> | |
| 44 | <form | |
| 45 | class="edit-row" | |
| 46 | method="post" | |
| 47 | action="${base}/settings/labels/${label.id}" | |
| 48 | > | |
| 49 | <span class="preview">${labelChip(label)}</span> | |
| 50 | <input | |
| 51 | class="input" | |
| 52 | type="text" | |
| 53 | name="name" | |
| 54 | value="${label.name}" | |
| 55 | aria-label="Label name" | |
| 56 | required | |
| 57 | /> | |
| 58 | ${colorField(label.color, `Colour of the label ${label.name}`)} | |
| 59 | ${btn("Save", { size: "sm" })} | |
| 60 | ${confirmAction({ | |
| 61 | action: `${base}/settings/labels/${label.id}/delete`, | |
| 62 | verb: "Delete", | |
| 63 | size: "sm", | |
| 64 | })} | |
| 65 | </form> | |
| 66 | </li>`; | |
| 67 | } | |
| 68 | ||
| 69 | export function labelsPage(ctx: Ctx): Response { | |
| 70 | const repo = timedAdminRepo(ctx); | |
| 71 | if (!repo) return repoNotFound(ctx); | |
| 72 | const labels = timePhase(ctx.request, "labels", () => listLabels(ctx.app.db, repo.id)); | |
| 73 | const rendering = performance.now(); | |
| 74 | const base = repoBase(repo); | |
| 75 | ||
| 76 | const list = | |
| 77 | labels.length === 0 | |
| 78 | ? empty({ | |
| 79 | icon: "tag", | |
| 80 | title: "No labels yet", | |
| 81 | note: "Labels are this repository's work queue — a handful is usually enough.", | |
| 82 | }) | |
| 83 | : html`<div class="card"> | |
| 84 | <ul class="list"> | |
| 85 | ${labels.map((l) => labelEditor(base, l))} | |
| 86 | </ul> | |
| 87 | </div>`; | |
| 88 | ||
| 89 | const create = card( | |
| 90 | html`<form class="edit-row" method="post" action="${base}/settings/labels"> | |
| 91 | <input | |
| 92 | class="input" | |
| 93 | type="text" | |
| 94 | name="name" | |
| 95 | placeholder="Label name" | |
| 96 | aria-label="New label name" | |
| 97 | required | |
| 98 | /> | |
| 99 | ${colorField("6b7280", "Colour of the new label")} | |
| 100 | ${btn("Add label", { variant: "primary", size: "sm" })} | |
| 101 | </form>`, | |
| 102 | { head: html`<h2>New label</h2>`, pad: false }, | |
| 103 | ); | |
| 104 | ||
| 105 | const body = html`${list} | |
| 106 | <div class="mt">${create}</div>`; | |
| 107 | const response = settingsPageResponse( | |
| 108 | ctx, | |
| 109 | repo, | |
| 110 | "labels", | |
| 111 | "Labels", | |
| 112 | body, | |
| 113 | labelCss(labels), | |
| 114 | ); | |
| 115 | notePhase(ctx.request, "render", performance.now() - rendering); | |
| 116 | return response; | |
| 117 | } | |
| 118 | ||
| 119 | export async function labelCreate(ctx: Ctx): Promise<Response> { | |
| 120 | const gate = await adminForm(ctx); | |
| 121 | if (gate instanceof Response) return gate; | |
| 122 | const { repo, form } = gate; | |
| 123 | const created = createLabel( | |
| 124 | ctx.app, | |
| 125 | ctx.auth.user, | |
| 126 | repo, | |
| 127 | field(form, "name"), | |
| 128 | readColor(form) || "6b7280", | |
| 129 | ); | |
| 130 | return flashTo( | |
| 131 | ctx, | |
| 132 | `${repoBase(repo)}/settings/labels`, | |
| 133 | created.ok | |
| 134 | ? { message: `Label “${created.value.name}” created.` } | |
| 135 | : { | |
| 136 | tone: "danger", | |
| 137 | message: | |
| 138 | created.error === "taken" | |
| 139 | ? "This repository already has a label by that name." | |
| 140 | : LABEL_WORDS.shape, | |
| 141 | }, | |
| 142 | ); | |
| 143 | } | |
| 144 | ||
| 145 | export async function labelUpdate(ctx: Ctx): Promise<Response> { | |
| 146 | const gate = await adminForm(ctx); | |
| 147 | if (gate instanceof Response) return gate; | |
| 148 | const { repo, form } = gate; | |
| 149 | const updated = updateLabel( | |
| 150 | ctx.app, | |
| 151 | ctx.auth.user, | |
| 152 | repo, | |
| 153 | ctx.params.id ?? "", | |
| 154 | field(form, "name"), | |
| 155 | readColor(form), | |
| 156 | ); | |
| 157 | return flashTo( | |
| 158 | ctx, | |
| 159 | `${repoBase(repo)}/settings/labels`, | |
| 160 | updated.ok | |
| 161 | ? { message: `Label “${updated.value.name}” updated.` } | |
| 162 | : { | |
| 163 | tone: "danger", | |
| 164 | message: | |
| 165 | updated.error === "taken" | |
| 166 | ? "This repository already has a label by that name." | |
| 167 | : updated.error === "not_found" | |
| 168 | ? "That label no longer exists." | |
| 169 | : LABEL_WORDS.shape, | |
| 170 | }, | |
| 171 | ); | |
| 172 | } | |
| 173 | ||
| 174 | export async function labelDelete(ctx: Ctx): Promise<Response> { | |
| 175 | const gate = await adminForm(ctx); | |
| 176 | if (gate instanceof Response) return gate; | |
| 177 | const { repo } = gate; | |
| 178 | const removed = deleteLabel( | |
| 179 | ctx.app, | |
| 180 | ctx.auth.user, | |
| 181 | repo, | |
| 182 | ctx.params.id ?? "", | |
| 183 | ); | |
| 184 | return flashTo( | |
| 185 | ctx, | |
| 186 | `${repoBase(repo)}/settings/labels`, | |
| 187 | removed.ok | |
| 188 | ? { message: "Label deleted." } | |
| 189 | : { tone: "danger", message: "That label no longer exists." }, | |
| 190 | ); | |
| 191 | } |
src/web/pages/settings.ts +3 −185
| @@ -50,12 +50,9 @@ import { addToCrew, listCrew, removeFromCrew } from "../../actions/crew.ts"; | ||
| 50 | 50 | import { getAgentByName, isCrewRole, setCrewRole } from "../../core/agents.ts"; |
| 51 | 51 | import { isAutonomy, isDecisionsBy } from "../../core/repos.ts"; |
| 52 | 52 | import { |
| 53 | createLabel, | |
| 54 | 53 | createWebhook, |
| 55 | deleteLabel, | |
| 56 | 54 | deleteRepo, |
| 57 | 55 | deleteWebhook, |
| 58 | updateLabel, | |
| 59 | 56 | updateRepoSettings, |
| 60 | 57 | } from "../../actions/repos.ts"; |
| 61 | 58 | import { appsRuntime } from "../../apps/runtime.ts"; |
| @@ -69,7 +66,6 @@ import { | ||
| 69 | 66 | type AppAccess, |
| 70 | 67 | type AppRecord, |
| 71 | 68 | } from "../../core/apps.ts"; |
| 72 | import { listLabels, type Label } from "../../core/issues.ts"; | |
| 73 | 69 | import { |
| 74 | 70 | readAppMembers, |
| 75 | 71 | setAppMembersThrough, |
| @@ -109,8 +105,6 @@ import { | ||
| 109 | 105 | dot, |
| 110 | 106 | empty, |
| 111 | 107 | field as formField, |
| 112 | labelChip, | |
| 113 | labelCss, | |
| 114 | 108 | row, |
| 115 | 109 | selectField, |
| 116 | 110 | time, |
| @@ -123,7 +117,6 @@ import { secretsSection } from "./secrets.ts"; | ||
| 123 | 117 | import { checkMemoryAt } from "../../apps/check-memory.ts"; |
| 124 | 118 | import { followUpstreamCard } from "./upstream-follow.ts"; |
| 125 | 119 | import { saveGrants } from "./subscriptions.ts"; |
| 126 | import { LABEL_WORDS } from "../../actions/label_words.ts"; | |
| 127 | 120 | |
| 128 | 121 | type Section = |
| 129 | 122 | | "general" |
| @@ -142,7 +135,7 @@ function loadAdminRepo(ctx: Ctx): Repo | undefined { | ||
| 142 | 135 | } |
| 143 | 136 | |
| 144 | 137 | /** `loadAdminRepo` for a GET page: names the session, then the repo read as `repo`. */ |
| 145 | function timedAdminRepo(ctx: Ctx): Repo | undefined { | |
| 138 | export function timedAdminRepo(ctx: Ctx): Repo | undefined { | |
| 146 | 139 | nameFrame(ctx.request); |
| 147 | 140 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 148 | 141 | return timePhase(ctx.request, "repo", () => loadAdminRepo(ctx)); |
| @@ -190,7 +183,7 @@ function sections(base: string, active: Section) { | ||
| 190 | 183 | } |
| 191 | 184 | |
| 192 | 185 | /** Every settings page is the same frame with a different pane inside it. */ |
| 193 | function settingsPageResponse( | |
| 186 | export function settingsPageResponse( | |
| 194 | 187 | ctx: Ctx, |
| 195 | 188 | repo: Repo, |
| 196 | 189 | active: Section, |
| @@ -214,7 +207,7 @@ function settingsPageResponse( | ||
| 214 | 207 | } |
| 215 | 208 | |
| 216 | 209 | /** The gate every settings POST passes: admin, same-origin, admin scope. */ |
| 217 | async function adminForm( | |
| 210 | export async function adminForm( | |
| 218 | 211 | ctx: Ctx, |
| 219 | 212 | ): Promise<{ repo: Repo; form: Form } | Response> { |
| 220 | 213 | const repo = loadAdminRepo(ctx); |
| @@ -1740,181 +1733,6 @@ export async function crewRemove(ctx: Ctx): Promise<Response> { | ||
| 1740 | 1733 | ); |
| 1741 | 1734 | } |
| 1742 | 1735 | |
| 1743 | // ---- labels ---- | |
| 1744 | ||
| 1745 | /** `<input type="color">` submits `#rrggbb`; the store keeps six hex digits. */ | |
| 1746 | function readColor(form: Form): string { | |
| 1747 | return field(form, "color").replace(/^#/, "").toLowerCase(); | |
| 1748 | } | |
| 1749 | ||
| 1750 | /** | |
| 1751 | * The colour control is a native `<input type="color">`, so it works with no | |
| 1752 | * script at all — the hex beside it is a readout, updated live where there is | |
| 1753 | * a script and correct on load where there is not. A browser submits it as | |
| 1754 | * `#rrggbb`; `readColor` strips the hash on the way in. | |
| 1755 | */ | |
| 1756 | function colorField(value: string, label: string): Html { | |
| 1757 | return html`<span class="color-field" | |
| 1758 | ><input | |
| 1759 | type="color" | |
| 1760 | name="color" | |
| 1761 | value="#${value}" | |
| 1762 | aria-label="${label}" | |
| 1763 | /><span class="mono muted" data-hex>#${value}</span></span | |
| 1764 | >`; | |
| 1765 | } | |
| 1766 | ||
| 1767 | function labelEditor(base: string, label: Label): Html { | |
| 1768 | return html`<li> | |
| 1769 | <form | |
| 1770 | class="edit-row" | |
| 1771 | method="post" | |
| 1772 | action="${base}/settings/labels/${label.id}" | |
| 1773 | > | |
| 1774 | <span class="preview">${labelChip(label)}</span> | |
| 1775 | <input | |
| 1776 | class="input" | |
| 1777 | type="text" | |
| 1778 | name="name" | |
| 1779 | value="${label.name}" | |
| 1780 | aria-label="Label name" | |
| 1781 | required | |
| 1782 | /> | |
| 1783 | ${colorField(label.color, `Colour of the label ${label.name}`)} | |
| 1784 | ${btn("Save", { size: "sm" })} | |
| 1785 | ${confirmAction({ | |
| 1786 | action: `${base}/settings/labels/${label.id}/delete`, | |
| 1787 | verb: "Delete", | |
| 1788 | size: "sm", | |
| 1789 | })} | |
| 1790 | </form> | |
| 1791 | </li>`; | |
| 1792 | } | |
| 1793 | ||
| 1794 | export function labelsPage(ctx: Ctx): Response { | |
| 1795 | const repo = timedAdminRepo(ctx); | |
| 1796 | if (!repo) return repoNotFound(ctx); | |
| 1797 | const labels = timePhase(ctx.request, "labels", () => listLabels(ctx.app.db, repo.id)); | |
| 1798 | const rendering = performance.now(); | |
| 1799 | const base = repoBase(repo); | |
| 1800 | ||
| 1801 | const list = | |
| 1802 | labels.length === 0 | |
| 1803 | ? empty({ | |
| 1804 | icon: "tag", | |
| 1805 | title: "No labels yet", | |
| 1806 | note: "Labels are this repository's work queue — a handful is usually enough.", | |
| 1807 | }) | |
| 1808 | : html`<div class="card"> | |
| 1809 | <ul class="list"> | |
| 1810 | ${labels.map((l) => labelEditor(base, l))} | |
| 1811 | </ul> | |
| 1812 | </div>`; | |
| 1813 | ||
| 1814 | const create = card( | |
| 1815 | html`<form class="edit-row" method="post" action="${base}/settings/labels"> | |
| 1816 | <input | |
| 1817 | class="input" | |
| 1818 | type="text" | |
| 1819 | name="name" | |
| 1820 | placeholder="Label name" | |
| 1821 | aria-label="New label name" | |
| 1822 | required | |
| 1823 | /> | |
| 1824 | ${colorField("6b7280", "Colour of the new label")} | |
| 1825 | ${btn("Add label", { variant: "primary", size: "sm" })} | |
| 1826 | </form>`, | |
| 1827 | { head: html`<h2>New label</h2>`, pad: false }, | |
| 1828 | ); | |
| 1829 | ||
| 1830 | const body = html`${list} | |
| 1831 | <div class="mt">${create}</div>`; | |
| 1832 | const response = settingsPageResponse( | |
| 1833 | ctx, | |
| 1834 | repo, | |
| 1835 | "labels", | |
| 1836 | "Labels", | |
| 1837 | body, | |
| 1838 | labelCss(labels), | |
| 1839 | ); | |
| 1840 | notePhase(ctx.request, "render", performance.now() - rendering); | |
| 1841 | return response; | |
| 1842 | } | |
| 1843 | ||
| 1844 | export async function labelCreate(ctx: Ctx): Promise<Response> { | |
| 1845 | const gate = await adminForm(ctx); | |
| 1846 | if (gate instanceof Response) return gate; | |
| 1847 | const { repo, form } = gate; | |
| 1848 | const created = createLabel( | |
| 1849 | ctx.app, | |
| 1850 | ctx.auth.user, | |
| 1851 | repo, | |
| 1852 | field(form, "name"), | |
| 1853 | readColor(form) || "6b7280", | |
| 1854 | ); | |
| 1855 | return flashTo( | |
| 1856 | ctx, | |
| 1857 | `${repoBase(repo)}/settings/labels`, | |
| 1858 | created.ok | |
| 1859 | ? { message: `Label “${created.value.name}” created.` } | |
| 1860 | : { | |
| 1861 | tone: "danger", | |
| 1862 | message: | |
| 1863 | created.error === "taken" | |
| 1864 | ? "This repository already has a label by that name." | |
| 1865 | : LABEL_WORDS.shape, | |
| 1866 | }, | |
| 1867 | ); | |
| 1868 | } | |
| 1869 | ||
| 1870 | export async function labelUpdate(ctx: Ctx): Promise<Response> { | |
| 1871 | const gate = await adminForm(ctx); | |
| 1872 | if (gate instanceof Response) return gate; | |
| 1873 | const { repo, form } = gate; | |
| 1874 | const updated = updateLabel( | |
| 1875 | ctx.app, | |
| 1876 | ctx.auth.user, | |
| 1877 | repo, | |
| 1878 | ctx.params.id ?? "", | |
| 1879 | field(form, "name"), | |
| 1880 | readColor(form), | |
| 1881 | ); | |
| 1882 | return flashTo( | |
| 1883 | ctx, | |
| 1884 | `${repoBase(repo)}/settings/labels`, | |
| 1885 | updated.ok | |
| 1886 | ? { message: `Label “${updated.value.name}” updated.` } | |
| 1887 | : { | |
| 1888 | tone: "danger", | |
| 1889 | message: | |
| 1890 | updated.error === "taken" | |
| 1891 | ? "This repository already has a label by that name." | |
| 1892 | : updated.error === "not_found" | |
| 1893 | ? "That label no longer exists." | |
| 1894 | : LABEL_WORDS.shape, | |
| 1895 | }, | |
| 1896 | ); | |
| 1897 | } | |
| 1898 | ||
| 1899 | export async function labelDelete(ctx: Ctx): Promise<Response> { | |
| 1900 | const gate = await adminForm(ctx); | |
| 1901 | if (gate instanceof Response) return gate; | |
| 1902 | const { repo } = gate; | |
| 1903 | const removed = deleteLabel( | |
| 1904 | ctx.app, | |
| 1905 | ctx.auth.user, | |
| 1906 | repo, | |
| 1907 | ctx.params.id ?? "", | |
| 1908 | ); | |
| 1909 | return flashTo( | |
| 1910 | ctx, | |
| 1911 | `${repoBase(repo)}/settings/labels`, | |
| 1912 | removed.ok | |
| 1913 | ? { message: "Label deleted." } | |
| 1914 | : { tone: "danger", message: "That label no longer exists." }, | |
| 1915 | ); | |
| 1916 | } | |
| 1917 | ||
| 1918 | 1736 | // ---- webhooks ---- |
| 1919 | 1737 | |
| 1920 | 1738 | export function hooksPage(ctx: Ctx): Response { |
new above
offline