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

Your app is listed twice on your own page, in two vocabularies #275

Merged opened this

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

Merged

scout/forge:scout/221-your-app-is-listed-twice-on-your-own-pag → main 1 commit fork

  • Merged. scout/221-your-app-is-listed-twice-on-your-own-pag was merged into main as 3d70ca2.

Review

Sign in to review this change.
2 files changed +225 −9
src/web/pages/org.ts +35 −9
@@ -57,8 +57,16 @@ const PER_PAGE = 20;
5757
5858 // ---- profile ----
5959
60 /** The repo list, filtered by the box above it and paged by repo id. */
61 function repoSection(ctx: Ctx, owner: User, isSelf: boolean): Html {
60 /**
61 * The repo list, filtered by the box above it and paged by repo id. In the
62 * studio, apps are drawn once, by `appsSection` above — so this list omits
63 * whatever page of results already showed there, and says so when that
64 * leaves it with nothing: "no other repositories" reads as the true state
65 * of the world, not as a bug. Pagination still walks the raw, unfiltered
66 * rows (the cursor is a repo id), so leaving apps out of what is *shown*
67 * never skips or repeats a plain repository across pages.
68 */
69 function repoSection(ctx: Ctx, owner: User, isSelf: boolean, studio: boolean): Html {
6270 const query = (ctx.url.searchParams.get("q") ?? "").slice(0, 100);
6371 const cursor = ctx.url.searchParams.get("cursor") ?? "";
6472 const repos = listReposByOwner(ctx.app.db, owner.username, ctx.auth.user, {
@@ -66,12 +74,20 @@ function repoSection(ctx: Ctx, owner: User, isSelf: boolean): Html {
6674 ...(query !== "" ? { query } : {}),
6775 ...(cursor !== "" ? { cursor } : {}),
6876 });
69 const shown = repos.slice(0, PER_PAGE);
70 const last = shown[shown.length - 1];
77 const page = repos.slice(0, PER_PAGE);
78 const last = page[page.length - 1];
7179 const nextParams = new URLSearchParams();
7280 if (query !== "") nextParams.set("q", query);
7381 if (last) nextParams.set("cursor", last.id);
7482
83 const apps = studio
84 ? appsForRepos(
85 ctx.app.db,
86 page.map((r) => r.id),
87 )
88 : undefined;
89 const shown = apps ? page.filter((repo) => !apps.has(repo.id)) : page;
90
7591 const lead = (repo: Repo) =>
7692 icon(repo.forkParentId !== null ? "fork" : "repo");
7793
@@ -92,11 +108,18 @@ function repoSection(ctx: Ctx, owner: User, isSelf: boolean): Html {
92108 ${shown.length === 0
93109 ? empty({
94110 icon: "repo",
95 title: query === "" ? "No repositories yet" : "No matches",
111 title:
112 query !== ""
113 ? "No matches"
114 : studio
115 ? "No other repositories"
116 : "No repositories yet",
96117 note:
97 query === ""
98 ? `${owner.username} has not published anything you can see.`
99 : "Try a different search.",
118 query !== ""
119 ? "Try a different search."
120 : studio
121 ? `Every repository ${owner.username} has is already listed as an app above.`
122 : `${owner.username} has not published anything you can see.`,
100123 ...(isSelf && query === ""
101124 ? {
102125 action: btn("New repository", {
@@ -124,6 +147,9 @@ function repoSection(ctx: Ctx, owner: User, isSelf: boolean): Html {
124147 : badge("Public", { variant: "secondary" })}`,
125148 }),
126149 ),
150 studio
151 ? { head: html`${icon("repo")}<h2>Other repositories</h2>` }
152 : {},
127153 )}
128154 ${pager(
129155 repos.length > PER_PAGE && last
@@ -293,7 +319,7 @@ export async function ownerProfile(ctx: Ctx): Promise<Response> {
293319 )}
294320 </div>
295321 ${apps}
296 ${repoSection(ctx, owner, isSelf)}
322 ${repoSection(ctx, owner, isSelf, studio)}
297323 ${when(isOrg && accessAllows(access, "read"), () =>
298324 teamsSection(ctx, owner, canManage),
299325 )}`;
test/studio.test.ts +190
@@ -1635,6 +1635,196 @@ describe("the studio", () => {
16351635 });
16361636 });
16371637
1638 describe("an owner's page lists an app once, not once in each vocabulary (#221)", () => {
1639 let dataDir: string;
1640 let app: App;
1641 let servers: Servers;
1642 let base: string;
1643
1644 beforeAll(async () => {
1645 dataDir = await mkdtemp(join(tmpdir(), "forge-owner-once-"));
1646 app = createApp({ dataDir, port: 0 });
1647 servers = await serve(app);
1648 base = servers.url;
1649 });
1650
1651 afterAll(async () => {
1652 servers.stop();
1653 app.close();
1654 await rm(dataDir, { recursive: true, force: true });
1655 });
1656
1657 const signIn = async (username: string, password: string) =>
1658 (
1659 (
1660 await fetch(`${base}/signin`, {
1661 method: "POST",
1662 body: new URLSearchParams({ username, password }),
1663 redirect: "manual",
1664 })
1665 ).headers.get("set-cookie") ?? ""
1666 ).split(";")[0]!;
1667
1668 const occurrences = (haystack: string, needle: string): number =>
1669 haystack.split(needle).length - 1;
1670
1671 test("a person with one app and one plain repository renders each once in the studio, and both together in one list in dev mode", async () => {
1672 const dan = await createUser(app.db, {
1673 username: "dan",
1674 email: "[email protected]",
1675 password: "a-good-passphrase",
1676 adminIfFirst: true,
1677 });
1678 if (!dan.ok) throw new Error(dan.error);
1679 const cookie = await signIn("dan", "a-good-passphrase");
1680
1681 const madeApp = await createRepo(app, dan.value, {
1682 name: "beacon",
1683 visibility: "public",
1684 description: "a lighthouse",
1685 });
1686 if (!madeApp.ok) throw new Error(String(madeApp.error));
1687 const appSeeded = await commitToBranch(app, dan.value, madeApp.value, {
1688 message: "Start",
1689 files: [{ path: "README.md", content: "hi\n" }],
1690 });
1691 if (!appSeeded.ok) throw new Error(JSON.stringify(appSeeded.error));
1692 const enabled = await enableApp(app, dan.value, madeApp.value, {
1693 slug: "beacon",
1694 });
1695 if (!enabled.ok) throw new Error(String(enabled.error));
1696
1697 const madePlain = await createRepo(app, dan.value, {
1698 name: "scratch",
1699 visibility: "public",
1700 description: "not an app",
1701 });
1702 if (!madePlain.ok) throw new Error(String(madePlain.error));
1703 const plainSeeded = await commitToBranch(app, dan.value, madePlain.value, {
1704 message: "Start",
1705 files: [{ path: "README.md", content: "hi\n" }],
1706 });
1707 if (!plainSeeded.ok) throw new Error(JSON.stringify(plainSeeded.error));
1708
1709 const studio = await (
1710 await fetch(`${base}/dan`, {
1711 headers: { cookie: `${cookie}; forge_mode=studio` },
1712 redirect: "manual",
1713 })
1714 ).text();
1715 expect(occurrences(studio, 'href="/dan/beacon"')).toBe(1);
1716 expect(occurrences(studio, 'href="/dan/scratch"')).toBe(1);
1717 expect(studio).toContain("<h2>Other repositories</h2>");
1718
1719 const dev = await (
1720 await fetch(`${base}/dan`, {
1721 headers: { cookie: `${cookie}; forge_mode=dev` },
1722 redirect: "manual",
1723 })
1724 ).text();
1725 expect(occurrences(dev, 'href="/dan/beacon"')).toBe(1);
1726 expect(occurrences(dev, 'href="/dan/scratch"')).toBe(1);
1727 expect(dev).not.toContain("<h2>Apps</h2>");
1728 expect(dev).not.toContain("<h2>Other repositories</h2>");
1729 });
1730
1731 test("an owner whose only repository is an app reads the empty list below as no other repositories, not as a bug", async () => {
1732 const eve = await createUser(app.db, {
1733 username: "eve",
1734 email: "[email protected]",
1735 password: "a-good-passphrase",
1736 });
1737 if (!eve.ok) throw new Error(eve.error);
1738 const cookie = await signIn("eve", "a-good-passphrase");
1739
1740 const madeApp = await createRepo(app, eve.value, {
1741 name: "lighthouse",
1742 visibility: "public",
1743 });
1744 if (!madeApp.ok) throw new Error(String(madeApp.error));
1745 const seeded = await commitToBranch(app, eve.value, madeApp.value, {
1746 message: "Start",
1747 files: [{ path: "README.md", content: "hi\n" }],
1748 });
1749 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
1750 const enabled = await enableApp(app, eve.value, madeApp.value, {
1751 slug: "lighthouse",
1752 });
1753 if (!enabled.ok) throw new Error(String(enabled.error));
1754
1755 const studio = await (
1756 await fetch(`${base}/eve`, {
1757 headers: { cookie: `${cookie}; forge_mode=studio` },
1758 redirect: "manual",
1759 })
1760 ).text();
1761 expect(occurrences(studio, 'href="/eve/lighthouse"')).toBe(1);
1762 expect(studio).toContain("No other repositories");
1763 expect(studio).not.toContain("<h2>Other repositories</h2>");
1764 });
1765
1766 test("an org's page draws the same way: its app once, its plain repository once, and Members/Teams unmoved", async () => {
1767 const fay = await createUser(app.db, {
1768 username: "fay",
1769 email: "[email protected]",
1770 password: "a-good-passphrase",
1771 });
1772 if (!fay.ok) throw new Error(fay.error);
1773 const cookie = await signIn("fay", "a-good-passphrase");
1774
1775 const org = await createOrg(app, fay.value, "vega");
1776 if (!org.ok) throw new Error(org.error);
1777
1778 const madeApp = await createRepo(app, fay.value, {
1779 name: "anvil",
1780 owner: "vega",
1781 visibility: "public",
1782 });
1783 if (!madeApp.ok) throw new Error(String(madeApp.error));
1784 const appSeeded = await commitToBranch(app, fay.value, madeApp.value, {
1785 message: "Start",
1786 files: [{ path: "README.md", content: "hi\n" }],
1787 });
1788 if (!appSeeded.ok) throw new Error(JSON.stringify(appSeeded.error));
1789 const enabled = await enableApp(app, fay.value, madeApp.value, {
1790 slug: "anvil",
1791 });
1792 if (!enabled.ok) throw new Error(String(enabled.error));
1793
1794 const madePlain = await createRepo(app, fay.value, {
1795 name: "ledger",
1796 owner: "vega",
1797 visibility: "public",
1798 });
1799 if (!madePlain.ok) throw new Error(String(madePlain.error));
1800 const plainSeeded = await commitToBranch(app, fay.value, madePlain.value, {
1801 message: "Start",
1802 files: [{ path: "README.md", content: "hi\n" }],
1803 });
1804 if (!plainSeeded.ok) throw new Error(JSON.stringify(plainSeeded.error));
1805
1806 const studio = await (
1807 await fetch(`${base}/vega`, {
1808 headers: { cookie: `${cookie}; forge_mode=studio` },
1809 redirect: "manual",
1810 })
1811 ).text();
1812 expect(occurrences(studio, 'href="/vega/anvil"')).toBe(1);
1813 expect(occurrences(studio, 'href="/vega/ledger"')).toBe(1);
1814 expect(studio).toContain('<h2 class="mt">Members</h2>');
1815 expect(studio).toContain('<h2 class="mt">Teams</h2>');
1816
1817 const dev = await (
1818 await fetch(`${base}/vega`, {
1819 headers: { cookie: `${cookie}; forge_mode=dev` },
1820 redirect: "manual",
1821 })
1822 ).text();
1823 expect(occurrences(dev, 'href="/vega/anvil"')).toBe(1);
1824 expect(occurrences(dev, 'href="/vega/ledger"')).toBe(1);
1825 });
1826 });
1827
16381828 describe("every open pull's preview on the studio page", () => {
16391829 let dataDir: string;
16401830 let app: App;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs