fix(ui): loading skeletons for dashboard/spoons/threads

This commit is contained in:
Gabriel Brown
2026-07-11 16:50:24 -04:00
parent 8d7486bc8c
commit 8c579d060a
5 changed files with 213 additions and 61 deletions
+47 -13
View File
@@ -3,27 +3,38 @@
import Link from 'next/link'; import Link from 'next/link';
import { MetricCard } from '@/components/dashboard/metric-card'; import { MetricCard } from '@/components/dashboard/metric-card';
import { SpoonCard } from '@/components/spoons/spoon-card'; import { SpoonCard } from '@/components/spoons/spoon-card';
import { ListSkeleton } from '@/components/ui/list-skeleton';
import { MaintenanceQueue } from '@/components/threads/maintenance-queue'; import { MaintenanceQueue } from '@/components/threads/maintenance-queue';
import { useQuery } from 'convex/react'; import { useQuery } from 'convex/react';
import { GitBranch, MessageSquare, RefreshCw, ShieldCheck } from 'lucide-react'; import { GitBranch, MessageSquare, RefreshCw, ShieldCheck } from 'lucide-react';
import { api } from '@spoon/backend/convex/_generated/api.js'; import { api } from '@spoon/backend/convex/_generated/api.js';
import { Button, Card, CardContent, CardHeader, CardTitle } from '@spoon/ui'; import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
Skeleton,
} from '@spoon/ui';
const DashboardPage = () => { const DashboardPage = () => {
const spoons = useQuery(api.spoons.listMineWithState, {}) ?? []; const spoons = useQuery(api.spoons.listMineWithState, {});
const syncRuns = useQuery(api.syncRuns.listRecent, { limit: 5 }) ?? []; const syncRuns = useQuery(api.syncRuns.listRecent, { limit: 5 });
const threads = useQuery(api.threads.listMine, { limit: 25 }) ?? []; const threads = useQuery(api.threads.listMine, { limit: 25 });
const activeSpoons = spoons.filter( const spoonList = spoons ?? [];
const threadList = threads ?? [];
const metricsLoading = spoons === undefined || threads === undefined;
const activeSpoons = spoonList.filter(
(spoon) => spoon.status === 'active', (spoon) => spoon.status === 'active',
).length; ).length;
const behind = spoons.filter( const behind = spoonList.filter(
(spoon) => spoon.effectiveUpstreamAheadBy > 0 && spoon.forkAheadBy === 0, (spoon) => spoon.effectiveUpstreamAheadBy > 0 && spoon.forkAheadBy === 0,
).length; ).length;
const diverged = spoons.filter( const diverged = spoonList.filter(
(spoon) => spoon.effectiveUpstreamAheadBy > 0 && spoon.forkAheadBy > 0, (spoon) => spoon.effectiveUpstreamAheadBy > 0 && spoon.forkAheadBy > 0,
).length; ).length;
const openPullRequests = spoons.reduce( const openPullRequests = spoonList.reduce(
(total, spoon) => total + spoon.effectiveUpstreamAheadBy, (total, spoon) => total + spoon.effectiveUpstreamAheadBy,
0, 0,
); );
@@ -43,10 +54,22 @@ const DashboardPage = () => {
</Button> </Button>
</div> </div>
{metricsLoading ? (
<div
role='status'
aria-label='Loading'
className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'
>
{Array.from({ length: 4 }).map((_, index) => (
<Skeleton key={index} className='h-32 w-full' />
))}
<span className='sr-only'>Loading</span>
</div>
) : (
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'> <div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
<MetricCard <MetricCard
label='Spoons' label='Spoons'
value={spoons.length} value={spoonList.length}
note={`${activeSpoons} active`} note={`${activeSpoons} active`}
icon={GitBranch} icon={GitBranch}
/> />
@@ -59,7 +82,7 @@ const DashboardPage = () => {
<MetricCard <MetricCard
label='Open threads' label='Open threads'
value={ value={
threads.filter( threadList.filter(
(thread) => (thread) =>
!['resolved', 'ignored', 'failed', 'cancelled'].includes( !['resolved', 'ignored', 'failed', 'cancelled'].includes(
thread.status, thread.status,
@@ -76,16 +99,23 @@ const DashboardPage = () => {
icon={ShieldCheck} icon={ShieldCheck}
/> />
</div> </div>
)}
<section className='space-y-3'> <section className='space-y-3'>
<h2 className='text-lg font-semibold'>Maintenance queue</h2> <h2 className='text-lg font-semibold'>Maintenance queue</h2>
{threads === undefined ? (
<ListSkeleton rows={2} />
) : (
<MaintenanceQueue threads={threads} /> <MaintenanceQueue threads={threads} />
)}
</section> </section>
<div className='grid gap-6 xl:grid-cols-2'> <div className='grid gap-6 xl:grid-cols-2'>
<section className='space-y-3'> <section className='space-y-3'>
<h2 className='text-lg font-semibold'>Recent Spoons</h2> <h2 className='text-lg font-semibold'>Recent Spoons</h2>
{spoons.length ? ( {spoons === undefined ? (
<ListSkeleton rows={3} />
) : spoons.length ? (
spoons spoons
.slice(0, 3) .slice(0, 3)
.map((spoon) => <SpoonCard key={spoon._id} spoon={spoon} />) .map((spoon) => <SpoonCard key={spoon._id} spoon={spoon} />)
@@ -108,7 +138,9 @@ const DashboardPage = () => {
<CardTitle className='text-base'>Upstream checks</CardTitle> <CardTitle className='text-base'>Upstream checks</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{syncRuns.length ? ( {syncRuns === undefined ? (
<ListSkeleton rows={3} rowClassName='h-14 w-full' />
) : syncRuns.length ? (
<div className='space-y-3'> <div className='space-y-3'>
{syncRuns.map((run) => ( {syncRuns.map((run) => (
<div <div
@@ -137,7 +169,9 @@ const DashboardPage = () => {
<CardTitle className='text-base'>Recent threads</CardTitle> <CardTitle className='text-base'>Recent threads</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{threads.length ? ( {threads === undefined ? (
<ListSkeleton rows={3} rowClassName='h-14 w-full' />
) : threads.length ? (
<div className='space-y-3'> <div className='space-y-3'>
{threads.slice(0, 5).map((thread) => ( {threads.slice(0, 5).map((thread) => (
<div <div
+28 -8
View File
@@ -3,6 +3,7 @@
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { SpoonStatusBadge } from '@/components/spoons/spoon-status-badge'; import { SpoonStatusBadge } from '@/components/spoons/spoon-status-badge';
import { ListSkeleton } from '@/components/ui/list-skeleton';
import { useQuery } from 'convex/react'; import { useQuery } from 'convex/react';
import { import {
ArrowUpRight, ArrowUpRight,
@@ -17,6 +18,7 @@ import {
Button, Button,
Card, Card,
CardContent, CardContent,
Skeleton,
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
@@ -32,15 +34,19 @@ const formatDate = (value?: number) =>
const SpoonsPage = () => { const SpoonsPage = () => {
const router = useRouter(); const router = useRouter();
const spoons = useQuery(api.spoons.listMineWithState, {}) ?? []; const spoons = useQuery(api.spoons.listMineWithState, {});
const threads = useQuery(api.threads.listMine, { limit: 100 }) ?? []; const threads = useQuery(api.threads.listMine, { limit: 100 });
const active = spoons.filter((spoon) => spoon.status === 'active').length; const spoonList = spoons ?? [];
const needsReview = threads.filter( const threadList = threads ?? [];
const spoonsLoading = spoons === undefined;
const metricsLoading = spoons === undefined || threads === undefined;
const active = spoonList.filter((spoon) => spoon.status === 'active').length;
const needsReview = threadList.filter(
(thread) => (thread) =>
thread.spoonId && thread.spoonId &&
!['resolved', 'ignored', 'failed', 'cancelled'].includes(thread.status), !['resolved', 'ignored', 'failed', 'cancelled'].includes(thread.status),
).length; ).length;
const upstreamWaiting = spoons.reduce( const upstreamWaiting = spoonList.reduce(
(total, spoon) => total + spoon.effectiveUpstreamAheadBy, (total, spoon) => total + spoon.effectiveUpstreamAheadBy,
0, 0,
); );
@@ -65,7 +71,11 @@ const SpoonsPage = () => {
<CardContent className='flex items-center justify-between p-4'> <CardContent className='flex items-center justify-between p-4'>
<div> <div>
<p className='text-muted-foreground text-sm'>Managed</p> <p className='text-muted-foreground text-sm'>Managed</p>
<p className='text-2xl font-semibold'>{spoons.length}</p> {spoonsLoading ? (
<Skeleton className='mt-1 h-8 w-10' />
) : (
<p className='text-2xl font-semibold'>{spoonList.length}</p>
)}
</div> </div>
<GitBranch className='text-muted-foreground size-5' /> <GitBranch className='text-muted-foreground size-5' />
</CardContent> </CardContent>
@@ -74,7 +84,11 @@ const SpoonsPage = () => {
<CardContent className='flex items-center justify-between p-4'> <CardContent className='flex items-center justify-between p-4'>
<div> <div>
<p className='text-muted-foreground text-sm'>Active</p> <p className='text-muted-foreground text-sm'>Active</p>
{spoonsLoading ? (
<Skeleton className='mt-1 h-8 w-10' />
) : (
<p className='text-2xl font-semibold'>{active}</p> <p className='text-2xl font-semibold'>{active}</p>
)}
</div> </div>
<RefreshCw className='text-muted-foreground size-5' /> <RefreshCw className='text-muted-foreground size-5' />
</CardContent> </CardContent>
@@ -83,14 +97,20 @@ const SpoonsPage = () => {
<CardContent className='flex items-center justify-between p-4'> <CardContent className='flex items-center justify-between p-4'>
<div> <div>
<p className='text-muted-foreground text-sm'>Open threads</p> <p className='text-muted-foreground text-sm'>Open threads</p>
{metricsLoading ? (
<Skeleton className='mt-1 h-8 w-10' />
) : (
<p className='text-2xl font-semibold'>{needsReview}</p> <p className='text-2xl font-semibold'>{needsReview}</p>
)}
</div> </div>
<MessageSquare className='text-muted-foreground size-5' /> <MessageSquare className='text-muted-foreground size-5' />
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{spoons.length ? ( {spoons === undefined ? (
<ListSkeleton rows={4} rowClassName='h-16 w-full' />
) : spoons.length ? (
<Card className='shadow-none'> <Card className='shadow-none'>
<CardContent className='p-0'> <CardContent className='p-0'>
<Table> <Table>
@@ -201,7 +221,7 @@ const SpoonsPage = () => {
</Card> </Card>
)} )}
{spoons.length ? ( {spoonList.length ? (
<p className='text-muted-foreground text-sm'> <p className='text-muted-foreground text-sm'>
Actionable upstream commits waiting across all Spoons:{' '} Actionable upstream commits waiting across all Spoons:{' '}
{upstreamWaiting} {upstreamWaiting}
+8 -5
View File
@@ -4,6 +4,7 @@ import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { DeleteThreadButton } from '@/components/threads/delete-thread-button'; import { DeleteThreadButton } from '@/components/threads/delete-thread-button';
import { ListSkeleton } from '@/components/ui/list-skeleton';
import { useMutation, useQuery } from 'convex/react'; import { useMutation, useQuery } from 'convex/react';
import { MessageSquare, Plus } from 'lucide-react'; import { MessageSquare, Plus } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -48,8 +49,7 @@ const ThreadsPage = () => {
const spoons = useQuery(api.spoons.listMineWithState, {}) ?? []; const spoons = useQuery(api.spoons.listMineWithState, {}) ?? [];
const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? []; const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
const defaultProfile = profiles.find((profile) => profile.isDefault); const defaultProfile = profiles.find((profile) => profile.isDefault);
const threads = const threads = useQuery(api.threads.listMine, {
useQuery(api.threads.listMine, {
source: source as source: source as
| 'all' | 'all'
| 'user_request' | 'user_request'
@@ -70,8 +70,9 @@ const ThreadsPage = () => {
| 'failed' | 'failed'
| 'cancelled', | 'cancelled',
limit: 100, limit: 100,
}) ?? []; });
const visibleThreads = threads.filter((thread) => { const threadsLoading = threads === undefined;
const visibleThreads = (threads ?? []).filter((thread) => {
if (spoonFilter !== 'all' && thread.spoonId !== spoonFilter) return false; if (spoonFilter !== 'all' && thread.spoonId !== spoonFilter) return false;
if (priorityFilter !== 'all' && thread.priority !== priorityFilter) { if (priorityFilter !== 'all' && thread.priority !== priorityFilter) {
return false; return false;
@@ -312,7 +313,9 @@ const ThreadsPage = () => {
</div> </div>
<div className='space-y-3'> <div className='space-y-3'>
{visibleThreads.length ? ( {threadsLoading ? (
<ListSkeleton rows={4} />
) : visibleThreads.length ? (
visibleThreads.map((thread) => ( visibleThreads.map((thread) => (
<Card <Card
key={thread._id} key={thread._id}
@@ -0,0 +1,23 @@
import { cn, Skeleton } from '@spoon/ui';
export const ListSkeleton = ({
rows = 3,
rowClassName = 'h-20 w-full',
className,
}: {
rows?: number;
rowClassName?: string;
className?: string;
}) => (
<div
role='status'
aria-label='Loading'
data-testid='list-skeleton'
className={cn('space-y-3', className)}
>
{Array.from({ length: rows }).map((_, index) => (
<Skeleton key={index} className={rowClassName} />
))}
<span className='sr-only'>Loading</span>
</div>
);
@@ -0,0 +1,72 @@
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import DashboardPage from '../../src/app/(app)/dashboard/page';
const { mockUseQuery } = vi.hoisted(() => ({
mockUseQuery: vi.fn(),
}));
vi.mock('convex/react', () => ({
useConvexAuth: () => ({ isAuthenticated: false }),
useMutation: vi.fn(),
useQuery: mockUseQuery,
}));
vi.mock('next/navigation', () => ({
useParams: vi.fn(),
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
useSearchParams: () => new URLSearchParams(),
}));
describe('dashboard loading vs empty state', () => {
afterEach(() => {
cleanup();
mockUseQuery.mockReset();
});
it('shows skeletons while queries are loading (undefined)', () => {
mockUseQuery.mockReturnValue(undefined);
render(<DashboardPage />);
expect(screen.getAllByTestId('list-skeleton').length).toBeGreaterThan(0);
expect(screen.queryByText('No Spoons yet')).not.toBeInTheDocument();
});
it('shows empty states once loaded with no data ([])', () => {
mockUseQuery.mockReturnValue([]);
render(<DashboardPage />);
expect(screen.queryByTestId('list-skeleton')).not.toBeInTheDocument();
expect(screen.getByText('No Spoons yet')).toBeInTheDocument();
});
it('renders data once loaded with items', () => {
mockUseQuery.mockImplementation((_query, args) => {
// spoons list has an empty args object ({}); recent syncRuns/threads pass limits.
if (args && 'limit' in args) return [];
return [
{
_id: 'spoon-1',
name: 'useSend',
upstreamOwner: 'acme',
upstreamRepo: 'app',
status: 'active',
syncCadence: 'daily',
effectiveUpstreamAheadBy: 0,
rawUpstreamAheadBy: 0,
forkAheadBy: 0,
ignoredUpstreamCount: 0,
},
];
});
render(<DashboardPage />);
expect(screen.getByText('useSend')).toBeInTheDocument();
expect(screen.queryByTestId('list-skeleton')).not.toBeInTheDocument();
expect(screen.queryByText('No Spoons yet')).not.toBeInTheDocument();
});
});