feat(#145): transform home page into user dashboard #151
@@ -11,6 +11,7 @@ import java.util.UUID;
|
||||
|
||||
|
||||
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
|
||||
import org.raddatz.familienarchiv.dto.DocumentVersionSummary;
|
||||
import org.raddatz.familienarchiv.dto.IncompleteDocumentDTO;
|
||||
@@ -167,7 +168,7 @@ public class DocumentController {
|
||||
|
||||
@GetMapping("/incomplete")
|
||||
public List<IncompleteDocumentDTO> getIncomplete(
|
||||
@RequestParam(defaultValue = "10") int size) {
|
||||
@Parameter(description = "Maximum number of results") @RequestParam(defaultValue = "10") int size) {
|
||||
return documentService.findIncompleteDocuments(size);
|
||||
}
|
||||
|
||||
@@ -178,6 +179,12 @@ public class DocumentController {
|
||||
.orElse(ResponseEntity.noContent().build());
|
||||
}
|
||||
|
||||
@GetMapping("/recent-activity")
|
||||
public ResponseEntity<List<Document>> getRecentActivity(
|
||||
@RequestParam(defaultValue = "5") int size) {
|
||||
return ResponseEntity.ok(documentService.getRecentActivity(size));
|
||||
}
|
||||
|
||||
@GetMapping("/search")
|
||||
public ResponseEntity<List<Document>> search(
|
||||
@RequestParam(required = false) String q,
|
||||
@@ -186,7 +193,7 @@ public class DocumentController {
|
||||
@RequestParam(required = false) UUID senderId,
|
||||
@RequestParam(required = false) UUID receiverId,
|
||||
@RequestParam(required = false, name = "tag") List<String> tags,
|
||||
@RequestParam(required = false) DocumentStatus status) {
|
||||
@Parameter(description = "Filter by document status") @RequestParam(required = false) DocumentStatus status) {
|
||||
return ResponseEntity.ok(documentService.searchDocuments(q, from, to, senderId, receiverId, tags, status));
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@ public interface NotificationRepository extends JpaRepository<Notification, UUID
|
||||
|
||||
Page<Notification> findByRecipientIdOrderByCreatedAtDesc(UUID recipientId, Pageable pageable);
|
||||
|
||||
Page<Notification> findByRecipientIdAndTypeOrderByCreatedAtDesc(
|
||||
UUID recipientId, NotificationType type, Pageable pageable);
|
||||
|
||||
Page<Notification> findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(
|
||||
UUID recipientId, NotificationType type, Pageable pageable);
|
||||
|
||||
|
||||
@@ -260,6 +260,13 @@ public class DocumentService {
|
||||
return documentRepository.save(doc);
|
||||
}
|
||||
|
||||
// 0. Zuletzt aktive Dokumente (sortiert nach updatedAt DESC)
|
||||
public List<Document> getRecentActivity(int size) {
|
||||
return documentRepository.findAll(
|
||||
PageRequest.of(0, size, Sort.by(Sort.Direction.DESC, "updatedAt"))
|
||||
).getContent();
|
||||
}
|
||||
|
||||
// 1. Allgemeine Suche (für das Suchfeld im Frontend)
|
||||
public List<Document> searchDocuments(String text, LocalDate from, LocalDate to, UUID sender, UUID receiver, List<String> tags, DocumentStatus status) {
|
||||
Specification<Document> spec = Specification.where(hasText(text))
|
||||
|
||||
@@ -98,6 +98,10 @@ public class NotificationService {
|
||||
return notificationRepository.findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(userId, type, pageable)
|
||||
.map(this::toDTO);
|
||||
}
|
||||
if (type != null) {
|
||||
return notificationRepository.findByRecipientIdAndTypeOrderByCreatedAtDesc(userId, type, pageable)
|
||||
.map(this::toDTO);
|
||||
}
|
||||
return notificationRepository.findByRecipientIdOrderByCreatedAtDesc(userId, pageable)
|
||||
.map(this::toDTO);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ spring:
|
||||
enabled: false # Managed explicitly via FlywayConfig bean
|
||||
|
||||
jpa:
|
||||
open-in-view: false # Prevents holding DB connections for the full HTTP request lifecycle
|
||||
hibernate:
|
||||
ddl-auto: none
|
||||
properties:
|
||||
|
||||
@@ -406,6 +406,38 @@ class DocumentControllerTest {
|
||||
.andExpect(status().isNoContent());
|
||||
}
|
||||
|
||||
// ─── GET /api/documents/recent-activity ──────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getRecentActivity_returns401_whenUnauthenticated() throws Exception {
|
||||
mockMvc.perform(get("/api/documents/recent-activity"))
|
||||
.andExpect(status().isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getRecentActivity_returnsOkWithDocuments() throws Exception {
|
||||
Document doc1 = Document.builder().id(UUID.randomUUID()).title("Alpha").originalFilename("a.pdf").build();
|
||||
Document doc2 = Document.builder().id(UUID.randomUUID()).title("Beta").originalFilename("b.pdf").build();
|
||||
when(documentService.getRecentActivity(5)).thenReturn(List.of(doc1, doc2));
|
||||
|
||||
mockMvc.perform(get("/api/documents/recent-activity").param("size", "5"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$[0].title").value("Alpha"))
|
||||
.andExpect(jsonPath("$[1].title").value("Beta"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithMockUser
|
||||
void getRecentActivity_appliesDefaultSizeOfFive_whenSizeParamOmitted() throws Exception {
|
||||
when(documentService.getRecentActivity(5)).thenReturn(List.of());
|
||||
|
||||
mockMvc.perform(get("/api/documents/recent-activity"))
|
||||
.andExpect(status().isOk());
|
||||
|
||||
verify(documentService).getRecentActivity(5);
|
||||
}
|
||||
|
||||
// ─── GET /api/documents/{id}/versions ────────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -152,6 +152,23 @@ class DocumentRepositoryTest {
|
||||
assertThat(documentRepository.countByMetadataCompleteFalse()).isEqualTo(1);
|
||||
}
|
||||
|
||||
// ─── findAll (PageRequest) — recent activity ──────────────────────────────
|
||||
|
||||
@Test
|
||||
void findAll_withPageRequest_returnsOnlySizeRows_notFullTable() {
|
||||
for (int i = 0; i < 10; i++) {
|
||||
documentRepository.save(Document.builder()
|
||||
.title("Doc " + i).originalFilename("doc" + i + ".pdf")
|
||||
.status(DocumentStatus.PLACEHOLDER).build());
|
||||
}
|
||||
|
||||
Page<Document> result = documentRepository.findAll(
|
||||
PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "updatedAt")));
|
||||
|
||||
assertThat(result.getContent()).hasSize(3);
|
||||
assertThat(result.getTotalElements()).isEqualTo(10);
|
||||
}
|
||||
|
||||
// ─── findByMetadataCompleteFalse (Pageable) ───────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -79,6 +79,24 @@ class NotificationRepositoryTest {
|
||||
assertThat(result.getTotalElements()).isEqualTo(5);
|
||||
}
|
||||
|
||||
// ─── findByRecipientIdAndType (without read filter) ──────────────────────
|
||||
|
||||
@Test
|
||||
void findByType_returnsBothReadAndUnreadMentions() {
|
||||
notificationRepository.save(mention(userA, false)); // unread
|
||||
notificationRepository.save(mention(userA, true)); // read — should also be included
|
||||
notificationRepository.save(reply(userA, false)); // REPLY — excluded
|
||||
notificationRepository.save(mention(userB, false)); // different user — excluded
|
||||
|
||||
Page<Notification> result = notificationRepository
|
||||
.findByRecipientIdAndTypeOrderByCreatedAtDesc(
|
||||
userA.getId(), NotificationType.MENTION, Pageable.ofSize(10));
|
||||
|
||||
assertThat(result.getContent()).hasSize(2);
|
||||
assertThat(result.getContent()).allMatch(n -> n.getType() == NotificationType.MENTION);
|
||||
assertThat(result.getContent()).allMatch(n -> n.getRecipient().getId().equals(userA.getId()));
|
||||
}
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private Notification mention(AppUser recipient, boolean read) {
|
||||
|
||||
@@ -1213,4 +1213,35 @@ class DocumentServiceTest {
|
||||
|
||||
verify(documentRepository).findAll(any(org.springframework.data.jpa.domain.Specification.class), any(Sort.class));
|
||||
}
|
||||
|
||||
// ─── getRecentActivity ────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void getRecentActivity_returnsMostRecentlyUpdatedDocuments() {
|
||||
Document doc1 = Document.builder().id(UUID.randomUUID()).title("Oldest").build();
|
||||
Document doc2 = Document.builder().id(UUID.randomUUID()).title("Middle").build();
|
||||
Document doc3 = Document.builder().id(UUID.randomUUID()).title("Newest").build();
|
||||
|
||||
Page<Document> page = new PageImpl<>(List.of(doc3, doc2));
|
||||
when(documentRepository.findAll(any(Pageable.class))).thenReturn(page);
|
||||
|
||||
List<Document> result = documentService.getRecentActivity(2);
|
||||
|
||||
assertThat(result).hasSize(2);
|
||||
assertThat(result).containsExactly(doc3, doc2);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getRecentActivity_usesPageRequestWithSizeLimit_notFindAll() {
|
||||
Page<Document> page = new PageImpl<>(List.of());
|
||||
when(documentRepository.findAll(any(Pageable.class))).thenReturn(page);
|
||||
|
||||
documentService.getRecentActivity(3);
|
||||
|
||||
ArgumentCaptor<Pageable> captor = ArgumentCaptor.forClass(Pageable.class);
|
||||
verify(documentRepository).findAll(captor.capture());
|
||||
assertThat(captor.getValue().getPageSize()).isEqualTo(3);
|
||||
assertThat(captor.getValue().getSort())
|
||||
.isEqualTo(Sort.by(Sort.Direction.DESC, "updatedAt"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,6 +386,39 @@ class NotificationServiceTest {
|
||||
verify(notificationRepository, never()).findByRecipientIdOrderByCreatedAtDesc(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getNotifications_withTypeOnly_usesTypeFilteredRepoMethod() {
|
||||
when(notificationRepository.findByRecipientIdAndTypeOrderByCreatedAtDesc(
|
||||
eq(userA.getId()), eq(NotificationType.MENTION), any()))
|
||||
.thenReturn(Page.empty());
|
||||
|
||||
notificationService.getNotifications(userA.getId(), NotificationType.MENTION, null, Pageable.ofSize(5));
|
||||
|
||||
verify(notificationRepository).findByRecipientIdAndTypeOrderByCreatedAtDesc(
|
||||
eq(userA.getId()), eq(NotificationType.MENTION), any());
|
||||
verify(notificationRepository, never()).findByRecipientIdOrderByCreatedAtDesc(any(), any());
|
||||
verify(notificationRepository, never())
|
||||
.findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getNotifications_withTypeAndReadTrue_fallsBackToTypeOnlyQuery() {
|
||||
// read=true with a type filter falls through to the type-only branch —
|
||||
// it returns all notifications of that type (both read and unread).
|
||||
// The read=true filter is intentionally not supported on the backend;
|
||||
// callers that need only-read results must filter client-side.
|
||||
when(notificationRepository.findByRecipientIdAndTypeOrderByCreatedAtDesc(
|
||||
eq(userA.getId()), eq(NotificationType.MENTION), any()))
|
||||
.thenReturn(Page.empty());
|
||||
|
||||
notificationService.getNotifications(userA.getId(), NotificationType.MENTION, true, Pageable.ofSize(5));
|
||||
|
||||
verify(notificationRepository).findByRecipientIdAndTypeOrderByCreatedAtDesc(
|
||||
eq(userA.getId()), eq(NotificationType.MENTION), any());
|
||||
verify(notificationRepository, never())
|
||||
.findByRecipientIdAndTypeAndReadFalseOrderByCreatedAtDesc(any(), any(), any());
|
||||
}
|
||||
|
||||
// ─── private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private DocumentComment commentWithAuthor(UUID id, UUID parentId, UUID authorId, String authorName) {
|
||||
|
||||
62
frontend/e2e/dashboard-screenshots.spec.ts
Normal file
62
frontend/e2e/dashboard-screenshots.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Dashboard proofshots — seeds the admin account with test data so every
|
||||
* widget is visible, then captures 6 screenshots (3 viewports × 2 themes).
|
||||
*
|
||||
* Seeded data is removed in afterAll so it doesn't pollute other tests.
|
||||
*/
|
||||
import { test } from '@playwright/test';
|
||||
import { execSync } from 'child_process';
|
||||
import { captureProofshots } from './proofshots';
|
||||
|
||||
// A real document that exists in the dev DB (most recently updated)
|
||||
const SEED_DOC_ID = '24580ce9-9765-40b1-ac59-b0ab15160ce0';
|
||||
const SEED_DOC_TITLE = 'Brief aus dem Krieg';
|
||||
|
||||
// Real comment IDs used as reference_id for deep-linking
|
||||
const COMMENT_IDS = [
|
||||
'46c5171f-1721-4085-a7ed-1eef7b4effb8',
|
||||
'a09cefe4-ddf8-47fa-addc-5c582183b459'
|
||||
];
|
||||
|
||||
const psql = (sql: string) =>
|
||||
execSync(
|
||||
`docker exec archive-db psql -U archive_user family_archive_db -c "${sql.replace(/"/g, '\\"')}"`
|
||||
);
|
||||
|
||||
test.beforeAll(() => {
|
||||
// Insert a MENTION and a REPLY notification for the admin user so the
|
||||
// notifications widget is populated in the screenshots.
|
||||
psql(`
|
||||
INSERT INTO notifications (recipient_id, type, document_id, reference_id, read, actor_name)
|
||||
SELECT id, 'MENTION', '${SEED_DOC_ID}', '${COMMENT_IDS[0]}', false, 'Berit Hoffmann'
|
||||
FROM users WHERE username = 'admin';
|
||||
|
||||
INSERT INTO notifications (recipient_id, type, document_id, reference_id, read, actor_name)
|
||||
SELECT id, 'REPLY', '${SEED_DOC_ID}', '${COMMENT_IDS[1]}', false, 'Marcel Raddatz'
|
||||
FROM users WHERE username = 'admin';
|
||||
`);
|
||||
});
|
||||
|
||||
test.afterAll(() => {
|
||||
// Remove only the seeded rows (identified by the sentinel actor names)
|
||||
psql(`
|
||||
DELETE FROM notifications
|
||||
WHERE actor_name IN ('Berit Hoffmann', 'Marcel Raddatz')
|
||||
AND recipient_id = (SELECT id FROM users WHERE username = 'admin');
|
||||
`);
|
||||
});
|
||||
|
||||
captureProofshots('/', 'dashboard', {
|
||||
setup: async (page) => {
|
||||
// Navigate to '/' first so the browser has an origin for localStorage,
|
||||
// then inject the lastVisited entry directly — no document page load needed.
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await page.evaluate(
|
||||
({ id, title }) => {
|
||||
localStorage.setItem('familienarchiv.lastVisited', JSON.stringify({ id, title }));
|
||||
},
|
||||
{ id: SEED_DOC_ID, title: SEED_DOC_TITLE }
|
||||
);
|
||||
}
|
||||
});
|
||||
85
frontend/e2e/proofshots.ts
Normal file
85
frontend/e2e/proofshots.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Shared proofshot helper for Playwright.
|
||||
*
|
||||
* Basic usage:
|
||||
* import { captureProofshots } from './proofshots';
|
||||
* captureProofshots('/persons', 'persons');
|
||||
*
|
||||
* With per-test setup (e.g. seed localStorage before navigation):
|
||||
* captureProofshots('/persons', 'persons', {
|
||||
* setup: async (page) => {
|
||||
* await page.goto('/persons/some-id'); // populates any localStorage state
|
||||
* }
|
||||
* });
|
||||
*
|
||||
* The setup callback runs before each screenshot's page.goto(url), so any
|
||||
* localStorage values it writes persist into the main navigation.
|
||||
*
|
||||
* Screenshots are saved to proofshot-artifacts/{featureName}/.
|
||||
*/
|
||||
import { type Page, test } from '@playwright/test';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const viewports = [
|
||||
{ name: 'mobile', width: 390, height: 844 },
|
||||
{ name: 'tablet', width: 768, height: 1024 },
|
||||
{ name: 'desktop', width: 1440, height: 900 }
|
||||
];
|
||||
|
||||
interface ProofshotOptions {
|
||||
/**
|
||||
* Optional async callback that runs before each screenshot's page.goto(url).
|
||||
* Use it to seed localStorage, visit a prerequisite page, etc.
|
||||
*/
|
||||
setup?: (page: Page) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers Playwright tests that navigate to `url`, apply each theme,
|
||||
* and capture full-page screenshots at all standard viewports.
|
||||
*
|
||||
* @param url The path to screenshot (e.g. '/', '/persons', '/admin')
|
||||
* @param featureName Used as the output directory name and screenshot file prefix
|
||||
* @param options Optional setup callback and other options
|
||||
*/
|
||||
export function captureProofshots(
|
||||
url: string,
|
||||
featureName: string,
|
||||
options?: ProofshotOptions
|
||||
): void {
|
||||
const outDir = path.join(__dirname, '../../proofshot-artifacts', featureName);
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
for (const vp of viewports) {
|
||||
for (const theme of ['light', 'dark'] as const) {
|
||||
test(`${featureName} – ${vp.name} – ${theme}`, async ({ page }) => {
|
||||
// Run optional setup before main navigation (e.g. seed localStorage)
|
||||
if (options?.setup) {
|
||||
await options.setup(page);
|
||||
}
|
||||
|
||||
await page.setViewportSize({ width: vp.width, height: vp.height });
|
||||
await page.goto(url);
|
||||
|
||||
// Apply theme via data-theme attribute and localStorage
|
||||
await page.evaluate((t) => {
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
localStorage.setItem('theme', t);
|
||||
}, theme);
|
||||
|
||||
// 'networkidle' is unreliable in SvelteKit dev mode due to the HMR WebSocket.
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await page.waitForSelector('main', { state: 'visible' });
|
||||
|
||||
await page.screenshot({
|
||||
path: path.join(outDir, `${featureName}-${vp.name}-${theme}.png`),
|
||||
fullPage: true
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -312,5 +312,13 @@
|
||||
"page_title_persons": "Personen",
|
||||
"page_title_admin": "Administration",
|
||||
"page_title_login": "Anmelden",
|
||||
"page_title_error": "Fehler – Familienarchiv"
|
||||
"page_title_error": "Fehler – Familienarchiv",
|
||||
"dashboard_notifications_heading": "Benachrichtigungen",
|
||||
"dashboard_notification_mentioned": "erwähnt Sie",
|
||||
"dashboard_notification_replied": "hat geantwortet",
|
||||
"dashboard_needs_metadata_heading": "Metadaten fehlen",
|
||||
"dashboard_needs_metadata_show_all": "Alle anzeigen",
|
||||
"dashboard_recent_heading": "Zuletzt aktiv",
|
||||
"dashboard_resume_label": "Zuletzt geöffnet:",
|
||||
"dashboard_resume_fallback": "Unbekanntes Dokument"
|
||||
}
|
||||
|
||||
@@ -312,5 +312,13 @@
|
||||
"page_title_persons": "Persons",
|
||||
"page_title_admin": "Administration",
|
||||
"page_title_login": "Sign in",
|
||||
"page_title_error": "Error – Family Archive"
|
||||
"page_title_error": "Error – Family Archive",
|
||||
"dashboard_notifications_heading": "Notifications",
|
||||
"dashboard_notification_mentioned": "mentioned you",
|
||||
"dashboard_notification_replied": "replied",
|
||||
"dashboard_needs_metadata_heading": "Missing Metadata",
|
||||
"dashboard_needs_metadata_show_all": "Show all",
|
||||
"dashboard_recent_heading": "Recent Activity",
|
||||
"dashboard_resume_label": "Last opened:",
|
||||
"dashboard_resume_fallback": "Unknown document"
|
||||
}
|
||||
|
||||
@@ -312,5 +312,13 @@
|
||||
"page_title_persons": "Personas",
|
||||
"page_title_admin": "Administración",
|
||||
"page_title_login": "Iniciar sesión",
|
||||
"page_title_error": "Error – Archivo familiar"
|
||||
"page_title_error": "Error – Archivo familiar",
|
||||
"dashboard_notifications_heading": "Notificaciones",
|
||||
"dashboard_notification_mentioned": "te mencionó",
|
||||
"dashboard_notification_replied": "respondió",
|
||||
"dashboard_needs_metadata_heading": "Metadatos incompletos",
|
||||
"dashboard_needs_metadata_show_all": "Ver todos",
|
||||
"dashboard_recent_heading": "Actividad reciente",
|
||||
"dashboard_resume_label": "Último abierto:",
|
||||
"dashboard_resume_fallback": "Documento desconocido"
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
type NotificationDTO = {
|
||||
id: string;
|
||||
type: 'REPLY' | 'MENTION';
|
||||
documentId?: string;
|
||||
referenceId?: string;
|
||||
annotationId?: string;
|
||||
read: boolean;
|
||||
createdAt: string;
|
||||
actorName?: string;
|
||||
@@ -18,19 +22,26 @@ let { mentions }: Props = $props();
|
||||
{#if mentions.length > 0}
|
||||
<div data-testid="dashboard-mentions" 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">
|
||||
Erwähnungen
|
||||
{m.dashboard_notifications_heading()}
|
||||
</h2>
|
||||
{#each mentions as mention (mention.id)}
|
||||
<div class="flex items-center gap-3 border-b border-line py-2 last:border-0">
|
||||
{#if mention.documentId}
|
||||
<a
|
||||
href="/documents/{mention.documentId}"
|
||||
class="font-serif text-sm text-ink hover:text-ink-2"
|
||||
href={mention.annotationId
|
||||
? `/documents/${mention.documentId}?commentId=${mention.referenceId}&annotationId=${mention.annotationId}`
|
||||
: `/documents/${mention.documentId}?commentId=${mention.referenceId}`}
|
||||
class="font-serif text-lg text-ink hover:text-ink-2"
|
||||
>
|
||||
{mention.actorName ?? ''}
|
||||
{#if mention.type === 'MENTION'}<span class="ml-1 font-sans text-xs text-gray-400"
|
||||
>{m.dashboard_notification_mentioned()}</span
|
||||
>{:else}<span class="ml-1 font-sans text-xs text-gray-400"
|
||||
>{m.dashboard_notification_replied()}</span
|
||||
>{/if}
|
||||
</a>
|
||||
{:else}
|
||||
<span class="font-serif text-sm text-ink">{mention.actorName ?? ''}</span>
|
||||
<span class="font-serif text-lg text-ink">{mention.actorName ?? ''}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -10,6 +10,8 @@ type NotificationDTO = {
|
||||
id: string;
|
||||
type: 'REPLY' | 'MENTION';
|
||||
documentId?: string;
|
||||
referenceId?: string;
|
||||
annotationId?: string;
|
||||
read: boolean;
|
||||
createdAt: string;
|
||||
actorName?: string;
|
||||
@@ -20,6 +22,7 @@ function makeMention(overrides: Partial<NotificationDTO> = {}): NotificationDTO
|
||||
id: 'notif-1',
|
||||
type: 'MENTION',
|
||||
documentId: 'doc-abc',
|
||||
referenceId: 'comment-xyz',
|
||||
read: false,
|
||||
createdAt: '2026-01-15T10:00:00Z',
|
||||
actorName: 'Anna Schmidt',
|
||||
@@ -40,19 +43,43 @@ describe('DashboardMentions', () => {
|
||||
await expect.element(widget).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders one row per mention with link to document', async () => {
|
||||
const mentions = [
|
||||
makeMention({ id: 'n1', documentId: 'doc-1', actorName: 'Anna' }),
|
||||
makeMention({ id: 'n2', documentId: 'doc-2', actorName: 'Bob' })
|
||||
];
|
||||
render(DashboardMentions, { mentions });
|
||||
const links = page.getByRole('link');
|
||||
await expect.element(links.nth(0)).toHaveAttribute('href', '/documents/doc-1');
|
||||
await expect.element(links.nth(1)).toHaveAttribute('href', '/documents/doc-2');
|
||||
it('builds link with commentId param when no annotationId', async () => {
|
||||
render(DashboardMentions, {
|
||||
mentions: [makeMention({ documentId: 'doc-1', referenceId: 'cmt-1' })]
|
||||
});
|
||||
const link = page.getByRole('link');
|
||||
await expect.element(link).toHaveAttribute('href', '/documents/doc-1?commentId=cmt-1');
|
||||
});
|
||||
|
||||
it('builds link with commentId and annotationId when annotationId is present', async () => {
|
||||
render(DashboardMentions, {
|
||||
mentions: [makeMention({ documentId: 'doc-2', referenceId: 'cmt-2', annotationId: 'ann-9' })]
|
||||
});
|
||||
const link = page.getByRole('link');
|
||||
await expect
|
||||
.element(link)
|
||||
.toHaveAttribute('href', '/documents/doc-2?commentId=cmt-2&annotationId=ann-9');
|
||||
});
|
||||
|
||||
it('shows actor name in each row', async () => {
|
||||
render(DashboardMentions, { mentions: [makeMention({ actorName: 'Maria Müller' })] });
|
||||
await expect.element(page.getByText('Maria Müller')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "replied" label for REPLY type', async () => {
|
||||
render(DashboardMentions, { mentions: [makeMention({ type: 'REPLY' })] });
|
||||
const widget = page.getByTestId('dashboard-mentions');
|
||||
await expect.element(widget).toBeInTheDocument();
|
||||
const link = page.getByRole('link');
|
||||
await expect.element(link).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a span instead of a link when documentId is absent', async () => {
|
||||
render(DashboardMentions, {
|
||||
mentions: [makeMention({ documentId: undefined, actorName: 'Lena Bauer' })]
|
||||
});
|
||||
await expect.element(page.getByText('Lena Bauer')).toBeInTheDocument();
|
||||
const links = page.getByRole('link');
|
||||
await expect.element(links).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
type IncompleteDocumentDTO = {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -14,20 +16,22 @@ let { incompleteDocs }: Props = $props();
|
||||
{#if incompleteDocs.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">
|
||||
Metadaten fehlen
|
||||
{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">
|
||||
<a
|
||||
href="/enrich/{doc.id}"
|
||||
class="font-serif text-sm text-ink hover:text-ink-2 hover:underline"
|
||||
class="font-serif text-lg text-ink hover:text-ink-2 hover:underline"
|
||||
>
|
||||
{doc.title}
|
||||
</a>
|
||||
</div>
|
||||
{/each}
|
||||
<div class="mt-4">
|
||||
<a href="/enrich" class="font-sans text-xs text-ink-2 hover:text-ink"> Alle anzeigen </a>
|
||||
<a href="/enrich" class="font-sans text-sm text-ink-2 hover:text-ink hover:underline"
|
||||
>{m.dashboard_needs_metadata_show_all()}</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { getLocale } from '$lib/paraglide/runtime.js';
|
||||
|
||||
type Document = {
|
||||
id: string;
|
||||
title: string;
|
||||
documentDate?: string;
|
||||
updatedAt?: string;
|
||||
sender?: { id: string; firstName: string; lastName: string };
|
||||
};
|
||||
|
||||
@@ -13,33 +16,34 @@ interface Props {
|
||||
let { recentDocs }: Props = $props();
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
// updatedAt is a full ISO datetime — no T12:00:00 noon-anchor needed here
|
||||
return new Intl.DateTimeFormat(getLocale(), {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
}).format(new Date(dateStr + 'T12:00:00'));
|
||||
}).format(new Date(dateStr));
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if recentDocs.length > 0}
|
||||
<div data-testid="dashboard-recent-docs" 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">
|
||||
Zuletzt hinzugefügt
|
||||
{m.dashboard_recent_heading()}
|
||||
</h2>
|
||||
{#each recentDocs as doc (doc.id)}
|
||||
<div class="flex items-center justify-between border-b border-line py-2 last:border-0">
|
||||
<a
|
||||
href="/documents/{doc.id}"
|
||||
class="font-serif text-sm text-ink hover:text-ink-2 hover:underline"
|
||||
class="font-serif text-lg text-ink hover:text-ink-2 hover:underline"
|
||||
>
|
||||
{doc.title}
|
||||
</a>
|
||||
{#if doc.documentDate}
|
||||
{#if doc.updatedAt}
|
||||
<span
|
||||
data-testid="doc-date-{doc.id}"
|
||||
class="ml-2 shrink-0 font-sans text-xs text-gray-400"
|
||||
>
|
||||
{formatDate(doc.documentDate)}
|
||||
{formatDate(doc.updatedAt)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -9,12 +9,12 @@ afterEach(cleanup);
|
||||
type Document = {
|
||||
id: string;
|
||||
title: string;
|
||||
documentDate?: string;
|
||||
updatedAt?: string;
|
||||
sender?: { id: string; firstName: string; lastName: string };
|
||||
};
|
||||
|
||||
function makeDoc(id: string, title: string, date?: string): Document {
|
||||
return { id, title, documentDate: date };
|
||||
function makeDoc(id: string, title: string, updatedAt?: string): Document {
|
||||
return { id, title, updatedAt };
|
||||
}
|
||||
|
||||
describe('DashboardRecentDocuments', () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
interface LastVisited {
|
||||
id: string;
|
||||
@@ -28,9 +29,9 @@ onMount(() => {
|
||||
data-testid="resume-strip"
|
||||
class="flex items-center gap-2 rounded-sm border border-line bg-surface px-4 py-3 font-sans text-sm"
|
||||
>
|
||||
<span class="text-ink-2">Zuletzt geöffnet:</span>
|
||||
<span class="text-ink-2">{m.dashboard_resume_label()}</span>
|
||||
<a href="/documents/{lastVisited.id}" class="font-medium text-ink hover:underline">
|
||||
{lastVisited.title || 'Zuletzt geöffnet'}
|
||||
{lastVisited.title || m.dashboard_resume_fallback()}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -40,4 +40,11 @@ describe('DashboardResumeStrip', () => {
|
||||
const link = page.getByRole('link');
|
||||
await expect.element(link).toHaveAttribute('href', '/documents/doc-456');
|
||||
});
|
||||
|
||||
it('renders nothing when localStorage contains malformed JSON', async () => {
|
||||
localStorage.setItem('familienarchiv.lastVisited', '{not valid json');
|
||||
render(DashboardResumeStrip, {});
|
||||
const strip = page.getByTestId('resume-strip');
|
||||
await expect.element(strip).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -136,9 +136,7 @@ let { doc }: { doc: Doc } = $props();
|
||||
{doc.sender.firstName[0]}{doc.sender.lastName[0]}
|
||||
</div>
|
||||
<div>
|
||||
<p
|
||||
class="font-serif text-ink decoration-brand-mint underline-offset-2 group-hover:underline"
|
||||
>
|
||||
<p class="font-serif text-ink group-hover:underline">
|
||||
{doc.sender.firstName}
|
||||
{doc.sender.lastName}
|
||||
</p>
|
||||
|
||||
@@ -628,6 +628,22 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/recent-activity": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get: operations["getRecentActivity"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/documents/incomplete": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -2328,6 +2344,28 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
getRecentActivity: {
|
||||
parameters: {
|
||||
query?: {
|
||||
size?: number;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"*/*": components["schemas"]["Document"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getIncomplete: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
||||
@@ -58,13 +58,9 @@ export async function load({ url, fetch }) {
|
||||
|
||||
if (isDashboard) {
|
||||
const [mentionsResult, incompleteResult, recentResult] = await Promise.allSettled([
|
||||
api.GET('/api/notifications', {
|
||||
params: { query: { type: 'MENTION', read: false, size: 5 } }
|
||||
}),
|
||||
api.GET('/api/notifications', { params: { query: { size: 5 } } }),
|
||||
api.GET('/api/documents/incomplete', { params: { query: { size: 5 } } }),
|
||||
api.GET('/api/documents/search', {
|
||||
params: { query: { status: 'REVIEWED' } }
|
||||
})
|
||||
api.GET('/api/documents/recent-activity', { params: { query: { size: 5 } } })
|
||||
]);
|
||||
|
||||
if (mentionsResult.status === 'fulfilled' && mentionsResult.value.response.ok) {
|
||||
@@ -74,7 +70,7 @@ export async function load({ url, fetch }) {
|
||||
incompleteDocs = incompleteResult.value.data ?? [];
|
||||
}
|
||||
if (recentResult.status === 'fulfilled' && recentResult.value.response.ok) {
|
||||
recentDocs = (recentResult.value.data ?? []).slice(0, 5);
|
||||
recentDocs = recentResult.value.data ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ $effect(() => {
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-6 grid gap-4 lg:grid-cols-2">
|
||||
<div class="mt-6 grid gap-4 {(data.mentions?.length ?? 0) > 0 ? 'lg:grid-cols-2' : ''}">
|
||||
<DashboardMentions mentions={data.mentions ?? []} />
|
||||
<DashboardNeedsMetadata incompleteDocs={data.incompleteDocs ?? []} />
|
||||
</div>
|
||||
|
||||
@@ -56,9 +56,7 @@ let {
|
||||
<!-- Main Info -->
|
||||
<div class="flex-1">
|
||||
<div class="mb-2 flex items-baseline justify-between">
|
||||
<h3
|
||||
class="font-serif text-xl font-medium text-ink decoration-brand-mint decoration-2 underline-offset-4 group-hover:underline"
|
||||
>
|
||||
<h3 class="font-serif text-xl font-medium text-ink group-hover:underline">
|
||||
{doc.title || doc.originalFilename}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -74,9 +74,7 @@ const count = $derived(documents.length);
|
||||
<li class="group transition-colors duration-200 hover:bg-muted">
|
||||
<a href="/enrich/{doc.id}" class="flex items-center justify-between p-6">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p
|
||||
class="font-serif text-lg font-medium text-ink decoration-accent decoration-2 underline-offset-4 group-hover:underline"
|
||||
>
|
||||
<p class="font-serif text-lg font-medium text-ink group-hover:underline">
|
||||
{doc.title}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,7 @@ import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
<a
|
||||
href="/enrich"
|
||||
class="font-sans text-xs text-ink-2 underline-offset-4 transition-colors hover:text-ink hover:underline"
|
||||
class="font-sans text-xs text-ink-2 transition-colors hover:text-ink hover:underline"
|
||||
>
|
||||
{m.enrich_back_to_list()}
|
||||
</a>
|
||||
|
||||
@@ -213,4 +213,10 @@
|
||||
background-color: var(--c-surface);
|
||||
color: var(--c-ink);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration-color: var(--c-accent);
|
||||
text-decoration-thickness: 2px;
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,40 +19,115 @@ function makeUrl(params: Record<string, string | string[]> = {}) {
|
||||
return url;
|
||||
}
|
||||
|
||||
// ─── happy path ───────────────────────────────────────────────────────────────
|
||||
// ─── dashboard mode (no search filters) ──────────────────────────────────────
|
||||
|
||||
describe('home page load — happy path', () => {
|
||||
it('returns documents and persons on success', async () => {
|
||||
vi.mocked(createApiClient).mockReturnValue({
|
||||
GET: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
response: { ok: true, status: 200 },
|
||||
data: [{ id: 'd1', title: 'Brief' }]
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
response: { ok: true, status: 200 },
|
||||
data: [{ id: 'p1', firstName: 'Hans', lastName: 'Müller' }]
|
||||
})
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: { count: 3 } })
|
||||
} as ReturnType<typeof createApiClient>);
|
||||
describe('home page load — dashboard mode', () => {
|
||||
it('sets isDashboard true and fetches all three widget APIs', async () => {
|
||||
const mockGet = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons
|
||||
.mockResolvedValueOnce({
|
||||
response: { ok: true },
|
||||
data: { content: [{ id: 'n1' }] }
|
||||
}) // notifications
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [{ id: 'd1' }] }) // incomplete
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [{ id: 'd2' }] }); // recent
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
|
||||
|
||||
expect(result.documents).toHaveLength(1);
|
||||
expect(result.incompleteCount).toBe(3);
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.isDashboard).toBe(true);
|
||||
expect(result.mentions).toHaveLength(1);
|
||||
expect(result.incompleteDocs).toHaveLength(1);
|
||||
expect(result.recentDocs).toHaveLength(1);
|
||||
expect(result.documents).toEqual([]);
|
||||
});
|
||||
|
||||
it('passes search params from the URL to the API', async () => {
|
||||
it('defaults mentions to [] when notifications API rejects', async () => {
|
||||
const mockGet = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons
|
||||
.mockRejectedValueOnce(new Error('network')) // notifications
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] }); // recent
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
|
||||
|
||||
expect(result.mentions).toEqual([]);
|
||||
});
|
||||
|
||||
it('defaults incompleteDocs to [] when incomplete API rejects', async () => {
|
||||
const mockGet = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: { content: [] } }) // notifications
|
||||
.mockRejectedValueOnce(new Error('network')) // incomplete
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] }); // recent
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
|
||||
|
||||
expect(result.incompleteDocs).toEqual([]);
|
||||
});
|
||||
|
||||
it('defaults recentDocs to [] when recent-activity API rejects', async () => {
|
||||
const mockGet = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: { content: [] } }) // notifications
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete
|
||||
.mockRejectedValueOnce(new Error('network')); // recent
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
|
||||
|
||||
expect(result.recentDocs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── search mode (any filter active) ─────────────────────────────────────────
|
||||
|
||||
describe('home page load — search mode', () => {
|
||||
it('sets isDashboard false and skips widget APIs when q is set', async () => {
|
||||
const mockGet = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [{ id: 'd1' }] }) // search docs
|
||||
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }); // persons
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
const result = await load({
|
||||
url: makeUrl({ q: 'Urlaub' }),
|
||||
fetch: vi.fn() as unknown as typeof fetch
|
||||
});
|
||||
|
||||
expect(result.isDashboard).toBe(false);
|
||||
expect(result.documents).toHaveLength(1);
|
||||
expect(result.mentions).toEqual([]);
|
||||
expect(result.incompleteDocs).toEqual([]);
|
||||
expect(result.recentDocs).toEqual([]);
|
||||
// Only two API calls — no widget calls
|
||||
expect(mockGet).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('passes search params from the URL to the documents API', async () => {
|
||||
const mockGet = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] })
|
||||
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] })
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: { count: 0 } });
|
||||
vi.mocked(createApiClient).mockReturnValue({
|
||||
GET: mockGet
|
||||
} as ReturnType<typeof createApiClient>);
|
||||
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] });
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
await load({
|
||||
url: makeUrl({ q: 'Urlaub', from: '2020-01-01' }),
|
||||
@@ -63,46 +138,14 @@ describe('home page load — happy path', () => {
|
||||
expect(firstCall[1].params.query.q).toBe('Urlaub');
|
||||
expect(firstCall[1].params.query.from).toBe('2020-01-01');
|
||||
});
|
||||
|
||||
it('returns incompleteCount 0 when the incomplete-count API fails', async () => {
|
||||
vi.mocked(createApiClient).mockReturnValue({
|
||||
GET: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] })
|
||||
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] })
|
||||
.mockResolvedValueOnce({ response: { ok: false }, data: null })
|
||||
} as ReturnType<typeof createApiClient>);
|
||||
|
||||
const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
|
||||
|
||||
expect(result.incompleteCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 401 redirect ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('home page load — auth redirect', () => {
|
||||
it('redirects to /login when documents API returns 401', async () => {
|
||||
vi.mocked(createApiClient).mockReturnValue({
|
||||
GET: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ response: { ok: false, status: 401 }, data: null })
|
||||
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] })
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: { count: 0 } })
|
||||
} as ReturnType<typeof createApiClient>);
|
||||
|
||||
await expect(
|
||||
load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch })
|
||||
).rejects.toMatchObject({ location: '/login' });
|
||||
});
|
||||
|
||||
it('redirects to /login when persons API returns 401', async () => {
|
||||
vi.mocked(createApiClient).mockReturnValue({
|
||||
GET: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] })
|
||||
.mockResolvedValueOnce({ response: { ok: false, status: 401 }, data: null })
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: { count: 0 } })
|
||||
GET: vi.fn().mockResolvedValueOnce({ response: { ok: false, status: 401 }, data: null })
|
||||
} as ReturnType<typeof createApiClient>);
|
||||
|
||||
await expect(
|
||||
@@ -123,6 +166,5 @@ describe('home page load — network error fallback', () => {
|
||||
|
||||
expect(result.error).toBe('Daten konnten nicht geladen werden.');
|
||||
expect(result.documents).toEqual([]);
|
||||
expect(result.incompleteCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -119,9 +119,7 @@ function handleSearch() {
|
||||
|
||||
<!-- Info -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<p
|
||||
class="truncate font-serif text-base font-medium text-ink decoration-brand-mint decoration-2 underline-offset-2 group-hover:underline"
|
||||
>
|
||||
<p class="truncate font-serif text-base font-medium text-ink group-hover:underline">
|
||||
{person.firstName}
|
||||
{person.lastName}
|
||||
</p>
|
||||
|
||||
@@ -86,7 +86,7 @@ const visibleDocuments = $derived(
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div
|
||||
class="truncate font-serif text-base font-medium text-ink decoration-brand-mint decoration-2 underline-offset-2 group-hover:underline"
|
||||
class="truncate font-serif text-base font-medium text-ink group-hover:underline"
|
||||
>
|
||||
{doc.title || doc.originalFilename}
|
||||
</div>
|
||||
|
||||
BIN
proofshot-artifacts/dashboard/dashboard-desktop-dark.png
Normal file
BIN
proofshot-artifacts/dashboard/dashboard-desktop-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
BIN
proofshot-artifacts/dashboard/dashboard-desktop-light.png
Normal file
BIN
proofshot-artifacts/dashboard/dashboard-desktop-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
BIN
proofshot-artifacts/dashboard/dashboard-mobile-dark.png
Normal file
BIN
proofshot-artifacts/dashboard/dashboard-mobile-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
BIN
proofshot-artifacts/dashboard/dashboard-mobile-light.png
Normal file
BIN
proofshot-artifacts/dashboard/dashboard-mobile-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
BIN
proofshot-artifacts/dashboard/dashboard-tablet-dark.png
Normal file
BIN
proofshot-artifacts/dashboard/dashboard-tablet-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
BIN
proofshot-artifacts/dashboard/dashboard-tablet-light.png
Normal file
BIN
proofshot-artifacts/dashboard/dashboard-tablet-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
Reference in New Issue
Block a user