Compare commits
11 Commits
fix/issue-
...
ab7fe81b2a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab7fe81b2a | ||
|
|
1333e690dd | ||
|
|
42fda7675a | ||
|
|
fb5f47f593 | ||
|
|
d4abe994a3 | ||
|
|
cd1c0b210e | ||
|
|
a239c16c31 | ||
|
|
8a8205ad8d | ||
|
|
0430383e1c | ||
|
|
e2d74ff880 | ||
|
|
586eea009b |
@@ -39,6 +39,12 @@ jobs:
|
|||||||
- name: Run unit and component tests
|
- name: Run unit and component tests
|
||||||
run: npm test
|
run: npm test
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
|
env:
|
||||||
|
TZ: Europe/Berlin
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
run: npm run build
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
- name: Upload screenshots
|
- name: Upload screenshots
|
||||||
if: always()
|
if: always()
|
||||||
@@ -74,6 +80,8 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
DOCKER_API_VERSION: "1.43" # NAS runner runs Docker 24.x (max API 1.43); Testcontainers 2.x defaults to 1.44
|
DOCKER_API_VERSION: "1.43" # NAS runner runs Docker 24.x (max API 1.43); Testcontainers 2.x defaults to 1.44
|
||||||
|
DOCKER_HOST: unix:///var/run/docker.sock
|
||||||
|
TESTCONTAINERS_RYUK_DISABLED: "true"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ let {
|
|||||||
onclick={onPrev}
|
onclick={onPrev}
|
||||||
disabled={currentPage <= 1}
|
disabled={currentPage <= 1}
|
||||||
aria-label="Zurück"
|
aria-label="Zurück"
|
||||||
class="rounded p-1 text-ink-3 transition hover:bg-surface/10 disabled:opacity-40"
|
class="min-h-[44px] min-w-[44px] rounded p-2 text-ink-3 transition hover:bg-surface/10 focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-1 disabled:opacity-40"
|
||||||
>
|
>
|
||||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
|
||||||
@@ -52,7 +52,7 @@ let {
|
|||||||
onclick={onNext}
|
onclick={onNext}
|
||||||
disabled={!isLoaded || currentPage >= totalPages}
|
disabled={!isLoaded || currentPage >= totalPages}
|
||||||
aria-label="Weiter"
|
aria-label="Weiter"
|
||||||
class="rounded p-1 text-ink-3 transition hover:bg-surface/10 disabled:opacity-40"
|
class="min-h-[44px] min-w-[44px] rounded p-2 text-ink-3 transition hover:bg-surface/10 focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-1 disabled:opacity-40"
|
||||||
>
|
>
|
||||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
|
||||||
@@ -65,7 +65,7 @@ let {
|
|||||||
<button
|
<button
|
||||||
onclick={onZoomOut}
|
onclick={onZoomOut}
|
||||||
aria-label="Verkleinern"
|
aria-label="Verkleinern"
|
||||||
class="rounded p-1 text-ink-3 transition hover:bg-surface/10"
|
class="min-h-[44px] min-w-[44px] rounded p-2 text-ink-3 transition hover:bg-surface/10 focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-1"
|
||||||
>
|
>
|
||||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<circle cx="11" cy="11" r="8" />
|
<circle cx="11" cy="11" r="8" />
|
||||||
@@ -75,7 +75,7 @@ let {
|
|||||||
<button
|
<button
|
||||||
onclick={onZoomIn}
|
onclick={onZoomIn}
|
||||||
aria-label="Vergrößern"
|
aria-label="Vergrößern"
|
||||||
class="rounded p-1 text-ink-3 transition hover:bg-surface/10"
|
class="min-h-[44px] min-w-[44px] rounded p-2 text-ink-3 transition hover:bg-surface/10 focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-1"
|
||||||
>
|
>
|
||||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<circle cx="11" cy="11" r="8" />
|
<circle cx="11" cy="11" r="8" />
|
||||||
@@ -89,7 +89,8 @@ let {
|
|||||||
<button
|
<button
|
||||||
onclick={onToggleAnnotations}
|
onclick={onToggleAnnotations}
|
||||||
aria-label={showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()}
|
aria-label={showAnnotations ? m.pdf_annotations_hide() : m.pdf_annotations_show()}
|
||||||
class="flex items-center gap-1.5 rounded px-2 py-1 font-sans text-xs transition {showAnnotations
|
aria-pressed={showAnnotations}
|
||||||
|
class="flex min-h-[44px] min-w-[44px] items-center gap-1.5 rounded px-3 py-2 font-sans text-xs transition focus-visible:ring-2 focus-visible:ring-brand-navy focus-visible:ring-offset-1 {showAnnotations
|
||||||
? 'text-ink-2 hover:bg-surface/10'
|
? 'text-ink-2 hover:bg-surface/10'
|
||||||
: 'bg-surface/10 text-primary'}"
|
: 'bg-surface/10 text-primary'}"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -65,3 +65,111 @@ describe('PdfControls — annotation toggle contrast (WCAG 2.1 AA)', () => {
|
|||||||
expect(annotationBtn!.className).not.toContain('text-accent');
|
expect(annotationBtn!.className).not.toContain('text-accent');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('PdfControls — focus rings (WCAG 2.1 §2.4.7)', () => {
|
||||||
|
it('annotation toggle button has focus-visible:ring-2 focus ring', async () => {
|
||||||
|
const { container } = render(PdfControls, {
|
||||||
|
...defaultProps,
|
||||||
|
annotationCount: 2,
|
||||||
|
showAnnotations: false
|
||||||
|
});
|
||||||
|
const allButtons = container.querySelectorAll('button');
|
||||||
|
const annotationBtn = Array.from(allButtons).find((b) =>
|
||||||
|
b.getAttribute('aria-label')?.toLowerCase().includes('annotierungen')
|
||||||
|
);
|
||||||
|
expect(annotationBtn).not.toBeNull();
|
||||||
|
expect(annotationBtn!.className).toContain('focus-visible:ring-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('icon-only nav/zoom buttons each have focus-visible:ring-2 focus ring', async () => {
|
||||||
|
const { container } = render(PdfControls, { ...defaultProps });
|
||||||
|
const allButtons = container.querySelectorAll('button');
|
||||||
|
const iconOnlyButtons = Array.from(allButtons).filter((b) => {
|
||||||
|
const label = b.getAttribute('aria-label') ?? '';
|
||||||
|
return ['zurück', 'weiter', 'verkleinern', 'vergrößern'].includes(label.toLowerCase());
|
||||||
|
});
|
||||||
|
expect(iconOnlyButtons).toHaveLength(4);
|
||||||
|
for (const btn of iconOnlyButtons) {
|
||||||
|
expect(btn.className).toContain('focus-visible:ring-2');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PdfControls — touch targets (WCAG 2.2 §2.5.8)', () => {
|
||||||
|
it('annotation toggle button has min-h-[44px] touch target', async () => {
|
||||||
|
const { container } = render(PdfControls, {
|
||||||
|
...defaultProps,
|
||||||
|
annotationCount: 2,
|
||||||
|
showAnnotations: false
|
||||||
|
});
|
||||||
|
const allButtons = container.querySelectorAll('button');
|
||||||
|
const annotationBtn = Array.from(allButtons).find((b) =>
|
||||||
|
b.getAttribute('aria-label')?.toLowerCase().includes('annotierungen')
|
||||||
|
);
|
||||||
|
expect(annotationBtn).not.toBeNull();
|
||||||
|
expect(annotationBtn!.className).toContain('min-h-[44px]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('annotation toggle button has min-w-[44px] touch target', async () => {
|
||||||
|
const { container } = render(PdfControls, {
|
||||||
|
...defaultProps,
|
||||||
|
annotationCount: 2,
|
||||||
|
showAnnotations: false
|
||||||
|
});
|
||||||
|
const allButtons = container.querySelectorAll('button');
|
||||||
|
const annotationBtn = Array.from(allButtons).find((b) =>
|
||||||
|
b.getAttribute('aria-label')?.toLowerCase().includes('annotierungen')
|
||||||
|
);
|
||||||
|
expect(annotationBtn).not.toBeNull();
|
||||||
|
expect(annotationBtn!.className).toContain('min-w-[44px]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('annotation toggle reflects pressed state via aria-pressed', async () => {
|
||||||
|
const { container: c1 } = render(PdfControls, {
|
||||||
|
...defaultProps,
|
||||||
|
annotationCount: 2,
|
||||||
|
showAnnotations: false
|
||||||
|
});
|
||||||
|
const btn1 = Array.from(c1.querySelectorAll('button')).find((b) =>
|
||||||
|
b.getAttribute('aria-label')?.toLowerCase().includes('annotierungen')
|
||||||
|
);
|
||||||
|
expect(btn1!.getAttribute('aria-pressed')).toBe('false');
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
const { container: c2 } = render(PdfControls, {
|
||||||
|
...defaultProps,
|
||||||
|
annotationCount: 2,
|
||||||
|
showAnnotations: true
|
||||||
|
});
|
||||||
|
const btn2 = Array.from(c2.querySelectorAll('button')).find((b) =>
|
||||||
|
b.getAttribute('aria-label')?.toLowerCase().includes('annotierungen')
|
||||||
|
);
|
||||||
|
expect(btn2!.getAttribute('aria-pressed')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('icon-only nav/zoom buttons each have min-h-[44px] touch target', async () => {
|
||||||
|
const { container } = render(PdfControls, { ...defaultProps });
|
||||||
|
const allButtons = container.querySelectorAll('button');
|
||||||
|
const iconOnlyButtons = Array.from(allButtons).filter((b) => {
|
||||||
|
const label = b.getAttribute('aria-label') ?? '';
|
||||||
|
return ['zurück', 'weiter', 'verkleinern', 'vergrößern'].includes(label.toLowerCase());
|
||||||
|
});
|
||||||
|
expect(iconOnlyButtons).toHaveLength(4);
|
||||||
|
for (const btn of iconOnlyButtons) {
|
||||||
|
expect(btn.className).toContain('min-h-[44px]');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('icon-only nav/zoom buttons each have min-w-[44px] touch target', async () => {
|
||||||
|
const { container } = render(PdfControls, { ...defaultProps });
|
||||||
|
const allButtons = container.querySelectorAll('button');
|
||||||
|
const iconOnlyButtons = Array.from(allButtons).filter((b) => {
|
||||||
|
const label = b.getAttribute('aria-label') ?? '';
|
||||||
|
return ['zurück', 'weiter', 'verkleinern', 'vergrößern'].includes(label.toLowerCase());
|
||||||
|
});
|
||||||
|
expect(iconOnlyButtons).toHaveLength(4);
|
||||||
|
for (const btn of iconOnlyButtons) {
|
||||||
|
expect(btn.className).toContain('min-w-[44px]');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ interface Props {
|
|||||||
restrictToCorrespondentsOf?: string;
|
restrictToCorrespondentsOf?: string;
|
||||||
excludePersonId?: string;
|
excludePersonId?: string;
|
||||||
badge?: 'additive' | 'replace';
|
badge?: 'additive' | 'replace';
|
||||||
|
resetKey?: number;
|
||||||
onchange?: (value: string) => void;
|
onchange?: (value: string) => void;
|
||||||
onfocused?: () => void;
|
onfocused?: () => void;
|
||||||
}
|
}
|
||||||
@@ -39,17 +40,20 @@ let {
|
|||||||
restrictToCorrespondentsOf,
|
restrictToCorrespondentsOf,
|
||||||
excludePersonId,
|
excludePersonId,
|
||||||
badge,
|
badge,
|
||||||
|
resetKey = 0,
|
||||||
onchange,
|
onchange,
|
||||||
onfocused
|
onfocused
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
// searchTerm must be both prop-derived AND locally writable (user typing), so $state +
|
// searchTerm must be both prop-derived AND locally writable (user typing), so $state +
|
||||||
// $effect is the correct pattern here — writable $derived is read-only and won't work.
|
// $effect is the correct pattern here — writable $derived is read-only and won't work.
|
||||||
// eslint-disable-next-line svelte/prefer-writable-derived
|
|
||||||
let searchTerm = $state(initialName);
|
let searchTerm = $state(initialName);
|
||||||
|
|
||||||
// Sync display text when the selected person changes externally (e.g. swap, navigation).
|
// Sync display text when initialName changes OR when resetKey increments (navigation reset).
|
||||||
|
// resetKey is incremented by the page on every SvelteKit navigation so that a manually-typed
|
||||||
|
// term that was never committed (no person selected) gets cleared even if initialName stays ''.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
void resetKey;
|
||||||
searchTerm = initialName;
|
searchTerm = initialName;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -270,6 +270,33 @@ describe('PersonTypeahead – correspondent mode', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── resetKey ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('PersonTypeahead – resetKey', () => {
|
||||||
|
// Note: rerender() in vitest-browser-svelte causes a full re-mount, not an in-place prop
|
||||||
|
// update. This is a smoke test — the $effect(resetKey) path that fires during SvelteKit
|
||||||
|
// navigation (prop update on a live instance) cannot be isolated at this level.
|
||||||
|
it('clears a manually-typed term when resetKey changes even if initialName stays empty', async () => {
|
||||||
|
mockFetchWithPersons([]);
|
||||||
|
const { rerender } = render(PersonTypeahead, {
|
||||||
|
name: 'senderId',
|
||||||
|
label: 'Absender',
|
||||||
|
initialName: '',
|
||||||
|
resetKey: 0
|
||||||
|
});
|
||||||
|
const input = page.getByPlaceholder('Namen tippen...');
|
||||||
|
|
||||||
|
// User types something without selecting a person
|
||||||
|
await input.fill('Max');
|
||||||
|
await waitForDebounce();
|
||||||
|
await expect.element(input).toHaveValue('Max');
|
||||||
|
|
||||||
|
// Navigation resets: initialName stays '', but resetKey increments
|
||||||
|
await rerender({ name: 'senderId', label: 'Absender', initialName: '', resetKey: 1 });
|
||||||
|
await expect.element(input).toHaveValue('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ─── Click outside ────────────────────────────────────────────────────────────
|
// ─── Click outside ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('PersonTypeahead – click outside', () => {
|
describe('PersonTypeahead – click outside', () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
import { isoToGerman, handleGermanDateInput, germanToIso } from '$lib/shared/utils/date';
|
import { isoToGerman, handleGermanDateInput, germanToIso } from '$lib/shared/utils/date';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
@@ -24,6 +25,16 @@ let {
|
|||||||
|
|
||||||
let display = $state(isoToGerman(value ?? ''));
|
let display = $state(isoToGerman(value ?? ''));
|
||||||
|
|
||||||
|
// Re-derive display when value changes externally (e.g. timeline drag, reset nav).
|
||||||
|
// Guard prevents overwriting while the user is mid-typing a partial date:
|
||||||
|
// germanToIso returns '' for partial input, matching value '' → no re-derive.
|
||||||
|
$effect(() => {
|
||||||
|
const externalIso = value ?? '';
|
||||||
|
if (germanToIso(untrack(() => display)) !== externalIso) {
|
||||||
|
display = isoToGerman(externalIso);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ─── Validation helper ────────────────────────────────────────────────────
|
// ─── Validation helper ────────────────────────────────────────────────────
|
||||||
function isCalendarValid(iso: string): boolean {
|
function isCalendarValid(iso: string): boolean {
|
||||||
if (!iso) return false;
|
if (!iso) return false;
|
||||||
|
|||||||
@@ -183,6 +183,26 @@ describe('DateInput – clearing the date', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── External value changes ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('DateInput – external value changes', () => {
|
||||||
|
it('clears display when value prop is reset to empty externally', async () => {
|
||||||
|
const { rerender } = render(DateInput, { value: '1920-01-01' });
|
||||||
|
const input = page.getByRole('textbox');
|
||||||
|
await expect.element(input).toHaveValue('01.01.1920');
|
||||||
|
await rerender({ value: '' });
|
||||||
|
await expect.element(input).toHaveValue('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates display when value prop changes to a new date externally', async () => {
|
||||||
|
const { rerender } = render(DateInput, { value: '1920-01-01' });
|
||||||
|
const input = page.getByRole('textbox');
|
||||||
|
await expect.element(input).toHaveValue('01.01.1920');
|
||||||
|
await rerender({ value: '1945-05-08' });
|
||||||
|
await expect.element(input).toHaveValue('08.05.1945');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ─── Hidden input ─────────────────────────────────────────────────────────────
|
// ─── Hidden input ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('DateInput – hidden input for form submission', () => {
|
describe('DateInput – hidden input for form submission', () => {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ let {
|
|||||||
showAdvanced = $bindable(false),
|
showAdvanced = $bindable(false),
|
||||||
initialSenderName = '',
|
initialSenderName = '',
|
||||||
initialReceiverName = '',
|
initialReceiverName = '',
|
||||||
|
navKey = 0,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
onSearch,
|
onSearch,
|
||||||
onSearchImmediate,
|
onSearchImmediate,
|
||||||
@@ -39,6 +40,7 @@ let {
|
|||||||
showAdvanced?: boolean;
|
showAdvanced?: boolean;
|
||||||
initialSenderName?: string;
|
initialSenderName?: string;
|
||||||
initialReceiverName?: string;
|
initialReceiverName?: string;
|
||||||
|
navKey?: number;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
onSearch: () => void;
|
onSearch: () => void;
|
||||||
onSearchImmediate?: () => void;
|
onSearchImmediate?: () => void;
|
||||||
@@ -197,6 +199,7 @@ $effect(() => {
|
|||||||
label={m.docs_filter_label_sender()}
|
label={m.docs_filter_label_sender()}
|
||||||
bind:value={senderId}
|
bind:value={senderId}
|
||||||
initialName={initialSenderName}
|
initialName={initialSenderName}
|
||||||
|
resetKey={navKey}
|
||||||
onchange={onSearch}
|
onchange={onSearch}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -212,6 +215,7 @@ $effect(() => {
|
|||||||
label={m.docs_filter_label_receivers()}
|
label={m.docs_filter_label_receivers()}
|
||||||
bind:value={receiverId}
|
bind:value={receiverId}
|
||||||
initialName={initialReceiverName}
|
initialName={initialReceiverName}
|
||||||
|
resetKey={navKey}
|
||||||
onchange={onSearch}
|
onchange={onSearch}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,20 @@ import { createApiClient } from '$lib/shared/api.server';
|
|||||||
import { getErrorMessage } from '$lib/shared/errors';
|
import { getErrorMessage } from '$lib/shared/errors';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
async function resolvePersonName(id: string, fetch: typeof globalThis.fetch): Promise<string> {
|
||||||
|
if (!UUID_RE.test(id)) return '';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/persons/${id}`);
|
||||||
|
if (!res.ok) return '';
|
||||||
|
const person = await res.json();
|
||||||
|
return person.displayName ?? '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
|
type DocumentSearchItem = components['schemas']['DocumentSearchItem'];
|
||||||
|
|
||||||
const VALID_SORTS = ['DATE', 'TITLE', 'SENDER', 'RECEIVER', 'UPLOAD_DATE', 'RELEVANCE'] as const;
|
const VALID_SORTS = ['DATE', 'TITLE', 'SENDER', 'RECEIVER', 'UPLOAD_DATE', 'RELEVANCE'] as const;
|
||||||
@@ -34,25 +48,30 @@ export async function load({ url, fetch }) {
|
|||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
|
|
||||||
let result;
|
let result;
|
||||||
|
let initialSenderName = '';
|
||||||
|
let initialReceiverName = '';
|
||||||
try {
|
try {
|
||||||
result = await api.GET('/api/documents/search', {
|
[result, [initialSenderName, initialReceiverName]] = await Promise.all([
|
||||||
params: {
|
api.GET('/api/documents/search', {
|
||||||
query: {
|
params: {
|
||||||
q: q || undefined,
|
query: {
|
||||||
from: from || undefined,
|
q: q || undefined,
|
||||||
to: to || undefined,
|
from: from || undefined,
|
||||||
senderId: senderId || undefined,
|
to: to || undefined,
|
||||||
receiverId: receiverId || undefined,
|
senderId: senderId || undefined,
|
||||||
tag: tags.length ? tags : undefined,
|
receiverId: receiverId || undefined,
|
||||||
tagQ: tagQ && !tags.length ? tagQ : undefined,
|
tag: tags.length ? tags : undefined,
|
||||||
tagOp: tagOp === 'OR' ? 'OR' : undefined,
|
tagQ: tagQ && !tags.length ? tagQ : undefined,
|
||||||
sort,
|
tagOp: tagOp === 'OR' ? 'OR' : undefined,
|
||||||
dir: dir || undefined,
|
sort,
|
||||||
page,
|
dir: dir || undefined,
|
||||||
size: PAGE_SIZE
|
page,
|
||||||
|
size: PAGE_SIZE
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}),
|
||||||
});
|
Promise.all([resolvePersonName(senderId, fetch), resolvePersonName(receiverId, fetch)])
|
||||||
|
]);
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return {
|
||||||
items: [] as DocumentSearchItem[],
|
items: [] as DocumentSearchItem[],
|
||||||
@@ -65,6 +84,8 @@ export async function load({ url, fetch }) {
|
|||||||
to,
|
to,
|
||||||
senderId,
|
senderId,
|
||||||
receiverId,
|
receiverId,
|
||||||
|
initialSenderName: '',
|
||||||
|
initialReceiverName: '',
|
||||||
tags,
|
tags,
|
||||||
sort,
|
sort,
|
||||||
dir,
|
dir,
|
||||||
@@ -94,6 +115,8 @@ export async function load({ url, fetch }) {
|
|||||||
to,
|
to,
|
||||||
senderId,
|
senderId,
|
||||||
receiverId,
|
receiverId,
|
||||||
|
initialSenderName,
|
||||||
|
initialReceiverName,
|
||||||
tags,
|
tags,
|
||||||
sort,
|
sort,
|
||||||
dir,
|
dir,
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ let from = $state(untrack(() => data.from || ''));
|
|||||||
let to = $state(untrack(() => data.to || ''));
|
let to = $state(untrack(() => data.to || ''));
|
||||||
let senderId = $state(untrack(() => data.senderId || ''));
|
let senderId = $state(untrack(() => data.senderId || ''));
|
||||||
let receiverId = $state(untrack(() => data.receiverId || ''));
|
let receiverId = $state(untrack(() => data.receiverId || ''));
|
||||||
|
let initialSenderName = $state(untrack(() => data.initialSenderName ?? ''));
|
||||||
|
let initialReceiverName = $state(untrack(() => data.initialReceiverName ?? ''));
|
||||||
|
let navKey = $state(0);
|
||||||
let tagNames = $state<{ name: string; id?: string; color?: string; parentId?: string }[]>(
|
let tagNames = $state<{ name: string; id?: string; color?: string; parentId?: string }[]>(
|
||||||
untrack(() => (data.tags || []).map((name: string) => ({ name })))
|
untrack(() => (data.tags || []).map((name: string) => ({ name })))
|
||||||
);
|
);
|
||||||
@@ -207,12 +210,17 @@ async function editAllMatching() {
|
|||||||
|
|
||||||
// Keep local filter state in sync with server data after navigation completes.
|
// Keep local filter state in sync with server data after navigation completes.
|
||||||
// Guard q: skip overwrite while the user is actively typing.
|
// Guard q: skip overwrite while the user is actively typing.
|
||||||
|
// navKey increments on every navigation so PersonTypeahead clears manually-typed
|
||||||
|
// terms even when initialSenderName/initialReceiverName stays '' across navigations.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!qFocused) q = data.q || '';
|
if (!qFocused) q = data.q || '';
|
||||||
from = data.from || '';
|
from = data.from || '';
|
||||||
to = data.to || '';
|
to = data.to || '';
|
||||||
senderId = data.senderId || '';
|
senderId = data.senderId || '';
|
||||||
receiverId = data.receiverId || '';
|
receiverId = data.receiverId || '';
|
||||||
|
initialSenderName = data.initialSenderName ?? '';
|
||||||
|
initialReceiverName = data.initialReceiverName ?? '';
|
||||||
|
untrack(() => navKey++);
|
||||||
tagNames = (data.tags || []).map((name: string) => ({ name }));
|
tagNames = (data.tags || []).map((name: string) => ({ name }));
|
||||||
sort = data.sort || 'DATE';
|
sort = data.sort || 'DATE';
|
||||||
dir = data.dir || 'desc';
|
dir = data.dir || 'desc';
|
||||||
@@ -247,6 +255,9 @@ $effect(() => {
|
|||||||
bind:dir={dir}
|
bind:dir={dir}
|
||||||
bind:tagQ={tagQ}
|
bind:tagQ={tagQ}
|
||||||
bind:tagOperator={tagOperator}
|
bind:tagOperator={tagOperator}
|
||||||
|
initialSenderName={initialSenderName}
|
||||||
|
initialReceiverName={initialReceiverName}
|
||||||
|
navKey={navKey}
|
||||||
isLoading={navigating.to !== null}
|
isLoading={navigating.to !== null}
|
||||||
onSearch={handleTextSearch}
|
onSearch={handleTextSearch}
|
||||||
onSearchImmediate={handleImmediateSearch}
|
onSearchImmediate={handleImmediateSearch}
|
||||||
|
|||||||
@@ -167,3 +167,72 @@ describe('documents page load — network error fallback', () => {
|
|||||||
expect(result.items).toEqual([]);
|
expect(result.items).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── person name resolution ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('documents page load — person name resolution', () => {
|
||||||
|
function makeSearchMock() {
|
||||||
|
const mockGet = vi.fn().mockResolvedValue({
|
||||||
|
response: { ok: true, status: 200 },
|
||||||
|
data: { items: [], totalElements: 0, pageNumber: 0, pageSize: 50, totalPages: 0 }
|
||||||
|
});
|
||||||
|
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||||
|
typeof createApiClient
|
||||||
|
>);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns initialSenderName from person lookup when senderId is a valid UUID', async () => {
|
||||||
|
makeSearchMock();
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: vi.fn().mockResolvedValue({ displayName: 'Max Mustermann' })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await load({
|
||||||
|
url: makeUrl({ senderId: '11111111-1111-1111-1111-111111111111' }),
|
||||||
|
fetch: mockFetch as unknown as typeof fetch
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.initialSenderName).toBe('Max Mustermann');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns initialReceiverName from person lookup when receiverId is a valid UUID', async () => {
|
||||||
|
makeSearchMock();
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: vi.fn().mockResolvedValue({ displayName: 'Anna Musterfrau' })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await load({
|
||||||
|
url: makeUrl({ receiverId: '22222222-2222-2222-2222-222222222222' }),
|
||||||
|
fetch: mockFetch as unknown as typeof fetch
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.initialReceiverName).toBe('Anna Musterfrau');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string when senderId is not a valid UUID', async () => {
|
||||||
|
makeSearchMock();
|
||||||
|
const mockFetch = vi.fn();
|
||||||
|
|
||||||
|
const result = await load({
|
||||||
|
url: makeUrl({ senderId: 'not-a-uuid' }),
|
||||||
|
fetch: mockFetch as unknown as typeof fetch
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.initialSenderName).toBe('');
|
||||||
|
expect(mockFetch).not.toHaveBeenCalledWith(expect.stringContaining('/api/persons/'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string when person fetch returns 404', async () => {
|
||||||
|
makeSearchMock();
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 404 });
|
||||||
|
|
||||||
|
const result = await load({
|
||||||
|
url: makeUrl({ senderId: '11111111-1111-1111-1111-111111111111' }),
|
||||||
|
fetch: mockFetch as unknown as typeof fetch
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.initialSenderName).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ function makeData(overrides: Record<string, unknown> = {}) {
|
|||||||
to: '',
|
to: '',
|
||||||
senderId: '',
|
senderId: '',
|
||||||
receiverId: '',
|
receiverId: '',
|
||||||
|
initialSenderName: '',
|
||||||
|
initialReceiverName: '',
|
||||||
tags: [],
|
tags: [],
|
||||||
sort: 'DATE',
|
sort: 'DATE',
|
||||||
dir: 'desc',
|
dir: 'desc',
|
||||||
@@ -136,6 +138,22 @@ describe('documents page — URL building', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Sender / receiver name display ──────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('documents page — sender/receiver display', () => {
|
||||||
|
it('pre-fills sender typeahead from initialSenderName when senderId filter is active', async () => {
|
||||||
|
render(Page, {
|
||||||
|
data: makeData({
|
||||||
|
senderId: '11111111-1111-1111-1111-111111111111',
|
||||||
|
initialSenderName: 'Max Mustermann'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
// Advanced filters are auto-shown when senderId is set
|
||||||
|
const inputs = page.getByPlaceholder('Namen tippen...');
|
||||||
|
await expect.element(inputs.first()).toHaveValue('Max Mustermann');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ─── Timeline density widget wiring (#385) ────────────────────────────────────
|
// ─── Timeline density widget wiring (#385) ────────────────────────────────────
|
||||||
|
|
||||||
describe('documents page — timeline density widget', () => {
|
describe('documents page — timeline density widget', () => {
|
||||||
|
|||||||
@@ -1,3 +1 @@
|
|||||||
// Safe: handleAuth in hooks.server.ts redirects unauthenticated requests
|
|
||||||
// before prerendered HTML is visible.
|
|
||||||
export const prerender = true;
|
export const prerender = true;
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ const config = {
|
|||||||
// Consult https://svelte.dev/docs/kit/integrations
|
// Consult https://svelte.dev/docs/kit/integrations
|
||||||
// for more information about preprocessors
|
// for more information about preprocessors
|
||||||
preprocess: vitePreprocess(),
|
preprocess: vitePreprocess(),
|
||||||
kit: { adapter: adapter() }
|
kit: {
|
||||||
|
adapter: adapter(),
|
||||||
|
prerender: { entries: ['/hilfe/transkription'] }
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
12
runner-config.yaml
Normal file
12
runner-config.yaml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# runner-config.yaml — only the relevant section
|
||||||
|
container:
|
||||||
|
# passed as DOCKER_HOST inside the job container
|
||||||
|
docker_host: "unix:///var/run/docker.sock"
|
||||||
|
# whitelists the socket path so workflows can mount it
|
||||||
|
valid_volumes:
|
||||||
|
- "/var/run/docker.sock"
|
||||||
|
# appended to `docker run` when the runner spawns a job container
|
||||||
|
options: "-v /var/run/docker.sock:/var/run/docker.sock"
|
||||||
|
# keep network mode default (bridge) — Testcontainers handles its own networking
|
||||||
|
force_pull: false
|
||||||
|
|
||||||
Reference in New Issue
Block a user