Skip to content

Commit bd6f032

Browse files
authored
Merge pull request #990 from samsondev23/fixes-and-feat
Fixes and feat
2 parents 9e1fbf2 + 25bf29c commit bd6f032

9 files changed

Lines changed: 633 additions & 25 deletions

‎src/components/GovernanceDashboard.test.tsx‎

Lines changed: 170 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,10 @@ function makeClient(overrides?: {
4747
delegate?: unknown;
4848
voteError?: string | null;
4949
delegateError?: string | null;
50+
revokeError?: string | null;
51+
voteThrows?: string;
5052
}) {
53+
let currentDelegate = overrides?.delegate ?? null;
5154
const invokeContract = vi.fn(async (params: InvokeParams) => {
5255
switch (params.method) {
5356
case "get_proposals":
@@ -58,11 +61,19 @@ function makeClient(overrides?: {
5861
};
5962
case "get_delegate":
6063
return {
61-
data: overrides?.delegate ?? null,
64+
data: currentDelegate,
6265
error: null,
6366
status: "success" as const,
6467
};
68+
case "revoke_delegation":
69+
if (!overrides?.revokeError) currentDelegate = null;
70+
return {
71+
data: overrides?.revokeError ? null : { hash: "tx_revoke" },
72+
error: overrides?.revokeError ?? null,
73+
status: overrides?.revokeError ? ("error" as const) : ("success" as const),
74+
};
6575
case "vote":
76+
if (overrides?.voteThrows) throw new Error(overrides.voteThrows);
6677
return {
6778
data: overrides?.voteError ? null : { hash: "tx_vote" },
6879
error: overrides?.voteError ?? null,
@@ -316,4 +327,162 @@ describe("GovernanceDashboard", () => {
316327
);
317328
});
318329
});
330+
// ─── Closed / executed proposals ───────────────────────────────────────────
331+
describe("closed proposals", () => {
332+
const withStatus = (id: string, status: string): GovernanceProposal => ({
333+
id,
334+
title: `Proposal ${status}`,
335+
description: `A ${status} proposal.`,
336+
status,
337+
votesFor: 10,
338+
votesAgainst: 5,
339+
votesAbstain: 1,
340+
});
341+
342+
it.each(["closed", "executed"])("disables every vote button for a %s proposal", async (status) => {
343+
const { client, invokeContract } = makeClient({ proposals: [withStatus("9", status)] });
344+
mockSorokit(client);
345+
346+
render(<GovernanceDashboard />);
347+
348+
const card = (await screen.findByText(`Proposal ${status}`)).closest('[role="listitem"]') as HTMLElement;
349+
expect(within(card).getByText(status)).toBeInTheDocument();
350+
expect(within(card).getByRole("button", { name: "Vote Yes" })).toBeDisabled();
351+
expect(within(card).getByRole("button", { name: "Vote No" })).toBeDisabled();
352+
expect(within(card).getByRole("button", { name: "Vote Abstain" })).toBeDisabled();
353+
expect(within(card).getByTestId("voting-closed-note")).toHaveTextContent("Voting closed");
354+
355+
fireEvent.click(within(card).getByRole("button", { name: "Vote Yes" }));
356+
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
357+
expect(invokeContract).not.toHaveBeenCalledWith(expect.objectContaining({ method: "vote" }));
358+
});
359+
360+
it("keeps voting enabled on an active proposal listed next to a closed one", async () => {
361+
const { client } = makeClient({
362+
proposals: [PROPOSALS[0], withStatus("9", "closed")],
363+
});
364+
mockSorokit(client);
365+
366+
render(<GovernanceDashboard />);
367+
368+
await screen.findByText("Proposal closed");
369+
const [active, closed] = screen.getAllByRole("listitem");
370+
expect(within(active).getByRole("button", { name: "Vote Yes" })).toBeEnabled();
371+
expect(within(active).queryByTestId("voting-closed-note")).not.toBeInTheDocument();
372+
expect(within(closed).getByRole("button", { name: "Vote Yes" })).toBeDisabled();
373+
});
374+
});
375+
376+
// ─── Vote failure feedback ─────────────────────────────────────────────────
377+
describe("vote failures", () => {
378+
async function openConfirm(client: SorokitClient) {
379+
mockSorokit(client);
380+
render(<GovernanceDashboard />);
381+
await screen.findByText("Increase validator rewards");
382+
fireEvent.click(within(screen.getAllByRole("listitem")[0]).getByRole("button", { name: "Vote Yes" }));
383+
return screen.findByRole("dialog");
384+
}
385+
386+
it("shows an error banner, no success notice, and allows retrying", async () => {
387+
const { client, invokeContract } = makeClient({ voteError: "tx_failed" });
388+
const dialog = await openConfirm(client);
389+
390+
fireEvent.click(within(dialog).getByRole("button", { name: /confirm vote/i }));
391+
392+
await waitFor(() => expect(within(dialog).getByRole("alert")).toBeInTheDocument());
393+
expect(screen.queryByRole("status")).not.toBeInTheDocument();
394+
expect(within(dialog).getByRole("button", { name: /confirm vote/i })).toBeEnabled();
395+
396+
fireEvent.click(within(dialog).getByRole("button", { name: /confirm vote/i }));
397+
await waitFor(() =>
398+
expect(invokeContract.mock.calls.filter(([p]) => p.method === "vote")).toHaveLength(2),
399+
);
400+
});
401+
402+
it("shows an error banner when the contract invocation throws", async () => {
403+
const { client } = makeClient({ voteThrows: "network down" });
404+
const dialog = await openConfirm(client);
405+
406+
fireEvent.click(within(dialog).getByRole("button", { name: /confirm vote/i }));
407+
408+
await waitFor(() => expect(within(dialog).getByRole("alert")).toBeInTheDocument());
409+
expect(screen.getByRole("dialog")).toBeInTheDocument();
410+
expect(screen.queryByRole("status")).not.toBeInTheDocument();
411+
expect(within(dialog).getByRole("button", { name: /confirm vote/i })).toBeEnabled();
412+
});
413+
414+
it("clears the error when the dialog is cancelled and reopened", async () => {
415+
const { client } = makeClient({ voteError: "tx_failed" });
416+
const dialog = await openConfirm(client);
417+
418+
fireEvent.click(within(dialog).getByRole("button", { name: /confirm vote/i }));
419+
await waitFor(() => expect(within(dialog).getByRole("alert")).toBeInTheDocument());
420+
421+
fireEvent.click(within(dialog).getByRole("button", { name: /cancel/i }));
422+
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
423+
424+
fireEvent.click(within(screen.getAllByRole("listitem")[0]).getByRole("button", { name: "Vote No" }));
425+
const reopened = await screen.findByRole("dialog");
426+
expect(within(reopened).queryByRole("alert")).not.toBeInTheDocument();
427+
});
428+
});
429+
430+
// ─── Delegation revocation ─────────────────────────────────────────────────
431+
describe("delegation revocation", () => {
432+
it("hides the revoke button when there is no delegate", async () => {
433+
const { client } = makeClient({ delegate: null });
434+
mockSorokit(client);
435+
436+
render(<GovernanceDashboard />);
437+
438+
await screen.findByTestId("current-delegate");
439+
expect(screen.queryByRole("button", { name: /revoke delegation/i })).not.toBeInTheDocument();
440+
});
441+
442+
it("revokes the current delegate and returns to self-voting", async () => {
443+
const user = userEvent.setup();
444+
const { client, invokeContract } = makeClient({ delegate: VALID_ADDRESS });
445+
mockSorokit(client);
446+
447+
render(<GovernanceDashboard />);
448+
449+
await waitFor(() =>
450+
expect(screen.getByTestId("current-delegate")).toHaveAttribute("title", VALID_ADDRESS),
451+
);
452+
await user.click(screen.getByRole("button", { name: /revoke delegation/i }));
453+
454+
await waitFor(() =>
455+
expect(invokeContract).toHaveBeenCalledWith(
456+
expect.objectContaining({
457+
method: "revoke_delegation",
458+
sourceAccount: VALID_ADDRESS,
459+
}),
460+
),
461+
);
462+
await waitFor(() =>
463+
expect(screen.getByRole("status")).toHaveTextContent(/delegation revoked/i),
464+
);
465+
await waitFor(() =>
466+
expect(screen.getByTestId("current-delegate")).toHaveTextContent(/none/i),
467+
);
468+
expect(screen.queryByRole("button", { name: /revoke delegation/i })).not.toBeInTheDocument();
469+
});
470+
471+
it("shows an error and keeps the delegate when revocation fails", async () => {
472+
const user = userEvent.setup();
473+
const { client } = makeClient({ delegate: VALID_ADDRESS, revokeError: "tx_failed" });
474+
mockSorokit(client);
475+
476+
render(<GovernanceDashboard />);
477+
478+
await waitFor(() =>
479+
expect(screen.getByTestId("current-delegate")).toHaveAttribute("title", VALID_ADDRESS),
480+
);
481+
await user.click(screen.getByRole("button", { name: /revoke delegation/i }));
482+
483+
await waitFor(() => expect(screen.getByRole("alert")).toBeInTheDocument());
484+
expect(screen.getByTestId("current-delegate")).toHaveAttribute("title", VALID_ADDRESS);
485+
expect(screen.queryByRole("status")).not.toBeInTheDocument();
486+
});
487+
});
319488
});

‎src/components/GovernanceDashboard.tsx‎

Lines changed: 72 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,21 @@ export type ProposalStatus =
4444
| "rejected"
4545
| "pending"
4646
| "executed"
47+
| "closed"
4748
| (string & {});
4849

50+
/** Statuses after which voting is over and no new votes can be cast. */
51+
const VOTING_CLOSED_STATUSES: ReadonlySet<string> = new Set([
52+
"closed",
53+
"executed",
54+
"passed",
55+
"rejected",
56+
]);
57+
58+
export function isVotingClosed(status: ProposalStatus): boolean {
59+
return VOTING_CLOSED_STATUSES.has(status);
60+
}
61+
4962
export interface GovernanceProposal {
5063
id: string;
5164
title: string;
@@ -63,7 +76,7 @@ export type VoteChoice = "yes" | "no" | "abstain";
6376
export interface GovernanceDashboardProps {
6477
/**
6578
* Governance contract ID that exposes `get_proposals`, `get_delegate`,
66-
* `vote`, and `delegate` methods. Defaults to a placeholder so the panel is
79+
* `vote`, `delegate`, and `revoke_delegation` methods. Defaults to a placeholder so the panel is
6780
* usable in demos before a real contract is wired up.
6881
*/
6982
contractId?: string;
@@ -133,6 +146,7 @@ function statusVariant(
133146
return "primary";
134147
case "pending":
135148
return "warning";
149+
case "closed":
136150
default:
137151
return "default";
138152
}
@@ -173,7 +187,9 @@ interface ProposalCardProps {
173187
onVote: (proposal: GovernanceProposal, choice: VoteChoice) => void;
174188
}
175189

176-
function ProposalCard({ proposal, disabled, onVote }: ProposalCardProps) {
190+
function ProposalCard({ proposal, disabled: walletDisabled, onVote }: ProposalCardProps) {
191+
const votingClosed = isVotingClosed(proposal.status);
192+
const disabled = walletDisabled || votingClosed;
177193
return (
178194
<div
179195
role="listitem"
@@ -218,6 +234,11 @@ function ProposalCard({ proposal, disabled, onVote }: ProposalCardProps) {
218234
>
219235
Vote Abstain
220236
</Button>
237+
{votingClosed && (
238+
<span className="self-center text-[11px] text-ink-3" data-testid="voting-closed-note">
239+
Voting closed
240+
</span>
241+
)}
221242
</div>
222243
</div>
223244
);
@@ -334,6 +355,7 @@ interface DelegationPanelProps {
334355
submitting: boolean;
335356
error: string | null;
336357
onDelegate: (target: string) => void;
358+
onRevoke: () => void;
337359
}
338360

339361
function DelegationPanel({
@@ -342,6 +364,7 @@ function DelegationPanel({
342364
submitting,
343365
error,
344366
onDelegate,
367+
onRevoke,
345368
}: DelegationPanelProps) {
346369
const [value, setValue] = useState("");
347370
const trimmed = value.trim();
@@ -395,6 +418,17 @@ function DelegationPanel({
395418
>
396419
{submitting ? "Delegating…" : "Delegate votes"}
397420
</Button>
421+
{currentDelegate && (
422+
<Button
423+
size="sm"
424+
variant="ghost"
425+
className="self-start"
426+
disabled={disabled || submitting}
427+
onClick={onRevoke}
428+
>
429+
Revoke delegation
430+
</Button>
431+
)}
398432
</CardContent>
399433
</Card>
400434
);
@@ -498,12 +532,17 @@ export function GovernanceDashboard({
498532
setVoting(true);
499533
setVoteError(null);
500534

501-
const { error } = await client.soroban.invokeContract({
502-
contractId,
503-
method: "vote",
504-
args: [pendingProposal.id, pendingChoice],
505-
sourceAccount: address ?? undefined,
506-
});
535+
let error: string | null;
536+
try {
537+
({ error } = await client.soroban.invokeContract({
538+
contractId,
539+
method: "vote",
540+
args: [pendingProposal.id, pendingChoice],
541+
sourceAccount: address ?? undefined,
542+
}));
543+
} catch (e) {
544+
error = e instanceof Error ? e.message : "Unknown error";
545+
}
507546

508547
setVoting(false);
509548

@@ -547,6 +586,30 @@ export function GovernanceDashboard({
547586
[client, contractId, address],
548587
);
549588

589+
const revokeDelegation = useCallback(async () => {
590+
if (!client) return;
591+
setDelegating(true);
592+
setDelegateError(null);
593+
594+
const { error } = await client.soroban.invokeContract({
595+
contractId,
596+
method: "revoke_delegation",
597+
args: [],
598+
sourceAccount: address ?? undefined,
599+
});
600+
601+
setDelegating(false);
602+
603+
if (error) {
604+
setDelegateError(friendlyError(error));
605+
return;
606+
}
607+
608+
setDelegate(null);
609+
setNotice("Delegation revoked. You are voting for yourself again.");
610+
setReloadKey((k) => k + 1);
611+
}, [client, contractId, address]);
612+
550613
return (
551614
<div className={cn("flex flex-col gap-5", className)}>
552615
<div>
@@ -627,6 +690,7 @@ export function GovernanceDashboard({
627690
submitting={delegating}
628691
error={delegateError}
629692
onDelegate={submitDelegation}
693+
onRevoke={() => void revokeDelegation()}
630694
/>
631695

632696
{/* Vote confirmation */}

0 commit comments

Comments
 (0)