diff --git a/.changeset/public-node-dao-proposals.md b/.changeset/public-node-dao-proposals.md new file mode 100644 index 00000000..aa360eca --- /dev/null +++ b/.changeset/public-node-dao-proposals.md @@ -0,0 +1,6 @@ +--- +"ui": patch +"api": patch +--- + +Anyone can read a community's Node DAO proposals at `/n//proposals`, with each proposal's status, proposer, kind, votes and description at `/n//proposals/`. The community page links there when the community has a Node DAO. The DAO comes from the new public `getNodeDao` procedure, which reads the community's tenant owner live and returns nothing for communities owned by a person or the platform. Reading the DAO, the proposal list, an older page or a single proposal each fail with a retry instead of an empty list. Testnet DAOs aren't shown yet. diff --git a/api/src/contract.ts b/api/src/contract.ts index e4cc07d2..cbc6ae15 100644 --- a/api/src/contract.ts +++ b/api/src/contract.ts @@ -287,6 +287,11 @@ export const MemberCountSchema = z.object({ observedAt: z.iso.datetime().nullable(), }); +export const NodeDaoSchema = z.object({ + daoAccountId: z.string().nullable(), + network: z.enum(["mainnet", "testnet"]).nullable(), +}); + export const AttendanceRecordSchema = z.object({ events: z.array( z.object({ @@ -1029,6 +1034,17 @@ export const contract = oc.router({ .input(z.object({ nodeId: z.string() })) .output(MemberCountSchema), + getNodeDao: oc + .route({ + method: "GET", + path: "/nodes/{nodeId}/dao", + summary: "Get the Node DAO that owns a community", + description: + "Public — the Sputnik DAO recorded as the community's tenant owner, read live, with its network. Null when a person or the platform owns the community.", + }) + .input(z.object({ nodeId: z.string() })) + .output(NodeDaoSchema), + getMyAttendance: oc .route({ method: "GET", diff --git a/api/src/index.ts b/api/src/index.ts index aa94903f..ecd96075 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -29,6 +29,7 @@ import { type MemberDirectoryService, MemberDirectoryTag, } from "./services/member-directory"; +import { resolveNetwork } from "./services/near-rpc"; import type { NodeEffect, NodeRecord, NodesService } from "./services/nodes"; import { NodesLive, NodesTag } from "./services/nodes"; import { @@ -966,6 +967,16 @@ export default createPlugin.withPlugins()({ return (yield* services.memberDirectory.latestCount(tenant.orgId)) ?? unavailable; }), + getNodeDao: builder.getNodeDao.effect(function* ({ input }) { + const services = yield* ApiServices; + const none = { daoAccountId: null, network: null }; + const node = yield* services.nodes.getById(input.nodeId); + if (!node?.tenantId) return none; + const tenant = yield* services.tenants.resolveTenantById(node.tenantId); + if (!tenant || tenant.ownerKind !== "dao") return none; + return { daoAccountId: tenant.accountId, network: resolveNetwork(tenant.accountId) }; + }), + reconcileAttendance: builder.reconcileAttendance.use(requireAuth).effect(function* ({ input, context, diff --git a/api/tests/integration/node-dao.test.ts b/api/tests/integration/node-dao.test.ts new file mode 100644 index 00000000..4d43c3d8 --- /dev/null +++ b/api/tests/integration/node-dao.test.ts @@ -0,0 +1,73 @@ +import { Effect } from "effect"; +import { afterAll, describe, expect, it, vi } from "vitest"; +import { daoContext, getPluginClient, teardown } from "../setup"; + +vi.mock("@/services/dao", () => ({ + verifyDaoMembership: vi.fn(() => + Effect.succeed({ isSputnikContract: true, isMember: true, policy: { roles: [] } }), + ), + parsePolicyGroupMembers: vi.fn(() => []), + isExplicitDaoMember: vi.fn(() => true), +})); + +afterAll(async () => { + await teardown(); +}); + +async function daoCommunity(daoAccountId: string) { + const id = crypto.randomUUID().slice(0, 8); + const owner = await getPluginClient(daoContext(`dao-owner-${id}`, `dao-org-${id}`, daoAccountId)); + const tenant = await owner.createTenant({ name: "DAO city", accountId: daoAccountId }); + const node = await owner.createNode({ + name: "DAO city", + slug: `dao-city-${id}`, + kind: "city", + tenantId: tenant.id, + }); + return { owner, tenantId: tenant.id, nodeId: node.id }; +} + +describe("public Node DAO", () => { + it("returns the DAO that owns the community, signed out", async () => { + const dao = `nd-${crypto.randomUUID().slice(0, 8)}.sputnik-dao.near`; + const { nodeId } = await daoCommunity(dao); + + const visitor = await getPluginClient(); + expect(await visitor.getNodeDao({ nodeId })).toEqual({ + daoAccountId: dao, + network: "mainnet", + }); + }); + + it("follows a changed owner account on the next read, keeping nothing from before", async () => { + const before = `nd-${crypto.randomUUID().slice(0, 8)}.sputnik-dao.near`; + const after = `nd-${crypto.randomUUID().slice(0, 8)}.sputnik-dao.near`; + const { owner, tenantId, nodeId } = await daoCommunity(before); + const visitor = await getPluginClient(); + expect((await visitor.getNodeDao({ nodeId })).daoAccountId).toBe(before); + + await owner.updateTenant({ tenantId, accountId: after }); + + expect(await visitor.getNodeDao({ nodeId })).toEqual({ + daoAccountId: after, + network: "mainnet", + }); + }); + + it("reads the network from a testnet account", async () => { + const dao = `nd-${crypto.randomUUID().slice(0, 8)}.sputnik-dao.testnet`; + const { nodeId } = await daoCommunity(dao); + + expect(await (await getPluginClient()).getNodeDao({ nodeId })).toEqual({ + daoAccountId: dao, + network: "testnet", + }); + }); + + it("has no DAO for an unknown community", async () => { + expect(await (await getPluginClient()).getNodeDao({ nodeId: crypto.randomUUID() })).toEqual({ + daoAccountId: null, + network: null, + }); + }); +}); diff --git a/ui/src/i18n/messages/public.ts b/ui/src/i18n/messages/public.ts index 793e2d21..87c7fcef 100644 --- a/ui/src/i18n/messages/public.ts +++ b/ui/src/i18n/messages/public.ts @@ -1008,4 +1008,137 @@ export const publicMessages = createMessageCatalogs(["en", "es", "fr", "zh"], { "Le pool détient {total} de {count} stakers · frais de {fee}.", "质押池共有 {count} 位质押者的 {total} · 费率 {fee}。", ], + "daoProposals.title": ["Proposals", "Propuestas", "Propositions", "提案"], + "daoProposals.description": [ + "Proposals to {dao}, this community's Node DAO. Anyone can read them.", + "Propuestas a {dao}, la Node DAO de esta comunidad. Cualquiera puede leerlas.", + "Propositions à {dao}, la Node DAO de cette communauté. Tout le monde peut les lire.", + "提交给本社区 Node DAO {dao} 的提案,任何人都可以查看。", + ], + "daoProposals.back": [ + "Back to {community}", + "Volver a {community}", + "Retour à {community}", + "返回 {community}", + ], + "daoProposals.noDao": [ + "This community doesn't have a Node DAO yet.", + "Esta comunidad aún no tiene una Node DAO.", + "Cette communauté n'a pas encore de Node DAO.", + "该社区还没有 Node DAO。", + ], + "daoProposals.testnet": [ + "Proposals from testnet DAOs aren't shown here yet.", + "Las propuestas de DAO en testnet aún no se muestran aquí.", + "Les propositions des DAO testnet ne sont pas encore affichées ici.", + "这里暂不显示测试网 DAO 的提案。", + ], + "daoProposals.daoError": [ + "Couldn't look up this community's Node DAO.", + "No se pudo consultar la Node DAO de esta comunidad.", + "Impossible de trouver la Node DAO de cette communauté.", + "无法查询该社区的 Node DAO。", + ], + "daoProposals.loadError": [ + "Couldn't load proposals from {dao}.", + "No se pudieron cargar las propuestas de {dao}.", + "Impossible de charger les propositions de {dao}.", + "无法加载 {dao} 的提案。", + ], + "daoProposals.retry": ["Try again", "Reintentar", "Réessayer", "重试"], + "daoProposals.empty": [ + "No proposals yet.", + "Aún no hay propuestas.", + "Aucune proposition pour l'instant.", + "暂无提案。", + ], + "daoProposals.showOlder": [ + "Show older", + "Ver anteriores", + "Voir les plus anciennes", + "查看更早的提案", + ], + "daoProposals.proposedBy": [ + "Proposed by {account}", + "Propuesta de {account}", + "Proposée par {account}", + "提案人 {account}", + ], + "daoProposals.submitted": ["Submitted", "Enviada", "Soumise", "提交时间"], + "daoProposals.proposer": ["Proposer", "Proponente", "Auteur", "提案人"], + "daoProposals.status": ["Status", "Estado", "Statut", "状态"], + "daoProposals.votes": ["Votes", "Votos", "Votes", "投票"], + "daoProposals.voteTally": [ + "{approve} approve · {reject} reject", + "{approve} a favor · {reject} en contra", + "{approve} pour · {reject} contre", + "{approve} 赞成 · {reject} 反对", + ], + "daoProposals.number": [ + "Proposal #{id}", + "Propuesta n.º {id}", + "Proposition n° {id}", + "提案 #{id}", + ], + "daoProposals.notFound": [ + "Proposal not found", + "Propuesta no encontrada", + "Proposition introuvable", + "未找到提案", + ], + "daoProposals.notFoundDescription": [ + "Proposal #{id} doesn't exist in {dao}, or it was removed.", + "La propuesta n.º {id} no existe en {dao} o fue eliminada.", + "La proposition n° {id} n'existe pas dans {dao}, ou elle a été supprimée.", + "{dao} 中不存在提案 #{id},或它已被移除。", + ], + "daoProposals.detailError": [ + "Couldn't load proposal #{id} from {dao}.", + "No se pudo cargar la propuesta n.º {id} de {dao}.", + "Impossible de charger la proposition n° {id} de {dao}.", + "无法从 {dao} 加载提案 #{id}。", + ], + "daoProposals.all": [ + "All proposals", + "Todas las propuestas", + "Toutes les propositions", + "全部提案", + ], + "daoProposals.openInTrezu": [ + "Open the DAO in Trezu", + "Abrir la DAO en Trezu", + "Ouvrir la DAO dans Trezu", + "在 Trezu 中打开该 DAO", + ], + "daoProposals.noDescription": [ + "No description", + "Sin descripción", + "Aucune description", + "无描述", + ], + "daoProposals.kindCall": [ + "Calls {method} on {receiver}", + "Llama a {method} en {receiver}", + "Appelle {method} sur {receiver}", + "调用 {receiver} 上的 {method}", + ], + "daoProposals.kindTransfer": [ + "Transfer to {receiver}", + "Transferencia a {receiver}", + "Transfert vers {receiver}", + "转账给 {receiver}", + ], + "daoProposals.kindOther": [ + "{kind} proposal", + "Propuesta {kind}", + "Proposition {kind}", + "{kind} 提案", + ], + "daoProposals.statusInProgress": ["Open", "Abierta", "Ouverte", "进行中"], + "daoProposals.statusApproved": ["Approved", "Aprobada", "Approuvée", "已通过"], + "daoProposals.statusRejected": ["Rejected", "Rechazada", "Rejetée", "已拒绝"], + "daoProposals.statusRemoved": ["Removed", "Eliminada", "Supprimée", "已移除"], + "daoProposals.statusExpired": ["Expired", "Vencida", "Expirée", "已过期"], + "daoProposals.statusMoved": ["Moved", "Movida", "Déplacée", "已转移"], + "daoProposals.statusFailed": ["Failed", "Fallida", "Échouée", "执行失败"], }); diff --git a/ui/src/lib/queries/node-dao.ts b/ui/src/lib/queries/node-dao.ts new file mode 100644 index 00000000..3bbf9f2c --- /dev/null +++ b/ui/src/lib/queries/node-dao.ts @@ -0,0 +1,40 @@ +import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query"; +import type { ApiClient } from "@/app"; +import { readDaoProposal, readDaoProposalPage } from "@/lib/sputnik-proposals"; + +export const nodeDaoQueryKeys = { + dao: (nodeId: string) => ["node-dao", nodeId] as const, + proposals: (daoAccountId: string) => ["node-dao", "proposals", daoAccountId] as const, + proposal: (daoAccountId: string, id: number) => + ["node-dao", "proposal", daoAccountId, id] as const, +}; + +/** The DAO recorded as the community's owner, read live from City Node (public). */ +export function nodeDaoQueryOptions(apiClient: ApiClient, nodeId: string) { + return queryOptions({ + queryKey: nodeDaoQueryKeys.dao(nodeId), + queryFn: () => apiClient.getNodeDao({ nodeId }), + staleTime: 60_000, + retry: false, + }); +} + +export function daoProposalPagesQueryOptions(daoAccountId: string) { + return infiniteQueryOptions({ + queryKey: nodeDaoQueryKeys.proposals(daoAccountId), + queryFn: ({ pageParam }) => readDaoProposalPage(daoAccountId, { before: pageParam }), + initialPageParam: undefined as number | undefined, + getNextPageParam: (page) => page.nextBefore ?? undefined, + staleTime: 30_000, + retry: false, + }); +} + +export function daoProposalQueryOptions(daoAccountId: string, id: number) { + return queryOptions({ + queryKey: nodeDaoQueryKeys.proposal(daoAccountId, id), + queryFn: () => readDaoProposal(daoAccountId, id), + staleTime: 30_000, + retry: false, + }); +} diff --git a/ui/src/lib/sputnik-proposals.test.ts b/ui/src/lib/sputnik-proposals.test.ts index a1598712..07001c2a 100644 --- a/ui/src/lib/sputnik-proposals.test.ts +++ b/ui/src/lib/sputnik-proposals.test.ts @@ -8,6 +8,8 @@ import { needsDelegationBalance, proposalCallsMethod, proposalMatchesPlan, + readDaoProposal, + readDaoProposalPage, readDaoProposalsSince, readDelegationBalance, readOpenDaoProposals, @@ -350,3 +352,115 @@ describe("readOpenDaoProposals", () => { await expect(readOpenDaoProposals(dao)).rejects.toThrow(`${method} failed`); }); }); + +describe("readDaoProposalPage", () => { + const dao = "example.sputnik-dao.near"; + + afterEach(() => { + vi.restoreAllMocks(); + }); + + function stubDao(last: number, removed: number[] = []) { + return vi.spyOn(Near.prototype, "view").mockImplementation((_contractId, method, args) => { + if (method === "get_last_proposal_id") return Promise.resolve(last); + const { from_index, limit } = args as { from_index: number; limit: number }; + const ids = Array.from({ length: limit }, (_, offset) => from_index + offset).filter( + (id) => id < last && !removed.includes(id), + ); + return Promise.resolve(ids.map((id) => proposalRecord({ id }))); + }); + } + + it("reads the newest page first and pages backwards by ID", async () => { + const view = stubDao(45); + + const first = await readDaoProposalPage(dao, { pageSize: 20 }); + expect(first.proposals.map((proposal) => proposal.id)).toEqual( + Array.from({ length: 20 }, (_, i) => 44 - i), + ); + expect(first.nextBefore).toBe(25); + + const second = await readDaoProposalPage(dao, { before: 25, pageSize: 20 }); + expect(second.proposals.map((proposal) => proposal.id)).toEqual( + Array.from({ length: 20 }, (_, i) => 24 - i), + ); + expect(second.nextBefore).toBe(5); + + const last = await readDaoProposalPage(dao, { before: 5, pageSize: 20 }); + expect(last.proposals.map((proposal) => proposal.id)).toEqual([4, 3, 2, 1, 0]); + expect(last.nextBefore).toBeNull(); + expect(view.mock.calls.filter(([, method]) => method === "get_last_proposal_id")).toHaveLength( + 1, + ); + }); + + it("keeps page boundaries stable when proposals were removed", async () => { + stubDao(10, [8, 6]); + const page = await readDaoProposalPage(dao, { pageSize: 5 }); + expect(page.proposals.map((proposal) => proposal.id)).toEqual([9, 7, 5]); + expect(page.nextBefore).toBe(5); + }); + + it("skips ranges whose proposals were all removed", async () => { + stubDao( + 30, + Array.from({ length: 12 }, (_, i) => 29 - i), + ); + const page = await readDaoProposalPage(dao, { pageSize: 5 }); + expect(page.proposals.map((proposal) => proposal.id)).toEqual([17, 16, 15]); + expect(page.nextBefore).toBe(15); + }); + + it("stops after a few empty ranges and still points at older proposals", async () => { + const view = stubDao( + 40, + Array.from({ length: 30 }, (_, i) => 39 - i), + ); + const page = await readDaoProposalPage(dao, { pageSize: 5 }); + expect(page).toEqual({ proposals: [], nextBefore: 15 }); + expect(view.mock.calls.filter(([, method]) => method === "get_proposals")).toHaveLength(5); + + const older = await readDaoProposalPage(dao, { before: 15, pageSize: 5 }); + expect(older.proposals.map((proposal) => proposal.id)).toEqual([9, 8, 7, 6, 5]); + }); + + it("returns an empty page for a DAO without proposals", async () => { + stubDao(0); + expect(await readDaoProposalPage(dao)).toEqual({ proposals: [], nextBefore: null }); + }); + + it("throws when the DAO can't be read, instead of returning an empty list", async () => { + vi.spyOn(Near.prototype, "view").mockRejectedValue(new Error("RPC unavailable")); + await expect(readDaoProposalPage(dao)).rejects.toThrow("RPC unavailable"); + }); +}); + +describe("readDaoProposal", () => { + const dao = "example.sputnik-dao.near"; + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("reads a proposal by ID, including ones older than the first page", async () => { + const view = vi + .spyOn(Near.prototype, "view") + .mockResolvedValue(proposalRecord({ id: 3, proposer: "member.near" })); + expect(await readDaoProposal(dao, 3)).toMatchObject({ id: 3, proposer: "member.near" }); + expect(view).toHaveBeenCalledWith(dao, "get_proposal", { id: 3 }); + }); + + it("returns null for a missing or removed proposal", async () => { + vi.spyOn(Near.prototype, "view").mockRejectedValue( + new Error( + 'Contract call failed: example.sputnik-dao.near.get_proposal - wasm execution failed with error: HostError(GuestPanic { panic_msg: "panicked at sputnikdao2/src/views.rs:100:48:\\nERR_NO_PROPOSAL" })', + ), + ); + expect(await readDaoProposal(dao, 999)).toBeNull(); + }); + + it("throws on any other failure", async () => { + vi.spyOn(Near.prototype, "view").mockRejectedValue(new Error("RPC unavailable")); + await expect(readDaoProposal(dao, 1)).rejects.toThrow("RPC unavailable"); + }); +}); diff --git a/ui/src/lib/sputnik-proposals.ts b/ui/src/lib/sputnik-proposals.ts index 0614b972..82690672 100644 --- a/ui/src/lib/sputnik-proposals.ts +++ b/ui/src/lib/sputnik-proposals.ts @@ -85,6 +85,62 @@ export async function readRecentDaoProposals( return [...(proposals ?? [])].sort((a, b) => b.id - a.id); } +export interface DaoProposalPage { + proposals: SputnikProposal[]; + /** Pass as `before` to read the next, older page; null when this page reaches proposal 0. */ + nextBefore: number | null; +} + +/** ID ranges one page may read past when every proposal in them was removed. */ +const MAX_EMPTY_RANGES = 5; + +/** + * Reads one page of proposals by ID range, newest first: IDs in + * `[before - pageSize, before)`, or the newest IDs when `before` is omitted. + * Removed proposals leave gaps, so a page can hold fewer than `pageSize`; a + * range with nothing left in it is skipped, up to MAX_EMPTY_RANGES, so a page + * is only empty with a non-null `nextBefore` after a long run of removals. + * Throws when the DAO can't be read. + */ +export async function readDaoProposalPage( + daoAccountId: string, + { before, pageSize = 20 }: { before?: number; pageSize?: number } = {}, +): Promise { + let end = + before ?? Number((await getNear().view(daoAccountId, "get_last_proposal_id", {})) ?? 0); + for (let range = 0; end > 0 && range < MAX_EMPTY_RANGES; range++) { + const fromIndex = Math.max(0, end - pageSize); + const proposals = + (await getNear().view(daoAccountId, "get_proposals", { + from_index: fromIndex, + limit: end - fromIndex, + })) ?? []; + end = fromIndex; + if (proposals.length > 0) { + return { + proposals: [...proposals].sort((a, b) => b.id - a.id), + nextBefore: end > 0 ? end : null, + }; + } + } + return { proposals: [], nextBefore: end > 0 ? end : null }; +} + +/** Reads one proposal by ID; null when it never existed or was removed. Throws on RPC failure. */ +export async function readDaoProposal( + daoAccountId: string, + id: number, +): Promise { + try { + return (await getNear().view(daoAccountId, "get_proposal", { id })) ?? null; + } catch (error) { + if (String(error instanceof Error ? error.message : error).includes("ERR_NO_PROPOSAL")) { + return null; + } + throw error; + } +} + /** Reads the most recent proposals, newest first. */ export async function fetchDaoProposals( daoAccountId: string, diff --git a/ui/src/routes/_public/n/$slug.tsx b/ui/src/routes/_public/n/$slug.tsx index 405b7304..3df14346 100644 --- a/ui/src/routes/_public/n/$slug.tsx +++ b/ui/src/routes/_public/n/$slug.tsx @@ -24,6 +24,7 @@ import { resolveAppLocale, translateAppMessage, useAppTranslation } from "@/i18n import { nodeKindLabel } from "@/lib/node-kind"; import { pageTitle } from "@/lib/page-title"; import { nodeMemberCountQueryOptions } from "@/lib/queries/member-directory"; +import { nodeDaoQueryOptions } from "@/lib/queries/node-dao"; import { childNodesQueryOptions, nodeBySlugQueryOptions, @@ -108,6 +109,11 @@ function NodePage() { enabled: !!nodeId, }); + const { data: nodeDao } = useQuery({ + ...nodeDaoQueryOptions(apiClient, nodeId ?? ""), + enabled: !!nodeId, + }); + if (nodeLoading) { return ( @@ -214,6 +220,22 @@ function NodePage() { )} + {nodeDao?.daoAccountId && ( + + )} diff --git a/ui/src/routes/_public/n/$slug_/proposals/$proposalId.tsx b/ui/src/routes/_public/n/$slug_/proposals/$proposalId.tsx new file mode 100644 index 00000000..b0c423ca --- /dev/null +++ b/ui/src/routes/_public/n/$slug_/proposals/$proposalId.tsx @@ -0,0 +1,42 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; +import { resolveAppLocale, translateAppMessage } from "@/i18n/runtime"; +import { pageTitle } from "@/lib/page-title"; +import { DaoProposalDetail, NodeDaoGate } from "../../-node-dao-proposals"; + +export const Route = createFileRoute("/_public/n/$slug_/proposals/$proposalId")({ + validateSearch: z.object({ parentId: z.uuid().optional() }), + loader: ({ context }) => ({ runtimeConfig: context.runtimeConfig }), + head: ({ loaderData, match, params }) => { + const locale = resolveAppLocale(undefined, match.context.locale); + return { + meta: [ + { + title: pageTitle( + translateAppMessage("daoProposals.number", { id: params.proposalId }, locale), + loaderData?.runtimeConfig, + ), + }, + ], + }; + }, + component: NodeDaoProposalPage, +}); + +function NodeDaoProposalPage() { + const { slug, proposalId } = Route.useParams(); + const { parentId } = Route.useSearch(); + const target = { slug, parentId }; + return ( + + {(resolved) => ( + + )} + + ); +} + +/** A proposal ID is a non-negative integer; anything else is shown as not found. */ +function parseProposalId(value: string): number | null { + return /^\d+$/.test(value) ? Number(value) : null; +} diff --git a/ui/src/routes/_public/n/$slug_/proposals/index.tsx b/ui/src/routes/_public/n/$slug_/proposals/index.tsx new file mode 100644 index 00000000..a6684274 --- /dev/null +++ b/ui/src/routes/_public/n/$slug_/proposals/index.tsx @@ -0,0 +1,35 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { z } from "zod"; +import { resolveAppLocale, translateAppMessage } from "@/i18n/runtime"; +import { pageTitle } from "@/lib/page-title"; +import { DaoProposalList, NodeDaoGate } from "../../-node-dao-proposals"; + +export const Route = createFileRoute("/_public/n/$slug_/proposals/")({ + validateSearch: z.object({ parentId: z.uuid().optional() }), + loader: ({ context }) => ({ runtimeConfig: context.runtimeConfig }), + head: ({ loaderData, match }) => { + const locale = resolveAppLocale(undefined, match.context.locale); + return { + meta: [ + { + title: pageTitle( + translateAppMessage("daoProposals.title", undefined, locale), + loaderData?.runtimeConfig, + ), + }, + ], + }; + }, + component: NodeDaoProposalsPage, +}); + +function NodeDaoProposalsPage() { + const { slug } = Route.useParams(); + const { parentId } = Route.useSearch(); + const target = { slug, parentId }; + return ( + + {(resolved) => } + + ); +} diff --git a/ui/src/routes/_public/n/-node-dao-proposals.test.tsx b/ui/src/routes/_public/n/-node-dao-proposals.test.tsx new file mode 100644 index 00000000..71243c8d --- /dev/null +++ b/ui/src/routes/_public/n/-node-dao-proposals.test.tsx @@ -0,0 +1,367 @@ +// @vitest-environment jsdom +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + createMemoryHistory, + createRootRouteWithContext, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from "@tanstack/react-router"; +import { cleanup, fireEvent, screen, within } from "@testing-library/react"; +import { Near } from "near-kit"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render } from "@/i18n/test-render"; +import { createApiClient } from "@/lib/api"; +import { createAuthClient } from "@/lib/auth"; +import type { SputnikProposal } from "@/lib/sputnik-proposals"; +import { proposalText, proposalTitle } from "./-node-dao-proposals"; +import { Route as DetailRoute } from "./$slug_/proposals/$proposalId"; +import { Route as ListRoute } from "./$slug_/proposals/index"; + +const DAO = "lisbon.sputnik-dao.near"; +const node = { + id: "00000000-0000-4000-8000-000000000010", + parentId: null, + tenantId: "tenant", + slug: "lisbon", + name: "Lisbon", + kind: "city", + metadata: {}, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", +}; + +type DaoResponse = { daoAccountId: string | null; network: string | null } | "fail"; + +const state: { + dao: DaoResponse; + last: number; + removed: Set; + kinds: Map; + failPagesBelow: number | null; + failNext: number; + apiCalls: string[]; +} = { + dao: { daoAccountId: DAO, network: "mainnet" }, + last: 0, + removed: new Set(), + kinds: new Map(), + failPagesBelow: null, + failNext: 0, + apiCalls: [], +}; +const clients: QueryClient[] = []; + +afterEach(() => { + cleanup(); + for (const client of clients.splice(0)) client.clear(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + state.dao = { daoAccountId: DAO, network: "mainnet" }; + state.last = 0; + state.removed.clear(); + state.kinds.clear(); + state.failPagesBelow = null; + state.failNext = 0; + state.apiCalls = []; +}); + +function proposal(id: number): SputnikProposal { + return { + id, + proposer: `member-${id}.near`, + description: `Proposal number ${id}$$$$metadata`, + kind: state.kinds.get(id) ?? { + FunctionCall: { + receiver_id: "lisbon.citynode.near", + actions: [{ method_name: "set_page", args: "", deposit: "0", gas: "0" }], + }, + }, + status: id % 2 === 0 ? "Approved" : "InProgress", + vote_counts: {}, + votes: { "a.near": "Approve", "b.near": "Approve", "c.near": "Reject" }, + submission_time: "1760000000000000000", + }; +} + +function mockBackends() { + vi.stubGlobal("scrollTo", vi.fn()); + vi.spyOn(Near.prototype, "view").mockImplementation((_contractId, method, args) => { + if (state.failNext > 0) { + state.failNext -= 1; + return Promise.reject(new Error("RPC unavailable")); + } + if (method === "get_last_proposal_id") return Promise.resolve(state.last); + if (method === "get_proposal") { + const { id } = args as { id: number }; + if (id >= state.last) { + return Promise.reject(new Error("Contract call failed: get_proposal - ERR_NO_PROPOSAL")); + } + return Promise.resolve(proposal(id)); + } + const { from_index, limit } = args as { from_index: number; limit: number }; + if (state.failPagesBelow !== null && from_index < state.failPagesBelow) { + return Promise.reject(new Error("RPC unavailable")); + } + const ids = Array.from({ length: limit }, (_, i) => from_index + i).filter( + (id) => id < state.last && !state.removed.has(id), + ); + return Promise.resolve(ids.map(proposal)); + }); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + const requestBody = + init?.body ?? (input instanceof Request ? await input.clone().text() : undefined); + const body = JSON.parse( + typeof requestBody === "string" ? requestBody : (url.searchParams.get("data") ?? "{}"), + ); + if (url.pathname === "/api/auth/get-session") return Response.json(null); + const args = body.json ?? {}; + const method = url.pathname.split("/").at(-1) ?? ""; + if (url.pathname.startsWith("/api/rpc/")) state.apiCalls.push(method); + switch (method) { + case "resolveNodeBySlug": + return Response.json({ json: args.slug === node.slug ? node : null }); + case "getNodeDao": + if (state.dao === "fail") return Response.json({ json: null }, { status: 500 }); + return Response.json({ json: state.dao }); + default: + throw new Error(`Unexpected API call: ${method}`); + } + }), + ); +} + +async function show(url: string) { + mockBackends(); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + clients.push(queryClient); + const context = { + apiClient: createApiClient({ hostUrl: "http://localhost", rpcBase: "/api/rpc" }, new Headers()), + queryClient, + runtimeConfig: { + env: "development", + account: "base.near", + networkId: "mainnet", + assetsUrl: "http://localhost/assets", + apiBase: "/api", + rpcBase: "/api/rpc", + hostUrl: "http://localhost", + runtime: { + accountId: "base.near", + gatewayId: "citynode.app", + runtimeBasePath: "/", + title: "City Nodes", + description: "", + hostUrl: "http://localhost", + }, + }, + authClient: createAuthClient({ + runtimeConfig: { + hostUrl: "http://localhost", + auth: { + name: "auth", + url: "http://localhost", + entry: "remoteEntry.js", + variables: { siwn: { recipient: "citynode.app" } }, + }, + }, + }), + }; + const root = createRootRouteWithContext()({ component: Outlet }); + const list = ListRoute.update({ + ...ListRoute.options, + getParentRoute: () => root, + path: "/n/$slug/proposals", + id: undefined, + } as never); + const detail = DetailRoute.update({ + ...DetailRoute.options, + getParentRoute: () => root, + path: "/n/$slug/proposals/$proposalId", + id: undefined, + } as never); + const community = createRoute({ getParentRoute: () => root, path: "/n/$slug" }); + const explore = createRoute({ getParentRoute: () => root, path: "/explore" }); + const router = createRouter({ + routeTree: root.addChildren([list, detail, community, explore]), + history: createMemoryHistory({ initialEntries: [url] }), + context, + defaultPendingMinMs: 0, + }); + await router.load(); + render( + + + , + ); + return router; +} + +describe("public Node DAO proposal list", () => { + it("lists proposals signed out, newest first, and pages to older ones", async () => { + state.last = 25; + await show("/n/lisbon/proposals"); + + const items = await screen.findAllByTestId("dao-proposals.item"); + expect(items).toHaveLength(20); + const newest = within(items[0] as HTMLElement); + expect(newest.getByText("Proposal #24")).toBeTruthy(); + expect(newest.getByText("Approved")).toBeTruthy(); + expect(newest.getByText("Proposed by member-24.near")).toBeTruthy(); + expect(newest.getByText("Calls set_page on lisbon.citynode.near")).toBeTruthy(); + expect(newest.getByText("Proposal number 24")).toBeTruthy(); + expect(items[0]?.getAttribute("href")).toBe("/n/lisbon/proposals/24"); + expect(screen.getByText(/Proposals to lisbon\.sputnik-dao\.near/)).toBeTruthy(); + expect(state.apiCalls).toEqual(["resolveNodeBySlug", "getNodeDao"]); + + fireEvent.click(screen.getByTestId("dao-proposals.show-older")); + expect(await screen.findByText("Proposal #0")).toBeTruthy(); + expect(screen.getAllByTestId("dao-proposals.item")).toHaveLength(25); + expect(screen.queryByTestId("dao-proposals.show-older")).toBeNull(); + }); + + it("shows a retryable error when the DAO can't be read, never an empty list", async () => { + state.last = 3; + state.failNext = 1; + await show("/n/lisbon/proposals"); + + expect(await screen.findByTestId("dao-proposals.load-error")).toBeTruthy(); + expect(screen.queryByTestId("dao-proposals.empty")).toBeNull(); + + fireEvent.click(screen.getByTestId("dao-proposals.load-error-retry")); + expect(await screen.findByText("Proposal #2")).toBeTruthy(); + }); + + it("keeps the loaded page when an older page fails, and retries it", async () => { + state.last = 25; + state.failPagesBelow = 5; + await show("/n/lisbon/proposals"); + await screen.findByText("Proposal #24"); + + fireEvent.click(screen.getByTestId("dao-proposals.show-older")); + expect(await screen.findByTestId("dao-proposals.older-error")).toBeTruthy(); + expect(screen.getAllByTestId("dao-proposals.item")).toHaveLength(20); + + state.failPagesBelow = null; + fireEvent.click(screen.getByTestId("dao-proposals.older-error-retry")); + expect(await screen.findByText("Proposal #0")).toBeTruthy(); + }); + + it("still reaches older proposals when the newest ones were all removed", async () => { + state.last = 125; + for (let id = 25; id < 125; id++) state.removed.add(id); + await show("/n/lisbon/proposals"); + + const older = await screen.findByTestId("dao-proposals.show-older"); + expect(screen.queryByTestId("dao-proposals.empty")).toBeNull(); + expect(screen.queryAllByTestId("dao-proposals.item")).toHaveLength(0); + + fireEvent.click(older); + expect(await screen.findByText("Proposal #24")).toBeTruthy(); + expect(screen.getAllByTestId("dao-proposals.item")).toHaveLength(20); + }); + + it("says when the DAO has no proposals yet", async () => { + await show("/n/lisbon/proposals"); + expect(await screen.findByTestId("dao-proposals.empty")).toBeTruthy(); + }); + + it("describes transfers and kinds it doesn't summarize by name", async () => { + state.last = 2; + state.kinds.set(1, { Transfer: { token_id: "", receiver_id: "fund.near", amount: "1" } }); + state.kinds.set(0, { ChangePolicy: { policy: {} } }); + await show("/n/lisbon/proposals"); + + expect(await screen.findByText("Transfer to fund.near")).toBeTruthy(); + expect(screen.getByText("ChangePolicy proposal")).toBeTruthy(); + }); + + it("says the community has no Node DAO when a person or the platform owns it", async () => { + state.dao = { daoAccountId: null, network: null }; + await show("/n/lisbon/proposals"); + expect(await screen.findByTestId("dao-proposals.no-dao")).toBeTruthy(); + expect(Near.prototype.view).not.toHaveBeenCalled(); + }); + + it("doesn't read testnet DAOs yet", async () => { + state.dao = { daoAccountId: "lisbon.sputnik-dao.testnet", network: "testnet" }; + await show("/n/lisbon/proposals"); + expect(await screen.findByTestId("dao-proposals.testnet")).toBeTruthy(); + expect(Near.prototype.view).not.toHaveBeenCalled(); + }); + + it("offers a retry when the DAO lookup fails", async () => { + state.dao = "fail"; + await show("/n/lisbon/proposals"); + expect(await screen.findByTestId("dao-proposals.dao-error")).toBeTruthy(); + + state.dao = { daoAccountId: DAO, network: "mainnet" }; + fireEvent.click(screen.getByTestId("dao-proposals.dao-error-retry")); + expect(await screen.findByTestId("dao-proposals.empty")).toBeTruthy(); + }); + + it("offers a way back to Explore when the community doesn't exist", async () => { + await show("/n/nowhere/proposals"); + expect(await screen.findByTestId("dao-proposals.back-to-explore")).toBeTruthy(); + expect(state.apiCalls).not.toContain("getNodeDao"); + }); +}); + +describe("public Node DAO proposal detail", () => { + it("reads a proposal older than the first page by ID, with status, proposer and votes", async () => { + state.last = 60; + await show("/n/lisbon/proposals/3"); + + const detail = await screen.findByTestId("dao-proposals.detail"); + expect(within(detail).getByText("Proposal #3")).toBeTruthy(); + expect(within(detail).getByText("Open")).toBeTruthy(); + expect(screen.getByTestId("dao-proposals.proposer").textContent).toBe("member-3.near"); + expect(within(detail).getByText("2 approve · 1 reject")).toBeTruthy(); + expect(within(detail).getByText("Proposal number 3")).toBeTruthy(); + expect(Near.prototype.view).toHaveBeenCalledWith(DAO, "get_proposal", { id: 3 }); + }); + + it("says a missing or removed proposal wasn't found", async () => { + state.last = 5; + await show("/n/lisbon/proposals/42"); + expect(await screen.findByText("Proposal not found")).toBeTruthy(); + expect( + screen.getByText("Proposal #42 doesn't exist in lisbon.sputnik-dao.near, or it was removed."), + ).toBeTruthy(); + }); + + it("treats a malformed ID as not found without reading the chain", async () => { + state.last = 5; + await show("/n/lisbon/proposals/abc"); + expect(await screen.findByText("Proposal not found")).toBeTruthy(); + expect(Near.prototype.view).not.toHaveBeenCalled(); + }); + + it("offers a retry when the proposal can't be read", async () => { + state.last = 5; + state.failNext = 1; + await show("/n/lisbon/proposals/2"); + expect(await screen.findByTestId("dao-proposals.detail-error")).toBeTruthy(); + + fireEvent.click(screen.getByTestId("dao-proposals.detail-error-retry")); + expect(await screen.findByTestId("dao-proposals.detail")).toBeTruthy(); + }); +}); + +describe("proposal descriptions", () => { + const trezu = "* Title: Vote abstain
* Summary: vote.dao.vote()$$$$meta"; + + it("turns Trezu line breaks into lines and drops metadata", () => { + expect(proposalText(trezu)).toBe("* Title: Vote abstain\n* Summary: vote.dao.vote()"); + }); + + it("uses the Title line in the list, or the first line", () => { + expect(proposalTitle(trezu)).toBe("Vote abstain"); + expect(proposalTitle("Set treasury policy\nmore")).toBe("Set treasury policy"); + expect(proposalTitle("")).toBe(""); + }); +}); diff --git a/ui/src/routes/_public/n/-node-dao-proposals.tsx b/ui/src/routes/_public/n/-node-dao-proposals.tsx new file mode 100644 index 00000000..f7a98fcb --- /dev/null +++ b/ui/src/routes/_public/n/-node-dao-proposals.tsx @@ -0,0 +1,463 @@ +import { ArrowLeftIcon, ArrowUpRightIcon, CompassIcon, WarningIcon } from "@phosphor-icons/react"; +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import type { ReactNode } from "react"; +import { useApiClient } from "@/app"; +import { Badge, Button, EmptyState, LocalDate, SectionHeader } from "@/components"; +import { PageContainer } from "@/components/layout/page-container"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useAppTranslation } from "@/i18n/runtime"; +import { + daoProposalPagesQueryOptions, + daoProposalQueryOptions, + nodeDaoQueryOptions, +} from "@/lib/queries/node-dao"; +import { nodeBySlugQueryOptions } from "@/lib/queries/nodes"; +import { type SputnikProposal, trezuDaoUrl } from "@/lib/sputnik-proposals"; + +type Translate = ReturnType; + +export interface NodeDaoTarget { + slug: string; + parentId?: string; +} + +interface ResolvedNodeDao { + node: { id: string; name: string; slug: string }; + daoAccountId: string; +} + +/** + * Resolves the community by slug and its Node DAO from City Node's public + * tenant record, then renders `children`. Every step has its own state, and a + * failure is retryable rather than shown as "no proposals". + */ +export function NodeDaoGate({ + target, + children, +}: { + target: NodeDaoTarget; + children: (resolved: ResolvedNodeDao) => ReactNode; +}) { + const translate = useAppTranslation(); + const apiClient = useApiClient(); + const nodeQuery = useQuery(nodeBySlugQueryOptions(apiClient, target.slug, target.parentId)); + const node = nodeQuery.data; + const daoQuery = useQuery({ + ...nodeDaoQueryOptions(apiClient, node?.id ?? ""), + enabled: !!node?.id, + }); + + if (nodeQuery.isPending || (node && daoQuery.isPending)) { + return ( + + + + ); + } + + if (nodeQuery.isError) { + return ( + + void nodeQuery.refetch()} + testId="dao-proposals.dao-error" + /> + + ); + } + + if (!node) { + return ( + + } + > + {translate("community.backExplore")} + + } + /> + + ); + } + + const back = ( + + ); + + let body: ReactNode; + if (daoQuery.isError) { + body = ( + void daoQuery.refetch()} + testId="dao-proposals.dao-error" + /> + ); + } else if (!daoQuery.data?.daoAccountId) { + body = {translate("daoProposals.noDao")}; + } else if (daoQuery.data.network !== "mainnet") { + body = {translate("daoProposals.testnet")}; + } else { + body = children({ node, daoAccountId: daoQuery.data.daoAccountId }); + } + + return ( + +
+ {back} + {body} +
+
+ ); +} + +export function DaoProposalList({ + node, + daoAccountId, + target, +}: ResolvedNodeDao & { + target: NodeDaoTarget; +}) { + const translate = useAppTranslation(); + const pages = useInfiniteQuery(daoProposalPagesQueryOptions(daoAccountId)); + const proposals = pages.data?.pages.flatMap((page) => page.proposals) ?? []; + + return ( +
+ } + /> + {pages.isPending ? ( + + ) : pages.isError && !pages.data ? ( + void pages.refetch()} + testId="dao-proposals.load-error" + /> + ) : proposals.length === 0 && !pages.hasNextPage ? ( + {translate("daoProposals.empty")} + ) : proposals.length === 0 ? null : ( +
    + {proposals.map((proposal) => ( +
  • + +
    + + {translate("daoProposals.number", { id: proposal.id })} + + + {proposalKindSummary(proposal, translate)} +
    +

    + {proposalTitle(proposal.description) || translate("daoProposals.noDescription")} +

    +
    + + {translate("daoProposals.proposedBy", { account: proposal.proposer })} + + +
    + +
  • + ))} +
+ )} + {pages.data && pages.isFetchNextPageError && ( + void pages.fetchNextPage()} + testId="dao-proposals.older-error" + /> + )} + {pages.data && pages.hasNextPage && !pages.isFetchNextPageError && ( + + )} +
+ ); +} + +export function DaoProposalDetail({ + node, + daoAccountId, + proposalId, + target, +}: ResolvedNodeDao & { proposalId: number | null; target: NodeDaoTarget }) { + const translate = useAppTranslation(); + const query = useQuery({ + ...daoProposalQueryOptions(daoAccountId, proposalId ?? -1), + enabled: proposalId !== null, + }); + const allLink = ( + + ); + + if (proposalId !== null && query.isPending) return ; + if (query.isError) { + return ( + void query.refetch()} + testId="dao-proposals.detail-error" + /> + ); + } + const proposal = proposalId === null ? null : query.data; + if (!proposal) { + return ( + + ); + } + + const tally = voteTally(proposal); + return ( +
+ +
+ + + + + + {proposal.proposer} + + + + + + + {translate("daoProposals.voteTally", tally)} + +
+

+ {proposalText(proposal.description) || translate("daoProposals.noDescription")} +

+ +
+ ); +} + +/* ----------------------------------------------------------------- helpers */ + +const STATUS_KEYS = { + InProgress: "daoProposals.statusInProgress", + Approved: "daoProposals.statusApproved", + Rejected: "daoProposals.statusRejected", + Removed: "daoProposals.statusRemoved", + Expired: "daoProposals.statusExpired", + Moved: "daoProposals.statusMoved", + Failed: "daoProposals.statusFailed", +} as const; + +function StatusBadge({ status, translate }: { status: string; translate: Translate }) { + const key = STATUS_KEYS[status as keyof typeof STATUS_KEYS]; + const variant = + status === "Approved" + ? "success" + : status === "InProgress" + ? "warning" + : status === "Rejected" || status === "Failed" + ? "destructive" + : "secondary"; + return ( + + {key ? translate(key) : status} + + ); +} + +export function proposalKindSummary(proposal: SputnikProposal, translate: Translate): string { + const kind = proposal.kind as Record; + const call = kind.FunctionCall as + | { receiver_id: string; actions: { method_name: string }[] } + | undefined; + if (call) { + return translate("daoProposals.kindCall", { + method: call.actions.map((action) => action.method_name).join(", "), + receiver: call.receiver_id, + }); + } + const transfer = kind.Transfer as { receiver_id: string } | undefined; + if (transfer) return translate("daoProposals.kindTransfer", { receiver: transfer.receiver_id }); + const name = typeof proposal.kind === "string" ? proposal.kind : Object.keys(kind)[0]; + return translate("daoProposals.kindOther", { kind: name ?? "—" }); +} + +/** + * Proposal descriptions are plain text with Trezu's conventions: `
` for + * line breaks, `* Title:` / `* Summary:` lines, and metadata after `$$$$`. + */ +export function proposalText(description: string): string { + return (description.split("$$$$")[0] ?? "") + .replace(//gi, "\n") + .split("\n") + .map((line) => line.trim()) + .join("\n") + .trim(); +} + +/** The `* Title:` line when there is one, otherwise the first line. */ +export function proposalTitle(description: string): string { + const lines = proposalText(description).split("\n").filter(Boolean); + const title = lines.find((line) => /^\*\s*Title:/i.test(line)); + return (title ?? lines[0] ?? "").replace(/^\*\s*Title:\s*/i, "").trim(); +} + +function submittedAt(proposal: SputnikProposal): number { + return Number(BigInt(proposal.submission_time || "0") / 1_000_000n); +} + +export function voteTally(proposal: SputnikProposal) { + const votes = Object.values(proposal.votes ?? {}); + return { + approve: votes.filter((vote) => vote === "Approve").length, + reject: votes.filter((vote) => vote === "Reject").length, + }; +} + +function TrezuLink({ daoAccountId }: { daoAccountId: string }) { + const translate = useAppTranslation(); + return ( + + ); +} + +function Fact({ label, children }: { label: string; children: ReactNode }) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +function Notice({ children, testId }: { children: ReactNode; testId: string }) { + return ( +

+ {children} +

+ ); +} + +function RetryNotice({ + message, + onRetry, + testId, +}: { + message: string; + onRetry: () => void; + testId: string; +}) { + const translate = useAppTranslation(); + return ( +
+

+ + {message} +

+ +
+ ); +} + +function ListSkeleton() { + return ( +
+ + + +
+ ); +} diff --git a/ui/src/routes/_public/n/-node-page.test.tsx b/ui/src/routes/_public/n/-node-page.test.tsx index 57f1a081..565d54df 100644 --- a/ui/src/routes/_public/n/-node-page.test.tsx +++ b/ui/src/routes/_public/n/-node-page.test.tsx @@ -54,6 +54,7 @@ const validator = { const clients: QueryClient[] = []; const memberCounts = new Map(); const listings = new Map | "fail">(); +const nodeDaos = new Map(); afterEach(() => { cleanup(); @@ -62,6 +63,7 @@ afterEach(() => { vi.unstubAllGlobals(); memberCounts.clear(); listings.clear(); + nodeDaos.clear(); }); function mockApi(nodes = [parent, child], ownPoolNodeId?: string) { @@ -110,6 +112,12 @@ function mockApi(nodes = [parent, child], ownPoolNodeId?: string) { return Response.json({ json: memberCounts.get(args.nodeId) ?? { count: null, observedAt: null }, }); + case "getNodeDao": { + const daoAccountId = nodeDaos.get(args.nodeId) ?? null; + return Response.json({ + json: { daoAccountId, network: daoAccountId ? "mainnet" : null }, + }); + } case "getDiscoveryNode": { const listing = listings.get(args.nodeId); if (listing === "fail") return Response.json({ json: null }, { status: 500 }); @@ -445,3 +453,18 @@ it("never shows the members-only bulletin on the public page", async () => { await screen.findByTestId("node-page.updates"); expect(screen.queryByText("Members meet at the back door")).toBeNull(); }); + +it("links to the Node DAO's proposals only when the community has one", async () => { + nodeDaos.set(parent.id, "illinois.sputnik-dao.near"); + mockApi(); + await showNode("/n/illinois"); + const link = await screen.findByTestId("node-page.proposals"); + expect(link.getAttribute("href")).toBe("/n/illinois/proposals"); + + cleanup(); + nodeDaos.clear(); + mockApi(); + await showNode("/n/chicago"); + expect(await screen.findByRole("heading", { name: "Chicago", level: 1 })).toBeTruthy(); + expect(screen.queryByTestId("node-page.proposals")).toBeNull(); +});