Improve loading state feedback (#202)

* Improve loading states with graceful transitions and enhanced UX

Co-authored-by: matt <matt@roocode.com>

* DRY it up

* Visual cleanup

* Cleanup

* More cleanup

* More cleanup

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
Matt Rubens 2025-07-04 13:31:04 -04:00 committed by GitHub
parent 7e2cd4b7b0
commit 9c59a4e278
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 392 additions and 68 deletions

View file

@ -10,7 +10,7 @@ import {
formatNumber,
formatTimestamp,
} from '@/lib/formatters';
import { Button, Skeleton } from '@/components/ui';
import { Button } from '@/components/ui';
import { DataTable } from '@/components/layout';
import type { Filter } from './types';
@ -24,13 +24,17 @@ export const Developers = ({
}) => {
const { orgId } = useAuth();
const { data = [], isPending } = useQuery({
const {
data = [],
isPending,
isError,
} = useQuery({
queryKey: ['getDeveloperUsage', orgId, filters],
queryFn: () => getDeveloperUsage({ orgId, filters }),
enabled: !!orgId,
});
const cols: ColumnDef<DeveloperUsage>[] = useMemo(
const columns: ColumnDef<DeveloperUsage>[] = useMemo(
() => [
{
header: 'Developer',
@ -93,16 +97,17 @@ export const Developers = ({
[onFilter],
);
const columns = useMemo(
() =>
isPending
? cols.map((col) => ({
...col,
cell: () => <Skeleton className="h-9 w-full" />,
}))
: cols,
[isPending, cols],
return (
<DataTable
data={data}
columns={columns}
isPending={isPending}
isError={isError}
filters={filters}
loadingMessage="Loading developers..."
errorTitle="Failed to load developers"
emptyTitle="No developers found"
emptyDescription="Developer activity will appear here once team members start using the system"
/>
);
return <DataTable columns={columns} data={data} />;
};

View file

@ -5,7 +5,7 @@ import { useAuth } from '@clerk/nextjs';
import { type ModelUsage, getModelUsage } from '@/actions/analytics';
import { formatCurrency, formatNumber } from '@/lib/formatters';
import { Button, Skeleton } from '@/components/ui';
import { Button } from '@/components/ui';
import { DataTable } from '@/components/layout';
import type { Filter } from './types';
@ -19,13 +19,17 @@ export const Models = ({
}) => {
const { orgId } = useAuth();
const { data = [], isPending } = useQuery({
const {
data = [],
isPending,
isError,
} = useQuery({
queryKey: ['getModelUsage', orgId, filters],
queryFn: () => getModelUsage({ orgId, filters }),
enabled: !!orgId,
});
const cols: ColumnDef<ModelUsage>[] = useMemo(
const columns: ColumnDef<ModelUsage>[] = useMemo(
() => [
{
header: 'Model',
@ -65,16 +69,17 @@ export const Models = ({
[onFilter],
);
const columns = useMemo(
() =>
isPending
? cols.map((col) => ({
...col,
cell: () => <Skeleton className="h-9 w-full" />,
}))
: cols,
[isPending, cols],
return (
<DataTable
data={data}
columns={columns}
isPending={isPending}
isError={isError}
filters={filters}
loadingMessage="Loading models..."
errorTitle="Failed to load models"
emptyTitle="No models found"
emptyDescription="Model usage data will appear here once AI models are used in tasks"
/>
);
return <DataTable columns={columns} data={data} />;
};

View file

@ -11,7 +11,7 @@ import {
formatNumber,
formatTimestamp,
} from '@/lib/formatters';
import { Button, Skeleton } from '@/components/ui';
import { Button } from '@/components/ui';
import { DataTable } from '@/components/layout';
import type { Filter } from './types';
@ -25,13 +25,17 @@ export const Repositories = ({
}) => {
const { orgId } = useAuth();
const { data = [], isPending } = useQuery({
const {
data = [],
isPending,
isError,
} = useQuery({
queryKey: ['getRepositoryUsage', orgId, filters],
queryFn: () => getRepositoryUsage({ orgId, filters }),
enabled: !!orgId,
});
const cols: ColumnDef<RepositoryUsage>[] = useMemo(
const columns: ColumnDef<RepositoryUsage>[] = useMemo(
() => [
{
header: 'Repository',
@ -103,25 +107,17 @@ export const Repositories = ({
[onFilter],
);
const columns = useMemo(
() =>
isPending
? cols.map((col) => ({
...col,
cell: () => <Skeleton className="h-9 w-full" />,
}))
: cols,
[isPending, cols],
return (
<DataTable
data={data}
columns={columns}
isPending={isPending}
isError={isError}
filters={filters}
loadingMessage="Loading repositories..."
errorTitle="Failed to load repositories"
emptyTitle="No repositories found"
emptyDescription="Repository data will appear here once tasks with Git context are created"
/>
);
if (data.length === 0 && !isPending) {
return (
<div className="text-center py-8 text-muted-foreground">
No Git repository data available. Repository information will appear
here once tasks with Git repository context are created.
</div>
);
}
return <DataTable columns={columns} data={data} />;
};

View file

@ -5,8 +5,10 @@ import { useQuery } from '@tanstack/react-query';
import type { TaskWithUser } from '@/actions/analytics';
import { useRealtimePolling } from '@/hooks/useRealtimePolling';
import { useGracefulLoading } from '@/hooks/useGracefulLoading';
import { getTasks } from '@/actions/analytics';
import { Skeleton, CursorPaginationControls } from '@/components/ui';
import { CursorPaginationControls } from '@/components/ui';
import { LoadingState, ErrorState, EmptyState } from '@/components/ui/states';
import { TaskCard } from '@/components/usage';
import { useCursorPagination } from '@/hooks/usePagination';
@ -32,7 +34,7 @@ export const Tasks = ({
// Initialize cursor-based pagination
const pagination = useCursorPagination(100);
const { data, isPending } = useQuery({
const { data, isPending, isError } = useQuery({
queryKey: [
'getTasksPaginated',
orgId,
@ -54,6 +56,13 @@ export const Tasks = ({
...polling,
});
// Use graceful loading hook
const { showContent, isTransitioning } = useGracefulLoading({
isPending,
data: data?.tasks || [],
dependencies: [filters],
});
// Update cursor when we get new data
useEffect(() => {
if (data?.nextCursor) {
@ -81,24 +90,26 @@ export const Tasks = ({
const tasks = data?.tasks || [];
if (isPending) {
// Show loading state while pending or during transition period
if (isPending || !showContent) {
return (
<div className="space-y-3">
{Array(8)
.fill(0)
.map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
<LoadingState
message={isPending ? 'Loading tasks...' : 'Preparing results...'}
isTransitioning={isTransitioning}
/>
);
}
if (isError) {
return <ErrorState title="Failed to load tasks" />;
}
if (tasks.length === 0) {
return (
<div className="text-center py-8 text-muted-foreground">
No tasks have been synced yet. Check back after creating or sharing a
task.
</div>
<EmptyState
title="No tasks found"
description="Tasks will appear here after being created or shared"
/>
);
}

View file

@ -15,22 +15,79 @@ import {
TableHeader,
TableRow,
} from '@/components/ui';
import { useGracefulLoading } from '@/hooks/useGracefulLoading';
import { LoadingState, ErrorState, EmptyState } from '@/components/ui/states';
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
isLoading?: boolean;
emptyMessage?: string;
emptyDescription?: string;
// New props for enhanced state management
isPending?: boolean;
isError?: boolean;
filters?: unknown[];
loadingMessage?: string;
errorTitle?: string;
emptyTitle?: string;
}
export function DataTable<TData, TValue>({
columns,
data,
isLoading = false,
emptyMessage = 'No results found',
emptyDescription = 'Try adjusting your search or filter criteria',
// New props
isPending,
isError,
filters = [],
loadingMessage = 'Loading...',
errorTitle = 'Failed to load',
emptyTitle,
}: DataTableProps<TData, TValue>) {
// Always call hooks at the top level to avoid conditional hook calls
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
// Use graceful loading if isPending is provided, otherwise fall back to isLoading
const shouldUseGracefulLoading = isPending !== undefined;
const { showContent, isTransitioning } = useGracefulLoading({
isPending: isPending || false,
data,
dependencies: [filters],
});
// Enhanced loading state management
if (shouldUseGracefulLoading) {
// Show loading state while pending or during transition period
if (isPending || !showContent) {
return (
<LoadingState
message={loadingMessage}
isTransitioning={isTransitioning}
/>
);
}
if (isError) {
return <ErrorState title={errorTitle} />;
}
if (data.length === 0) {
return (
<EmptyState
title={emptyTitle || emptyMessage}
description={emptyDescription}
/>
);
}
}
return (
<div className="bg-card border shadow rounded">
<Table>
@ -53,7 +110,18 @@ export function DataTable<TData, TValue>({
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
{isLoading ? (
// Show loading rows while data is being fetched
Array.from({ length: 5 }, (_, index) => (
<TableRow key={`loading-${index}`}>
{columns.map((_, colIndex) => (
<TableCell key={`loading-cell-${colIndex}`}>
<div className="h-4 bg-muted/20 rounded animate-pulse" />
</TableCell>
))}
</TableRow>
))
) : table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
@ -68,8 +136,32 @@ export function DataTable<TData, TValue>({
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
<TableCell colSpan={columns.length} className="h-32">
<div className="text-center space-y-3">
<div className="w-10 h-10 mx-auto rounded-full bg-muted/20 flex items-center justify-center">
<svg
className="w-5 h-5 text-muted-foreground/50"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
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"
/>
</svg>
</div>
<div>
<p className="text-sm font-medium text-foreground">
{emptyMessage}
</p>
<p className="text-xs text-muted-foreground">
{emptyDescription}
</p>
</div>
</div>
</TableCell>
</TableRow>
)}

View file

@ -0,0 +1,39 @@
/**
* Collection of SVG icons used in loading, error, and empty states
*/
export const ErrorIcon = () => (
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
className="w-6 h-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
</svg>
);
export const EmptyIcon = () => (
<svg
className="w-6 h-6 text-muted-foreground/50"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
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"
/>
</svg>
);
export const Spinner = () => (
<div className="w-4 h-4 border-2 border-muted-foreground/30 border-t-muted-foreground rounded-full animate-spin" />
);

View file

@ -0,0 +1,24 @@
import React from 'react';
import { StateContainer } from './StateContainer';
import { EmptyIcon } from '../icons/StateIcons';
interface EmptyStateProps {
title: string;
description: string;
}
/**
* Reusable empty state component for when no data is available.
* Uses a consistent default table icon for all empty states.
*/
export function EmptyState({ title, description }: EmptyStateProps) {
return (
<StateContainer
icon={<EmptyIcon />}
iconClassName="bg-muted/20"
iconColor="text-muted-foreground/50"
title={title}
description={description}
/>
);
}

View file

@ -0,0 +1,27 @@
import React from 'react';
import { StateContainer } from './StateContainer';
import { ErrorIcon } from '../icons/StateIcons';
interface ErrorStateProps {
title?: string;
description?: string;
}
/**
* Reusable error state component with consistent styling and messaging.
* Used when data fetching fails or encounters an error.
*/
export function ErrorState({
title = 'Failed to load',
description = 'Please try again or check your connection',
}: ErrorStateProps) {
return (
<StateContainer
icon={<ErrorIcon />}
iconClassName="bg-destructive/10"
iconColor="text-destructive"
title={title}
description={description}
/>
);
}

View file

@ -0,0 +1,27 @@
import React from 'react';
import { Spinner } from '../icons/StateIcons';
interface LoadingStateProps {
message?: string;
transitionMessage?: string;
isTransitioning?: boolean;
}
/**
* Reusable loading state component with spinner and customizable messages.
* Shows different messages for initial loading vs transition states.
*/
export function LoadingState({
message = 'Loading...',
transitionMessage = 'Preparing data...',
isTransitioning = false,
}: LoadingStateProps) {
return (
<div className="text-center mt-8 mb-4">
<div className="inline-flex items-center gap-2 text-sm text-muted-foreground">
<Spinner />
{isTransitioning ? transitionMessage : message}
</div>
</div>
);
}

View file

@ -0,0 +1,37 @@
import React from 'react';
interface StateContainerProps {
icon: React.ReactNode;
iconClassName?: string;
iconColor?: string;
title: string;
description: string;
}
/**
* Shared container component for loading, error, and empty states.
* Provides consistent styling and layout across all state components.
*/
export function StateContainer({
icon,
iconClassName = 'bg-muted/20',
iconColor = 'text-muted-foreground/50',
title,
description,
}: StateContainerProps) {
return (
<div className="text-center py-8">
<div className="text-center space-y-3">
<div
className={`w-12 h-12 mx-auto rounded-full ${iconClassName} flex items-center justify-center`}
>
<div className={iconColor}>{icon}</div>
</div>
<div>
<p className="text-sm font-medium text-foreground">{title}</p>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,4 @@
export { StateContainer } from './StateContainer';
export { LoadingState } from './LoadingState';
export { ErrorState } from './ErrorState';
export { EmptyState } from './EmptyState';

View file

@ -0,0 +1,57 @@
'use client';
import { useState, useEffect } from 'react';
interface GracefulLoadingOptions {
isPending: boolean;
data: unknown;
dependencies?: unknown[];
}
interface GracefulLoadingReturn {
showContent: boolean;
isTransitioning: boolean;
}
/**
* Custom hook to manage graceful loading transitions that prevent jarring "no data" flashes.
* Ensures a minimum loading time before showing content and resets state when dependencies change.
*/
export function useGracefulLoading({
isPending,
data,
dependencies = [],
}: GracefulLoadingOptions): GracefulLoadingReturn {
const [showContent, setShowContent] = useState(false);
// Prevent flashing by ensuring minimum loading time
useEffect(() => {
let timeoutId: number;
if (!isPending && data !== undefined) {
// Add minimum delay before showing content to prevent flash
timeoutId = window.setTimeout(() => {
setShowContent(true);
}, 400);
} else {
setShowContent(false);
}
return () => {
if (timeoutId) {
window.clearTimeout(timeoutId);
}
};
}, [isPending, data]);
// Reset show content when dependencies change (e.g., filters)
const dependenciesKey = JSON.stringify(dependencies);
useEffect(() => {
setShowContent(false);
}, [dependenciesKey]);
return {
showContent,
isTransitioning: !isPending && !showContent,
};
}