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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/6306-the-labels-settings-page-and-its-forms-l → main 1 commit fork

  • Merged. mason/6306-the-labels-settings-page-and-its-forms-l was merged into main as d54727c.

Review

Sign in to review this change.
3 files changed +200 −189
src/http/server.ts +6 −4
@@ -375,10 +375,6 @@ import {
375375 crewSettingsPage,
376376 dangerPage,
377377 hooksPage,
378 labelCreate,
379 labelDelete,
380 labelUpdate,
381 labelsPage,
382378 promptSourceAdd,
383379 promptSourceDelete,
384380 promptSourceRun,
@@ -393,6 +389,12 @@ import {
393389 webhookCreate,
394390 webhookDelete,
395391 } from "../web/pages/settings.ts";
392 import {
393 labelCreate,
394 labelDelete,
395 labelsPage,
396 labelUpdate,
397 } from "../web/pages/labels.ts";
396398 import {
397399 newOrgPage,
398400 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";
5050 import { getAgentByName, isCrewRole, setCrewRole } from "../../core/agents.ts";
5151 import { isAutonomy, isDecisionsBy } from "../../core/repos.ts";
5252 import {
53 createLabel,
5453 createWebhook,
55 deleteLabel,
5654 deleteRepo,
5755 deleteWebhook,
58 updateLabel,
5956 updateRepoSettings,
6057 } from "../../actions/repos.ts";
6158 import { appsRuntime } from "../../apps/runtime.ts";
@@ -69,7 +66,6 @@ import {
6966 type AppAccess,
7067 type AppRecord,
7168 } from "../../core/apps.ts";
72 import { listLabels, type Label } from "../../core/issues.ts";
7369 import {
7470 readAppMembers,
7571 setAppMembersThrough,
@@ -109,8 +105,6 @@ import {
109105 dot,
110106 empty,
111107 field as formField,
112 labelChip,
113 labelCss,
114108 row,
115109 selectField,
116110 time,
@@ -123,7 +117,6 @@ import { secretsSection } from "./secrets.ts";
123117 import { checkMemoryAt } from "../../apps/check-memory.ts";
124118 import { followUpstreamCard } from "./upstream-follow.ts";
125119 import { saveGrants } from "./subscriptions.ts";
126 import { LABEL_WORDS } from "../../actions/label_words.ts";
127120
128121 type Section =
129122 | "general"
@@ -142,7 +135,7 @@ function loadAdminRepo(ctx: Ctx): Repo | undefined {
142135 }
143136
144137 /** `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 {
146139 nameFrame(ctx.request);
147140 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
148141 return timePhase(ctx.request, "repo", () => loadAdminRepo(ctx));
@@ -190,7 +183,7 @@ function sections(base: string, active: Section) {
190183 }
191184
192185 /** Every settings page is the same frame with a different pane inside it. */
193 function settingsPageResponse(
186 export function settingsPageResponse(
194187 ctx: Ctx,
195188 repo: Repo,
196189 active: Section,
@@ -214,7 +207,7 @@ function settingsPageResponse(
214207 }
215208
216209 /** The gate every settings POST passes: admin, same-origin, admin scope. */
217 async function adminForm(
210 export async function adminForm(
218211 ctx: Ctx,
219212 ): Promise<{ repo: Repo; form: Form } | Response> {
220213 const repo = loadAdminRepo(ctx);
@@ -1740,181 +1733,6 @@ export async function crewRemove(ctx: Ctx): Promise<Response> {
17401733 );
17411734 }
17421735
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
19181736 // ---- webhooks ----
19191737
19201738 export function hooksPage(ctx: Ctx): Response {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs