Compare commits
7 Commits
92e7aa127c
...
19035fbeab
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19035fbeab | ||
|
|
79faee554a | ||
|
|
5adef7bec5 | ||
|
|
595c2eb987 | ||
|
|
518019f099 | ||
|
|
38b8804b17 | ||
|
|
81ed1ce3ed |
3
frontend/.gitignore
vendored
3
frontend/.gitignore
vendored
@@ -31,3 +31,6 @@ src/lib/paraglide
|
|||||||
# src/lib/generated/api.ts
|
# src/lib/generated/api.ts
|
||||||
src/lib/paraglide_bak*
|
src/lib/paraglide_bak*
|
||||||
/coverage
|
/coverage
|
||||||
|
|
||||||
|
# Playwright auth state — regenerated at the start of each CI run via auth.setup.ts
|
||||||
|
e2e/.auth/
|
||||||
|
|||||||
29
frontend/e2e/dashboard-classic-split.spec.ts
Normal file
29
frontend/e2e/dashboard-classic-split.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { login } from './helpers/auth';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classic Split layout — verifies the right column visibility guard.
|
||||||
|
*
|
||||||
|
* The right column (DropZone + NeedsMetadata queue) is only rendered when
|
||||||
|
* `canWrite === true` or there are incomplete docs. A read-only user with a
|
||||||
|
* complete archive must never see an empty 300px ghost column.
|
||||||
|
*/
|
||||||
|
|
||||||
|
test.describe('Dashboard Classic Split — write user', () => {
|
||||||
|
test('right column is visible for admin user', async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page.getByTestId('dashboard-right-column')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Dashboard Classic Split — read-only user', () => {
|
||||||
|
test.use({ storageState: { cookies: [], origins: [] } });
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await login(page, 'reader', 'reader123');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('right column is absent for read-only user with no incomplete docs', async ({ page }) => {
|
||||||
|
await expect(page.getByTestId('dashboard-right-column')).not.toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
<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;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
mentions: NotificationDTO[];
|
|
||||||
}
|
|
||||||
|
|
||||||
let { mentions }: Props = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#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">
|
|
||||||
{m.dashboard_notifications_heading()}
|
|
||||||
</h2>
|
|
||||||
<div>
|
|
||||||
{#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={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 hover:underline"
|
|
||||||
>{mention.actorName ?? ''}</a
|
|
||||||
>
|
|
||||||
<span class="font-sans text-xs text-gray-400">
|
|
||||||
{mention.type === 'MENTION'
|
|
||||||
? m.dashboard_notification_mentioned()
|
|
||||||
: m.dashboard_notification_replied()}
|
|
||||||
</span>
|
|
||||||
{:else}
|
|
||||||
<span class="font-serif text-lg text-ink">{mention.actorName ?? ''}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<div class="mt-4 border-t border-line pt-4">
|
|
||||||
<a
|
|
||||||
href="/notifications"
|
|
||||||
class="text-sm font-medium text-ink-2 transition-colors hover:text-ink"
|
|
||||||
>{m.notification_history_view_link()}</a
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import { describe, it, expect, afterEach } from 'vitest';
|
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
|
||||||
import { page } from 'vitest/browser';
|
|
||||||
|
|
||||||
import DashboardMentions from './DashboardMentions.svelte';
|
|
||||||
|
|
||||||
afterEach(cleanup);
|
|
||||||
|
|
||||||
type NotificationDTO = {
|
|
||||||
id: string;
|
|
||||||
type: 'REPLY' | 'MENTION';
|
|
||||||
documentId?: string;
|
|
||||||
referenceId?: string;
|
|
||||||
annotationId?: string;
|
|
||||||
read: boolean;
|
|
||||||
createdAt: string;
|
|
||||||
actorName?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function makeMention(overrides: Partial<NotificationDTO> = {}): NotificationDTO {
|
|
||||||
return {
|
|
||||||
id: 'notif-1',
|
|
||||||
type: 'MENTION',
|
|
||||||
documentId: 'doc-abc',
|
|
||||||
referenceId: 'comment-xyz',
|
|
||||||
read: false,
|
|
||||||
createdAt: '2026-01-15T10:00:00Z',
|
|
||||||
actorName: 'Anna Schmidt',
|
|
||||||
...overrides
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('DashboardMentions', () => {
|
|
||||||
it('renders nothing when mentions list is empty', async () => {
|
|
||||||
render(DashboardMentions, { mentions: [] });
|
|
||||||
const widget = page.getByTestId('dashboard-mentions');
|
|
||||||
await expect.element(widget).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows a heading when mentions are present', async () => {
|
|
||||||
render(DashboardMentions, { mentions: [makeMention()] });
|
|
||||||
const widget = page.getByTestId('dashboard-mentions');
|
|
||||||
await expect.element(widget).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
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', { name: 'Anna Schmidt' });
|
|
||||||
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', { name: 'Anna Schmidt' });
|
|
||||||
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', { name: 'Anna Schmidt' });
|
|
||||||
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 actorLink = page.getByRole('link', { name: 'Lena Bauer' });
|
|
||||||
await expect.element(actorLink).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -56,7 +56,7 @@ function formatDate(dateStr: string): string {
|
|||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{#if stats?.totalDocuments != null}
|
{#if stats?.totalDocuments != null}
|
||||||
<p data-testid="dashboard-stats-footnote" class="mt-4 font-sans text-xs text-ink-3">
|
<p data-testid="dashboard-stats-footnote" class="mt-4 font-sans text-sm text-ink-3">
|
||||||
{stats.totalDocuments}
|
{stats.totalDocuments}
|
||||||
{m.dashboard_stats_documents()} · {stats.totalPersons}
|
{m.dashboard_stats_documents()} · {stats.totalPersons}
|
||||||
{m.dashboard_stats_persons()}
|
{m.dashboard_stats_persons()}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export async function load({ url, fetch }) {
|
|||||||
if (isDashboard) {
|
if (isDashboard) {
|
||||||
const [statsResult, incompleteResult, recentResult] = await Promise.allSettled([
|
const [statsResult, incompleteResult, recentResult] = await Promise.allSettled([
|
||||||
api.GET('/api/stats'),
|
api.GET('/api/stats'),
|
||||||
api.GET('/api/documents/incomplete', { params: { query: { size: 5 } } }),
|
api.GET('/api/documents/incomplete', { params: { query: { size: 3 } } }),
|
||||||
api.GET('/api/documents/recent-activity', { params: { query: { size: 5 } } })
|
api.GET('/api/documents/recent-activity', { params: { query: { size: 5 } } })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -98,13 +98,12 @@ const showRightColumn = $derived(data.canWrite || (data.incompleteDocs?.length ?
|
|||||||
{#if data.isDashboard}
|
{#if data.isDashboard}
|
||||||
<DashboardResumeStrip />
|
<DashboardResumeStrip />
|
||||||
|
|
||||||
<!-- Classic Split: left column (recent docs) + right column (upload + metadata queue) -->
|
<!-- Classic Split: right column first in DOM so it appears above recent docs on mobile.
|
||||||
|
lg:order-last moves it back to the visual right on desktop. -->
|
||||||
<!-- No items-start — CSS Grid stretch default makes both columns equal height -->
|
<!-- No items-start — CSS Grid stretch default makes both columns equal height -->
|
||||||
<div class="mt-4 grid grid-cols-1 gap-4 {showRightColumn ? 'lg:grid-cols-[1fr_300px]' : ''}">
|
<div class="mt-4 grid grid-cols-1 gap-4 {showRightColumn ? 'lg:grid-cols-[1fr_300px]' : ''}">
|
||||||
<DashboardRecentDocuments recentDocs={data.recentDocs ?? []} stats={data.stats} />
|
|
||||||
|
|
||||||
{#if showRightColumn}
|
{#if showRightColumn}
|
||||||
<div data-testid="dashboard-right-column" class="flex h-full flex-col gap-4">
|
<div data-testid="dashboard-right-column" class="flex h-full flex-col gap-4 lg:order-last">
|
||||||
{#if data.canWrite}
|
{#if data.canWrite}
|
||||||
<DropZone />
|
<DropZone />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -116,6 +115,8 @@ const showRightColumn = $derived(data.canWrite || (data.incompleteDocs?.length ?
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<DashboardRecentDocuments recentDocs={data.recentDocs ?? []} stats={data.stats} />
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<DocumentList documents={data.documents ?? []} canWrite={data.canWrite} error={data.error} />
|
<DocumentList documents={data.documents ?? []} canWrite={data.canWrite} error={data.error} />
|
||||||
|
|||||||
@@ -112,8 +112,9 @@ describe('GroupsListPanel — collapse toggle', () => {
|
|||||||
render(GroupsListPanel, { groups });
|
render(GroupsListPanel, { groups });
|
||||||
const setSpy = vi.spyOn(Storage.prototype, 'setItem');
|
const setSpy = vi.spyOn(Storage.prototype, 'setItem');
|
||||||
document.querySelector<HTMLButtonElement>('[aria-label="Liste einklappen"]')!.click();
|
document.querySelector<HTMLButtonElement>('[aria-label="Liste einklappen"]')!.click();
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
await vi.waitFor(() =>
|
||||||
expect(setSpy).toHaveBeenCalledWith('admin_groups_list_collapsed', 'true');
|
expect(setSpy).toHaveBeenCalledWith('admin_groups_list_collapsed', 'true')
|
||||||
|
);
|
||||||
setSpy.mockRestore();
|
setSpy.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -91,8 +91,9 @@ describe('TagsListPanel — collapse toggle', () => {
|
|||||||
render(TagsListPanel, { tags });
|
render(TagsListPanel, { tags });
|
||||||
const setSpy = vi.spyOn(Storage.prototype, 'setItem');
|
const setSpy = vi.spyOn(Storage.prototype, 'setItem');
|
||||||
document.querySelector<HTMLButtonElement>('[aria-label="Liste einklappen"]')!.click();
|
document.querySelector<HTMLButtonElement>('[aria-label="Liste einklappen"]')!.click();
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
await vi.waitFor(() =>
|
||||||
expect(setSpy).toHaveBeenCalledWith('admin_tags_list_collapsed', 'true');
|
expect(setSpy).toHaveBeenCalledWith('admin_tags_list_collapsed', 'true')
|
||||||
|
);
|
||||||
setSpy.mockRestore();
|
setSpy.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -134,8 +134,9 @@ describe('UsersListPanel — collapse toggle', () => {
|
|||||||
render(UsersListPanel, { users });
|
render(UsersListPanel, { users });
|
||||||
const setSpy = vi.spyOn(Storage.prototype, 'setItem');
|
const setSpy = vi.spyOn(Storage.prototype, 'setItem');
|
||||||
document.querySelector<HTMLButtonElement>('[aria-label="Liste einklappen"]')!.click();
|
document.querySelector<HTMLButtonElement>('[aria-label="Liste einklappen"]')!.click();
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
await vi.waitFor(() =>
|
||||||
expect(setSpy).toHaveBeenCalledWith('admin_users_list_collapsed', 'true');
|
expect(setSpy).toHaveBeenCalledWith('admin_users_list_collapsed', 'true')
|
||||||
|
);
|
||||||
setSpy.mockRestore();
|
setSpy.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user