Public: anyone can read this; the crew can change it.
Archive an app from its page or the apps list: spun down, hidden, restorable in one tap #10844
mason/forge:mason/10779-archive-an-app-from-its-page-or-the-apps → main 6 commits fork
8 files
changed +142
−46
docs/design/attention-budget.json +8 −6
| @@ -14,26 +14,28 @@ | ||
| 14 | 14 | "repository": { |
| 15 | 15 | "390": { |
| 16 | 16 | "words": 117, |
| 17 | "blocks": 18, | |
| 17 | "blocks": 19, | |
| 18 | 18 | "colours": 2 |
| 19 | 19 | }, |
| 20 | 20 | "1280": { |
| 21 | 21 | "words": 117, |
| 22 | "blocks": 18, | |
| 22 | "blocks": 19, | |
| 23 | 23 | "colours": 2 |
| 24 | } | |
| 24 | }, | |
| 25 | "note": "19 blocks: the closed Archive disclosure's summary (#10779) is the one added line." | |
| 25 | 26 | }, |
| 26 | 27 | "app": { |
| 27 | 28 | "390": { |
| 28 | 29 | "words": 117, |
| 29 | "blocks": 18, | |
| 30 | "blocks": 19, | |
| 30 | 31 | "colours": 2 |
| 31 | 32 | }, |
| 32 | 33 | "1280": { |
| 33 | 34 | "words": 117, |
| 34 | "blocks": 18, | |
| 35 | "blocks": 19, | |
| 35 | 36 | "colours": 2 |
| 36 | } | |
| 37 | }, | |
| 38 | "note": "19 blocks: the closed Archive disclosure's summary (#10779) is the one added line." | |
| 37 | 39 | }, |
| 38 | 40 | "pull": { |
| 39 | 41 | "390": { |
docs/features/archive-app.md added +16
| @@ -0,0 +1,16 @@ | ||
| 1 | # Archive an app | |
| 2 | ||
| 3 | An admin can archive an app from its page or from the apps list, and restore it from the list. Code: `src/web/pages/archive_forms.ts` (the forms), `src/web/pages/apps.ts` (list and Archived filter), `src/web/pages/repo.ts` (app page). | |
| 4 | ||
| 5 | ## Archive | |
| 6 | ||
| 7 | 1. Open `/apps` (or the app's page) as an admin. | |
| 8 | 2. Tap **Archive** on the row. A confirm step follows; nothing changes until you tap **Confirm archive**. | |
| 9 | 3. The app leaves the default list. | |
| 10 | ||
| 11 | ## Restore | |
| 12 | ||
| 13 | 1. On `/apps`, open the **Archived** filter. | |
| 14 | 2. Tap **Restore** on the row. | |
| 15 | ||
| 16 | Every button is a same-origin form post; a non-admin sees no button and the post is refused. |
src/web/pages/apps.ts +34 −22
| @@ -74,6 +74,7 @@ import { flashTo, page, redirect } from "../layout.ts"; | ||
| 74 | 74 | import { writableAppsOf } from "../studio/asks.ts"; |
| 75 | 75 | import { costLine } from "../studio/cost_line.ts"; |
| 76 | 76 | import { homeTabs, unshippedAsks } from "../studio/home_tabs.ts"; |
| 77 | import { archiveForm, restoreForm } from "./archive_forms.ts"; | |
| 77 | 78 | import { shelfRows } from "../studio/shelf.ts"; |
| 78 | 79 | import { |
| 79 | 80 | badge, |
| @@ -147,20 +148,28 @@ export function archivedAppCount(ctx: Ctx): number { | ||
| 147 | 148 | ).size; |
| 148 | 149 | } |
| 149 | 150 | |
| 150 | /** The archived list's Wake: a small form under each row the viewer administers. */ | |
| 151 | function wakeForms(ctx: Ctx, repos: readonly Repo[]): Map<string, Html> { | |
| 151 | /** Under each row the viewer administers: Restore on the archived list, Archive on the live one. */ | |
| 152 | function rowForms(ctx: Ctx, repos: readonly Repo[], archived: boolean): Map<string, Html> { | |
| 152 | 153 | const out = new Map<string, Html>(); |
| 154 | const user = ctx.auth.user; | |
| 155 | // Past the viewer's own repos, admin rests on the owner alone (the org | |
| 156 | // they belong to), so it is asked once per owner, not once per row. | |
| 157 | const byOwner = new Map<string, boolean>(); | |
| 158 | const mayAdmin = (repo: Repo): boolean => { | |
| 159 | if (user !== undefined && (user.id === repo.ownerId || user.isAdmin)) | |
| 160 | return canAdmin(ctx.app.db, repo, user); | |
| 161 | let ok = byOwner.get(repo.ownerId); | |
| 162 | if (ok === undefined) { | |
| 163 | ok = canAdmin(ctx.app.db, repo, user); | |
| 164 | byOwner.set(repo.ownerId, ok); | |
| 165 | } | |
| 166 | return ok; | |
| 167 | }; | |
| 153 | 168 | for (const repo of repos) { |
| 154 | if (!canAdmin(ctx.app.db, repo, ctx.auth.user)) continue; | |
| 169 | if (!mayAdmin(repo)) continue; | |
| 155 | 170 | out.set( |
| 156 | 171 | repo.id, |
| 157 | html`<li> | |
| 158 | <form method="post" action="${repoBase(repo)}/settings/archive"> | |
| 159 | <input type="hidden" name="archived" value="0" /> | |
| 160 | <input type="hidden" name="to" value="/apps?archived=1" /> | |
| 161 | ${btn(html`Wake<span class="sr-only"> ${repo.ownerName}/${repo.name}</span>`)} | |
| 162 | </form> | |
| 163 | </li>`, | |
| 172 | html`<li>${archived ? restoreForm(repo, "/apps?archived=1") : archiveForm(repo, "/apps")}</li>`, | |
| 164 | 173 | ); |
| 165 | 174 | } |
| 166 | 175 | return out; |
| @@ -207,25 +216,35 @@ export async function appsList(ctx: Ctx): Promise<Response> { | ||
| 207 | 216 | }) |
| 208 | 217 | : btn("Sign in to make one", { href: "/signin", size: "sm" }); |
| 209 | 218 | const filters = |
| 210 | archivedView || entries.length === 0 | |
| 219 | entries.length === 0 && archivedCount === 0 && !archivedView | |
| 211 | 220 | ? null |
| 212 | 221 | : html`<div class="bar mb"> |
| 213 | 222 | ${seg([ |
| 214 | 223 | { |
| 215 | 224 | label: "All", |
| 216 | 225 | href: "/apps", |
| 217 | current: !live, | |
| 218 | count: entries.length, | |
| 226 | current: !live && !archivedView, | |
| 227 | ...(archivedView ? {} : { count: entries.length }), | |
| 219 | 228 | }, |
| 220 | 229 | { |
| 221 | 230 | label: "Live", |
| 222 | 231 | href: "/apps?live=1", |
| 223 | 232 | current: live, |
| 224 | count: liveCount, | |
| 233 | ...(archivedView ? {} : { count: liveCount }), | |
| 225 | 234 | }, |
| 235 | ...(archivedCount > 0 || archivedView | |
| 236 | ? [ | |
| 237 | { | |
| 238 | label: "Archived", | |
| 239 | href: "/apps?archived=1", | |
| 240 | current: archivedView, | |
| 241 | count: archivedView ? entries.length : archivedCount, | |
| 242 | }, | |
| 243 | ] | |
| 244 | : []), | |
| 226 | 245 | ])} |
| 227 | 246 | </div>`; |
| 228 | const wake = archivedView ? wakeForms(ctx, shown.map((e) => e.repo)) : undefined; | |
| 247 | const wake = rowForms(ctx, shown.map((e) => e.repo), archivedView); | |
| 229 | 248 | const rows = await timePhaseAsync(ctx.request, "shelf", () => |
| 230 | 249 | shelfRows( |
| 231 | 250 | ctx, |
| @@ -249,13 +268,6 @@ export async function appsList(ctx: Ctx): Promise<Response> { | ||
| 249 | 268 | })} |
| 250 | 269 | ${cost} |
| 251 | 270 | ${filters} |
| 252 | ${archivedView | |
| 253 | ? html`<p class="muted small mb"><a href="/apps">Back to apps</a></p>` | |
| 254 | : archivedCount > 0 | |
| 255 | ? html`<p class="muted small mb"> | |
| 256 | <a href="/apps?archived=1">${archivedCount} archived</a> | |
| 257 | </p>` | |
| 258 | : null} | |
| 259 | 271 | ${shown.length === 0 |
| 260 | 272 | ? html`<section class="stack"> |
| 261 | 273 | <p><strong>${archivedView |
src/web/pages/archive_forms.ts added +35
| @@ -0,0 +1,35 @@ | ||
| 1 | /** | |
| 2 | * The two forms that put an app away and bring it back: Archive, behind a | |
| 3 | * no-JavaScript confirm (a disclosure whose second button is the real | |
| 4 | * submit), and Restore. Both post to the one route, `/settings/archive`, | |
| 5 | * which checks admin and same-origin; `to` says where to land afterwards. | |
| 6 | */ | |
| 7 | ||
| 8 | import { html, type Html } from "../html.ts"; | |
| 9 | import { btn } from "../ui.ts"; | |
| 10 | import { repoBase } from "../chrome.ts"; | |
| 11 | import type { Repo } from "../../core/repos.ts"; | |
| 12 | ||
| 13 | const nameOf = (repo: Repo): string => `${repo.ownerName}/${repo.name}`; | |
| 14 | ||
| 15 | /** "Archive" opens a confirm line; its button stops the app and hides it. */ | |
| 16 | export function archiveForm(repo: Repo, to: string): Html { | |
| 17 | return html`<details class="archive-confirm"> | |
| 18 | <summary>Archive<span class="sr-only"> ${nameOf(repo)}</span></summary> | |
| 19 | <form method="post" action="${repoBase(repo)}/settings/archive"> | |
| 20 | <input type="hidden" name="archived" value="1" /> | |
| 21 | <input type="hidden" name="to" value="${to}" /> | |
| 22 | <p class="muted small">Stops ${nameOf(repo)} and hides it from Home and Apps. Its data and code stay; Restore starts it again.</p> | |
| 23 | ${btn(html`Confirm archive<span class="sr-only"> ${nameOf(repo)}</span>`, { size: "sm" })} | |
| 24 | </form> | |
| 25 | </details>`; | |
| 26 | } | |
| 27 | ||
| 28 | /** "Restore" starts the app again and lists it. */ | |
| 29 | export function restoreForm(repo: Repo, to: string): Html { | |
| 30 | return html`<form method="post" action="${repoBase(repo)}/settings/archive"> | |
| 31 | <input type="hidden" name="archived" value="0" /> | |
| 32 | <input type="hidden" name="to" value="${to}" /> | |
| 33 | ${btn(html`Restore<span class="sr-only"> ${nameOf(repo)}</span>`, { size: "sm" })} | |
| 34 | </form>`; | |
| 35 | } |
src/web/pages/repo.ts +2 −8
| @@ -84,6 +84,7 @@ import { type CodeRows, rowsMarkup } from "../code_rows.ts"; | ||
| 84 | 84 | import { rowsOffThread } from "../code_rows_pool.ts"; |
| 85 | 85 | import { flashTo, fragment, page, redirect } from "../layout.ts"; |
| 86 | 86 | import { renderMarkdown } from "../markdown.ts"; |
| 87 | import { restoreForm } from "./archive_forms.ts"; | |
| 87 | 88 | import { overSize } from "../render/archive.ts"; |
| 88 | 89 | import { csvJs, isCsvPath } from "../render/csv.ts"; |
| 89 | 90 | import { fontJs } from "../render/font.ts"; |
| @@ -1327,14 +1328,7 @@ function archivedLine(ctx: Ctx, repo: Repo): Html | null { | ||
| 1327 | 1328 | Archived on |
| 1328 | 1329 | <time datetime="${repo.archivedAt}">${repo.archivedAt.slice(0, 10)}</time> |
| 1329 | 1330 | </p> |
| 1330 | ${when( | |
| 1331 | canAdmin(ctx.app.db, repo, ctx.auth.user), | |
| 1332 | () => html`<form method="post" action="${repoBase(repo)}/settings/archive"> | |
| 1333 | <input type="hidden" name="archived" value="0" /> | |
| 1334 | <input type="hidden" name="to" value="repo" /> | |
| 1335 | ${btn("Wake", { size: "sm" })} | |
| 1336 | </form>`, | |
| 1337 | )} | |
| 1331 | ${when(canAdmin(ctx.app.db, repo, ctx.auth.user), () => restoreForm(repo, "repo"))} | |
| 1338 | 1332 | </div>`; |
| 1339 | 1333 | } |
| 1340 | 1334 |
src/web/studio/app.ts +8 −2
| @@ -9,6 +9,7 @@ | ||
| 9 | 9 | * for a viewer who can write here; a stranger sees the app. |
| 10 | 10 | */ |
| 11 | 11 | |
| 12 | import { archiveForm, restoreForm } from "../pages/archive_forms.ts"; | |
| 12 | 13 | import { appCardPath } from "../og/path.ts"; |
| 13 | 14 | import { sealedScope } from "../../core/sealed.ts"; |
| 14 | 15 | import { egressSetting } from "../../core/capacity.ts"; |
| @@ -451,10 +452,15 @@ export async function studioApp( | ||
| 451 | 452 | // The rest of the app's environment — its variables, hostname, access and |
| 452 | 453 | // domains — is the admin's, on the app's settings page. |
| 453 | 454 | // Issues, Pulls and the map are the head's own tabs; only what it lacks is here. |
| 454 | const more = html`<p class="muted small"> | |
| 455 | const more = html`<div 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>`)} |
| 457 | </p>`; | |
| 458 | ${when(canAdmin(db, repo, ctx.auth.user), () => | |
| 459 | repo.archivedAt === null | |
| 460 | ? archiveForm(repo, "repo") | |
| 461 | : html`Archived on ${repo.archivedAt!.slice(0, 10)}. ${restoreForm(repo, "repo")}`, | |
| 462 | )} | |
| 463 | </div>`; | |
| 458 | 464 | |
| 459 | 465 | // An app that follows another says so before anything else: whether it |
| 460 | 466 | // is in step is the first thing its owner wants to know. |
test/apps-archived.test.ts +38 −7
| @@ -76,7 +76,7 @@ test("archiving hides an app from /apps, lists it at ?archived=1, and waking bri | ||
| 76 | 76 | let page = await get("/apps"); |
| 77 | 77 | expect(page.body).toContain(ROW_KEEPER); |
| 78 | 78 | expect(page.body).toContain(ROW_SHELVED); |
| 79 | expect(page.body).not.toContain("archived</a>"); | |
| 79 | expect(page.body).not.toContain("?archived=1"); | |
| 80 | 80 | |
| 81 | 81 | const done = await updateRepoSettings(app, alice, getRepo(app.db, "alice", "shelved")!, { archived: true }); |
| 82 | 82 | expect(done.ok).toBe(true); |
| @@ -84,7 +84,8 @@ test("archiving hides an app from /apps, lists it at ?archived=1, and waking bri | ||
| 84 | 84 | page = await get("/apps"); |
| 85 | 85 | expect(page.body).toContain(ROW_KEEPER); |
| 86 | 86 | expect(page.body).not.toContain(ROW_SHELVED); |
| 87 | expect(page.body).toContain('href="/apps?archived=1">1 archived</a>'); | |
| 87 | expect(page.body).toContain('href="/apps?archived=1"'); | |
| 88 | expect(page.body).toContain("Archived"); | |
| 88 | 89 | |
| 89 | 90 | const only = await get("/apps?archived=1"); |
| 90 | 91 | expect(only.body).toContain(ROW_SHELVED); |
| @@ -96,15 +97,15 @@ test("archiving hides an app from /apps, lists it at ?archived=1, and waking bri | ||
| 96 | 97 | expect(woke.ok).toBe(true); |
| 97 | 98 | page = await get("/apps"); |
| 98 | 99 | expect(page.body).toContain(ROW_SHELVED); |
| 99 | expect(page.body).not.toContain("archived</a>"); | |
| 100 | expect(page.body).not.toContain("?archived=1"); | |
| 100 | 101 | }); |
| 101 | 102 | |
| 102 | test("the archived view has no All/Live filters, only a way back", async () => { | |
| 103 | test("the archived view keeps the filter bar, marks Archived current, and ignores live", async () => { | |
| 103 | 104 | await updateRepoSettings(app, alice, getRepo(app.db, "alice", "shelved")!, { archived: true }); |
| 104 | 105 | const only = await get("/apps?archived=1&live=1"); |
| 105 | 106 | expect(only.body).toContain(ROW_SHELVED); |
| 106 | expect(only.body).not.toContain('href="/apps?live=1"'); | |
| 107 | expect(only.body).toContain('href="/apps">Back to apps</a>'); | |
| 107 | expect(only.body).toContain('href="/apps"'); | |
| 108 | expect(only.body).toMatch(/href="\/apps\?archived=1"[^>]*aria-current|aria-current[^>]*href="\/apps\?archived=1"/); | |
| 108 | 109 | await updateRepoSettings(app, alice, getRepo(app.db, "alice", "shelved")!, { archived: false }); |
| 109 | 110 | }); |
| 110 | 111 | |
| @@ -139,7 +140,7 @@ test("the archived list wakes an app in place, and ignores a return address off | ||
| 139 | 140 | expect(list).toContain('action="/alice/shelved/settings/archive"'); |
| 140 | 141 | expect(list).toContain('name="to" value="/apps?archived=1"'); |
| 141 | 142 | // 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>/); | |
| 143 | expect(list).toMatch(/Restore<span class="sr-only"> alice\/shelved<\/span>/); | |
| 143 | 144 | const post = (to: string, headers: Record<string, string> = { origin: servers.url }) => |
| 144 | 145 | fetch(`${servers.url}/alice/shelved/settings/archive`, { |
| 145 | 146 | method: "POST", |
| @@ -176,3 +177,33 @@ test("a viewer who does not administer an archived app gets no Wake button", asy | ||
| 176 | 177 | expect(anon).not.toContain("settings/archive"); |
| 177 | 178 | expect((await get("/apps?archived=1")).body).toContain("/alice/openshelf/settings/archive"); |
| 178 | 179 | }); |
| 180 | ||
| 181 | test("an admin archives from the apps list behind a confirm, the app stays reachable, and Restore brings it back", async () => { | |
| 182 | const list = (await get("/apps")).body; | |
| 183 | expect(list).toContain('<details class="archive-confirm">'); | |
| 184 | expect(list).toContain('action="/alice/shelved/settings/archive"'); | |
| 185 | const page = (await get("/alice/shelved")).body; | |
| 186 | expect(page).toContain('<details class="archive-confirm">'); | |
| 187 | const post = (archived: string) => | |
| 188 | fetch(`${servers.url}/alice/shelved/settings/archive`, { | |
| 189 | method: "POST", | |
| 190 | headers: { cookie, origin: servers.url }, | |
| 191 | body: new URLSearchParams({ archived, to: archived === "1" ? "/apps" : "/apps?archived=1" }), | |
| 192 | redirect: "manual", | |
| 193 | }); | |
| 194 | expect((await post("1")).status).toBe(303); | |
| 195 | expect(getRepo(app.db, "alice", "shelved")!.archivedAt).not.toBeNull(); | |
| 196 | expect((await get("/apps")).body).not.toContain(ROW_SHELVED); | |
| 197 | const archived = (await get("/apps?archived=1")).body; | |
| 198 | expect(archived).toContain(ROW_SHELVED); | |
| 199 | expect(archived).toMatch(/Restore<span class="sr-only"> alice\/shelved/); | |
| 200 | expect((await get("/alice/shelved")).status).toBe(200); | |
| 201 | expect((await post("0")).status).toBe(303); | |
| 202 | expect(getRepo(app.db, "alice", "shelved")!.archivedAt).toBeNull(); | |
| 203 | expect((await get("/apps")).body).toContain(ROW_SHELVED); | |
| 204 | }); | |
| 205 | ||
| 206 | test("a viewer who is not an admin sees no Archive on the list", async () => { | |
| 207 | const anon = await (await fetch(`${servers.url}/apps`)).text(); | |
| 208 | expect(anon).not.toContain("archive-confirm"); | |
| 209 | }); |
test/archive.test.ts +1 −1
| @@ -149,7 +149,7 @@ describe("archive and wake", () => { | ||
| 149 | 149 | ).text(); |
| 150 | 150 | expect(asOwner).toContain(`Archived on`); |
| 151 | 151 | expect(asOwner).toContain(day); |
| 152 | expect(asOwner).toContain("Wake"); | |
| 152 | expect(asOwner).toContain("Restore"); | |
| 153 | 153 | |
| 154 | 154 | const asStranger = await ( |
| 155 | 155 | await fetch(`${base}/ada/old`, { headers: { cookie: stranger } }) |
new above
offline