Compare commits
4 Commits
1a849362a1
...
a7b0bd96d4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7b0bd96d4 | ||
|
|
7734ce7bae | ||
|
|
c8da2224f8 | ||
|
|
08f3f92167 |
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -386,6 +386,21 @@ 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());
|
||||
}
|
||||
|
||||
// ─── private helpers ──────────────────────────────────────────────────────
|
||||
|
||||
private DocumentComment commentWithAuthor(UUID id, UUID parentId, UUID authorId, String authorName) {
|
||||
|
||||
45
frontend/e2e/dashboard-screenshots.spec.ts
Normal file
45
frontend/e2e/dashboard-screenshots.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Captures dashboard screenshots at multiple viewport sizes and in both
|
||||
* light and dark theme. Output goes to proofshot-artifacts/dashboard/.
|
||||
* Run via: npx playwright test e2e/dashboard-screenshots.spec.ts
|
||||
*/
|
||||
import { 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 outDir = path.join(__dirname, '../../proofshot-artifacts/dashboard');
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const viewports = [
|
||||
{ name: 'mobile', width: 390, height: 844 },
|
||||
{ name: 'tablet', width: 768, height: 1024 },
|
||||
{ name: 'desktop', width: 1440, height: 900 }
|
||||
];
|
||||
|
||||
for (const vp of viewports) {
|
||||
for (const theme of ['light', 'dark'] as const) {
|
||||
test(`dashboard – ${vp.name} – ${theme}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: vp.width, height: vp.height });
|
||||
await page.goto('/');
|
||||
|
||||
// Apply theme
|
||||
await page.evaluate((t) => {
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
localStorage.setItem('theme', t);
|
||||
}, theme);
|
||||
|
||||
// Wait for the main content to be rendered.
|
||||
// 'networkidle' is unreliable in SvelteKit dev mode due to the HMR WebSocket.
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
// Wait for at least one dashboard widget or the search bar to be visible
|
||||
await page.waitForSelector('main', { state: 'visible' });
|
||||
|
||||
await page.screenshot({
|
||||
path: path.join(outDir, `dashboard-${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 hinzugefügt",
|
||||
"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": "Recently Added",
|
||||
"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": "Añadidos recientemente",
|
||||
"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,16 +22,23 @@ 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}"
|
||||
href={mention.annotationId
|
||||
? `/documents/${mention.documentId}?commentId=${mention.referenceId}&annotationId=${mention.annotationId}`
|
||||
: `/documents/${mention.documentId}?commentId=${mention.referenceId}`}
|
||||
class="font-serif text-sm 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>
|
||||
|
||||
@@ -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,15 +43,22 @@ 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 () => {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
type IncompleteDocumentDTO = {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -14,7 +16,7 @@ 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">
|
||||
@@ -27,7 +29,9 @@ let { incompleteDocs }: Props = $props();
|
||||
</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-xs text-ink-2 hover:text-ink"
|
||||
>{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;
|
||||
createdAt?: string;
|
||||
sender?: { id: string; firstName: string; lastName: string };
|
||||
};
|
||||
|
||||
@@ -13,18 +16,18 @@ interface Props {
|
||||
let { recentDocs }: Props = $props();
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
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">
|
||||
@@ -34,12 +37,12 @@ function formatDate(dateStr: string): string {
|
||||
>
|
||||
{doc.title}
|
||||
</a>
|
||||
{#if doc.documentDate}
|
||||
{#if doc.createdAt}
|
||||
<span
|
||||
data-testid="doc-date-{doc.id}"
|
||||
class="ml-2 shrink-0 font-sans text-xs text-gray-400"
|
||||
>
|
||||
{formatDate(doc.documentDate)}
|
||||
{formatDate(doc.createdAt)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -9,12 +9,12 @@ afterEach(cleanup);
|
||||
type Document = {
|
||||
id: string;
|
||||
title: string;
|
||||
documentDate?: string;
|
||||
createdAt?: 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, createdAt?: string): Document {
|
||||
return { id, title, createdAt };
|
||||
}
|
||||
|
||||
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}
|
||||
|
||||
@@ -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/search', { params: { query: {} } })
|
||||
]);
|
||||
|
||||
if (mentionsResult.status === 'fulfilled' && mentionsResult.value.response.ok) {
|
||||
|
||||
Reference in New Issue
Block a user