This repository was archived by the owner on Mar 16, 2026. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 9
Expand file tree
/
Copy pathVotesTable.tsx
More file actions
300 lines (278 loc) · 10.6 KB
/
VotesTable.tsx
File metadata and controls
300 lines (278 loc) · 10.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchProposalVotes } from "@/services/vote.service";
import type { Vote } from "@/types";
import { ThumbsUp, ThumbsDown, ExternalLink, Eye, Loader2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { TokenBalance } from "../reusables/BalanceDisplay";
import { getExplorerLink } from "@/utils/format";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useState } from "react";
interface VotesTableProps {
proposalId: string;
limit?: number;
}
const VotesTable = ({ proposalId, limit }: VotesTableProps) => {
const queryClient = useQueryClient();
const [selectedReasoning, setSelectedReasoning] = useState<{
reasoning: string;
voter: string;
} | null>(null);
const {
data: votes,
isLoading,
error,
isError,
refetch,
} = useQuery<Vote[], Error>({
queryKey: ["proposalVotesTable", proposalId],
queryFn: () => fetchProposalVotes(proposalId),
enabled: !!proposalId,
refetchOnWindowFocus: false,
staleTime: 1000 * 60 * 1, // 1 minute stale time
gcTime: 1000 * 60 * 5, // 5 minutes garbage collection time
});
// Handle retry with proper query invalidation
const handleRetry = async () => {
await queryClient.invalidateQueries({
queryKey: ["proposalVotesTable", proposalId],
});
refetch();
};
// Helper function to truncate addresses
const truncateAddress = (address: string) => {
return address.length > 10
? `${address.substring(0, 5)}...${address.substring(address.length - 5)}`
: address;
};
// Helper function to parse and display score
const renderScore = (value: string | object) => {
if (!value) return <span className="text-muted-foreground text-xs">-</span>;
try {
const parsedScore = typeof value === "string" ? JSON.parse(value) : value;
// Handle object score format with final_score property
if (typeof parsedScore === "object" && parsedScore !== null) {
const finalScore =
parsedScore.final_score ?? parsedScore.score ?? parsedScore;
return (
<Badge variant="secondary" className="text-xs">
{typeof finalScore === "number"
? finalScore.toFixed(4)
: String(finalScore)}
</Badge>
);
}
// Handle simple numeric scores
return (
<Badge variant="secondary" className="text-xs">
{typeof parsedScore === "number"
? parsedScore.toFixed(4)
: String(parsedScore)}
</Badge>
);
} catch {
return <span className="text-muted-foreground text-xs">-</span>;
}
};
// Loading state
if (isLoading) {
return (
<div className="w-full p-8 flex items-center justify-center">
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
<span>Loading votes...</span>
</div>
</div>
);
}
// Error state
if (isError) {
return (
<div className="w-full p-8 flex flex-col items-center justify-center gap-4">
<div className="text-center">
<h3 className="text-lg font-semibold text-foreground mb-2">
Failed to load votes
</h3>
<p className="text-sm text-muted-foreground mb-4">
{error?.message ||
"There was an error loading the vote data. Please try again."}
</p>
<Button onClick={handleRetry} variant="outline">
Try Again
</Button>
</div>
</div>
);
}
// Empty state
if (!votes || votes.length === 0) {
return (
<div className="w-full p-8 flex flex-col items-center justify-center gap-4">
<ThumbsUp className="h-12 w-12 text-muted-foreground/50" />
<div className="text-center">
<h3 className="text-lg font-semibold text-foreground">
No votes recorded
</h3>
<p className="text-sm text-muted-foreground">
No votes have been recorded for this contribution yet.
</p>
</div>
</div>
);
}
// Apply limit if specified
const displayedVotes = limit ? votes.slice(0, limit) : votes;
return (
<>
<div className="w-full overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="border-b border-border/50 bg-muted/50">
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Voter
</th>
<th className="px-4 py-3 text-center text-xs font-medium text-muted-foreground uppercase tracking-wider hidden sm:table-cell">
Vote
</th>
<th className="px-4 py-3 text-center text-xs font-medium text-muted-foreground uppercase tracking-wider hidden sm:table-cell">
Amount
</th>
<th className="px-4 py-3 text-center text-xs font-medium text-muted-foreground uppercase tracking-wider hidden sm:table-cell">
Score
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Reasoning
</th>
<th className="px-4 py-3 text-center text-xs font-medium text-muted-foreground uppercase tracking-wider hidden sm:table-cell">
TX
</th>
</tr>
</thead>
<tbody>
{displayedVotes.map((vote) => (
<tr
key={vote.id}
className="border-b border-border/50 hover:bg-muted/30 transition-colors"
>
{/* Voter */}
<td className="px-4 py-3 text-sm">
<span
title={vote.address || undefined}
className="text-xs font-mono text-muted-foreground"
>
{vote.address ? truncateAddress(vote.address) : "-"}
</span>
</td>
{/* Vote */}
<td className="px-4 py-3 text-sm text-center hidden sm:table-cell">
{!vote.tx_id ? (
<span className="text-muted-foreground text-xs">-</span>
) : vote.answer ? (
<span className="inline-flex items-center gap-1 text-primary font-medium">
<ThumbsUp className="h-3 w-3" />
<span className="text-xs">Yes</span>
</span>
) : (
<span className="inline-flex items-center gap-1 text-secondary font-medium">
<ThumbsDown className="h-3 w-3" />
<span className="text-xs">No</span>
</span>
)}
</td>
{/* Amount */}
<td className="px-4 py-3 text-sm text-center hidden sm:table-cell">
{vote.amount !== null && vote.amount !== undefined ? (
<TokenBalance value={vote.amount} variant="abbreviated" />
) : (
<span className="text-muted-foreground text-xs">-</span>
)}
</td>
{/* Score */}
<td className="px-4 py-3 text-sm text-center hidden sm:table-cell">
{vote.evaluation_score ? (
renderScore(vote.evaluation_score)
) : (
<span className="text-muted-foreground text-xs">-</span>
)}
</td>
{/* Reasoning */}
<td className="px-4 py-3 text-sm">
{!vote.reasoning ? (
<span className="text-muted-foreground text-xs">-</span>
) : (
<button
onClick={() =>
setSelectedReasoning({
reasoning: vote.reasoning || "",
voter: vote.address || "Unknown",
})
}
className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 transition-colors cursor-pointer group w-full text-left"
title="Click to view full reasoning"
>
<Eye className="h-3 w-3 opacity-60 group-hover:opacity-100 flex-shrink-0" />
<span className="truncate max-w-[200px] sm:max-w-[400px]">
{vote.reasoning}
</span>
</button>
)}
</td>
{/* TX */}
<td className="px-4 py-3 text-sm text-center hidden sm:table-cell">
{!vote.tx_id ? (
<span className="text-muted-foreground text-xs">-</span>
) : (
<a
href={getExplorerLink("tx", vote.tx_id)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-primary hover:text-primary/80 transition-colors"
>
<ExternalLink className="h-3 w-3" />
</a>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Reasoning Modal */}
<Dialog
open={!!selectedReasoning}
onOpenChange={(open) => !open && setSelectedReasoning(null)}
>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Eye className="h-5 w-5" />
Vote Reasoning
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="text-sm text-muted-foreground">
<strong>Voter:</strong>{" "}
<span className="font-mono">
{selectedReasoning?.voter
? truncateAddress(selectedReasoning.voter)
: "Unknown"}
</span>
</div>
<div className="bg-muted/50 rounded-sm p-4">
<p className="text-sm whitespace-pre-wrap leading-relaxed">
{selectedReasoning?.reasoning || "No reasoning provided"}
</p>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
};
export default VotesTable;