Voeg loading-skeleton toe voor pakketten-sectie #13

Merged
Sluijsens merged 2 commits from feature/packages-loading-skeleton into master 2026-08-02 23:46:41 +02:00
3 changed files with 59 additions and 2 deletions
Showing only changes of commit eac58ee6ba - Show all commits
@@ -40,10 +40,11 @@ describe('PackagesSection', () => {
renderWithQueryClient(); renderWithQueryClient();
await waitFor(() => { await waitFor(() => {
expect(screen.getByTestId('packages-grid').children.length).toBe(packages.length); expect(screen.queryByTestId('package-card-skeleton')).not.toBeInTheDocument();
}); });
expect(fetch).toHaveBeenCalledWith('/api/v1/offerings'); expect(fetch).toHaveBeenCalledWith('/api/v1/offerings');
expect(screen.getByTestId('packages-grid').children.length).toBe(packages.length);
for (const pkg of packages) { for (const pkg of packages) {
expect(screen.getByTestId(`package-card-${pkg.id}`)).toBeInTheDocument(); expect(screen.getByTestId(`package-card-${pkg.id}`)).toBeInTheDocument();
expect(screen.getByText(pkg.title)).toBeInTheDocument(); expect(screen.getByText(pkg.title)).toBeInTheDocument();
@@ -60,6 +61,29 @@ describe('PackagesSection', () => {
}); });
}); });
it('shows skeleton placeholders while the request is in flight, reserving the grid space', async () => {
let resolveFetch: (response: Response) => void;
vi.stubGlobal(
'fetch',
vi.fn().mockReturnValue(
new Promise<Response>((resolve) => {
resolveFetch = resolve;
}),
),
);
renderWithQueryClient();
expect(screen.getAllByTestId('package-card-skeleton')).toHaveLength(packages.length);
resolveFetch!(new Response(JSON.stringify(packages), { status: 200 }));
await waitFor(() => {
expect(screen.queryAllByTestId('package-card-skeleton')).toHaveLength(0);
});
expect(screen.getByTestId('packages-grid').children.length).toBe(packages.length);
});
it('shows an error message when the API request fails', async () => { it('shows an error message when the API request fails', async () => {
mockFetchOnce(new Response(null, { status: 500, statusText: 'Internal Server Error' })); mockFetchOnce(new Response(null, { status: 500, statusText: 'Internal Server Error' }));
@@ -0,0 +1,26 @@
// Mirrors PackageCard's structure/spacing 1-to-1 so the grid doesn't reflow once
// real data arrives — only the content becomes pulsing placeholder blocks.
export function PackageCardSkeleton() {
return (
<div
className="flex animate-pulse flex-col rounded border border-line bg-surface p-8"
data-testid="package-card-skeleton"
aria-hidden="true"
>
<div className="mb-3.5 h-[0.78rem] w-16 rounded bg-line" />
<div className="mb-2.5 h-6 w-3/4 rounded bg-line" />
<div className="mb-6 space-y-2">
<div className="h-4 w-full rounded bg-line" />
<div className="h-4 w-2/3 rounded bg-line" />
</div>
<div className="mb-1 h-8 w-24 rounded bg-line" />
<div className="mb-6 h-[0.78rem] w-28 rounded bg-line" />
<ul className="my-6 flex-1 list-none space-y-2.5">
{[1, 2, 3, 4].map((i) => (
<li key={i} className="h-4 w-full rounded bg-line" />
))}
</ul>
<div className="h-[3.4rem] w-full rounded-lg bg-line" />
</div>
);
}
@@ -1,5 +1,10 @@
import { usePackagesQuery } from '../hooks/usePackagesQuery'; import { usePackagesQuery } from '../hooks/usePackagesQuery';
import { PackageCard } from './PackageCard'; import { PackageCard } from './PackageCard';
import { PackageCardSkeleton } from './PackageCardSkeleton';
// Matches the fixed number of packages (see content.ts) so the skeleton grid has the
// same shape as the loaded grid — no layout shift once the real cards arrive.
const SKELETON_COUNT = 3;
export function PackagesSection() { export function PackagesSection() {
const { data: packages, isLoading, isError } = usePackagesQuery(); const { data: packages, isLoading, isError } = usePackagesQuery();
@@ -27,7 +32,9 @@ export function PackagesSection() {
<p className="col-span-full text-center text-muted" data-testid="packages-error"> <p className="col-span-full text-center text-muted" data-testid="packages-error">
Pakketten konden niet worden geladen. Probeer het later opnieuw. Pakketten konden niet worden geladen. Probeer het later opnieuw.
</p> </p>
) : isLoading || !packages ? null : ( ) : isLoading || !packages ? (
Array.from({ length: SKELETON_COUNT }, (_, i) => <PackageCardSkeleton key={i} />)
) : (
packages.map((pkg) => <PackageCard key={pkg.id} pkg={pkg} />) packages.map((pkg) => <PackageCard key={pkg.id} pkg={pkg} />)
)} )}
</div> </div>