Public: anyone can read this; the crew can change it.
The home's app rail ends with `N archived`, one tap to /apps?archived=1 #6602
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6085-the-home-s-app-rail-ends-with-n-archived → main 1 commit fork 1 thread · 1 unresolved
3 files
changed +22
−2
src/web/pages/apps.ts +1 −1
| @@ -129,7 +129,7 @@ function appsForViewer(ctx: Ctx, archived: boolean): AppEntry[] { | ||
| 129 | 129 | } |
| 130 | 130 | |
| 131 | 131 | /** How many archived apps the viewer can see (the "N archived" line). */ |
| 132 | function archivedAppCount(ctx: Ctx): number { | |
| 132 | export function archivedAppCount(ctx: Ctx): number { | |
| 133 | 133 | const repos = listReposVisibleTo(ctx.app.db, ctx.auth.user, { |
| 134 | 134 | limit: 200, |
| 135 | 135 | archivedOnly: true, |
src/web/studio/home.ts +8 −1
| @@ -57,6 +57,7 @@ import { kpiRow, vitalsRow, waitingLine } from "./kpi_row.ts"; | ||
| 57 | 57 | import { feedCandidateCache } from "../../feed/feed.ts"; |
| 58 | 58 | import { notePhase, notePhaseRest, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| 59 | 59 | import { kpiCache, panelCache, waitingCache } from "./home_cache.ts"; |
| 60 | import { archivedAppCount } from "../pages/apps.ts"; | |
| 60 | 61 | import { pinnedPanels } from "./pinned.ts"; |
| 61 | 62 | import { homeTabs } from "./home_tabs.ts"; |
| 62 | 63 | import { crewKeyNote } from "./crew.ts"; |
| @@ -108,6 +109,7 @@ function yourApps(ctx: Ctx, user: User, apps = writableAppsOf(ctx, user)): Html | ||
| 108 | 109 | ...(state === undefined ? {} : { trail: envStateBadge(state) }), |
| 109 | 110 | }); |
| 110 | 111 | }); |
| 112 | const archived = archivedAppCount(ctx); | |
| 111 | 113 | const list = html`${rows.length === 0 |
| 112 | 114 | ? html`<p class="muted small">No apps yet.</p>` |
| 113 | 115 | : html`<ul class="list"> |
| @@ -128,7 +130,12 @@ function yourApps(ctx: Ctx, user: User, apps = writableAppsOf(ctx, user)): Html | ||
| 128 | 130 | btn("Front page", { href: "/front", size: "sm", icon: "globe" }), |
| 129 | 131 | )} |
| 130 | 132 | ${when(user.isAdmin && rows.length > 0, () => btn("Archive", { size: "sm" }))} |
| 131 | </div>`; | |
| 133 | </div> | |
| 134 | ${when( | |
| 135 | archived > 0, | |
| 136 | () => | |
| 137 | html`<p class="muted small"><a href="/apps?archived=1">${archived} archived</a></p>`, | |
| 138 | )}`; | |
| 132 | 139 | return card( |
| 133 | 140 | // An admin's list is a form: tick apps, then Archive. |
| 134 | 141 | user.isAdmin && rows.length > 0 |
test/home-archive.test.ts +13
| @@ -116,6 +116,19 @@ describe("home archive", () => { | ||
| 116 | 116 | expect(twice.headers.get("location")).toBe("/"); |
| 117 | 117 | }); |
| 118 | 118 | |
| 119 | test("the rail ends with `N archived` once something is put away, and it leads there", async () => { | |
| 120 | expect(await full(rootCookie)).not.toMatch(/\d+ archived<\/a>/); | |
| 121 | const res = await post("/home/archive", rootCookie, body(repos[2]!.id)); | |
| 122 | const receipt = res.headers.get("location")!; | |
| 123 | const page = await full(rootCookie); | |
| 124 | expect(page).toContain('<a href="/apps?archived=1">1 archived</a>'); | |
| 125 | const listed = await (await get("/apps?archived=1", rootCookie)).text(); | |
| 126 | expect(listed).toContain("keeper/gamma"); | |
| 127 | expect(await full(bobCookie)).toContain("1 archived</a>"); // public, so bob can see it too | |
| 128 | await post(`${receipt}/undo`, rootCookie, ""); | |
| 129 | expect(await full(rootCookie)).not.toMatch(/\d+ archived<\/a>/); | |
| 130 | }); | |
| 131 | ||
| 119 | 132 | test("a cross-origin post is refused and archives nothing", async () => { |
| 120 | 133 | const res = await post( |
| 121 | 134 | "/home/archive", |
new above
offline
This runs an uncached listReposVisibleTo query (limit 200) on every home render. If the home has a cache budget, put the count behind the panelCache.