From 9c59a4e2782e93ea05d8442e16a62b4aa3d6a00f Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Fri, 4 Jul 2025 13:31:04 -0400 Subject: [PATCH] Improve loading state feedback (#202) * Improve loading states with graceful transitions and enhanced UX Co-authored-by: matt * DRY it up * Visual cleanup * Cleanup * More cleanup * More cleanup --------- Co-authored-by: Cursor Agent --- .../app/(authenticated)/usage/Developers.tsx | 33 ++++--- .../src/app/(authenticated)/usage/Models.tsx | 33 ++++--- .../(authenticated)/usage/Repositories.tsx | 42 ++++---- .../src/app/(authenticated)/usage/Tasks.tsx | 39 +++++--- apps/web/src/components/layout/DataTable.tsx | 98 ++++++++++++++++++- .../src/components/ui/icons/StateIcons.tsx | 39 ++++++++ .../src/components/ui/states/EmptyState.tsx | 24 +++++ .../src/components/ui/states/ErrorState.tsx | 27 +++++ .../src/components/ui/states/LoadingState.tsx | 27 +++++ .../components/ui/states/StateContainer.tsx | 37 +++++++ apps/web/src/components/ui/states/index.ts | 4 + apps/web/src/hooks/useGracefulLoading.ts | 57 +++++++++++ 12 files changed, 392 insertions(+), 68 deletions(-) create mode 100644 apps/web/src/components/ui/icons/StateIcons.tsx create mode 100644 apps/web/src/components/ui/states/EmptyState.tsx create mode 100644 apps/web/src/components/ui/states/ErrorState.tsx create mode 100644 apps/web/src/components/ui/states/LoadingState.tsx create mode 100644 apps/web/src/components/ui/states/StateContainer.tsx create mode 100644 apps/web/src/components/ui/states/index.ts create mode 100644 apps/web/src/hooks/useGracefulLoading.ts diff --git a/apps/web/src/app/(authenticated)/usage/Developers.tsx b/apps/web/src/app/(authenticated)/usage/Developers.tsx index f4fa67ec22..04a1781246 100644 --- a/apps/web/src/app/(authenticated)/usage/Developers.tsx +++ b/apps/web/src/app/(authenticated)/usage/Developers.tsx @@ -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[] = useMemo( + const columns: ColumnDef[] = useMemo( () => [ { header: 'Developer', @@ -93,16 +97,17 @@ export const Developers = ({ [onFilter], ); - const columns = useMemo( - () => - isPending - ? cols.map((col) => ({ - ...col, - cell: () => , - })) - : cols, - [isPending, cols], + return ( + ); - - return ; }; diff --git a/apps/web/src/app/(authenticated)/usage/Models.tsx b/apps/web/src/app/(authenticated)/usage/Models.tsx index 2783ef45f1..411a3b820f 100644 --- a/apps/web/src/app/(authenticated)/usage/Models.tsx +++ b/apps/web/src/app/(authenticated)/usage/Models.tsx @@ -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[] = useMemo( + const columns: ColumnDef[] = useMemo( () => [ { header: 'Model', @@ -65,16 +69,17 @@ export const Models = ({ [onFilter], ); - const columns = useMemo( - () => - isPending - ? cols.map((col) => ({ - ...col, - cell: () => , - })) - : cols, - [isPending, cols], + return ( + ); - - return ; }; diff --git a/apps/web/src/app/(authenticated)/usage/Repositories.tsx b/apps/web/src/app/(authenticated)/usage/Repositories.tsx index 63602b69df..81d25a1b64 100644 --- a/apps/web/src/app/(authenticated)/usage/Repositories.tsx +++ b/apps/web/src/app/(authenticated)/usage/Repositories.tsx @@ -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[] = useMemo( + const columns: ColumnDef[] = useMemo( () => [ { header: 'Repository', @@ -103,25 +107,17 @@ export const Repositories = ({ [onFilter], ); - const columns = useMemo( - () => - isPending - ? cols.map((col) => ({ - ...col, - cell: () => , - })) - : cols, - [isPending, cols], + return ( + ); - - if (data.length === 0 && !isPending) { - return ( -
- No Git repository data available. Repository information will appear - here once tasks with Git repository context are created. -
- ); - } - - return ; }; diff --git a/apps/web/src/app/(authenticated)/usage/Tasks.tsx b/apps/web/src/app/(authenticated)/usage/Tasks.tsx index 1b15cca6d9..92087e8137 100644 --- a/apps/web/src/app/(authenticated)/usage/Tasks.tsx +++ b/apps/web/src/app/(authenticated)/usage/Tasks.tsx @@ -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 ( -
- {Array(8) - .fill(0) - .map((_, i) => ( - - ))} -
+ ); } + if (isError) { + return ; + } + if (tasks.length === 0) { return ( -
- No tasks have been synced yet. Check back after creating or sharing a - task. -
+ ); } diff --git a/apps/web/src/components/layout/DataTable.tsx b/apps/web/src/components/layout/DataTable.tsx index 50f6e959a9..dff74e7ef1 100644 --- a/apps/web/src/components/layout/DataTable.tsx +++ b/apps/web/src/components/layout/DataTable.tsx @@ -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 { columns: ColumnDef[]; 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({ 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) { + // 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 ( + + ); + } + + if (isError) { + return ; + } + + if (data.length === 0) { + return ( + + ); + } + } + return (
@@ -53,7 +110,18 @@ export function DataTable({ ))} - {table.getRowModel().rows?.length ? ( + {isLoading ? ( + // Show loading rows while data is being fetched + Array.from({ length: 5 }, (_, index) => ( + + {columns.map((_, colIndex) => ( + +
+ + ))} + + )) + ) : table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( ({ )) ) : ( - - No results. + +
+
+ + + +
+
+

+ {emptyMessage} +

+

+ {emptyDescription} +

+
+
)} diff --git a/apps/web/src/components/ui/icons/StateIcons.tsx b/apps/web/src/components/ui/icons/StateIcons.tsx new file mode 100644 index 0000000000..fbdcc48765 --- /dev/null +++ b/apps/web/src/components/ui/icons/StateIcons.tsx @@ -0,0 +1,39 @@ +/** + * Collection of SVG icons used in loading, error, and empty states + */ + +export const ErrorIcon = () => ( + + + +); + +export const EmptyIcon = () => ( + + + +); + +export const Spinner = () => ( +
+); diff --git a/apps/web/src/components/ui/states/EmptyState.tsx b/apps/web/src/components/ui/states/EmptyState.tsx new file mode 100644 index 0000000000..5c579ee86a --- /dev/null +++ b/apps/web/src/components/ui/states/EmptyState.tsx @@ -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 ( + } + iconClassName="bg-muted/20" + iconColor="text-muted-foreground/50" + title={title} + description={description} + /> + ); +} diff --git a/apps/web/src/components/ui/states/ErrorState.tsx b/apps/web/src/components/ui/states/ErrorState.tsx new file mode 100644 index 0000000000..374204ed48 --- /dev/null +++ b/apps/web/src/components/ui/states/ErrorState.tsx @@ -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 ( + } + iconClassName="bg-destructive/10" + iconColor="text-destructive" + title={title} + description={description} + /> + ); +} diff --git a/apps/web/src/components/ui/states/LoadingState.tsx b/apps/web/src/components/ui/states/LoadingState.tsx new file mode 100644 index 0000000000..da73bde642 --- /dev/null +++ b/apps/web/src/components/ui/states/LoadingState.tsx @@ -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 ( +
+
+ + {isTransitioning ? transitionMessage : message} +
+
+ ); +} diff --git a/apps/web/src/components/ui/states/StateContainer.tsx b/apps/web/src/components/ui/states/StateContainer.tsx new file mode 100644 index 0000000000..a09c41bab3 --- /dev/null +++ b/apps/web/src/components/ui/states/StateContainer.tsx @@ -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 ( +
+
+
+
{icon}
+
+
+

{title}

+

{description}

+
+
+
+ ); +} diff --git a/apps/web/src/components/ui/states/index.ts b/apps/web/src/components/ui/states/index.ts new file mode 100644 index 0000000000..d3b0e4fa4c --- /dev/null +++ b/apps/web/src/components/ui/states/index.ts @@ -0,0 +1,4 @@ +export { StateContainer } from './StateContainer'; +export { LoadingState } from './LoadingState'; +export { ErrorState } from './ErrorState'; +export { EmptyState } from './EmptyState'; diff --git a/apps/web/src/hooks/useGracefulLoading.ts b/apps/web/src/hooks/useGracefulLoading.ts new file mode 100644 index 0000000000..00ac455cb6 --- /dev/null +++ b/apps/web/src/hooks/useGracefulLoading.ts @@ -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, + }; +}