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
171 changes: 170 additions & 1 deletion src/components/GovernanceDashboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ function makeClient(overrides?: {
delegate?: unknown;
voteError?: string | null;
delegateError?: string | null;
revokeError?: string | null;
voteThrows?: string;
}) {
let currentDelegate = overrides?.delegate ?? null;
const invokeContract = vi.fn(async (params: InvokeParams) => {
switch (params.method) {
case "get_proposals":
Expand All @@ -58,11 +61,19 @@ function makeClient(overrides?: {
};
case "get_delegate":
return {
data: overrides?.delegate ?? null,
data: currentDelegate,
error: null,
status: "success" as const,
};
case "revoke_delegation":
if (!overrides?.revokeError) currentDelegate = null;
return {
data: overrides?.revokeError ? null : { hash: "tx_revoke" },
error: overrides?.revokeError ?? null,
status: overrides?.revokeError ? ("error" as const) : ("success" as const),
};
case "vote":
if (overrides?.voteThrows) throw new Error(overrides.voteThrows);
return {
data: overrides?.voteError ? null : { hash: "tx_vote" },
error: overrides?.voteError ?? null,
Expand Down Expand Up @@ -316,4 +327,162 @@ describe("GovernanceDashboard", () => {
);
});
});
// ─── Closed / executed proposals ───────────────────────────────────────────
describe("closed proposals", () => {
const withStatus = (id: string, status: string): GovernanceProposal => ({
id,
title: `Proposal ${status}`,
description: `A ${status} proposal.`,
status,
votesFor: 10,
votesAgainst: 5,
votesAbstain: 1,
});

it.each(["closed", "executed"])("disables every vote button for a %s proposal", async (status) => {
const { client, invokeContract } = makeClient({ proposals: [withStatus("9", status)] });
mockSorokit(client);

render(<GovernanceDashboard />);

const card = (await screen.findByText(`Proposal ${status}`)).closest('[role="listitem"]') as HTMLElement;
expect(within(card).getByText(status)).toBeInTheDocument();
expect(within(card).getByRole("button", { name: "Vote Yes" })).toBeDisabled();
expect(within(card).getByRole("button", { name: "Vote No" })).toBeDisabled();
expect(within(card).getByRole("button", { name: "Vote Abstain" })).toBeDisabled();
expect(within(card).getByTestId("voting-closed-note")).toHaveTextContent("Voting closed");

fireEvent.click(within(card).getByRole("button", { name: "Vote Yes" }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(invokeContract).not.toHaveBeenCalledWith(expect.objectContaining({ method: "vote" }));
});

it("keeps voting enabled on an active proposal listed next to a closed one", async () => {
const { client } = makeClient({
proposals: [PROPOSALS[0], withStatus("9", "closed")],
});
mockSorokit(client);

render(<GovernanceDashboard />);

await screen.findByText("Proposal closed");
const [active, closed] = screen.getAllByRole("listitem");
expect(within(active).getByRole("button", { name: "Vote Yes" })).toBeEnabled();
expect(within(active).queryByTestId("voting-closed-note")).not.toBeInTheDocument();
expect(within(closed).getByRole("button", { name: "Vote Yes" })).toBeDisabled();
});
});

// ─── Vote failure feedback ─────────────────────────────────────────────────
describe("vote failures", () => {
async function openConfirm(client: SorokitClient) {
mockSorokit(client);
render(<GovernanceDashboard />);
await screen.findByText("Increase validator rewards");
fireEvent.click(within(screen.getAllByRole("listitem")[0]).getByRole("button", { name: "Vote Yes" }));
return screen.findByRole("dialog");
}

it("shows an error banner, no success notice, and allows retrying", async () => {
const { client, invokeContract } = makeClient({ voteError: "tx_failed" });
const dialog = await openConfirm(client);

fireEvent.click(within(dialog).getByRole("button", { name: /confirm vote/i }));

await waitFor(() => expect(within(dialog).getByRole("alert")).toBeInTheDocument());
expect(screen.queryByRole("status")).not.toBeInTheDocument();
expect(within(dialog).getByRole("button", { name: /confirm vote/i })).toBeEnabled();

fireEvent.click(within(dialog).getByRole("button", { name: /confirm vote/i }));
await waitFor(() =>
expect(invokeContract.mock.calls.filter(([p]) => p.method === "vote")).toHaveLength(2),
);
});

it("shows an error banner when the contract invocation throws", async () => {
const { client } = makeClient({ voteThrows: "network down" });
const dialog = await openConfirm(client);

fireEvent.click(within(dialog).getByRole("button", { name: /confirm vote/i }));

await waitFor(() => expect(within(dialog).getByRole("alert")).toBeInTheDocument());
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.queryByRole("status")).not.toBeInTheDocument();
expect(within(dialog).getByRole("button", { name: /confirm vote/i })).toBeEnabled();
});

it("clears the error when the dialog is cancelled and reopened", async () => {
const { client } = makeClient({ voteError: "tx_failed" });
const dialog = await openConfirm(client);

fireEvent.click(within(dialog).getByRole("button", { name: /confirm vote/i }));
await waitFor(() => expect(within(dialog).getByRole("alert")).toBeInTheDocument());

fireEvent.click(within(dialog).getByRole("button", { name: /cancel/i }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());

fireEvent.click(within(screen.getAllByRole("listitem")[0]).getByRole("button", { name: "Vote No" }));
const reopened = await screen.findByRole("dialog");
expect(within(reopened).queryByRole("alert")).not.toBeInTheDocument();
});
});

// ─── Delegation revocation ─────────────────────────────────────────────────
describe("delegation revocation", () => {
it("hides the revoke button when there is no delegate", async () => {
const { client } = makeClient({ delegate: null });
mockSorokit(client);

render(<GovernanceDashboard />);

await screen.findByTestId("current-delegate");
expect(screen.queryByRole("button", { name: /revoke delegation/i })).not.toBeInTheDocument();
});

it("revokes the current delegate and returns to self-voting", async () => {
const user = userEvent.setup();
const { client, invokeContract } = makeClient({ delegate: VALID_ADDRESS });
mockSorokit(client);

render(<GovernanceDashboard />);

await waitFor(() =>
expect(screen.getByTestId("current-delegate")).toHaveAttribute("title", VALID_ADDRESS),
);
await user.click(screen.getByRole("button", { name: /revoke delegation/i }));

await waitFor(() =>
expect(invokeContract).toHaveBeenCalledWith(
expect.objectContaining({
method: "revoke_delegation",
sourceAccount: VALID_ADDRESS,
}),
),
);
await waitFor(() =>
expect(screen.getByRole("status")).toHaveTextContent(/delegation revoked/i),
);
await waitFor(() =>
expect(screen.getByTestId("current-delegate")).toHaveTextContent(/none/i),
);
expect(screen.queryByRole("button", { name: /revoke delegation/i })).not.toBeInTheDocument();
});

it("shows an error and keeps the delegate when revocation fails", async () => {
const user = userEvent.setup();
const { client } = makeClient({ delegate: VALID_ADDRESS, revokeError: "tx_failed" });
mockSorokit(client);

render(<GovernanceDashboard />);

await waitFor(() =>
expect(screen.getByTestId("current-delegate")).toHaveAttribute("title", VALID_ADDRESS),
);
await user.click(screen.getByRole("button", { name: /revoke delegation/i }));

await waitFor(() => expect(screen.getByRole("alert")).toBeInTheDocument());
expect(screen.getByTestId("current-delegate")).toHaveAttribute("title", VALID_ADDRESS);
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});
});
});
80 changes: 72 additions & 8 deletions src/components/GovernanceDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,21 @@ export type ProposalStatus =
| "rejected"
| "pending"
| "executed"
| "closed"
| (string & {});

/** Statuses after which voting is over and no new votes can be cast. */
const VOTING_CLOSED_STATUSES: ReadonlySet<string> = new Set([
"closed",
"executed",
"passed",
"rejected",
]);

export function isVotingClosed(status: ProposalStatus): boolean {
return VOTING_CLOSED_STATUSES.has(status);
}

export interface GovernanceProposal {
id: string;
title: string;
Expand All @@ -63,7 +76,7 @@ export type VoteChoice = "yes" | "no" | "abstain";
export interface GovernanceDashboardProps {
/**
* Governance contract ID that exposes `get_proposals`, `get_delegate`,
* `vote`, and `delegate` methods. Defaults to a placeholder so the panel is
* `vote`, `delegate`, and `revoke_delegation` methods. Defaults to a placeholder so the panel is
* usable in demos before a real contract is wired up.
*/
contractId?: string;
Expand Down Expand Up @@ -133,6 +146,7 @@ function statusVariant(
return "primary";
case "pending":
return "warning";
case "closed":
default:
return "default";
}
Expand Down Expand Up @@ -173,7 +187,9 @@ interface ProposalCardProps {
onVote: (proposal: GovernanceProposal, choice: VoteChoice) => void;
}

function ProposalCard({ proposal, disabled, onVote }: ProposalCardProps) {
function ProposalCard({ proposal, disabled: walletDisabled, onVote }: ProposalCardProps) {
const votingClosed = isVotingClosed(proposal.status);
const disabled = walletDisabled || votingClosed;
return (
<div
role="listitem"
Expand Down Expand Up @@ -218,6 +234,11 @@ function ProposalCard({ proposal, disabled, onVote }: ProposalCardProps) {
>
Vote Abstain
</Button>
{votingClosed && (
<span className="self-center text-[11px] text-ink-3" data-testid="voting-closed-note">
Voting closed
</span>
)}
</div>
</div>
);
Expand Down Expand Up @@ -334,6 +355,7 @@ interface DelegationPanelProps {
submitting: boolean;
error: string | null;
onDelegate: (target: string) => void;
onRevoke: () => void;
}

function DelegationPanel({
Expand All @@ -342,6 +364,7 @@ function DelegationPanel({
submitting,
error,
onDelegate,
onRevoke,
}: DelegationPanelProps) {
const [value, setValue] = useState("");
const trimmed = value.trim();
Expand Down Expand Up @@ -395,6 +418,17 @@ function DelegationPanel({
>
{submitting ? "Delegating…" : "Delegate votes"}
</Button>
{currentDelegate && (
<Button
size="sm"
variant="ghost"
className="self-start"
disabled={disabled || submitting}
onClick={onRevoke}
>
Revoke delegation
</Button>
)}
</CardContent>
</Card>
);
Expand Down Expand Up @@ -498,12 +532,17 @@ export function GovernanceDashboard({
setVoting(true);
setVoteError(null);

const { error } = await client.soroban.invokeContract({
contractId,
method: "vote",
args: [pendingProposal.id, pendingChoice],
sourceAccount: address ?? undefined,
});
let error: string | null;
try {
({ error } = await client.soroban.invokeContract({
contractId,
method: "vote",
args: [pendingProposal.id, pendingChoice],
sourceAccount: address ?? undefined,
}));
} catch (e) {
error = e instanceof Error ? e.message : "Unknown error";
}

setVoting(false);

Expand Down Expand Up @@ -547,6 +586,30 @@ export function GovernanceDashboard({
[client, contractId, address],
);

const revokeDelegation = useCallback(async () => {
if (!client) return;
setDelegating(true);
setDelegateError(null);

const { error } = await client.soroban.invokeContract({
contractId,
method: "revoke_delegation",
args: [],
sourceAccount: address ?? undefined,
});

setDelegating(false);

if (error) {
setDelegateError(friendlyError(error));
return;
}

setDelegate(null);
setNotice("Delegation revoked. You are voting for yourself again.");
setReloadKey((k) => k + 1);
}, [client, contractId, address]);

return (
<div className={cn("flex flex-col gap-5", className)}>
<div>
Expand Down Expand Up @@ -627,6 +690,7 @@ export function GovernanceDashboard({
submitting={delegating}
error={delegateError}
onDelegate={submitDelegation}
onRevoke={() => void revokeDelegation()}
/>

{/* Vote confirmation */}
Expand Down
Loading