Skip to content

Commit 9c59a4e

Browse files
mrubenscursoragent
andauthored
Improve loading state feedback (#202)
* Improve loading states with graceful transitions and enhanced UX Co-authored-by: matt <[email protected]> * DRY it up * Visual cleanup * Cleanup * More cleanup * More cleanup --------- Co-authored-by: Cursor Agent <[email protected]>
1 parent 7e2cd4b commit 9c59a4e

File tree

12 files changed

+392
-68
lines changed

12 files changed

+392
-68
lines changed

apps/web/src/app/(authenticated)/usage/Developers.tsx

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
formatNumber,
1111
formatTimestamp,
1212
} from '@/lib/formatters';
13-
import { Button, Skeleton } from '@/components/ui';
13+
import { Button } from '@/components/ui';
1414
import { DataTable } from '@/components/layout';
1515

1616
import type { Filter } from './types';
@@ -24,13 +24,17 @@ export const Developers = ({
2424
}) => {
2525
const { orgId } = useAuth();
2626

27-
const { data = [], isPending } = useQuery({
27+
const {
28+
data = [],
29+
isPending,
30+
isError,
31+
} = useQuery({
2832
queryKey: ['getDeveloperUsage', orgId, filters],
2933
queryFn: () => getDeveloperUsage({ orgId, filters }),
3034
enabled: !!orgId,
3135
});
3236

33-
const cols: ColumnDef<DeveloperUsage>[] = useMemo(
37+
const columns: ColumnDef<DeveloperUsage>[] = useMemo(
3438
() => [
3539
{
3640
header: 'Developer',
@@ -93,16 +97,17 @@ export const Developers = ({
9397
[onFilter],
9498
);
9599

96-
const columns = useMemo(
97-
() =>
98-
isPending
99-
? cols.map((col) => ({
100-
...col,
101-
cell: () => <Skeleton className="h-9 w-full" />,
102-
}))
103-
: cols,
104-
[isPending, cols],
100+
return (
101+
<DataTable
102+
data={data}
103+
columns={columns}
104+
isPending={isPending}
105+
isError={isError}
106+
filters={filters}
107+
loadingMessage="Loading developers..."
108+
errorTitle="Failed to load developers"
109+
emptyTitle="No developers found"
110+
emptyDescription="Developer activity will appear here once team members start using the system"
111+
/>
105112
);
106-
107-
return <DataTable columns={columns} data={data} />;
108113
};

apps/web/src/app/(authenticated)/usage/Models.tsx

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { useAuth } from '@clerk/nextjs';
55

66
import { type ModelUsage, getModelUsage } from '@/actions/analytics';
77
import { formatCurrency, formatNumber } from '@/lib/formatters';
8-
import { Button, Skeleton } from '@/components/ui';
8+
import { Button } from '@/components/ui';
99
import { DataTable } from '@/components/layout';
1010

1111
import type { Filter } from './types';
@@ -19,13 +19,17 @@ export const Models = ({
1919
}) => {
2020
const { orgId } = useAuth();
2121

22-
const { data = [], isPending } = useQuery({
22+
const {
23+
data = [],
24+
isPending,
25+
isError,
26+
} = useQuery({
2327
queryKey: ['getModelUsage', orgId, filters],
2428
queryFn: () => getModelUsage({ orgId, filters }),
2529
enabled: !!orgId,
2630
});
2731

28-
const cols: ColumnDef<ModelUsage>[] = useMemo(
32+
const columns: ColumnDef<ModelUsage>[] = useMemo(
2933
() => [
3034
{
3135
header: 'Model',
@@ -65,16 +69,17 @@ export const Models = ({
6569
[onFilter],
6670
);
6771

68-
const columns = useMemo(
69-
() =>
70-
isPending
71-
? cols.map((col) => ({
72-
...col,
73-
cell: () => <Skeleton className="h-9 w-full" />,
74-
}))
75-
: cols,
76-
[isPending, cols],
72+
return (
73+
<DataTable
74+
data={data}
75+
columns={columns}
76+
isPending={isPending}
77+
isError={isError}
78+
filters={filters}
79+
loadingMessage="Loading models..."
80+
errorTitle="Failed to load models"
81+
emptyTitle="No models found"
82+
emptyDescription="Model usage data will appear here once AI models are used in tasks"
83+
/>
7784
);
78-
79-
return <DataTable columns={columns} data={data} />;
8085
};

apps/web/src/app/(authenticated)/usage/Repositories.tsx

Lines changed: 19 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
formatNumber,
1212
formatTimestamp,
1313
} from '@/lib/formatters';
14-
import { Button, Skeleton } from '@/components/ui';
14+
import { Button } from '@/components/ui';
1515
import { DataTable } from '@/components/layout';
1616

1717
import type { Filter } from './types';
@@ -25,13 +25,17 @@ export const Repositories = ({
2525
}) => {
2626
const { orgId } = useAuth();
2727

28-
const { data = [], isPending } = useQuery({
28+
const {
29+
data = [],
30+
isPending,
31+
isError,
32+
} = useQuery({
2933
queryKey: ['getRepositoryUsage', orgId, filters],
3034
queryFn: () => getRepositoryUsage({ orgId, filters }),
3135
enabled: !!orgId,
3236
});
3337

34-
const cols: ColumnDef<RepositoryUsage>[] = useMemo(
38+
const columns: ColumnDef<RepositoryUsage>[] = useMemo(
3539
() => [
3640
{
3741
header: 'Repository',
@@ -103,25 +107,17 @@ export const Repositories = ({
103107
[onFilter],
104108
);
105109

106-
const columns = useMemo(
107-
() =>
108-
isPending
109-
? cols.map((col) => ({
110-
...col,
111-
cell: () => <Skeleton className="h-9 w-full" />,
112-
}))
113-
: cols,
114-
[isPending, cols],
110+
return (
111+
<DataTable
112+
data={data}
113+
columns={columns}
114+
isPending={isPending}
115+
isError={isError}
116+
filters={filters}
117+
loadingMessage="Loading repositories..."
118+
errorTitle="Failed to load repositories"
119+
emptyTitle="No repositories found"
120+
emptyDescription="Repository data will appear here once tasks with Git context are created"
121+
/>
115122
);
116-
117-
if (data.length === 0 && !isPending) {
118-
return (
119-
<div className="text-center py-8 text-muted-foreground">
120-
No Git repository data available. Repository information will appear
121-
here once tasks with Git repository context are created.
122-
</div>
123-
);
124-
}
125-
126-
return <DataTable columns={columns} data={data} />;
127123
};

apps/web/src/app/(authenticated)/usage/Tasks.tsx

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import { useQuery } from '@tanstack/react-query';
55
import type { TaskWithUser } from '@/actions/analytics';
66

77
import { useRealtimePolling } from '@/hooks/useRealtimePolling';
8+
import { useGracefulLoading } from '@/hooks/useGracefulLoading';
89
import { getTasks } from '@/actions/analytics';
9-
import { Skeleton, CursorPaginationControls } from '@/components/ui';
10+
import { CursorPaginationControls } from '@/components/ui';
11+
import { LoadingState, ErrorState, EmptyState } from '@/components/ui/states';
1012
import { TaskCard } from '@/components/usage';
1113
import { useCursorPagination } from '@/hooks/usePagination';
1214

@@ -32,7 +34,7 @@ export const Tasks = ({
3234
// Initialize cursor-based pagination
3335
const pagination = useCursorPagination(100);
3436

35-
const { data, isPending } = useQuery({
37+
const { data, isPending, isError } = useQuery({
3638
queryKey: [
3739
'getTasksPaginated',
3840
orgId,
@@ -54,6 +56,13 @@ export const Tasks = ({
5456
...polling,
5557
});
5658

59+
// Use graceful loading hook
60+
const { showContent, isTransitioning } = useGracefulLoading({
61+
isPending,
62+
data: data?.tasks || [],
63+
dependencies: [filters],
64+
});
65+
5766
// Update cursor when we get new data
5867
useEffect(() => {
5968
if (data?.nextCursor) {
@@ -81,24 +90,26 @@ export const Tasks = ({
8190

8291
const tasks = data?.tasks || [];
8392

84-
if (isPending) {
93+
// Show loading state while pending or during transition period
94+
if (isPending || !showContent) {
8595
return (
86-
<div className="space-y-3">
87-
{Array(8)
88-
.fill(0)
89-
.map((_, i) => (
90-
<Skeleton key={i} className="h-14 w-full" />
91-
))}
92-
</div>
96+
<LoadingState
97+
message={isPending ? 'Loading tasks...' : 'Preparing results...'}
98+
isTransitioning={isTransitioning}
99+
/>
93100
);
94101
}
95102

103+
if (isError) {
104+
return <ErrorState title="Failed to load tasks" />;
105+
}
106+
96107
if (tasks.length === 0) {
97108
return (
98-
<div className="text-center py-8 text-muted-foreground">
99-
No tasks have been synced yet. Check back after creating or sharing a
100-
task.
101-
</div>
109+
<EmptyState
110+
title="No tasks found"
111+
description="Tasks will appear here after being created or shared"
112+
/>
102113
);
103114
}
104115

apps/web/src/components/layout/DataTable.tsx

Lines changed: 95 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,22 +15,79 @@ import {
1515
TableHeader,
1616
TableRow,
1717
} from '@/components/ui';
18+
import { useGracefulLoading } from '@/hooks/useGracefulLoading';
19+
import { LoadingState, ErrorState, EmptyState } from '@/components/ui/states';
1820

1921
interface DataTableProps<TData, TValue> {
2022
columns: ColumnDef<TData, TValue>[];
2123
data: TData[];
24+
isLoading?: boolean;
25+
emptyMessage?: string;
26+
emptyDescription?: string;
27+
// New props for enhanced state management
28+
isPending?: boolean;
29+
isError?: boolean;
30+
filters?: unknown[];
31+
loadingMessage?: string;
32+
errorTitle?: string;
33+
emptyTitle?: string;
2234
}
2335

2436
export function DataTable<TData, TValue>({
2537
columns,
2638
data,
39+
isLoading = false,
40+
emptyMessage = 'No results found',
41+
emptyDescription = 'Try adjusting your search or filter criteria',
42+
// New props
43+
isPending,
44+
isError,
45+
filters = [],
46+
loadingMessage = 'Loading...',
47+
errorTitle = 'Failed to load',
48+
emptyTitle,
2749
}: DataTableProps<TData, TValue>) {
50+
// Always call hooks at the top level to avoid conditional hook calls
2851
const table = useReactTable({
2952
data,
3053
columns,
3154
getCoreRowModel: getCoreRowModel(),
3255
});
3356

57+
// Use graceful loading if isPending is provided, otherwise fall back to isLoading
58+
const shouldUseGracefulLoading = isPending !== undefined;
59+
const { showContent, isTransitioning } = useGracefulLoading({
60+
isPending: isPending || false,
61+
data,
62+
dependencies: [filters],
63+
});
64+
65+
// Enhanced loading state management
66+
if (shouldUseGracefulLoading) {
67+
// Show loading state while pending or during transition period
68+
if (isPending || !showContent) {
69+
return (
70+
<LoadingState
71+
message={loadingMessage}
72+
isTransitioning={isTransitioning}
73+
/>
74+
);
75+
}
76+
77+
if (isError) {
78+
return <ErrorState title={errorTitle} />;
79+
}
80+
81+
if (data.length === 0) {
82+
return (
83+
<EmptyState
84+
title={emptyTitle || emptyMessage}
85+
description={emptyDescription}
86+
/>
87+
);
88+
}
89+
}
90+
3491
return (
3592
<div className="bg-card border shadow rounded">
3693
<Table>
@@ -53,7 +110,18 @@ export function DataTable<TData, TValue>({
53110
))}
54111
</TableHeader>
55112
<TableBody>
56-
{table.getRowModel().rows?.length ? (
113+
{isLoading ? (
114+
// Show loading rows while data is being fetched
115+
Array.from({ length: 5 }, (_, index) => (
116+
<TableRow key={`loading-${index}`}>
117+
{columns.map((_, colIndex) => (
118+
<TableCell key={`loading-cell-${colIndex}`}>
119+
<div className="h-4 bg-muted/20 rounded animate-pulse" />
120+
</TableCell>
121+
))}
122+
</TableRow>
123+
))
124+
) : table.getRowModel().rows?.length ? (
57125
table.getRowModel().rows.map((row) => (
58126
<TableRow
59127
key={row.id}
@@ -68,8 +136,32 @@ export function DataTable<TData, TValue>({
68136
))
69137
) : (
70138
<TableRow>
71-
<TableCell colSpan={columns.length} className="h-24 text-center">
72-
No results.
139+
<TableCell colSpan={columns.length} className="h-32">
140+
<div className="text-center space-y-3">
141+
<div className="w-10 h-10 mx-auto rounded-full bg-muted/20 flex items-center justify-center">
142+
<svg
143+
className="w-5 h-5 text-muted-foreground/50"
144+
fill="none"
145+
stroke="currentColor"
146+
viewBox="0 0 24 24"
147+
>
148+
<path
149+
strokeLinecap="round"
150+
strokeLinejoin="round"
151+
strokeWidth={1.5}
152+
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
153+
/>
154+
</svg>
155+
</div>
156+
<div>
157+
<p className="text-sm font-medium text-foreground">
158+
{emptyMessage}
159+
</p>
160+
<p className="text-xs text-muted-foreground">
161+
{emptyDescription}
162+
</p>
163+
</div>
164+
</div>
73165
</TableCell>
74166
</TableRow>
75167
)}

0 commit comments

Comments
 (0)