fix(journey-editor): note UX, error codes, list semantics, a11y labels

- handleNotePatch routes failures through failureMessage() so a backend
  JOURNEY_NOTE_TOO_LONG renders its specific message in the row
- handleNoteBlur: '' vs undefined no-op guard (no spurious PATCH
  {note:null}), empty blur collapses the textarea, clearing an existing
  note collapses after the PATCH lands (spec LE-3)
- 'Notiz hinzufügen' toggle gets aria-expanded and moves focus into the
  revealed textarea
- journey_remove_item_aria interpolates the item title (de/en/es); dead
  journey_drag_aria_label key deleted from all locales
- editor item list is an <ol> (screen readers announce the ordering)
- editor title input gets maxlength=255 + border-danger error cue; intro
  textarea gets maxlength=4000
- Briefmeta line (date · von X an Y) under document titles in the editor
  row via the shared formatDocumentMetaLine (spec LE-2)
- new specs: successful save clears the unsaved warning; item add does
  not arm the guard; server-confirmed order after successful reorder;
  blur-without-typing no-op; focus hand-off into the note textarea

Review round 3: Sara (1-3), Felix, Elicit (LE-2/LE-3), Leonie.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-11 08:28:38 +02:00
parent b9bed19610
commit 8995b6e922
7 changed files with 237 additions and 79 deletions

View File

@@ -338,7 +338,9 @@ describe('JourneyEditor — remove with pending state', () => {
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByRole('button', { name: m.journey_remove_item_aria() }));
await userEvent.click(
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief A' }) })
);
// Row still present, marked as pending (text appears in the row AND the live region,
// so scope the query to the row instead of using a page-wide locator)
@@ -364,7 +366,9 @@ describe('JourneyEditor — remove with pending state', () => {
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
// Click remove (no note → direct remove)
await userEvent.click(page.getByRole('button', { name: m.journey_remove_item_aria() }));
await userEvent.click(
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief A' }) })
);
await expect.element(page.getByRole('alert')).toBeInTheDocument();
await expect.element(page.getByText('Brief A')).toBeInTheDocument();
@@ -376,7 +380,9 @@ describe('JourneyEditor — remove with pending state', () => {
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByRole('button', { name: m.journey_remove_item_aria() }));
await userEvent.click(
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief A' }) })
);
await expect.element(page.getByText('Brief A')).not.toBeInTheDocument();
});
@@ -387,7 +393,9 @@ describe('JourneyEditor — remove with pending state', () => {
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByRole('button', { name: m.journey_remove_item_aria() }));
await userEvent.click(
page.getByRole('button', { name: m.journey_remove_item_aria({ title: 'Brief A' }) })
);
await expect.element(page.getByText('Brief A')).not.toBeInTheDocument();
await vi.waitFor(() => {
@@ -460,6 +468,36 @@ describe('JourneyEditor — reorder via move buttons', () => {
});
});
it('renders the server-confirmed order after a successful reorder', async () => {
const items = [
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
{ id: 'i2', position: 1, document: docSummary('d2', 'Brief B') }
];
// Server response deliberately NOT pre-sorted — pins items = updated.sort(...)
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue([
{ id: 'i1', position: 20, document: docSummary('d1', 'Brief A') },
{ id: 'i2', position: 10, document: docSummary('d2', 'Brief B') }
])
})
);
render(JourneyEditor, defaultProps({ geschichte: makeGeschichte({ items }) }));
await userEvent.click(page.getByRole('button', { name: /Brief A.*nach unten verschieben/ }));
await vi.waitFor(() => {
const briefB = page.getByText('Brief B').element();
const briefA = page.getByText('Brief A').element();
expect(
briefB.compareDocumentPosition(briefA) & Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy();
});
});
it('restores the original DOM order and shows an alert on failed reorder (non-ok)', async () => {
const items = [
{ id: 'i1', position: 0, document: docSummary('d1', 'Brief A') },
@@ -630,7 +668,7 @@ describe('JourneyEditor — unsaved warning banner', () => {
it('banner is absent before any edit or navigation attempt', async () => {
render(JourneyEditor, defaultProps());
expect(document.querySelector('[class*="amber"]')).toBeNull();
expect(page.getByText(m.admin_unsaved_warning()).query()).toBeNull();
});
it('banner appears when dirty and a navigation is attempted', async () => {
@@ -673,9 +711,52 @@ describe('JourneyEditor — unsaved warning banner', () => {
// Banner must still be visible (isDirty was not cleared)
await vi.waitFor(() => {
expect(document.querySelector('[class*="amber"]')).toBeTruthy();
expect(page.getByText(m.admin_unsaved_warning()).query()).toBeTruthy();
});
});
it('successful save clears the unsaved warning (navigation unblocked after onSubmit resolves)', async () => {
// Regression guard for clearOnSuccess(): without it, a curator who edits the
// title and saves successfully stays trapped — the page goto() gets cancelled
// by the still-armed guard and the banner appears after a *successful* save.
const onSubmit = vi.fn().mockResolvedValue(undefined);
render(JourneyEditor, defaultProps({ onSubmit }));
// Mark dirty
const titleInput = page.getByPlaceholder(/Titel/);
await titleInput.element().dispatchEvent(new InputEvent('input', { bubbles: true }));
// Dirty state blocks navigation
expect(triggerNavigationAttempt()).toHaveBeenCalled();
// Save succeeds
await userEvent.click(page.getByRole('button', { name: m.geschichte_editor_save_draft() }));
await vi.waitFor(() => expect(onSubmit).toHaveBeenCalled());
// Guard is disarmed again — navigation passes and no banner shows
await vi.waitFor(() => {
expect(triggerNavigationAttempt()).not.toHaveBeenCalled();
});
expect(page.getByText(m.admin_unsaved_warning()).query()).toBeNull();
});
it('item add does not arm the unsaved-changes guard (items persist immediately)', async () => {
mockCsrfFetch(() => ({ id: 'i-new', position: 10, note: 'Zwischentext' }));
render(JourneyEditor, defaultProps());
await userEvent.click(page.getByText(m.journey_add_interlude()));
await userEvent.fill(page.getByPlaceholder(m.journey_interlude_placeholder()), 'Zwischentext');
await userEvent.click(
page.getByRole('button', { name: m.journey_add_interlude_confirm(), exact: true })
);
// The new interlude row renders its note textarea once the POST resolved
await expect
.element(page.getByRole('textbox', { name: /Kuratoren-Notiz/ }))
.toBeInTheDocument();
// The item was persisted by its own POST — navigating away loses nothing
expect(triggerNavigationAttempt()).not.toHaveBeenCalled();
});
});
describe('JourneyEditor — selectedPersons marks dirty', () => {