Public: anyone can read this; the crew can change it.
An app the owner no longer cares about is archived in one tap and restored in one, starting from the parked archive branch #10885
mason/forge:mason/10872-an-app-the-owner-no-longer-cares-about-i → main 1 commit fork
6 files
changed +47
−3
src/http/server.ts +2
| @@ -393,6 +393,7 @@ import { | ||
| 393 | 393 | settingsUpdate, |
| 394 | 394 | waveCut, |
| 395 | 395 | archiveSubmit, |
| 396 | archiveConfirmed, | |
| 396 | 397 | transferSubmit, |
| 397 | 398 | provenanceSubmit, |
| 398 | 399 | mirrorStop, |
| @@ -1472,6 +1473,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1472 | 1473 | router.add("POST", "/:owner/:name/settings", settingsUpdate); |
| 1473 | 1474 | router.add("POST", "/:owner/:name/settings/wave", waveCut); |
| 1474 | 1475 | router.add("POST", "/:owner/:name/settings/archive", archiveSubmit); |
| 1476 | router.add("POST", "/:owner/:name/archive", archiveConfirmed); | |
| 1475 | 1477 | router.add("POST", "/:owner/:name/settings/transfer", transferSubmit); |
| 1476 | 1478 | router.add("POST", "/:owner/:name/settings/provenance", provenanceSubmit); |
| 1477 | 1479 | router.add("POST", "/:owner/:name/settings/mirror/stop", mirrorStop); |
src/web/pages/apps.ts +1 −1
| @@ -158,7 +158,7 @@ function wakeForms(ctx: Ctx, repos: readonly Repo[]): Map<string, Html> { | ||
| 158 | 158 | <form method="post" action="${repoBase(repo)}/settings/archive"> |
| 159 | 159 | <input type="hidden" name="archived" value="0" /> |
| 160 | 160 | <input type="hidden" name="to" value="/apps?archived=1" /> |
| 161 | ${btn(html`Wake<span class="sr-only"> ${repo.ownerName}/${repo.name}</span>`)} | |
| 161 | ${btn(html`Restore<span class="sr-only"> ${repo.ownerName}/${repo.name}</span>`)} | |
| 162 | 162 | </form> |
| 163 | 163 | </li>`, |
| 164 | 164 | ); |
src/web/pages/search.ts +10
| @@ -564,6 +564,16 @@ const DESTRUCTIVE: { | ||
| 564 | 564 | pattern: RegExp; |
| 565 | 565 | describe: (m: RegExpMatchArray, ctx: Ctx) => Destructive; |
| 566 | 566 | }[] = [ |
| 567 | { | |
| 568 | pattern: /^\/([^/]+)\/([^/]+)\/archive$/, | |
| 569 | describe: (m) => ({ | |
| 570 | verb: "Archive", | |
| 571 | title: `Archive ${m[1]}/${m[2]}?`, | |
| 572 | detail: | |
| 573 | "Its process stops and it leaves home and the apps list. Its data and git stay, its address still answers, and Restore on the Archived list starts it again.", | |
| 574 | phrase: "", | |
| 575 | }), | |
| 576 | }, | |
| 567 | 577 | { |
| 568 | 578 | pattern: /^\/([^/]+)\/([^/]+)\/settings\/delete$/, |
| 569 | 579 | describe: (m) => ({ |
src/web/pages/settings.ts +13 −1
| @@ -549,10 +549,22 @@ function samePath(ctx: Ctx, to: string | undefined): string | undefined { | ||
| 549 | 549 | * for travels in the form, so a double submit is idempotent. |
| 550 | 550 | */ |
| 551 | 551 | export async function archiveSubmit(ctx: Ctx): Promise<Response> { |
| 552 | return archiveWith(ctx, undefined); | |
| 553 | } | |
| 554 | ||
| 555 | /** | |
| 556 | * POST /:owner/:name/archive — the one-tap Archive, reached only through | |
| 557 | * `/-/confirm`: the form there carries no field, so the answer is fixed here. | |
| 558 | */ | |
| 559 | export async function archiveConfirmed(ctx: Ctx): Promise<Response> { | |
| 560 | return archiveWith(ctx, "1"); | |
| 561 | } | |
| 562 | ||
| 563 | async function archiveWith(ctx: Ctx, fixed: "1" | undefined): Promise<Response> { | |
| 552 | 564 | const gate = await adminForm(ctx); |
| 553 | 565 | if (gate instanceof Response) return gate; |
| 554 | 566 | const { repo, form } = gate; |
| 555 | const asked = field(form, "archived"); | |
| 567 | const asked = fixed ?? field(form, "archived"); | |
| 556 | 568 | if (asked !== "0" && asked !== "1") |
| 557 | 569 | return flashTo(ctx, `${repoBase(repo)}/settings`, { |
| 558 | 570 | tone: "danger", |
src/web/studio/app.ts +2
| @@ -47,6 +47,7 @@ import { | ||
| 47 | 47 | badge, |
| 48 | 48 | btn, |
| 49 | 49 | card, |
| 50 | confirmAction, | |
| 50 | 51 | envPhrase, |
| 51 | 52 | icon, |
| 52 | 53 | listCard, |
| @@ -454,6 +455,7 @@ export async function studioApp( | ||
| 454 | 455 | const more = html`<p class="muted small"> |
| 455 | 456 | <a href="${base}/variants">Variants</a> |
| 456 | 457 | ${when(canAdmin(db, repo, ctx.auth.user), () => html`<a href="${base}/settings/app">Environment settings</a>`)} |
| 458 | ${when(canAdmin(db, repo, ctx.auth.user), () => confirmAction({ action: `${base}/archive`, verb: "Archive", size: "sm" }))} | |
| 457 | 459 | </p>`; |
| 458 | 460 | |
| 459 | 461 | // An app that follows another says so before anything else: whether it |
test/apps-archived.test.ts +19 −1
| @@ -139,7 +139,7 @@ test("the archived list wakes an app in place, and ignores a return address off | ||
| 139 | 139 | expect(list).toContain('action="/alice/shelved/settings/archive"'); |
| 140 | 140 | expect(list).toContain('name="to" value="/apps?archived=1"'); |
| 141 | 141 | // Each button names its app, so a screen reader can tell the Wakes apart. |
| 142 | expect(list).toMatch(/Wake<span class="sr-only"> alice\/shelved<\/span>/); | |
| 142 | expect(list).toMatch(/Restore<span class="sr-only"> alice\/shelved<\/span>/); | |
| 143 | 143 | const post = (to: string, headers: Record<string, string> = { origin: servers.url }) => |
| 144 | 144 | fetch(`${servers.url}/alice/shelved/settings/archive`, { |
| 145 | 145 | method: "POST", |
| @@ -176,3 +176,21 @@ test("a viewer who does not administer an archived app gets no Wake button", asy | ||
| 176 | 176 | expect(anon).not.toContain("settings/archive"); |
| 177 | 177 | expect((await get("/apps?archived=1")).body).toContain("/alice/openshelf/settings/archive"); |
| 178 | 178 | }); |
| 179 | ||
| 180 | test("one tap: the app's page offers Archive through the confirm page, which archives and Restore undoes", async () => { | |
| 181 | const page = await get("/alice/keeper"); | |
| 182 | expect(page.body).toContain("/-/confirm?to=%2Falice%2Fkeeper%2Farchive"); | |
| 183 | const confirm = await get("/-/confirm?to=%2Falice%2Fkeeper%2Farchive"); | |
| 184 | expect(confirm.status).toBe(200); | |
| 185 | expect(confirm.body).toContain('action="/alice/keeper/archive"'); | |
| 186 | const post = (headers: Record<string, string>) => | |
| 187 | fetch(`${servers.url}/alice/keeper/archive`, { method: "POST", headers: { cookie, ...headers }, redirect: "manual" }); | |
| 188 | expect((await post({ origin: "https://evil.example" })).status).toBeGreaterThanOrEqual(400); | |
| 189 | expect(getRepo(app.db, "alice", "keeper")!.archivedAt).toBeNull(); | |
| 190 | expect((await post({ origin: servers.url })).status).toBe(303); | |
| 191 | expect(getRepo(app.db, "alice", "keeper")!.archivedAt).not.toBeNull(); | |
| 192 | expect((await get("/apps")).body).not.toContain(ROW_KEEPER); | |
| 193 | expect((await get("/apps?archived=1")).body).toContain(ROW_KEEPER); | |
| 194 | expect((await get("/alice/keeper")).status).toBe(200); | |
| 195 | await updateRepoSettings(app, alice, getRepo(app.db, "alice", "keeper")!, { archived: false }); | |
| 196 | }); |
new above
offline