Public: anyone can read this; the crew can change it.
Your app is listed twice on your own page, in two vocabularies #275
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/221-your-app-is-listed-twice-on-your-own-pag → main 1 commit fork
2 files
changed +225
−9
src/web/pages/org.ts +35 −9
| @@ -57,8 +57,16 @@ const PER_PAGE = 20; | ||
| 57 | 57 | |
| 58 | 58 | // ---- profile ---- |
| 59 | 59 | |
| 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 { | |
| 62 | 70 | const query = (ctx.url.searchParams.get("q") ?? "").slice(0, 100); |
| 63 | 71 | const cursor = ctx.url.searchParams.get("cursor") ?? ""; |
| 64 | 72 | const repos = listReposByOwner(ctx.app.db, owner.username, ctx.auth.user, { |
| @@ -66,12 +74,20 @@ function repoSection(ctx: Ctx, owner: User, isSelf: boolean): Html { | ||
| 66 | 74 | ...(query !== "" ? { query } : {}), |
| 67 | 75 | ...(cursor !== "" ? { cursor } : {}), |
| 68 | 76 | }); |
| 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]; | |
| 71 | 79 | const nextParams = new URLSearchParams(); |
| 72 | 80 | if (query !== "") nextParams.set("q", query); |
| 73 | 81 | if (last) nextParams.set("cursor", last.id); |
| 74 | 82 | |
| 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 | ||
| 75 | 91 | const lead = (repo: Repo) => |
| 76 | 92 | icon(repo.forkParentId !== null ? "fork" : "repo"); |
| 77 | 93 | |
| @@ -92,11 +108,18 @@ function repoSection(ctx: Ctx, owner: User, isSelf: boolean): Html { | ||
| 92 | 108 | ${shown.length === 0 |
| 93 | 109 | ? empty({ |
| 94 | 110 | 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", | |
| 96 | 117 | 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.`, | |
| 100 | 123 | ...(isSelf && query === "" |
| 101 | 124 | ? { |
| 102 | 125 | action: btn("New repository", { |
| @@ -124,6 +147,9 @@ function repoSection(ctx: Ctx, owner: User, isSelf: boolean): Html { | ||
| 124 | 147 | : badge("Public", { variant: "secondary" })}`, |
| 125 | 148 | }), |
| 126 | 149 | ), |
| 150 | studio | |
| 151 | ? { head: html`${icon("repo")}<h2>Other repositories</h2>` } | |
| 152 | : {}, | |
| 127 | 153 | )} |
| 128 | 154 | ${pager( |
| 129 | 155 | repos.length > PER_PAGE && last |
| @@ -293,7 +319,7 @@ export async function ownerProfile(ctx: Ctx): Promise<Response> { | ||
| 293 | 319 | )} |
| 294 | 320 | </div> |
| 295 | 321 | ${apps} |
| 296 | ${repoSection(ctx, owner, isSelf)} | |
| 322 | ${repoSection(ctx, owner, isSelf, studio)} | |
| 297 | 323 | ${when(isOrg && accessAllows(access, "read"), () => |
| 298 | 324 | teamsSection(ctx, owner, canManage), |
| 299 | 325 | )}`; |
test/studio.test.ts +190
| @@ -1635,6 +1635,196 @@ describe("the studio", () => { | ||
| 1635 | 1635 | }); |
| 1636 | 1636 | }); |
| 1637 | 1637 | |
| 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 | ||
| 1638 | 1828 | describe("every open pull's preview on the studio page", () => { |
| 1639 | 1829 | let dataDir: string; |
| 1640 | 1830 | let app: App; |
new above
offline