feat(#248): admin tag page complete overhaul — tree panel, merge, subtree delete, new edit components #249

Merged
marcel merged 51 commits from feat/issue-221-tag-hierarchy into main 2026-04-17 10:24:10 +02:00
2 changed files with 242 additions and 0 deletions
Showing only changes of commit a3660a79e1 - Show all commits

View File

@@ -0,0 +1,115 @@
<script lang="ts">
import { untrack } from 'svelte';
import type { components } from '$lib/generated/api';
import { m } from '$lib/paraglide/messages.js';
import { clickOutside } from '$lib/actions/clickOutside';
import { createTypeahead } from '$lib/hooks/useTypeahead.svelte';
type Tag = components['schemas']['Tag'];
interface Props {
name: string;
value?: string;
excludeIds?: string[];
initialName?: string;
}
let { name, value = $bindable(''), excludeIds = [], initialName = '' }: Props = $props();
// displayName 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.
// eslint-disable-next-line svelte/prefer-writable-derived
let displayName = $state(initialName);
$effect(() => {
displayName = initialName;
});
const typeahead = createTypeahead<Tag>({
fetchUrl: async (q) => {
const res = await fetch(`/api/tags?query=${encodeURIComponent(q)}`);
return res.ok ? await res.json() : [];
},
debounceMs: 300
});
const filteredResults = $derived(typeahead.results.filter((t) => !excludeIds.includes(t.id)));
function handleInput() {
const term = untrack(() => displayName);
typeahead.setQuery(term);
}
function selectTag(tag: Tag) {
value = tag.id;
displayName = tag.name;
typeahead.close();
}
function clearSelection() {
value = '';
displayName = '';
typeahead.close();
}
</script>
<div class="relative" use:clickOutside onclickoutside={() => typeahead.close()}>
<input type="hidden" name={name} bind:value={value} />
<div class="relative">
<input
type="text"
id="{name}-search"
autocomplete="off"
role="combobox"
aria-expanded={typeahead.isOpen}
aria-controls="{name}-listbox"
aria-autocomplete="list"
bind:value={displayName}
oninput={handleInput}
placeholder={m.admin_tag_parent_placeholder()}
class="mt-1 block w-full rounded-md border border-line bg-surface p-2 pr-8 text-ink shadow-sm placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
{#if value}
<button
type="button"
onclick={clearSelection}
aria-label="Auswahl entfernen"
class="absolute top-1/2 right-2 -translate-y-1/2 text-ink-3 hover:text-ink focus:outline-none"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
</div>
{#if typeahead.isOpen && filteredResults.length > 0}
<div
id="{name}-listbox"
role="listbox"
class="ring-opacity-5 absolute top-full left-0 z-50 mt-1 max-h-60 w-full overflow-auto rounded-md bg-surface py-1 text-base shadow-lg ring-1 ring-black focus:outline-none sm:text-sm"
>
{#each filteredResults as tag (tag.id)}
<div
class="relative cursor-pointer py-2 pr-9 pl-3 text-ink select-none hover:bg-accent-bg"
onclick={() => selectTag(tag)}
onkeydown={(e) => e.key === 'Enter' && selectTag(tag)}
role="button"
tabindex="0"
>
<span class="block truncate font-medium">{tag.name}</span>
{#if tag.parentId}
<span class="block truncate text-xs text-ink-3">{tag.parentId}</span>
{/if}
</div>
{/each}
</div>
{/if}
</div>

View File

@@ -0,0 +1,127 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import TagParentPicker from './TagParentPicker.svelte';
function hiddenInput(name: string) {
return document.querySelector<HTMLInputElement>(`input[type="hidden"][name="${name}"]`);
}
function mockFetchWithTags(tags: { id: string; name: string; parentId?: string }[]) {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue(tags)
})
);
}
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.useRealTimers();
});
// ─── Rendering ────────────────────────────────────────────────────────────────
describe('TagParentPicker rendering', () => {
it('renders the text input', async () => {
render(TagParentPicker, { name: 'parentId' });
await expect.element(page.getByRole('combobox')).toBeInTheDocument();
});
it('renders hidden input with correct name', async () => {
render(TagParentPicker, { name: 'parentId' });
await vi.advanceTimersByTimeAsync(0);
expect(hiddenInput('parentId')).toBeTruthy();
});
});
// ─── Search ───────────────────────────────────────────────────────────────────
describe('TagParentPicker search', () => {
it('typing shows dropdown results', async () => {
mockFetchWithTags([{ id: 't1', name: 'Haus' }]);
render(TagParentPicker, { name: 'parentId' });
const input = page.getByRole('combobox');
await input.fill('H');
await vi.advanceTimersByTimeAsync(300);
await expect.element(page.getByText('Haus')).toBeInTheDocument();
});
it('filters excludeIds from results', async () => {
mockFetchWithTags([
{ id: 't1', name: 'Haus' },
{ id: 't2', name: 'Garten' }
]);
render(TagParentPicker, { name: 'parentId', excludeIds: ['t1'] });
const input = page.getByRole('combobox');
await input.fill('a');
await vi.advanceTimersByTimeAsync(300);
await expect.element(page.getByText('Haus')).not.toBeInTheDocument();
await expect.element(page.getByText('Garten')).toBeInTheDocument();
});
});
// ─── Selection ────────────────────────────────────────────────────────────────
describe('TagParentPicker selection', () => {
it('selecting an option sets the hidden input value', async () => {
mockFetchWithTags([{ id: 't1', name: 'Haus' }]);
render(TagParentPicker, { name: 'parentId' });
const input = page.getByRole('combobox');
await input.fill('H');
await vi.advanceTimersByTimeAsync(300);
document.querySelector<HTMLElement>('[role="button"]')!.click();
await vi.advanceTimersByTimeAsync(0);
expect(hiddenInput('parentId')?.value).toBe('t1');
});
it('clear button appears when value is set', async () => {
mockFetchWithTags([{ id: 't1', name: 'Haus' }]);
render(TagParentPicker, { name: 'parentId' });
const input = page.getByRole('combobox');
await input.fill('H');
await vi.advanceTimersByTimeAsync(300);
document.querySelector<HTMLElement>('[role="button"]')!.click();
await vi.advanceTimersByTimeAsync(0);
await expect
.element(page.getByRole('button', { name: 'Auswahl entfernen' }))
.toBeInTheDocument();
});
it('clear button resets value', async () => {
mockFetchWithTags([{ id: 't1', name: 'Haus' }]);
render(TagParentPicker, { name: 'parentId' });
const input = page.getByRole('combobox');
await input.fill('H');
await vi.advanceTimersByTimeAsync(300);
document.querySelector<HTMLElement>('[role="button"]')!.click();
await vi.advanceTimersByTimeAsync(0);
expect(hiddenInput('parentId')?.value).toBe('t1');
const clearBtn = document.querySelector<HTMLElement>('button[aria-label="Auswahl entfernen"]')!;
clearBtn.click();
await vi.advanceTimersByTimeAsync(0);
expect(hiddenInput('parentId')?.value).toBe('');
});
});