fix(journey): editor review round — labels, errors, pending state, a11y, tests
Addresses the remaining #792 review blockers and concerns in the journey editor cluster: - Interlude rows show 'Zwischentext' (dedicated key), not the add-button text - All four mutation handlers route the backend ErrorCode through getErrorMessage (a 409 duplicate no longer says 'bitte Seite neu laden') and console.error their failures so client-side errors leave a trace - Remove implements the spec'd pending state: row stays dimmed with an aria-live 'wird entfernt…' until the DELETE resolves; failure keeps the row - Move announcements fire after the reorder resolves (no false 'verschoben') - Touch targets ≥44px (remove ×, note links, create submit); focus moves to the new row after add, to a sensible neighbor after remove, back to × on confirm-cancel; drag handle is pointer-only; title/intro get aria-labels; publish-disabled reason is a visible hint, not a title tooltip - Amber warning styles use new --color-warning-* tokens with dark remaps - Blocked interlude-clear restores the draft instead of showing phantom text - useBlockDragDrop moves to $lib/shared/hooks — geschichte no longer imports another domain's internals - Test hardening: reorder-failure rollback (non-ok + reject), publish/ unpublish/empty-warning surface, destructive confirm path, maxlength assertions, JourneyCreate failure path, edit-page STORY/JOURNEY branch, fixture factory, m.* assertions, all fixed sleeps replaced with polling 67 component tests green across 6 spec files; transcription consumer of the moved hook re-verified (30 green). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { csrfFetch } from '$lib/shared/cookies';
|
||||
import { createBlockDragDrop } from '$lib/document/transcription/useBlockDragDrop.svelte';
|
||||
import { getErrorMessage } from '$lib/shared/errors';
|
||||
import { createBlockDragDrop } from '$lib/shared/hooks/useBlockDragDrop.svelte';
|
||||
import { toPersonOption, type PersonOption } from '$lib/person/personOption';
|
||||
import { createUnsavedWarning } from '$lib/shared/hooks/useUnsavedWarning.svelte';
|
||||
import type { DocumentOption } from '$lib/document/documentTypeahead';
|
||||
@@ -40,6 +42,7 @@ let items: JourneyItemView[] = $state(
|
||||
|
||||
let titleTouched = $state(false);
|
||||
let mutationError = $state('');
|
||||
let pendingRemoveIds: string[] = $state([]);
|
||||
let liveAnnounce = $state('');
|
||||
let announceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
@@ -61,6 +64,7 @@ const canPublish = $derived(items.length > 0 && !titleEmpty);
|
||||
const showPublishedEmptyWarning = $derived(status === 'PUBLISHED' && items.length === 0);
|
||||
|
||||
let listEl: HTMLElement | null = $state(null);
|
||||
let editorColEl: HTMLElement | null = $state(null);
|
||||
|
||||
const dragDrop = createBlockDragDrop<JourneyItemView>({
|
||||
getSortedBlocks: () => items,
|
||||
@@ -71,6 +75,18 @@ $effect(() => {
|
||||
dragDrop.setListElement(listEl);
|
||||
});
|
||||
|
||||
/** Maps a failed mutation response to a user-facing message via its backend error code. */
|
||||
async function failureMessage(res: Response): Promise<string> {
|
||||
const code = (await res.json().catch(() => ({})))?.code;
|
||||
return code ? getErrorMessage(code) : m.journey_mutation_error_reload();
|
||||
}
|
||||
|
||||
/** Moves keyboard focus to a control inside the row of the given item. */
|
||||
async function focusRowControl(itemId: string, selector: string) {
|
||||
await tick();
|
||||
editorColEl?.querySelector<HTMLElement>(`[data-block-id="${itemId}"] ${selector}`)?.focus();
|
||||
}
|
||||
|
||||
async function handleReorder(itemIds: string[]) {
|
||||
const prev = [...items];
|
||||
items = itemIds.map((id) => items.find((i) => i.id === id)!);
|
||||
@@ -81,17 +97,21 @@ async function handleReorder(itemIds: string[]) {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ itemIds })
|
||||
});
|
||||
if (!res.ok) throw new Error('reorder failed');
|
||||
if (!res.ok) {
|
||||
items = prev;
|
||||
mutationError = await failureMessage(res);
|
||||
return;
|
||||
}
|
||||
const updated: JourneyItemView[] = await res.json();
|
||||
items = updated.sort((a, b) => a.position - b.position);
|
||||
} catch {
|
||||
} catch (e) {
|
||||
console.error('Journey reorder failed', e);
|
||||
items = prev;
|
||||
mutationError = m.journey_mutation_error_reload();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddDocument(doc: DocumentOption) {
|
||||
const prev = [...items];
|
||||
mutationError = '';
|
||||
try {
|
||||
const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items`, {
|
||||
@@ -99,17 +119,21 @@ async function handleAddDocument(doc: DocumentOption) {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ documentId: doc.id })
|
||||
});
|
||||
if (!res.ok) throw new Error('add document failed');
|
||||
if (!res.ok) {
|
||||
mutationError = await failureMessage(res);
|
||||
return;
|
||||
}
|
||||
const newItem: JourneyItemView = await res.json();
|
||||
items = [...items, newItem];
|
||||
} catch {
|
||||
items = prev; // prev === items here (add is pessimistic); kept for symmetry with optimistic handlers
|
||||
// Move-up is disabled on the first row — fall back to the remove button then.
|
||||
await focusRowControl(newItem.id, '[data-move-up]:not([disabled]), [data-remove-btn]');
|
||||
} catch (e) {
|
||||
console.error('Journey add document failed', e);
|
||||
mutationError = m.journey_mutation_error_reload();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddInterlude(text: string) {
|
||||
const prev = [...items];
|
||||
mutationError = '';
|
||||
try {
|
||||
const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items`, {
|
||||
@@ -117,27 +141,46 @@ async function handleAddInterlude(text: string) {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ note: text })
|
||||
});
|
||||
if (!res.ok) throw new Error('add interlude failed');
|
||||
if (!res.ok) {
|
||||
mutationError = await failureMessage(res);
|
||||
return;
|
||||
}
|
||||
const newItem: JourneyItemView = await res.json();
|
||||
items = [...items, newItem];
|
||||
} catch {
|
||||
items = prev; // prev === items here (add is pessimistic); kept for symmetry with optimistic handlers
|
||||
// Move-up is disabled on the first row — fall back to the remove button then.
|
||||
await focusRowControl(newItem.id, '[data-move-up]:not([disabled]), [data-remove-btn]');
|
||||
} catch (e) {
|
||||
console.error('Journey add interlude failed', e);
|
||||
mutationError = m.journey_mutation_error_reload();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(itemId: string) {
|
||||
const prev = [...items];
|
||||
items = items.filter((i) => i.id !== itemId);
|
||||
const idx = items.findIndex((i) => i.id === itemId);
|
||||
mutationError = '';
|
||||
pendingRemoveIds = [...pendingRemoveIds, itemId];
|
||||
liveAnnounce = m.journey_item_pending_remove();
|
||||
scheduleAnnounceReset();
|
||||
try {
|
||||
const res = await csrfFetch(`/api/geschichten/${geschichte.id}/items/${itemId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!res.ok) throw new Error('delete failed');
|
||||
} catch {
|
||||
items = prev;
|
||||
if (!res.ok) {
|
||||
mutationError = await failureMessage(res);
|
||||
return;
|
||||
}
|
||||
items = items.filter((i) => i.id !== itemId);
|
||||
await tick();
|
||||
if (items.length === 0 || idx <= 0) {
|
||||
editorColEl?.querySelector<HTMLElement>('[data-add-document]')?.focus();
|
||||
} else {
|
||||
await focusRowControl(items[idx - 1].id, '[data-remove-btn]');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Journey item remove failed', e);
|
||||
mutationError = m.journey_mutation_error_reload();
|
||||
} finally {
|
||||
pendingRemoveIds = pendingRemoveIds.filter((id) => id !== itemId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,27 +197,27 @@ async function handleNotePatch(itemId: string, note: string | null) {
|
||||
|
||||
async function handleMoveUp(index: number) {
|
||||
if (index === 0) return;
|
||||
const total = items.length;
|
||||
const ids = items.map((i) => i.id);
|
||||
[ids[index - 1], ids[index]] = [ids[index], ids[index - 1]];
|
||||
liveAnnounce = m.journey_item_moved({
|
||||
position: index + 1,
|
||||
total: items.length,
|
||||
newPosition: index
|
||||
});
|
||||
await handleReorder(ids);
|
||||
// Announce only after the server confirmed (or rejected) the reorder —
|
||||
// announcing beforehand would claim success for a move that rolled back.
|
||||
liveAnnounce = mutationError
|
||||
? mutationError
|
||||
: m.journey_item_moved({ position: index + 1, total, newPosition: index });
|
||||
scheduleAnnounceReset();
|
||||
}
|
||||
|
||||
async function handleMoveDown(index: number) {
|
||||
if (index === items.length - 1) return;
|
||||
const total = items.length;
|
||||
const ids = items.map((i) => i.id);
|
||||
[ids[index], ids[index + 1]] = [ids[index + 1], ids[index]];
|
||||
liveAnnounce = m.journey_item_moved({
|
||||
position: index + 1,
|
||||
total: items.length,
|
||||
newPosition: index + 2
|
||||
});
|
||||
await handleReorder(ids);
|
||||
liveAnnounce = mutationError
|
||||
? mutationError
|
||||
: m.journey_item_moved({ position: index + 1, total, newPosition: index + 2 });
|
||||
scheduleAnnounceReset();
|
||||
}
|
||||
|
||||
@@ -196,7 +239,7 @@ async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[2fr_1fr]">
|
||||
<!-- Editor column -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<div bind:this={editorColEl} class="flex flex-col gap-4">
|
||||
<!-- Title -->
|
||||
<div>
|
||||
<input
|
||||
@@ -205,6 +248,7 @@ async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
|
||||
oninput={() => unsaved.markDirty()}
|
||||
onblur={() => (titleTouched = true)}
|
||||
placeholder={m.geschichte_editor_title_placeholder()}
|
||||
aria-label={m.journey_title_aria_label()}
|
||||
aria-invalid={showTitleError}
|
||||
aria-describedby={showTitleError ? 'journey-title-error' : undefined}
|
||||
class="block w-full rounded border border-line bg-surface px-3 py-3 font-serif text-2xl font-bold text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
@@ -222,6 +266,7 @@ async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
|
||||
bind:value={body}
|
||||
oninput={() => unsaved.markDirty()}
|
||||
placeholder={m.journey_intro_placeholder()}
|
||||
aria-label={m.journey_intro_aria_label()}
|
||||
rows={3}
|
||||
class="block w-full resize-y rounded border border-line bg-surface px-3 py-2 font-serif text-base text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
></textarea>
|
||||
@@ -231,7 +276,7 @@ async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
|
||||
<!-- Item list -->
|
||||
{#if showPublishedEmptyWarning}
|
||||
<p
|
||||
class="rounded border border-amber-300 bg-amber-50 px-3 py-2 font-sans text-sm text-amber-800"
|
||||
class="rounded border border-[var(--color-warning-border)] bg-[var(--color-warning-bg)] px-3 py-2 font-sans text-sm text-[var(--color-warning-text)]"
|
||||
>
|
||||
{m.journey_published_empty_warning()}
|
||||
</p>
|
||||
@@ -275,6 +320,7 @@ async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
|
||||
item={item}
|
||||
index={i}
|
||||
total={items.length}
|
||||
pendingRemove={pendingRemoveIds.includes(item.id)}
|
||||
onMoveUp={() => handleMoveUp(i)}
|
||||
onMoveDown={() => handleMoveDown(i)}
|
||||
onRemove={() => handleRemove(item.id)}
|
||||
@@ -302,42 +348,47 @@ async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
|
||||
<p class="font-sans text-xs text-ink-3">
|
||||
{isDraft ? m.geschichte_editor_save_hint_draft() : m.journey_save_hint_published()}
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
{#if isDraft}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => save('DRAFT')}
|
||||
disabled={submitting || titleEmpty}
|
||||
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{m.geschichte_editor_save_draft()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => save('PUBLISHED')}
|
||||
disabled={submitting || !canPublish}
|
||||
title={canPublish ? undefined : m.journey_publish_disabled_title()}
|
||||
class="inline-flex h-11 items-center rounded bg-primary px-4 font-sans text-sm font-medium text-primary-fg hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{m.geschichte_editor_publish()}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => save('DRAFT')}
|
||||
disabled={submitting}
|
||||
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-amber-700 hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{m.geschichte_editor_unpublish()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => save('PUBLISHED')}
|
||||
disabled={submitting || titleEmpty}
|
||||
class="inline-flex h-11 items-center rounded bg-primary px-4 font-sans text-sm font-medium text-primary-fg hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{m.geschichte_editor_save()}
|
||||
</button>
|
||||
<div class="flex flex-col items-start gap-1 sm:items-end">
|
||||
<div class="flex gap-2">
|
||||
{#if isDraft}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => save('DRAFT')}
|
||||
disabled={submitting || titleEmpty}
|
||||
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{m.geschichte_editor_save_draft()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => save('PUBLISHED')}
|
||||
disabled={submitting || !canPublish}
|
||||
title={canPublish ? undefined : m.journey_publish_disabled_title()}
|
||||
class="inline-flex h-11 items-center rounded bg-primary px-4 font-sans text-sm font-medium text-primary-fg hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{m.geschichte_editor_publish()}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => save('DRAFT')}
|
||||
disabled={submitting}
|
||||
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-[var(--color-warning-text)] hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{m.geschichte_editor_unpublish()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => save('PUBLISHED')}
|
||||
disabled={submitting || titleEmpty}
|
||||
class="inline-flex h-11 items-center rounded bg-primary px-4 font-sans text-sm font-medium text-primary-fg hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{m.geschichte_editor_save()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if isDraft && !canPublish}
|
||||
<p class="font-sans text-xs text-ink-3">{m.journey_publish_disabled_hint()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user