Public: anyone can read this; the crew can change it.
Absorb: the menu entry's name and page follow the app's latest production commit, not the admin's act #9642
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9541-absorb-the-menu-entry-s-name-and-page-fo → main 6 commits fork 2 threads · 2 unresolved
17 files
changed +315
−25
docs/features/absorb.md +14
| @@ -83,3 +83,17 @@ Absorb was built by Ben (benjivers). The review of #9441 found: | ||
| 83 | 83 | - **Speed:** the Features list reads git for every app on every request (#9543). |
| 84 | 84 | |
| 85 | 85 | This section is ready for the owner to share with Ben. |
| 86 | ||
| 87 | ## The menu entry is the admin's, not the deploy's | |
| 88 | ||
| 89 | The name and page in the menu are the ones the admin saw when absorbing. If the production | |
| 90 | `forge.json` later declares others, the menu keeps serving the absorbed ones and Settings › | |
| 91 | Features shows "declaration changed: review" with the new values; an admin accepts them | |
| 92 | (`POST /settings/features/:slug/accept` with the reviewed `name` and `page`; refused with a re-review flash when the app declares something else by now; event `app.feature_declared`). Feature entries sit under | |
| 93 | their own "Features from apps" heading, and a name equal to a built-in menu label is refused. | |
| 94 | ||
| 95 | ### Accepting in the terminal | |
| 96 | ||
| 97 | Open the palette and choose "Menu entries to review" (site admins who are people only). It lists the | |
| 98 | absorbed apps whose declaration changed; `j`/`k` move, `a` accepts the selected row. The accept posts the | |
| 99 | name and page the screen showed, so one that changed since is refused ("review it again"), as on the page. |
src/actions/features.ts +44 −3
| @@ -24,6 +24,7 @@ import { appsRuntime } from "../apps/runtime.ts"; | ||
| 24 | 24 | import { getAppById, getAppBySlug, listApps, type AppRecord } from "../core/apps.ts"; |
| 25 | 25 | import { |
| 26 | 26 | absorb, |
| 27 | keepDeclaration, | |
| 27 | 28 | featureSettings, |
| 28 | 29 | getAbsorbed, |
| 29 | 30 | listAbsorbed, |
| @@ -46,7 +47,9 @@ export type FeatureError = | ||
| 46 | 47 | /** The app is not absorbed: its settings are not the forge's to change. */ |
| 47 | 48 | | "not_absorbed" |
| 48 | 49 | /** A value that does not fit its declaration; `message` names it. */ |
| 49 | | "invalid_setting"; | |
| 50 | | "invalid_setting" | |
| 51 | /** What the admin reviewed is not what the app declares now: review again. */ | |
| 52 | | "declaration_changed"; | |
| 50 | 53 | |
| 51 | 54 | export interface SettingView { |
| 52 | 55 | readonly key: string; |
| @@ -69,6 +72,8 @@ export interface FeatureView { | ||
| 69 | 72 | readonly name: string; |
| 70 | 73 | readonly summary: string | null; |
| 71 | 74 | readonly page: string; |
| 75 | /** The name and page the app declares now, when they differ from the absorbed ones: waits for an admin. */ | |
| 76 | readonly pending: { readonly name: string; readonly page: string } | null; | |
| 72 | 77 | readonly absorbed: boolean; |
| 73 | 78 | readonly absorbedBy: string | null; |
| 74 | 79 | readonly absorbedAt: string | null; |
| @@ -121,9 +126,13 @@ function viewOf(record: AppRecord, repo: Repo, app: App, spec: FeatureSpec | und | ||
| 121 | 126 | return { |
| 122 | 127 | slug: record.slug, |
| 123 | 128 | repo: `${repo.ownerName}/${repo.name}`, |
| 124 | name: spec?.name ?? record.slug, | |
| 129 | name: absorbed?.name ?? spec?.name ?? record.slug, | |
| 125 | 130 | summary: spec?.summary ?? null, |
| 126 | page: spec?.page ?? "/", | |
| 131 | page: absorbed?.page ?? spec?.page ?? "/", | |
| 132 | pending: | |
| 133 | absorbed !== undefined && spec !== undefined && (spec.name !== absorbed.name || spec.page !== absorbed.page) | |
| 134 | ? { name: spec.name, page: spec.page } | |
| 135 | : null, | |
| 127 | 136 | absorbed: absorbed !== undefined, |
| 128 | 137 | absorbedBy: absorbed?.absorbedBy ?? null, |
| 129 | 138 | absorbedAt: absorbed?.absorbedAt ?? null, |
| @@ -227,6 +236,38 @@ export async function absorbApp(app: App, actor: User | undefined, slug: string) | ||
| 227 | 236 | return ok(viewOf(found.record, found.repo, app, declared.value, null)); |
| 228 | 237 | } |
| 229 | 238 | |
| 239 | /** | |
| 240 | * Accept the name and page the admin reviewed as the menu's. Refused with | |
| 241 | * `declaration_changed` when the app declares something else by now, so an | |
| 242 | * admin never accepts values they did not see. An admin's act, and an event. | |
| 243 | */ | |
| 244 | export async function acceptDeclaration( | |
| 245 | app: App, | |
| 246 | actor: User | undefined, | |
| 247 | slug: string, | |
| 248 | reviewed: { readonly name: string; readonly page: string }, | |
| 249 | ): Promise<Result<FeatureView, FeatureError>> { | |
| 250 | const found = readable(app, actor, slug); | |
| 251 | if (found === undefined) return err("not_found"); | |
| 252 | if (!siteAdmin(actor)) return err("forbidden"); | |
| 253 | const before = getAbsorbed(app.db, found.record.id); | |
| 254 | if (before === undefined) return err("not_absorbed"); | |
| 255 | const declared = await declaredFeature(app, found.repo); | |
| 256 | if (!declared.ok || declared.value === undefined) return err("not_a_feature"); | |
| 257 | const { name, page } = declared.value; | |
| 258 | if (name !== reviewed.name || page !== reviewed.page) return err("declaration_changed"); | |
| 259 | if (name !== before.name || page !== before.page) { | |
| 260 | keepDeclaration(app.db, found.record.id, name, page); | |
| 261 | app.bus.append({ | |
| 262 | kind: "app.feature_declared", | |
| 263 | repoId: found.repo.id, | |
| 264 | actorId: actor.id, | |
| 265 | payload: { slug, name, page, was: { name: before.name, page: before.page } }, | |
| 266 | }); | |
| 267 | } | |
| 268 | return ok(viewOf(found.record, found.repo, app, declared.value, null)); | |
| 269 | } | |
| 270 | ||
| 230 | 271 | /** Put an absorbed app back as an ordinary app. Its settings are kept for the next absorb. */ |
| 231 | 272 | export async function unabsorbApp(app: App, actor: User | undefined, slug: string): Promise<Result<FeatureView, FeatureError>> { |
| 232 | 273 | const found = readable(app, actor, slug); |
src/apps/feature.ts +16
| @@ -28,6 +28,21 @@ export type SettingType = "bool" | "int" | "string" | "choice" | "days" | "list" | ||
| 28 | 28 | |
| 29 | 29 | export const SETTING_TYPES: readonly SettingType[] = ["bool", "int", "string", "choice", "days", "list"]; |
| 30 | 30 | |
| 31 | /** The labels the forge's own menus use: a feature may not borrow one. */ | |
| 32 | const BUILT_IN_MENU_LABELS = [ | |
| 33 | "your profile", "repositories", "apps", "notes", "talk", "explore", "packs", "displays", "map", "features", | |
| 34 | "new app", "new repository", "new issue", "new organization", "migrate", "settings", "access tokens", "ssh keys", | |
| 35 | "origins", "boards", "crew", "system memory", "help", "sign out", "log out", "sign in", "admin", "administration", | |
| 36 | "theme", "create", "new forge", "create new", "appearance and help", "features from apps", "keyboard shortcuts", | |
| 37 | ]; | |
| 38 | ||
| 39 | const menuForm = (s: string): string => s.normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim(); | |
| 40 | ||
| 41 | /** True when `name` reads as one of the forge's own menu labels. */ | |
| 42 | export function isBuiltInMenuLabel(name: string): boolean { | |
| 43 | return BUILT_IN_MENU_LABELS.includes(menuForm(name)); | |
| 44 | } | |
| 45 | ||
| 31 | 46 | /** The days a `days` setting can name, in the order they are kept and shown. */ |
| 32 | 47 | export const DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const; |
| 33 | 48 | |
| @@ -79,6 +94,7 @@ export function parseFeature(forgeJson: Raw): Result<FeatureSpec | undefined, st | ||
| 79 | 94 | const name = block["name"]; |
| 80 | 95 | if (typeof name !== "string" || name.trim() === "" || name.length > NAME_MAX) |
| 81 | 96 | return bad(`name is text of 1 to ${NAME_MAX} characters`); |
| 97 | if (isBuiltInMenuLabel(name)) return bad("name is already a label in the forge's own menu"); | |
| 82 | 98 | const summary = block["summary"]; |
| 83 | 99 | if (summary !== undefined && (typeof summary !== "string" || summary.length > SUMMARY_MAX)) |
| 84 | 100 | return bad(`summary is text of at most ${SUMMARY_MAX} characters`); |
src/apps/feature_env.ts +3 −5
| @@ -8,13 +8,12 @@ | ||
| 8 | 8 | |
| 9 | 9 | import type { Database } from "bun:sqlite"; |
| 10 | 10 | import { featureEnv, parseFeature } from "./feature.ts"; |
| 11 | import { featureSettings, getAbsorbed, refreshAbsorbed } from "../core/features.ts"; | |
| 11 | import { featureSettings, getAbsorbed } from "../core/features.ts"; | |
| 12 | 12 | |
| 13 | 13 | export function featureVarsAt( |
| 14 | 14 | db: Database, |
| 15 | 15 | appId: string, |
| 16 | 16 | forgeJson: string | undefined, |
| 17 | o: { production?: boolean } = {}, | |
| 18 | 17 | ): Record<string, string> { |
| 19 | 18 | if (forgeJson === undefined || getAbsorbed(db, appId) === undefined) return {}; |
| 20 | 19 | let parsed: unknown; |
| @@ -26,9 +25,8 @@ export function featureVarsAt( | ||
| 26 | 25 | if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {}; |
| 27 | 26 | const spec = parseFeature(parsed as Record<string, unknown>); |
| 28 | 27 | if (!spec.ok || spec.value === undefined) return {}; |
| 29 | // The menu names it as the code production runs names it; a preview's | |
| 30 | // forge.json is a pull's, and does not speak for the forge's menu. | |
| 31 | if (o.production === true) refreshAbsorbed(db, appId, spec.value.name, spec.value.page); | |
| 28 | // The menu keeps the name and page the admin saw at absorb; a changed | |
| 29 | // declaration waits for an admin to accept it (`acceptDeclaration`). | |
| 32 | 30 | return featureEnv(spec.value, featureSettings(db, appId)); |
| 33 | 31 | } |
| 34 | 32 |
src/apps/runtime.ts +1 −1
| @@ -1695,7 +1695,7 @@ export class AppsRuntime { | ||
| 1695 | 1695 | } catch { |
| 1696 | 1696 | forgeJson = undefined; |
| 1697 | 1697 | } |
| 1698 | return featureVarsAt(this.app.db, appId, forgeJson, { production: env === "production" }); | |
| 1698 | return featureVarsAt(this.app.db, appId, forgeJson); | |
| 1699 | 1699 | } |
| 1700 | 1700 | |
| 1701 | 1701 | /** |
src/core/features.ts +2 −2
| @@ -36,8 +36,8 @@ export function absorb(db: Database, appId: string, by: string, name: string, pa | ||
| 36 | 36 | ); |
| 37 | 37 | } |
| 38 | 38 | |
| 39 | /** The declaration's name and page as last read, for the nav. */ | |
| 40 | export function refreshAbsorbed(db: Database, appId: string, name: string, page: string): void { | |
| 39 | /** Keep this name and page for the nav: an admin's act (accepting a changed declaration), never a deploy's. */ | |
| 40 | export function keepDeclaration(db: Database, appId: string, name: string, page: string): void { | |
| 41 | 41 | db.query("UPDATE app_feature SET name = ?2, page = ?3 WHERE app_id = ?1").run(appId, name, page); |
| 42 | 42 | } |
| 43 | 43 |
src/design/activity.ts +4
| @@ -64,6 +64,10 @@ export function eventVerb(event: ForgeEvent): string { | ||
| 64 | 64 | const name = payload["name"]; |
| 65 | 65 | return `absorbed ${typeof name === "string" ? name : "the app"} as a forge feature`; |
| 66 | 66 | } |
| 67 | if (kind === "app.feature_declared") { | |
| 68 | const name = payload["name"]; | |
| 69 | return `accepted the feature's declared name ${typeof name === "string" ? name : "and page"}`; | |
| 70 | } | |
| 67 | 71 | if (kind === "app.unabsorbed") return "put the app back as an ordinary app"; |
| 68 | 72 | if (kind === "app.feature_settings") { |
| 69 | 73 | const keys = payload["keys"]; |
src/http/api/features.ts +2 −1
| @@ -25,12 +25,13 @@ import type { Router } from "../router.ts"; | ||
| 25 | 25 | const WORDS: Record<Exclude<FeatureError, "not_found" | "forbidden" | "invalid_setting">, string> = { |
| 26 | 26 | not_a_feature: "The app's forge.json declares no feature, or declares it wrongly.", |
| 27 | 27 | not_absorbed: "The app is not absorbed.", |
| 28 | declaration_changed: "The app declares something else than was reviewed.", | |
| 28 | 29 | }; |
| 29 | 30 | |
| 30 | 31 | function refused(error: FeatureError, message?: string): Response { |
| 31 | 32 | if (error === "not_found" || error === "forbidden") return respondAccess(error, 403); |
| 32 | 33 | if (error === "invalid_setting") return json({ error, message: message ?? "A setting does not fit." }, 422); |
| 33 | return json({ error, message: WORDS[error] }, error === "not_absorbed" ? 409 : 422); | |
| 34 | return json({ error, message: WORDS[error] }, error === "not_absorbed" || error === "declaration_changed" ? 409 : 422); | |
| 34 | 35 | } |
| 35 | 36 | |
| 36 | 37 | export function featureRoutes(router: Router<Handler>): void { |
src/http/settings_routes.ts +2 −1
| @@ -31,7 +31,7 @@ import { | ||
| 31 | 31 | signInAct, |
| 32 | 32 | } from "../web/pages/accounts.ts"; |
| 33 | 33 | import { confirmsList, confirmYes } from "../web/pages/confirms.ts"; |
| 34 | import { featurePage, featureSave, featuresPage, featureSwitch } from "../web/pages/features.ts"; | |
| 34 | import { featureAccept, featurePage, featureSave, featuresPage, featureSwitch } from "../web/pages/features.ts"; | |
| 35 | 35 | import { passkeyAdd, passkeyDelete, passkeyOptions, passkeysPage } from "../web/pages/passkeys.ts"; |
| 36 | 36 | import { secretDelete, secretSet, secretsPage } from "../web/pages/secrets.ts"; |
| 37 | 37 | import { |
| @@ -187,6 +187,7 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea | ||
| 187 | 187 | router.add("POST", "/settings/features/:slug", featureSave); |
| 188 | 188 | router.add("POST", "/settings/features/:slug/absorb", featureSwitch(true)); |
| 189 | 189 | router.add("POST", "/settings/features/:slug/unabsorb", featureSwitch(false)); |
| 190 | router.add("POST", "/settings/features/:slug/accept", featureAccept); | |
| 190 | 191 | router.add("POST", "/settings/flags", flagsSet); |
| 191 | 192 | router.add("GET", "/settings/usage", (ctx) => usagePage(ctx, knownPatterns)); |
| 192 | 193 | router.add("GET", "/settings/spend", spendPage); |
src/tui/screens/entries.ts +2
| @@ -25,6 +25,7 @@ import { entry as displays } from "./displays.ts"; | ||
| 25 | 25 | import { entry as domains } from "./domains.ts"; |
| 26 | 26 | import { entry as edgeOnly } from "./edgeonly.ts"; |
| 27 | 27 | import { entry as traffic } from "./trafficview.ts"; |
| 28 | import { entry as featureReview } from "./features.ts"; | |
| 28 | 29 | import { entry as feed } from "./feed.ts"; |
| 29 | 30 | import { entry as forgeView } from "./forgeview.ts"; |
| 30 | 31 | import { entry as front } from "./front.ts"; |
| @@ -111,6 +112,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 111 | 112 | connections, |
| 112 | 113 | domains, |
| 113 | 114 | feed, |
| 115 | featureReview, | |
| 114 | 116 | front, |
| 115 | 117 | owner, |
| 116 | 118 | forges, |
src/tui/screens/features.ts added +82
| @@ -0,0 +1,82 @@ | ||
| 1 | /** | |
| 2 | * Absorbed apps whose declared menu entry changed, in the terminal: the rows | |
| 3 | * Settings › Features marks "declaration changed: review". `a` accepts the | |
| 4 | * selected row's declaration through `acceptDeclaration`, the page's own | |
| 5 | * action, posting what the screen showed, so a declaration that moved since is | |
| 6 | * refused. Offered to a site admin who is a person, as the page is. | |
| 7 | */ | |
| 8 | ||
| 9 | import { acceptDeclaration, listFeatures, type FeatureView } from "../../actions/features.ts"; | |
| 10 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 11 | import { binding } from "../app/keys.ts"; | |
| 12 | import { style } from "../term/style.ts"; | |
| 13 | import { truncate } from "../term/width.ts"; | |
| 14 | import type { Session } from "../session.ts"; | |
| 15 | import { fill, len, split } from "../ui/layout.ts"; | |
| 16 | import { emptyState } from "../ui/widgets.ts"; | |
| 17 | import { ListState, drawList, type ListRow } from "./common.ts"; | |
| 18 | import type { PaletteEntry } from "./entries.ts"; | |
| 19 | ||
| 20 | export interface FeaturesScreen extends Screen { | |
| 21 | /** The read or accept in flight, for whoever must wait on it. */ | |
| 22 | busy(): Promise<void>; | |
| 23 | } | |
| 24 | ||
| 25 | const mayReview = (f: Session): boolean => f.user !== undefined && f.user.isAdmin && f.user.kind !== "agent"; | |
| 26 | ||
| 27 | export function featuresScreen(f: Session, ctx: Ctx): FeaturesScreen { | |
| 28 | const state = new ListState(); | |
| 29 | let rows: FeatureView[] = []; | |
| 30 | let work: Promise<void> = Promise.resolve(); | |
| 31 | const reload = (): Promise<void> => | |
| 32 | listFeatures(f.app, f.user).then((r) => { | |
| 33 | rows = r.ok ? r.value.filter((v) => v.pending !== null) : []; | |
| 34 | state.clamp(rows.length); | |
| 35 | ctx.invalidate(); | |
| 36 | }); | |
| 37 | work = reload(); | |
| 38 | ||
| 39 | return { | |
| 40 | title: "Menu entries to review", | |
| 41 | keys: [binding(["j", "k"], "move cursor"), binding(["a"], "accept")], | |
| 42 | render(area, s) { | |
| 43 | const c = ctx.theme.c; | |
| 44 | const [head, body] = split(area, "v", [len(1), fill(1)]); | |
| 45 | if (rows.length === 0) { | |
| 46 | emptyState(s, body!, c, { title: "No absorbed app has a declaration to review." }); | |
| 47 | return; | |
| 48 | } | |
| 49 | const line = mayReview(f) ? "a accepts the app's declaration as the menu entry" : "only a site admin accepts"; | |
| 50 | s.text(head!.x, head!.y, truncate(line, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 51 | const list: ListRow[] = rows.map((r) => ({ | |
| 52 | title: `${r.name} (${r.page})`, | |
| 53 | secondary: `${r.repo} declares ${r.pending!.name} (${r.pending!.page})`, | |
| 54 | meta: "", | |
| 55 | })); | |
| 56 | drawList(s, body!, c, state, list); | |
| 57 | }, | |
| 58 | key(e) { | |
| 59 | if (state.key(e, rows.length)) return true; | |
| 60 | if (e.ctrl || e.alt || e.key !== "a") return false; | |
| 61 | const row = rows[state.index]; | |
| 62 | if (row === undefined || row.pending === null || !mayReview(f)) return false; | |
| 63 | work = acceptDeclaration(f.app, f.user, row.slug, row.pending).then(async (done) => { | |
| 64 | if (done.ok) ctx.toast(`${row.pending!.name} is now the menu's entry`, "success"); | |
| 65 | else if (done.error === "declaration_changed") ctx.toast("the app declared something else; review it again", "danger"); | |
| 66 | else ctx.toast("that could not be accepted", "danger"); | |
| 67 | await reload(); | |
| 68 | }); | |
| 69 | return true; | |
| 70 | }, | |
| 71 | busy: () => work, | |
| 72 | }; | |
| 73 | } | |
| 74 | ||
| 75 | /** The pending declarations: a site admin's, as the settings page is. */ | |
| 76 | export const entry: PaletteEntry = { | |
| 77 | id: "feature-review", | |
| 78 | title: "Menu entries to review", | |
| 79 | hint: "absorbed apps whose declared name or page changed: accept them", | |
| 80 | offered: mayReview, | |
| 81 | run: (session, ctx) => ctx.push(featuresScreen(session, ctx)), | |
| 82 | }; |
src/web/layout.ts +5 −1
| @@ -271,7 +271,7 @@ function newMenu(ctx: Ctx): Html { | ||
| 271 | 271 | */ |
| 272 | 272 | function featureItems(ctx: Ctx, user: User): MenuItem[] { |
| 273 | 273 | const runtime = appsRuntime(ctx.app); |
| 274 | return absorbedForNav(ctx.app, user).map((f) => { | |
| 274 | const entries = absorbedForNav(ctx.app, user).map((f) => { | |
| 275 | 275 | // Without a runtime there is no host to open: the app's own page instead. |
| 276 | 276 | const href = |
| 277 | 277 | runtime === undefined |
| @@ -279,6 +279,10 @@ function featureItems(ctx: Ctx, user: User): MenuItem[] { | ||
| 279 | 279 | : `/apps/open?host=${encodeURIComponent(new URL(runtime.urlFor(f.record, "production")).host)}&to=${encodeURIComponent(f.page)}`; |
| 280 | 280 | return { label: f.name, href, icon: "rocket" as const }; |
| 281 | 281 | }); |
| 282 | // Under a heading of their own, so an app's chosen name cannot pass for a forge item. | |
| 283 | return entries.length === 0 | |
| 284 | ? [] | |
| 285 | : [{ separator: true, label: "" }, { label: "Features from apps", heading: true }, ...entries]; | |
| 282 | 286 | } |
| 283 | 287 | |
| 284 | 288 | function userMenu( |
src/web/pages/features.ts +36 −3
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | |
| 9 | 9 | import { |
| 10 | 10 | absorbApp, |
| 11 | acceptDeclaration, | |
| 11 | 12 | changeFeatureSettings, |
| 12 | 13 | getFeature, |
| 13 | 14 | listFeatures, |
| @@ -47,13 +48,21 @@ function featureRow(f: FeatureView) { | ||
| 47 | 48 | return row({ |
| 48 | 49 | lead: icon("rocket"), |
| 49 | 50 | title: html`${f.name} <span class="muted small mono">${f.repo}</span> |
| 50 | ${f.absorbed ? badge("absorbed", { tone: "success" }) : null}`, | |
| 51 | meta: f.problem !== null | |
| 51 | ${f.absorbed ? badge("absorbed", { tone: "success" }) : null} | |
| 52 | ${f.pending !== null ? badge("declaration changed: review", { tone: "attn" }) : null}`, | |
| 53 | meta: f.pending !== null | |
| 54 | ? html`Menu: <strong>${f.name}</strong> at <span class="mono">${f.page}</span> → app: <strong>${f.pending.name}</strong> at | |
| 55 | <span class="mono">${f.pending.page}</span>` | |
| 56 | : f.problem !== null | |
| 52 | 57 | ? html`<span class="danger">${f.problem}</span>` |
| 53 | 58 | : html`${f.summary ?? ""}${when(f.summary !== null, () => html`<br />`)}${f.settings.length} setting${f.settings.length === 1 ? "" : "s"}${f.absorbed |
| 54 | 59 | ? html` · absorbed by ${f.absorbedBy ?? "someone"}` |
| 55 | 60 | : html` · not absorbed: the app runs as an ordinary app`}`, |
| 56 | trail: html`<form class="row-fields" method="post" action="${base}/${f.absorbed ? "unabsorb" : "absorb"}"> | |
| 61 | trail: html`${when(f.pending !== null, () => html`<form class="row-fields" method="post" action="${base}/accept"> | |
| 62 | <input type="hidden" name="name" value="${f.pending?.name ?? ""}" /> | |
| 63 | <input type="hidden" name="page" value="${f.pending?.page ?? ""}" /> | |
| 64 | ${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"}"> | |
| 57 | 66 | ${when(f.absorbed, () => btn("Settings", { size: "sm", href: base }))} |
| 58 | 67 | ${btn(f.absorbed ? "Un-absorb" : "Absorb", { |
| 59 | 68 | size: "sm", |
| @@ -235,3 +244,27 @@ export function featureSwitch(absorbing: boolean) { | ||
| 235 | 244 | ); |
| 236 | 245 | }; |
| 237 | 246 | } |
| 247 | ||
| 248 | // POST /settings/features/:slug/accept | |
| 249 | export async function featureAccept(ctx: Ctx): Promise<Response> { | |
| 250 | const no = requireAdmin(ctx); | |
| 251 | if (no) return no; | |
| 252 | const form = await readForm(ctx, "admin"); | |
| 253 | if (form instanceof Response) return form; | |
| 254 | const reviewed = { name: String(form.get("name") ?? ""), page: String(form.get("page") ?? "") }; | |
| 255 | const done = await acceptDeclaration(ctx.app, ctx.auth.user, ctx.params["slug"] ?? "", reviewed); | |
| 256 | if (!done.ok && done.error === "not_found") return notFoundPage(ctx); | |
| 257 | return flashTo( | |
| 258 | ctx, | |
| 259 | "/settings/features", | |
| 260 | done.ok | |
| 261 | ? { message: `${done.value.name} is now the menu's entry for this app.` } | |
| 262 | : { | |
| 263 | tone: "danger", | |
| 264 | message: | |
| 265 | done.error === "declaration_changed" | |
| 266 | ? "The app declared something else since you looked. Review the new declaration and accept again." | |
| 267 | : "That app is not absorbed, or no longer declares a feature.", | |
| 268 | }, | |
| 269 | ); | |
| 270 | } |
src/web/ui.ts +3
| @@ -1099,6 +1099,8 @@ export interface MenuItem { | ||
| 1099 | 1099 | icon?: IconName; |
| 1100 | 1100 | current?: boolean; |
| 1101 | 1101 | separator?: boolean; |
| 1102 | /** A non-interactive group label. */ | |
| 1103 | heading?: boolean; | |
| 1102 | 1104 | /** Renders as a submit button inside its own POST form. */ |
| 1103 | 1105 | post?: string; |
| 1104 | 1106 | /** Shown but not usable: the reason, in words, under the label. */ |
| @@ -1139,6 +1141,7 @@ export function menu(o: { | ||
| 1139 | 1141 | if (item instanceof Html) return item; |
| 1140 | 1142 | const i = item; |
| 1141 | 1143 | if (i.separator) return html`<hr class="menu-sep" />`; |
| 1144 | if (i.heading) return html`<span class="menu-title">${i.label}</span>`; | |
| 1142 | 1145 | if (i.disabled !== undefined) { |
| 1143 | 1146 | return html`<span class="menu-item" aria-disabled="true" title="${i.disabled}" |
| 1144 | 1147 | >${i.icon ? icon(i.icon) : null}<span class="grow" |
test/apps-features.test.ts +4 −7
| @@ -175,14 +175,11 @@ describe("absorbing a running app", () => { | ||
| 175 | 175 | }, 60_000); |
| 176 | 176 | |
| 177 | 177 | test("a preview's forge.json does not rename the forge's menu entry", () => { |
| 178 | featureVarsAt(forge.app.db, record.id, forgeJson("Not Radar"), { | |
| 179 | production: false, | |
| 180 | }); | |
| 178 | featureVarsAt(forge.app.db, record.id, forgeJson("Not Radar")); | |
| 179 | expect(getAbsorbed(forge.app.db, record.id)?.name).toBe("Radar"); | |
| 180 | featureVarsAt(forge.app.db, record.id, forgeJson("Radar 2")); | |
| 181 | // A production push does not rename it either: an admin accepts. | |
| 181 | 182 | expect(getAbsorbed(forge.app.db, record.id)?.name).toBe("Radar"); |
| 182 | featureVarsAt(forge.app.db, record.id, forgeJson("Radar 2"), { | |
| 183 | production: true, | |
| 184 | }); | |
| 185 | expect(getAbsorbed(forge.app.db, record.id)?.name).toBe("Radar 2"); | |
| 186 | 183 | }); |
| 187 | 184 | |
| 188 | 185 | test("un-absorbed, it restarts told nothing again", async () => { |
test/features.test.ts +94 −1
| @@ -15,7 +15,7 @@ import { join } from "node:path"; | ||
| 15 | 15 | import { statements } from "./helpers/statements.ts"; |
| 16 | 16 | import { expectWork } from "./helpers/work.ts"; |
| 17 | 17 | import { createApp, type App } from "../src/app.ts"; |
| 18 | import { absorbApp, absorbedForNav, declaredFeature, changeFeatureSettings, getFeature, unabsorbApp } from "../src/actions/features.ts"; | |
| 18 | import { absorbApp, acceptDeclaration, absorbedForNav, declaredFeature, changeFeatureSettings, getFeature, unabsorbApp } from "../src/actions/features.ts"; | |
| 19 | 19 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 20 | 20 | import { checkSetting, featureEnv, parseFeature, settingEnvName } from "../src/apps/feature.ts"; |
| 21 | 21 | import { featureVarsAt } from "../src/apps/feature_env.ts"; |
| @@ -266,6 +266,99 @@ describe("absorbing an app", () => { | ||
| 266 | 266 | expect(strangers).not.toContain("Radar"); |
| 267 | 267 | }); |
| 268 | 268 | |
| 269 | test("a production push that renames the feature leaves the menu unchanged until an admin accepts", async () => { | |
| 270 | const renamed = JSON.stringify({ app: { run: "x" }, feature: { ...FEATURE, name: "Radar Pro", page: "/pro" } }); | |
| 271 | featureVarsAt(app.db, appId, renamed); | |
| 272 | expect(absorbedForNav(app, ada).map((f) => [f.name, f.page])).toEqual([["Radar", "/"]]); | |
| 273 | const c = await commitToBranch(app, cy, repo, { message: "rename", files: [{ path: "forge.json", content: renamed }] }); | |
| 274 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 275 | const view = await getFeature(app, ada, "radar"); | |
| 276 | expect(view.ok && view.value.pending).toEqual({ name: "Radar Pro", page: "/pro" }); | |
| 277 | expect(view.ok && view.value.name).toBe("Radar"); | |
| 278 | const list = await (await web(adaCookie, "/settings/features")).text(); | |
| 279 | expect(list).toContain("declaration changed: review"); | |
| 280 | // The owner is not a site admin; only an admin accepts. | |
| 281 | const refused = await acceptDeclaration(app, cy, "radar", { name: "Radar Pro", page: "/pro" }); | |
| 282 | expect(!refused.ok && refused.error).toBe("forbidden"); | |
| 283 | expect(absorbedForNav(app, ada)[0]!.name).toBe("Radar"); | |
| 284 | expect((await web(adaCookie, "/settings/features/radar/accept", new URLSearchParams({ name: "Radar Pro", page: "/pro" }))).status).toBe(303); | |
| 285 | expect(absorbedForNav(app, ada).map((f) => [f.name, f.page])).toEqual([["Radar Pro", "/pro"]]); | |
| 286 | expect(listEvents(app.db, { repoId: repo.id }).filter((e) => e.kind === "app.feature_declared")).toHaveLength(1); | |
| 287 | // Back to the original, accepted the same way. | |
| 288 | await commitToBranch(app, cy, repo, { message: "back", files: [{ path: "forge.json", content: FORGE_JSON }] }); | |
| 289 | await acceptDeclaration(app, ada, "radar", { name: "Radar", page: "/" }); | |
| 290 | expect(absorbedForNav(app, ada)[0]!.name).toBe("Radar"); | |
| 291 | }); | |
| 292 | ||
| 293 | test("accepting what the admin reviewed is refused when production declared something else since", async () => { | |
| 294 | const declare = (name: string) => | |
| 295 | commitToBranch(app, cy, repo, { message: name, files: [{ path: "forge.json", content: JSON.stringify({ app: { run: "x" }, feature: { ...FEATURE, name } }) }] }); | |
| 296 | await declare("Reviewed"); | |
| 297 | await declare("Sneaky"); | |
| 298 | const stale = await acceptDeclaration(app, ada, "radar", { name: "Reviewed", page: FEATURE.page }); | |
| 299 | expect(!stale.ok && stale.error).toBe("declaration_changed"); | |
| 300 | expect(absorbedForNav(app, ada)[0]!.name).toBe("Radar"); | |
| 301 | const res = await web(adaCookie, "/settings/features/radar/accept", new URLSearchParams({ name: "Reviewed", page: FEATURE.page })); | |
| 302 | expect(res.status).toBe(303); | |
| 303 | expect(absorbedForNav(app, ada)[0]!.name).toBe("Radar"); | |
| 304 | const fresh = await acceptDeclaration(app, ada, "radar", { name: "Sneaky", page: FEATURE.page }); | |
| 305 | expect(fresh.ok).toBe(true); | |
| 306 | await commitToBranch(app, cy, repo, { message: "back", files: [{ path: "forge.json", content: FORGE_JSON }] }); | |
| 307 | await acceptDeclaration(app, ada, "radar", { name: "Radar", page: "/" }); | |
| 308 | }); | |
| 309 | ||
| 310 | test("the terminal accepts under the page's rules: admin only, and what the screen showed", async () => { | |
| 311 | const { featuresScreen, entry } = await import("../src/tui/screens/features.ts"); | |
| 312 | const { Screen: Surface } = await import("../src/tui/term/screen.ts"); | |
| 313 | const { theme } = await import("../src/tui/ui/theme.ts"); | |
| 314 | const declare = (name: string) => | |
| 315 | commitToBranch(app, cy, repo, { message: name, files: [{ path: "forge.json", content: JSON.stringify({ app: { run: "x" }, feature: { ...FEATURE, name } }) }] }); | |
| 316 | await declare("Radar TUI"); | |
| 317 | const toasts: string[] = []; | |
| 318 | const ctx = { theme: theme("dark"), invalidate() {}, push() {}, pop() {}, copy() {}, toast: (m: string) => toasts.push(m) } as never; | |
| 319 | const open = async (user: typeof ada | typeof cy) => { | |
| 320 | const screen = featuresScreen({ app, db: app.db, user } as never, ctx); | |
| 321 | await screen.busy(); | |
| 322 | return screen; | |
| 323 | }; | |
| 324 | expect(entry.offered({ app, db: app.db, user: cy } as never)).toBe(false); | |
| 325 | expect(entry.offered({ app, db: app.db, user: ada } as never)).toBe(true); | |
| 326 | const shown = new Surface(110, 20); | |
| 327 | (await open(ada)).render({ x: 0, y: 0, w: 110, h: 20 }, shown); | |
| 328 | expect(shown.toText()).toContain("Radar TUI"); | |
| 329 | const denied = await open(cy); | |
| 330 | expect(denied.key!({ key: "a" } as never)).toBe(false); | |
| 331 | expect(absorbedForNav(app, ada)[0]!.name).toBe("Radar"); | |
| 332 | const screen = await open(ada); | |
| 333 | // The app declares something else after the screen was read. | |
| 334 | await declare("Radar Later"); | |
| 335 | expect(screen.key!({ key: "a" } as never)).toBe(true); | |
| 336 | await screen.busy(); | |
| 337 | expect(toasts.at(-1)).toContain("review it again"); | |
| 338 | expect(absorbedForNav(app, ada)[0]!.name).toBe("Radar"); | |
| 339 | const again = await open(ada); | |
| 340 | expect(again.key!({ key: "a" } as never)).toBe(true); | |
| 341 | await again.busy(); | |
| 342 | expect(absorbedForNav(app, ada)[0]!.name).toBe("Radar Later"); | |
| 343 | await commitToBranch(app, cy, repo, { message: "back", files: [{ path: "forge.json", content: FORGE_JSON }] }); | |
| 344 | await acceptDeclaration(app, ada, "radar", { name: "Radar", page: "/" }); | |
| 345 | }); | |
| 346 | ||
| 347 | test("every label in the forge's own menus is one a feature may not take", async () => { | |
| 348 | const { readFileSync } = await import("node:fs"); | |
| 349 | const src = readFileSync(new URL("../src/web/layout.ts", import.meta.url), "utf8"); | |
| 350 | const labels = [...src.matchAll(/\blabel: "([^"]+)"/g)].map((m) => m[1]!); | |
| 351 | expect(labels.length).toBeGreaterThan(15); | |
| 352 | for (const l of labels) expect({ l, refused: !parseFeature({ feature: { name: l } }).ok }).toEqual({ l, refused: true }); | |
| 353 | }); | |
| 354 | ||
| 355 | test("the menu groups feature entries under their own heading, and a built-in label is refused", async () => { | |
| 356 | const home = await (await web(adaCookie, "/settings")).text(); | |
| 357 | expect(home).toMatch(/menu-title">Features from apps<\/span>[\s\S]*Radar/); | |
| 358 | for (const name of ["Sign out", "admin", " SIGN-OUT ", "Settings"]) expect(parseFeature({ feature: { name } }).ok).toBe(false); | |
| 359 | expect(parseFeature({ feature: { name: "Settings" } }).ok).toBe(false); | |
| 360 | }); | |
| 361 | ||
| 269 | 362 | test("a value that does not fit changes nothing, and names the setting", async () => { |
| 270 | 363 | const res = await api("PATCH", adaToken, "/radar/settings", { settings: { runDays: ["mon"], runHourUtc: 30 } }); |
| 271 | 364 | expect(res.status).toBe(422); |
test/palette-roster.test.ts +1
| @@ -139,6 +139,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 139 | 139 | ["origins", "Connected origins", "the hosts your mirrors come from"], |
| 140 | 140 | ["domains", "Failure domains", "failure domains and their last drill"], |
| 141 | 141 | ["feed", "Feed", "what happened, ranked; r replies on a post about an issue or pull, l likes"], |
| 142 | ["feature-review", "Menu entries to review", "absorbed apps whose declared name or page changed: accept them"], | |
| 142 | 143 | ["front", "Front page", "the apps a first visitor sees, ranked; an admin pins or hides"], |
| 143 | 144 | ["owner", "Owner's needs", "what needs the owner, snapshots, failure domains"], |
| 144 | 145 | ["forges", "Forges you sent", "each forge sent, claimed or not, and its apps"], |
new above
offline
Dead parameter. Remove
_oand theproductionoption at the call sites.