feat(journey-editor): build JourneyItemRow with note editing and remove confirm
Item row with drag handle, move-up/down buttons, inline note textarea (PATCH on blur), interlude visual treatment, and inline confirm for removes that would discard a note. Interlude note cannot be cleared (blocked on empty). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
206
frontend/src/lib/geschichte/JourneyItemRow.svelte
Normal file
206
frontend/src/lib/geschichte/JourneyItemRow.svelte
Normal file
@@ -0,0 +1,206 @@
|
||||
<script lang="ts">
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
type JourneyItemView = components['schemas']['JourneyItemView'];
|
||||
|
||||
interface Props {
|
||||
item: JourneyItemView;
|
||||
index: number;
|
||||
total: number;
|
||||
onMoveUp: () => void;
|
||||
onMoveDown: () => void;
|
||||
onRemove: () => void;
|
||||
onNotePatch: (note: string | null) => Promise<void>;
|
||||
}
|
||||
|
||||
let { item, index, total, onMoveUp, onMoveDown, onRemove, onNotePatch }: Props = $props();
|
||||
|
||||
const isInterlude = $derived(!item.document);
|
||||
const itemTitle = $derived(item.document?.title ?? m.journey_add_interlude());
|
||||
const needsConfirmOnRemove = $derived(!!item.note);
|
||||
|
||||
let showNote = $state(!!item.note);
|
||||
let noteDraft = $state(item.note ?? '');
|
||||
let noteSaving = $state(false);
|
||||
let noteError = $state('');
|
||||
let showRemoveConfirm = $state(false);
|
||||
|
||||
async function handleNoteBlur() {
|
||||
if (noteSaving) return;
|
||||
if (noteDraft === item.note) return;
|
||||
if (isInterlude && noteDraft.trim().length === 0) return;
|
||||
|
||||
noteSaving = true;
|
||||
noteError = '';
|
||||
try {
|
||||
await onNotePatch(noteDraft.trim().length === 0 ? null : noteDraft);
|
||||
} catch {
|
||||
noteError = m.journey_note_error();
|
||||
} finally {
|
||||
noteSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNoteRemove() {
|
||||
noteDraft = '';
|
||||
showNote = false;
|
||||
noteError = '';
|
||||
await onNotePatch(null);
|
||||
}
|
||||
|
||||
function handleRemoveClick() {
|
||||
if (needsConfirmOnRemove) {
|
||||
showRemoveConfirm = true;
|
||||
} else {
|
||||
onRemove();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
data-block-id={item.id}
|
||||
class={[
|
||||
'flex min-w-0 flex-col rounded border transition-colors',
|
||||
isInterlude
|
||||
? 'border-l-4 border-[var(--color-interlude-border)] bg-[var(--color-interlude-bg)]'
|
||||
: 'border-line bg-surface'
|
||||
].join(' ')}
|
||||
>
|
||||
<div class="flex min-w-0 items-start gap-1 px-2 py-2">
|
||||
<!-- Drag handle (desktop) -->
|
||||
<button
|
||||
type="button"
|
||||
data-drag-handle
|
||||
aria-label={m.journey_drag_aria_label({ title: itemTitle })}
|
||||
class="hidden shrink-0 cursor-grab items-center justify-center text-ink-3 transition-colors hover:text-ink active:cursor-grabbing md:flex"
|
||||
style="min-height: 44px; min-width: 44px;"
|
||||
>
|
||||
⠿
|
||||
</button>
|
||||
|
||||
<!-- Move up/down (mobile + always visible) -->
|
||||
<div class="flex shrink-0 flex-col">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onMoveUp}
|
||||
disabled={index === 0}
|
||||
aria-label={m.journey_move_up({ title: itemTitle })}
|
||||
class="flex items-center justify-center rounded text-ink-3 transition-colors hover:bg-muted hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:opacity-20"
|
||||
style="min-height: 22px; min-width: 44px;"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onMoveDown}
|
||||
disabled={index === total - 1}
|
||||
aria-label={m.journey_move_down({ title: itemTitle })}
|
||||
class="flex items-center justify-center rounded text-ink-3 transition-colors hover:bg-muted hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:opacity-20"
|
||||
style="min-height: 22px; min-width: 44px;"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="min-w-0 flex-1 py-1 break-words">
|
||||
{#if isInterlude}
|
||||
<span
|
||||
class="font-sans text-xs font-bold tracking-widest uppercase"
|
||||
style="color: var(--color-interlude-label);"
|
||||
>
|
||||
{m.journey_add_interlude()}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="font-sans text-xs text-ink-3">{index + 1}.</span>
|
||||
<span class="ml-1 font-serif text-sm text-ink">{item.document!.title}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Remove button / confirm -->
|
||||
<div class="shrink-0">
|
||||
{#if showRemoveConfirm}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-sans text-xs text-ink-2">{m.journey_remove_confirm()}</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onRemove}
|
||||
class="rounded bg-danger px-2 py-1 font-sans text-xs font-medium text-white hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
{m.journey_remove_confirm_yes()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showRemoveConfirm = false)}
|
||||
class="rounded border border-line px-2 py-1 font-sans text-xs font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
{m.journey_remove_confirm_cancel()}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleRemoveClick}
|
||||
aria-label={m.journey_remove_confirm()}
|
||||
class="-m-1 rounded p-3 text-ink-3 hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Note section -->
|
||||
{#if showNote}
|
||||
<div class="border-t border-line/50 px-3 pt-2 pb-3">
|
||||
<textarea
|
||||
aria-label={m.journey_note_aria_label({ title: itemTitle })}
|
||||
bind:value={noteDraft}
|
||||
onblur={handleNoteBlur}
|
||||
maxlength={2000}
|
||||
rows={2}
|
||||
class="block w-full resize-y rounded border border-line bg-transparent px-2 py-1.5 font-sans text-sm text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
></textarea>
|
||||
<div class="mt-1 flex items-center justify-between gap-2">
|
||||
<p class="font-sans text-xs text-ink-3">{m.journey_note_save_hint()}</p>
|
||||
{#if !isInterlude}
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleNoteRemove}
|
||||
class="font-sans text-xs text-ink-3 underline hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
{m.journey_note_remove()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if noteError}
|
||||
<p class="mt-1 font-sans text-xs text-danger" role="alert">{noteError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if !isInterlude}
|
||||
<div class="px-3 pb-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
showNote = true;
|
||||
}}
|
||||
class="font-sans text-xs text-ink-3 underline hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
{m.journey_note_add()}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
112
frontend/src/lib/geschichte/JourneyItemRow.svelte.spec.ts
Normal file
112
frontend/src/lib/geschichte/JourneyItemRow.svelte.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import JourneyItemRow from './JourneyItemRow.svelte';
|
||||
|
||||
const docItem = (overrides: Partial<{ note: string }> = {}) => ({
|
||||
id: 'item-1',
|
||||
position: 0,
|
||||
document: { id: 'doc-1', title: 'Brief von Karl', datePrecision: 'DAY' as const },
|
||||
...overrides
|
||||
});
|
||||
|
||||
const interludeItem = (note = 'Reise nach Wien') => ({
|
||||
id: 'item-2',
|
||||
position: 1,
|
||||
note
|
||||
});
|
||||
|
||||
const defaultProps = (overrides = {}) => ({
|
||||
index: 0,
|
||||
total: 3,
|
||||
onMoveUp: vi.fn(),
|
||||
onMoveDown: vi.fn(),
|
||||
onRemove: vi.fn(),
|
||||
onNotePatch: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides
|
||||
});
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('JourneyItemRow — note textarea', () => {
|
||||
it('opens note textarea on "Notiz hinzufügen" click', async () => {
|
||||
render(JourneyItemRow, { item: docItem(), ...defaultProps() });
|
||||
|
||||
await userEvent.click(page.getByText('Notiz hinzufügen'));
|
||||
|
||||
await expect
|
||||
.element(page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onNotePatch on textarea blur with non-empty value', async () => {
|
||||
const onNotePatch = vi.fn().mockResolvedValue(undefined);
|
||||
render(JourneyItemRow, { item: docItem(), ...defaultProps({ onNotePatch }) });
|
||||
|
||||
await userEvent.click(page.getByText('Notiz hinzufügen'));
|
||||
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz für Brief von Karl/ });
|
||||
await userEvent.fill(textarea, 'Eine neue Notiz');
|
||||
await textarea.element().dispatchEvent(new FocusEvent('blur'));
|
||||
|
||||
expect(onNotePatch).toHaveBeenCalledWith('Eine neue Notiz');
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyItemRow — interlude rules', () => {
|
||||
it('does not show "Notiz entfernen" for interlude items', async () => {
|
||||
render(JourneyItemRow, { item: interludeItem(), ...defaultProps() });
|
||||
|
||||
// Note section should be visible (interlude always shows note)
|
||||
await expect
|
||||
.element(page.getByRole('textbox', { name: /Kuratoren-Notiz/ }))
|
||||
.toBeInTheDocument();
|
||||
// But "Notiz entfernen" must be absent
|
||||
await expect.element(page.getByText('Notiz entfernen')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('blocks saving empty text on interlude note blur', async () => {
|
||||
const onNotePatch = vi.fn().mockResolvedValue(undefined);
|
||||
render(JourneyItemRow, {
|
||||
item: interludeItem('original text'),
|
||||
...defaultProps({ onNotePatch })
|
||||
});
|
||||
|
||||
const textarea = page.getByRole('textbox', { name: /Kuratoren-Notiz/ });
|
||||
await userEvent.clear(textarea);
|
||||
await textarea.element().dispatchEvent(new FocusEvent('blur'));
|
||||
|
||||
expect(onNotePatch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('JourneyItemRow — remove confirm', () => {
|
||||
it('shows inline confirm when removing a document item that has a note', async () => {
|
||||
render(JourneyItemRow, {
|
||||
item: docItem({ note: 'Wichtige Notiz' }),
|
||||
...defaultProps()
|
||||
});
|
||||
|
||||
// Click remove (x button)
|
||||
await userEvent.click(page.getByRole('button', { name: 'Wirklich entfernen?' }));
|
||||
|
||||
await expect.element(page.getByText('Wirklich entfernen?')).toBeInTheDocument();
|
||||
await expect.element(page.getByRole('button', { name: 'Bestätigen' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('confirm cancel restores remove button without calling onRemove', async () => {
|
||||
const onRemove = vi.fn();
|
||||
render(JourneyItemRow, {
|
||||
item: docItem({ note: 'Notiz' }),
|
||||
...defaultProps({ onRemove })
|
||||
});
|
||||
|
||||
await userEvent.click(page.getByRole('button', { name: 'Wirklich entfernen?' }));
|
||||
await userEvent.click(page.getByRole('button', { name: 'Abbrechen' }));
|
||||
|
||||
expect(onRemove).not.toHaveBeenCalled();
|
||||
// The remove button should be back
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: 'Wirklich entfernen?' }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user