Compare commits

..

7 Commits

Author SHA1 Message Date
Marcel
19035fbeab fix(dashboard): move right column first in DOM for mobile-first upload zone
Some checks failed
CI / Backend Unit Tests (pull_request) Failing after 2m37s
CI / E2E Tests (pull_request) Failing after 1h12m25s
CI / Unit & Component Tests (push) Failing after 1m21s
CI / Backend Unit Tests (push) Failing after 2m30s
CI / E2E Tests (push) Failing after 6m59s
CI / Unit & Component Tests (pull_request) Failing after 1m41s
On small screens the upload zone now appears above recent docs.
lg:order-last keeps it visually on the right at desktop width.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 20:42:37 +02:00
Marcel
79faee554a fix(dashboard): reduce incomplete docs widget from 5 to 3 items to prevent scroll
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 20:40:02 +02:00
Marcel
5adef7bec5 refactor(dashboard): delete DashboardMentions component — notifications page exists
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 20:29:03 +02:00
Marcel
595c2eb987 test(e2e): Classic Split — right column absent for read-only user, present for admin
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 20:27:39 +02:00
Marcel
518019f099 chore(e2e): gitignore Playwright auth state — regenerate in CI via auth.setup.ts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 20:26:01 +02:00
Marcel
38b8804b17 style(dashboard): bump stats footnote from text-xs to text-sm for legibility
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 20:24:47 +02:00
Marcel
81ed1ce3ed test(admin): replace setTimeout timing hack with vi.waitFor in layout specs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 20:23:05 +02:00
10 changed files with 48 additions and 154 deletions

3
frontend/.gitignore vendored
View File

@@ -31,3 +31,6 @@ src/lib/paraglide
# src/lib/generated/api.ts
src/lib/paraglide_bak*
/coverage
# Playwright auth state — regenerated at the start of each CI run via auth.setup.ts
e2e/.auth/

View 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();
});
});

View File

@@ -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}

View File

@@ -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();
});
});

View File

@@ -56,7 +56,7 @@ function formatDate(dateStr: string): string {
</div>
{/each}
{#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}
{m.dashboard_stats_documents()} · {stats.totalPersons}
{m.dashboard_stats_persons()}

View File

@@ -62,7 +62,7 @@ export async function load({ url, fetch }) {
if (isDashboard) {
const [statsResult, incompleteResult, recentResult] = await Promise.allSettled([
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 } } })
]);

View File

@@ -98,13 +98,12 @@ const showRightColumn = $derived(data.canWrite || (data.incompleteDocs?.length ?
{#if data.isDashboard}
<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 -->
<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}
<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}
<DropZone />
{/if}
@@ -116,6 +115,8 @@ const showRightColumn = $derived(data.canWrite || (data.incompleteDocs?.length ?
</div>
</div>
{/if}
<DashboardRecentDocuments recentDocs={data.recentDocs ?? []} stats={data.stats} />
</div>
{:else}
<DocumentList documents={data.documents ?? []} canWrite={data.canWrite} error={data.error} />

View File

@@ -112,8 +112,9 @@ describe('GroupsListPanel — collapse toggle', () => {
render(GroupsListPanel, { groups });
const setSpy = vi.spyOn(Storage.prototype, 'setItem');
document.querySelector<HTMLButtonElement>('[aria-label="Liste einklappen"]')!.click();
await new Promise((r) => setTimeout(r, 0));
expect(setSpy).toHaveBeenCalledWith('admin_groups_list_collapsed', 'true');
await vi.waitFor(() =>
expect(setSpy).toHaveBeenCalledWith('admin_groups_list_collapsed', 'true')
);
setSpy.mockRestore();
});
});

View File

@@ -91,8 +91,9 @@ describe('TagsListPanel — collapse toggle', () => {
render(TagsListPanel, { tags });
const setSpy = vi.spyOn(Storage.prototype, 'setItem');
document.querySelector<HTMLButtonElement>('[aria-label="Liste einklappen"]')!.click();
await new Promise((r) => setTimeout(r, 0));
expect(setSpy).toHaveBeenCalledWith('admin_tags_list_collapsed', 'true');
await vi.waitFor(() =>
expect(setSpy).toHaveBeenCalledWith('admin_tags_list_collapsed', 'true')
);
setSpy.mockRestore();
});
});

View File

@@ -134,8 +134,9 @@ describe('UsersListPanel — collapse toggle', () => {
render(UsersListPanel, { users });
const setSpy = vi.spyOn(Storage.prototype, 'setItem');
document.querySelector<HTMLButtonElement>('[aria-label="Liste einklappen"]')!.click();
await new Promise((r) => setTimeout(r, 0));
expect(setSpy).toHaveBeenCalledWith('admin_users_list_collapsed', 'true');
await vi.waitFor(() =>
expect(setSpy).toHaveBeenCalledWith('admin_users_list_collapsed', 'true')
);
setSpy.mockRestore();
});
});