From 14f372f51ccae3e9cd340e6528fba442e6769d99 Mon Sep 17 00:00:00 2001 From: adenarc <334235131+adenarc@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:44:06 +0530 Subject: [PATCH 1/2] feat: read a community's Node DAO proposals signed out (#413, part 1) - public getNodeDao: the community's tenant owner when it's a DAO, read live, network from the account suffix - /n/$slug/proposals lists proposals newest first with stable pages by ID and Show older - /n/$slug/proposals/$proposalId reads one proposal by ID, older ones included; missing or removed IDs show not found - every read fails with a retry instead of an empty list; testnet DAOs aren't shown yet - community page links to Proposals when there is a Node DAO --- .changeset/public-node-dao-proposals.md | 6 + api/src/contract.ts | 16 + api/src/index.ts | 11 + api/tests/integration/node-dao.test.ts | 73 +++ ui/src/i18n/messages/public.ts | 133 +++++ ui/src/lib/queries/node-dao.ts | 40 ++ ui/src/lib/sputnik-proposals.test.ts | 91 ++++ ui/src/lib/sputnik-proposals.ts | 46 ++ ui/src/routes/_public/n/$slug.tsx | 22 + .../n/$slug_/proposals/$proposalId.tsx | 42 ++ .../_public/n/$slug_/proposals/index.tsx | 35 ++ .../_public/n/-node-dao-proposals.test.tsx | 350 +++++++++++++ .../routes/_public/n/-node-dao-proposals.tsx | 463 ++++++++++++++++++ ui/src/routes/_public/n/-node-page.test.tsx | 23 + 14 files changed, 1351 insertions(+) create mode 100644 .changeset/public-node-dao-proposals.md create mode 100644 api/tests/integration/node-dao.test.ts create mode 100644 ui/src/lib/queries/node-dao.ts create mode 100644 ui/src/routes/_public/n/$slug_/proposals/$proposalId.tsx create mode 100644 ui/src/routes/_public/n/$slug_/proposals/index.tsx create mode 100644 ui/src/routes/_public/n/-node-dao-proposals.test.tsx create mode 100644 ui/src/routes/_public/n/-node-dao-proposals.tsx 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..15e1b8fd 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,92 @@ 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("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..10e136f4 100644 --- a/ui/src/lib/sputnik-proposals.ts +++ b/ui/src/lib/sputnik-proposals.ts @@ -85,6 +85,52 @@ 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; +} + +/** + * 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`. + * Throws when the DAO can't be read. + */ +export async function readDaoProposalPage( + daoAccountId: string, + { before, pageSize = 20 }: { before?: number; pageSize?: number } = {}, +): Promise { + const end = + before ?? Number((await getNear().view(daoAccountId, "get_last_proposal_id", {})) ?? 0); + if (end <= 0) return { proposals: [], nextBefore: null }; + const fromIndex = Math.max(0, end - pageSize); + const proposals = + (await getNear().view(daoAccountId, "get_proposals", { + from_index: fromIndex, + limit: end - fromIndex, + })) ?? []; + return { + proposals: [...proposals].sort((a, b) => b.id - a.id), + nextBefore: fromIndex > 0 ? fromIndex : 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..c8ca1d74 --- /dev/null +++ b/ui/src/routes/_public/n/-node-dao-proposals.test.tsx @@ -0,0 +1,350 @@ +// @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; + kinds: Map; + failPagesBelow: number | null; + failNext: number; + apiCalls: string[]; +} = { + dao: { daoAccountId: DAO, network: "mainnet" }, + last: 0, + 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.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, + ); + 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("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..179bdd30 --- /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 && proposals.length === 0 ? ( + void pages.refetch()} + testId="dao-proposals.load-error" + /> + ) : proposals.length === 0 ? ( + {translate("daoProposals.empty")} + ) : ( +
    + {proposals.map((proposal) => ( +
  • + +
    + + {translate("daoProposals.number", { id: proposal.id })} + + + {proposalKindSummary(proposal, translate)} +
    +

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

    +
    + + {translate("daoProposals.proposedBy", { account: proposal.proposer })} + + +
    + +
  • + ))} +
+ )} + {proposals.length > 0 && pages.isFetchNextPageError && ( + void pages.fetchNextPage()} + testId="dao-proposals.older-error" + /> + )} + {proposals.length > 0 && 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(); +}); From 136dbd5ef5a1e6ba53bcc93570f27f1f22c19369 Mon Sep 17 00:00:00 2001 From: adenarc <334235131+adenarc@users.noreply.github.com> Date: Mon, 2 Nov 2026 02:59:31 +0530 Subject: [PATCH 2/2] fix(ui): reach older proposals when the newest ones were all removed A page now skips ID ranges whose proposals were all removed (up to five), and the list shows Show older whenever there is an older page, instead of saying the DAO has no proposals. --- ui/src/lib/sputnik-proposals.test.ts | 23 ++++++++++++ ui/src/lib/sputnik-proposals.ts | 36 ++++++++++++------- .../_public/n/-node-dao-proposals.test.tsx | 19 +++++++++- .../routes/_public/n/-node-dao-proposals.tsx | 10 +++--- 4 files changed, 69 insertions(+), 19 deletions(-) diff --git a/ui/src/lib/sputnik-proposals.test.ts b/ui/src/lib/sputnik-proposals.test.ts index 15e1b8fd..07001c2a 100644 --- a/ui/src/lib/sputnik-proposals.test.ts +++ b/ui/src/lib/sputnik-proposals.test.ts @@ -401,6 +401,29 @@ describe("readDaoProposalPage", () => { 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 }); diff --git a/ui/src/lib/sputnik-proposals.ts b/ui/src/lib/sputnik-proposals.ts index 10e136f4..82690672 100644 --- a/ui/src/lib/sputnik-proposals.ts +++ b/ui/src/lib/sputnik-proposals.ts @@ -91,29 +91,39 @@ export interface DaoProposalPage { 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`. + * 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 { - const end = + let end = before ?? Number((await getNear().view(daoAccountId, "get_last_proposal_id", {})) ?? 0); - if (end <= 0) return { proposals: [], nextBefore: null }; - const fromIndex = Math.max(0, end - pageSize); - const proposals = - (await getNear().view(daoAccountId, "get_proposals", { - from_index: fromIndex, - limit: end - fromIndex, - })) ?? []; - return { - proposals: [...proposals].sort((a, b) => b.id - a.id), - nextBefore: fromIndex > 0 ? fromIndex : null, - }; + 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. */ diff --git a/ui/src/routes/_public/n/-node-dao-proposals.test.tsx b/ui/src/routes/_public/n/-node-dao-proposals.test.tsx index c8ca1d74..71243c8d 100644 --- a/ui/src/routes/_public/n/-node-dao-proposals.test.tsx +++ b/ui/src/routes/_public/n/-node-dao-proposals.test.tsx @@ -37,6 +37,7 @@ type DaoResponse = { daoAccountId: string | null; network: string | null } | "fa const state: { dao: DaoResponse; last: number; + removed: Set; kinds: Map; failPagesBelow: number | null; failNext: number; @@ -44,6 +45,7 @@ const state: { } = { dao: { daoAccountId: DAO, network: "mainnet" }, last: 0, + removed: new Set(), kinds: new Map(), failPagesBelow: null, failNext: 0, @@ -58,6 +60,7 @@ afterEach(() => { vi.unstubAllGlobals(); state.dao = { daoAccountId: DAO, network: "mainnet" }; state.last = 0; + state.removed.clear(); state.kinds.clear(); state.failPagesBelow = null; state.failNext = 0; @@ -102,7 +105,7 @@ function mockBackends() { return Promise.reject(new Error("RPC unavailable")); } const ids = Array.from({ length: limit }, (_, i) => from_index + i).filter( - (id) => id < state.last, + (id) => id < state.last && !state.removed.has(id), ); return Promise.resolve(ids.map(proposal)); }); @@ -248,6 +251,20 @@ describe("public Node DAO proposal list", () => { 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(); diff --git a/ui/src/routes/_public/n/-node-dao-proposals.tsx b/ui/src/routes/_public/n/-node-dao-proposals.tsx index 179bdd30..f7a98fcb 100644 --- a/ui/src/routes/_public/n/-node-dao-proposals.tsx +++ b/ui/src/routes/_public/n/-node-dao-proposals.tsx @@ -155,15 +155,15 @@ export function DaoProposalList({ /> {pages.isPending ? ( - ) : pages.isError && proposals.length === 0 ? ( + ) : pages.isError && !pages.data ? ( void pages.refetch()} testId="dao-proposals.load-error" /> - ) : proposals.length === 0 ? ( + ) : proposals.length === 0 && !pages.hasNextPage ? ( {translate("daoProposals.empty")} - ) : ( + ) : proposals.length === 0 ? null : (
    {proposals.map((proposal) => (
  • @@ -195,14 +195,14 @@ export function DaoProposalList({ ))}
)} - {proposals.length > 0 && pages.isFetchNextPageError && ( + {pages.data && pages.isFetchNextPageError && ( void pages.fetchNextPage()} testId="dao-proposals.older-error" /> )} - {proposals.length > 0 && pages.hasNextPage && !pages.isFetchNextPageError && ( + {pages.data && pages.hasNextPage && !pages.isFetchNextPageError && (