Public: anyone can read this; the crew can change it.
The /apps list speaks the home page's language #9728
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9711-the-apps-list-speaks-the-home-page-s-lan → main 5 commits fork 2 threads · 2 unresolved
3 files
changed +72
−30
docs/design/areas/lists.md +4
| @@ -27,6 +27,10 @@ one-line ellipsis. No fixed width; the page does not scroll sideways. No new cla | ||
| 27 | 27 | `/preview-admin/sample/pulls` is attached to the pull from its preview. |
| 28 | 28 | - `.archived-list` — the rows of an origin's archived repositories: long names wrap, and on a phone each row's buttons drop below it. |
| 29 | 29 | |
| 30 | ## The apps list and new-app step (#9711) | |
| 31 | ||
| 32 | `/apps` is the shelf's `.list.setting-list` of `.row`s straight on the page, no card around it; `/apps/new` is the form straight on the page. The pull page's preview card is `.card.bare` unless it is failed or needs attention, when it keeps its tone and box. No new class; `test/web-apps.test.ts` holds it. | |
| 33 | ||
| 30 | 34 | ## The audit as headings over rows (#9710) |
| 31 | 35 | |
| 32 | 36 | `/audit` (`auditPage` in `src/web/pages/audit.ts`) has no boxed card. Each app's standing (its policy table with the |
src/web/pages/apps.ts +35 −27
| @@ -78,7 +78,6 @@ import { | ||
| 78 | 78 | badge, |
| 79 | 79 | btn, |
| 80 | 80 | card, |
| 81 | empty, | |
| 82 | 81 | envFacts, |
| 83 | 82 | envStateBadge, |
| 84 | 83 | icon, |
| @@ -247,19 +246,20 @@ export async function appsList(ctx: Ctx): Promise<Response> { | ||
| 247 | 246 | </p>` |
| 248 | 247 | : null} |
| 249 | 248 | ${shown.length === 0 |
| 250 | ? empty({ | |
| 251 | icon: "rocket", | |
| 252 | title: archivedView | |
| 249 | ? html`<section class="stack"> | |
| 250 | <p><strong>${archivedView | |
| 253 | 251 | ? "Nothing is archived" |
| 254 | 252 | : live |
| 255 | 253 | ? "Nothing is live" |
| 256 | : "Nothing runs here yet", | |
| 257 | note: live | |
| 258 | ? "Nothing is serving right now — clear the filter to see every app." | |
| 259 | : "A new app is a repository with a small working app in it, served at its own hostname a few seconds later.", | |
| 260 | action: create, | |
| 261 | }) | |
| 262 | : card(html`<ul class="list setting-list">${rows}</ul>`, { pad: false })}</div>`; | |
| 254 | : "Nothing runs here yet"}</strong></p> | |
| 255 | <p class="muted small">${archivedView | |
| 256 | ? "Apps you put away show up here." | |
| 257 | : live | |
| 258 | ? "Nothing is serving right now. Clear the filter to see every app." | |
| 259 | : "A new app is a repository with a small working app in it. It is live at its own address seconds later."}</p> | |
| 260 | ${archivedView ? null : create} | |
| 261 | </section>` | |
| 262 | : html`<ul class="list setting-list">${rows}</ul>`}</div>`; | |
| 263 | 263 | const response = page({ ctx, title: "Apps", body }); |
| 264 | 264 | notePhase(ctx.request, "render", performance.now() - rendering); |
| 265 | 265 | return response; |
| @@ -313,10 +313,9 @@ function newAppForm( | ||
| 313 | 313 | |
| 314 | 314 | return html`<div class="narrow-wide"> |
| 315 | 315 | ${pageHead("New app", { |
| 316 | sub: "A repository with a small working app in it: a first issue from your sentence, a crew member on it, production at its hostname, and a preview for every change.", | |
| 316 | sub: "Describe what it should do. You get a repository, a first issue, a crew member on it, a live app, and a preview for every change.", | |
| 317 | 317 | })} |
| 318 | ${card( | |
| 319 | html`<form method="post" action="/apps/new"> | |
| 318 | ${html`<form method="post" action="/apps/new"> | |
| 320 | 319 | ${formField({ |
| 321 | 320 | label: "What should it do?", |
| 322 | 321 | name: "description", |
| @@ -355,14 +354,14 @@ function newAppForm( | ||
| 355 | 354 | pattern: "[A-Za-z0-9][A-Za-z0-9._-]*", |
| 356 | 355 | hint: |
| 357 | 356 | domain === undefined |
| 358 | ? "Left blank, it's made from your sentence above. Letters, digits, dots, dashes and underscores; the hostname is made from it." | |
| 359 | : `Left blank, it's made from your sentence above. Letters, digits, dots, dashes and underscores; it runs at <name>${appHostSuffix(domain)}.`, | |
| 357 | ? "Left blank, it comes from your sentence. Use letters, digits, dots, dashes and underscores. The address comes from it." | |
| 358 | : `Left blank, it comes from your sentence. Use letters, digits, dots, dashes and underscores. It runs at <name>${appHostSuffix(domain)}.`, | |
| 360 | 359 | ...(error ? { error } : {}), |
| 361 | 360 | })} |
| 362 | 361 | ${selectField({ |
| 363 | 362 | label: "Visibility", |
| 364 | 363 | name: "visibility", |
| 365 | hint: "A private app answers only to people who can see its repository; the forge vouches for them on the way in.", | |
| 364 | hint: "Only people who can see the repository can open a private app.", | |
| 366 | 365 | options: [ |
| 367 | 366 | { |
| 368 | 367 | value: "private", |
| @@ -420,8 +419,7 @@ function newAppForm( | ||
| 420 | 419 | ${btn("Create app", { variant: "primary", icon: "rocket" })} |
| 421 | 420 | ${btn("Cancel", { href: "/apps", variant: "ghost" })} |
| 422 | 421 | </div> |
| 423 | </form>`, | |
| 424 | )} | |
| 422 | </form>`} | |
| 425 | 423 | <p class="muted">Already have an app on another forge? <a href="/bring">Bring it here whole</a>.</p> |
| 426 | 424 | </div>`; |
| 427 | 425 | } |
| @@ -646,6 +644,21 @@ export function appPage(ctx: Ctx): Response { | ||
| 646 | 644 | |
| 647 | 645 | // ---- the pull page's preview card ---- |
| 648 | 646 | |
| 647 | /** | |
| 648 | * How the preview card looks: a quiet one is bare (heading over rows, no | |
| 649 | * box); a failed one or one that needs attention keeps its tone and box, | |
| 650 | * since a bare card draws no tone. | |
| 651 | */ | |
| 652 | export function previewLook(env: EnvStatus): { | |
| 653 | extra: string; | |
| 654 | tone?: "danger" | "attn"; | |
| 655 | } { | |
| 656 | if (env.state === "failed" || env.error !== undefined) | |
| 657 | return { extra: "env-card", tone: "danger" }; | |
| 658 | if (env.failedDeploy !== undefined) return { extra: "env-card", tone: "attn" }; | |
| 659 | return { extra: "bare env-card" }; | |
| 660 | } | |
| 661 | ||
| 649 | 662 | /** Where this pull's preview stands, with the way in. */ |
| 650 | 663 | export function previewCard(ctx: Ctx, repo: Repo, pull: Pull): Html { |
| 651 | 664 | const view = appView(ctx.app, ctx.auth.user, repo); |
| @@ -660,7 +673,7 @@ export function previewCard(ctx: Ctx, repo: Repo, pull: Pull): Html { | ||
| 660 | 673 | if (!view.value.serving) |
| 661 | 674 | return card( |
| 662 | 675 | html`<p class="muted small">Nothing is serving apps on this forge.</p>`, |
| 663 | { head }, | |
| 676 | { head, extra: "bare" }, | |
| 664 | 677 | ); |
| 665 | 678 | if (env === undefined) { |
| 666 | 679 | const why = |
| @@ -669,7 +682,7 @@ export function previewCard(ctx: Ctx, repo: Repo, pull: Pull): Html { | ||
| 669 | 682 | : pull.state === "closed" |
| 670 | 683 | ? "The preview was removed when this closed. Reopen the pull request and it comes back." |
| 671 | 684 | : "No preview yet — one starts when this pull request's head deploys."; |
| 672 | return card(html`<p class="muted small">${why}</p>`, { head }); | |
| 685 | return card(html`<p class="muted small">${why}</p>`, { head, extra: "bare" }); | |
| 673 | 686 | } |
| 674 | 687 | // A preview's terminal is a different login from production's — its own |
| 675 | 688 | // command, shown only to a writer, same as the app page's. |
| @@ -698,12 +711,7 @@ export function previewCard(ctx: Ctx, repo: Repo, pull: Pull): Html { | ||
| 698 | 711 | </div>`, |
| 699 | 712 | { |
| 700 | 713 | head, |
| 701 | extra: "env-card", | |
| 702 | ...(env.state === "failed" || env.error !== undefined | |
| 703 | ? { tone: "danger" as const } | |
| 704 | : env.failedDeploy !== undefined | |
| 705 | ? { tone: "attn" as const } | |
| 706 | : {}), | |
| 714 | ...previewLook(env), | |
| 707 | 715 | }, |
| 708 | 716 | ); |
| 709 | 717 | } |
test/web-apps.test.ts +33 −3
| @@ -18,6 +18,8 @@ import { | ||
| 18 | 18 | STARTERS, |
| 19 | 19 | starterHref, |
| 20 | 20 | } from "../src/web/pages/apps-starters.ts"; |
| 21 | import { previewLook } from "../src/web/pages/apps.ts"; | |
| 22 | import type { EnvStatus } from "../src/apps/runtime.ts"; | |
| 21 | 23 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; |
| 22 | 24 | import { mkdtemp, rm } from "node:fs/promises"; |
| 23 | 25 | import { tmpdir } from "node:os"; |
| @@ -307,6 +309,34 @@ describe("apps, runs and the crew on the web", () => { | ||
| 307 | 309 | expect(anonymous).toContain("Sign in"); |
| 308 | 310 | }); |
| 309 | 311 | |
| 312 | test("the apps list is rows, not a box, and so is the new-app step", async () => { | |
| 313 | const list = await bodyOf(alice, "/apps"); | |
| 314 | expect(list).toContain('class="list setting-list"'); | |
| 315 | expect(list).not.toMatch(/class="card[ "]/); | |
| 316 | const form = await bodyOf(alice, "/apps/new"); | |
| 317 | expect(form).toContain('action="/apps/new"'); | |
| 318 | expect(form).not.toMatch(/class="card[ "]/); | |
| 319 | }); | |
| 320 | ||
| 321 | test("an empty /apps says so in plain words, with no box", async () => { | |
| 322 | const none = await bodyOf(alice, "/apps?archived=1"); | |
| 323 | expect(none).toContain("Nothing is archived"); | |
| 324 | expect(none).not.toMatch(/class="card[ "]/); | |
| 325 | }); | |
| 326 | ||
| 327 | test("the preview card is bare when quiet and keeps its tone and box when failed", () => { | |
| 328 | const env = { env: "pull-1", state: "ok" } as unknown as EnvStatus; | |
| 329 | expect(previewLook(env)).toEqual({ extra: "bare env-card" }); | |
| 330 | expect(previewLook({ ...env, state: "failed" } as EnvStatus)).toEqual({ | |
| 331 | extra: "env-card", | |
| 332 | tone: "danger", | |
| 333 | }); | |
| 334 | expect(previewLook({ ...env, error: "boom" } as EnvStatus).tone).toBe("danger"); | |
| 335 | expect( | |
| 336 | previewLook({ ...env, failedDeploy: {} } as unknown as EnvStatus), | |
| 337 | ).toEqual({ extra: "env-card", tone: "attn" }); | |
| 338 | }); | |
| 339 | ||
| 310 | 340 | // ---- the app page and the runs ---- |
| 311 | 341 | |
| 312 | 342 | test("dev's old App URL lands on the studio's app page, which has the environment controls a writer gets", async () => { |
| @@ -901,9 +931,9 @@ describe("apps, runs and the crew on the web", () => { | ||
| 901 | 931 | |
| 902 | 932 | // The preview is on the change; the assignee, the checks line and the |
| 903 | 933 | // merge box are its Files step, one tab under it. |
| 904 | expect(await bodyOf(alice, `/alice/hello/pulls/${number}`)).toContain( | |
| 905 | "<h2>Preview</h2>", | |
| 906 | ); | |
| 934 | const change = await bodyOf(alice, `/alice/hello/pulls/${number}`); | |
| 935 | expect(change).toContain("<h2>Preview</h2>"); | |
| 936 | expect(change).toMatch(/<div\s+class="card bare[ "][^>]*>\s*<div class="card-head">\s*<svg[^]*?<h2>Preview<\/h2>/); | |
| 907 | 937 | const page = await bodyOf(alice, `/alice/hello/pulls/${number}/files`); |
| 908 | 938 | expect(page).toContain("<h2>Assignee</h2>"); |
| 909 | 939 | // Checks are required: the line says so, and the box waits for them |
Covers /apps and /apps/new only. Add the previewCard case, and an empty-state case, since the empty /apps uses a different path than the rows.