fix(ui): loading skeletons for dashboard/spoons/threads
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user