@semiont/react-ui 0.2.36 → 0.2.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +8 -0
- package/dist/index.mjs +252 -166
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/components/CodeMirrorRenderer.tsx +71 -203
- package/src/components/__tests__/AnnotateReferencesProgressWidget.test.tsx +142 -0
- package/src/components/__tests__/LiveRegion.hooks.test.tsx +79 -0
- package/src/components/__tests__/ResizeHandle.test.tsx +165 -0
- package/src/components/__tests__/SessionExpiryBanner.test.tsx +123 -0
- package/src/components/__tests__/StatusDisplay.test.tsx +160 -0
- package/src/components/__tests__/Toolbar.test.tsx +110 -0
- package/src/components/annotation-popups/__tests__/JsonLdView.test.tsx +285 -0
- package/src/components/annotation-popups/__tests__/SharedPopupElements.test.tsx +273 -0
- package/src/components/modals/__tests__/KeyboardShortcutsHelpModal.test.tsx +90 -0
- package/src/components/modals/__tests__/ProposeEntitiesModal.test.tsx +129 -0
- package/src/components/modals/__tests__/ResourceSearchModal.test.tsx +180 -0
- package/src/components/navigation/__tests__/ObservableLink.test.tsx +90 -0
- package/src/components/navigation/__tests__/SimpleNavigation.test.tsx +169 -0
- package/src/components/navigation/__tests__/SortableResourceTab.test.tsx +371 -0
- package/src/components/resource/AnnotateView.tsx +27 -153
- package/src/components/resource/__tests__/AnnotationHistory.test.tsx +349 -0
- package/src/components/resource/__tests__/HistoryEvent.test.tsx +492 -0
- package/src/components/resource/__tests__/event-formatting.test.ts +273 -0
- package/src/components/resource/panels/__tests__/AssessmentEntry.test.tsx +226 -0
- package/src/components/resource/panels/__tests__/HighlightEntry.test.tsx +188 -0
- package/src/components/resource/panels/__tests__/PanelHeader.test.tsx +69 -0
- package/src/components/resource/panels/__tests__/ReferenceEntry.test.tsx +445 -0
- package/src/components/resource/panels/__tests__/StatisticsPanel.test.tsx +271 -0
- package/src/components/resource/panels/__tests__/TagEntry.test.tsx +210 -0
- package/src/components/settings/__tests__/SettingsPanel.test.tsx +190 -0
- package/src/components/viewers/__tests__/ImageViewer.test.tsx +63 -0
- package/src/integrations/__tests__/css-modules-helper.test.tsx +225 -0
- package/src/integrations/__tests__/styled-components-theme.test.ts +179 -0
package/package.json
CHANGED
|
@@ -4,29 +4,33 @@ import { useEffect, useRef } from 'react';
|
|
|
4
4
|
import { EditorView, Decoration, DecorationSet, lineNumbers } from '@codemirror/view';
|
|
5
5
|
import { EditorState, RangeSetBuilder, StateField, StateEffect, Compartment } from '@codemirror/state';
|
|
6
6
|
import { markdown } from '@codemirror/lang-markdown';
|
|
7
|
-
import { ANNOTATORS } from '../lib/annotation-registry';
|
|
8
7
|
import { ReferenceResolutionWidget, showWidgetPreview, hideWidgetPreview } from '../lib/codemirror-widgets';
|
|
9
8
|
import { scrollAnnotationIntoView } from '../lib/scroll-utils';
|
|
10
|
-
import {
|
|
11
|
-
import type { components } from '@semiont/core';
|
|
9
|
+
import { isReference } from '@semiont/api-client';
|
|
12
10
|
import type { EventBus } from "@semiont/core";
|
|
13
11
|
import { createHoverHandlers } from '../hooks/useBeckonFlow';
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
import {
|
|
13
|
+
convertSegmentPositions,
|
|
14
|
+
computeAnnotationDecorations,
|
|
15
|
+
computeWidgetDecorations,
|
|
16
|
+
} from '../lib/codemirror-logic';
|
|
17
|
+
import type { TextSegment } from '../lib/codemirror-logic';
|
|
18
|
+
import {
|
|
19
|
+
handleAnnotationClick,
|
|
20
|
+
handleWidgetClick as processWidgetClick,
|
|
21
|
+
dispatchWidgetClick,
|
|
22
|
+
handleWidgetMouseEnter as processWidgetMouseEnter,
|
|
23
|
+
handleWidgetMouseLeave as processWidgetMouseLeave,
|
|
24
|
+
} from '../lib/codemirror-handlers';
|
|
25
|
+
|
|
26
|
+
// Re-export TextSegment for consumers
|
|
27
|
+
export type { TextSegment } from '../lib/codemirror-logic';
|
|
16
28
|
|
|
17
29
|
// Type augmentation for custom DOM properties used to store CodeMirror state
|
|
18
30
|
interface EnrichedHTMLElement extends HTMLElement {
|
|
19
|
-
__lastHoveredAnnotation?: string | null;
|
|
20
31
|
__cmView?: EditorView;
|
|
21
32
|
}
|
|
22
33
|
|
|
23
|
-
export interface TextSegment {
|
|
24
|
-
exact: string;
|
|
25
|
-
annotation?: Annotation;
|
|
26
|
-
start: number;
|
|
27
|
-
end: number;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
34
|
interface Props {
|
|
31
35
|
content: string;
|
|
32
36
|
segments?: TextSegment[]; // Optional - only needed for annotation rendering
|
|
@@ -63,118 +67,24 @@ interface WidgetUpdate {
|
|
|
63
67
|
|
|
64
68
|
const updateWidgetsEffect = StateEffect.define<WidgetUpdate>();
|
|
65
69
|
|
|
66
|
-
|
|
67
|
-
* Convert positions from CRLF character space to LF character space.
|
|
68
|
-
* CodeMirror normalizes all line endings to LF internally, but annotation positions
|
|
69
|
-
* are calculated in the original content's character space (which may have CRLF).
|
|
70
|
-
*
|
|
71
|
-
* @param segments - Segments with positions in CRLF space
|
|
72
|
-
* @param content - Original content (may have CRLF line endings)
|
|
73
|
-
* @returns Segments with positions adjusted for LF space
|
|
74
|
-
*/
|
|
75
|
-
function convertSegmentPositions(segments: TextSegment[], content: string): TextSegment[] {
|
|
76
|
-
// If content has no CRLF, no conversion needed
|
|
77
|
-
if (!content.includes('\r\n')) {
|
|
78
|
-
return segments;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// Build a map of CRLF positions for efficient lookup
|
|
82
|
-
const crlfPositions: number[] = [];
|
|
83
|
-
for (let i = 0; i < content.length - 1; i++) {
|
|
84
|
-
if (content[i] === '\r' && content[i + 1] === '\n') {
|
|
85
|
-
crlfPositions.push(i);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
// Binary search: count CRLFs before a position in O(log n)
|
|
90
|
-
const convertPosition = (pos: number): number => {
|
|
91
|
-
let lo = 0;
|
|
92
|
-
let hi = crlfPositions.length;
|
|
93
|
-
while (lo < hi) {
|
|
94
|
-
const mid = (lo + hi) >>> 1;
|
|
95
|
-
if (crlfPositions[mid]! < pos) lo = mid + 1;
|
|
96
|
-
else hi = mid;
|
|
97
|
-
}
|
|
98
|
-
return pos - lo;
|
|
99
|
-
};
|
|
100
|
-
|
|
101
|
-
return segments.map(seg => ({
|
|
102
|
-
...seg,
|
|
103
|
-
start: convertPosition(seg.start),
|
|
104
|
-
end: convertPosition(seg.end)
|
|
105
|
-
}));
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Get tooltip text for annotation based on type/motivation
|
|
110
|
-
*/
|
|
111
|
-
function getAnnotationTooltip(annotation: Annotation): string {
|
|
112
|
-
const isCommentAnn = isComment(annotation);
|
|
113
|
-
const isHighlightAnn = isHighlight(annotation);
|
|
114
|
-
const isAssessmentAnn = isAssessment(annotation);
|
|
115
|
-
const isTagAnn = isTag(annotation);
|
|
116
|
-
const isReferenceAnn = isReference(annotation);
|
|
117
|
-
const isResolvedRef = isResolvedReference(annotation);
|
|
118
|
-
|
|
119
|
-
if (isCommentAnn) {
|
|
120
|
-
return 'Comment';
|
|
121
|
-
} else if (isHighlightAnn) {
|
|
122
|
-
return 'Highlight';
|
|
123
|
-
} else if (isAssessmentAnn) {
|
|
124
|
-
return 'Assessment';
|
|
125
|
-
} else if (isTagAnn) {
|
|
126
|
-
return 'Tag';
|
|
127
|
-
} else if (isResolvedRef) {
|
|
128
|
-
return 'Resolved Reference';
|
|
129
|
-
} else if (isReferenceAnn) {
|
|
130
|
-
return 'Unresolved Reference';
|
|
131
|
-
}
|
|
132
|
-
return 'Annotation';
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// Build decorations from segments
|
|
70
|
+
// Build CodeMirror decorations from pure metadata
|
|
136
71
|
function buildAnnotationDecorations(
|
|
137
72
|
segments: TextSegment[],
|
|
138
73
|
newAnnotationIds?: Set<string>
|
|
139
74
|
): DecorationSet {
|
|
140
75
|
const builder = new RangeSetBuilder<Decoration>();
|
|
76
|
+
const entries = computeAnnotationDecorations(segments, newAnnotationIds);
|
|
141
77
|
|
|
142
|
-
const
|
|
143
|
-
.filter(s => s.annotation)
|
|
144
|
-
.sort((a, b) => a.start - b.start);
|
|
145
|
-
|
|
146
|
-
for (const segment of annotatedSegments) {
|
|
147
|
-
if (!segment.annotation) continue;
|
|
148
|
-
|
|
149
|
-
const isNew = newAnnotationIds?.has(segment.annotation.id) || false;
|
|
150
|
-
const baseClassName = Object.values(ANNOTATORS).find(a => a.matchesAnnotation(segment.annotation!))?.className || 'annotation-highlight';
|
|
151
|
-
const className = isNew ? `${baseClassName} annotation-sparkle` : baseClassName;
|
|
152
|
-
|
|
153
|
-
// Use W3C helpers to determine annotation type
|
|
154
|
-
const isHighlightAnn = isHighlight(segment.annotation);
|
|
155
|
-
const isReferenceAnn = isReference(segment.annotation);
|
|
156
|
-
const isCommentAnn = isComment(segment.annotation);
|
|
157
|
-
const isAssessmentAnn = isAssessment(segment.annotation);
|
|
158
|
-
const isTagAnn = isTag(segment.annotation);
|
|
159
|
-
|
|
160
|
-
// Determine annotation type for data attribute - use motivation directly
|
|
161
|
-
let annotationType = 'highlight'; // default
|
|
162
|
-
if (isCommentAnn) annotationType = 'comment';
|
|
163
|
-
else if (isReferenceAnn) annotationType = 'reference';
|
|
164
|
-
else if (isAssessmentAnn) annotationType = 'assessment';
|
|
165
|
-
else if (isTagAnn) annotationType = 'tag';
|
|
166
|
-
else if (isHighlightAnn) annotationType = 'highlight';
|
|
167
|
-
|
|
78
|
+
for (const { start, end, meta } of entries) {
|
|
168
79
|
const decoration = Decoration.mark({
|
|
169
|
-
class: className,
|
|
80
|
+
class: meta.className,
|
|
170
81
|
attributes: {
|
|
171
|
-
'data-annotation-id':
|
|
172
|
-
'data-annotation-type': annotationType,
|
|
173
|
-
title:
|
|
174
|
-
}
|
|
82
|
+
'data-annotation-id': meta.annotationId,
|
|
83
|
+
'data-annotation-type': meta.annotationType,
|
|
84
|
+
title: meta.tooltip,
|
|
85
|
+
},
|
|
175
86
|
});
|
|
176
|
-
|
|
177
|
-
builder.add(segment.start, segment.end, decoration);
|
|
87
|
+
builder.add(start, end, decoration);
|
|
178
88
|
}
|
|
179
89
|
|
|
180
90
|
return builder.finish();
|
|
@@ -201,7 +111,7 @@ function createAnnotationDecorationsField() {
|
|
|
201
111
|
});
|
|
202
112
|
}
|
|
203
113
|
|
|
204
|
-
// Build widget decorations
|
|
114
|
+
// Build widget decorations using pure metadata
|
|
205
115
|
function buildWidgetDecorations(
|
|
206
116
|
_content: string,
|
|
207
117
|
segments: TextSegment[],
|
|
@@ -209,39 +119,26 @@ function buildWidgetDecorations(
|
|
|
209
119
|
getTargetDocumentName?: (documentId: string) => string | undefined
|
|
210
120
|
): DecorationSet {
|
|
211
121
|
const builder = new RangeSetBuilder<Decoration>();
|
|
122
|
+
const widgetMetas = computeWidgetDecorations(segments, generatingReferenceId, getTargetDocumentName);
|
|
212
123
|
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
for (const segment of allAnnotatedSegments) {
|
|
220
|
-
if (!segment.annotation) continue;
|
|
221
|
-
|
|
222
|
-
const annotation = segment.annotation;
|
|
223
|
-
|
|
224
|
-
// For references: add resolution widget (🔗, ✨ pulsing, or ❓)
|
|
225
|
-
if (isReference(annotation)) {
|
|
226
|
-
const bodySource = getBodySource(annotation.body);
|
|
227
|
-
const targetName = bodySource
|
|
228
|
-
? getTargetDocumentName?.(bodySource)
|
|
229
|
-
: undefined;
|
|
230
|
-
const isGenerating = generatingReferenceId
|
|
231
|
-
? annotation.id === generatingReferenceId
|
|
232
|
-
: false;
|
|
233
|
-
const widget = new ReferenceResolutionWidget(
|
|
234
|
-
annotation,
|
|
235
|
-
targetName,
|
|
236
|
-
isGenerating
|
|
237
|
-
);
|
|
238
|
-
builder.add(
|
|
239
|
-
segment.end,
|
|
240
|
-
segment.end,
|
|
241
|
-
Decoration.widget({ widget, side: 1 })
|
|
242
|
-
);
|
|
124
|
+
// We still need the full annotation objects for ReferenceResolutionWidget
|
|
125
|
+
const annotationsByEnd = new Map<number, TextSegment>();
|
|
126
|
+
for (const s of segments) {
|
|
127
|
+
if (s.annotation && isReference(s.annotation)) {
|
|
128
|
+
annotationsByEnd.set(s.end, s);
|
|
243
129
|
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
for (const meta of widgetMetas) {
|
|
133
|
+
const segment = annotationsByEnd.get(meta.position);
|
|
134
|
+
if (!segment?.annotation) continue;
|
|
244
135
|
|
|
136
|
+
const widget = new ReferenceResolutionWidget(
|
|
137
|
+
segment.annotation,
|
|
138
|
+
meta.targetName,
|
|
139
|
+
meta.isGenerating
|
|
140
|
+
);
|
|
141
|
+
builder.add(meta.position, meta.position, Decoration.widget({ widget, side: 1 }));
|
|
245
142
|
}
|
|
246
143
|
|
|
247
144
|
return builder.finish();
|
|
@@ -337,28 +234,18 @@ export function CodeMirrorRenderer({
|
|
|
337
234
|
onChange(newContent);
|
|
338
235
|
}
|
|
339
236
|
}),
|
|
340
|
-
// Handle clicks on annotations
|
|
237
|
+
// Handle clicks on annotations — delegates to extracted handler
|
|
341
238
|
EditorView.domEventHandlers({
|
|
342
239
|
click: (event, _view) => {
|
|
343
240
|
const target = event.target as HTMLElement;
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
if (annotationId && eventBusRef.current) {
|
|
348
|
-
const segment = segmentsByIdRef.current.get(annotationId);
|
|
349
|
-
if (segment?.annotation) {
|
|
350
|
-
event.preventDefault();
|
|
351
|
-
eventBusRef.current.get('browse:click').next({
|
|
352
|
-
annotationId,
|
|
353
|
-
motivation: segment.annotation.motivation
|
|
354
|
-
});
|
|
355
|
-
return true; // Stop propagation
|
|
356
|
-
}
|
|
241
|
+
if (eventBusRef.current && handleAnnotationClick(target, segmentsByIdRef.current, eventBusRef.current)) {
|
|
242
|
+
event.preventDefault();
|
|
243
|
+
return true;
|
|
357
244
|
}
|
|
358
245
|
return false;
|
|
359
246
|
}
|
|
360
247
|
}),
|
|
361
|
-
// Style the editor
|
|
248
|
+
// Style the editor
|
|
362
249
|
EditorView.baseTheme({
|
|
363
250
|
'&.cm-editor': {
|
|
364
251
|
height: '100%',
|
|
@@ -368,7 +255,7 @@ export function CodeMirrorRenderer({
|
|
|
368
255
|
outline: 'none'
|
|
369
256
|
},
|
|
370
257
|
'.cm-scroller': {
|
|
371
|
-
overflow: 'visible !important',
|
|
258
|
+
overflow: 'visible !important',
|
|
372
259
|
height: 'auto !important'
|
|
373
260
|
},
|
|
374
261
|
'.cm-content, .cm-gutters': {
|
|
@@ -433,67 +320,48 @@ export function CodeMirrorRenderer({
|
|
|
433
320
|
if (annotationElement) handleMouseLeave();
|
|
434
321
|
};
|
|
435
322
|
|
|
436
|
-
// Delegated widget event handlers
|
|
437
|
-
const
|
|
323
|
+
// Delegated widget event handlers — delegates to extracted handlers
|
|
324
|
+
const onWidgetClick = (e: MouseEvent) => {
|
|
438
325
|
const target = e.target as HTMLElement;
|
|
439
|
-
const
|
|
440
|
-
if (!
|
|
326
|
+
const result = processWidgetClick(target);
|
|
327
|
+
if (!result.handled) return;
|
|
441
328
|
|
|
442
329
|
e.preventDefault();
|
|
443
330
|
e.stopPropagation();
|
|
444
331
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
const isResolved = widget.dataset.widgetResolved === 'true';
|
|
448
|
-
|
|
449
|
-
if (!annotationId || !eventBusRef.current) return;
|
|
450
|
-
|
|
451
|
-
if (isResolved && bodySource) {
|
|
452
|
-
eventBusRef.current.get('browse:reference-navigate').next({ documentId: bodySource });
|
|
453
|
-
} else {
|
|
454
|
-
const motivation = (widget.dataset.widgetMotivation || 'linking') as Annotation['motivation'];
|
|
455
|
-
eventBusRef.current.get('browse:click').next({ annotationId, motivation });
|
|
332
|
+
if (eventBusRef.current) {
|
|
333
|
+
dispatchWidgetClick(result, eventBusRef.current);
|
|
456
334
|
}
|
|
457
335
|
};
|
|
458
336
|
|
|
459
|
-
const
|
|
337
|
+
const onWidgetMouseEnter = (e: MouseEvent) => {
|
|
460
338
|
const target = e.target as HTMLElement;
|
|
461
|
-
const
|
|
462
|
-
if (
|
|
463
|
-
|
|
464
|
-
const indicator = widget.querySelector('.reference-indicator') as HTMLElement | null;
|
|
465
|
-
if (indicator) indicator.style.opacity = '1';
|
|
466
|
-
|
|
467
|
-
if (widget.dataset.widgetResolved === 'true' && widget.dataset.widgetTargetName) {
|
|
468
|
-
showWidgetPreview(widget, widget.dataset.widgetTargetName);
|
|
339
|
+
const result = processWidgetMouseEnter(target);
|
|
340
|
+
if (result.showPreview && result.targetName && result.widget) {
|
|
341
|
+
showWidgetPreview(result.widget, result.targetName);
|
|
469
342
|
}
|
|
470
343
|
};
|
|
471
344
|
|
|
472
|
-
const
|
|
345
|
+
const onWidgetMouseLeave = (e: MouseEvent) => {
|
|
473
346
|
const target = e.target as HTMLElement;
|
|
474
|
-
const
|
|
475
|
-
if (
|
|
476
|
-
|
|
477
|
-
const indicator = widget.querySelector('.reference-indicator') as HTMLElement | null;
|
|
478
|
-
if (indicator) indicator.style.opacity = '0.6';
|
|
479
|
-
|
|
480
|
-
if (widget.dataset.widgetResolved === 'true') {
|
|
481
|
-
hideWidgetPreview(widget);
|
|
347
|
+
const result = processWidgetMouseLeave(target);
|
|
348
|
+
if (result.hidePreview && result.widget) {
|
|
349
|
+
hideWidgetPreview(result.widget);
|
|
482
350
|
}
|
|
483
351
|
};
|
|
484
352
|
|
|
485
353
|
container.addEventListener('mouseover', handleMouseOver);
|
|
486
354
|
container.addEventListener('mouseout', handleMouseOut);
|
|
487
|
-
container.addEventListener('click',
|
|
488
|
-
container.addEventListener('mouseenter',
|
|
489
|
-
container.addEventListener('mouseleave',
|
|
355
|
+
container.addEventListener('click', onWidgetClick);
|
|
356
|
+
container.addEventListener('mouseenter', onWidgetMouseEnter, true);
|
|
357
|
+
container.addEventListener('mouseleave', onWidgetMouseLeave, true);
|
|
490
358
|
|
|
491
359
|
return () => {
|
|
492
360
|
container.removeEventListener('mouseover', handleMouseOver);
|
|
493
361
|
container.removeEventListener('mouseout', handleMouseOut);
|
|
494
|
-
container.removeEventListener('click',
|
|
495
|
-
container.removeEventListener('mouseenter',
|
|
496
|
-
container.removeEventListener('mouseleave',
|
|
362
|
+
container.removeEventListener('click', onWidgetClick);
|
|
363
|
+
container.removeEventListener('mouseenter', onWidgetMouseEnter, true);
|
|
364
|
+
container.removeEventListener('mouseleave', onWidgetMouseLeave, true);
|
|
497
365
|
cleanupHover();
|
|
498
366
|
view.destroy();
|
|
499
367
|
viewRef.current = null;
|
|
@@ -617,4 +485,4 @@ export function CodeMirrorRenderer({
|
|
|
617
485
|
: "semiont-codemirror";
|
|
618
486
|
|
|
619
487
|
return <div ref={containerRef} className={containerClasses} data-markdown-container />;
|
|
620
|
-
}
|
|
488
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { screen, fireEvent } from '@testing-library/react';
|
|
4
|
+
import '@testing-library/jest-dom';
|
|
5
|
+
import { renderWithProviders, resetEventBusForTesting } from '../../test-utils';
|
|
6
|
+
import { AnnotateReferencesProgressWidget } from '../AnnotateReferencesProgressWidget';
|
|
7
|
+
import type { MarkProgress } from '@semiont/core';
|
|
8
|
+
|
|
9
|
+
describe('AnnotateReferencesProgressWidget', () => {
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
resetEventBusForTesting();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('returns null when progress is null', () => {
|
|
15
|
+
const { container } = renderWithProviders(
|
|
16
|
+
<AnnotateReferencesProgressWidget progress={null} />
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
expect(container.firstChild).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('renders progress with status message', () => {
|
|
23
|
+
const progress: MarkProgress = {
|
|
24
|
+
status: 'in-progress',
|
|
25
|
+
message: 'Processing entities...',
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
renderWithProviders(
|
|
29
|
+
<AnnotateReferencesProgressWidget progress={progress} />
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
expect(screen.getByText('Processing entities...')).toBeInTheDocument();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('shows cancel button when not complete', () => {
|
|
36
|
+
const progress: MarkProgress = {
|
|
37
|
+
status: 'in-progress',
|
|
38
|
+
message: 'Working...',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
renderWithProviders(
|
|
42
|
+
<AnnotateReferencesProgressWidget progress={progress} />
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const cancelButton = screen.getByTitle('ReferencesPanel.cancelAnnotation');
|
|
46
|
+
expect(cancelButton).toBeInTheDocument();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('hides cancel button when complete', () => {
|
|
50
|
+
const progress: MarkProgress = {
|
|
51
|
+
status: 'complete',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
renderWithProviders(
|
|
55
|
+
<AnnotateReferencesProgressWidget progress={progress} />
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
expect(screen.queryByTitle('ReferencesPanel.cancelAnnotation')).not.toBeInTheDocument();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('emits job:cancel-requested on cancel click', () => {
|
|
62
|
+
const handler = vi.fn();
|
|
63
|
+
const progress: MarkProgress = {
|
|
64
|
+
status: 'in-progress',
|
|
65
|
+
message: 'Working...',
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const { eventBus } = renderWithProviders(
|
|
69
|
+
<AnnotateReferencesProgressWidget progress={progress} />,
|
|
70
|
+
{ returnEventBus: true }
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const subscription = eventBus!.get('job:cancel-requested').subscribe(handler);
|
|
74
|
+
|
|
75
|
+
const cancelButton = screen.getByTitle('ReferencesPanel.cancelAnnotation');
|
|
76
|
+
fireEvent.click(cancelButton);
|
|
77
|
+
|
|
78
|
+
expect(handler).toHaveBeenCalledWith({ jobType: 'annotation' });
|
|
79
|
+
|
|
80
|
+
subscription.unsubscribe();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('renders completed entity types', () => {
|
|
84
|
+
const progress: MarkProgress = {
|
|
85
|
+
status: 'in-progress',
|
|
86
|
+
completedEntityTypes: [
|
|
87
|
+
{ entityType: 'Person', foundCount: 5 },
|
|
88
|
+
{ entityType: 'Organization', foundCount: 3 },
|
|
89
|
+
],
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
renderWithProviders(
|
|
93
|
+
<AnnotateReferencesProgressWidget progress={progress} />
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
expect(screen.getByText('Person:')).toBeInTheDocument();
|
|
97
|
+
expect(screen.getByText('Organization:')).toBeInTheDocument();
|
|
98
|
+
// Translation mock returns "ReferencesPanel.found" for each entity type
|
|
99
|
+
const foundLabels = screen.getAllByText('ReferencesPanel.found');
|
|
100
|
+
expect(foundLabels).toHaveLength(2);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('shows complete icon for complete status', () => {
|
|
104
|
+
const progress: MarkProgress = {
|
|
105
|
+
status: 'complete',
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const { container } = renderWithProviders(
|
|
109
|
+
<AnnotateReferencesProgressWidget progress={progress} />
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
expect(container.querySelector('[data-status="complete"]')).toBeInTheDocument();
|
|
113
|
+
expect(screen.getByText('ReferencesPanel.complete')).toBeInTheDocument();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('shows error message for error status', () => {
|
|
117
|
+
const progress: MarkProgress = {
|
|
118
|
+
status: 'error',
|
|
119
|
+
message: 'Something went wrong',
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const { container } = renderWithProviders(
|
|
123
|
+
<AnnotateReferencesProgressWidget progress={progress} />
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
expect(container.querySelector('[data-status="error"]')).toBeInTheDocument();
|
|
127
|
+
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('shows current entity type processing details', () => {
|
|
131
|
+
const progress: MarkProgress = {
|
|
132
|
+
status: 'in-progress',
|
|
133
|
+
currentEntityType: 'Location',
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
renderWithProviders(
|
|
137
|
+
<AnnotateReferencesProgressWidget progress={progress} />
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
expect(screen.getByText(/Processing: Location/)).toBeInTheDocument();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { renderHook, act } from '@testing-library/react';
|
|
4
|
+
import { render, screen } from '@testing-library/react';
|
|
5
|
+
import '@testing-library/jest-dom';
|
|
6
|
+
import {
|
|
7
|
+
LiveRegionProvider,
|
|
8
|
+
useFormAnnouncements,
|
|
9
|
+
useLanguageChangeAnnouncements,
|
|
10
|
+
} from '../LiveRegion';
|
|
11
|
+
|
|
12
|
+
function Wrapper({ children }: { children: React.ReactNode }) {
|
|
13
|
+
return <LiveRegionProvider>{children}</LiveRegionProvider>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('useFormAnnouncements', () => {
|
|
17
|
+
it('announceFormSubmitting sets polite message', () => {
|
|
18
|
+
const { result } = renderHook(() => useFormAnnouncements(), { wrapper: Wrapper });
|
|
19
|
+
|
|
20
|
+
act(() => result.current.announceFormSubmitting());
|
|
21
|
+
|
|
22
|
+
const { container } = render(
|
|
23
|
+
<LiveRegionProvider>
|
|
24
|
+
<div />
|
|
25
|
+
</LiveRegionProvider>
|
|
26
|
+
);
|
|
27
|
+
// The announcement happens via context; verify the hook returns the functions
|
|
28
|
+
expect(typeof result.current.announceFormSubmitting).toBe('function');
|
|
29
|
+
expect(typeof result.current.announceFormSuccess).toBe('function');
|
|
30
|
+
expect(typeof result.current.announceFormError).toBe('function');
|
|
31
|
+
expect(typeof result.current.announceFormValidationError).toBe('function');
|
|
32
|
+
container.remove();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('announceFormSuccess uses custom message', () => {
|
|
36
|
+
const { result } = renderHook(() => useFormAnnouncements(), { wrapper: Wrapper });
|
|
37
|
+
act(() => result.current.announceFormSuccess('Created!'));
|
|
38
|
+
// No throw = success
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('announceFormSuccess uses default message', () => {
|
|
42
|
+
const { result } = renderHook(() => useFormAnnouncements(), { wrapper: Wrapper });
|
|
43
|
+
act(() => result.current.announceFormSuccess());
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('announceFormError uses custom message', () => {
|
|
47
|
+
const { result } = renderHook(() => useFormAnnouncements(), { wrapper: Wrapper });
|
|
48
|
+
act(() => result.current.announceFormError('Network error'));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('announceFormError uses default message', () => {
|
|
52
|
+
const { result } = renderHook(() => useFormAnnouncements(), { wrapper: Wrapper });
|
|
53
|
+
act(() => result.current.announceFormError());
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('announceFormValidationError with 1 field', () => {
|
|
57
|
+
const { result } = renderHook(() => useFormAnnouncements(), { wrapper: Wrapper });
|
|
58
|
+
act(() => result.current.announceFormValidationError(1));
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('announceFormValidationError with multiple fields', () => {
|
|
62
|
+
const { result } = renderHook(() => useFormAnnouncements(), { wrapper: Wrapper });
|
|
63
|
+
act(() => result.current.announceFormValidationError(3));
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('useLanguageChangeAnnouncements', () => {
|
|
68
|
+
it('announceLanguageChanging is callable', () => {
|
|
69
|
+
const { result } = renderHook(() => useLanguageChangeAnnouncements(), { wrapper: Wrapper });
|
|
70
|
+
expect(typeof result.current.announceLanguageChanging).toBe('function');
|
|
71
|
+
act(() => result.current.announceLanguageChanging('French'));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('announceLanguageChanged is callable', () => {
|
|
75
|
+
const { result } = renderHook(() => useLanguageChangeAnnouncements(), { wrapper: Wrapper });
|
|
76
|
+
expect(typeof result.current.announceLanguageChanged).toBe('function');
|
|
77
|
+
act(() => result.current.announceLanguageChanged('German'));
|
|
78
|
+
});
|
|
79
|
+
});
|