fix(persons): prevent stale navigation from clobbering focused search input
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 2m12s
CI / Backend Unit Tests (pull_request) Successful in 1m58s
CI / E2E Tests (pull_request) Successful in 17m40s
CI / Unit & Component Tests (push) Successful in 1m58s
CI / Backend Unit Tests (push) Successful in 1m59s
CI / E2E Tests (push) Successful in 14m56s
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 2m12s
CI / Backend Unit Tests (pull_request) Successful in 1m58s
CI / E2E Tests (pull_request) Successful in 17m40s
CI / Unit & Component Tests (push) Successful in 1m58s
CI / Backend Unit Tests (push) Successful in 1m59s
CI / E2E Tests (push) Successful in 14m56s
The persons list search input used value={data.q || ''} bound directly to
server data, so every navigation completion would reset it to the URL value
mid-typing, dropping keystrokes just like issue #34 on the home page.
Apply the same focus-guard fix: introduce local `q` state, a `qFocused`
flag, and a guarded $effect that only syncs URL → state when the input is
not focused. Adds a regression test matching the home-page pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit was merged in pull request #44.
This commit is contained in:
@@ -1,16 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { untrack } from 'svelte';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
|
let q = $state(untrack(() => data.q || ''));
|
||||||
|
let qFocused = $state(false);
|
||||||
|
|
||||||
|
// Sync URL → local state after navigation, but not while the user is typing.
|
||||||
|
$effect(() => {
|
||||||
|
if (!qFocused) q = data.q || '';
|
||||||
|
});
|
||||||
|
|
||||||
let searchTimeout: ReturnType<typeof setTimeout>;
|
let searchTimeout: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
function handleSearch(e: Event) {
|
function handleSearch() {
|
||||||
const value = (e.target as HTMLInputElement).value;
|
|
||||||
clearTimeout(searchTimeout);
|
clearTimeout(searchTimeout);
|
||||||
searchTimeout = setTimeout(() => {
|
searchTimeout = setTimeout(() => {
|
||||||
goto(`/persons?q=${value}`, { keepFocus: true });
|
goto(`/persons?q=${q}`, { keepFocus: true });
|
||||||
}, 300);
|
}, 300);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -49,8 +57,10 @@ function handleSearch(e: Event) {
|
|||||||
id="search"
|
id="search"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={m.persons_search_placeholder()}
|
placeholder={m.persons_search_placeholder()}
|
||||||
value={data.q || ''}
|
bind:value={q}
|
||||||
oninput={handleSearch}
|
oninput={handleSearch}
|
||||||
|
onfocus={() => (qFocused = true)}
|
||||||
|
onblur={() => (qFocused = false)}
|
||||||
class="block w-full rounded-sm border border-gray-300 bg-white py-2.5 pr-10 pl-4 font-sans text-sm text-brand-navy placeholder-gray-400 shadow-sm focus:border-brand-navy focus:ring-1 focus:ring-brand-navy focus:outline-none"
|
class="block w-full rounded-sm border border-gray-300 bg-white py-2.5 pr-10 pl-4 font-sans text-sm text-brand-navy placeholder-gray-400 shadow-sm focus:border-brand-navy focus:ring-1 focus:ring-brand-navy focus:outline-none"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
|
|||||||
70
frontend/src/routes/persons/page.svelte.spec.ts
Normal file
70
frontend/src/routes/persons/page.svelte.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { render } from 'vitest-browser-svelte';
|
||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import Page from './+page.svelte';
|
||||||
|
|
||||||
|
const tick = () => new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||||
|
|
||||||
|
const makePerson = (overrides = {}) => ({
|
||||||
|
id: '1',
|
||||||
|
firstName: 'Max',
|
||||||
|
lastName: 'Mustermann',
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
const emptyData = { user: undefined, canWrite: true, q: '', persons: [] };
|
||||||
|
const dataWithPersons = { ...emptyData, persons: [makePerson()] };
|
||||||
|
|
||||||
|
// ─── Rendering ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Persons page – rendering', () => {
|
||||||
|
it('renders the search input', async () => {
|
||||||
|
render(Page, { data: emptyData });
|
||||||
|
await expect.element(page.getByRole('textbox')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pre-fills the search input from data.q', async () => {
|
||||||
|
render(Page, { data: { ...emptyData, q: 'Müller' } });
|
||||||
|
await expect.element(page.getByRole('textbox')).toHaveValue('Müller');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows empty state when no persons', async () => {
|
||||||
|
render(Page, { data: emptyData });
|
||||||
|
await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders person cards', async () => {
|
||||||
|
render(Page, { data: dataWithPersons });
|
||||||
|
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links person card to detail page', async () => {
|
||||||
|
render(Page, { data: dataWithPersons });
|
||||||
|
await expect
|
||||||
|
.element(page.getByRole('link', { name: /Max Mustermann/ }))
|
||||||
|
.toHaveAttribute('href', '/persons/1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Keystroke preservation (issue #34) ──────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Persons page – search input keystroke preservation', () => {
|
||||||
|
it('does not overwrite the search input while the user is focused and stale data arrives', async () => {
|
||||||
|
const { rerender } = render(Page, { data: emptyData });
|
||||||
|
|
||||||
|
const input = page.getByRole('textbox');
|
||||||
|
|
||||||
|
// User types "abc" — input is focused
|
||||||
|
await input.click();
|
||||||
|
await input.fill('abc');
|
||||||
|
|
||||||
|
// Simulate a navigation completing with stale data (q='a') while the user is still typing
|
||||||
|
await rerender({ data: { ...emptyData, q: 'a' } });
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
// Input must still show what the user typed, not the stale URL value
|
||||||
|
await expect.element(input).toHaveValue('abc');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user