@semiont/react-ui 0.5.0 → 0.5.2

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 (56) hide show
  1. package/README.md +13 -0
  2. package/dist/{chunk-4NOUO3W6.mjs → chunk-7VWNZ5YX.mjs} +5032 -2876
  3. package/dist/chunk-7VWNZ5YX.mjs.map +1 -0
  4. package/dist/index.d.mts +292 -25
  5. package/dist/index.mjs +1021 -332
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/test-utils.d.mts +1 -1
  8. package/dist/test-utils.mjs +4 -2352
  9. package/dist/test-utils.mjs.map +1 -1
  10. package/package.json +1 -1
  11. package/src/components/StatusDisplay.tsx +1 -1
  12. package/src/components/modals/PermissionDeniedModal.tsx +2 -2
  13. package/src/components/modals/SessionExpiredModal.tsx +4 -4
  14. package/src/components/resource/panels/AssessmentPanel.tsx +4 -0
  15. package/src/components/resource/panels/AssistSection.tsx +10 -1
  16. package/src/components/resource/panels/CollaborationPanel.tsx +1 -1
  17. package/src/components/resource/panels/CommentsPanel.tsx +4 -0
  18. package/src/components/resource/panels/HighlightPanel.tsx +4 -0
  19. package/src/components/resource/panels/ReferencesPanel.tsx +11 -0
  20. package/src/components/resource/panels/TaggingPanel.tsx +10 -0
  21. package/src/components/resource/panels/UnifiedAnnotationsPanel.tsx +11 -1
  22. package/src/components/resource/panels/__tests__/ReferencesPanel.observable-flow.test.tsx +2 -2
  23. package/src/features/admin-devops/components/AdminDevOpsPage.tsx +1 -1
  24. package/src/features/admin-exchange/components/AdminExchangePage.tsx +1 -1
  25. package/src/features/admin-exchange/components/ImportCard.tsx +1 -1
  26. package/src/features/admin-exchange/state/__tests__/exchange-state-unit.test.ts +171 -0
  27. package/src/features/admin-exchange/state/exchange-state-unit.ts +131 -0
  28. package/src/features/admin-security/components/AdminSecurityPage.tsx +1 -1
  29. package/src/features/admin-security/state/__tests__/admin-security-state-unit.test.ts +68 -0
  30. package/src/features/admin-security/state/admin-security-state-unit.ts +46 -0
  31. package/src/features/admin-users/components/AdminUsersPage.tsx +1 -1
  32. package/src/features/admin-users/state/__tests__/admin-users-state-unit.test.ts +86 -0
  33. package/src/features/admin-users/state/admin-users-state-unit.ts +73 -0
  34. package/src/features/auth-welcome/state/__tests__/welcome-state-unit.test.ts +86 -0
  35. package/src/features/auth-welcome/state/welcome-state-unit.ts +44 -0
  36. package/src/features/moderate-entity-tags/components/EntityTagsPage.tsx +1 -1
  37. package/src/features/moderate-entity-tags/state/__tests__/entity-tags-state-unit.test.ts +102 -0
  38. package/src/features/moderate-entity-tags/state/entity-tags-state-unit.ts +64 -0
  39. package/src/features/moderate-recent/components/RecentDocumentsPage.tsx +1 -1
  40. package/src/features/moderate-tag-schemas/components/TagSchemasPage.tsx +1 -1
  41. package/src/features/moderation-linked-data/components/LinkedDataPage.tsx +1 -1
  42. package/src/features/resource-compose/__tests__/UploadProgressBar.test.tsx +225 -0
  43. package/src/features/resource-compose/components/ResourceComposePage.tsx +19 -4
  44. package/src/features/resource-compose/components/UploadProgressBar.tsx +94 -0
  45. package/src/features/resource-compose/state/__tests__/compose-page-state-unit.test.ts +187 -0
  46. package/src/features/resource-compose/state/compose-page-state-unit.ts +209 -0
  47. package/src/features/resource-discovery/components/ResourceDiscoveryPage.tsx +1 -1
  48. package/src/features/resource-discovery/state/__tests__/discover-state-unit.test.ts +76 -0
  49. package/src/features/resource-discovery/state/discover-state-unit.ts +54 -0
  50. package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +4 -2
  51. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +36 -32
  52. package/src/features/resource-viewer/state/__tests__/resource-loader-state-unit.test.ts +46 -0
  53. package/src/features/resource-viewer/state/__tests__/resource-viewer-page-state-unit.test.ts +203 -0
  54. package/src/features/resource-viewer/state/resource-loader-state-unit.ts +26 -0
  55. package/src/features/resource-viewer/state/resource-viewer-page-state-unit.ts +180 -0
  56. package/dist/chunk-4NOUO3W6.mjs.map +0 -1
@@ -0,0 +1,225 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { render, screen } from '@testing-library/react';
3
+ import '@testing-library/jest-dom';
4
+ import type { UploadProgress } from '@semiont/sdk';
5
+ import type { ResourceId } from '@semiont/core';
6
+ import { UploadProgressBar } from '../components/UploadProgressBar';
7
+
8
+ describe('UploadProgressBar', () => {
9
+ describe('null progress', () => {
10
+ it('renders nothing when progress is null', () => {
11
+ const { container } = render(<UploadProgressBar progress={null} />);
12
+ expect(container.firstChild).toBeNull();
13
+ });
14
+ });
15
+
16
+ describe('phase: started', () => {
17
+ const started: UploadProgress = { phase: 'started', totalBytes: 1024 };
18
+
19
+ it('shows the starting label with default "Upload" prefix', () => {
20
+ render(<UploadProgressBar progress={started} />);
21
+ expect(screen.getByText('Upload: starting…')).toBeInTheDocument();
22
+ });
23
+
24
+ it('uses a custom label when provided', () => {
25
+ render(<UploadProgressBar progress={started} label="Image" />);
26
+ expect(screen.getByText('Image: starting…')).toBeInTheDocument();
27
+ });
28
+
29
+ it('renders an indeterminate bar (no role=progressbar in this phase)', () => {
30
+ const { container } = render(<UploadProgressBar progress={started} />);
31
+ expect(screen.getByRole('status')).toBeInTheDocument();
32
+ expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
33
+ expect(container.querySelector('.semiont-progress--indeterminate')).not.toBeNull();
34
+ });
35
+
36
+ it('marks the live region polite for assistive tech', () => {
37
+ render(<UploadProgressBar progress={started} />);
38
+ const status = screen.getByRole('status');
39
+ expect(status).toHaveAttribute('aria-live', 'polite');
40
+ });
41
+ });
42
+
43
+ describe('phase: progress (determinate)', () => {
44
+ it('renders percentage and byte counts when totalBytes is known', () => {
45
+ const progress: UploadProgress = {
46
+ phase: 'progress',
47
+ bytesUploaded: 512,
48
+ totalBytes: 2048,
49
+ };
50
+ render(<UploadProgressBar progress={progress} />);
51
+ expect(screen.getByText('Upload: 25%')).toBeInTheDocument();
52
+ expect(screen.getByText('512 B / 2.0 KB')).toBeInTheDocument();
53
+ });
54
+
55
+ it('rounds percentage to nearest integer', () => {
56
+ const progress: UploadProgress = {
57
+ phase: 'progress',
58
+ bytesUploaded: 333,
59
+ totalBytes: 1000,
60
+ };
61
+ render(<UploadProgressBar progress={progress} />);
62
+ // 333/1000 = 33.3% → rounds to 33
63
+ expect(screen.getByText('Upload: 33%')).toBeInTheDocument();
64
+ });
65
+
66
+ it('caps percentage at 100 when bytesUploaded exceeds totalBytes', () => {
67
+ const progress: UploadProgress = {
68
+ phase: 'progress',
69
+ bytesUploaded: 5000,
70
+ totalBytes: 1000,
71
+ };
72
+ render(<UploadProgressBar progress={progress} />);
73
+ expect(screen.getByText('Upload: 100%')).toBeInTheDocument();
74
+ const bar = screen.getByRole('progressbar');
75
+ expect(bar).toHaveAttribute('aria-valuenow', '100');
76
+ });
77
+
78
+ it('exposes ARIA progressbar attributes for determinate progress', () => {
79
+ const progress: UploadProgress = {
80
+ phase: 'progress',
81
+ bytesUploaded: 500,
82
+ totalBytes: 1000,
83
+ };
84
+ render(<UploadProgressBar progress={progress} />);
85
+ const bar = screen.getByRole('progressbar');
86
+ expect(bar).toHaveAttribute('aria-valuemin', '0');
87
+ expect(bar).toHaveAttribute('aria-valuemax', '100');
88
+ expect(bar).toHaveAttribute('aria-valuenow', '50');
89
+ });
90
+
91
+ it('sets the fill width inline style to the percentage', () => {
92
+ const progress: UploadProgress = {
93
+ phase: 'progress',
94
+ bytesUploaded: 750,
95
+ totalBytes: 1000,
96
+ };
97
+ const { container } = render(<UploadProgressBar progress={progress} />);
98
+ const fill = container.querySelector('.semiont-progress__fill') as HTMLElement;
99
+ expect(fill).not.toBeNull();
100
+ expect(fill.style.width).toBe('75%');
101
+ });
102
+
103
+ it('does not apply the indeterminate class when totalBytes is known', () => {
104
+ const progress: UploadProgress = {
105
+ phase: 'progress',
106
+ bytesUploaded: 100,
107
+ totalBytes: 1000,
108
+ };
109
+ const { container } = render(<UploadProgressBar progress={progress} />);
110
+ expect(container.querySelector('.semiont-progress--indeterminate')).toBeNull();
111
+ });
112
+
113
+ it('uses the custom label in determinate mode', () => {
114
+ const progress: UploadProgress = {
115
+ phase: 'progress',
116
+ bytesUploaded: 100,
117
+ totalBytes: 1000,
118
+ };
119
+ render(<UploadProgressBar progress={progress} label="Avatar" />);
120
+ expect(screen.getByText('Avatar: 10%')).toBeInTheDocument();
121
+ });
122
+ });
123
+
124
+ describe('phase: progress (indeterminate)', () => {
125
+ it('renders bytesUploaded only when totalBytes is 0', () => {
126
+ const progress: UploadProgress = {
127
+ phase: 'progress',
128
+ bytesUploaded: 4096,
129
+ totalBytes: 0,
130
+ };
131
+ render(<UploadProgressBar progress={progress} />);
132
+ expect(screen.getByText('Upload: 4.0 KB…')).toBeInTheDocument();
133
+ expect(screen.queryByText(/%/)).not.toBeInTheDocument();
134
+ });
135
+
136
+ it('renders bytesUploaded only when totalBytes is negative', () => {
137
+ const progress: UploadProgress = {
138
+ phase: 'progress',
139
+ bytesUploaded: 1234,
140
+ totalBytes: -1,
141
+ };
142
+ render(<UploadProgressBar progress={progress} />);
143
+ expect(screen.getByText('Upload: 1.2 KB…')).toBeInTheDocument();
144
+ });
145
+
146
+ it('omits aria-valuemax and aria-valuenow in indeterminate mode', () => {
147
+ const progress: UploadProgress = {
148
+ phase: 'progress',
149
+ bytesUploaded: 100,
150
+ totalBytes: 0,
151
+ };
152
+ render(<UploadProgressBar progress={progress} />);
153
+ const bar = screen.getByRole('progressbar');
154
+ expect(bar).toHaveAttribute('aria-valuemin', '0');
155
+ expect(bar).not.toHaveAttribute('aria-valuemax');
156
+ expect(bar).not.toHaveAttribute('aria-valuenow');
157
+ });
158
+
159
+ it('applies the indeterminate class and omits inline width', () => {
160
+ const progress: UploadProgress = {
161
+ phase: 'progress',
162
+ bytesUploaded: 100,
163
+ totalBytes: 0,
164
+ };
165
+ const { container } = render(<UploadProgressBar progress={progress} />);
166
+ expect(container.querySelector('.semiont-progress--indeterminate')).not.toBeNull();
167
+ const fill = container.querySelector('.semiont-progress__fill') as HTMLElement;
168
+ expect(fill.style.width).toBe('');
169
+ });
170
+ });
171
+
172
+ describe('phase: finished', () => {
173
+ const finished: UploadProgress = {
174
+ phase: 'finished',
175
+ resourceId: 'res-1' as ResourceId,
176
+ };
177
+
178
+ it('renders an "uploaded" label', () => {
179
+ render(<UploadProgressBar progress={finished} />);
180
+ expect(screen.getByText('Upload: uploaded')).toBeInTheDocument();
181
+ });
182
+
183
+ it('uses a custom label in the finished state', () => {
184
+ render(<UploadProgressBar progress={finished} label="Image" />);
185
+ expect(screen.getByText('Image: uploaded')).toBeInTheDocument();
186
+ });
187
+
188
+ it('reports 100% on the progressbar', () => {
189
+ render(<UploadProgressBar progress={finished} />);
190
+ const bar = screen.getByRole('progressbar');
191
+ expect(bar).toHaveAttribute('aria-valuemin', '0');
192
+ expect(bar).toHaveAttribute('aria-valuemax', '100');
193
+ expect(bar).toHaveAttribute('aria-valuenow', '100');
194
+ });
195
+
196
+ it('applies the success fill modifier and full width', () => {
197
+ const { container } = render(<UploadProgressBar progress={finished} />);
198
+ const fill = container.querySelector('.semiont-progress__fill--success') as HTMLElement;
199
+ expect(fill).not.toBeNull();
200
+ expect(fill.style.width).toBe('100%');
201
+ });
202
+ });
203
+
204
+ describe('byte formatting', () => {
205
+ // Exercises formatBytes() at every threshold via the visible label.
206
+ it.each([
207
+ [0, '0 B'],
208
+ [1023, '1023 B'],
209
+ [1024, '1.0 KB'],
210
+ [1024 * 1024 - 1, '1024.0 KB'],
211
+ [1024 * 1024, '1.0 MB'],
212
+ [1024 * 1024 * 1024 - 1, '1024.0 MB'],
213
+ [1024 * 1024 * 1024, '1.00 GB'],
214
+ [5 * 1024 * 1024 * 1024, '5.00 GB'],
215
+ ])('formats %i bytes as "%s"', (bytes, expected) => {
216
+ const progress: UploadProgress = {
217
+ phase: 'progress',
218
+ bytesUploaded: bytes,
219
+ totalBytes: 0,
220
+ };
221
+ render(<UploadProgressBar progress={progress} />);
222
+ expect(screen.getByText(`Upload: ${expected}…`)).toBeInTheDocument();
223
+ });
224
+ });
225
+ });
@@ -9,11 +9,13 @@
9
9
  import React, { useState, useEffect } from 'react';
10
10
  import type { GatheredContext } from '@semiont/core';
11
11
  import { isImageMimeType, isPdfMimeType, LOCALES } from '@semiont/core';
12
- import { type CloneData, type ReferenceData } from '@semiont/sdk';
13
- import { COMMON_PANELS, type ToolbarPanelType } from '@semiont/sdk';
12
+ import type { UploadProgress } from '@semiont/sdk';
13
+ import { type CloneData, type ReferenceData } from '../state/compose-page-state-unit';
14
+ import { COMMON_PANELS, type ToolbarPanelType } from '../../../state/shell-state-unit';
14
15
  import { buttonStyles } from '../../../lib/button-styles';
15
16
  import { CodeMirrorRenderer } from '../../../components/CodeMirrorRenderer';
16
17
  import { useFormAnnouncements } from '../../../components/LiveRegion';
18
+ import { UploadProgressBar } from './UploadProgressBar';
17
19
 
18
20
  export interface ResourceComposePageProps {
19
21
  mode: 'new' | 'clone' | 'reference';
@@ -37,6 +39,14 @@ export interface ResourceComposePageProps {
37
39
  onSaveResource: (params: SaveResourceParams) => Promise<void>;
38
40
  onCancel: () => void;
39
41
 
42
+ /**
43
+ * Live upload-progress for the in-flight save. Resolved by the route
44
+ * shell from `composeVM.uploadProgress$`. `null` between saves and
45
+ * after completion. When non-null, the form disables Save and the
46
+ * inline `<UploadProgressBar />` below the action buttons renders.
47
+ */
48
+ uploadProgress?: UploadProgress | null;
49
+
40
50
  // Translations
41
51
  translations: {
42
52
  title: string;
@@ -105,6 +115,7 @@ export function ResourceComposePage({
105
115
  activePanel,
106
116
  onSaveResource,
107
117
  onCancel,
118
+ uploadProgress = null,
108
119
  translations: t,
109
120
  ToolbarPanels,
110
121
  Toolbar,
@@ -655,14 +666,14 @@ export function ResourceComposePage({
655
666
  <button
656
667
  type="button"
657
668
  onClick={onCancel}
658
- disabled={isCreating}
669
+ disabled={isCreating || uploadProgress !== null}
659
670
  className={buttonStyles.tertiary.base}
660
671
  >
661
672
  {t.cancel}
662
673
  </button>
663
674
  <button
664
675
  type="submit"
665
- disabled={isCreating || !newResourceName.trim()}
676
+ disabled={isCreating || uploadProgress !== null || !newResourceName.trim()}
666
677
  className={buttonStyles.primary.base}
667
678
  >
668
679
  {isCreating
@@ -670,6 +681,10 @@ export function ResourceComposePage({
670
681
  : (isClone ? t.saveClonedResource : isReferenceCompletion ? t.createAndLinkResource : t.createResource)}
671
682
  </button>
672
683
  </div>
684
+
685
+ {/* Inline upload progress — renders below the action buttons while
686
+ an upload is in flight. `null` between saves and after completion. */}
687
+ <UploadProgressBar progress={uploadProgress} />
673
688
  </form>
674
689
  </div>
675
690
  </div>
@@ -0,0 +1,94 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Inline upload-progress affordance for the compose page.
5
+ *
6
+ * Subscribes (via prop) to a `UploadProgress | null` value derived from
7
+ * `composeVM.uploadProgress$`. Renders nothing when null; renders an
8
+ * indeterminate state on `started`; renders a labeled bar with byte
9
+ * counts on `progress`; renders a brief "Uploaded" success state on
10
+ * `finished` (cleared by the state unit's `null` push on complete).
11
+ *
12
+ * Designed to live below the Save button in the compose form so the
13
+ * visual association with the action that triggered the upload is
14
+ * direct. Uses the existing `.semiont-progress` styles in
15
+ * `packages/react-ui/src/styles/core/progress.css`.
16
+ */
17
+
18
+ import React from 'react';
19
+ import type { UploadProgress } from '@semiont/sdk';
20
+
21
+ export interface UploadProgressBarProps {
22
+ progress: UploadProgress | null;
23
+ /** Optional label for the "starting" / "uploaded" lines. Defaults to "Upload". */
24
+ label?: string;
25
+ }
26
+
27
+ function formatBytes(bytes: number): string {
28
+ if (bytes < 1024) return `${bytes} B`;
29
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
30
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
31
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
32
+ }
33
+
34
+ export function UploadProgressBar({ progress, label = 'Upload' }: UploadProgressBarProps): React.ReactElement | null {
35
+ if (!progress) return null;
36
+
37
+ if (progress.phase === 'started') {
38
+ return (
39
+ <div className="semiont-progress-wrapper" role="status" aria-live="polite">
40
+ <div className="semiont-progress__label">
41
+ <span>{label}: starting…</span>
42
+ </div>
43
+ <div className="semiont-progress semiont-progress--indeterminate">
44
+ <div className="semiont-progress__fill" />
45
+ </div>
46
+ </div>
47
+ );
48
+ }
49
+
50
+ if (progress.phase === 'progress') {
51
+ const indeterminate = progress.totalBytes <= 0;
52
+ const percentage = indeterminate
53
+ ? 0
54
+ : Math.min(100, Math.round((progress.bytesUploaded / progress.totalBytes) * 100));
55
+ return (
56
+ <div className="semiont-progress-wrapper" role="status" aria-live="polite">
57
+ <div className="semiont-progress__label">
58
+ {indeterminate ? (
59
+ <span>{label}: {formatBytes(progress.bytesUploaded)}…</span>
60
+ ) : (
61
+ <>
62
+ <span>{label}: {percentage}%</span>
63
+ <span>{formatBytes(progress.bytesUploaded)} / {formatBytes(progress.totalBytes)}</span>
64
+ </>
65
+ )}
66
+ </div>
67
+ <div
68
+ className={`semiont-progress${indeterminate ? ' semiont-progress--indeterminate' : ''}`}
69
+ role="progressbar"
70
+ aria-valuemin={0}
71
+ aria-valuemax={indeterminate ? undefined : 100}
72
+ aria-valuenow={indeterminate ? undefined : percentage}
73
+ >
74
+ <div
75
+ className="semiont-progress__fill"
76
+ style={indeterminate ? undefined : { width: `${percentage}%` }}
77
+ />
78
+ </div>
79
+ </div>
80
+ );
81
+ }
82
+
83
+ // phase === 'finished'
84
+ return (
85
+ <div className="semiont-progress-wrapper" role="status" aria-live="polite">
86
+ <div className="semiont-progress__label">
87
+ <span>{label}: uploaded</span>
88
+ </div>
89
+ <div className="semiont-progress" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={100}>
90
+ <div className="semiont-progress__fill semiont-progress__fill--success" style={{ width: '100%' }} />
91
+ </div>
92
+ </div>
93
+ );
94
+ }
@@ -0,0 +1,187 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { BehaviorSubject, Observable, firstValueFrom } from 'rxjs';
3
+ import { filter } from 'rxjs/operators';
4
+ import type { SemiontClient } from '@semiont/sdk';
5
+ import type { ShellStateUnit } from '../../../../state/shell-state-unit';
6
+ import { createComposePageStateUnit } from '../compose-page-state-unit';
7
+
8
+ /** Build an `UploadObservable`-shaped mock that emits started → finished. */
9
+ function mockUpload(resourceId: string) {
10
+ return vi.fn().mockReturnValue(
11
+ new Observable((subscriber) => {
12
+ subscriber.next({ phase: 'started', totalBytes: 100 });
13
+ subscriber.next({ phase: 'finished', resourceId });
14
+ subscriber.complete();
15
+ }),
16
+ );
17
+ }
18
+
19
+ function mockBrowse(): ShellStateUnit {
20
+ return { dispose: vi.fn() } as unknown as ShellStateUnit;
21
+ }
22
+
23
+ function mockClient(overrides: {
24
+ entityTypes$?: BehaviorSubject<string[] | undefined>;
25
+ fromToken?: ReturnType<typeof vi.fn>;
26
+ getResourceRepresentation?: ReturnType<typeof vi.fn>;
27
+ createFromToken?: ReturnType<typeof vi.fn>;
28
+ resource?: ReturnType<typeof vi.fn>;
29
+ body?: ReturnType<typeof vi.fn>;
30
+ } = {}): SemiontClient {
31
+ const entityTypes$ = overrides.entityTypes$ ?? new BehaviorSubject<string[] | undefined>(['Person']);
32
+ return {
33
+ browse: {
34
+ entityTypes: () => entityTypes$.asObservable(),
35
+ },
36
+ yield: {
37
+ fromToken: overrides.fromToken ?? vi.fn().mockResolvedValue({ '@id': 'src-1', representations: [{ mediaType: 'text/plain' }] }),
38
+ createFromToken: overrides.createFromToken ?? vi.fn().mockResolvedValue({ resourceId: 'new-1' }),
39
+ resource: overrides.resource ?? mockUpload('new-2'),
40
+ },
41
+ bind: {
42
+ body: overrides.body ?? vi.fn().mockResolvedValue(undefined),
43
+ },
44
+ getResourceRepresentation: overrides.getResourceRepresentation ?? vi.fn().mockResolvedValue({
45
+ data: new TextEncoder().encode('source content').buffer,
46
+ contentType: 'text/plain',
47
+ }),
48
+ } as unknown as SemiontClient;
49
+ }
50
+
51
+ describe('createComposePageStateUnit', () => {
52
+ it('defaults to "new" mode', async () => {
53
+ const stateUnit = createComposePageStateUnit(mockClient(), mockBrowse(), {});
54
+
55
+ const mode = await firstValueFrom(stateUnit.mode$);
56
+ expect(mode).toBe('new');
57
+
58
+ const loading = await firstValueFrom(stateUnit.loading$.pipe(filter((l) => !l)));
59
+ expect(loading).toBe(false);
60
+
61
+ stateUnit.dispose();
62
+ });
63
+
64
+ it('detects reference mode from params', async () => {
65
+ const stateUnit = createComposePageStateUnit(mockClient(), mockBrowse(), {
66
+ annotationUri: 'ann-1',
67
+ sourceDocumentId: 'doc-1',
68
+ name: 'Reference Doc',
69
+ entityTypes: 'Person,Place',
70
+ });
71
+
72
+ const mode = await firstValueFrom(stateUnit.mode$);
73
+ expect(mode).toBe('reference');
74
+
75
+ const ref = await firstValueFrom(stateUnit.referenceData$.pipe(filter((r) => r !== null)));
76
+ expect(ref!.annotationUri).toBe('ann-1');
77
+ expect(ref!.entityTypes).toEqual(['Person', 'Place']);
78
+
79
+ stateUnit.dispose();
80
+ });
81
+
82
+ it('parses storedContext in reference mode', async () => {
83
+ const context = { annotation: { id: 'ann-1' }, sourceContext: 'text' };
84
+ const stateUnit = createComposePageStateUnit(mockClient(), mockBrowse(), {
85
+ annotationUri: 'ann-1',
86
+ sourceDocumentId: 'doc-1',
87
+ name: 'Ref',
88
+ storedContext: JSON.stringify(context),
89
+ });
90
+
91
+ const gathered = await firstValueFrom(stateUnit.gatheredContext$.pipe(filter((g) => g !== null)));
92
+ expect(gathered).toEqual(context);
93
+
94
+ stateUnit.dispose();
95
+ });
96
+
97
+ it('ignores malformed storedContext', async () => {
98
+ const stateUnit = createComposePageStateUnit(mockClient(), mockBrowse(), {
99
+ annotationUri: 'ann-1',
100
+ sourceDocumentId: 'doc-1',
101
+ name: 'Ref',
102
+ storedContext: 'not-json{{{',
103
+ });
104
+
105
+ const loading = await firstValueFrom(stateUnit.loading$.pipe(filter((l) => !l)));
106
+ expect(loading).toBe(false);
107
+
108
+ const gathered = await firstValueFrom(stateUnit.gatheredContext$);
109
+ expect(gathered).toBeNull();
110
+
111
+ stateUnit.dispose();
112
+ });
113
+
114
+ it('exposes entity types', async () => {
115
+ const stateUnit = createComposePageStateUnit(mockClient(), mockBrowse(), {});
116
+
117
+ const types = await firstValueFrom(stateUnit.entityTypes$);
118
+ expect(types).toEqual(['Person']);
119
+
120
+ stateUnit.dispose();
121
+ });
122
+
123
+ it('save in new mode calls yield.resource', async () => {
124
+ const resource = mockUpload('new-3');
125
+ const stateUnit = createComposePageStateUnit(mockClient({ resource }), mockBrowse(), {});
126
+
127
+ const id = await stateUnit.save({
128
+ mode: 'new',
129
+ name: 'Test',
130
+ storageUri: '/docs/test.md',
131
+ content: '# Hello',
132
+ format: 'text/markdown',
133
+ language: 'en',
134
+ });
135
+
136
+ expect(id).toBe('new-3');
137
+ expect(resource).toHaveBeenCalledOnce();
138
+
139
+ stateUnit.dispose();
140
+ });
141
+
142
+ it('save in reference mode calls yield.resource then bind.body', async () => {
143
+ const resource = mockUpload('new-4');
144
+ const body = vi.fn().mockResolvedValue(undefined);
145
+ const stateUnit = createComposePageStateUnit(mockClient({ resource, body }), mockBrowse(), {
146
+ annotationUri: 'ann-1',
147
+ sourceDocumentId: 'doc-1',
148
+ name: 'Ref',
149
+ });
150
+
151
+ const id = await stateUnit.save({
152
+ mode: 'reference',
153
+ name: 'Ref Doc',
154
+ storageUri: '/docs/ref.md',
155
+ content: 'content',
156
+ language: 'en',
157
+ annotationUri: 'ann-1',
158
+ sourceDocumentId: 'doc-1',
159
+ });
160
+
161
+ expect(id).toBe('new-4');
162
+ expect(body).toHaveBeenCalledOnce();
163
+
164
+ stateUnit.dispose();
165
+ });
166
+
167
+ it('save in clone mode calls yield.createFromToken', async () => {
168
+ const createFromToken = vi.fn().mockResolvedValue({ resourceId: 'cloned-1' });
169
+ const stateUnit = createComposePageStateUnit(mockClient({ createFromToken }), mockBrowse(), {
170
+ mode: 'clone',
171
+ token: 'tok-abc',
172
+ });
173
+
174
+ const id = await stateUnit.save({
175
+ mode: 'clone',
176
+ name: 'Cloned',
177
+ storageUri: '/docs/cloned.md',
178
+ content: 'cloned content',
179
+ language: 'en',
180
+ });
181
+
182
+ expect(id).toBe('cloned-1');
183
+ expect(createFromToken).toHaveBeenCalledOnce();
184
+
185
+ stateUnit.dispose();
186
+ });
187
+ });