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

Parked opened this
Closed without merging

mason/forge:mason/10779-archive-an-app-from-its-page-or-the-apps → main 6 commits fork

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
8 files changed +142 −46
docs/design/attention-budget.json +8 −6
@@ -14,26 +14,28 @@
1414 "repository": {
1515 "390": {
1616 "words": 117,
17 "blocks": 18,
17 "blocks": 19,
1818 "colours": 2
1919 },
2020 "1280": {
2121 "words": 117,
22 "blocks": 18,
22 "blocks": 19,
2323 "colours": 2
24 }
24 },
25 "note": "19 blocks: the closed Archive disclosure's summary (#10779) is the one added line."
2526 },
2627 "app": {
2728 "390": {
2829 "words": 117,
29 "blocks": 18,
30 "blocks": 19,
3031 "colours": 2
3132 },
3233 "1280": {
3334 "words": 117,
34 "blocks": 18,
35 "blocks": 19,
3536 "colours": 2
36 }
37 },
38 "note": "19 blocks: the closed Archive disclosure's summary (#10779) is the one added line."
3739 },
3840 "pull": {
3941 "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";
7474 import { writableAppsOf } from "../studio/asks.ts";
7575 import { costLine } from "../studio/cost_line.ts";
7676 import { homeTabs, unshippedAsks } from "../studio/home_tabs.ts";
77 import { archiveForm, restoreForm } from "./archive_forms.ts";
7778 import { shelfRows } from "../studio/shelf.ts";
7879 import {
7980 badge,
@@ -147,20 +148,28 @@ export function archivedAppCount(ctx: Ctx): number {
147148 ).size;
148149 }
149150
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> {
152153 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 };
153168 for (const repo of repos) {
154 if (!canAdmin(ctx.app.db, repo, ctx.auth.user)) continue;
169 if (!mayAdmin(repo)) continue;
155170 out.set(
156171 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>`,
164173 );
165174 }
166175 return out;
@@ -207,25 +216,35 @@ export async function appsList(ctx: Ctx): Promise<Response> {
207216 })
208217 : btn("Sign in to make one", { href: "/signin", size: "sm" });
209218 const filters =
210 archivedView || entries.length === 0
219 entries.length === 0 && archivedCount === 0 && !archivedView
211220 ? null
212221 : html`<div class="bar mb">
213222 ${seg([
214223 {
215224 label: "All",
216225 href: "/apps",
217 current: !live,
218 count: entries.length,
226 current: !live && !archivedView,
227 ...(archivedView ? {} : { count: entries.length }),
219228 },
220229 {
221230 label: "Live",
222231 href: "/apps?live=1",
223232 current: live,
224 count: liveCount,
233 ...(archivedView ? {} : { count: liveCount }),
225234 },
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 : []),
226245 ])}
227246 </div>`;
228 const wake = archivedView ? wakeForms(ctx, shown.map((e) => e.repo)) : undefined;
247 const wake = rowForms(ctx, shown.map((e) => e.repo), archivedView);
229248 const rows = await timePhaseAsync(ctx.request, "shelf", () =>
230249 shelfRows(
231250 ctx,
@@ -249,13 +268,6 @@ export async function appsList(ctx: Ctx): Promise<Response> {
249268 })}
250269 ${cost}
251270 ${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}
259271 ${shown.length === 0
260272 ? html`<section class="stack">
261273 <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";
8484 import { rowsOffThread } from "../code_rows_pool.ts";
8585 import { flashTo, fragment, page, redirect } from "../layout.ts";
8686 import { renderMarkdown } from "../markdown.ts";
87 import { restoreForm } from "./archive_forms.ts";
8788 import { overSize } from "../render/archive.ts";
8889 import { csvJs, isCsvPath } from "../render/csv.ts";
8990 import { fontJs } from "../render/font.ts";
@@ -1327,14 +1328,7 @@ function archivedLine(ctx: Ctx, repo: Repo): Html | null {
13271328 Archived on
13281329 <time datetime="${repo.archivedAt}">${repo.archivedAt.slice(0, 10)}</time>
13291330 </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"))}
13381332 </div>`;
13391333 }
13401334
src/web/studio/app.ts +8 −2
@@ -9,6 +9,7 @@
99 * for a viewer who can write here; a stranger sees the app.
1010 */
1111
12 import { archiveForm, restoreForm } from "../pages/archive_forms.ts";
1213 import { appCardPath } from "../og/path.ts";
1314 import { sealedScope } from "../../core/sealed.ts";
1415 import { egressSetting } from "../../core/capacity.ts";
@@ -451,10 +452,15 @@ export async function studioApp(
451452 // The rest of the app's environment — its variables, hostname, access and
452453 // domains — is the admin's, on the app's settings page.
453454 // 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">
455456 <a href="${base}/variants">Variants</a>
456457 ${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>`;
458464
459465 // An app that follows another says so before anything else: whether it
460466 // 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
7676 let page = await get("/apps");
7777 expect(page.body).toContain(ROW_KEEPER);
7878 expect(page.body).toContain(ROW_SHELVED);
79 expect(page.body).not.toContain("archived</a>");
79 expect(page.body).not.toContain("?archived=1");
8080
8181 const done = await updateRepoSettings(app, alice, getRepo(app.db, "alice", "shelved")!, { archived: true });
8282 expect(done.ok).toBe(true);
@@ -84,7 +84,8 @@ test("archiving hides an app from /apps, lists it at ?archived=1, and waking bri
8484 page = await get("/apps");
8585 expect(page.body).toContain(ROW_KEEPER);
8686 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");
8889
8990 const only = await get("/apps?archived=1");
9091 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
9697 expect(woke.ok).toBe(true);
9798 page = await get("/apps");
9899 expect(page.body).toContain(ROW_SHELVED);
99 expect(page.body).not.toContain("archived</a>");
100 expect(page.body).not.toContain("?archived=1");
100101 });
101102
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 () => {
103104 await updateRepoSettings(app, alice, getRepo(app.db, "alice", "shelved")!, { archived: true });
104105 const only = await get("/apps?archived=1&live=1");
105106 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"/);
108109 await updateRepoSettings(app, alice, getRepo(app.db, "alice", "shelved")!, { archived: false });
109110 });
110111
@@ -139,7 +140,7 @@ test("the archived list wakes an app in place, and ignores a return address off
139140 expect(list).toContain('action="/alice/shelved/settings/archive"');
140141 expect(list).toContain('name="to" value="/apps?archived=1"');
141142 // 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>/);
143144 const post = (to: string, headers: Record<string, string> = { origin: servers.url }) =>
144145 fetch(`${servers.url}/alice/shelved/settings/archive`, {
145146 method: "POST",
@@ -176,3 +177,33 @@ test("a viewer who does not administer an archived app gets no Wake button", asy
176177 expect(anon).not.toContain("settings/archive");
177178 expect((await get("/apps?archived=1")).body).toContain("/alice/openshelf/settings/archive");
178179 });
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", () => {
149149 ).text();
150150 expect(asOwner).toContain(`Archived on`);
151151 expect(asOwner).toContain(day);
152 expect(asOwner).toContain("Wake");
152 expect(asOwner).toContain("Restore");
153153
154154 const asStranger = await (
155155 await fetch(`${base}/ada/old`, { headers: { cookie: stranger } })

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs