From d88cde06a0e4a47612041d3d8ae40d1360dca339 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 9 Jun 2026 12:48:57 +0200 Subject: [PATCH] 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 --- .../src/lib/geschichte/JourneyItemRow.svelte | 206 ++++++++++++++++++ .../geschichte/JourneyItemRow.svelte.spec.ts | 112 ++++++++++ 2 files changed, 318 insertions(+) create mode 100644 frontend/src/lib/geschichte/JourneyItemRow.svelte create mode 100644 frontend/src/lib/geschichte/JourneyItemRow.svelte.spec.ts diff --git a/frontend/src/lib/geschichte/JourneyItemRow.svelte b/frontend/src/lib/geschichte/JourneyItemRow.svelte new file mode 100644 index 00000000..dd7a838c --- /dev/null +++ b/frontend/src/lib/geschichte/JourneyItemRow.svelte @@ -0,0 +1,206 @@ + + +
+
+ + + + +
+ + +
+ + +
+ {#if isInterlude} + + {m.journey_add_interlude()} + + {:else} + {index + 1}. + {item.document!.title} + {/if} +
+ + +
+ {#if showRemoveConfirm} +
+ {m.journey_remove_confirm()} + + +
+ {:else} + + {/if} +
+
+ + + {#if showNote} +
+ +
+

{m.journey_note_save_hint()}

+ {#if !isInterlude} + + {/if} +
+ {#if noteError} + + {/if} +
+ {:else if !isInterlude} +
+ +
+ {/if} +
diff --git a/frontend/src/lib/geschichte/JourneyItemRow.svelte.spec.ts b/frontend/src/lib/geschichte/JourneyItemRow.svelte.spec.ts new file mode 100644 index 00000000..3123aae7 --- /dev/null +++ b/frontend/src/lib/geschichte/JourneyItemRow.svelte.spec.ts @@ -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(); + }); +});