Public: anyone can read this; the crew can change it.

The /apps list speaks the home page's language #9728

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/9711-the-apps-list-speaks-the-home-page-s-lan → main 5 commits fork 2 threads · 2 unresolved

  • Merged. scout/9711-the-apps-list-speaks-the-home-page-s-lan was merged into main as 985ca3e.

Review

Sign in to review this change.
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
2727 `/preview-admin/sample/pulls` is attached to the pull from its preview.
2828 - `.archived-list` — the rows of an origin's archived repositories: long names wrap, and on a phone each row's buttons drop below it.
2929
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
3034 ## The audit as headings over rows (#9710)
3135
3236 `/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 {
7878 badge,
7979 btn,
8080 card,
81 empty,
8281 envFacts,
8382 envStateBadge,
8483 icon,
@@ -247,19 +246,20 @@ export async function appsList(ctx: Ctx): Promise<Response> {
247246 </p>`
248247 : null}
249248 ${shown.length === 0
250 ? empty({
251 icon: "rocket",
252 title: archivedView
249 ? html`<section class="stack">
250 <p><strong>${archivedView
253251 ? "Nothing is archived"
254252 : live
255253 ? "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>`;
263263 const response = page({ ctx, title: "Apps", body });
264264 notePhase(ctx.request, "render", performance.now() - rendering);
265265 return response;
@@ -313,10 +313,9 @@ function newAppForm(
313313
314314 return html`<div class="narrow-wide">
315315 ${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.",
317317 })}
318 ${card(
319 html`<form method="post" action="/apps/new">
318 ${html`<form method="post" action="/apps/new">
320319 ${formField({
321320 label: "What should it do?",
322321 name: "description",
@@ -355,14 +354,14 @@ function newAppForm(
355354 pattern: "[A-Za-z0-9][A-Za-z0-9._-]*",
356355 hint:
357356 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)}.`,
360359 ...(error ? { error } : {}),
361360 })}
362361 ${selectField({
363362 label: "Visibility",
364363 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.",
366365 options: [
367366 {
368367 value: "private",
@@ -420,8 +419,7 @@ function newAppForm(
420419 ${btn("Create app", { variant: "primary", icon: "rocket" })}
421420 ${btn("Cancel", { href: "/apps", variant: "ghost" })}
422421 </div>
423 </form>`,
424 )}
422 </form>`}
425423 <p class="muted">Already have an app on another forge? <a href="/bring">Bring it here whole</a>.</p>
426424 </div>`;
427425 }
@@ -646,6 +644,21 @@ export function appPage(ctx: Ctx): Response {
646644
647645 // ---- the pull page's preview card ----
648646
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
649662 /** Where this pull's preview stands, with the way in. */
650663 export function previewCard(ctx: Ctx, repo: Repo, pull: Pull): Html {
651664 const view = appView(ctx.app, ctx.auth.user, repo);
@@ -660,7 +673,7 @@ export function previewCard(ctx: Ctx, repo: Repo, pull: Pull): Html {
660673 if (!view.value.serving)
661674 return card(
662675 html`<p class="muted small">Nothing is serving apps on this forge.</p>`,
663 { head },
676 { head, extra: "bare" },
664677 );
665678 if (env === undefined) {
666679 const why =
@@ -669,7 +682,7 @@ export function previewCard(ctx: Ctx, repo: Repo, pull: Pull): Html {
669682 : pull.state === "closed"
670683 ? "The preview was removed when this closed. Reopen the pull request and it comes back."
671684 : "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" });
673686 }
674687 // A preview's terminal is a different login from production's — its own
675688 // 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 {
698711 </div>`,
699712 {
700713 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),
707715 },
708716 );
709717 }
test/web-apps.test.ts +33 −3
@@ -18,6 +18,8 @@ import {
1818 STARTERS,
1919 starterHref,
2020 } 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";
2123 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2224 import { mkdtemp, rm } from "node:fs/promises";
2325 import { tmpdir } from "node:os";
@@ -307,6 +309,34 @@ describe("apps, runs and the crew on the web", () => {
307309 expect(anonymous).toContain("Sign in");
308310 });
outdated

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.

309311
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
310340 // ---- the app page and the runs ----
311341
312342 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", () => {
901931
902932 // The preview is on the change; the assignee, the checks line and the
903933 // 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>/);
907937 const page = await bodyOf(alice, `/alice/hello/pulls/${number}/files`);
908938 expect(page).toContain("<h2>Assignee</h2>");
909939 // Checks are required: the line says so, and the box waits for them

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs