fix(ui): loading skeletons for dashboard/spoons/threads
This commit is contained in:
@@ -3,27 +3,38 @@
|
||||
import Link from 'next/link';
|
||||
import { MetricCard } from '@/components/dashboard/metric-card';
|
||||
import { SpoonCard } from '@/components/spoons/spoon-card';
|
||||
import { ListSkeleton } from '@/components/ui/list-skeleton';
|
||||
import { MaintenanceQueue } from '@/components/threads/maintenance-queue';
|
||||
import { useQuery } from 'convex/react';
|
||||
import { GitBranch, MessageSquare, RefreshCw, ShieldCheck } from 'lucide-react';
|
||||
|
||||
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 spoons = useQuery(api.spoons.listMineWithState, {}) ?? [];
|
||||
const syncRuns = useQuery(api.syncRuns.listRecent, { limit: 5 }) ?? [];
|
||||
const threads = useQuery(api.threads.listMine, { limit: 25 }) ?? [];
|
||||
const activeSpoons = spoons.filter(
|
||||
const spoons = useQuery(api.spoons.listMineWithState, {});
|
||||
const syncRuns = useQuery(api.syncRuns.listRecent, { limit: 5 });
|
||||
const threads = useQuery(api.threads.listMine, { limit: 25 });
|
||||
const spoonList = spoons ?? [];
|
||||
const threadList = threads ?? [];
|
||||
const metricsLoading = spoons === undefined || threads === undefined;
|
||||
const activeSpoons = spoonList.filter(
|
||||
(spoon) => spoon.status === 'active',
|
||||
).length;
|
||||
const behind = spoons.filter(
|
||||
const behind = spoonList.filter(
|
||||
(spoon) => spoon.effectiveUpstreamAheadBy > 0 && spoon.forkAheadBy === 0,
|
||||
).length;
|
||||
const diverged = spoons.filter(
|
||||
const diverged = spoonList.filter(
|
||||
(spoon) => spoon.effectiveUpstreamAheadBy > 0 && spoon.forkAheadBy > 0,
|
||||
).length;
|
||||
const openPullRequests = spoons.reduce(
|
||||
const openPullRequests = spoonList.reduce(
|
||||
(total, spoon) => total + spoon.effectiveUpstreamAheadBy,
|
||||
0,
|
||||
);
|
||||
@@ -43,49 +54,68 @@ const DashboardPage = () => {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
||||
<MetricCard
|
||||
label='Spoons'
|
||||
value={spoons.length}
|
||||
note={`${activeSpoons} active`}
|
||||
icon={GitBranch}
|
||||
/>
|
||||
<MetricCard
|
||||
label='Behind upstream'
|
||||
value={behind}
|
||||
note={`${diverged} diverged`}
|
||||
icon={RefreshCw}
|
||||
/>
|
||||
<MetricCard
|
||||
label='Open threads'
|
||||
value={
|
||||
threads.filter(
|
||||
(thread) =>
|
||||
!['resolved', 'ignored', 'failed', 'cancelled'].includes(
|
||||
thread.status,
|
||||
),
|
||||
).length
|
||||
}
|
||||
note='Across all Spoons'
|
||||
icon={MessageSquare}
|
||||
/>
|
||||
<MetricCard
|
||||
label='Upstream commits'
|
||||
value={openPullRequests}
|
||||
note='Actionable after ignores'
|
||||
icon={ShieldCheck}
|
||||
/>
|
||||
</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'>
|
||||
<MetricCard
|
||||
label='Spoons'
|
||||
value={spoonList.length}
|
||||
note={`${activeSpoons} active`}
|
||||
icon={GitBranch}
|
||||
/>
|
||||
<MetricCard
|
||||
label='Behind upstream'
|
||||
value={behind}
|
||||
note={`${diverged} diverged`}
|
||||
icon={RefreshCw}
|
||||
/>
|
||||
<MetricCard
|
||||
label='Open threads'
|
||||
value={
|
||||
threadList.filter(
|
||||
(thread) =>
|
||||
!['resolved', 'ignored', 'failed', 'cancelled'].includes(
|
||||
thread.status,
|
||||
),
|
||||
).length
|
||||
}
|
||||
note='Across all Spoons'
|
||||
icon={MessageSquare}
|
||||
/>
|
||||
<MetricCard
|
||||
label='Upstream commits'
|
||||
value={openPullRequests}
|
||||
note='Actionable after ignores'
|
||||
icon={ShieldCheck}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className='space-y-3'>
|
||||
<h2 className='text-lg font-semibold'>Maintenance queue</h2>
|
||||
<MaintenanceQueue threads={threads} />
|
||||
{threads === undefined ? (
|
||||
<ListSkeleton rows={2} />
|
||||
) : (
|
||||
<MaintenanceQueue threads={threads} />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className='grid gap-6 xl:grid-cols-2'>
|
||||
<section className='space-y-3'>
|
||||
<h2 className='text-lg font-semibold'>Recent Spoons</h2>
|
||||
{spoons.length ? (
|
||||
{spoons === undefined ? (
|
||||
<ListSkeleton rows={3} />
|
||||
) : spoons.length ? (
|
||||
spoons
|
||||
.slice(0, 3)
|
||||
.map((spoon) => <SpoonCard key={spoon._id} spoon={spoon} />)
|
||||
@@ -108,7 +138,9 @@ const DashboardPage = () => {
|
||||
<CardTitle className='text-base'>Upstream checks</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{syncRuns.length ? (
|
||||
{syncRuns === undefined ? (
|
||||
<ListSkeleton rows={3} rowClassName='h-14 w-full' />
|
||||
) : syncRuns.length ? (
|
||||
<div className='space-y-3'>
|
||||
{syncRuns.map((run) => (
|
||||
<div
|
||||
@@ -137,7 +169,9 @@ const DashboardPage = () => {
|
||||
<CardTitle className='text-base'>Recent threads</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{threads.length ? (
|
||||
{threads === undefined ? (
|
||||
<ListSkeleton rows={3} rowClassName='h-14 w-full' />
|
||||
) : threads.length ? (
|
||||
<div className='space-y-3'>
|
||||
{threads.slice(0, 5).map((thread) => (
|
||||
<div
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { SpoonStatusBadge } from '@/components/spoons/spoon-status-badge';
|
||||
import { ListSkeleton } from '@/components/ui/list-skeleton';
|
||||
import { useQuery } from 'convex/react';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
@@ -32,15 +34,19 @@ const formatDate = (value?: number) =>
|
||||
|
||||
const SpoonsPage = () => {
|
||||
const router = useRouter();
|
||||
const spoons = useQuery(api.spoons.listMineWithState, {}) ?? [];
|
||||
const threads = useQuery(api.threads.listMine, { limit: 100 }) ?? [];
|
||||
const active = spoons.filter((spoon) => spoon.status === 'active').length;
|
||||
const needsReview = threads.filter(
|
||||
const spoons = useQuery(api.spoons.listMineWithState, {});
|
||||
const threads = useQuery(api.threads.listMine, { limit: 100 });
|
||||
const spoonList = spoons ?? [];
|
||||
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.spoonId &&
|
||||
!['resolved', 'ignored', 'failed', 'cancelled'].includes(thread.status),
|
||||
).length;
|
||||
const upstreamWaiting = spoons.reduce(
|
||||
const upstreamWaiting = spoonList.reduce(
|
||||
(total, spoon) => total + spoon.effectiveUpstreamAheadBy,
|
||||
0,
|
||||
);
|
||||
@@ -65,7 +71,11 @@ const SpoonsPage = () => {
|
||||
<CardContent className='flex items-center justify-between p-4'>
|
||||
<div>
|
||||
<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>
|
||||
<GitBranch className='text-muted-foreground size-5' />
|
||||
</CardContent>
|
||||
@@ -74,7 +84,11 @@ const SpoonsPage = () => {
|
||||
<CardContent className='flex items-center justify-between p-4'>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-sm'>Active</p>
|
||||
<p className='text-2xl font-semibold'>{active}</p>
|
||||
{spoonsLoading ? (
|
||||
<Skeleton className='mt-1 h-8 w-10' />
|
||||
) : (
|
||||
<p className='text-2xl font-semibold'>{active}</p>
|
||||
)}
|
||||
</div>
|
||||
<RefreshCw className='text-muted-foreground size-5' />
|
||||
</CardContent>
|
||||
@@ -83,14 +97,20 @@ const SpoonsPage = () => {
|
||||
<CardContent className='flex items-center justify-between p-4'>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-sm'>Open threads</p>
|
||||
<p className='text-2xl font-semibold'>{needsReview}</p>
|
||||
{metricsLoading ? (
|
||||
<Skeleton className='mt-1 h-8 w-10' />
|
||||
) : (
|
||||
<p className='text-2xl font-semibold'>{needsReview}</p>
|
||||
)}
|
||||
</div>
|
||||
<MessageSquare className='text-muted-foreground size-5' />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{spoons.length ? (
|
||||
{spoons === undefined ? (
|
||||
<ListSkeleton rows={4} rowClassName='h-16 w-full' />
|
||||
) : spoons.length ? (
|
||||
<Card className='shadow-none'>
|
||||
<CardContent className='p-0'>
|
||||
<Table>
|
||||
@@ -201,7 +221,7 @@ const SpoonsPage = () => {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{spoons.length ? (
|
||||
{spoonList.length ? (
|
||||
<p className='text-muted-foreground text-sm'>
|
||||
Actionable upstream commits waiting across all Spoons:{' '}
|
||||
{upstreamWaiting}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { DeleteThreadButton } from '@/components/threads/delete-thread-button';
|
||||
import { ListSkeleton } from '@/components/ui/list-skeleton';
|
||||
import { useMutation, useQuery } from 'convex/react';
|
||||
import { MessageSquare, Plus } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
@@ -48,8 +49,7 @@ const ThreadsPage = () => {
|
||||
const spoons = useQuery(api.spoons.listMineWithState, {}) ?? [];
|
||||
const profiles = useQuery(api.aiProviderProfiles.listMine, {}) ?? [];
|
||||
const defaultProfile = profiles.find((profile) => profile.isDefault);
|
||||
const threads =
|
||||
useQuery(api.threads.listMine, {
|
||||
const threads = useQuery(api.threads.listMine, {
|
||||
source: source as
|
||||
| 'all'
|
||||
| 'user_request'
|
||||
@@ -69,9 +69,10 @@ const ThreadsPage = () => {
|
||||
| 'ignored'
|
||||
| 'failed'
|
||||
| 'cancelled',
|
||||
limit: 100,
|
||||
}) ?? [];
|
||||
const visibleThreads = threads.filter((thread) => {
|
||||
limit: 100,
|
||||
});
|
||||
const threadsLoading = threads === undefined;
|
||||
const visibleThreads = (threads ?? []).filter((thread) => {
|
||||
if (spoonFilter !== 'all' && thread.spoonId !== spoonFilter) return false;
|
||||
if (priorityFilter !== 'all' && thread.priority !== priorityFilter) {
|
||||
return false;
|
||||
@@ -312,7 +313,9 @@ const ThreadsPage = () => {
|
||||
</div>
|
||||
|
||||
<div className='space-y-3'>
|
||||
{visibleThreads.length ? (
|
||||
{threadsLoading ? (
|
||||
<ListSkeleton rows={4} />
|
||||
) : visibleThreads.length ? (
|
||||
visibleThreads.map((thread) => (
|
||||
<Card
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user