Skip to content

Commit f422900

Browse files
authored
feat: standardize empty states and loading skeletons (#1294)
* feat: add compact mode to EmptyState and emptyState prop to DataTable - Add compact prop to EmptyState for inline/table usage (smaller icon, reduced height) - Add emptyState prop to DataTable for custom per-table empty state content * feat: add shared DetailSkeleton component for detail page loading states Reusable skeleton that renders back-nav, title, stats grid, and tab bar placeholders, reducing per-page boilerplate for loading states. * feat: apply standardized loading skeletons to detail pages - TenantDetailPage: replace single-div skeleton with DetailSkeleton - ReconciliationDetailPage: replace minimal skeletons with DetailSkeleton - MappingDetailPage: replace thin card skeleton with DetailSkeleton - DatasetDetailPage: add header skeleton while dataset loads - PositionDetailPage: replace per-field inline skeletons with Skeleton component * fix: clamp fieldCount and tabCount in DetailSkeleton to prevent crashes Negative or non-integer prop values could crash Array.from rendering. Use Math.max(0, Math.floor(...)) for defensive normalization. --------- Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent 3903fc7 commit f422900

9 files changed

Lines changed: 145 additions & 78 deletions

File tree

frontend/src/components/shared/data-table.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ export interface DataTableProps<T> {
5454
onRowClick?: (row: T) => void
5555
className?: string
5656
defaultSort?: SortingState
57+
/** Custom empty state rendered inside the table when there are no results */
58+
emptyState?: React.ReactNode
5759
}
5860

5961
interface SortableHeaderProps {
@@ -223,6 +225,7 @@ export function DataTable<T>({
223225
onRowClick,
224226
className,
225227
defaultSort = [],
228+
emptyState,
226229
}: DataTableProps<T>) {
227230
const [activeFilters, setActiveFilters] = React.useState<Record<string, string>>({})
228231
const [pageToken, setPageToken] = React.useState<string | undefined>(undefined)
@@ -302,7 +305,15 @@ export function DataTable<T>({
302305
) : isError ? (
303306
<ErrorState onRetry={() => void refetch()} />
304307
) : rows.length === 0 ? (
305-
<EmptyState />
308+
emptyState ? (
309+
<TableRow>
310+
<TableCell colSpan={99} className="p-0">
311+
{emptyState}
312+
</TableCell>
313+
</TableRow>
314+
) : (
315+
<EmptyState />
316+
)
306317
) : (
307318
rows.map((row) => (
308319
<TableRow
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import * as React from 'react'
2+
import { Skeleton } from '@/components/ui/skeleton'
3+
import { cn } from '@/lib/utils'
4+
5+
export interface DetailSkeletonProps {
6+
/** Number of stat/summary fields to show in the top grid */
7+
fieldCount?: number
8+
/** Number of tabs to show */
9+
tabCount?: number
10+
/** Whether to show a back-nav skeleton */
11+
showBackNav?: boolean
12+
className?: string
13+
}
14+
15+
/**
16+
* Reusable full-page loading skeleton for detail pages.
17+
* Renders a back-nav placeholder, title, stats grid, and optional tabs.
18+
*/
19+
export function DetailSkeleton({
20+
fieldCount = 4,
21+
tabCount = 3,
22+
showBackNav = true,
23+
className,
24+
}: DetailSkeletonProps) {
25+
const safeFieldCount = Math.max(0, Math.floor(fieldCount))
26+
const safeTabCount = Math.max(0, Math.floor(tabCount))
27+
28+
return (
29+
<div
30+
data-testid="detail-skeleton"
31+
className={cn('animate-pulse space-y-6 p-6', className)}
32+
>
33+
{showBackNav && (
34+
<Skeleton className="h-4 w-24" />
35+
)}
36+
37+
{/* Title + badge */}
38+
<div className="flex items-center gap-3">
39+
<Skeleton className="h-8 w-64" />
40+
<Skeleton className="h-6 w-20 rounded-full" />
41+
</div>
42+
43+
{/* Summary stats grid */}
44+
<div className={cn(
45+
'grid gap-4',
46+
safeFieldCount <= 2 ? 'grid-cols-2' : 'grid-cols-2 md:grid-cols-4'
47+
)}>
48+
{Array.from({ length: safeFieldCount }).map((_, i) => (
49+
<Skeleton key={i} className="h-20 rounded-lg" />
50+
))}
51+
</div>
52+
53+
{/* Tabs bar */}
54+
{safeTabCount > 0 && (
55+
<div className="flex gap-2">
56+
{Array.from({ length: safeTabCount }).map((_, i) => (
57+
<Skeleton key={i} className="h-9 w-24 rounded-md" />
58+
))}
59+
</div>
60+
)}
61+
62+
{/* Tab content area */}
63+
<Skeleton className="h-64 rounded-lg" />
64+
</div>
65+
)
66+
}

frontend/src/components/shared/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,6 @@ export type { CreateValuationFeatureDialogProps, AccountType } from './create-va
1616

1717
export { EntityLink } from './entity-link';
1818
export type { EntityLinkProps, EntityType } from './entity-link';
19+
20+
export { DetailSkeleton } from './detail-skeleton';
21+
export type { DetailSkeletonProps } from './detail-skeleton';

frontend/src/components/ui/empty-state.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,26 +12,30 @@ interface EmptyStateProps {
1212
label: string
1313
onClick: () => void
1414
}
15+
/** Compact mode for use inside tables or small containers */
16+
compact?: boolean
1517
}
1618

1719
export function EmptyState({
1820
icon: Icon = FileQuestion,
1921
title,
2022
description,
2123
action,
24+
compact = false,
2225
}: EmptyStateProps) {
2326
return (
2427
<div
2528
data-slot="empty-state"
2629
className={cn(
27-
"flex min-h-[400px] flex-col items-center justify-center gap-4 px-4 py-8"
30+
"flex flex-col items-center justify-center gap-4 px-4",
31+
compact ? "min-h-[120px] py-4" : "min-h-[400px] py-8"
2832
)}
2933
>
3034
<div
3135
data-slot="empty-state-icon"
3236
className="text-muted-foreground"
3337
>
34-
<Icon className="size-12" />
38+
<Icon className={compact ? "size-6" : "size-12"} />
3539
</div>
3640

3741
<div

frontend/src/pages/mappings/[mappingId].tsx

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
66
import { Button } from '@/components/ui/button'
77
import { Badge } from '@/components/ui/badge'
88
import { StatusBadge } from '@/components/shared/status-badge'
9+
import { DetailSkeleton } from '@/components/shared/detail-skeleton'
910
import { useApiClients } from '@/api/context'
1011

1112
// ─── Types ───────────────────────────────────────────────────────────────────
@@ -437,18 +438,7 @@ export function MappingDetailPage() {
437438
}
438439

439440
if (isLoading) {
440-
return (
441-
<div className="space-y-6">
442-
<div>
443-
<h1 className="text-3xl font-bold tracking-tight">Mapping Details</h1>
444-
</div>
445-
<Card>
446-
<div className="p-6">
447-
<div className="h-6 w-48 animate-pulse rounded bg-muted" />
448-
</div>
449-
</Card>
450-
</div>
451-
)
441+
return <DetailSkeleton fieldCount={4} tabCount={3} showBackNav={false} />
452442
}
453443

454444
if (isError || !data) {

frontend/src/pages/market-data/[datasetCode].tsx

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'
33
import { format } from 'date-fns'
44
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
55
import { StatusBadge } from '@/components/shared/status-badge'
6+
import { Skeleton } from '@/components/ui/skeleton'
67
import { useApiClients } from '@/api/context'
78
import { useTenantContext } from '@/contexts/tenant-context'
89
import { tenantKeys } from '@/lib/query-keys'
@@ -243,20 +244,29 @@ export function DatasetDetailPage() {
243244
return (
244245
<div className="p-6 space-y-6">
245246
<div>
246-
<h1 className="text-2xl font-semibold">
247-
{dataset?.displayName || datasetCode}
248-
</h1>
249-
{dataset && (
250-
<div className="mt-2 flex items-center gap-3">
251-
<span className="font-mono text-sm text-muted-foreground">{dataset.code}</span>
252-
<StatusBadge status={statusLabel(dataset.status)} />
253-
<span className="text-sm text-muted-foreground">
254-
Unit: <span className="font-mono">{dataset.unit}</span>
255-
</span>
247+
{datasetQuery.isLoading ? (
248+
<div className="space-y-2">
249+
<Skeleton className="h-8 w-64" />
250+
<Skeleton className="h-5 w-48" />
256251
</div>
257-
)}
258-
{dataset?.description && (
259-
<p className="mt-2 text-sm text-muted-foreground">{dataset.description}</p>
252+
) : (
253+
<>
254+
<h1 className="text-2xl font-semibold">
255+
{dataset?.displayName || datasetCode}
256+
</h1>
257+
{dataset && (
258+
<div className="mt-2 flex items-center gap-3">
259+
<span className="font-mono text-sm text-muted-foreground">{dataset.code}</span>
260+
<StatusBadge status={statusLabel(dataset.status)} />
261+
<span className="text-sm text-muted-foreground">
262+
Unit: <span className="font-mono">{dataset.unit}</span>
263+
</span>
264+
</div>
265+
)}
266+
{dataset?.description && (
267+
<p className="mt-2 text-sm text-muted-foreground">{dataset.description}</p>
268+
)}
269+
</>
260270
)}
261271
</div>
262272

frontend/src/pages/positions/detail.tsx

Lines changed: 29 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,10 @@ import { TimeDisplay } from '@/components/shared/time-display'
1010
import { QualityLadderBadge } from '@/components/shared/quality-ladder-badge'
1111
import { DirectionBadge } from '@/components/shared/direction-badge'
1212
import { EntityLink } from '@/components/shared'
13+
import { Skeleton } from '@/components/ui/skeleton'
1314
import { useApiClients } from '@/api/context'
1415
import type { FinancialPositionLog, TransactionLogEntry } from './index'
1516

16-
function SkeletonField() {
17-
return <div className="h-5 w-40 animate-pulse rounded bg-muted" />
18-
}
19-
2017
function LabeledField({ label, children }: { label: string; children: React.ReactNode }) {
2118
return (
2219
<div>
@@ -187,51 +184,44 @@ export function PositionDetailPage() {
187184

188185
{(isLoading || log) && (
189186
<Card className="p-6">
190-
<dl className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
191-
<LabeledField label="Log ID">
192-
{isLoading ? (
193-
<SkeletonField />
194-
) : (
187+
{isLoading ? (
188+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
189+
{Array.from({ length: 5 }).map((_, i) => (
190+
<div key={i}>
191+
<Skeleton className="h-4 w-24 mb-2" />
192+
<Skeleton className="h-5 w-40" />
193+
</div>
194+
))}
195+
</div>
196+
) : (
197+
<dl className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
198+
<LabeledField label="Log ID">
195199
<span className="font-mono text-xs">{log?.logId ?? '—'}</span>
196-
)}
197-
</LabeledField>
200+
</LabeledField>
198201

199-
<LabeledField label="Account ID">
200-
{isLoading ? (
201-
<SkeletonField />
202-
) : log?.accountId ? (
203-
<EntityLink type="account" id={log.accountId} className="font-mono text-xs text-blue-600 hover:underline dark:text-blue-400" />
204-
) : (
205-
<span></span>
206-
)}
207-
</LabeledField>
202+
<LabeledField label="Account ID">
203+
{log?.accountId ? (
204+
<EntityLink type="account" id={log.accountId} className="font-mono text-xs text-blue-600 hover:underline dark:text-blue-400" />
205+
) : (
206+
<span></span>
207+
)}
208+
</LabeledField>
208209

209-
<LabeledField label="Status">
210-
{isLoading ? (
211-
<SkeletonField />
212-
) : (
210+
<LabeledField label="Status">
213211
<span>
214212
{typeof log?.statusTracking?.currentStatus === 'string' ? log.statusTracking.currentStatus.replace(/_/g, ' ') : '—'}
215213
</span>
216-
)}
217-
</LabeledField>
214+
</LabeledField>
218215

219-
<LabeledField label="Created">
220-
{isLoading ? (
221-
<SkeletonField />
222-
) : (
216+
<LabeledField label="Created">
223217
<TimeDisplay timestamp={log?.createdAt} />
224-
)}
225-
</LabeledField>
218+
</LabeledField>
226219

227-
<LabeledField label="Last Updated">
228-
{isLoading ? (
229-
<SkeletonField />
230-
) : (
220+
<LabeledField label="Last Updated">
231221
<TimeDisplay timestamp={log?.updatedAt} />
232-
)}
233-
</LabeledField>
234-
</dl>
222+
</LabeledField>
223+
</dl>
224+
)}
235225
</Card>
236226
)}
237227

frontend/src/pages/reconciliation/detail.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'
77
import { Input } from '@/components/ui/input'
88
import { StatusBadge } from '@/components/shared/status-badge'
99
import { CELEditor } from '@/components/shared/cel-editor'
10+
import { DetailSkeleton } from '@/components/shared/detail-skeleton'
1011
import {
1112
VarianceDetail,
1213
type Variance,
@@ -559,12 +560,7 @@ export function ReconciliationDetailPage() {
559560
if (!runId) return null
560561

561562
if (isLoading) {
562-
return (
563-
<div className="p-6 space-y-4">
564-
<div className="h-8 w-64 animate-pulse rounded bg-muted" />
565-
<div className="h-4 w-96 animate-pulse rounded bg-muted" />
566-
</div>
567-
)
563+
return <DetailSkeleton fieldCount={4} tabCount={3} showBackNav={true} />
568564
}
569565

570566
if (isError || !run) {

frontend/src/pages/tenants/[tenantId].tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Link, useParams } from 'react-router-dom'
22
import { ChevronLeft, CheckCircle2, Circle, Loader2, XCircle } from 'lucide-react'
33
import { StatusBadge } from '@/components/shared/status-badge'
44
import { TimeDisplay } from '@/components/shared/time-display'
5+
import { DetailSkeleton } from '@/components/shared/detail-skeleton'
56
import { Button } from '@/components/ui/button'
67
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
78
import { useTenant, useTenantProvisioningStatus, useUpdateTenantStatus } from '@/hooks/use-tenant'
@@ -94,11 +95,7 @@ export function TenantDetailPage() {
9495
const updateStatus = useUpdateTenantStatus(tenantId ?? '')
9596

9697
if (tenantLoading) {
97-
return (
98-
<div className="p-6">
99-
<div className="h-8 w-48 animate-pulse rounded bg-muted" />
100-
</div>
101-
)
98+
return <DetailSkeleton fieldCount={4} tabCount={0} showBackNav={true} />
10299
}
103100

104101
if (!tenant) {

0 commit comments

Comments
 (0)