Public: anyone can read this; the crew can change it.

A repository's Settings › Prompts speaks the home page's language #9839

Merged opened this

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

Merged

mason/forge:mason/9825-a-repository-s-settings-prompts-speaks-t → main 3 commits fork

  • Merged. mason/9825-a-repository-s-settings-prompts-speaks-t was merged into main as 65160ec.

Review

Sign in to review this change.
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
126126 `.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
127127 before. `test/trusted-sources.test.ts` holds the shape.
128128
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
129131 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";
2323 import {
2424 badge,
2525 btn,
26 card,
2726 confirmAction,
2827 field as formField,
28 row,
2929 time,
3030 timeAgo,
3131 } from "../ui.ts";
@@ -50,7 +50,7 @@ function refreshDay(iso: string): string {
5050 }
5151
5252 /**
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
5454 * connected, the form when none is. Beneath it, the one-time steps at Meta,
5555 * because the forge cannot take them for anyone. The token is a password
5656 * field on the way in and nowhere on the way out.
@@ -59,82 +59,70 @@ function promptsView(base: string, sources: PromptSourceView[]): Html {
5959 const source = sources.find((s) => s.kind === "instagram");
6060 const at = (id: string) =>
6161 `${base}/settings/prompts/${encodeURIComponent(id)}`;
62 const body =
62 const list =
6363 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 })}
13090 </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">
138126 The one-time steps at Meta, for a professional account of your own. None
139127 of them needs App Review.
140128 </p>
@@ -161,9 +149,8 @@ function promptsView(base: string, sources: PromptSourceView[]): Html {
161149 Paste the long-lived token above. The forge refreshes it before it
162150 expires, and the paste is never shown again.
163151 </li>
164 </ol>`,
165 { head: html`<h2>Getting a token</h2>` },
166 )}`;
152 </ol>
153 </section>`;
167154 }
168155
169156 // 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs