Public: anyone can read this; the crew can change it.
Absorb: finding and removing a feature, and how the menu shows one #9644
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9544-absorb-finding-and-removing-a-feature-an → main 7 commits fork 3 threads · 3 unresolved
9 files
changed +345
−32
docs/features/absorb.md +4
| @@ -84,6 +84,10 @@ Absorb was built by Ben (benjivers). The review of #9441 found: | ||
| 84 | 84 | |
| 85 | 85 | This section is ready for the owner to share with Ben. |
| 86 | 86 | |
| 87 | ## Finding it, and what the switch does | |
| 88 | ||
| 89 | A site admin sees a "Features" link on the app strip of an absorbed app (an app not yet absorbed is reached from Settings → Features: finding one that merely declares a feature would cost a git read on every app page). The same list, switch and settings are in the terminal: the "Features" palette entry. Beside Absorb or Un-absorb, a line says what restarts and whose menu gains or loses the entry. A refused settings save re-draws the form with what was typed and marks the refused field. | |
| 90 | ||
| 87 | 91 | ## The menu entry is the admin's, not the deploy's |
| 88 | 92 | |
| 89 | 93 | The name and page in the menu are the ones the admin saw when absorbing. If the production |
src/actions/features.ts +3 −3
| @@ -290,8 +290,8 @@ export async function changeFeatureSettings( | ||
| 290 | 290 | actor: User | undefined, |
| 291 | 291 | slug: string, |
| 292 | 292 | values: Readonly<Record<string, unknown>>, |
| 293 | ): Promise<Result<FeatureView, { error: FeatureError; message: string }>> { | |
| 294 | const refuse = (error: FeatureError, message: string) => err({ error, message }); | |
| 293 | ): Promise<Result<FeatureView, { error: FeatureError; message: string; key?: string }>> { | |
| 294 | const refuse = (error: FeatureError, message: string, key?: string) => err(key === undefined ? { error, message } : { error, message, key }); | |
| 295 | 295 | const found = readable(app, actor, slug); |
| 296 | 296 | if (found === undefined) return refuse("not_found", "No such app."); |
| 297 | 297 | if (!siteAdmin(actor)) return refuse("forbidden", "Only a site admin changes a feature's settings."); |
| @@ -303,7 +303,7 @@ export async function changeFeatureSettings( | ||
| 303 | 303 | const spec = declared.value.settings.find((s) => s.key === key); |
| 304 | 304 | if (spec === undefined) return refuse("invalid_setting", `${key} is not one of this feature's settings.`); |
| 305 | 305 | const checked = checkSetting(spec, raw); |
| 306 | if (!checked.ok) return refuse("invalid_setting", `${spec.label} ${checked.error}.`); | |
| 306 | if (!checked.ok) return refuse("invalid_setting", `${spec.label} ${checked.error}.`, key); | |
| 307 | 307 | changes.set(key, checked.value); |
| 308 | 308 | } |
| 309 | 309 | const before = featureSettings(app.db, found.record.id); |
src/tui/screens/entries.ts +2
| @@ -27,6 +27,7 @@ import { entry as edgeOnly } from "./edgeonly.ts"; | ||
| 27 | 27 | import { entry as traffic } from "./trafficview.ts"; |
| 28 | 28 | import { entry as featureReview } from "./features.ts"; |
| 29 | 29 | import { entry as feed } from "./feed.ts"; |
| 30 | import { entry as features } from "./featureswitch.ts"; | |
| 30 | 31 | import { entry as forgeView } from "./forgeview.ts"; |
| 31 | 32 | import { entry as flowsAll } from "./flowsall.ts"; |
| 32 | 33 | import { entry as front } from "./front.ts"; |
| @@ -117,6 +118,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 117 | 118 | domains, |
| 118 | 119 | feed, |
| 119 | 120 | featureReview, |
| 121 | features, | |
| 120 | 122 | front, |
| 121 | 123 | owner, |
| 122 | 124 | forges, |
src/tui/screens/featureswitch.ts added +138
| @@ -0,0 +1,138 @@ | ||
| 1 | /** | |
| 2 | * The features list in the terminal, a site admin's who is a person: the page's list | |
| 3 | * (Settings → Features) from the same `listFeatures`, `a` absorbs or | |
| 4 | * un-absorbs the one under the cursor, Enter opens its settings and a setting | |
| 5 | * is typed in and saved through `changeFeatureSettings`, which checks it | |
| 6 | * against the app's declaration exactly as the page does. Anyone else is not | |
| 7 | * offered it, and the actions refuse them anyway. | |
| 8 | */ | |
| 9 | ||
| 10 | import { | |
| 11 | absorbApp, | |
| 12 | changeFeatureSettings, | |
| 13 | listFeatures, | |
| 14 | unabsorbApp, | |
| 15 | type FeatureView, | |
| 16 | type SettingView, | |
| 17 | } from "../../actions/features.ts"; | |
| 18 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 19 | import { binding } from "../app/keys.ts"; | |
| 20 | import type { Session } from "../session.ts"; | |
| 21 | import { ListState, drawList, promptScreen, runAsync, type ListRow } from "./common.ts"; | |
| 22 | import type { PaletteEntry } from "./entries.ts"; | |
| 23 | ||
| 24 | /** Site admins who are people: what the page's `requireAdmin` lets in. */ | |
| 25 | export const mayFeatures = (f: Session): boolean => f.user !== undefined && f.user.isAdmin && f.user.kind !== "agent"; | |
| 26 | ||
| 27 | const FAILS: Record<string, string> = { | |
| 28 | not_found: "no such app", | |
| 29 | forbidden: "only a site admin does that", | |
| 30 | not_a_feature: "that app's forge.json declares no feature", | |
| 31 | not_absorbed: "that app is not absorbed", | |
| 32 | }; | |
| 33 | ||
| 34 | function featureRow(f: FeatureView): ListRow { | |
| 35 | return { | |
| 36 | title: `${f.name} ${f.repo}`, | |
| 37 | meta: f.absorbed ? "absorbed" : "not absorbed", | |
| 38 | note: f.problem ?? `${String(f.settings.length)} setting${f.settings.length === 1 ? "" : "s"}`, | |
| 39 | }; | |
| 40 | } | |
| 41 | ||
| 42 | function settingRow(s: SettingView): ListRow { | |
| 43 | return { title: `${s.label} ${s.env}`, meta: s.value === "" ? "empty" : s.value, note: `${s.help ?? s.type}${s.set ? "" : " · not changed"}` }; | |
| 44 | } | |
| 45 | ||
| 46 | function settingsScreen(f: Session, ctx: Ctx, slug: string): Screen { | |
| 47 | const state = new ListState(); | |
| 48 | let view: FeatureView | undefined; | |
| 49 | const reload = async (): Promise<void> => { | |
| 50 | const listed = await listFeatures(f.app, f.user); | |
| 51 | view = listed.ok ? listed.value.find((v) => v.slug === slug) : undefined; | |
| 52 | ctx.invalidate(); | |
| 53 | }; | |
| 54 | const screen: Screen = { | |
| 55 | title: "Feature settings", | |
| 56 | keys: [binding(["j", "k"], "move cursor"), binding(["enter"], "change")], | |
| 57 | load: reload, | |
| 58 | render(area, s) { | |
| 59 | drawList(s, area, ctx.theme.c, state, (view?.settings ?? []).map(settingRow)); | |
| 60 | }, | |
| 61 | key(e) { | |
| 62 | const settings = view?.settings ?? []; | |
| 63 | if (e.key !== "enter") return state.key(e, settings.length); | |
| 64 | const setting = settings[state.index]; | |
| 65 | if (view === undefined || setting === undefined) return true; | |
| 66 | if (!view.absorbed) { | |
| 67 | ctx.toast(`absorb ${view.name} to change its settings`, "attn"); | |
| 68 | return true; | |
| 69 | } | |
| 70 | ctx.push( | |
| 71 | promptScreen(ctx, screen, { | |
| 72 | title: setting.label, | |
| 73 | label: setting.type === "list" || setting.type === "days" ? "comma separated" : setting.type, | |
| 74 | value: setting.value, | |
| 75 | hint: setting.help ?? `${setting.env}; default ${setting.default === "" ? "empty" : setting.default}`, | |
| 76 | onSubmit: async (text) => { | |
| 77 | const saved = await changeFeatureSettings(f.app, f.user, slug, { [setting.key]: text }); | |
| 78 | if (!saved.ok) { | |
| 79 | ctx.toast(saved.error.message, "attn"); | |
| 80 | return; | |
| 81 | } | |
| 82 | ctx.toast(`${saved.value.name}: saved. It restarts with the new settings.`); | |
| 83 | await reload(); | |
| 84 | }, | |
| 85 | }), | |
| 86 | ); | |
| 87 | return true; | |
| 88 | }, | |
| 89 | }; | |
| 90 | return screen; | |
| 91 | } | |
| 92 | ||
| 93 | export function featureSwitchScreen(f: Session, ctx: Ctx): Screen { | |
| 94 | const state = new ListState(); | |
| 95 | let features: readonly FeatureView[] = []; | |
| 96 | const reload = async (): Promise<void> => { | |
| 97 | const listed = mayFeatures(f) ? await listFeatures(f.app, f.user) : undefined; | |
| 98 | features = listed?.ok ? listed.value : []; | |
| 99 | ctx.invalidate(); | |
| 100 | }; | |
| 101 | const screen: Screen = { | |
| 102 | title: "Features", | |
| 103 | keys: [binding(["j", "k"], "move cursor"), binding(["a"], "absorb / un-absorb"), binding(["enter"], "settings")], | |
| 104 | load: reload, | |
| 105 | render(area, s) { | |
| 106 | drawList(s, area, ctx.theme.c, state, features.map(featureRow)); | |
| 107 | }, | |
| 108 | key(e) { | |
| 109 | const here = features[state.index]; | |
| 110 | if (e.key === "a" && here !== undefined) { | |
| 111 | runAsync(ctx, async () => { | |
| 112 | const done = here.absorbed ? await unabsorbApp(f.app, f.user, here.slug) : await absorbApp(f.app, f.user, here.slug); | |
| 113 | ctx.toast( | |
| 114 | done.ok ? (here.absorbed ? `${here.name} runs as an ordinary app again; its settings are kept.` : `${here.name} is absorbed: it is in the forge's nav.`) : (FAILS[done.error] ?? done.error), | |
| 115 | done.ok ? undefined : "attn", | |
| 116 | ); | |
| 117 | await reload(); | |
| 118 | }); | |
| 119 | return true; | |
| 120 | } | |
| 121 | if (e.key === "enter" && here !== undefined) { | |
| 122 | ctx.push(settingsScreen(f, ctx, here.slug)); | |
| 123 | return true; | |
| 124 | } | |
| 125 | return state.key(e, features.length); | |
| 126 | }, | |
| 127 | }; | |
| 128 | return screen; | |
| 129 | } | |
| 130 | ||
| 131 | /** Features, as Settings → Features shows them: a site admin's who is a person. */ | |
| 132 | export const entry: PaletteEntry = { | |
| 133 | id: "features", | |
| 134 | title: "Features", | |
| 135 | hint: "apps that declare a feature: absorb one, change its settings", | |
| 136 | offered: mayFeatures, | |
| 137 | run: (session, ctx) => ctx.push(featureSwitchScreen(session, ctx)), | |
| 138 | }; |
src/web/pages/apps.ts +5
| @@ -12,6 +12,7 @@ | ||
| 12 | 12 | * 404 for everyone. |
| 13 | 13 | */ |
| 14 | 14 | |
| 15 | import { getAbsorbed } from "../../core/features.ts"; | |
| 15 | 16 | import { storageLimitMessage } from "../../core/storage.ts"; |
| 16 | 17 | import { repositoryLimitMessage } from "../../actions/repos.ts"; |
| 17 | 18 | import { appLimitWords } from "../../actions/app_limit.ts"; |
| @@ -607,6 +608,10 @@ export function appStrip(ctx: Ctx, repo: Repo): Html { | ||
| 607 | 608 | >`, |
| 608 | 609 | )} |
| 609 | 610 | ${when(pace, (line) => html`<span class="muted">${line}</span>`)} |
| 611 | ${when( | |
| 612 | ctx.auth.user?.isAdmin === true && ctx.auth.user.kind !== "agent" && getAbsorbed(ctx.app.db, record.id) !== undefined, | |
| 613 | () => html`<a href="/settings/features/${encodeURIComponent(record.slug)}" title="This app is one of the forge's features">Features</a>`, | |
| 614 | )} | |
| 610 | 615 | </div>`; |
| 611 | 616 | } |
| 612 | 617 |
src/web/pages/features.ts +58 −29
| @@ -21,7 +21,7 @@ import type { Ctx } from "../../http/context.ts"; | ||
| 21 | 21 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 22 | 22 | import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts"; |
| 23 | 23 | import { readForm, type Form } from "../form.ts"; |
| 24 | import { html, when } from "../html.ts"; | |
| 24 | import { html, raw, when } from "../html.ts"; | |
| 25 | 25 | import { flashTo, page } from "../layout.ts"; |
| 26 | 26 | import { badge, btn, card, empty, icon, row } from "../ui.ts"; |
| 27 | 27 | import { accountSections } from "./origins.ts"; |
| @@ -32,6 +32,9 @@ function requireAdmin(ctx: Ctx): Response | undefined { | ||
| 32 | 32 | return undefined; |
| 33 | 33 | } |
| 34 | 34 | |
| 35 | /** The guide for app authors and admins. */ | |
| 36 | const DOCS = "/trevato/forge/blob/main/docs/features/absorb.md"; | |
| 37 | ||
| 35 | 38 | const DAY_LABEL: Record<string, string> = { mon: "Mon", tue: "Tue", wed: "Wed", thu: "Thu", fri: "Fri", sat: "Sat", sun: "Sun" }; |
| 36 | 39 | |
| 37 | 40 | function shell(ctx: Ctx, title: string, body: ReturnType<typeof html>): Response { |
| @@ -43,6 +46,11 @@ function shell(ctx: Ctx, title: string, body: ReturnType<typeof html>): Response | ||
| 43 | 46 | }); |
| 44 | 47 | } |
| 45 | 48 | |
outdated
DESIGN 1/8: one plain sentence per row is the most this note can be; today it is said on every row and again by the button label. Keep it short, and check it at 360px. |
||
| 49 | /** What the switch does, in one sentence. */ | |
| 50 | export function switchNote(f: Pick<FeatureView, "name" | "absorbed">): string { | |
| 51 | return f.absorbed ? `Un-absorbing takes ${f.name} off every menu; its settings are kept.` : `Absorbing restarts ${f.name} and adds it to the menu.`; | |
| 52 | } | |
| 53 | ||
| 46 | 54 | function featureRow(f: FeatureView) { |
| 47 | 55 | const base = `/settings/features/${encodeURIComponent(f.slug)}`; |
| 48 | 56 | return row({ |
| @@ -50,19 +58,19 @@ function featureRow(f: FeatureView) { | ||
| 50 | 58 | title: html`${f.name} <span class="muted small mono">${f.repo}</span> |
| 51 | 59 | ${f.absorbed ? badge("absorbed", { tone: "success" }) : null} |
| 52 | 60 | ${f.pending !== null ? badge("declaration changed: review", { tone: "attn" }) : null}`, |
| 53 | meta: f.pending !== null | |
| 61 | meta: html`${f.pending !== null | |
| 54 | 62 | ? html`Menu: <strong>${f.name}</strong> at <span class="mono">${f.page}</span> → app: <strong>${f.pending.name}</strong> at |
| 55 | 63 | <span class="mono">${f.pending.page}</span>` |
| 56 | 64 | : f.problem !== null |
| 57 | 65 | ? html`<span class="danger">${f.problem}</span>` |
| 58 | 66 | : html`${f.summary ?? ""}${when(f.summary !== null, () => html`<br />`)}${f.settings.length} setting${f.settings.length === 1 ? "" : "s"}${f.absorbed |
| 59 | 67 | ? html` · absorbed by ${f.absorbedBy ?? "someone"}` |
| 60 | : html` · not absorbed: the app runs as an ordinary app`}`, | |
| 68 | : null}`}<br /><span id="sw-${f.slug}">${switchNote(f)}</span>`, | |
| 61 | 69 | trail: html`${when(f.pending !== null, () => html`<form class="row-fields" method="post" action="${base}/accept"> |
| 62 | 70 | <input type="hidden" name="name" value="${f.pending?.name ?? ""}" /> |
| 63 | 71 | <input type="hidden" name="page" value="${f.pending?.page ?? ""}" /> |
| 64 | 72 | ${btn("Accept", { size: "sm", label: `Accept ${f.pending?.name ?? ""} as ${f.name}'s menu entry` })} |
| 65 | </form>`)}<form class="row-fields" method="post" action="${base}/${f.absorbed ? "unabsorb" : "absorb"}"> | |
| 73 | </form>`)}<form class="row-fields" method="post" action="${base}/${f.absorbed ? "unabsorb" : "absorb"}" aria-describedby="sw-${f.slug}"> | |
| 66 | 74 | ${when(f.absorbed, () => btn("Settings", { size: "sm", href: base }))} |
| 67 | 75 | ${btn(f.absorbed ? "Un-absorb" : "Absorb", { |
| 68 | 76 | size: "sm", |
| @@ -81,7 +89,7 @@ export async function featuresPage(ctx: Ctx): Promise<Response> { | ||
| 81 | 89 | const listed = await timePhase(ctx.request, "features", () => listFeatures(ctx.app, ctx.auth.user)); |
| 82 | 90 | if (!listed.ok) return notFoundPage(ctx); |
| 83 | 91 | const rendering = performance.now(); |
| 84 | const body = card( | |
| 92 | const body = html`${card( | |
| 85 | 93 | listed.value.length === 0 |
| 86 | 94 | ? html`<div class="card-body"> |
| 87 | 95 | ${empty({ |
| @@ -98,14 +106,15 @@ export async function featuresPage(ctx: Ctx): Promise<Response> { | ||
| 98 | 106 | <h2>Features</h2>`, |
| 99 | 107 | pad: false, |
| 100 | 108 | }, |
| 101 | ); | |
| 109 | )}${when(listed.value.length === 0, () => html`<p class="muted small"><a href="${DOCS}">How an app declares a feature</a></p>`)}`; | |
| 102 | 110 | const response = shell(ctx, "Features", body); |
| 103 | 111 | notePhase(ctx.request, "render", performance.now() - rendering); |
| 104 | 112 | return response; |
| 105 | 113 | } |
| 106 | 114 | |
| 107 | function input(s: SettingView) { | |
| 115 | function input(s: SettingView, invalid = false) { | |
| 108 | 116 | const name = `s.${s.key}`; |
| 117 | const bad = invalid ? raw('aria-invalid="true"') : null; | |
| 109 | 118 | switch (s.type) { |
| 110 | 119 | case "bool": |
| 111 | 120 | return html`<input type="hidden" name="${name}" value="off" /><label class="check" |
| @@ -114,9 +123,9 @@ function input(s: SettingView) { | ||
| 114 | 123 | case "int": |
| 115 | 124 | return html`<input class="input mono" type="number" name="${name}" value="${s.value}" |
| 116 | 125 | ${s.min === null ? "" : html`min="${String(s.min)}"`} ${s.max === null ? "" : html`max="${String(s.max)}"`} |
| 117 | aria-label="${s.label}" />`; | |
| 126 | aria-label="${s.label}" ${bad} />`; | |
| 118 | 127 | case "choice": |
| 119 | return html`<select class="input" name="${name}" aria-label="${s.label}"> | |
| 128 | return html`<select class="input" name="${name}" aria-label="${s.label}" ${bad}> | |
| 120 | 129 | ${(s.choices ?? []).map((c) => html`<option value="${c}" ${c === s.value ? "selected" : ""}>${c}</option>`)} |
| 121 | 130 | </select>`; |
| 122 | 131 | case "days": { |
| @@ -126,9 +135,9 @@ function input(s: SettingView) { | ||
| 126 | 135 | )}`; |
| 127 | 136 | } |
| 128 | 137 | case "list": |
| 129 | return html`<textarea class="input mono" name="${name}" rows="3" aria-label="${s.label}">${s.value.split(",").filter(Boolean).join("\n")}</textarea>`; | |
| 138 | return html`<textarea class="input mono" name="${name}" rows="3" aria-label="${s.label}" ${bad}>${s.value.split(",").filter(Boolean).join("\n")}</textarea>`; | |
| 130 | 139 | default: |
| 131 | return html`<input class="input" name="${name}" value="${s.value}" aria-label="${s.label}" />`; | |
| 140 | return html`<input class="input" name="${name}" value="${s.value}" aria-label="${s.label}" ${bad} />`; | |
| 132 | 141 | } |
| 133 | 142 | } |
| 134 | 143 | |
| @@ -139,18 +148,41 @@ export async function featurePage(ctx: Ctx): Promise<Response> { | ||
| 139 | 148 | if (no) return no; |
| 140 | 149 | const found = await timePhase(ctx.request, "feature", () => getFeature(ctx.app, ctx.auth.user, ctx.params["slug"] ?? "")); |
| 141 | 150 | if (!found.ok) return notFoundPage(ctx); |
| 142 | const f = found.value; | |
| 143 | 151 | const rendering = performance.now(); |
| 152 | const response = featureView(ctx, found.value); | |
| 153 | notePhase(ctx.request, "render", performance.now() - rendering); | |
| 154 | return response; | |
| 155 | } | |
| 156 | ||
| 157 | /** What a refused save hands back: the values as typed, and the one that was refused. */ | |
| 158 | interface Refused { | |
| 159 | readonly typed: Readonly<Record<string, unknown>>; | |
| 160 | readonly key: string | undefined; | |
| 161 | readonly message: string; | |
| 162 | } | |
| 163 | ||
| 164 | /** A setting's value as the form sent it, in the shape `input` draws. */ | |
| 165 | function typedValue(s: SettingView, raw: unknown): string { | |
| 166 | if (Array.isArray(raw)) return raw.map(String).join(","); | |
| 167 | const text = String(raw ?? ""); | |
| 168 | if (s.type === "bool") return text === "on" ? "true" : "false"; | |
| 169 | if (s.type === "list") return text.split(/[\n,]/).map((t) => t.trim()).filter(Boolean).join(","); | |
| 170 | return text; | |
| 171 | } | |
| 172 | ||
| 173 | function featureView(ctx: Ctx, f: FeatureView, refused?: Refused): Response { | |
| 144 | 174 | const base = `/settings/features/${encodeURIComponent(f.slug)}`; |
| 145 | const settings = f.settings.map((s) => | |
| 146 | row({ | |
| 175 | const settings = f.settings.map((shown) => { | |
| 176 | const bad = refused?.key === shown.key; | |
| 177 | const s = refused !== undefined && shown.key in refused.typed ? { ...shown, value: typedValue(shown, refused.typed[shown.key]) } : shown; | |
| 178 | return row({ | |
| 147 | 179 | lead: icon("settings"), |
| 148 | 180 | title: html`${s.label} <span class="muted small mono">${s.env}</span>`, |
| 149 | meta: html`${s.help ?? ""}${when(s.help !== null, () => html`<br />`)}Default | |
| 181 | meta: html`${bad ? html`<span class="danger" role="alert">${refused.message}</span><br />` : null}${s.help ?? ""}${when(s.help !== null, () => html`<br />`)}Default | |
| 150 | 182 | <span class="mono">${s.default === "" ? "empty" : s.default}</span>${s.set ? "" : " · not changed"}`, |
| 151 | trail: input(s), | |
| 152 | }), | |
| 153 | ); | |
| 183 | trail: input(s, bad), | |
| 184 | }); | |
| 185 | }); | |
| 154 | 186 | const body = html`${card( |
| 155 | 187 | f.settings.length === 0 |
| 156 | 188 | ? html`<div class="card-body"><p class="muted small">${f.problem ?? "This feature declares no settings."}</p></div>` |
| @@ -173,9 +205,8 @@ export async function featurePage(ctx: Ctx): Promise<Response> { | ||
| 173 | 205 | Saving restarts ${f.name}: its process reads each setting as an environment variable, named on the right. |
| 174 | 206 | <a href="/settings/features">All features</a> |
| 175 | 207 | </p>`; |
| 176 | const response = shell(ctx, f.name, body); | |
| 177 | notePhase(ctx.request, "render", performance.now() - rendering); | |
| 178 | return response; | |
| 208 | const answered = shell(ctx, f.name, html`${refused !== undefined && refused.key === undefined ? html`<p class="danger" role="alert">${refused.message}</p>` : null}${body}`); | |
| 209 | return refused === undefined ? answered : new Response(answered.body, { status: 422, headers: answered.headers }); | |
| 179 | 210 | } |
| 180 | 211 | |
| 181 | 212 | /** The form's settings, as the action takes them: `days` from its checkboxes, the rest as sent. */ |
| @@ -201,16 +232,14 @@ export async function featureSave(ctx: Ctx): Promise<Response> { | ||
| 201 | 232 | const slug = ctx.params["slug"] ?? ""; |
| 202 | 233 | const found = await getFeature(ctx.app, ctx.auth.user, slug); |
| 203 | 234 | if (!found.ok) return notFoundPage(ctx); |
| 204 | const changed = await changeFeatureSettings(ctx.app, ctx.auth.user, slug, settingsFromForm(form, found.value.settings)); | |
| 235 | const typed = settingsFromForm(form, found.value.settings); | |
| 236 | const changed = await changeFeatureSettings(ctx.app, ctx.auth.user, slug, typed); | |
| 205 | 237 | const back = `/settings/features/${encodeURIComponent(slug)}`; |
| 206 | 238 | if (!changed.ok && changed.error.error === "not_found") return notFoundPage(ctx); |
| 207 | return flashTo( | |
| 208 | ctx, | |
| 209 | back, | |
| 210 | changed.ok | |
| 211 | ? { message: `${changed.value.name}: saved. It restarts with the new settings.` } | |
| 212 | : { tone: "danger", message: changed.error.message }, | |
| 213 | ); | |
| 239 | if (changed.ok) return flashTo(ctx, back, { message: `${changed.value.name}: saved. It restarts with the new settings.` }); | |
| 240 | if (changed.error.error === "invalid_setting") | |
| 241 | return featureView(ctx, found.value, { typed, key: changed.error.key, message: changed.error.message }); | |
| 242 | return flashTo(ctx, back, { tone: "danger", message: changed.error.message }); | |
| 214 | 243 | } |
| 215 | 244 | |
| 216 | 245 | // POST /settings/features/:slug/absorb and /unabsorb |
test/features-tui.test.ts added +115
| @@ -0,0 +1,115 @@ | ||
| 1 | /** | |
| 2 | * Features in the terminal (#9544): the page's list, its switch and its | |
| 3 | * settings, through the same actions. A site admin finds the entry, absorbs | |
| 4 | * and un-absorbs, and a setting is saved or refused as on the page; anyone | |
| 5 | * else is not offered it. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 12 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 13 | import { createApp as insertApp } from "../src/core/apps.ts"; | |
| 14 | import { featureSettings, getAbsorbed } from "../src/core/features.ts"; | |
| 15 | import { createUser, type User } from "../src/core/users.ts"; | |
| 16 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 17 | import type { TuiIdentity } from "../src/tui/session.ts"; | |
| 18 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 19 | import { quietLogs } from "./helpers/globals.ts"; | |
| 20 | import { settleApp } from "./helpers/tui.ts"; | |
| 21 | ||
| 22 | quietLogs(); | |
| 23 | ||
| 24 | const FORGE_JSON = JSON.stringify({ | |
| 25 | app: { run: "bun run src/server.ts" }, | |
| 26 | feature: { name: "Radar", page: "/", settings: { runHourUtc: { type: "int", default: 13, min: 0, max: 23, label: "Hour (UTC)" } } }, | |
| 27 | }); | |
| 28 | ||
| 29 | describe("features in the terminal", () => { | |
| 30 | let dir: string; | |
| 31 | let forge: Forge; | |
| 32 | let ada: User; | |
| 33 | let eve: User; | |
| 34 | let appId: string; | |
| 35 | ||
| 36 | function boot(user: User) { | |
| 37 | const terminal = headlessTerminal(120, 32); | |
| 38 | const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user }; | |
| 39 | const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false }); | |
| 40 | app.start(); | |
| 41 | const key = (k: string) => app.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false }); | |
| 42 | const press = async (k: string) => { | |
| 43 | key(k); | |
| 44 | await settleApp(app); | |
| 45 | await settleApp(app); | |
| 46 | }; | |
| 47 | const type = async (text: string) => { | |
| 48 | for (const ch of text) key(ch); | |
| 49 | await settleApp(app); | |
| 50 | }; | |
| 51 | const palette = async () => { | |
| 52 | await settleApp(app); | |
| 53 | app.dispatch({ type: "key", key: "k", ctrl: true, alt: false, shift: false }); | |
| 54 | await settleApp(app); | |
| 55 | await type("Features"); | |
| 56 | }; | |
| 57 | return { text: () => app.screen.toText(), press, type, palette, stop: () => app.stop() }; | |
| 58 | } | |
| 59 | ||
| 60 | beforeAll(async () => { | |
| 61 | dir = await mkdtemp(join(tmpdir(), "forge-features-tui-")); | |
| 62 | forge = createApp({ dataDir: dir, port: 0 }); | |
| 63 | const make = async (username: string, adminIfFirst: boolean): Promise<User> => { | |
| 64 | const made = await createUser(forge.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst }); | |
| 65 | if (!made.ok) throw new Error(made.error); | |
| 66 | return made.value; | |
| 67 | }; | |
| 68 | ada = await make("ada", true); | |
| 69 | eve = await make("eve", false); | |
| 70 | const repo = await createRepo(forge, ada, { name: "radar", owner: "ada", visibility: "public", description: "" }); | |
| 71 | if (!repo.ok) throw new Error(JSON.stringify(repo.error)); | |
| 72 | const done = await commitToBranch(forge, ada, repo.value, { message: "feature", files: [{ path: "forge.json", content: FORGE_JSON }] }); | |
| 73 | if (!done.ok) throw new Error(JSON.stringify(done.error)); | |
| 74 | const row = insertApp(forge.db, { repoId: repo.value.id, slug: "radar", access: "public" }); | |
| 75 | if (!row.ok) throw new Error(row.error); | |
| 76 | appId = row.value.id; | |
| 77 | }, 60_000); | |
| 78 | afterAll(async () => { | |
| 79 | forge?.close(); | |
| 80 | await rm(dir, { recursive: true, force: true }); | |
| 81 | }); | |
| 82 | ||
| 83 | test("only a site admin is offered the entry", async () => { | |
| 84 | const other = boot(eve); | |
| 85 | await other.palette(); | |
| 86 | expect(other.text()).not.toContain("apps that declare a feature"); | |
| 87 | other.stop(); | |
| 88 | }); | |
| 89 | ||
| 90 | test("an admin absorbs, saves a setting, is refused a bad one, and un-absorbs", async () => { | |
| 91 | const t = boot(ada); | |
| 92 | await t.palette(); | |
| 93 | await t.press("enter"); | |
| 94 | expect(t.text()).toContain("Radar"); | |
| 95 | expect(t.text()).toContain("not absorbed"); | |
| 96 | await t.press("a"); | |
| 97 | expect(getAbsorbed(forge.db, appId)).toBeDefined(); | |
| 98 | await t.press("enter"); | |
| 99 | await t.press("enter"); | |
| 100 | for (let i = 0; i < 4; i++) await t.press("backspace"); | |
| 101 | await t.type("9"); | |
| 102 | await t.press("enter"); | |
| 103 | expect(featureSettings(forge.db, appId).get("runHourUtc")).toBe("9"); | |
| 104 | await t.press("enter"); | |
| 105 | for (let i = 0; i < 4; i++) await t.press("backspace"); | |
| 106 | await t.type("99"); | |
| 107 | await t.press("enter"); | |
| 108 | expect(t.text()).toContain("Hour (UTC) is at most 23"); | |
| 109 | expect(featureSettings(forge.db, appId).get("runHourUtc")).toBe("9"); | |
| 110 | await t.press("esc"); | |
| 111 | await t.press("a"); | |
| 112 | expect(getAbsorbed(forge.db, appId)).toBeUndefined(); | |
| 113 | t.stop(); | |
| 114 | }); | |
| 115 | }); |
test/features.test.ts +19
| @@ -370,6 +370,25 @@ describe("absorbing an app", () => { | ||
| 370 | 370 | expect(!byOwner.ok && byOwner.error.error).toBe("forbidden"); |
| 371 | 371 | }); |
| 372 | 372 | |
| 373 | test("a refused save keeps what was typed and marks the field; the menu and strip say more", async () => { | |
| 374 | const form = new URLSearchParams([ | |
| 375 | ["s.runDays", "tue"], | |
| 376 | ["s.runHourUtc", "99"], | |
| 377 | ["s.claudeCode", "stable"], | |
| 378 | ]); | |
| 379 | const refused = await web(adaCookie, "/settings/features/radar", form); | |
| 380 | expect(refused.status).toBe(422); | |
| 381 | const page = await refused.text(); | |
| 382 | expect(page).toContain('value="99"'); | |
| 383 | expect(page).toContain("Hour (UTC) is at most 23"); | |
| 384 | expect(page).toContain('aria-invalid="true"'); | |
| 385 | // The switch says what it does before it is pressed. | |
| 386 | const list = await (await web(adaCookie, "/settings/features")).text(); | |
| 387 | expect(list).toContain("Un-absorbing takes Radar off every menu"); | |
| 388 | // The app's own page links to Features for an admin. | |
| 389 | expect(await (await web(adaCookie, "/cy/radar/files")).text()).toContain('href="/settings/features/radar"'); | |
| 390 | }); | |
| 391 | ||
| 373 | 392 | test("good values are kept and reach the app as FORGE_SETTING_*", async () => { |
| 374 | 393 | const res = await api("PATCH", adaToken, "/radar/settings", { settings: { runDays: ["wed", "mon", "fri"], runHourUtc: 9 } }); |
| 375 | 394 | expect(res.status).toBe(200); |
test/palette-roster.test.ts +1
| @@ -142,6 +142,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 142 | 142 | ["domains", "Failure domains", "failure domains and their last drill"], |
| 143 | 143 | ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull, l likes"], |
| 144 | 144 | ["feature-review", "Menu entries to review", "absorbed apps whose declared name or page changed: accept them"], |
| 145 | ["features", "Features", "apps that declare a feature: absorb one, change its settings"], | |
| 145 | 146 | ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"], |
| 146 | 147 | ["owner", "Owner's needs", "what needs the owner, snapshots, failure domains"], |
| 147 | 148 | ["forges", "Forges you sent", "each forge sent, claimed or not, and its apps"], |
new above
offline
Says the summary is the menu tooltip; the tooltip was removed. Fix with the code (finding 2).