diff --git a/.changeset/node-empty-state-ctas.md b/.changeset/node-empty-state-ctas.md new file mode 100644 index 00000000..dc946b03 --- /dev/null +++ b/.changeset/node-empty-state-ctas.md @@ -0,0 +1,5 @@ +--- +"ui": patch +--- + +Route My Node empty states to organization creation, node creation, or node proposals based on the missing resource and viewer role. diff --git a/packages/everything-dev/src/build.ts b/packages/everything-dev/src/build.ts index c88d6768..7e39fddf 100644 --- a/packages/everything-dev/src/build.ts +++ b/packages/everything-dev/src/build.ts @@ -100,9 +100,7 @@ export function resolveCdnProvider(bosConfig: BosConfig | null): "zephyr" | "clo return bosConfig.deploy.cdn ?? "zephyr"; } -export function checkCdnProviderDeployable( - bosConfig: BosConfig | null, -): string | null { +export function checkCdnProviderDeployable(bosConfig: BosConfig | null): string | null { const provider = resolveCdnProvider(bosConfig); if (provider === "zephyr") return null; return "Cloudflare deploy support is not implemented yet — fall back to the zephyr CDN for this release."; diff --git a/ui/src/components/ui/app-detail-content.tsx b/ui/src/components/ui/app-detail-content.tsx index 910c294b..0b7d4300 100644 --- a/ui/src/components/ui/app-detail-content.tsx +++ b/ui/src/components/ui/app-detail-content.tsx @@ -128,12 +128,10 @@ export function AppDetailContent({ const signed = await auth.near.buildSignedDelegateAction( prepared.data.contractId, (builder: TransactionBuilder, receiverId: string) => - builder.functionCall( - receiverId, - prepared.data.methodName, - prepared.data.args, - { gas: "10000000000000", attachedDeposit: 0n }, - ), + builder.functionCall(receiverId, prepared.data.methodName, prepared.data.args, { + gas: "10000000000000", + attachedDeposit: 0n, + }), ); const result = await auth.near.relayTransaction({ payload: signed }); if (result.error) throw new Error(result.error.message || "Relay failed"); @@ -155,12 +153,10 @@ export function AppDetailContent({ return auth.near.buildSignedDelegateAction( prepared.data.contractId, (builder: TransactionBuilder, receiverId: string) => - builder.functionCall( - receiverId, - prepared.data.methodName, - prepared.data.args, - { gas: "10000000000000", attachedDeposit: 0n }, - ), + builder.functionCall(receiverId, prepared.data.methodName, prepared.data.args, { + gas: "10000000000000", + attachedDeposit: 0n, + }), ); }, onSuccess: (payload: string) => { diff --git a/ui/src/routes/_layout/_authenticated/_dashboard/dashboard/node.tsx b/ui/src/routes/_layout/_authenticated/_dashboard/dashboard/node.tsx index d96a85e0..4947ab79 100644 --- a/ui/src/routes/_layout/_authenticated/_dashboard/dashboard/node.tsx +++ b/ui/src/routes/_layout/_authenticated/_dashboard/dashboard/node.tsx @@ -4,6 +4,7 @@ import { getActiveRuntime } from "@/app"; import { Badge, Button, EmptyState, PageContainer, PageHeader } from "@/components"; import { cn } from "@/lib/utils"; import { hasNodeProposalReviewPermission } from "./node/-node-access"; +import { getNodeEmptyStateContent } from "./node/-node-empty-state"; type NodeDashboardSearch = { nodeId?: string }; @@ -88,25 +89,24 @@ export const Route = createFileRoute("/_layout/_authenticated/_dashboard/dashboa function NodeDashboardLayout() { const navigate = useNavigate(); const pathname = useRouterState({ select: (state) => state.location.pathname }); - const { runtimeConfig, nodes, selectedNode, summary, emptyReason } = Route.useRouteContext(); + const context = Route.useRouteContext(); + const { runtimeConfig, nodes, selectedNode, summary, emptyReason } = context; const gateway = getActiveRuntime(runtimeConfig)?.gatewayId; if (!selectedNode || !summary) { - const description = - emptyReason === "no-org" - ? "Join or create an organization to manage a City Node." - : emptyReason === "no-tenant" - ? "Your active organization does not have a tenant deployment yet." - : "Your active organization's tenant does not manage a City Node yet."; + const emptyState = getNodeEmptyStateContent( + emptyReason ?? "no-node", + context.auth.user?.role === "admin", + ); return ( - open organizations + {emptyState.actionLabel} } /> diff --git a/ui/src/routes/_layout/_authenticated/_dashboard/dashboard/node/-node-empty-state.test.ts b/ui/src/routes/_layout/_authenticated/_dashboard/dashboard/node/-node-empty-state.test.ts new file mode 100644 index 00000000..3d843a68 --- /dev/null +++ b/ui/src/routes/_layout/_authenticated/_dashboard/dashboard/node/-node-empty-state.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { getNodeEmptyStateContent } from "./-node-empty-state"; + +describe("node dashboard empty state", () => { + it("sends viewers without an organization to organization creation", () => { + expect(getNodeEmptyStateContent("no-org", false)).toEqual({ + description: "Create an organization to start managing a City Node.", + actionLabel: "create an organization", + actionTo: "/orgs/new", + }); + }); + + it.each([ + { + reason: "no-tenant", + viewer: "administrator", + canCreateNode: true, + actionLabel: "create node", + actionTo: "/admin/tenants/new", + }, + { + reason: "no-node", + viewer: "administrator", + canCreateNode: true, + actionLabel: "create node", + actionTo: "/admin/tenants/new", + }, + { + reason: "no-tenant", + viewer: "member", + canCreateNode: false, + actionLabel: "propose a node", + actionTo: "/apply", + }, + { + reason: "no-node", + viewer: "member", + canCreateNode: false, + actionLabel: "propose a node", + actionTo: "/apply", + }, + ] as const)("sends a $viewer with $reason to $actionTo", (scenario) => { + expect(getNodeEmptyStateContent(scenario.reason, scenario.canCreateNode)).toMatchObject({ + actionLabel: scenario.actionLabel, + actionTo: scenario.actionTo, + }); + }); + + it("explains whether the active organization lacks a tenant or a node", () => { + expect(getNodeEmptyStateContent("no-tenant", false).description).toBe( + "Your active organization does not have a tenant deployment yet.", + ); + expect(getNodeEmptyStateContent("no-node", false).description).toBe( + "Your active organization's tenant does not manage a City Node yet.", + ); + }); +}); diff --git a/ui/src/routes/_layout/_authenticated/_dashboard/dashboard/node/-node-empty-state.ts b/ui/src/routes/_layout/_authenticated/_dashboard/dashboard/node/-node-empty-state.ts new file mode 100644 index 00000000..bcc9d1fb --- /dev/null +++ b/ui/src/routes/_layout/_authenticated/_dashboard/dashboard/node/-node-empty-state.ts @@ -0,0 +1,20 @@ +type NodeDashboardEmptyReason = "no-org" | "no-tenant" | "no-node"; + +export function getNodeEmptyStateContent(reason: NodeDashboardEmptyReason, canCreateNode: boolean) { + if (reason === "no-org") { + return { + description: "Create an organization to start managing a City Node.", + actionLabel: "create an organization", + actionTo: "/orgs/new" as const, + }; + } + + return { + description: + reason === "no-tenant" + ? "Your active organization does not have a tenant deployment yet." + : "Your active organization's tenant does not manage a City Node yet.", + actionLabel: canCreateNode ? "create node" : "propose a node", + actionTo: canCreateNode ? ("/admin/tenants/new" as const) : ("/apply" as const), + }; +} diff --git a/ui/src/routes/_layout/_authenticated/_dashboard/tenant/$tenantId.tsx b/ui/src/routes/_layout/_authenticated/_dashboard/tenant/$tenantId.tsx index f7b4cfc0..2eb08765 100644 --- a/ui/src/routes/_layout/_authenticated/_dashboard/tenant/$tenantId.tsx +++ b/ui/src/routes/_layout/_authenticated/_dashboard/tenant/$tenantId.tsx @@ -96,15 +96,10 @@ async function publishTenantConfig( const signed = await auth.near.buildSignedDelegateAction( prepared.data.contractId, (builder: TransactionBuilder, receiverId: string) => - builder.functionCall( - receiverId, - prepared.data.methodName, - prepared.data.args, - { - gas: CONFIG_GAS, - attachedDeposit: 0n, - }, - ), + builder.functionCall(receiverId, prepared.data.methodName, prepared.data.args, { + gas: CONFIG_GAS, + attachedDeposit: 0n, + }), ); const relayed = await auth.near.relayTransaction({ payload: signed });