@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.
Files changed (33) hide show
  1. package/dist/index.d.mts +8 -0
  2. package/dist/index.mjs +252 -166
  3. package/dist/index.mjs.map +1 -1
  4. package/package.json +1 -1
  5. package/src/components/CodeMirrorRenderer.tsx +71 -203
  6. package/src/components/__tests__/AnnotateReferencesProgressWidget.test.tsx +142 -0
  7. package/src/components/__tests__/LiveRegion.hooks.test.tsx +79 -0
  8. package/src/components/__tests__/ResizeHandle.test.tsx +165 -0
  9. package/src/components/__tests__/SessionExpiryBanner.test.tsx +123 -0
  10. package/src/components/__tests__/StatusDisplay.test.tsx +160 -0
  11. package/src/components/__tests__/Toolbar.test.tsx +110 -0
  12. package/src/components/annotation-popups/__tests__/JsonLdView.test.tsx +285 -0
  13. package/src/components/annotation-popups/__tests__/SharedPopupElements.test.tsx +273 -0
  14. package/src/components/modals/__tests__/KeyboardShortcutsHelpModal.test.tsx +90 -0
  15. package/src/components/modals/__tests__/ProposeEntitiesModal.test.tsx +129 -0
  16. package/src/components/modals/__tests__/ResourceSearchModal.test.tsx +180 -0
  17. package/src/components/navigation/__tests__/ObservableLink.test.tsx +90 -0
  18. package/src/components/navigation/__tests__/SimpleNavigation.test.tsx +169 -0
  19. package/src/components/navigation/__tests__/SortableResourceTab.test.tsx +371 -0
  20. package/src/components/resource/AnnotateView.tsx +27 -153
  21. package/src/components/resource/__tests__/AnnotationHistory.test.tsx +349 -0
  22. package/src/components/resource/__tests__/HistoryEvent.test.tsx +492 -0
  23. package/src/components/resource/__tests__/event-formatting.test.ts +273 -0
  24. package/src/components/resource/panels/__tests__/AssessmentEntry.test.tsx +226 -0
  25. package/src/components/resource/panels/__tests__/HighlightEntry.test.tsx +188 -0
  26. package/src/components/resource/panels/__tests__/PanelHeader.test.tsx +69 -0
  27. package/src/components/resource/panels/__tests__/ReferenceEntry.test.tsx +445 -0
  28. package/src/components/resource/panels/__tests__/StatisticsPanel.test.tsx +271 -0
  29. package/src/components/resource/panels/__tests__/TagEntry.test.tsx +210 -0
  30. package/src/components/settings/__tests__/SettingsPanel.test.tsx +190 -0
  31. package/src/components/viewers/__tests__/ImageViewer.test.tsx +63 -0
  32. package/src/integrations/__tests__/css-modules-helper.test.tsx +225 -0
  33. package/src/integrations/__tests__/styled-components-theme.test.ts +179 -0
@@ -0,0 +1,492 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import React from 'react';
3
+ import { screen, fireEvent, act } from '@testing-library/react';
4
+ import '@testing-library/jest-dom';
5
+ import { HistoryEvent } from '../HistoryEvent';
6
+ import { renderWithProviders } from '../../../test-utils';
7
+ import type { StoredEvent } from '@semiont/core';
8
+
9
+ // Mock @semiont/core - must use importOriginal to preserve EventBus etc.
10
+ vi.mock('@semiont/core', async (importOriginal) => {
11
+ const actual = await importOriginal<typeof import('@semiont/core')>();
12
+ return {
13
+ ...actual,
14
+ getAnnotationUriFromEvent: vi.fn(() => null),
15
+ };
16
+ });
17
+
18
+ // Stable mock return values (defined outside vi.mock to avoid re-render loops)
19
+ const mockDisplayContent = { exact: 'Test content', isTag: false, isQuoted: false };
20
+ const mockEmptyDisplayContent = null;
21
+ const mockTagContent = { exact: 'Person', isTag: true, isQuoted: false };
22
+ const mockQuotedContent = { exact: 'quoted text', isTag: false, isQuoted: true };
23
+ const mockEntityTypes: string[] = [];
24
+ const mockCreationDetails = null;
25
+
26
+ // Mock event-formatting utilities
27
+ vi.mock('../event-formatting', () => ({
28
+ formatEventType: vi.fn((_type: string, t: (key: string) => string) => t('resourceCreated')),
29
+ getEventEmoji: vi.fn(() => '\u{1F4C4}'),
30
+ formatRelativeTime: vi.fn(() => '2 minutes ago'),
31
+ getEventDisplayContent: vi.fn(() => mockDisplayContent),
32
+ getEventEntityTypes: vi.fn(() => mockEntityTypes),
33
+ getResourceCreationDetails: vi.fn(() => mockCreationDetails),
34
+ }));
35
+
36
+ import { getAnnotationUriFromEvent } from '@semiont/core';
37
+ import {
38
+ formatEventType,
39
+ getEventEmoji,
40
+ formatRelativeTime,
41
+ getEventDisplayContent,
42
+ getEventEntityTypes,
43
+ getResourceCreationDetails,
44
+ } from '../event-formatting';
45
+
46
+ const mockGetAnnotationUri = getAnnotationUriFromEvent as ReturnType<typeof vi.fn>;
47
+ const mockGetEventDisplayContent = getEventDisplayContent as ReturnType<typeof vi.fn>;
48
+ const mockGetEventEntityTypes = getEventEntityTypes as ReturnType<typeof vi.fn>;
49
+ const mockGetResourceCreationDetails = getResourceCreationDetails as ReturnType<typeof vi.fn>;
50
+ const mockFormatEventType = formatEventType as ReturnType<typeof vi.fn>;
51
+
52
+ function makeStoredEvent(overrides: Partial<StoredEvent['event']> = {}): StoredEvent {
53
+ return {
54
+ event: {
55
+ id: 'evt-1',
56
+ type: 'resource.created',
57
+ timestamp: '2026-03-06T12:00:00Z',
58
+ resourceId: 'http://localhost/resources/res-1',
59
+ userId: 'user-1',
60
+ version: 1,
61
+ payload: { name: 'Test', format: 'text/plain', contentChecksum: 'abc', creationMethod: 'upload' },
62
+ ...overrides,
63
+ },
64
+ metadata: {
65
+ sequenceNumber: 1,
66
+ storedAt: '2026-03-06T12:00:00Z',
67
+ },
68
+ } as StoredEvent;
69
+ }
70
+
71
+ const mockT = (key: string) => key;
72
+ const MockLink = ({ href, children, ...props }: any) => <a href={href} {...props}>{children}</a>;
73
+ const mockRoutes = {
74
+ resourceDetail: (id: string) => `/resources/${id}`,
75
+ } as any;
76
+
77
+ describe('HistoryEvent', () => {
78
+ beforeEach(() => {
79
+ vi.clearAllMocks();
80
+ mockGetAnnotationUri.mockReturnValue(null);
81
+ mockGetEventDisplayContent.mockReturnValue(mockDisplayContent);
82
+ mockGetEventEntityTypes.mockReturnValue(mockEntityTypes);
83
+ mockGetResourceCreationDetails.mockReturnValue(mockCreationDetails);
84
+ });
85
+
86
+ it('renders basic event with display content', () => {
87
+ const event = makeStoredEvent();
88
+ renderWithProviders(
89
+ <HistoryEvent
90
+ event={event}
91
+ annotations={[]}
92
+ allEvents={[event]}
93
+ isRelated={false}
94
+ t={mockT}
95
+ Link={MockLink}
96
+ routes={mockRoutes}
97
+ />
98
+ );
99
+
100
+ expect(screen.getByText('Test content')).toBeInTheDocument();
101
+ expect(screen.getByText('2 minutes ago')).toBeInTheDocument();
102
+ });
103
+
104
+ it('renders emoji from getEventEmoji', () => {
105
+ const event = makeStoredEvent();
106
+ renderWithProviders(
107
+ <HistoryEvent
108
+ event={event}
109
+ annotations={[]}
110
+ allEvents={[event]}
111
+ isRelated={false}
112
+ t={mockT}
113
+ Link={MockLink}
114
+ routes={mockRoutes}
115
+ />
116
+ );
117
+
118
+ expect(screen.getByText('\u{1F4C4}')).toBeInTheDocument();
119
+ });
120
+
121
+ it('renders as div when no annotationUri', () => {
122
+ mockGetAnnotationUri.mockReturnValue(null);
123
+ const event = makeStoredEvent();
124
+ const { container } = renderWithProviders(
125
+ <HistoryEvent
126
+ event={event}
127
+ annotations={[]}
128
+ allEvents={[event]}
129
+ isRelated={false}
130
+ t={mockT}
131
+ Link={MockLink}
132
+ routes={mockRoutes}
133
+ />
134
+ );
135
+
136
+ const wrapper = container.querySelector('.semiont-history-event');
137
+ expect(wrapper?.tagName).toBe('DIV');
138
+ });
139
+
140
+ it('renders as button when annotationUri exists', () => {
141
+ mockGetAnnotationUri.mockReturnValue('http://localhost/annotations/ann-1');
142
+ const event = makeStoredEvent({ type: 'annotation.added' } as any);
143
+ const { container } = renderWithProviders(
144
+ <HistoryEvent
145
+ event={event}
146
+ annotations={[]}
147
+ allEvents={[event]}
148
+ isRelated={false}
149
+ t={mockT}
150
+ Link={MockLink}
151
+ routes={mockRoutes}
152
+ />
153
+ );
154
+
155
+ const wrapper = container.querySelector('.semiont-history-event');
156
+ expect(wrapper?.tagName).toBe('BUTTON');
157
+ });
158
+
159
+ it('calls onEventClick when button is clicked', () => {
160
+ const annotationUri = 'http://localhost/annotations/ann-1';
161
+ mockGetAnnotationUri.mockReturnValue(annotationUri);
162
+ const onEventClick = vi.fn();
163
+ const event = makeStoredEvent({ type: 'annotation.added' } as any);
164
+
165
+ renderWithProviders(
166
+ <HistoryEvent
167
+ event={event}
168
+ annotations={[]}
169
+ allEvents={[event]}
170
+ isRelated={false}
171
+ t={mockT}
172
+ Link={MockLink}
173
+ routes={mockRoutes}
174
+ onEventClick={onEventClick}
175
+ />
176
+ );
177
+
178
+ const button = screen.getByRole('button');
179
+ fireEvent.click(button);
180
+ expect(onEventClick).toHaveBeenCalledWith(annotationUri);
181
+ });
182
+
183
+ it('sets data-related attribute based on isRelated prop', () => {
184
+ const event = makeStoredEvent();
185
+ const { container } = renderWithProviders(
186
+ <HistoryEvent
187
+ event={event}
188
+ annotations={[]}
189
+ allEvents={[event]}
190
+ isRelated={true}
191
+ t={mockT}
192
+ Link={MockLink}
193
+ routes={mockRoutes}
194
+ />
195
+ );
196
+
197
+ const wrapper = container.querySelector('.semiont-history-event');
198
+ expect(wrapper).toHaveAttribute('data-related', 'true');
199
+ });
200
+
201
+ it('sets data-related=false when not related', () => {
202
+ const event = makeStoredEvent();
203
+ const { container } = renderWithProviders(
204
+ <HistoryEvent
205
+ event={event}
206
+ annotations={[]}
207
+ allEvents={[event]}
208
+ isRelated={false}
209
+ t={mockT}
210
+ Link={MockLink}
211
+ routes={mockRoutes}
212
+ />
213
+ );
214
+
215
+ const wrapper = container.querySelector('.semiont-history-event');
216
+ expect(wrapper).toHaveAttribute('data-related', 'false');
217
+ });
218
+
219
+ it('renders userId when present', () => {
220
+ const event = makeStoredEvent({ userId: 'alice' } as any);
221
+ renderWithProviders(
222
+ <HistoryEvent
223
+ event={event}
224
+ annotations={[]}
225
+ allEvents={[event]}
226
+ isRelated={false}
227
+ t={mockT}
228
+ Link={MockLink}
229
+ routes={mockRoutes}
230
+ />
231
+ );
232
+
233
+ expect(screen.getByText('alice')).toBeInTheDocument();
234
+ });
235
+
236
+ it('renders tag content with tag class', () => {
237
+ mockGetEventDisplayContent.mockReturnValue(mockTagContent);
238
+ const event = makeStoredEvent();
239
+ const { container } = renderWithProviders(
240
+ <HistoryEvent
241
+ event={event}
242
+ annotations={[]}
243
+ allEvents={[event]}
244
+ isRelated={false}
245
+ t={mockT}
246
+ Link={MockLink}
247
+ routes={mockRoutes}
248
+ />
249
+ );
250
+
251
+ const tagEl = container.querySelector('.semiont-history-event__tag');
252
+ expect(tagEl).toBeInTheDocument();
253
+ expect(tagEl).toHaveTextContent('Person');
254
+ });
255
+
256
+ it('renders quoted content with quoted class', () => {
257
+ mockGetEventDisplayContent.mockReturnValue(mockQuotedContent);
258
+ const event = makeStoredEvent();
259
+ const { container } = renderWithProviders(
260
+ <HistoryEvent
261
+ event={event}
262
+ annotations={[]}
263
+ allEvents={[event]}
264
+ isRelated={false}
265
+ t={mockT}
266
+ Link={MockLink}
267
+ routes={mockRoutes}
268
+ />
269
+ );
270
+
271
+ const quotedEl = container.querySelector('.semiont-history-event__text--quoted');
272
+ expect(quotedEl).toBeInTheDocument();
273
+ });
274
+
275
+ it('falls back to formatEventType when displayContent is null', () => {
276
+ mockGetEventDisplayContent.mockReturnValue(mockEmptyDisplayContent);
277
+ mockFormatEventType.mockReturnValue('Resource Created');
278
+ const event = makeStoredEvent();
279
+ renderWithProviders(
280
+ <HistoryEvent
281
+ event={event}
282
+ annotations={[]}
283
+ allEvents={[event]}
284
+ isRelated={false}
285
+ t={mockT}
286
+ Link={MockLink}
287
+ routes={mockRoutes}
288
+ />
289
+ );
290
+
291
+ expect(screen.getByText('Resource Created')).toBeInTheDocument();
292
+ });
293
+
294
+ it('renders entity type tags when present', () => {
295
+ mockGetEventEntityTypes.mockReturnValue(['Person', 'Organization']);
296
+ const event = makeStoredEvent();
297
+ const { container } = renderWithProviders(
298
+ <HistoryEvent
299
+ event={event}
300
+ annotations={[]}
301
+ allEvents={[event]}
302
+ isRelated={false}
303
+ t={mockT}
304
+ Link={MockLink}
305
+ routes={mockRoutes}
306
+ />
307
+ );
308
+
309
+ expect(screen.getByText('Person')).toBeInTheDocument();
310
+ expect(screen.getByText('Organization')).toBeInTheDocument();
311
+ const tags = container.querySelectorAll('.semiont-tag--small');
312
+ expect(tags).toHaveLength(2);
313
+ });
314
+
315
+ it('renders creation details when present', () => {
316
+ mockGetResourceCreationDetails.mockReturnValue({
317
+ type: 'created',
318
+ userId: 'alice',
319
+ method: 'upload',
320
+ });
321
+ const event = makeStoredEvent();
322
+ renderWithProviders(
323
+ <HistoryEvent
324
+ event={event}
325
+ annotations={[]}
326
+ allEvents={[event]}
327
+ isRelated={false}
328
+ t={mockT}
329
+ Link={MockLink}
330
+ routes={mockRoutes}
331
+ />
332
+ );
333
+
334
+ expect(screen.getByText('alice')).toBeInTheDocument();
335
+ expect(screen.getByText('upload')).toBeInTheDocument();
336
+ });
337
+
338
+ it('renders "View Original" link for cloned resources', () => {
339
+ mockGetResourceCreationDetails.mockReturnValue({
340
+ type: 'cloned',
341
+ userId: 'bob',
342
+ method: 'clone',
343
+ sourceDocId: 'doc-source-123',
344
+ });
345
+ const event = makeStoredEvent();
346
+ renderWithProviders(
347
+ <HistoryEvent
348
+ event={event}
349
+ annotations={[]}
350
+ allEvents={[event]}
351
+ isRelated={false}
352
+ t={mockT}
353
+ Link={MockLink}
354
+ routes={mockRoutes}
355
+ />
356
+ );
357
+
358
+ const link = screen.getByText('viewOriginal');
359
+ expect(link).toBeInTheDocument();
360
+ expect(link).toHaveAttribute('href', '/resources/doc-source-123');
361
+ });
362
+
363
+ it('calls onEventRef with annotationUri and element', () => {
364
+ const annotationUri = 'http://localhost/annotations/ann-1';
365
+ mockGetAnnotationUri.mockReturnValue(annotationUri);
366
+ const onEventRef = vi.fn();
367
+ const event = makeStoredEvent({ type: 'annotation.added' } as any);
368
+
369
+ renderWithProviders(
370
+ <HistoryEvent
371
+ event={event}
372
+ annotations={[]}
373
+ allEvents={[event]}
374
+ isRelated={false}
375
+ t={mockT}
376
+ Link={MockLink}
377
+ routes={mockRoutes}
378
+ onEventRef={onEventRef}
379
+ />
380
+ );
381
+
382
+ expect(onEventRef).toHaveBeenCalledWith(annotationUri, expect.any(HTMLElement));
383
+ });
384
+
385
+ it('handles emoji hover with delayed callback', () => {
386
+ vi.useFakeTimers();
387
+ const annotationUri = 'http://localhost/annotations/ann-1';
388
+ mockGetAnnotationUri.mockReturnValue(annotationUri);
389
+ const onEventHover = vi.fn();
390
+ const event = makeStoredEvent({ type: 'annotation.added' } as any);
391
+
392
+ const { container } = renderWithProviders(
393
+ <HistoryEvent
394
+ event={event}
395
+ annotations={[]}
396
+ allEvents={[event]}
397
+ isRelated={false}
398
+ t={mockT}
399
+ Link={MockLink}
400
+ routes={mockRoutes}
401
+ onEventHover={onEventHover}
402
+ />
403
+ );
404
+
405
+ const emoji = container.querySelector('.semiont-history-event__emoji')!;
406
+ fireEvent.mouseEnter(emoji);
407
+
408
+ // Should not fire immediately
409
+ expect(onEventHover).not.toHaveBeenCalled();
410
+
411
+ // Should fire after 300ms delay
412
+ act(() => { vi.advanceTimersByTime(300); });
413
+ expect(onEventHover).toHaveBeenCalledWith(annotationUri);
414
+
415
+ vi.useRealTimers();
416
+ });
417
+
418
+ it('clears hover on mouse leave and calls with null', () => {
419
+ vi.useFakeTimers();
420
+ const annotationUri = 'http://localhost/annotations/ann-1';
421
+ mockGetAnnotationUri.mockReturnValue(annotationUri);
422
+ const onEventHover = vi.fn();
423
+ const event = makeStoredEvent({ type: 'annotation.added' } as any);
424
+
425
+ const { container } = renderWithProviders(
426
+ <HistoryEvent
427
+ event={event}
428
+ annotations={[]}
429
+ allEvents={[event]}
430
+ isRelated={false}
431
+ t={mockT}
432
+ Link={MockLink}
433
+ routes={mockRoutes}
434
+ onEventHover={onEventHover}
435
+ />
436
+ );
437
+
438
+ const emoji = container.querySelector('.semiont-history-event__emoji')!;
439
+ fireEvent.mouseEnter(emoji);
440
+
441
+ // Leave before 300ms timeout fires
442
+ act(() => { vi.advanceTimersByTime(100); });
443
+ fireEvent.mouseLeave(emoji);
444
+
445
+ // The timeout should have been cleared, and hover state cleared
446
+ expect(onEventHover).toHaveBeenCalledWith(null);
447
+
448
+ // After full timeout, the delayed hover should NOT have fired
449
+ act(() => { vi.advanceTimersByTime(300); });
450
+ expect(onEventHover).toHaveBeenCalledTimes(1); // Only the null call
451
+
452
+ vi.useRealTimers();
453
+ });
454
+
455
+ it('sets data-interactive on button wrapper', () => {
456
+ mockGetAnnotationUri.mockReturnValue('http://localhost/annotations/ann-1');
457
+ const event = makeStoredEvent({ type: 'annotation.added' } as any);
458
+ const { container } = renderWithProviders(
459
+ <HistoryEvent
460
+ event={event}
461
+ annotations={[]}
462
+ allEvents={[event]}
463
+ isRelated={false}
464
+ t={mockT}
465
+ Link={MockLink}
466
+ routes={mockRoutes}
467
+ />
468
+ );
469
+
470
+ const wrapper = container.querySelector('.semiont-history-event');
471
+ expect(wrapper).toHaveAttribute('data-interactive', 'true');
472
+ });
473
+
474
+ it('does not set data-interactive on div wrapper', () => {
475
+ mockGetAnnotationUri.mockReturnValue(null);
476
+ const event = makeStoredEvent();
477
+ const { container } = renderWithProviders(
478
+ <HistoryEvent
479
+ event={event}
480
+ annotations={[]}
481
+ allEvents={[event]}
482
+ isRelated={false}
483
+ t={mockT}
484
+ Link={MockLink}
485
+ routes={mockRoutes}
486
+ />
487
+ );
488
+
489
+ const wrapper = container.querySelector('.semiont-history-event');
490
+ expect(wrapper).not.toHaveAttribute('data-interactive');
491
+ });
492
+ });