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:
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user