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

small: a repository's Settings and App settings open each section on its control, with the explanations behind one `?` #11276

Merged opened this

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

Merged

mason/forge:mason/11210-small-a-repository-s-settings-and-app-se → main 4 commits fork

  • Merged. mason/11210-small-a-repository-s-settings-and-app-se was merged into main as 1dc9a6b.

Review

Sign in to review this change.
3 files changed +120 −54
src/web/pages/settings.ts +35 −53
@@ -97,6 +97,7 @@ import { NOT_CHANGED } from "../edit_words.ts";
9797 import { STOP_APP } from "../danger_words.ts";
9898 import { icon } from "../icons.ts";
9999 import { flashTo, page, redirect } from "../layout.ts";
100 import { helpAsked, pageHelp } from "../page_help.ts";
100101 import type { Flash } from "../flash.ts";
101102 import {
102103 badge,
@@ -330,12 +331,6 @@ export async function settingsPage(
330331 const wave = html`<section class="settings-block">
331332 <h2>Cut a wave</h2>
332333 <form method="post" action="${base}/settings/wave">
333 <p class="muted">
334 Opens a pull that adds the next <code>waves/&lt;version&gt;.md</code>,
335 listing every pull merged since the last wave. The headline and
336 highlights are left as TODO for you to write.
337 <a href="${base}/waves">See the waves so far</a>.
338 </p>
339334 <div class="form-actions">${btn("Cut a wave", { variant: "primary" })}</div>
340335 </form>
341336 </section>`;
@@ -345,11 +340,7 @@ export async function settingsPage(
345340 <h2>Archive</h2>
346341 <form method="post" action="${base}/settings/archive">
347342 <input type="hidden" name="archived" value="${repo.archivedAt === null ? "1" : "0"}" />
348 <p class="muted">
349 ${repo.archivedAt === null
350 ? "Put a finished repository away without deleting it. Its page says when it was archived; links, clone and push keep working."
351 : html`Archived on ${repo.archivedAt.slice(0, 10)}. Restore it to take the label off.`}
352 </p>
343 ${repo.archivedAt === null ? null : html`<p class="muted small">Archived on ${repo.archivedAt.slice(0, 10)}.</p>`}
353344 <div class="form-actions">
354345 ${btn(repo.archivedAt === null ? "Archive" : "Restore", {
355346 variant: "primary",
@@ -359,12 +350,9 @@ export async function settingsPage(
359350 </section>`;
360351 const move = html`<section class="settings-block" id="move">
361352 <h2>Move to another owner</h2>
353 <p class="muted small">Hands the repository to someone else.</p>
362354 <form method="get" action="/-/confirm">
363355 <input type="hidden" name="to" value="${base}/settings/transfer" />
364 <p class="muted">
365 Give the repository to an account or organisation you can create
366 repositories under.
367 </p>
368356 ${formField({
369357 label: "New owner",
370358 name: "owner",
@@ -388,9 +376,7 @@ export async function settingsPage(
388376 : html`<section class="settings-block">
389377 <h2>Mirror</h2>
390378 <p class="muted" data-mirror-line>${mirrorLine(held)}</p>
391 <p class="muted small">
392 Stopping leaves ${held.fullName} as it is; nothing more is pushed.
393 </p>
379 <p class="muted small">Stopping leaves ${held.fullName} as it is; nothing more is pushed.</p>
394380 <div class="form-actions">
395381 ${confirmAction({
396382 action: `${base}/settings/mirror/stop`,
@@ -398,12 +384,22 @@ export async function settingsPage(
398384 })}
399385 </div>
400386 </section>`;
387 const trailHelp = getProvenance(ctx.app.db, repo.id);
388 const help = pageHelp(
389 [
390 ["Cut a wave", html`Opens a pull that adds the next <code>waves/&lt;version&gt;.md</code>, listing every pull merged since the last wave. The headline and highlights are left as TODO for you to write. <a href="${base}/waves">See the waves so far</a>.`],
391 ["Archive", "Puts a finished repository away without deleting it. Its page says when it was archived; links, clone and push keep working. Restore takes the label off."],
392 ["Move to another owner", "Gives the repository to an account or organisation you can create repositories under. You are asked to confirm before it moves."],
393 ...(trailHelp === undefined ? [] : ([["Where this came from", "Where this repository was migrated from. Shown, it appears under the repository's name and in the API. Hidden, nobody else sees it, but the record is kept, so you can show it again."]] as const)),
394 ],
395 helpAsked(ctx.request.url),
396 );
401397 const response = settingsPageResponse(
402398 ctx,
403399 repo,
404400 "general",
405401 "Settings",
406 html`${lead}${mirror ? html`<div class="mt">${mirror}</div>` : null}<div class="mt">${body}</div>${trail
402 html`${help}${lead}${mirror ? html`<div class="mt">${mirror}</div>` : null}<div class="mt">${body}</div>${trail
407403 ? html`<div class="mt">${trail}</div>`
408404 : null}${upstream ? html`<div class="mt">${upstream}</div>` : null}${sources ? html`<div class="mt">${sources}</div>` : null}<div class="mt">${wave}</div><div class="mt">${archive}</div><div class="mt">${move}</div>${proposals
409405 ? html`<div class="mt">${proposals}</div>`
@@ -563,7 +559,13 @@ function provenanceCard(ctx: Ctx, repo: Repo): Html | null {
563559 <h2>Show where this came from</h2>
564560 <form method="post" action="${base}/settings/provenance">
565561 <input type="hidden" name="shown" value="${trail.shown ? "0" : "1"}" />
566 <p class="muted">
562 <p class="muted small">${trail.shown ? "Shown under the repository's name." : "Hidden; the record is kept."}</p>
563 <div class="form-actions">
564 ${btn(trail.shown ? "Hide where this came from" : "Show where this came from", {
565 variant: "outline",
566 })}
567 </div>
568 <p class="muted small mt">
567569 Migrated from
568570 <a href="${trail.sourceUrl}" rel="noopener noreferrer"
569571 >${sourceLabel(trail.sourceUrl)}</a
@@ -575,15 +577,7 @@ function provenanceCard(ctx: Ctx, repo: Repo): Html | null {
575577 >`,
576578 )}
577579 on ${trail.migratedAt.slice(0, 10)}.
578 ${trail.shown
579 ? "Shown under the repository's name on every page and in the API. Hiding it keeps the record, so it can come back."
580 : "Hidden: nobody else sees it, on a page or in the API. The record is kept."}
581580 </p>
582 <div class="form-actions">
583 ${btn(trail.shown ? "Hide where this came from" : "Show where this came from", {
584 variant: "outline",
585 })}
586 </div>
587581 </form>
588582 </section>`;
589583 }
@@ -766,7 +760,16 @@ function appSettingsView(
766760 const vitals = timePhase(ctx.request, "vitals", () =>
767761 appVitals(ctx.app.db, record.id),
768762 );
769 return html`${form}
763 const help = pageHelp(
764 [
765 ["Visitors and speed", "Counted by the beacon, with no address kept; the 7 day figure adds the days up."],
766 ["Environment", "What the app reads at start-up; values are write-only, and one set for production only or previews only wins there over one set everywhere."],
767 ["Who may enter", "Named people may open the app and its previews, and nothing else: not the code, the crew, the settings or the terminal. Removing a name shuts them out at once. What the app itself shows them is the app's to decide: it runs with your read-only token, so anything it shows visitors from the forge, they see too."],
768 ["Domains", html`Serve the app at a name of your own — <span class="mono">app.example.com</span>, or an apex. Add it, publish the two records it shows, and press Check now. The forge serves the app there as soon as the record is verified; the app itself learns its new address at its next deploy.`],
769 ],
770 helpAsked(ctx.request.url),
771 );
772 return html`${help}${form}
770773 ${when(listed?.ok === true, () => html`<div class="mt">${appMembersView(base, values.access, named)}</div>`)}
771774 <div class="mt">${appVisitorsView(vitals)}</div>
772775 <div class="mt">${statement}</div>
@@ -811,10 +814,7 @@ export function appVisitorsView(v: AppVitals): Html {
811814 );
812815 return html`<section class="settings-block" id="visitors">
813816 <h2>Visitors and speed</h2>
814 <p class="muted small mb">
815 Counted by the beacon, with no address kept; the 7 day figure adds
816 the days up. <a href="app/traffic">Where visitors come from</a>
817 </p>
817 <p class="small mb"><a href="app/traffic">Where visitors come from</a></p>
818818 <dl class="stats">
819819 <div><dt>Visitors today</dt><dd>${v.today}</dd></div>
820820 <div><dt>Visitors, 7 days</dt><dd>${v.week}</dd></div>
@@ -856,10 +856,6 @@ export function appEnvView(base: string, names: AppEnvName[]): Html {
856856 );
857857 return html`<section class="settings-block" id="environment">
858858 <h2>Environment</h2>
859 <p class="muted small">
860 What the app reads at start-up; values are write-only, and one set
861 for production only or previews only wins there over one set everywhere.
862 </p>
863859 ${when(
864860 rows.length > 0,
865861 () =>
@@ -933,11 +929,7 @@ function appMembersView(
933929 >
934930 ${named.join("\n")}</textarea
935931 >
936 <p class="hint">
937 ${access === "private"
938 ? "They may open the app and its previews, and nothing else: not the code, the crew, the settings or the terminal. Removing a name shuts them out at once. What the app itself shows them is the app's to decide: it runs with your read-only token, so anything it shows visitors from the forge, they see too."
939 : "The app is public, so anyone may open it now; these names matter once it is private."}
940 </p>
932 ${access === "private" ? null : html`<p class="hint">The app is public, so anyone may open it now; these names matter once it is private.</p>`}
941933 </div>
942934 <div class="form-actions">${btn("Save people", { variant: "primary" })}</div>
943935 </form>
@@ -984,11 +976,6 @@ function appDomainsView(
984976 );
985977 return html`<section class="settings-block" id="domains">
986978 <h2>${icon("globe")} Domains</h2>
987 <p class="muted small mb">
988 Serve the app at a name of your own —
989 <span class="mono">app.example.com</span>, or an apex. Add it, publish
990 the two records it shows, and press Check now.
991 </p>
992979 ${when(
993980 rows.length > 0,
994981 () =>
@@ -1001,12 +988,7 @@ function appDomainsView(
1001988 Nothing is serving apps on this forge, so a domain would have
1002989 nowhere to point.
1003990 </p>`
1004 : html`<p class="muted small mb">
1005 The forge serves the app there as soon as the record is
1006 verified. The app itself learns its new address at its next
1007 deploy.
1008 </p>
1009 <form
991 : html`<form
1010992 class="edit-row"
1011993 method="post"
1012994 action="${base}/settings/app/domains"
test/migrate.test.ts +2 −1
@@ -663,7 +663,8 @@ describe("migrate", () => {
663663 shown: false,
664664 });
665665 const settings = await (await browser(aliceCookie, "/alice/hush/settings")).text();
666 expect(settings).toContain("Hidden: nobody else sees it");
666 expect(settings).toContain("Hidden; the record is kept.");
667 expect(settings.indexOf("nobody else sees it")).toBeGreaterThan(settings.indexOf("data-page-help"));
667668 expect(settings).not.toContain("Hide where this came from");
668669 expect(settings).toMatch(/<button[^>]*>\s*Show where this came from/);
669670
test/settings-help.test.ts added +83
@@ -0,0 +1,83 @@
1 /** A repository's Settings and App settings open each section on its control; the explanations sit behind one `?` (#11210). */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { createRepo } from "../src/actions/repos.ts";
8 import { createApp as insertApp } from "../src/core/apps.ts";
9 import { setMirrorTarget, deleteMirrorTarget } from "../src/core/migrate_mirror.ts";
10 import { serve, type Servers } from "../src/http/server.ts";
11 import { seedAdmin } from "./helpers/admin.ts";
12
13 let dataDir = "";
14 let app: App, servers: Servers, bearer = "", repoId = "";
15
16 beforeAll(async () => {
17 dataDir = await mkdtemp(join(tmpdir(), "forge-sethelp-"));
18 app = createApp({ dataDir, port: 0 });
19 servers = await serve(app);
20 const admin = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "correct-horse-battery" });
21 if (!admin.ok) throw new Error(admin.error);
22 const made = await createRepo(app, admin.value, { name: "notes", visibility: "public", description: "" });
23 if (!made.ok) throw new Error(String(made.error));
24 repoId = made.value.id;
25 const row = insertApp(app.db, { repoId: made.value.id, slug: "notes", access: "public" });
26 if (!row.ok) throw new Error(row.error);
27 const minted = await fetch(`${servers.url}/api/v1/tokens`, {
28 method: "POST",
29 headers: { "content-type": "application/json" },
30 body: JSON.stringify({ adminConfirm: true, username: "alice", password: "correct-horse-battery", name: "cli", scope: "admin" }),
31 });
32 bearer = ((await minted.json()) as { token: string }).token;
33 });
34
35 afterAll(async () => {
36 servers.stop();
37 app.close();
38 await rm(dataDir, { recursive: true, force: true });
39 });
40
41 const get = async (path: string) => {
42 const res = await fetch(`${servers.url}${path}`, { headers: { authorization: `Bearer ${bearer}` } });
43 expect(res.status).toBe(200);
44 return res.text();
45 };
46
47 test("Settings keeps its explanations in the one help, open on ?help", async () => {
48 const closed = await get("/alice/notes/settings");
49 expect(closed.match(/data-page-help/g)?.length).toBe(1);
50 expect(closed).not.toContain("data-page-help open");
51 expect(closed).toContain("Opens a pull that adds the next");
52 expect(closed.indexOf("Opens a pull that adds the next")).toBeGreaterThan(closed.indexOf("data-page-help"));
53 expect(closed).not.toContain('<p class="muted">\n Opens a pull');
54 expect((await get("/alice/notes/settings?help")).includes("data-page-help open")).toBe(true);
55 });
56
57 test("App settings keeps traffic, environment, access and domains words in the one help", async () => {
58 const page = await get("/alice/notes/settings/app");
59 expect(page.match(/data-page-help/g)?.length).toBe(1);
60 const help = page.slice(page.indexOf("data-page-help"), page.indexOf("</details>", page.indexOf("data-page-help")));
61 for (const w of ["Counted by the beacon", "write-only", "read-only token", "publish the two records"]) {
62 expect(help).toContain(w);
63 expect(page.split(w).length - 1).toBe(1);
64 }
65 expect(await get("/alice/notes/settings/app?help")).toContain("data-page-help open");
66 });
67
68 test("the mirror warning sits once, beside Stop mirroring; a repository with no mirror or trail has neither entry", async () => {
69 const bare = await get("/alice/notes/settings?help");
70 expect(bare).not.toContain("Stopping leaves");
71 expect(bare).not.toContain("Where this came from");
72 expect(bare).not.toContain("Stop mirroring");
73 const repo = { id: repoId };
74 app.db.run("PRAGMA foreign_keys = OFF");
75 setMirrorTarget(app.db, { repoId: repo.id, originId: "o1", fullName: "octo/notes", cloneUrl: "https://code.example/octo/notes.git", tips: {} });
76 try {
77 const page = await get("/alice/notes/settings");
78 expect(page.split("Stopping leaves octo/notes as it is").length - 1).toBe(1);
79 expect(page.indexOf("Stopping leaves")).toBeLessThan(page.indexOf("Stop mirroring"));
80 } finally {
81 deleteMirrorTarget(app.db, repo.id);
82 }
83 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs