Skip to content
Open
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
42 changes: 42 additions & 0 deletions apps/web/src/components/ui/ProposalCard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { render, screen } from "@testing-library/react";
import { ProposalCard } from "../ProposalCard";

describe("ProposalCard Action Requirements", () => {
it("renders Execute button only if user is a member and proposal is approved", () => {
const { rerender } = render(
<ProposalCard
proposal={{ id: "1", status: "approved", expiryLedger: 100 }}
currentLedger={50}
isMember={true}
onExecute={() => {}}
onFinalize={() => {}}
/>
);
expect(screen.getByText("Execute Withdrawal")).toBeInTheDocument();

// Re-render with membership set to false
rerender(
<ProposalCard
proposal={{ id: "1", status: "approved", expiryLedger: 100 }}
currentLedger={50}
isMember={false}
onExecute={() => {}}
onFinalize={() => {}}
/>
);
expect(screen.queryByText("Execute Withdrawal")).toBeNull();
});

it("shows Finalize button instead of choice operations on expired entries", () => {
render(
<ProposalCard
proposal={{ id: "2", status: "expired", expiryLedger: 40 }}
currentLedger={50}
isMember={true}
onExecute={() => {}}
onFinalize={() => {}}
/>
);
expect(screen.getByText("Finalize")).toBeInTheDocument();
});
});
95 changes: 95 additions & 0 deletions apps/web/src/components/ui/ProposalCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import React, { useState, useEffect } from "react";

export type ProposalStatus = "pending" | "approved" | "executed" | "rejected" | "expired";

interface ProposalCardProps {
proposal: {
id: string;
status: ProposalStatus;
expiryLedger: number; // Block index target for expiration
};
currentLedger: number; // Passed from global ledger context state sync
isMember: boolean;
onExecute: (id: string) => void;
onFinalize: (id: string) => void;
}

export const ProposalCard: React.FC<ProposalCardProps> = ({
proposal,
currentLedger,
isMember,
onExecute,
onFinalize,
}) => {
const [isCollapsed, setIsCollapsed] = useState(
proposal.status === "executed" || proposal.status === "rejected"
);
const [timeLeft, setTimeLeft] = useState("");

// Calculate countdown: 1 ledger ≈ 5s. Updates every minute.
useEffect(() => {
const calculateTime = () => {
if (proposal.status !== "pending") return;
const ledgersLeft = proposal.expiryLedger - currentLedger;
if (ledgersLeft <= 0) {
setTimeLeft("Expired");
return;
}
const totalSeconds = ledgersLeft * 5;
const minutes = Math.floor(totalSeconds / 60);
setTimeLeft(`${minutes}m left`);
};

calculateTime();
const interval = setInterval(calculateTime, 60000); // 1-minute updates
return () => clearInterval(interval);
}, [currentLedger, proposal.expiryLedger, proposal.status]);

// Map explicitly defined color configurations
const badgeColors: Record<ProposalStatus, string> = {
pending: "bg-yellow-500 text-black",
approved: "bg-blue-500 text-white",
executed: "bg-green-500 text-white",
rejected: "bg-red-500 text-white",
expired: "bg-gray-500 text-white",
};

return (
<div className="border p-4 rounded mb-4 shadow-sm">
<div className="flex justify-between items-center">
<h3>Proposal #{proposal.id}</h3>
<span className={`px-2 py-1 rounded text-sm ${badgeColors[proposal.status]}`}>
{proposal.status.toUpperCase()}
</span>
</div>

{/* Expiry Countdown for pending proposals */}
{proposal.status === "pending" && <p className="text-sm text-gray-500 mt-1">{timeLeft}</p>}

{/* Collapsible content section toggle wrapper */}
{(proposal.status === "executed" || proposal.status === "rejected") && (
<button className="text-xs underline mt-2" onClick={() => setIsCollapsed(!isCollapsed)}>
{isCollapsed ? "Show Past Details" : "Hide Details"}
</button>
)}

{!isCollapsed && (
<div className="mt-4 flex gap-2">
{/* Execute button only visible to verified members when status is approved */}
{proposal.status === "approved" && isMember && (
<button className="btn bg-blue-600 text-white px-4 py-2" onClick={() => onExecute(proposal.id)}>
Execute Withdrawal
</button>
)}

{/* Expired proposals replace approve/reject with a Finalize button */}
{proposal.status === "expired" && (
<button className="btn bg-gray-700 text-white px-4 py-2" onClick={() => onFinalize(proposal.id)}>
Finalize
</button>
)}
</div>
)}
</div>
);
};
Loading