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:
197
frontend/src/lib/shared/hooks/useBlockDragDrop.svelte.test.ts
Normal file
197
frontend/src/lib/shared/hooks/useBlockDragDrop.svelte.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { describe, it, expect, vi, expectTypeOf } from 'vitest';
|
||||
import { createBlockDragDrop } from './useBlockDragDrop.svelte';
|
||||
import type { TranscriptionBlockData } from '$lib/shared/types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type-regression guard: createBlockDragDrop must accept any T extends {id: string}
|
||||
// so JourneyEditor can reuse it without importing TranscriptionBlockData.
|
||||
// This test fails with "Expected 0 type arguments, but got 1" via tsc --noEmit
|
||||
// until the function is made generic.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('createBlockDragDrop — generic type guard', () => {
|
||||
it('accepts items shaped as { id: string; position: number } — not only TranscriptionBlockData', () => {
|
||||
type SimpleItem = { id: string; position: number };
|
||||
const items: SimpleItem[] = [
|
||||
{ id: 'item-1', position: 0 },
|
||||
{ id: 'item-2', position: 1 }
|
||||
];
|
||||
const onReorder = vi.fn();
|
||||
const dd = createBlockDragDrop<SimpleItem>({ getSortedBlocks: () => items, onReorder });
|
||||
// Verify the hook is functional with the new type — state reads must work
|
||||
expect(dd.draggedBlockId).toBeNull();
|
||||
expect(dd.dragOffsetY).toBe(0);
|
||||
});
|
||||
|
||||
it('TranscriptionBlockData caller still compiles — regression guard for existing transcription editor', () => {
|
||||
// If the generic constraint is wrong this line fails tsc --noEmit
|
||||
expectTypeOf(createBlockDragDrop<TranscriptionBlockData>).toBeFunction();
|
||||
// Runtime assertion so browser-mode doesn't report "no assertions"
|
||||
expect(typeof createBlockDragDrop).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
function makeBlock(id: string, sortOrder: number): TranscriptionBlockData {
|
||||
return {
|
||||
id,
|
||||
annotationId: `ann-${id}`,
|
||||
documentId: 'doc-1',
|
||||
text: '',
|
||||
label: null,
|
||||
sortOrder,
|
||||
version: 1,
|
||||
source: 'MANUAL',
|
||||
reviewed: false,
|
||||
mentionedPersons: []
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a DOM list, mocks getBoundingClientRect (60px per wrapper),
|
||||
* drags `dragId` and drops it so dropTargetIdx === targetIdx, then
|
||||
* triggers handlePointerUp. Returns the onReorder spy.
|
||||
*/
|
||||
function simulateDragDrop(
|
||||
dragId: string,
|
||||
targetIdx: number,
|
||||
blocks: TranscriptionBlockData[]
|
||||
): ReturnType<typeof vi.fn> {
|
||||
const onReorder = vi.fn();
|
||||
const dd = createBlockDragDrop({ getSortedBlocks: () => blocks, onReorder });
|
||||
|
||||
// Build DOM
|
||||
const listEl = document.createElement('div');
|
||||
const wrappers = blocks.map(() => {
|
||||
const grip = document.createElement('div');
|
||||
grip.setAttribute('data-drag-handle', '');
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.setAttribute('data-block-wrapper', '');
|
||||
wrapper.appendChild(grip);
|
||||
listEl.appendChild(wrapper);
|
||||
return { grip, wrapper };
|
||||
});
|
||||
document.body.appendChild(listEl);
|
||||
dd.setListElement(listEl);
|
||||
|
||||
// Mock bounding rects: each wrapper is 60px tall starting at y=0
|
||||
wrappers.forEach(({ wrapper }, i) => {
|
||||
vi.spyOn(wrapper, 'getBoundingClientRect').mockReturnValue({
|
||||
top: i * 60,
|
||||
height: 60,
|
||||
bottom: (i + 1) * 60,
|
||||
left: 0,
|
||||
right: 100,
|
||||
width: 100,
|
||||
x: 0,
|
||||
y: i * 60,
|
||||
toJSON: () => ({})
|
||||
} as DOMRect);
|
||||
});
|
||||
|
||||
const dragIdx = blocks.findIndex((b) => b.id === dragId);
|
||||
const { grip, wrapper: dragWrapper } = wrappers[dragIdx];
|
||||
dragWrapper.setPointerCapture = vi.fn();
|
||||
|
||||
// Start drag
|
||||
const downEvent = new PointerEvent('pointerdown', { clientY: dragIdx * 60, cancelable: true });
|
||||
Object.defineProperty(downEvent, 'target', { value: grip });
|
||||
dd.handleGripDown(downEvent as PointerEvent, dragId);
|
||||
|
||||
// Move pointer to achieve the desired targetIdx
|
||||
// midpoint of wrapper[i] = i*60 + 30
|
||||
// clientY just before midpoint[i] → target = i
|
||||
// clientY past last midpoint → target = wrappers.length
|
||||
let clientY: number;
|
||||
if (targetIdx <= 0) {
|
||||
clientY = 5; // before first midpoint (30)
|
||||
} else if (targetIdx >= wrappers.length) {
|
||||
clientY = wrappers.length * 60 + 10; // past all midpoints
|
||||
} else {
|
||||
clientY = targetIdx * 60 + 5; // just past top of wrapper[targetIdx], before its midpoint
|
||||
}
|
||||
|
||||
const moveEvent = new PointerEvent('pointermove', { clientY });
|
||||
dd.handlePointerMove(moveEvent as PointerEvent);
|
||||
dd.handlePointerUp();
|
||||
|
||||
document.body.removeChild(listEl);
|
||||
return onReorder;
|
||||
}
|
||||
|
||||
describe('createBlockDragDrop', () => {
|
||||
it('initial state — no drag in progress', () => {
|
||||
const dd = createBlockDragDrop({ getSortedBlocks: () => [], onReorder: vi.fn() });
|
||||
expect(dd.draggedBlockId).toBeNull();
|
||||
expect(dd.dropTargetIdx).toBeNull();
|
||||
expect(dd.dragOffsetY).toBe(0);
|
||||
});
|
||||
|
||||
it('handleGripDown sets draggedBlockId when grip is hit', () => {
|
||||
const dd = createBlockDragDrop({ getSortedBlocks: () => [], onReorder: vi.fn() });
|
||||
const grip = document.createElement('div');
|
||||
grip.setAttribute('data-drag-handle', '');
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.setAttribute('data-block-wrapper', '');
|
||||
wrapper.appendChild(grip);
|
||||
document.body.appendChild(wrapper);
|
||||
|
||||
const e = new PointerEvent('pointerdown', { clientY: 100, cancelable: true, bubbles: true });
|
||||
Object.defineProperty(e, 'target', { value: grip });
|
||||
wrapper.setPointerCapture = vi.fn();
|
||||
|
||||
dd.handleGripDown(e as PointerEvent, 'block-1');
|
||||
expect(dd.draggedBlockId).toBe('block-1');
|
||||
|
||||
document.body.removeChild(wrapper);
|
||||
});
|
||||
|
||||
it('handlePointerUp without active drag is a no-op', () => {
|
||||
const onReorder = vi.fn();
|
||||
const dd = createBlockDragDrop({ getSortedBlocks: () => [], onReorder });
|
||||
dd.handlePointerUp();
|
||||
expect(onReorder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handlePointerUp with null dropTargetIdx does not call onReorder', () => {
|
||||
const onReorder = vi.fn();
|
||||
const blocks = [makeBlock('b1', 0), makeBlock('b2', 1)];
|
||||
const dd = createBlockDragDrop({ getSortedBlocks: () => blocks, onReorder });
|
||||
|
||||
const grip = document.createElement('div');
|
||||
grip.setAttribute('data-drag-handle', '');
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.setAttribute('data-block-wrapper', '');
|
||||
wrapper.appendChild(grip);
|
||||
document.body.appendChild(wrapper);
|
||||
wrapper.setPointerCapture = vi.fn();
|
||||
|
||||
const downEvent = new PointerEvent('pointerdown', { clientY: 50, cancelable: true });
|
||||
Object.defineProperty(downEvent, 'target', { value: grip });
|
||||
dd.handleGripDown(downEvent as PointerEvent, 'b1');
|
||||
|
||||
// dropTargetIdx is still null (no pointer move happened)
|
||||
dd.handlePointerUp();
|
||||
expect(onReorder).not.toHaveBeenCalled();
|
||||
expect(dd.draggedBlockId).toBeNull();
|
||||
|
||||
document.body.removeChild(wrapper);
|
||||
});
|
||||
|
||||
it('reorder: moves block from index 0 to end', () => {
|
||||
const blocks = [makeBlock('b1', 0), makeBlock('b2', 1), makeBlock('b3', 2)];
|
||||
const onReorder = simulateDragDrop('b1', 3, blocks);
|
||||
expect(onReorder).toHaveBeenCalledWith(['b2', 'b3', 'b1']);
|
||||
});
|
||||
|
||||
it('reorder: moves block from end to index 0', () => {
|
||||
const blocks = [makeBlock('b1', 0), makeBlock('b2', 1), makeBlock('b3', 2)];
|
||||
const onReorder = simulateDragDrop('b3', 0, blocks);
|
||||
expect(onReorder).toHaveBeenCalledWith(['b3', 'b1', 'b2']);
|
||||
});
|
||||
|
||||
it('reorder: moves block down by one position (tests insertAt = dropTargetIdx - 1)', () => {
|
||||
const blocks = [makeBlock('b1', 0), makeBlock('b2', 1), makeBlock('b3', 2)];
|
||||
// dragId=b1 (idx=0), targetIdx=2 → insertAt = 2-1 = 1 → [b2, b1, b3]
|
||||
const onReorder = simulateDragDrop('b1', 2, blocks);
|
||||
expect(onReorder).toHaveBeenCalledWith(['b2', 'b1', 'b3']);
|
||||
});
|
||||
});
|
||||
89
frontend/src/lib/shared/hooks/useBlockDragDrop.svelte.ts
Normal file
89
frontend/src/lib/shared/hooks/useBlockDragDrop.svelte.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
type Options<T extends { id: string }> = {
|
||||
getSortedBlocks: () => T[];
|
||||
onReorder: (blockIds: string[]) => void;
|
||||
};
|
||||
|
||||
export function createBlockDragDrop<T extends { id: string }>({
|
||||
getSortedBlocks,
|
||||
onReorder
|
||||
}: Options<T>) {
|
||||
let draggedBlockId = $state<string | null>(null);
|
||||
let dropTargetIdx = $state<number | null>(null);
|
||||
let dragOffsetY = $state(0);
|
||||
|
||||
// Internal mutable refs — not reactive
|
||||
let dragStartY = 0;
|
||||
let capturedEl: HTMLElement | null = null;
|
||||
let listEl: HTMLElement | null = null;
|
||||
|
||||
function setListElement(el: HTMLElement | null): void {
|
||||
listEl = el;
|
||||
}
|
||||
|
||||
function handleGripDown(e: PointerEvent, blockId: string): void {
|
||||
if (!(e.target as HTMLElement).closest('[data-drag-handle]')) return;
|
||||
e.preventDefault();
|
||||
draggedBlockId = blockId;
|
||||
dragStartY = e.clientY;
|
||||
dragOffsetY = 0;
|
||||
capturedEl = (e.target as HTMLElement).closest('[data-block-wrapper]') as HTMLElement;
|
||||
capturedEl?.setPointerCapture(e.pointerId);
|
||||
}
|
||||
|
||||
function handlePointerMove(e: PointerEvent): void {
|
||||
if (!draggedBlockId || !listEl) return;
|
||||
dragOffsetY = e.clientY - dragStartY;
|
||||
|
||||
const sortedBlocks = getSortedBlocks();
|
||||
const wrappers = Array.from(listEl.querySelectorAll('[data-block-wrapper]'));
|
||||
const dragIdx = sortedBlocks.findIndex((b) => b.id === draggedBlockId);
|
||||
let target: number | null = null;
|
||||
|
||||
for (let i = 0; i < wrappers.length; i++) {
|
||||
const rect = wrappers[i].getBoundingClientRect();
|
||||
if (e.clientY < rect.top + rect.height / 2) {
|
||||
target = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (target === null) target = wrappers.length;
|
||||
if (target === dragIdx || target === dragIdx + 1) target = null;
|
||||
dropTargetIdx = target;
|
||||
}
|
||||
|
||||
function handlePointerUp(): void {
|
||||
if (!draggedBlockId) return;
|
||||
|
||||
if (dropTargetIdx !== null) {
|
||||
const sorted = [...getSortedBlocks()];
|
||||
const fromIdx = sorted.findIndex((b) => b.id === draggedBlockId);
|
||||
if (fromIdx >= 0) {
|
||||
const [moved] = sorted.splice(fromIdx, 1);
|
||||
const insertAt = dropTargetIdx > fromIdx ? dropTargetIdx - 1 : dropTargetIdx;
|
||||
sorted.splice(insertAt, 0, moved);
|
||||
onReorder(sorted.map((b) => b.id));
|
||||
}
|
||||
}
|
||||
|
||||
draggedBlockId = null;
|
||||
dropTargetIdx = null;
|
||||
dragOffsetY = 0;
|
||||
capturedEl = null;
|
||||
}
|
||||
|
||||
return {
|
||||
get draggedBlockId() {
|
||||
return draggedBlockId;
|
||||
},
|
||||
get dropTargetIdx() {
|
||||
return dropTargetIdx;
|
||||
},
|
||||
get dragOffsetY() {
|
||||
return dragOffsetY;
|
||||
},
|
||||
setListElement,
|
||||
handleGripDown,
|
||||
handlePointerMove,
|
||||
handlePointerUp
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user