feat(dashboard): add EnrichmentBlock wrapper component

Composes UploadSuccessBanner + DashboardNeedsMetadata and reserves a
360px skeleton while \$navigating re-runs the loader with a fresh
incomplete list. Prevents the layout-shift jump after a batch upload
(Leonie's resolved decision #3 on issue #296).

Renders nothing when there is nothing to show — keeps the clean empty
dashboard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-20 21:58:05 +02:00
parent b29125615f
commit e824e23c8c
2 changed files with 100 additions and 0 deletions

View File

@@ -0,0 +1,39 @@
<script lang="ts">
import { navigating } from '$app/stores';
import DashboardNeedsMetadata from './DashboardNeedsMetadata.svelte';
import UploadSuccessBanner from './UploadSuccessBanner.svelte';
type IncompleteDoc = {
id: string;
title: string;
uploadedAt: string;
};
interface Props {
topDocs: IncompleteDoc[];
totalCount: number;
bannerCount: number;
onBannerClose: () => void;
}
let { topDocs, totalCount, bannerCount, onBannerClose }: Props = $props();
const showSkeleton = $derived(!!$navigating && topDocs.length === 0);
const showBlock = $derived(topDocs.length > 0 || bannerCount > 0 || showSkeleton);
</script>
{#if showBlock}
<div data-testid="enrichment-block" class="flex flex-col gap-3">
{#if bannerCount > 0}
<UploadSuccessBanner count={bannerCount} onClose={onBannerClose} />
{/if}
{#if topDocs.length > 0}
<DashboardNeedsMetadata topDocs={topDocs} totalCount={totalCount} />
{:else if showSkeleton}
<div
class="h-[360px] animate-pulse rounded-sm border border-line bg-surface/50 motion-reduce:animate-none"
aria-hidden="true"
></div>
{/if}
</div>
{/if}

View File

@@ -0,0 +1,61 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import EnrichmentBlock from './EnrichmentBlock.svelte';
vi.mock('$app/stores', async () => {
const { writable } = await import('svelte/store');
return { navigating: writable(null) };
});
afterEach(cleanup);
type Doc = { id: string; title: string; uploadedAt: string };
function doc(id: string, title = 'Doc'): Doc {
return { id, title, uploadedAt: '2026-04-20T12:00:00' };
}
describe('EnrichmentBlock', () => {
it('renders nothing when topDocs is empty and banner count is 0', async () => {
render(EnrichmentBlock, {
topDocs: [],
totalCount: 0,
bannerCount: 0,
onBannerClose: vi.fn()
});
await expect.element(page.getByTestId('enrichment-block')).not.toBeInTheDocument();
});
it('renders the list component when topDocs is non-empty', async () => {
render(EnrichmentBlock, {
topDocs: [doc('d1')],
totalCount: 1,
bannerCount: 0,
onBannerClose: vi.fn()
});
await expect.element(page.getByTestId('dashboard-needs-metadata')).toBeInTheDocument();
});
it('renders the banner when bannerCount > 0', async () => {
render(EnrichmentBlock, {
topDocs: [],
totalCount: 0,
bannerCount: 3,
onBannerClose: vi.fn()
});
await expect.element(page.getByRole('status')).toBeInTheDocument();
});
it('composes banner + list when both are present', async () => {
render(EnrichmentBlock, {
topDocs: [doc('d1')],
totalCount: 1,
bannerCount: 2,
onBannerClose: vi.fn()
});
await expect.element(page.getByRole('status')).toBeInTheDocument();
await expect.element(page.getByTestId('dashboard-needs-metadata')).toBeInTheDocument();
});
});