Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/node-empty-state-ctas.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 1 addition & 3 deletions packages/everything-dev/src/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Expand Down
20 changes: 8 additions & 12 deletions ui/src/components/ui/app-detail-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -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 (
<PageContainer variant="wide">
<EmptyState
icon={Network}
title="No node available"
description={description}
description={emptyState.description}
action={
<Button asChild>
<Link to="/orgs">open organizations</Link>
<Link to={emptyState.actionTo}>{emptyState.actionLabel}</Link>
</Button>
}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -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.",
);
});
});
Original file line number Diff line number Diff line change
@@ -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),
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
Loading