Public: anyone can read this; the crew can change it.
A repository's Settings › Prompts speaks the home page's language #9839
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9825-a-repository-s-settings-prompts-speaks-t → main 3 commits fork
3 files
changed +140
−79
docs/design/areas/settings.md +2
| @@ -126,4 +126,6 @@ title, each with a glyph (Trusted with a check, Ends soon with a clock, Lapsed w | ||
| 126 | 126 | `.row-fields` forms. With no entry, one plain sentence: no one is trusted, so every pull waits for review. The forms post to the same routes as |
| 127 | 127 | before. `test/trusted-sources.test.ts` holds the shape. |
| 128 | 128 | |
| 129 | Settings › Prompts (`promptsView()` in `src/web/pages/prompts.ts`, #9825) adds no class: two `<section class="settings-block">` blocks, Instagram (the connected account as a `row()` in a `.setting-list` with its Fetch now and Remove forms, or one plain sentence and the add form) and Getting a token. Pinned by `test/prompts-language.test.ts`. | |
| 130 | ||
| 129 | 131 | Settings › Hooks (`hooksPage()` in `src/web/pages/hooks.ts`, #9762) adds no component class and has no card or table: Webhooks and Add a webhook are each a `<section class="settings-block">` with an `<h2>`. Each hook is a `row()` in `<ul class="list setting-list">` (URL as title; events, state, added date and "unsigned" as meta; Send ping and a confirmed Delete in the trail), with its recent deliveries as nested child rows, Redeliver in the trail of a failed one. With no hook, one plain sentence. The secret is never drawn. Pinned by `test/hooks-rows.test.ts`. |
src/web/pages/prompts.ts +66 −79
| @@ -23,9 +23,9 @@ import { flashTo } from "../layout.ts"; | ||
| 23 | 23 | import { |
| 24 | 24 | badge, |
| 25 | 25 | btn, |
| 26 | card, | |
| 27 | 26 | confirmAction, |
| 28 | 27 | field as formField, |
| 28 | row, | |
| 29 | 29 | time, |
| 30 | 30 | timeAgo, |
| 31 | 31 | } from "../ui.ts"; |
| @@ -50,7 +50,7 @@ function refreshDay(iso: string): string { | ||
| 50 | 50 | } |
| 51 | 51 | |
| 52 | 52 | /** |
| 53 | * The Instagram card: the account and what the last run said when one is | |
| 53 | * The Instagram section: the account and what the last run said when one is | |
| 54 | 54 | * connected, the form when none is. Beneath it, the one-time steps at Meta, |
| 55 | 55 | * because the forge cannot take them for anyone. The token is a password |
| 56 | 56 | * field on the way in and nowhere on the way out. |
| @@ -59,82 +59,70 @@ function promptsView(base: string, sources: PromptSourceView[]): Html { | ||
| 59 | 59 | const source = sources.find((s) => s.kind === "instagram"); |
| 60 | 60 | const at = (id: string) => |
| 61 | 61 | `${base}/settings/prompts/${encodeURIComponent(id)}`; |
| 62 | const body = | |
| 62 | const list = | |
| 63 | 63 | source === undefined |
| 64 | ? html`<div class="card-body"> | |
| 65 | <p class="muted small"> | |
| 66 | Once a day, the top comment on your latest Reel becomes the crew's | |
| 67 | next tell. | |
| 68 | </p> | |
| 69 | </div> | |
| 70 | <form | |
| 71 | class="card-body" | |
| 72 | method="post" | |
| 73 | action="${base}/settings/prompts" | |
| 74 | > | |
| 75 | ${formField({ | |
| 76 | label: "Token", | |
| 77 | name: "token", | |
| 78 | type: "password", | |
| 79 | hint: "A long-lived Instagram token. The forge seals it, refreshes it before it expires, and never shows it again.", | |
| 80 | required: true, | |
| 81 | autocomplete: "off", | |
| 82 | mono: true, | |
| 83 | })} | |
| 84 | ${formField({ | |
| 85 | label: "Every day at … UTC", | |
| 86 | name: "at", | |
| 87 | type: "time", | |
| 88 | value: "15:00", | |
| 89 | required: true, | |
| 90 | })} | |
| 91 | <div class="form-actions"> | |
| 92 | ${btn("Add", { variant: "primary", icon: "plus" })} | |
| 93 | </div> | |
| 94 | </form>` | |
| 95 | : html`<ul class="list"> | |
| 96 | <li> | |
| 97 | <div class="row"> | |
| 98 | <span class="row-lead">${icon("comment")}</span> | |
| 99 | <span class="row-main"> | |
| 100 | <span class="row-title" | |
| 101 | >${source.account === "" | |
| 102 | ? html`<span class="muted">not read yet</span>` | |
| 103 | : `@${source.account}`} | |
| 104 | ${badge(`every day at ${source.atUtc} UTC`)}</span | |
| 105 | > | |
| 106 | <span class="row-meta" | |
| 107 | >${source.lastRanAt === null | |
| 108 | ? "Has not run yet." | |
| 109 | : html`Last ran ${time(source.lastRanAt)}. | |
| 110 | ${source.lastSaid}`}</span | |
| 111 | > | |
| 112 | <span class="row-meta" | |
| 113 | >Token refreshed ${timeAgo(source.tokenRefreshedAt)}; the | |
| 114 | forge refreshes it again around | |
| 115 | ${refreshDay(source.tokenRefreshedAt)}.</span | |
| 116 | > | |
| 117 | </span> | |
| 118 | <span class="row-trail"> | |
| 119 | <form method="post" action="${at(source.id)}/run"> | |
| 120 | ${btn("Fetch now", { size: "sm", icon: "sync" })} | |
| 121 | </form> | |
| 122 | ${confirmAction({ | |
| 123 | action: `${at(source.id)}/delete`, | |
| 124 | verb: "Remove", | |
| 125 | size: "sm", | |
| 126 | })} | |
| 127 | </span> | |
| 128 | </div> | |
| 129 | </li> | |
| 64 | ? html`<p class="muted small"> | |
| 65 | Once a day, the top comment on your latest Reel becomes the crew's | |
| 66 | next tell. No account is connected yet. | |
| 67 | </p>` | |
| 68 | : html`<ul class="list setting-list"> | |
| 69 | ${row({ | |
| 70 | id: `prompt-${source.id}`, | |
| 71 | lead: icon("comment"), | |
| 72 | title: html`${source.account === "" | |
| 73 | ? html`<span class="muted">not read yet</span>` | |
| 74 | : `@${source.account}`} | |
| 75 | ${badge(`every day at ${source.atUtc} UTC`)}`, | |
| 76 | meta: html`${source.lastRanAt === null | |
| 77 | ? "Has not run yet." | |
| 78 | : html`Last ran ${time(source.lastRanAt)}. ${source.lastSaid}`} | |
| 79 | Token refreshed ${timeAgo(source.tokenRefreshedAt)}; the forge | |
| 80 | refreshes it again around ${refreshDay(source.tokenRefreshedAt)}.`, | |
| 81 | trail: html`<form method="post" action="${at(source.id)}/run"> | |
| 82 | ${btn("Fetch now", { size: "sm", icon: "sync" })} | |
| 83 | </form> | |
| 84 | ${confirmAction({ | |
| 85 | action: `${at(source.id)}/delete`, | |
| 86 | verb: "Remove", | |
| 87 | size: "sm", | |
| 88 | })}`, | |
| 89 | })} | |
| 130 | 90 | </ul>`; |
| 131 | return html`${card(body, { | |
| 132 | head: html`${icon("comment")} | |
| 133 | <h2>Instagram</h2>`, | |
| 134 | pad: false, | |
| 135 | })} | |
| 136 | ${card( | |
| 137 | html`<p class="muted small"> | |
| 91 | const add = | |
| 92 | source !== undefined | |
| 93 | ? null | |
| 94 | : html`<form | |
| 95 | class="mt" | |
| 96 | method="post" | |
| 97 | action="${base}/settings/prompts" | |
| 98 | > | |
| 99 | ${formField({ | |
| 100 | label: "Token", | |
| 101 | name: "token", | |
| 102 | type: "password", | |
| 103 | hint: "A long-lived Instagram token. The forge seals it, refreshes it before it expires, and never shows it again.", | |
| 104 | required: true, | |
| 105 | autocomplete: "off", | |
| 106 | mono: true, | |
| 107 | })} | |
| 108 | ${formField({ | |
| 109 | label: "Every day at … UTC", | |
| 110 | name: "at", | |
| 111 | type: "time", | |
| 112 | value: "15:00", | |
| 113 | required: true, | |
| 114 | })} | |
| 115 | <div class="form-actions"> | |
| 116 | ${btn("Add", { variant: "primary", icon: "plus" })} | |
| 117 | </div> | |
| 118 | </form>`; | |
| 119 | return html`<section class="settings-block"> | |
| 120 | <h2>Instagram</h2> | |
| 121 | ${list}${add} | |
| 122 | </section> | |
| 123 | <section class="settings-block"> | |
| 124 | <h2>Getting a token</h2> | |
| 125 | <p class="muted small"> | |
| 138 | 126 | The one-time steps at Meta, for a professional account of your own. None |
| 139 | 127 | of them needs App Review. |
| 140 | 128 | </p> |
| @@ -161,9 +149,8 @@ function promptsView(base: string, sources: PromptSourceView[]): Html { | ||
| 161 | 149 | Paste the long-lived token above. The forge refreshes it before it |
| 162 | 150 | expires, and the paste is never shown again. |
| 163 | 151 | </li> |
| 164 | </ol>`, | |
| 165 | { head: html`<h2>Getting a token</h2>` }, | |
| 166 | )}`; | |
| 152 | </ol> | |
| 153 | </section>`; | |
| 167 | 154 | } |
| 168 | 155 | |
| 169 | 156 | // GET /:owner/:name/settings/prompts |
test/prompts-language.test.ts added +72
| @@ -0,0 +1,72 @@ | ||
| 1 | /** | |
| 2 | * Settings › Prompts speaks the home page's language: one heading, the source | |
| 3 | * as a plain row, the add form on the page, no boxed cards. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { createApp, type App } from "../src/app.ts"; | |
| 10 | import { addPromptSource } from "../src/core/prompts.ts"; | |
| 11 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 12 | import { adminCookie } from "./helpers/admin.ts"; | |
| 13 | ||
| 14 | describe("settings prompts language", () => { | |
| 15 | let dataDir: string; | |
| 16 | let app: App; | |
| 17 | let servers: Servers; | |
| 18 | let cookie = ""; | |
| 19 | let repoId = ""; | |
| 20 | const PAGE = "/olive/proj/settings/prompts"; | |
| 21 | ||
| 22 | beforeAll(async () => { | |
| 23 | dataDir = await mkdtemp(join(tmpdir(), "forge-prompts-language-")); | |
| 24 | app = createApp({ dataDir, port: 0 }); | |
| 25 | servers = await serve(app); | |
| 26 | cookie = await adminCookie(app, { username: "olive", email: "[email protected]", password: "prompts-language-password" }); | |
| 27 | await fetch(`${servers.url}/new`, { | |
| 28 | method: "POST", | |
| 29 | headers: { cookie }, | |
| 30 | body: new URLSearchParams({ name: "proj", visibility: "private", description: "" }), | |
| 31 | redirect: "manual", | |
| 32 | }); | |
| 33 | repoId = app.db.query<{ id: string }, []>("SELECT id FROM repo").get()!.id; | |
| 34 | }); | |
| 35 | ||
| 36 | afterAll(async () => { | |
| 37 | servers.stop(); | |
| 38 | app.close(); | |
| 39 | await rm(dataDir, { recursive: true, force: true }); | |
| 40 | }); | |
| 41 | ||
| 42 | const main = async () => { | |
| 43 | const text = await (await fetch(`${servers.url}${PAGE}`, { headers: { cookie } })).text(); | |
| 44 | const start = text.indexOf("<main"); | |
| 45 | return text.slice(start, text.indexOf("</main>", start)); | |
| 46 | }; | |
| 47 | ||
| 48 | function plain(m: string) { | |
| 49 | expect(m.match(/<h1[ >]/g)?.length).toBe(1); | |
| 50 | expect(m).not.toContain('class="card'); | |
| 51 | expect(m).not.toContain("card-body"); | |
| 52 | } | |
| 53 | ||
| 54 | test("with no source: the sentence and the add form", async () => { | |
| 55 | const m = await main(); | |
| 56 | plain(m); | |
| 57 | expect(m).toContain(`action="${PAGE}"`); | |
| 58 | expect(m).toContain('name="token"'); | |
| 59 | expect(m).not.toContain("Fetch now"); | |
| 60 | }); | |
| 61 | ||
| 62 | test("with a source: its row, run and delete, no add form", async () => { | |
| 63 | const added = addPromptSource(app.db, dataDir, { repoId, kind: "instagram", token: "IGAAQtoken-for-language-test", atUtc: "15:00" }); | |
| 64 | if (!added.ok) throw new Error(added.error); | |
| 65 | const id = added.value.id; | |
| 66 | const m = await main(); | |
| 67 | plain(m); | |
| 68 | expect(m).toContain(`action="${PAGE}/${id}/run"`); | |
| 69 | expect(m).toContain(encodeURIComponent(`${PAGE}/${id}/delete`)); | |
| 70 | expect(m).not.toContain(`action="${PAGE}"`); | |
| 71 | }); | |
| 72 | }); |