From bab27b3aa098d8cf12fe4858efa71cbcc17d6285 Mon Sep 17 00:00:00 2001 From: Kappa16 Date: Wed, 29 Jul 2026 23:32:11 +0100 Subject: [PATCH] #367 Extract a reusable "Voting Power Preview" component for governance proposal forms FIXED --- frontend/src/components/ApprovalBar.tsx | 37 ---- .../src/components/CreateProposalModal.tsx | 84 ++++----- frontend/src/components/ProposalCard.tsx | 1 + .../components/VotingPowerPreview.test.tsx | 60 +++++++ .../src/components/VotingPowerPreview.tsx | 120 +++++++++++++ .../VotingPowerPreview.test.tsx.snap | 169 ++++++++++++++++++ .../hooks/__tests__/useOwnerWeights.test.ts | 114 ++++-------- frontend/src/hooks/useOwnerWeights.ts | 6 +- frontend/src/pages/DashboardPage.tsx | 2 +- frontend/src/pages/OwnersPage.tsx | 19 +- 10 files changed, 434 insertions(+), 178 deletions(-) create mode 100644 frontend/src/components/VotingPowerPreview.test.tsx create mode 100644 frontend/src/components/VotingPowerPreview.tsx create mode 100644 frontend/src/components/__snapshots__/VotingPowerPreview.test.tsx.snap diff --git a/frontend/src/components/ApprovalBar.tsx b/frontend/src/components/ApprovalBar.tsx index 873b235..9c485ab 100644 --- a/frontend/src/components/ApprovalBar.tsx +++ b/frontend/src/components/ApprovalBar.tsx @@ -40,40 +40,3 @@ export const ApprovalBar = React.memo(function ApprovalBar({ approvalWeight, quo ); }); -type ApprovalBarProps = { - approvals: number; - threshold: number; - approverAddresses?: string[]; -}; - -export const ApprovalBar = React.memo(function ApprovalBar({ approvals, threshold, approverAddresses = [] }: ApprovalBarProps) { - return ( -
-
- {Array.from({ length: threshold }).map((_, i) => { - const isApproved = i < approvals; - - // Truncate the address if this dot represents an approval - let tooltipTitle = undefined; - if (isApproved && approverAddresses[i]) { - const addr = approverAddresses[i]; - tooltipTitle = `${addr.slice(0, 6)}...${addr.slice(-4)}`; - } - - return ( -
- ); - })} -
- - {approvals}/{threshold} - -
- ); -}); \ No newline at end of file diff --git a/frontend/src/components/CreateProposalModal.tsx b/frontend/src/components/CreateProposalModal.tsx index 4f556fb..631cf77 100644 --- a/frontend/src/components/CreateProposalModal.tsx +++ b/frontend/src/components/CreateProposalModal.tsx @@ -16,6 +16,7 @@ import { getWeightCapPct, } from "../lib/contract"; import { displayToStroops } from "../lib/soroban"; +import { VotingPowerPreview } from "./VotingPowerPreview"; import { StrKey } from "@stellar/stellar-sdk"; import type { ProposalKind } from "../types/accord"; // Testnet token addresses — swap for mainnet when ready @@ -763,12 +764,13 @@ export function CreateProposalModal({ walletAddress, onClose, onSubmitted, trigg {/* Live Voting-Power Preview (MIN_OWNER_WEIGHT = 1) */} {ownerAddress && StrKey.isValidEd25519PublicKey(ownerAddress.trim()) && ( -
-

Live Impact Preview

-

Total voting weight will increase from {totalWeight} to {totalWeight + 1}.

-

New owner percentage share: {(totalWeight + 1 > 0 ? (1 / (totalWeight + 1)) * 100 : 0).toFixed(1)}%.

-

Note: The contract assigns a minimum weight of 1 (MIN_OWNER_WEIGHT) to newly added owners. Custom weights are not supported during owner creation.

-
+ )}
)} @@ -794,25 +796,28 @@ export function CreateProposalModal({ walletAddress, onClose, onSubmitted, trigg {/* Live Quorum-Impact Warning */} - {selectedOwner && ( -
-
-

Live Impact Preview

-

Owner's current weight: {currentWeights[selectedOwner] ?? 1}.

-

Resulting total voting weight: {totalWeight - (currentWeights[selectedOwner] ?? 1)} (threshold: {quorumWeight}).

-
- {totalWeight - (currentWeights[selectedOwner] ?? 1) < quorumWeight && ( -
- - - -

- Warning: Removing this owner drops remaining total weight ({totalWeight - (currentWeights[selectedOwner] ?? 1)}) below the required quorum threshold ({quorumWeight}). Future proposals will not be executable. -

-
- )} -
- )} + {selectedOwner && (() => { + const currentWeight = currentWeights[selectedOwner] ?? 1; + const resultingTotalWeight = totalWeight - currentWeight; + const isQuorumBroken = resultingTotalWeight < quorumWeight; + return ( + + Warning: Removing this owner drops remaining total weight ({resultingTotalWeight}) below the required quorum threshold ({quorumWeight}). Future proposals will not be executable. +

+ ) + }} + /> + ); + })()} )} @@ -884,23 +889,21 @@ export function CreateProposalModal({ walletAddress, onClose, onSubmitted, trigg const exceedsCap = newSharePct > weightCapPct; return ( -
-
-

Live Impact Preview

-

Resulting total voting weight: {nextTotalW}.

-

Owner's new share: {newSharePct.toFixed(1)}% (cap: {weightCapPct}%).

-
- {exceedsCap && ( -
- - - -

+ Warning: Resulting weight share ({newSharePct.toFixed(1)}%) exceeds the contract's configured max weight cap ({weightCapPct}%). This weight change proposal may be rejected by the contract upon execution.

-
- )} -
+ ) + }} + /> ); })()} @@ -1105,6 +1108,7 @@ export function CreateProposalModal({ walletAddress, onClose, onSubmitted, trigg {step === "type" && renderTypeSelector()} {step === "form" && renderForm()} {step === "preview" && renderPreview()} + {step === "confirm" && renderConfirm()} diff --git a/frontend/src/components/ProposalCard.tsx b/frontend/src/components/ProposalCard.tsx index 9ce3e43..6993871 100644 --- a/frontend/src/components/ProposalCard.tsx +++ b/frontend/src/components/ProposalCard.tsx @@ -25,6 +25,7 @@ const KIND_LABELS: Record = { remove_owner: { title: "Remove Owner", badge: "Governance" }, change_threshold: { title: "Change Threshold", badge: "Governance" }, set_spending_limit: { title: "Set Spending Limit", badge: "Spending Limit" }, + change_owner_weight: { title: "Change Owner Weight", badge: "Governance" }, }; // Colour palette per category, mirroring the pill styling used by StatusBadge. diff --git a/frontend/src/components/VotingPowerPreview.test.tsx b/frontend/src/components/VotingPowerPreview.test.tsx new file mode 100644 index 0000000..0716f00 --- /dev/null +++ b/frontend/src/components/VotingPowerPreview.test.tsx @@ -0,0 +1,60 @@ +import React from "react"; +import { render } from "@testing-library/react"; +import { describe, expect, test } from "vitest"; +import { VotingPowerPreview } from "./VotingPowerPreview"; + +describe("VotingPowerPreview", () => { + test("renders add_owner preview correctly", () => { + const { container } = render( + + ); + expect(container.textContent).toContain("Live Impact Preview"); + expect(container.textContent).toContain("Total voting weight will increase from 5 to 6"); + expect(container.textContent).toContain("New owner percentage share: 16.7%"); + expect(container.textContent).toContain("Note: Test owner addition."); + expect(container).toMatchSnapshot(); + }); + + test("renders remove_owner preview correctly with warning", () => { + const { container } = render( + Warning: Total weight falls below threshold!

, + }} + /> + ); + expect(container.textContent).toContain("Live Impact Preview"); + expect(container.textContent).toContain("Owner's current weight: 3"); + expect(container.textContent).toContain("Resulting total voting weight: 7 (threshold: 8)"); + expect(container.textContent).toContain("Warning: Total weight falls below threshold!"); + expect(container).toMatchSnapshot(); + }); + + test("renders change_owner_weight preview correctly", () => { + const { container } = render( + + ); + expect(container.textContent).toContain("Live Impact Preview"); + expect(container.textContent).toContain("Resulting total voting weight: 12"); + expect(container.textContent).toContain("Owner's new share: 41.7% (cap: 50%)"); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/frontend/src/components/VotingPowerPreview.tsx b/frontend/src/components/VotingPowerPreview.tsx new file mode 100644 index 0000000..32c7549 --- /dev/null +++ b/frontend/src/components/VotingPowerPreview.tsx @@ -0,0 +1,120 @@ +import React from "react"; +import { formatWeightPercent } from "../lib/soroban"; + +export interface VotingPowerPreviewProps { + beforeWeight: number; + afterWeight: number; + totalWeight: number; // Resulting total weight after the action + type: "add_owner" | "remove_owner" | "change_owner_weight"; + threshold?: number; // Relevant for remove_owner (quorumWeight) + weightCapPct?: number; // Relevant for change_owner_weight (weightCapPct) + note?: string; + warning?: { + show: boolean; + message: React.ReactNode; + }; +} + +export function VotingPowerPreview({ + beforeWeight, + afterWeight, + totalWeight, + type, + threshold, + weightCapPct, + note, + warning, +}: VotingPowerPreviewProps) { + return ( +
+
+

Live Impact Preview

+ + {type === "add_owner" && ( + <> +

+ Total voting weight will increase from{" "} + {beforeWeight} to{" "} + {afterWeight}. +

+

+ New owner percentage share:{" "} + + {formatWeightPercent(1, totalWeight)} + + . +

+ + )} + + {type === "remove_owner" && ( + <> +

+ Owner's current weight:{" "} + {beforeWeight}. +

+

+ Resulting total voting weight:{" "} + {totalWeight} + {threshold !== undefined && ( + <> + {" "}(threshold:{" "} + {threshold}) + + )} + . +

+ + )} + + {type === "change_owner_weight" && ( + <> +

+ Resulting total voting weight:{" "} + {totalWeight}. +

+

+ Owner's new share:{" "} + + {formatWeightPercent(afterWeight, totalWeight)} + + {weightCapPct !== undefined && ( + <> + {" "}(cap:{" "} + {weightCapPct}%) + + )} + . +

+ + )} + + {note && ( +

+ {note} +

+ )} +
+ + {warning && warning.show && ( +
+ + + +
+ {warning.message} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/__snapshots__/VotingPowerPreview.test.tsx.snap b/frontend/src/components/__snapshots__/VotingPowerPreview.test.tsx.snap new file mode 100644 index 0000000..2ce1d02 --- /dev/null +++ b/frontend/src/components/__snapshots__/VotingPowerPreview.test.tsx.snap @@ -0,0 +1,169 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`VotingPowerPreview > renders add_owner preview correctly 1`] = ` +
+
+
+

+ Live Impact Preview +

+

+ Total voting weight will increase from + + + 5 + + to + + + 6 + + . +

+

+ New owner percentage share: + + + 16.7% + + . +

+

+ Note: Test owner addition. +

+
+
+
+`; + +exports[`VotingPowerPreview > renders change_owner_weight preview correctly 1`] = ` +
+
+
+

+ Live Impact Preview +

+

+ Resulting total voting weight: + + + 12 + + . +

+

+ Owner's new share: + + + 41.7% + + + (cap: + + + 50 + % + + ) + . +

+
+
+
+`; + +exports[`VotingPowerPreview > renders remove_owner preview correctly with warning 1`] = ` +
+
+
+

+ Live Impact Preview +

+

+ Owner's current weight: + + + 3 + + . +

+

+ Resulting total voting weight: + + + 7 + + + (threshold: + + + 8 + + ) + . +

+
+
+ + + +
+

+ Warning: Total weight falls below threshold! +

+
+
+
+
+`; diff --git a/frontend/src/hooks/__tests__/useOwnerWeights.test.ts b/frontend/src/hooks/__tests__/useOwnerWeights.test.ts index 25f5a66..ed60b63 100644 --- a/frontend/src/hooks/__tests__/useOwnerWeights.test.ts +++ b/frontend/src/hooks/__tests__/useOwnerWeights.test.ts @@ -1,122 +1,70 @@ -import { act, renderHook } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import * as contract from "../../lib/contract"; -import { useOwnerWeights } from "../useOwnerWeights"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +// Mock the heavy Stellar SDK globally to avoid high memory overhead during test execution +vi.mock("@stellar/stellar-sdk", () => ({})); vi.mock("../../lib/contract", () => ({ getOwnerWeights: vi.fn(), })); -const intervalMs = 5000; +import { renderHook, waitFor } from "@testing-library/react"; +import * as contract from "../../lib/contract"; +import { useOwnerWeights } from "../useOwnerWeights"; + const initialWeights = [ { address: "GOWNER111", weight: 4 }, { address: "GOWNER222", weight: 6 }, ]; -const refreshedWeights = [ - { address: "GOWNER111", weight: 3 }, - { address: "GOWNER222", weight: 7 }, -]; describe("useOwnerWeights", () => { beforeEach(() => { - vi.useFakeTimers(); vi.clearAllMocks(); - vi.spyOn(console, "error").mockImplementation(() => undefined); - }); - - afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); }); test("fetches owner weights on mount", async () => { vi.mocked(contract.getOwnerWeights).mockResolvedValueOnce(initialWeights); - const { result } = renderHook(() => useOwnerWeights(intervalMs)); + const { result } = renderHook(() => useOwnerWeights(["GOWNER111", "GOWNER222"])); - expect(result.current).toEqual({ - ownerWeights: [], - loading: true, - error: null, - }); + expect(result.current.loading).toBe(true); - await vi.waitFor(() => { + await waitFor(() => { expect(result.current.loading).toBe(false); }); expect(contract.getOwnerWeights).toHaveBeenCalledTimes(1); - expect(result.current.ownerWeights).toEqual(initialWeights); - expect(result.current.error).toBeNull(); - }); - - test("refreshes owner weights on an interval", async () => { - vi.mocked(contract.getOwnerWeights) - .mockResolvedValueOnce(initialWeights) - .mockResolvedValueOnce(refreshedWeights); - - const { result } = renderHook(() => useOwnerWeights(intervalMs)); - - await vi.waitFor(() => { - expect(result.current.ownerWeights).toEqual(initialWeights); - }); - - act(() => { - vi.advanceTimersByTime(intervalMs); + expect(result.current.weights).toEqual({ + GOWNER111: 4, + GOWNER222: 6, }); - - await vi.waitFor(() => { - expect(contract.getOwnerWeights).toHaveBeenCalledTimes(2); - expect(result.current.ownerWeights).toEqual(refreshedWeights); - }); - - expect(result.current.loading).toBe(false); + expect(result.current.totalWeight).toBe(10); expect(result.current.error).toBeNull(); }); - test("keeps cached owner weights when a refresh fails", async () => { - const refreshError = new Error("RPC unavailable"); - vi.mocked(contract.getOwnerWeights) - .mockResolvedValueOnce(initialWeights) - .mockRejectedValueOnce(refreshError); - - const { result } = renderHook(() => useOwnerWeights(intervalMs)); - - await vi.waitFor(() => { - expect(result.current.ownerWeights).toEqual(initialWeights); - }); - - act(() => { - vi.advanceTimersByTime(intervalMs); - }); + test("handles empty ownerAddresses", async () => { + const { result } = renderHook(() => useOwnerWeights([])); - await vi.waitFor(() => { - expect(contract.getOwnerWeights).toHaveBeenCalledTimes(2); - expect(result.current.error).toBe("RPC unavailable"); + await waitFor(() => { + expect(result.current.loading).toBe(false); }); - expect(result.current.ownerWeights).toEqual(initialWeights); - expect(result.current.loading).toBe(false); - expect(console.error).toHaveBeenCalledWith( - "Failed to fetch owner weights", - refreshError, - ); + expect(contract.getOwnerWeights).not.toHaveBeenCalled(); + expect(result.current.weights).toEqual({}); + expect(result.current.totalWeight).toBe(0); + expect(result.current.error).toBeNull(); }); - test("stops refreshing after unmount", async () => { - vi.mocked(contract.getOwnerWeights).mockResolvedValueOnce(initialWeights); - - const { result, unmount } = renderHook(() => useOwnerWeights(intervalMs)); + test("handles error on fetch", async () => { + const fetchError = new Error("RPC unavailable"); + vi.mocked(contract.getOwnerWeights).mockRejectedValueOnce(fetchError); - await vi.waitFor(() => { - expect(result.current.ownerWeights).toEqual(initialWeights); - }); - - unmount(); + const { result } = renderHook(() => useOwnerWeights(["GOWNER111"])); - act(() => { - vi.advanceTimersByTime(intervalMs); + await waitFor(() => { + expect(result.current.loading).toBe(false); }); - expect(contract.getOwnerWeights).toHaveBeenCalledTimes(1); + expect(result.current.error).toBe("RPC unavailable"); + expect(result.current.weights).toEqual({}); + expect(result.current.totalWeight).toBe(0); }); }); diff --git a/frontend/src/hooks/useOwnerWeights.ts b/frontend/src/hooks/useOwnerWeights.ts index c128d14..fd62e32 100644 --- a/frontend/src/hooks/useOwnerWeights.ts +++ b/frontend/src/hooks/useOwnerWeights.ts @@ -16,6 +16,8 @@ export function useOwnerWeights(ownerAddresses: string[]) { error: null, }); + const serializedAddresses = ownerAddresses.join(","); + useEffect(() => { let cancelled = false; if (ownerAddresses.length === 0) { @@ -70,9 +72,9 @@ export function useOwnerWeights(ownerAddresses: string[]) { return () => { cancelled = true; - clearInterval(intervalId); }; - }, [ownerAddresses]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [serializedAddresses]); return state; } diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index fd44ded..c1a332b 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -47,7 +47,7 @@ export function DashboardPage({ // Compute owner weights and quorum weight for weight-based UI const ownerAddresses = owners.map((o) => o.address); - const { weights, totalWeight, loading: weightsLoading } = useOwnerWeights(ownerAddresses); + const { weights, totalWeight } = useOwnerWeights(ownerAddresses); const [quorumWeight, setQuorumWeight] = useState(0); useEffect(() => { diff --git a/frontend/src/pages/OwnersPage.tsx b/frontend/src/pages/OwnersPage.tsx index 055e4fe..c0e45f7 100644 --- a/frontend/src/pages/OwnersPage.tsx +++ b/frontend/src/pages/OwnersPage.tsx @@ -3,7 +3,6 @@ import { getRequiredQuorumWeight, getSpendingLimit } from "../lib/contract"; import { createSpendingLimitProposal } from "../lib/submit"; import { displayToStroops, - stroopsToDisplay, shortenAddr, } from "../lib/soroban"; import { StrKey } from "@stellar/stellar-sdk"; @@ -56,7 +55,7 @@ export function OwnersPage({ owners, ownerAddresses, threshold, - totalOwners, + totalOwners: _totalOwners, walletAddress, onProposalSubmitted, }: OwnersPageProps) { @@ -66,8 +65,8 @@ export function OwnersPage({ loading: weightsLoading, error: weightsError, } = useOwnerWeights(ownerAddresses); - const [spendingLimits, setSpendingLimits] = useState({}); - const [limitsLoading, setLimitsLoading] = useState(true); + const [_spendingLimits, setSpendingLimits] = useState({}); + const [_limitsLoading, setLimitsLoading] = useState(true); const [showForm, setShowForm] = useState(false); const [sortByWeightDesc, setSortByWeightDesc] = useState(false); const [filterMode, setFilterMode] = useState<"all" | "above" | "below">( @@ -209,17 +208,7 @@ export function OwnersPage({ return owner.percentage < thresholdVal; }); - function formatLimit( - limit: bigint, - symbol: string, - ): { text: string; variant: "unrestricted" | "zero" | "configured" } { - if (limit < 0n) return { text: "Unrestricted", variant: "unrestricted" }; - if (limit === 0n) return { text: `0 ${symbol}`, variant: "zero" }; - return { - text: `${stroopsToDisplay(limit)} ${symbol}`, - variant: "configured", - }; - } + async function handleCreateSpendingLimit() { if (!walletAddress) {