Skip to content

Commit e8a3874

Browse files
committed
feat(role-manager): implement live dashboard statistics with real data
Implements Phase 4 & 5 of 007-dashboard-real-data specification: - Add React Query provider and useDashboardData hook for data aggregation - Implement contract registration tracking in ContractContext - Display real roles count and unique authorized accounts (deduplicated) - Add loading, error, and "not supported" states to DashboardStatsCard - Implement refresh functionality with proper loading states - Initialize AppConfigService for indexer/RPC configuration - Add unit tests for useDashboardData hook and deduplication utility Configuration: Use VITE_APP_CFG_INDEXER_ENDPOINT_* env vars in .env.local for indexer endpoints with API keys (see tasks.md for details).
1 parent 0405f75 commit e8a3874

15 files changed

Lines changed: 1084 additions & 56 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"$comment": "Base configuration. For secrets (API keys), use .env.local with VITE_APP_CFG_* variables",
3+
"featureFlags": {},
4+
"rpcEndpoints": {},
5+
"indexerEndpoints": {}
6+
}

apps/role-manager/src/App.tsx

Lines changed: 36 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
12
import { BrowserRouter, Route, Routes } from 'react-router-dom';
23

34
import { MainLayout } from './components/Layout/MainLayout';
@@ -7,29 +8,52 @@ import { Dashboard } from './pages/Dashboard';
78
import { RoleChanges } from './pages/RoleChanges';
89
import { Roles } from './pages/Roles';
910

11+
/**
12+
* Create a stable QueryClient instance for React Query
13+
* - staleTime: 1 minute - data considered fresh for 1 minute
14+
* - gcTime: 10 minutes - unused data kept in cache for 10 minutes
15+
* - retry: false - don't auto-retry failed queries (handled manually)
16+
*/
17+
const queryClient = new QueryClient({
18+
defaultOptions: {
19+
queries: {
20+
staleTime: 1 * 60 * 1000,
21+
gcTime: 10 * 60 * 1000,
22+
retry: false,
23+
},
24+
},
25+
});
26+
1027
/**
1128
* Root application component
1229
* Sets up routing and layout structure
1330
*
31+
* Provider hierarchy:
32+
* - QueryClientProvider: React Query for data fetching/caching
33+
* - BrowserRouter: Client-side routing
34+
* - ContractProvider: Shared contract selection state
35+
*
1436
* ContractProvider wraps inside BrowserRouter to enable:
1537
* - Shared contract selection state across all pages
1638
* - Access to selected contract in Dashboard and other pages
1739
* Feature: 007-dashboard-real-data
1840
*/
1941
function App() {
2042
return (
21-
<BrowserRouter>
22-
<ContractProvider>
23-
<MainLayout>
24-
<Routes>
25-
<Route path="/" element={<Dashboard />} />
26-
<Route path="/authorized-accounts" element={<AuthorizedAccounts />} />
27-
<Route path="/roles" element={<Roles />} />
28-
<Route path="/role-changes" element={<RoleChanges />} />
29-
</Routes>
30-
</MainLayout>
31-
</ContractProvider>
32-
</BrowserRouter>
43+
<QueryClientProvider client={queryClient}>
44+
<BrowserRouter>
45+
<ContractProvider>
46+
<MainLayout>
47+
<Routes>
48+
<Route path="/" element={<Dashboard />} />
49+
<Route path="/authorized-accounts" element={<AuthorizedAccounts />} />
50+
<Route path="/roles" element={<Roles />} />
51+
<Route path="/role-changes" element={<RoleChanges />} />
52+
</Routes>
53+
</MainLayout>
54+
</ContractProvider>
55+
</BrowserRouter>
56+
</QueryClientProvider>
3357
);
3458
}
3559

apps/role-manager/src/components/Dashboard/DashboardStatsCard.tsx

Lines changed: 107 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,47 @@
1+
/**
2+
* DashboardStatsCard Component
3+
* Feature: 007-dashboard-real-data
4+
*
5+
* Displays a statistics card on the Dashboard with support for:
6+
* - Loading states (spinner)
7+
* - Error states with retry button
8+
* - Disabled/not supported states
9+
* - Click navigation
10+
*/
11+
12+
import { AlertCircle, Loader2 } from 'lucide-react';
113
import { ReactNode } from 'react';
214

315
import { Button, Card, CardContent, CardHeader, CardTitle } from '@openzeppelin/ui-builder-ui';
416
import { cn } from '@openzeppelin/ui-builder-utils';
517

18+
import { FeatureBadge } from '../Shared/FeatureBadge';
19+
620
interface DashboardStatsCardProps {
21+
/** Card title */
722
title: string;
8-
count: string | number;
23+
/** Count value to display */
24+
count: string | number | null;
25+
/** Label below the count */
926
label: string;
27+
/** Icon to display in the header */
1028
icon?: ReactNode;
29+
/** Click handler for navigation */
1130
onClick?: () => void;
31+
/** Additional CSS classes */
1232
className?: string;
33+
/** Whether data is loading */
34+
isLoading?: boolean;
35+
/** Whether there's an error */
36+
hasError?: boolean;
37+
/** Error message to display */
38+
errorMessage?: string | null;
39+
/** Retry handler for errors */
40+
onRetry?: () => void;
41+
/** Whether the feature is not supported */
42+
isNotSupported?: boolean;
43+
/** Whether the card is disabled (not clickable) */
44+
disabled?: boolean;
1345
}
1446

1547
export function DashboardStatsCard({
@@ -19,34 +51,95 @@ export function DashboardStatsCard({
1951
icon,
2052
onClick,
2153
className,
54+
isLoading = false,
55+
hasError = false,
56+
errorMessage,
57+
onRetry,
58+
isNotSupported = false,
59+
disabled = false,
2260
}: DashboardStatsCardProps) {
61+
// Determine if card should be clickable
62+
const isClickable = !disabled && !isNotSupported && !isLoading && !hasError && !!onClick;
63+
2364
return (
2465
<Card
2566
className={cn(
26-
'group relative flex flex-col justify-between transition-all duration-200 hover:scale-[1.02] cursor-pointer overflow-hidden bg-white shadow-none',
67+
'group relative flex flex-col justify-between transition-all duration-200 overflow-hidden bg-white shadow-none',
68+
isClickable && 'hover:scale-[1.02] cursor-pointer',
69+
(disabled || isNotSupported) && 'opacity-75',
2770
className
2871
)}
29-
onClick={onClick}
72+
onClick={isClickable ? onClick : undefined}
3073
>
31-
<div className="absolute top-4 right-4 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity duration-200 z-10">
32-
<Button
33-
variant="outline"
34-
size="sm"
35-
className="h-7 text-xs bg-white shadow-sm hover:bg-slate-50 px-3"
36-
>
37-
Open
38-
</Button>
39-
</div>
74+
{/* Open button - only show when clickable */}
75+
{isClickable && (
76+
<div className="absolute top-4 right-4 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity duration-200 z-10">
77+
<Button
78+
variant="outline"
79+
size="sm"
80+
className="h-7 text-xs bg-white shadow-sm hover:bg-slate-50 px-3"
81+
>
82+
Open
83+
</Button>
84+
</div>
85+
)}
4086

4187
<CardHeader className="pb-2">
4288
<div className="flex justify-between items-start">
4389
<CardTitle className="text-sm font-medium text-slate-500">{title}</CardTitle>
4490
{icon && <div className="text-slate-400">{icon}</div>}
4591
</div>
4692
</CardHeader>
93+
4794
<CardContent>
48-
<div className="text-4xl font-bold tracking-tight mb-1 text-slate-900">{count}</div>
49-
<p className="text-xs text-slate-500">{label}</p>
95+
{/* Loading State */}
96+
{isLoading && (
97+
<div className="flex flex-col items-center justify-center py-4 gap-2">
98+
<Loader2 className="h-8 w-8 animate-spin text-slate-400" />
99+
<span className="text-sm text-slate-500">Loading...</span>
100+
</div>
101+
)}
102+
103+
{/* Error State - Vertical layout with larger icon */}
104+
{hasError && !isLoading && (
105+
<div className="flex flex-col items-center text-center py-2 gap-3">
106+
<AlertCircle className="h-10 w-10 text-red-500" />
107+
<p className="text-sm text-red-600 leading-tight">
108+
{errorMessage || 'Failed to load data'}
109+
</p>
110+
{onRetry && (
111+
<Button
112+
variant="outline"
113+
size="sm"
114+
onClick={(e) => {
115+
e.stopPropagation();
116+
onRetry();
117+
}}
118+
className="h-8 text-xs"
119+
>
120+
Retry
121+
</Button>
122+
)}
123+
</div>
124+
)}
125+
126+
{/* Not Supported State */}
127+
{isNotSupported && !isLoading && !hasError && (
128+
<div className="space-y-1">
129+
<FeatureBadge variant="slate">Not Supported</FeatureBadge>
130+
<p className="text-xs text-slate-500">{label}</p>
131+
</div>
132+
)}
133+
134+
{/* Normal State */}
135+
{!isLoading && !hasError && !isNotSupported && (
136+
<>
137+
<div className="text-4xl font-bold tracking-tight mb-1 text-slate-900">
138+
{count ?? '-'}
139+
</div>
140+
<p className="text-xs text-slate-500">{label}</p>
141+
</>
142+
)}
50143
</CardContent>
51144
</Card>
52145
);

apps/role-manager/src/context/ContractContext.tsx

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,12 @@
1313

1414
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
1515

16-
import type { NetworkConfig } from '@openzeppelin/ui-builder-types';
16+
import type {
17+
AccessControlService,
18+
ContractSchema,
19+
NetworkConfig,
20+
} from '@openzeppelin/ui-builder-types';
21+
import { logger } from '@openzeppelin/ui-builder-utils';
1722

1823
import { useAllNetworks } from '../hooks/useAllNetworks';
1924
import { useNetworkAdapter } from '../hooks/useNetworkAdapter';
@@ -129,6 +134,98 @@ export function ContractProvider({ children }: ContractProviderProps): React.Rea
129134

130135
const { adapter, isLoading: isAdapterLoading } = useNetworkAdapter(selectedNetwork);
131136

137+
// ==========================================================================
138+
// Contract Registration
139+
// ==========================================================================
140+
141+
// Track which contracts have been registered to avoid duplicate registrations
142+
// Using state (not ref) so changes trigger re-renders and hooks get updated value
143+
const [registeredContracts, setRegisteredContracts] = useState<Set<string>>(new Set());
144+
145+
// Compute isContractRegistered synchronously based on current selection
146+
// This ensures the value is correct during render, not just after effects
147+
const isContractRegistered = useMemo(() => {
148+
if (!selectedNetwork || !selectedContract) {
149+
return false;
150+
}
151+
const registrationKey = `${selectedNetwork.ecosystem}:${selectedContract.address}`;
152+
return registeredContracts.has(registrationKey);
153+
}, [selectedNetwork, selectedContract, registeredContracts]);
154+
155+
// Register contract with access control service when contract/adapter changes
156+
useEffect(() => {
157+
// Skip if no adapter, still loading, no network, or no contract selected
158+
if (!adapter || isAdapterLoading || !selectedNetwork || !selectedContract) {
159+
return;
160+
}
161+
162+
const registrationKey = `${selectedNetwork.ecosystem}:${selectedContract.address}`;
163+
164+
// Skip if already registered
165+
if (registeredContracts.has(registrationKey)) {
166+
return;
167+
}
168+
169+
// Skip if contract doesn't have a schema
170+
if (!selectedContract.schema) {
171+
logger.debug('ContractContext', 'Contract has no schema, skipping registration', {
172+
address: selectedContract.address,
173+
});
174+
// Mark as "registered" even without schema so data hooks don't wait forever
175+
setRegisteredContracts((prev) => new Set(prev).add(registrationKey));
176+
return;
177+
}
178+
179+
// Get access control service from adapter
180+
const service = adapter.getAccessControlService?.() as
181+
| (AccessControlService & {
182+
registerContract?: (address: string, schema: ContractSchema) => void;
183+
})
184+
| undefined;
185+
186+
// If adapter doesn't support registration, mark as registered anyway
187+
if (!service || typeof service.registerContract !== 'function') {
188+
setRegisteredContracts((prev) => new Set(prev).add(registrationKey));
189+
return;
190+
}
191+
192+
try {
193+
// Parse the stored schema JSON
194+
const schema = JSON.parse(selectedContract.schema) as ContractSchema;
195+
196+
// Register the contract with the service
197+
service.registerContract(selectedContract.address, schema);
198+
199+
logger.debug('ContractContext', 'Registered contract with access control service', {
200+
address: selectedContract.address,
201+
ecosystem: selectedNetwork.ecosystem,
202+
});
203+
204+
// Add to registered set (triggers re-render, hooks see updated isContractRegistered)
205+
setRegisteredContracts((prev) => new Set(prev).add(registrationKey));
206+
} catch (error) {
207+
logger.error('ContractContext', 'Failed to register contract', error);
208+
// Still mark as registered so hooks don't wait forever (they'll get the error)
209+
setRegisteredContracts((prev) => new Set(prev).add(registrationKey));
210+
}
211+
}, [adapter, isAdapterLoading, selectedNetwork, selectedContract, registeredContracts]);
212+
213+
// Clear registration cache when network/adapter changes (new service instance)
214+
useEffect(() => {
215+
if (selectedNetwork) {
216+
// When network changes, the adapter and service are recreated
217+
// Clear registrations for other ecosystems
218+
setRegisteredContracts((prev) => {
219+
const currentEcosystem = selectedNetwork.ecosystem;
220+
const filtered = Array.from(prev).filter((key) => key.startsWith(`${currentEcosystem}:`));
221+
if (filtered.length !== prev.size) {
222+
return new Set(filtered);
223+
}
224+
return prev;
225+
});
226+
}
227+
}, [selectedNetwork]);
228+
132229
// ==========================================================================
133230
// Context Value
134231
// ==========================================================================
@@ -143,6 +240,7 @@ export function ContractProvider({ children }: ContractProviderProps): React.Rea
143240
isAdapterLoading,
144241
contracts: contracts ?? [],
145242
isContractsLoading,
243+
isContractRegistered,
146244
}),
147245
[
148246
selectedContract,
@@ -153,6 +251,7 @@ export function ContractProvider({ children }: ContractProviderProps): React.Rea
153251
isAdapterLoading,
154252
contracts,
155253
isContractsLoading,
254+
isContractRegistered,
156255
]
157256
);
158257

0 commit comments

Comments
 (0)