feat: dashboard enrichment-list-block after batch upload (#296) #298
@@ -17,6 +17,7 @@ import org.raddatz.familienarchiv.dto.DocumentSearchResult;
|
||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||
import org.raddatz.familienarchiv.dto.TagOperator;
|
||||
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
||||
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
|
||||
import org.raddatz.familienarchiv.exception.DomainException;
|
||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||
import org.raddatz.familienarchiv.model.Document;
|
||||
@@ -193,11 +194,21 @@ public class DocumentController {
|
||||
}
|
||||
|
||||
@GetMapping("/incomplete-count")
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public Map<String, Long> getIncompleteCount() {
|
||||
return Map.of("count", documentService.getIncompleteCount());
|
||||
}
|
||||
|
||||
@GetMapping("/incomplete")
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public List<IncompleteDocumentDTO> getIncomplete(
|
||||
@Parameter(description = "Maximum number of results (server caps at 200)")
|
||||
@RequestParam(defaultValue = "50") int size) {
|
||||
return documentService.findIncompleteDocuments(Math.min(size, 200));
|
||||
}
|
||||
|
||||
@GetMapping("/incomplete/next")
|
||||
@RequirePermission(Permission.WRITE_ALL)
|
||||
public ResponseEntity<Document> getNextIncomplete(@RequestParam UUID excludeId) {
|
||||
return documentService.findNextIncompleteDocument(excludeId)
|
||||
.map(ResponseEntity::ok)
|
||||
|
||||
@@ -2,9 +2,11 @@ package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
public record IncompleteDocumentDTO(
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) UUID id,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) String title,
|
||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED) LocalDateTime uploadedAt
|
||||
) {}
|
||||
|
||||
@@ -542,7 +542,7 @@ public class DocumentService {
|
||||
PageRequest pageable = PageRequest.of(0, size, Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||
return documentRepository.findByMetadataCompleteFalse(pageable)
|
||||
.stream()
|
||||
.map(doc -> new IncompleteDocumentDTO(doc.getId(), doc.getTitle()))
|
||||
.map(doc -> new IncompleteDocumentDTO(doc.getId(), doc.getTitle(), doc.getCreatedAt()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
|
||||
@@ -382,7 +382,7 @@ class DocumentControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void getIncompleteCount_returns200_withCount() throws Exception {
|
||||
when(documentService.getIncompleteCount()).thenReturn(3L);
|
||||
|
||||
@@ -391,14 +391,52 @@ class DocumentControllerTest {
|
||||
.andExpect(jsonPath("$.count").value(3));
|
||||
}
|
||||
|
||||
// ─── GET /api/documents/incomplete (removed — superseded by dashboard) ────
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void getIncompleteCount_returns403_forReaderOnly() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/incomplete-count"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
// ─── GET /api/documents/incomplete ───────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getIncomplete_endpointRemoved() throws Exception {
|
||||
// The path hits /{id} and fails UUID conversion — not a 200 anymore
|
||||
void getIncomplete_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/incomplete"))
|
||||
.andExpect(status().is4xxClientError());
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = {"WRITE_ALL"})
|
||||
void getIncomplete_returns200_forWriter_withDTOList() throws Exception {
|
||||
UUID id = UUID.randomUUID();
|
||||
java.time.LocalDateTime uploadedAt = java.time.LocalDateTime.of(2026, 4, 20, 12, 0);
|
||||
var dto = new org.raddatz.familienarchiv.dto.IncompleteDocumentDTO(id, "Unvollständig", uploadedAt);
|
||||
when(documentService.findIncompleteDocuments(anyInt())).thenReturn(List.of(dto));
|
||||
|
||||
mockMvc.perform(get("/api/documents/incomplete"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].id").value(id.toString()))
|
||||
.andExpect(jsonPath("$[0].title").value("Unvollständig"))
|
||||
.andExpect(jsonPath("$[0].uploadedAt").exists());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void getIncomplete_returns403_forReaderOnly() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/incomplete"))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void getIncomplete_capsSizeAt200() throws Exception {
|
||||
when(documentService.findIncompleteDocuments(anyInt())).thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/documents/incomplete").param("size", "9999"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(documentService).findIncompleteDocuments(200);
|
||||
}
|
||||
|
||||
// ─── GET /api/documents/incomplete/next ──────────────────────────────────
|
||||
@@ -411,7 +449,7 @@ class DocumentControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void getNextIncomplete_returns200_whenNextExists() throws Exception {
|
||||
UUID excludeId = UUID.randomUUID();
|
||||
Document next = Document.builder()
|
||||
@@ -425,7 +463,15 @@ class DocumentControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
@WithMockUser(authorities = "READ_ALL")
|
||||
void getNextIncomplete_returns403_forReaderOnly() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/incomplete/next")
|
||||
.param("excludeId", UUID.randomUUID().toString()))
|
||||
.andExpect(status().isForbidden());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser(authorities = "WRITE_ALL")
|
||||
void getNextIncomplete_returns204_whenNoneRemain() throws Exception {
|
||||
UUID excludeId = UUID.randomUUID();
|
||||
when(documentService.findNextIncompleteDocument(excludeId)).thenReturn(Optional.empty());
|
||||
|
||||
@@ -390,6 +390,22 @@ class DocumentServiceTest {
|
||||
assertThat(result.get(0).title()).isEqualTo("Unvollständig");
|
||||
}
|
||||
|
||||
@Test
|
||||
void findIncompleteDocuments_mapsUploadedAtFromCreatedAt() {
|
||||
java.time.LocalDateTime createdAt = java.time.LocalDateTime.of(2026, 4, 20, 12, 0);
|
||||
Document doc = Document.builder()
|
||||
.id(UUID.randomUUID())
|
||||
.title("Recent")
|
||||
.createdAt(createdAt)
|
||||
.build();
|
||||
when(documentRepository.findByMetadataCompleteFalse(any(Pageable.class)))
|
||||
.thenReturn(new PageImpl<>(List.of(doc)));
|
||||
|
||||
List<IncompleteDocumentDTO> result = documentService.findIncompleteDocuments(3);
|
||||
|
||||
assertThat(result.get(0).uploadedAt()).isEqualTo(createdAt);
|
||||
}
|
||||
|
||||
@Test
|
||||
void findIncompleteDocuments_passesSizeToPageable() {
|
||||
when(documentRepository.findByMetadataCompleteFalse(any(Pageable.class)))
|
||||
|
||||
83
frontend/e2e/dashboard-enrichment-block.spec.ts
Normal file
83
frontend/e2e/dashboard-enrichment-block.spec.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Dashboard enrichment-list-block (issue #296) — full upload → banner → CTA journey,
|
||||
* plus axe sweep in light and dark mode at 320 / 768 / 1440 viewports.
|
||||
*
|
||||
* The uploaded PDFs are deleted in afterEach so this spec does not pollute
|
||||
* the dev DB between runs.
|
||||
*/
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { execSync } from 'child_process';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const VIEWPORTS = [
|
||||
{ name: '320', width: 320, height: 720 },
|
||||
{ name: '768', width: 768, height: 1024 },
|
||||
{ name: '1440', width: 1440, height: 900 }
|
||||
];
|
||||
|
||||
const psql = (sql: string) =>
|
||||
execSync(
|
||||
`docker exec archive-db psql -U archive_user family_archive_db -c "${sql.replace(/"/g, '\\"')}"`
|
||||
);
|
||||
|
||||
test.afterEach(() => {
|
||||
// Remove any document whose filename matches the seeded sentinel — keeps the
|
||||
// DB clean for subsequent test runs.
|
||||
psql(`DELETE FROM documents WHERE original_filename IN ('minimal.pdf', 'minimal2.pdf');`);
|
||||
});
|
||||
|
||||
async function uploadViaDropZone(page: Page, files: string[]) {
|
||||
const inputLocator = page.locator('input[type="file"][accept*="pdf"]');
|
||||
await inputLocator.first().setInputFiles(files);
|
||||
}
|
||||
|
||||
test.describe('Dashboard enrichment block — upload → banner → CTA', () => {
|
||||
test('banner appears after upload and CTA navigates to /enrich', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
const fixturePath = (name: string) => path.join(__dirname, 'fixtures', name);
|
||||
await uploadViaDropZone(page, [fixturePath('minimal.pdf'), fixturePath('minimal2.pdf')]);
|
||||
|
||||
const banner = page.getByRole('status').filter({ hasText: /hochgeladen/ });
|
||||
await expect(banner).toBeVisible();
|
||||
await expect(banner).toContainText(/2 Dokumente/);
|
||||
|
||||
await banner.getByRole('link', { name: /ergänzen/i }).click();
|
||||
await expect(page).toHaveURL(/\/enrich(\/|$)/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Dashboard enrichment block — axe sweep', () => {
|
||||
for (const viewport of VIEWPORTS) {
|
||||
for (const scheme of ['light', 'dark'] as const) {
|
||||
test(`no wcag2a/wcag2aa violations at ${viewport.name}px (${scheme})`, async ({
|
||||
browser
|
||||
}) => {
|
||||
const context = await browser.newContext({
|
||||
colorScheme: scheme,
|
||||
viewport: { width: viewport.width, height: viewport.height },
|
||||
storageState: path.join(__dirname, '.auth/user.json')
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await page.goto('/');
|
||||
await page.waitForSelector('[data-hydrated]');
|
||||
|
||||
const results = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze();
|
||||
|
||||
if (results.violations.length > 0) {
|
||||
console.log(
|
||||
`Violations on dashboard @ ${viewport.name}px ${scheme}:\n` +
|
||||
results.violations.map((v) => `[${v.impact}] ${v.id}`).join('\n')
|
||||
);
|
||||
}
|
||||
expect(results.violations).toEqual([]);
|
||||
await context.close();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -415,6 +415,11 @@
|
||||
"dashboard_notification_replied": "hat geantwortet",
|
||||
"dashboard_needs_metadata_heading": "Metadaten fehlen",
|
||||
"dashboard_needs_metadata_show_all": "Alle anzeigen",
|
||||
"dashboard_needs_metadata_show_all_count": "Alle {count} anzeigen →",
|
||||
"upload_banner_singular": "1 Dokument hochgeladen.",
|
||||
"upload_banner_plural": "{count} Dokumente hochgeladen.",
|
||||
"upload_banner_cta": "Jetzt ergänzen →",
|
||||
"upload_banner_close": "Benachrichtigung schließen",
|
||||
"dashboard_recent_heading": "Zuletzt aktiv",
|
||||
"dashboard_stats_documents": "Dokumente",
|
||||
"dashboard_stats_persons": "Personen",
|
||||
|
||||
@@ -415,6 +415,11 @@
|
||||
"dashboard_notification_replied": "replied",
|
||||
"dashboard_needs_metadata_heading": "Missing Metadata",
|
||||
"dashboard_needs_metadata_show_all": "Show all",
|
||||
"dashboard_needs_metadata_show_all_count": "Show all {count} →",
|
||||
"upload_banner_singular": "1 document uploaded.",
|
||||
"upload_banner_plural": "{count} documents uploaded.",
|
||||
"upload_banner_cta": "Enrich now →",
|
||||
"upload_banner_close": "Dismiss notification",
|
||||
"dashboard_recent_heading": "Recent Activity",
|
||||
"dashboard_stats_documents": "Documents",
|
||||
"dashboard_stats_persons": "Persons",
|
||||
|
||||
@@ -415,6 +415,11 @@
|
||||
"dashboard_notification_replied": "respondió",
|
||||
"dashboard_needs_metadata_heading": "Metadatos incompletos",
|
||||
"dashboard_needs_metadata_show_all": "Ver todos",
|
||||
"dashboard_needs_metadata_show_all_count": "Ver los {count} →",
|
||||
"upload_banner_singular": "1 documento subido.",
|
||||
"upload_banner_plural": "{count} documentos subidos.",
|
||||
"upload_banner_cta": "Completar ahora →",
|
||||
"upload_banner_close": "Cerrar notificación",
|
||||
"dashboard_recent_heading": "Actividad reciente",
|
||||
"dashboard_stats_documents": "Documentos",
|
||||
"dashboard_stats_persons": "Personas",
|
||||
|
||||
@@ -1,37 +1,73 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { relativeTimeDe } from '$lib/relativeTime';
|
||||
|
||||
type IncompleteDocumentDTO = {
|
||||
type IncompleteDoc = {
|
||||
id: string;
|
||||
title: string;
|
||||
uploadedAt: string;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
incompleteDocs: IncompleteDocumentDTO[];
|
||||
topDocs: IncompleteDoc[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
let { incompleteDocs }: Props = $props();
|
||||
let { topDocs, totalCount }: Props = $props();
|
||||
|
||||
const showFooter = $derived(totalCount > 5);
|
||||
</script>
|
||||
|
||||
{#if incompleteDocs.length > 0}
|
||||
{#if topDocs.length > 0}
|
||||
<div data-testid="dashboard-needs-metadata" class="rounded-sm border border-line bg-surface p-6">
|
||||
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||
{m.dashboard_needs_metadata_heading()}
|
||||
</h2>
|
||||
{#each incompleteDocs as doc (doc.id)}
|
||||
<div class="flex items-center border-b border-line py-2 last:border-0">
|
||||
<ul class="divide-y divide-line">
|
||||
{#each topDocs as doc (doc.id)}
|
||||
<li>
|
||||
<a
|
||||
href="/enrich/{doc.id}"
|
||||
class="group flex items-center gap-3 py-3 text-ink hover:bg-accent-bg/40"
|
||||
>
|
||||
<img
|
||||
src="/degruyter-icons/Simple/Medium-24px/SVG/Action/Copy-Item-MD.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5 shrink-0 opacity-50 group-hover:opacity-80"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate font-serif text-base text-ink group-hover:underline">
|
||||
<span class="sr-only">PDF: </span>{doc.title}
|
||||
</div>
|
||||
<div class="font-sans text-xs text-ink-3">
|
||||
{relativeTimeDe(new Date(doc.uploadedAt))}
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
class="h-4 w-4 shrink-0 text-ink-3 transition-transform group-hover:translate-x-0.5 motion-reduce:transition-none motion-reduce:group-hover:translate-x-0"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if showFooter}
|
||||
<div class="mt-4">
|
||||
<a
|
||||
href="/enrich/{doc.id}"
|
||||
class="font-serif text-lg text-ink hover:text-ink-2 hover:underline"
|
||||
href="/enrich"
|
||||
class="font-sans text-sm font-medium text-ink-2 hover:text-ink hover:underline"
|
||||
>
|
||||
{doc.title}
|
||||
{m.dashboard_needs_metadata_show_all_count({ count: totalCount })}
|
||||
</a>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="mt-4">
|
||||
<a href="/enrich" class="font-sans text-sm text-ink-2 hover:text-ink hover:underline"
|
||||
>{m.dashboard_needs_metadata_show_all()}</a
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -6,44 +6,52 @@ import DashboardNeedsMetadata from './DashboardNeedsMetadata.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
type IncompleteDocumentDTO = {
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
type IncompleteDoc = { id: string; title: string; uploadedAt: string };
|
||||
|
||||
function makeDoc(id: string, title: string): IncompleteDocumentDTO {
|
||||
return { id, title };
|
||||
function makeDoc(id: string, title: string, uploadedAt = '2026-04-20T12:00:00'): IncompleteDoc {
|
||||
return { id, title, uploadedAt };
|
||||
}
|
||||
|
||||
describe('DashboardNeedsMetadata', () => {
|
||||
it('renders nothing when incompleteDocs is empty', async () => {
|
||||
render(DashboardNeedsMetadata, { incompleteDocs: [] });
|
||||
it('renders nothing when topDocs is empty', async () => {
|
||||
render(DashboardNeedsMetadata, { topDocs: [], totalCount: 0 });
|
||||
const widget = page.getByTestId('dashboard-needs-metadata');
|
||||
await expect.element(widget).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the widget when incompleteDocs are present', async () => {
|
||||
render(DashboardNeedsMetadata, { incompleteDocs: [makeDoc('d1', 'Taufschein')] });
|
||||
const widget = page.getByTestId('dashboard-needs-metadata');
|
||||
await expect.element(widget).toBeInTheDocument();
|
||||
it('shows the widget when topDocs is present', async () => {
|
||||
render(DashboardNeedsMetadata, { topDocs: [makeDoc('d1', 'Taufschein')], totalCount: 1 });
|
||||
await expect.element(page.getByTestId('dashboard-needs-metadata')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a link to /enrich/{id} for each document', async () => {
|
||||
it('renders one link per row pointing at /enrich/{id}', async () => {
|
||||
const docs = [makeDoc('d1', 'Taufschein'), makeDoc('d2', 'Heiratsurkunde')];
|
||||
render(DashboardNeedsMetadata, { incompleteDocs: docs });
|
||||
const links = page.getByRole('link');
|
||||
await expect.element(links.nth(0)).toHaveAttribute('href', '/enrich/d1');
|
||||
await expect.element(links.nth(1)).toHaveAttribute('href', '/enrich/d2');
|
||||
render(DashboardNeedsMetadata, { topDocs: docs, totalCount: 2 });
|
||||
await expect
|
||||
.element(page.getByRole('link', { name: /Taufschein/ }))
|
||||
.toHaveAttribute('href', '/enrich/d1');
|
||||
await expect
|
||||
.element(page.getByRole('link', { name: /Heiratsurkunde/ }))
|
||||
.toHaveAttribute('href', '/enrich/d2');
|
||||
});
|
||||
|
||||
it('shows the document title in each row', async () => {
|
||||
render(DashboardNeedsMetadata, { incompleteDocs: [makeDoc('d1', 'Sterbeurkunde 1930')] });
|
||||
await expect.element(page.getByText('Sterbeurkunde 1930')).toBeInTheDocument();
|
||||
it('hides the footer link when totalCount is 5 or fewer', async () => {
|
||||
const docs = Array.from({ length: 5 }, (_, i) => makeDoc(`d${i}`, `Dok ${i}`));
|
||||
render(DashboardNeedsMetadata, { topDocs: docs, totalCount: 5 });
|
||||
const footer = page.getByRole('link', { name: /Alle/i });
|
||||
await expect.element(footer).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a "Alle anzeigen" link to /enrich', async () => {
|
||||
render(DashboardNeedsMetadata, { incompleteDocs: [makeDoc('d1', 'Dok')] });
|
||||
const allLink = page.getByRole('link', { name: /Alle anzeigen/i });
|
||||
await expect.element(allLink).toHaveAttribute('href', '/enrich');
|
||||
it('shows the footer link with totalCount when totalCount > 5', async () => {
|
||||
const docs = Array.from({ length: 5 }, (_, i) => makeDoc(`d${i}`, `Dok ${i}`));
|
||||
render(DashboardNeedsMetadata, { topDocs: docs, totalCount: 12 });
|
||||
const footer = page.getByRole('link', { name: /12/ });
|
||||
await expect.element(footer).toHaveAttribute('href', '/enrich');
|
||||
});
|
||||
|
||||
it('uses totalCount in the footer even when topDocs has fewer items', async () => {
|
||||
const docs = [makeDoc('d1', 'Only one')];
|
||||
render(DashboardNeedsMetadata, { topDocs: docs, totalCount: 50 });
|
||||
await expect.element(page.getByRole('link', { name: /50/ })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
41
frontend/src/lib/components/EnrichmentBlock.svelte
Normal file
41
frontend/src/lib/components/EnrichmentBlock.svelte
Normal file
@@ -0,0 +1,41 @@
|
||||
<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
|
||||
data-testid="enrichment-block-skeleton"
|
||||
class="h-[360px] animate-pulse rounded-sm border border-line bg-surface/50 motion-reduce:animate-none"
|
||||
aria-busy="true"
|
||||
aria-label="Lade aktualisierte Liste"
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
89
frontend/src/lib/components/EnrichmentBlock.svelte.spec.ts
Normal file
89
frontend/src/lib/components/EnrichmentBlock.svelte.spec.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
|
||||
// The store must live in a separate module because vi.mock factories are
|
||||
// hoisted and cannot reference top-level variables defined in this file.
|
||||
import { navigatingStore } from './__mocks__/navigatingStore';
|
||||
import EnrichmentBlock from './EnrichmentBlock.svelte';
|
||||
|
||||
vi.mock('$app/stores', async () => {
|
||||
const mod = await import('./__mocks__/navigatingStore');
|
||||
return { navigating: mod.navigatingStore };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
navigatingStore.set(null);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it('renders the skeleton when $navigating is active and topDocs is empty', async () => {
|
||||
navigatingStore.set({ type: 'link' });
|
||||
render(EnrichmentBlock, {
|
||||
topDocs: [],
|
||||
totalCount: 0,
|
||||
bannerCount: 0,
|
||||
onBannerClose: vi.fn()
|
||||
});
|
||||
await expect.element(page.getByTestId('enrichment-block-skeleton')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render the skeleton when topDocs is non-empty even during $navigating', async () => {
|
||||
navigatingStore.set({ type: 'link' });
|
||||
render(EnrichmentBlock, {
|
||||
topDocs: [doc('d1')],
|
||||
totalCount: 1,
|
||||
bannerCount: 0,
|
||||
onBannerClose: vi.fn()
|
||||
});
|
||||
await expect.element(page.getByTestId('enrichment-block-skeleton')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
62
frontend/src/lib/components/UploadSuccessBanner.svelte
Normal file
62
frontend/src/lib/components/UploadSuccessBanner.svelte
Normal file
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
interface Props {
|
||||
count: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { count, onClose }: Props = $props();
|
||||
|
||||
const message = $derived(
|
||||
count === 1 ? m.upload_banner_singular() : m.upload_banner_plural({ count })
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
const timer = setTimeout(onClose, 8000);
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="flex items-center gap-3 rounded-sm border border-line bg-accent-bg/60 px-4 py-3 text-ink"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 shrink-0 text-primary"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<p class="flex-1 font-sans text-sm">
|
||||
<span>{message}</span>
|
||||
<a href="/enrich" class="ml-1 font-medium text-primary hover:underline">
|
||||
{m.upload_banner_cta()}
|
||||
</a>
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="upload-banner-close"
|
||||
aria-label={m.upload_banner_close()}
|
||||
onclick={onClose}
|
||||
class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-sm text-ink-3 hover:bg-ink/10 hover:text-ink"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
|
||||
import UploadSuccessBanner from './UploadSuccessBanner.svelte';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('UploadSuccessBanner', () => {
|
||||
it('renders singular copy for count of 1', async () => {
|
||||
render(UploadSuccessBanner, { count: 1, onClose: () => {} });
|
||||
const status = page.getByRole('status');
|
||||
await expect.element(status).toBeInTheDocument();
|
||||
await expect.element(status).toHaveTextContent(/1 Dokument/);
|
||||
});
|
||||
|
||||
it('renders plural copy for count greater than 1', async () => {
|
||||
render(UploadSuccessBanner, { count: 3, onClose: () => {} });
|
||||
await expect.element(page.getByRole('status')).toHaveTextContent(/3 Dokumente/);
|
||||
});
|
||||
|
||||
it('exposes role=status with aria-live polite', async () => {
|
||||
render(UploadSuccessBanner, { count: 1, onClose: () => {} });
|
||||
await expect.element(page.getByRole('status')).toHaveAttribute('aria-live', 'polite');
|
||||
});
|
||||
|
||||
it('renders a CTA link to /enrich', async () => {
|
||||
render(UploadSuccessBanner, { count: 2, onClose: () => {} });
|
||||
await expect
|
||||
.element(page.getByRole('link', { name: /ergänzen/i }))
|
||||
.toHaveAttribute('href', '/enrich');
|
||||
});
|
||||
|
||||
it('invokes onClose when the close button is clicked', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(UploadSuccessBanner, { count: 1, onClose });
|
||||
const button = document.querySelector(
|
||||
'[data-testid="upload-banner-close"]'
|
||||
) as HTMLButtonElement | null;
|
||||
expect(button).not.toBeNull();
|
||||
button?.click();
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('auto-dismisses after 8000ms', async () => {
|
||||
vi.useFakeTimers();
|
||||
const onClose = vi.fn();
|
||||
render(UploadSuccessBanner, { count: 1, onClose });
|
||||
vi.advanceTimersByTime(7999);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(2);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
3
frontend/src/lib/components/__mocks__/navigatingStore.ts
Normal file
3
frontend/src/lib/components/__mocks__/navigatingStore.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const navigatingStore = writable<unknown | null>(null);
|
||||
@@ -1140,6 +1140,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/incomplete": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getIncomplete"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/incomplete/next": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1799,17 +1815,17 @@ export interface components {
|
||||
/** Format: uuid */
|
||||
id?: string;
|
||||
displayName?: string;
|
||||
personType?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
/** Format: int64 */
|
||||
documentCount?: number;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
/** Format: int32 */
|
||||
birthYear?: number;
|
||||
/** Format: int32 */
|
||||
deathYear?: number;
|
||||
alias?: string;
|
||||
notes?: string;
|
||||
personType?: string;
|
||||
};
|
||||
SenderModel: {
|
||||
/** Format: uuid */
|
||||
@@ -1877,10 +1893,10 @@ export interface components {
|
||||
timeout?: number;
|
||||
};
|
||||
PageNotificationDTO: {
|
||||
/** Format: int64 */
|
||||
totalElements?: number;
|
||||
/** Format: int32 */
|
||||
totalPages?: number;
|
||||
/** Format: int64 */
|
||||
totalElements?: number;
|
||||
pageable?: components["schemas"]["PageableObject"];
|
||||
first?: boolean;
|
||||
last?: boolean;
|
||||
@@ -1979,6 +1995,13 @@ export interface components {
|
||||
summarySnippet?: string;
|
||||
summaryOffsets: components["schemas"]["MatchOffset"][];
|
||||
};
|
||||
IncompleteDocumentDTO: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
title: string;
|
||||
/** Format: date-time */
|
||||
uploadedAt: string;
|
||||
};
|
||||
DashboardResumeDTO: {
|
||||
/** Format: uuid */
|
||||
documentId: string;
|
||||
@@ -4223,6 +4246,29 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getIncomplete: {
|
||||
parameters: {
|
||||
query?: {
|
||||
/** @description Maximum number of results (server caps at 200) */
|
||||
size?: number;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["IncompleteDocumentDTO"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getNextIncomplete: {
|
||||
parameters: {
|
||||
query: {
|
||||
|
||||
41
frontend/src/lib/relativeTime.spec.ts
Normal file
41
frontend/src/lib/relativeTime.spec.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { relativeTimeDe } from './relativeTime';
|
||||
|
||||
const NOW = new Date('2026-04-20T12:00:00Z');
|
||||
|
||||
describe('relativeTimeDe', () => {
|
||||
it('returns minutes for a gap under one hour', () => {
|
||||
const from = new Date('2026-04-20T11:58:00Z');
|
||||
expect(relativeTimeDe(from, NOW)).toContain('2');
|
||||
expect(relativeTimeDe(from, NOW)).toMatch(/Minute/i);
|
||||
});
|
||||
|
||||
it('returns hours for a gap between 1 and 24 hours', () => {
|
||||
const from = new Date('2026-04-20T09:00:00Z');
|
||||
expect(relativeTimeDe(from, NOW)).toContain('3');
|
||||
expect(relativeTimeDe(from, NOW)).toMatch(/Stunde/i);
|
||||
});
|
||||
|
||||
it('returns days for a gap of 24 hours or more', () => {
|
||||
const from = new Date('2026-04-18T12:00:00Z');
|
||||
expect(relativeTimeDe(from, NOW)).toContain('2');
|
||||
expect(relativeTimeDe(from, NOW)).toMatch(/Tag/i);
|
||||
});
|
||||
|
||||
it('rounds minutes to the nearest whole number', () => {
|
||||
const from = new Date('2026-04-20T11:58:20Z');
|
||||
expect(relativeTimeDe(from, NOW)).toContain('2');
|
||||
});
|
||||
|
||||
it('handles zero gap as 0 minutes', () => {
|
||||
expect(relativeTimeDe(NOW, NOW)).toMatch(/0/);
|
||||
expect(relativeTimeDe(NOW, NOW)).toMatch(/Minute/i);
|
||||
});
|
||||
|
||||
it('falls back to 0 minutes when the input Date is invalid', () => {
|
||||
const invalid = new Date('not-a-real-date');
|
||||
// Never crash the UI if the backend ever ships a malformed uploadedAt.
|
||||
expect(relativeTimeDe(invalid, NOW)).toMatch(/0/);
|
||||
expect(relativeTimeDe(invalid, NOW)).toMatch(/Minute/i);
|
||||
});
|
||||
});
|
||||
11
frontend/src/lib/relativeTime.ts
Normal file
11
frontend/src/lib/relativeTime.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
export function relativeTimeDe(from: Date, now: Date = new Date()): string {
|
||||
const minutes = Math.round((now.getTime() - from.getTime()) / 60_000);
|
||||
// Malformed input (e.g. Invalid Date from a broken backend string) must not
|
||||
// crash the dashboard — fall back to "0 Minuten" rather than render NaN.
|
||||
if (!Number.isFinite(minutes)) return m.comment_time_minutes({ count: 0 });
|
||||
if (minutes < 60) return m.comment_time_minutes({ count: minutes });
|
||||
if (minutes < 1440) return m.comment_time_hours({ count: Math.round(minutes / 60) });
|
||||
return m.comment_time_days({ count: Math.round(minutes / 1440) });
|
||||
}
|
||||
@@ -8,6 +8,7 @@ type TranscriptionWeeklyStatsDTO = components['schemas']['TranscriptionWeeklySta
|
||||
type DashboardResumeDTO = components['schemas']['DashboardResumeDTO'];
|
||||
type DashboardPulseDTO = components['schemas']['DashboardPulseDTO'];
|
||||
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
|
||||
type IncompleteDocumentDTO = components['schemas']['IncompleteDocumentDTO'];
|
||||
|
||||
export async function load({ fetch }) {
|
||||
const api = createApiClient(fetch);
|
||||
@@ -27,7 +28,9 @@ export async function load({ fetch }) {
|
||||
segmentationResult,
|
||||
transcriptionResult,
|
||||
readyResult,
|
||||
weeklyStatsResult
|
||||
weeklyStatsResult,
|
||||
incompleteResult,
|
||||
incompleteCountResult
|
||||
] = await Promise.allSettled([
|
||||
api.GET('/api/stats'),
|
||||
api.GET('/api/dashboard/resume'),
|
||||
@@ -36,7 +39,9 @@ export async function load({ fetch }) {
|
||||
api.GET('/api/transcription/segmentation-queue'),
|
||||
api.GET('/api/transcription/transcription-queue'),
|
||||
api.GET('/api/transcription/ready-to-read'),
|
||||
api.GET('/api/transcription/weekly-stats')
|
||||
api.GET('/api/transcription/weekly-stats'),
|
||||
api.GET('/api/documents/incomplete', { params: { query: { size: 5 } } }),
|
||||
api.GET('/api/documents/incomplete-count')
|
||||
]);
|
||||
|
||||
let stats: StatsDTO | null = null;
|
||||
@@ -47,6 +52,8 @@ export async function load({ fetch }) {
|
||||
let transcriptionDocs: TranscriptionQueueItemDTO[] = [];
|
||||
let readyDocs: TranscriptionQueueItemDTO[] = [];
|
||||
let weeklyStats: TranscriptionWeeklyStatsDTO | null = null;
|
||||
let incompleteDocs: IncompleteDocumentDTO[] = [];
|
||||
let incompleteTotal = 0;
|
||||
|
||||
if (statsResult.status === 'fulfilled' && statsResult.value.response.ok) {
|
||||
stats = statsResult.value.data ?? null;
|
||||
@@ -72,6 +79,12 @@ export async function load({ fetch }) {
|
||||
if (weeklyStatsResult.status === 'fulfilled' && weeklyStatsResult.value.response.ok) {
|
||||
weeklyStats = weeklyStatsResult.value.data ?? null;
|
||||
}
|
||||
if (incompleteResult.status === 'fulfilled' && incompleteResult.value.response.ok) {
|
||||
incompleteDocs = (incompleteResult.value.data ?? []) as IncompleteDocumentDTO[];
|
||||
}
|
||||
if (incompleteCountResult.status === 'fulfilled' && incompleteCountResult.value.response.ok) {
|
||||
incompleteTotal = (incompleteCountResult.value.data?.count as number | undefined) ?? 0;
|
||||
}
|
||||
|
||||
return {
|
||||
stats,
|
||||
@@ -82,6 +95,8 @@ export async function load({ fetch }) {
|
||||
transcriptionDocs,
|
||||
readyDocs,
|
||||
weeklyStats,
|
||||
incompleteDocs,
|
||||
incompleteTotal,
|
||||
error: null as string | null
|
||||
};
|
||||
} catch (e) {
|
||||
@@ -96,6 +111,8 @@ export async function load({ fetch }) {
|
||||
transcriptionDocs: [],
|
||||
readyDocs: [],
|
||||
weeklyStats: null,
|
||||
incompleteDocs: [] as IncompleteDocumentDTO[],
|
||||
incompleteTotal: 0,
|
||||
error: 'Daten konnten nicht geladen werden.' as string | null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,10 +4,13 @@ import DashboardResumeStrip from '$lib/components/DashboardResumeStrip.svelte';
|
||||
import MissionControlStrip from '$lib/components/MissionControlStrip.svelte';
|
||||
import DashboardFamilyPulse from '$lib/components/DashboardFamilyPulse.svelte';
|
||||
import DashboardActivityFeed from '$lib/components/DashboardActivityFeed.svelte';
|
||||
import EnrichmentBlock from '$lib/components/EnrichmentBlock.svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let bannerCount = $state(0);
|
||||
|
||||
const greetingText = $derived.by(() => {
|
||||
const name = data?.user?.firstName ?? '';
|
||||
const h = new Date().getHours();
|
||||
@@ -32,6 +35,13 @@ const greetingText = $derived.by(() => {
|
||||
<div class="flex flex-col gap-5">
|
||||
<DashboardResumeStrip resumeDoc={data.resumeDoc ?? null} />
|
||||
|
||||
<EnrichmentBlock
|
||||
topDocs={data.incompleteDocs ?? []}
|
||||
totalCount={data.incompleteTotal ?? 0}
|
||||
bannerCount={bannerCount}
|
||||
onBannerClose={() => (bannerCount = 0)}
|
||||
/>
|
||||
|
||||
<section aria-label={m.dashboard_mission_caption()}>
|
||||
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.dashboard_mission_caption()}
|
||||
@@ -49,7 +59,7 @@ const greetingText = $derived.by(() => {
|
||||
<DashboardFamilyPulse pulse={data.pulse ?? null} />
|
||||
<DashboardActivityFeed feed={data.activityFeed ?? []} />
|
||||
{#if data.canWrite}
|
||||
<DropZone />
|
||||
<DropZone onUploadComplete={(count) => (bannerCount = count)} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,12 @@ import { getErrorMessage } from '$lib/errors';
|
||||
|
||||
const ACCEPTED_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/tiff'];
|
||||
|
||||
interface Props {
|
||||
onUploadComplete?: (count: number) => void;
|
||||
}
|
||||
|
||||
let { onUploadComplete }: Props = $props();
|
||||
|
||||
let isDragging = $state(false);
|
||||
let windowDragging = $state(false);
|
||||
let dragCounter = 0;
|
||||
@@ -80,6 +86,7 @@ async function uploadFiles(files: File[]) {
|
||||
const result = JSON.parse(body);
|
||||
if (result.created?.length > 0) {
|
||||
messages.push({ text: m.upload_success({ count: result.created.length }), isError: false });
|
||||
onUploadComplete?.(result.created.length);
|
||||
}
|
||||
for (const doc of result.updated ?? []) {
|
||||
messages.push({
|
||||
|
||||
89
frontend/src/routes/DropZone.svelte.spec.ts
Normal file
89
frontend/src/routes/DropZone.svelte.spec.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
|
||||
import DropZone from './DropZone.svelte';
|
||||
|
||||
// vi.hoisted lets the mock fn reference survive vi.mock's hoisting so tests
|
||||
// can assert on it from below while the factory remains self-contained.
|
||||
const { invalidateAllMock } = vi.hoisted(() => ({ invalidateAllMock: vi.fn(async () => {}) }));
|
||||
vi.mock('$app/navigation', () => ({ invalidateAll: invalidateAllMock }));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function stubXhrWith(responseBody: string) {
|
||||
class FakeXhr {
|
||||
upload = { addEventListener: vi.fn() };
|
||||
status = 200;
|
||||
responseText = responseBody;
|
||||
private loadHandler: (() => void) | null = null;
|
||||
open = vi.fn();
|
||||
addEventListener = vi.fn((event: string, handler: () => void) => {
|
||||
if (event === 'load') this.loadHandler = handler;
|
||||
});
|
||||
send = vi.fn(() => {
|
||||
queueMicrotask(() => this.loadHandler?.());
|
||||
});
|
||||
}
|
||||
vi.stubGlobal('XMLHttpRequest', FakeXhr);
|
||||
}
|
||||
|
||||
describe('DropZone onUploadComplete', () => {
|
||||
it('invokes callback with created.length after a successful upload', async () => {
|
||||
stubXhrWith(JSON.stringify({ created: [{ id: 'd1' }, { id: 'd2' }], updated: [], errors: [] }));
|
||||
|
||||
const onUploadComplete = vi.fn();
|
||||
render(DropZone, { onUploadComplete });
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement | null;
|
||||
expect(input).not.toBeNull();
|
||||
const file = new File(['%PDF-1.4'], 'test.pdf', { type: 'application/pdf' });
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(file);
|
||||
input!.files = dataTransfer.files;
|
||||
input!.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(onUploadComplete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(onUploadComplete).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('does not invoke callback when no files were created', async () => {
|
||||
stubXhrWith(JSON.stringify({ created: [], updated: [], errors: [] }));
|
||||
|
||||
const onUploadComplete = vi.fn();
|
||||
render(DropZone, { onUploadComplete });
|
||||
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const file = new File(['%PDF-1.4'], 'dupe.pdf', { type: 'application/pdf' });
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(file);
|
||||
input.files = dt.files;
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
|
||||
// invalidateAll is the last async step of the upload handler — once it
|
||||
// has been called, the callback decision has already been made.
|
||||
await vi.waitFor(() => {
|
||||
expect(invalidateAllMock).toHaveBeenCalled();
|
||||
});
|
||||
expect(onUploadComplete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('works when the onUploadComplete prop is not supplied', async () => {
|
||||
stubXhrWith(JSON.stringify({ created: [{ id: 'x' }], updated: [], errors: [] }));
|
||||
|
||||
render(DropZone, {});
|
||||
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const file = new File(['%PDF-1.4'], 'x.pdf', { type: 'application/pdf' });
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(file);
|
||||
// Should not throw when the optional callback is absent.
|
||||
input.files = dt.files;
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
await expect.element(page.getByText(/1 Dokument/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -92,7 +92,9 @@ describe('home page load — dashboard', () => {
|
||||
.mockResolvedValueOnce({
|
||||
response: { ok: true },
|
||||
data: { segmentationCount: 0, transcriptionCount: 0, readyCount: 0 }
|
||||
}); // weekly-stats
|
||||
}) // weekly-stats
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: { count: 0 } }); // incomplete-count
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
@@ -123,7 +125,9 @@ describe('home page load — dashboard', () => {
|
||||
.mockResolvedValueOnce({
|
||||
response: { ok: true },
|
||||
data: { segmentationCount: 0, transcriptionCount: 0, readyCount: 0 }
|
||||
}); // weekly-stats
|
||||
}) // weekly-stats
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: { count: 0 } }); // incomplete-count
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
Reference in New Issue
Block a user