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

Click your own name in the studio and you land in dev's repository list #207

Merged opened this

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

Merged

scout/forge:scout/185-click-your-own-name-in-the-studio-and-yo → main 1 commit fork

  • Merged. scout/185-click-your-own-name-in-the-studio-and-yo was merged into main as f330c16.

Review

Sign in to review this change.
2 files changed +104 −3
src/web/pages/org.ts +58 −3
@@ -27,6 +27,7 @@ import {
2727 type Permission,
2828 type Team,
2929 } from "../../core/orgs.ts";
30 import { appsForRepos } from "../../core/apps.ts";
3031 import { listReposByOwner, type Repo } from "../../core/repos.ts";
3132 import { getUserByName, type User } from "../../core/users.ts";
3233 import type { Ctx } from "../../http/context.ts";
@@ -35,6 +36,7 @@ import { field, readForm, type Form } from "../form.ts";
3536 import { html, when, type Html } from "../html.ts";
3637 import { icon } from "../icons.ts";
3738 import { flashTo, page } from "../layout.ts";
39 import { shelfRows } from "../studio/shelf.ts";
3840 import {
3941 avatar,
4042 badge,
@@ -130,6 +132,52 @@ function repoSection(ctx: Ctx, owner: User, isSelf: boolean): Html {
130132 )}`;
131133 }
132134
135 /**
136 * The owner's apps in the studio, one shelf row each — the same row the
137 * studio home's *Your apps* card draws (`shelfRows`, `studio/shelf.ts`),
138 * never a second copy of it. Visibility is `listReposByOwner`'s own, the
139 * same rule the repository list below uses, so a stranger never sees a
140 * private app here either.
141 */
142 async function appsSection(ctx: Ctx, owner: User, isSelf: boolean): Promise<Html> {
143 const repos = listReposByOwner(ctx.app.db, owner.username, ctx.auth.user, {
144 limit: 200,
145 });
146 const records = appsForRepos(
147 ctx.app.db,
148 repos.map((r) => r.id),
149 );
150 const appRepos = repos.filter((r) => records.has(r.id));
151 const newAppHref = `/apps/new?owner=${owner.username}`;
152
153 if (appRepos.length === 0)
154 return empty({
155 icon: "rocket",
156 title: "No apps yet",
157 note: isSelf
158 ? "Make one, tell the crew what it is, and watch it come up."
159 : `${owner.username} has no apps you can see.`,
160 ...(isSelf
161 ? { action: btn("New app", { href: newAppHref, variant: "primary" }) }
162 : {}),
163 });
164
165 const rows = await shelfRows(ctx, appRepos);
166 return listCard(rows, {
167 head: html`${icon("rocket")}
168 <h2>Apps</h2>
169 ${when(
170 isSelf,
171 () =>
172 html`<span class="spacer"></span>${btn("New app", {
173 href: newAppHref,
174 size: "sm",
175 icon: "plus",
176 })}`,
177 )}`,
178 });
179 }
180
133181 /** Everyone on any of the org's teams, once each. */
134182 function orgMembers(ctx: Ctx, teams: Team[]): User[] {
135183 const seen = new Map<string, User>();
@@ -207,8 +255,11 @@ function teamsSection(ctx: Ctx, org: User, canManage: boolean): Html {
207255 )}`;
208256 }
209257
210 // GET /:owner — a person or an org, with their repos.
211 export function ownerProfile(ctx: Ctx): Response {
258 // GET /:owner — a person or an org, with their repos. In the studio this
259 // leads with the owner's apps, drawn by the same shelf the studio's own
260 // home draws its *Your apps* card from; dev mode is byte-identical to what
261 // this page has always rendered.
262 export async function ownerProfile(ctx: Ctx): Promise<Response> {
212263 const owner = getUserByName(ctx.app.db, ctx.params.owner ?? "");
213264 if (!owner) return notFoundPage(ctx);
214265
@@ -219,6 +270,9 @@ export function ownerProfile(ctx: Ctx): Response {
219270 const canManage = isOrg && accessAllows(access, "admin");
220271 const isSelf =
221272 ctx.auth.user !== undefined && (ctx.auth.user.id === owner.id || canManage);
273 const studio = ctx.mode === "studio";
274
275 const apps = studio ? await appsSection(ctx, owner, isSelf) : null;
222276
223277 const body = html`<div class="page-head">
224278 ${avatar(owner.username, "lg")}
@@ -232,12 +286,13 @@ export function ownerProfile(ctx: Ctx): Response {
232286 ${when(isSelf, () =>
233287 btn("New repository", {
234288 href: `/new?owner=${owner.username}`,
235 variant: "primary",
289 variant: studio ? "ghost" : "primary",
236290 size: "sm",
237291 icon: "plus",
238292 }),
239293 )}
240294 </div>
295 ${apps}
241296 ${repoSection(ctx, owner, isSelf)}
242297 ${when(isOrg && accessAllows(access, "read"), () =>
243298 teamsSection(ctx, owner, canManage),
test/studio.test.ts +46
@@ -1350,6 +1350,52 @@ describe("the studio", () => {
13501350 expect(dev).not.toContain('href="/apps?live=1"');
13511351 });
13521352
1353 test("clicking your own name in the studio lands on /:owner, which leads with your apps on the shelf and New app as the primary action; dev mode is unchanged, and a stranger never sees a private one", async () => {
1354 const studio = await (await get("/alice")).text();
1355 expect(studio).toContain("<h2>Apps</h2>");
1356 expect(studio).toContain('href="/alice/notes"');
1357 expect(studio).toContain("not running");
1358 expect(studio).toContain('href="/apps/new?owner=alice"');
1359 // The repository list stays reachable, filter and all — nothing disappears.
1360 expect(studio).toContain('data-search-input');
1361 expect(studio).toContain("notes");
1362
1363 const dev = await (
1364 await get("/alice", { cookie: `${cookie}; forge_mode=dev` })
1365 ).text();
1366 expect(dev).not.toContain("<h2>Apps</h2>");
1367 expect(dev).not.toContain('href="/apps/new?owner=alice"');
1368 expect(dev).not.toContain("not running");
1369
1370 const carol = await createUser(app.db, {
1371 username: "carol",
1372 email: "[email protected]",
1373 password: "a-good-passphrase",
1374 });
1375 if (!carol.ok) throw new Error(carol.error);
1376 const carolCookie = (
1377 (
1378 await fetch(`${base}/signin`, {
1379 method: "POST",
1380 body: new URLSearchParams({
1381 username: "carol",
1382 password: "a-good-passphrase",
1383 }),
1384 redirect: "manual",
1385 })
1386 ).headers.get("set-cookie") ?? ""
1387 ).split(";")[0]!;
1388 const stranger = await (
1389 await fetch(`${base}/alice`, {
1390 headers: { cookie: `${carolCookie}; forge_mode=studio` },
1391 redirect: "manual",
1392 })
1393 ).text();
1394 expect(stranger).not.toContain('href="/alice/notes"');
1395 expect(stranger).toContain("No apps yet");
1396 expect(stranger).not.toContain('href="/apps/new?owner=alice"');
1397 });
1398
13531399 test("signed out in studio, / leads with the public apps that are serving — their look, not a private one's", async () => {
13541400 // A second app, public, with a picture of its production. "notes" stays
13551401 // private, so its presence (or absence) is the negative case below.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs