@semiont/react-ui 0.5.28 → 0.5.30

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 (113) hide show
  1. package/README.md +3 -3
  2. package/dist/{PdfAnnotationCanvas.client-C2IGG4SW.js → PdfAnnotationCanvas.client-LSH3PZPY.js} +4 -4
  3. package/dist/{ar-FABPTJVB.js → ar-AZBRADEB.js} +5 -1
  4. package/dist/{bn-LKP6BDMH.js → bn-CCEW6AOS.js} +5 -1
  5. package/dist/{chunk-VXASXU4K.js → chunk-3ZBKCWUZ.js} +1 -1
  6. package/dist/{chunk-2YHEE63S.js → chunk-G4VHL4UA.js} +31 -31
  7. package/dist/{chunk-2GOHTTHW.js → chunk-SDKFZLEX.js} +5 -1
  8. package/dist/{cs-YQYNWHRG.js → cs-HXPF2CTE.js} +5 -1
  9. package/dist/{da-QHZOYKRY.js → da-K5NTK7LH.js} +5 -1
  10. package/dist/{de-GKA7RD7J.js → de-GMRQEDZI.js} +5 -1
  11. package/dist/{el-45T257AL.js → el-EUB4CI4G.js} +5 -1
  12. package/dist/{en-RVW5EQ66.js → en-R5XA26NF.js} +2 -2
  13. package/dist/{es-AVER7RU4.js → es-Z32DAZ6W.js} +5 -1
  14. package/dist/{fa-K3VPK3O2.js → fa-G7HN6B32.js} +5 -1
  15. package/dist/{fi-5NEKF7EB.js → fi-3YBLZO7I.js} +5 -1
  16. package/dist/{fr-FUMNDLBZ.js → fr-VUBH7BBS.js} +5 -1
  17. package/dist/{he-3JGNA3PR.js → he-WQQEY26B.js} +5 -1
  18. package/dist/{hi-IJY375DF.js → hi-HJZ6W4JB.js} +5 -1
  19. package/dist/{id-LWHKJ7ZD.js → id-ZOGZ37MI.js} +5 -1
  20. package/dist/index.d.ts +157 -257
  21. package/dist/index.js +763 -1209
  22. package/dist/{it-EBVOV4RW.js → it-DT6UW66F.js} +5 -1
  23. package/dist/{ja-KWRIX2NF.js → ja-RUKQCOLO.js} +5 -1
  24. package/dist/{ko-L5CJMFDS.js → ko-ZH5ADKZM.js} +5 -1
  25. package/dist/{ms-LSNMJDR2.js → ms-Y2AFPZTU.js} +5 -1
  26. package/dist/{nl-ZB32MB7U.js → nl-OQEWIXNA.js} +5 -1
  27. package/dist/{no-4GTNY7M5.js → no-KPAYJZWY.js} +5 -1
  28. package/dist/{pl-LUHBLTHB.js → pl-WSXK2UUD.js} +5 -1
  29. package/dist/{pt-C4H3UNQ6.js → pt-NWRJRUCK.js} +5 -1
  30. package/dist/{ro-GGITS5V4.js → ro-3CGVMOC6.js} +5 -1
  31. package/dist/{sv-T56RTLBP.js → sv-HGSEBA7S.js} +5 -1
  32. package/dist/test-utils.js +16 -7
  33. package/dist/{th-22X2IWCB.js → th-NZ4G352Z.js} +5 -1
  34. package/dist/{tr-77TFG62H.js → tr-LGGS4DLW.js} +5 -1
  35. package/dist/{uk-MIIL6NXT.js → uk-AIX536L4.js} +5 -1
  36. package/dist/{vi-7JYOIGNV.js → vi-V6CVQZLQ.js} +5 -1
  37. package/dist/{zh-ID6DRYU4.js → zh-AGKPGI3L.js} +5 -1
  38. package/package.json +13 -13
  39. package/src/components/StatusDisplay.tsx +13 -13
  40. package/src/components/__tests__/StatusDisplay.test.tsx +9 -9
  41. package/src/components/modals/ComposeStep.tsx +19 -4
  42. package/src/components/modals/ConfigureGenerationStep.tsx +143 -15
  43. package/src/components/modals/ReferenceWizardModal.tsx +14 -9
  44. package/src/components/modals/ResourceGenerateModal.tsx +10 -5
  45. package/src/components/modals/__tests__/ConfigureGenerationStep.test.tsx +171 -8
  46. package/src/components/modals/__tests__/ReferenceWizardModal.test.tsx +25 -1
  47. package/src/components/modals/__tests__/ResourceGenerateModal.test.tsx +24 -1
  48. package/src/components/modals/__tests__/WizardFooter.test.tsx +3 -5
  49. package/src/components/resource/BrowseView.tsx +3 -3
  50. package/src/components/resource/DownloadFileLink.tsx +1 -1
  51. package/src/components/resource/__tests__/BrowseView.reference-hover.test.tsx +1 -1
  52. package/src/components/resource/__tests__/BrowseView.test.tsx +2 -2
  53. package/src/components/resource/__tests__/download-link.test.tsx +6 -6
  54. package/src/components/resource/panels/ReferenceEntry.tsx +2 -2
  55. package/src/components/resource/panels/ReferencesPanel.tsx +5 -1
  56. package/src/components/resource/panels/__tests__/ReferencesPanel.observable-flow.test.tsx +1 -1
  57. package/src/components/resource/panels/__tests__/ReferencesPanel.test.tsx +51 -2
  58. package/src/components/resource/panels/__tests__/ResourceInfoPanel.test.tsx +1 -1
  59. package/src/features/auth/__tests__/SignInForm.a11y.test.tsx +12 -12
  60. package/src/features/auth/components/SignInForm.tsx +53 -53
  61. package/src/features/auth-welcome/state/__tests__/welcome-state-unit.test.ts +2 -2
  62. package/src/features/moderate-recent/__tests__/RecentDocumentsPage.test.tsx +2 -2
  63. package/src/features/resource-compose/components/ResourceComposePage.tsx +18 -3
  64. package/src/features/resource-discovery/__tests__/ResourceDiscoveryPage.test.tsx +1 -1
  65. package/src/features/resource-discovery/components/ResourceDiscoveryPage.tsx +2 -2
  66. package/src/features/resource-discovery/state/discover-state-unit.ts +1 -1
  67. package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +2 -2
  68. package/src/features/resource-viewer/__tests__/generation-options.test.ts +53 -0
  69. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +16 -25
  70. package/src/features/resource-viewer/generation-options.ts +29 -0
  71. package/src/styles/index.css +0 -1
  72. package/translations/ar.json +4 -0
  73. package/translations/bn.json +4 -0
  74. package/translations/cs.json +4 -0
  75. package/translations/da.json +4 -0
  76. package/translations/de.json +4 -0
  77. package/translations/el.json +4 -0
  78. package/translations/en.json +4 -0
  79. package/translations/es.json +4 -0
  80. package/translations/fa.json +4 -0
  81. package/translations/fi.json +4 -0
  82. package/translations/fr.json +4 -0
  83. package/translations/he.json +4 -0
  84. package/translations/hi.json +4 -0
  85. package/translations/id.json +4 -0
  86. package/translations/it.json +4 -0
  87. package/translations/ja.json +4 -0
  88. package/translations/ko.json +4 -0
  89. package/translations/ms.json +4 -0
  90. package/translations/nl.json +4 -0
  91. package/translations/no.json +4 -0
  92. package/translations/pl.json +4 -0
  93. package/translations/pt.json +4 -0
  94. package/translations/ro.json +4 -0
  95. package/translations/sv.json +4 -0
  96. package/translations/th.json +4 -0
  97. package/translations/tr.json +4 -0
  98. package/translations/uk.json +4 -0
  99. package/translations/vi.json +4 -0
  100. package/translations/zh.json +4 -0
  101. package/src/features/admin-exchange/__tests__/AdminExchangePage.test.tsx +0 -137
  102. package/src/features/admin-exchange/__tests__/ExportCard.test.tsx +0 -41
  103. package/src/features/admin-exchange/__tests__/ImportCard.test.tsx +0 -148
  104. package/src/features/admin-exchange/__tests__/ImportProgress.test.tsx +0 -117
  105. package/src/features/admin-exchange/components/AdminExchangePage.tsx +0 -117
  106. package/src/features/admin-exchange/components/ExportCard.tsx +0 -35
  107. package/src/features/admin-exchange/components/ImportCard.tsx +0 -183
  108. package/src/features/admin-exchange/components/ImportProgress.tsx +0 -80
  109. package/src/features/admin-exchange/state/__tests__/exchange-state-unit.test.ts +0 -185
  110. package/src/features/admin-exchange/state/exchange-state-unit.ts +0 -133
  111. package/src/features/moderation-linked-data/__tests__/LinkedDataPage.test.tsx +0 -118
  112. package/src/features/moderation-linked-data/components/LinkedDataPage.tsx +0 -118
  113. package/src/styles/features/exchange.css +0 -374
@@ -1,183 +0,0 @@
1
- /**
2
- * ImportCard — File drop zone, preview, and import trigger
3
- *
4
- * Pure React component. All state and handlers passed as props.
5
- */
6
-
7
- import React, { useRef, useState, useCallback } from 'react';
8
- import type { ImportPreview } from '../state/exchange-state-unit';
9
- export type { ImportPreview };
10
-
11
- export interface ImportCardTranslations {
12
- title: string;
13
- description: string;
14
- dropzoneLabel: string;
15
- dropzoneActive: string;
16
- detectedFormat: string;
17
- statsPreview: string;
18
- importButton: string;
19
- importing: string;
20
- importConfirmTitle: string;
21
- importConfirmMessage: string;
22
- confirmImport: string;
23
- cancelImport: string;
24
- }
25
-
26
- export interface ImportCardProps {
27
- onFileSelected: (file: File) => void;
28
- onImport: () => void;
29
- onCancel: () => void;
30
- selectedFile: File | null;
31
- preview: ImportPreview | null;
32
- isImporting: boolean;
33
- translations: ImportCardTranslations;
34
- }
35
-
36
- export function ImportCard({
37
- onFileSelected,
38
- onImport,
39
- onCancel,
40
- selectedFile,
41
- preview,
42
- isImporting,
43
- translations: t,
44
- }: ImportCardProps) {
45
- const fileInputRef = useRef<HTMLInputElement>(null);
46
- const [isDragActive, setIsDragActive] = useState(false);
47
- const [showConfirm, setShowConfirm] = useState(false);
48
-
49
- const handleDragOver = useCallback((e: React.DragEvent) => {
50
- e.preventDefault();
51
- setIsDragActive(true);
52
- }, []);
53
-
54
- const handleDragLeave = useCallback(() => {
55
- setIsDragActive(false);
56
- }, []);
57
-
58
- const handleDrop = useCallback((e: React.DragEvent) => {
59
- e.preventDefault();
60
- setIsDragActive(false);
61
- setShowConfirm(false);
62
- const file = e.dataTransfer.files[0];
63
- if (file) onFileSelected(file);
64
- }, [onFileSelected]);
65
-
66
- const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
67
- setShowConfirm(false);
68
- const file = e.target.files?.[0];
69
- if (file) onFileSelected(file);
70
- }, [onFileSelected]);
71
-
72
- const handleImportClick = () => {
73
- setShowConfirm(true);
74
- };
75
-
76
- const handleConfirm = () => {
77
- setShowConfirm(false);
78
- onImport();
79
- };
80
-
81
- const handleCancelConfirm = () => {
82
- setShowConfirm(false);
83
- };
84
-
85
- return (
86
- <div className="semiont-admin__card">
87
- <h2 className="semiont-admin__section-title">{t.title}</h2>
88
- <p className="semiont-admin__section-description">{t.description}</p>
89
-
90
- <div
91
- className={`semiont-exchange__dropzone${isDragActive ? ' semiont-exchange__dropzone--active' : ''}`}
92
- onDragOver={handleDragOver}
93
- onDragLeave={handleDragLeave}
94
- onDrop={handleDrop}
95
- onClick={() => fileInputRef.current?.click()}
96
- >
97
- <p className="semiont-exchange__dropzone-text">
98
- {isDragActive ? t.dropzoneActive : t.dropzoneLabel}
99
- </p>
100
- {selectedFile && (
101
- <p className="semiont-exchange__file-info">
102
- {selectedFile.name} ({(selectedFile.size / 1024).toFixed(1)} KB)
103
- </p>
104
- )}
105
- </div>
106
-
107
- <input
108
- ref={fileInputRef}
109
- type="file"
110
- accept=".tar.gz,.jsonl,.gz"
111
- onChange={handleFileChange}
112
- style={{ display: 'none' }}
113
- />
114
-
115
- {preview && (
116
- <div className="semiont-exchange__preview">
117
- <div className="semiont-exchange__preview-row">
118
- <span className="semiont-exchange__preview-label">{t.detectedFormat}</span>
119
- <span className="semiont-exchange__preview-value">
120
- {preview.format} v{preview.version}
121
- </span>
122
- </div>
123
- {preview.sourceUrl && (
124
- <div className="semiont-exchange__preview-row">
125
- <span className="semiont-exchange__preview-label">Source</span>
126
- <span className="semiont-exchange__preview-value">{preview.sourceUrl}</span>
127
- </div>
128
- )}
129
- {Object.keys(preview.stats).length > 0 && (
130
- <div className="semiont-exchange__preview-row">
131
- <span className="semiont-exchange__preview-label">{t.statsPreview}</span>
132
- <span className="semiont-exchange__preview-value">
133
- {Object.entries(preview.stats).map(([k, v]) => `${v} ${k}`).join(', ')}
134
- </span>
135
- </div>
136
- )}
137
- </div>
138
- )}
139
-
140
- {showConfirm && (
141
- <div className="semiont-exchange__confirm">
142
- <p className="semiont-exchange__confirm-title">{t.importConfirmTitle}</p>
143
- <p className="semiont-exchange__confirm-message">{t.importConfirmMessage}</p>
144
- <div className="semiont-exchange__confirm-actions">
145
- <button
146
- className="semiont-exchange__button semiont-exchange__button--primary"
147
- onClick={handleConfirm}
148
- >
149
- {t.confirmImport}
150
- </button>
151
- <button
152
- className="semiont-exchange__button semiont-exchange__button--secondary"
153
- onClick={handleCancelConfirm}
154
- >
155
- {t.cancelImport}
156
- </button>
157
- </div>
158
- </div>
159
- )}
160
-
161
- {!showConfirm && (
162
- <div className="semiont-exchange__actions">
163
- <button
164
- className="semiont-exchange__button semiont-exchange__button--primary"
165
- onClick={handleImportClick}
166
- disabled={!preview || isImporting}
167
- >
168
- {isImporting ? t.importing : t.importButton}
169
- </button>
170
- {selectedFile && (
171
- <button
172
- className="semiont-exchange__button semiont-exchange__button--secondary"
173
- onClick={onCancel}
174
- disabled={isImporting}
175
- >
176
- {t.cancelImport}
177
- </button>
178
- )}
179
- </div>
180
- )}
181
- </div>
182
- );
183
- }
@@ -1,80 +0,0 @@
1
- /**
2
- * ImportProgress — Shows SSE-driven progress during restore
3
- *
4
- * Pure React component. All state passed as props.
5
- */
6
-
7
- export interface ImportProgressTranslations {
8
- phaseStarted: string;
9
- phaseComplete: string;
10
- phaseError: string;
11
- statsEventsReplayed: string;
12
- statsResourcesCreated: string;
13
- statsAnnotationsCreated: string;
14
- statsEntityTypesAdded: string;
15
- }
16
-
17
- export interface ImportProgressProps {
18
- phase: string;
19
- message?: string;
20
- result?: Record<string, unknown>;
21
- translations: ImportProgressTranslations;
22
- }
23
-
24
- const PHASE_LABELS: Record<string, keyof ImportProgressTranslations> = {
25
- started: 'phaseStarted',
26
- complete: 'phaseComplete',
27
- error: 'phaseError',
28
- };
29
-
30
- const STAT_LABELS: Record<string, keyof ImportProgressTranslations> = {
31
- eventsReplayed: 'statsEventsReplayed',
32
- resourcesCreated: 'statsResourcesCreated',
33
- annotationsCreated: 'statsAnnotationsCreated',
34
- entityTypesAdded: 'statsEntityTypesAdded',
35
- };
36
-
37
- export function ImportProgress({ phase, message, result, translations: t }: ImportProgressProps) {
38
- const labelKey = PHASE_LABELS[phase];
39
- const phaseLabel = labelKey ? t[labelKey] : phase;
40
-
41
- const isComplete = phase === 'complete';
42
- const isError = phase === 'error';
43
-
44
- const stats = result?.stats != null && typeof result.stats === 'object'
45
- ? (result.stats as Record<string, unknown>)
46
- : undefined;
47
- const statEntries = stats
48
- ? Object.entries(stats).filter((entry): entry is [string, number] => typeof entry[1] === 'number')
49
- : [];
50
-
51
- return (
52
- <div className="semiont-exchange__progress">
53
- <div className={`semiont-exchange__phase-label${isError ? ' semiont-exchange__phase-label--error' : isComplete ? ' semiont-exchange__phase-label--complete' : ''}`}>
54
- {phaseLabel}
55
- </div>
56
-
57
- {message && !isComplete && !isError && (
58
- <p className="semiont-exchange__progress-message">{message}</p>
59
- )}
60
-
61
- {isComplete && statEntries.length > 0 && (
62
- <div className="semiont-exchange__result">
63
- {statEntries.map(([key, value]) => {
64
- const statKey = STAT_LABELS[key];
65
- return (
66
- <div key={key} className="semiont-exchange__result-stat">
67
- <span className="semiont-exchange__result-value">{value}</span>
68
- <span className="semiont-exchange__result-label">{statKey ? t[statKey] : key}</span>
69
- </div>
70
- );
71
- })}
72
- </div>
73
- )}
74
-
75
- {isError && message && (
76
- <p className="semiont-exchange__error-message">{message}</p>
77
- )}
78
- </div>
79
- );
80
- }
@@ -1,185 +0,0 @@
1
- import { describe, it, expect, vi } from 'vitest';
2
- import { firstValueFrom, of, throwError } from 'rxjs';
3
- import type { ShellStateUnit } from '../../../../state/shell-state-unit';
4
- import { createExchangeStateUnit } from '../exchange-state-unit';
5
- import { assertStateUnitAxioms, disposeProbe } from '@semiont/core/testing/axioms';
6
-
7
- function mockBrowse(): ShellStateUnit {
8
- return { dispose: vi.fn() } as unknown as ShellStateUnit;
9
- }
10
-
11
- function makeMockFile(name: string): File {
12
- return new File(['content'], name, { type: 'application/gzip' });
13
- }
14
-
15
- describe('createExchangeStateUnit', () => {
16
- it('initializes with null/empty state', async () => {
17
- const stateUnit = createExchangeStateUnit(mockBrowse(), vi.fn(), vi.fn());
18
-
19
- expect(await firstValueFrom(stateUnit.selectedFile$)).toBeNull();
20
- expect(await firstValueFrom(stateUnit.preview$)).toBeNull();
21
- expect(await firstValueFrom(stateUnit.importPhase$)).toBeNull();
22
- expect(await firstValueFrom(stateUnit.isExporting$)).toBe(false);
23
- expect(await firstValueFrom(stateUnit.isImporting$)).toBe(false);
24
-
25
- stateUnit.dispose();
26
- });
27
-
28
- it('selectFile sets file and generates preview', async () => {
29
- const stateUnit = createExchangeStateUnit(mockBrowse(), vi.fn(), vi.fn());
30
-
31
- stateUnit.selectFile(makeMockFile('backup.tar.gz'));
32
-
33
- const file = await firstValueFrom(stateUnit.selectedFile$);
34
- expect(file?.name).toBe('backup.tar.gz');
35
-
36
- const preview = await firstValueFrom(stateUnit.preview$);
37
- expect(preview?.format).toBe('semiont-linked-data');
38
-
39
- stateUnit.dispose();
40
- });
41
-
42
- it('selectFile detects unknown format', async () => {
43
- const stateUnit = createExchangeStateUnit(mockBrowse(), vi.fn(), vi.fn());
44
-
45
- stateUnit.selectFile(makeMockFile('data.json'));
46
-
47
- const preview = await firstValueFrom(stateUnit.preview$);
48
- expect(preview?.format).toBe('unknown');
49
-
50
- stateUnit.dispose();
51
- });
52
-
53
- it('cancelImport resets all state', async () => {
54
- const stateUnit = createExchangeStateUnit(mockBrowse(), vi.fn(), vi.fn());
55
-
56
- stateUnit.selectFile(makeMockFile('backup.tar.gz'));
57
- stateUnit.cancelImport();
58
-
59
- expect(await firstValueFrom(stateUnit.selectedFile$)).toBeNull();
60
- expect(await firstValueFrom(stateUnit.preview$)).toBeNull();
61
- expect(await firstValueFrom(stateUnit.importPhase$)).toBeNull();
62
-
63
- stateUnit.dispose();
64
- });
65
-
66
- // jsdom doesn't implement `Blob.prototype.stream()` portably, so build
67
- // the stream literal — same shape, no environment dependency.
68
- function streamOf(text: string): ReadableStream<Uint8Array> {
69
- return new ReadableStream<Uint8Array>({
70
- start(controller) {
71
- controller.enqueue(new TextEncoder().encode(text));
72
- controller.close();
73
- },
74
- });
75
- }
76
-
77
- it('doExport calls exportFn and returns blob + filename from BackendDownload', async () => {
78
- // exportFn now returns a BackendDownload — a transport-neutral
79
- // { stream, contentType, filename? } object. The state unit converts the
80
- // stream to a Blob and threads filename through.
81
- const exportFn = vi.fn().mockResolvedValue({
82
- stream: streamOf('data'),
83
- contentType: 'application/x-tar',
84
- filename: 'export.tar.gz',
85
- });
86
-
87
- const stateUnit = createExchangeStateUnit(mockBrowse(), exportFn, vi.fn());
88
-
89
- const result = await stateUnit.doExport();
90
- expect(result.filename).toBe('export.tar.gz');
91
- expect(await result.blob.text()).toBe('data');
92
-
93
- expect(await firstValueFrom(stateUnit.isExporting$)).toBe(false);
94
-
95
- stateUnit.dispose();
96
- });
97
-
98
- it('doExport falls back to a synthesized filename when the download omits one', async () => {
99
- const exportFn = vi.fn().mockResolvedValue({
100
- stream: streamOf('data'),
101
- contentType: 'application/x-tar',
102
- // no filename
103
- });
104
-
105
- const stateUnit = createExchangeStateUnit(mockBrowse(), exportFn, vi.fn());
106
-
107
- const result = await stateUnit.doExport();
108
- expect(result.filename).toMatch(/^semiont-export-\d+\.tar\.gz$/);
109
-
110
- stateUnit.dispose();
111
- });
112
-
113
- it('doExport propagates errors from exportFn and clears isExporting$', async () => {
114
- // The state unit no longer inspects HTTP status — non-OK responses are the
115
- // transport's concern (ky throws on non-OK by default). The state unit just
116
- // propagates whatever the exportFn rejects with and resets state.
117
- const exportFn = vi.fn().mockRejectedValue(new Error('transport boom'));
118
-
119
- const stateUnit = createExchangeStateUnit(mockBrowse(), exportFn, vi.fn());
120
-
121
- await expect(stateUnit.doExport()).rejects.toThrow('transport boom');
122
- expect(await firstValueFrom(stateUnit.isExporting$)).toBe(false);
123
-
124
- stateUnit.dispose();
125
- });
126
-
127
- it('doImport subscribes to importFn Observable and mirrors each progress event', async () => {
128
- // importFn now returns Observable<ProgressEvent>. The state unit subscribes,
129
- // mirrors each emit into its state subjects, and resolves when the
130
- // observable completes.
131
- const importFn = vi.fn().mockReturnValue(
132
- of(
133
- { phase: 'uploading', message: '50%' },
134
- { phase: 'complete', result: { resources: 10 } },
135
- ),
136
- );
137
-
138
- const stateUnit = createExchangeStateUnit(mockBrowse(), vi.fn(), importFn);
139
- stateUnit.selectFile(makeMockFile('import.tar.gz'));
140
-
141
- await stateUnit.doImport();
142
-
143
- expect(importFn).toHaveBeenCalledOnce();
144
- expect(await firstValueFrom(stateUnit.importResult$)).toEqual({ resources: 10 });
145
- expect(await firstValueFrom(stateUnit.importPhase$)).toBe('complete');
146
- expect(await firstValueFrom(stateUnit.isImporting$)).toBe(false);
147
-
148
- stateUnit.dispose();
149
- });
150
-
151
- it('doImport propagates errors from the importFn Observable and clears isImporting$', async () => {
152
- const importFn = vi.fn().mockReturnValue(throwError(() => new Error('import boom')));
153
-
154
- const stateUnit = createExchangeStateUnit(mockBrowse(), vi.fn(), importFn);
155
- stateUnit.selectFile(makeMockFile('import.tar.gz'));
156
-
157
- await expect(stateUnit.doImport()).rejects.toThrow('import boom');
158
- expect(await firstValueFrom(stateUnit.isImporting$)).toBe(false);
159
-
160
- stateUnit.dispose();
161
- });
162
-
163
- it('doImport is no-op without selected file', async () => {
164
- const importFn = vi.fn();
165
- const stateUnit = createExchangeStateUnit(mockBrowse(), vi.fn(), importFn);
166
-
167
- await stateUnit.doImport();
168
- expect(importFn).not.toHaveBeenCalled();
169
-
170
- stateUnit.dispose();
171
- });
172
- });
173
-
174
- describe('ExchangeStateUnit — StateUnit axioms', () => {
175
- it('satisfies the StateUnit axioms (incl. A7-passed: never disposes the injected browse)', () => {
176
- assertStateUnitAxioms({
177
- setup: () => {
178
- const browse = disposeProbe();
179
- return { unit: createExchangeStateUnit(browse as unknown as ShellStateUnit, vi.fn(), vi.fn()), passedIn: [browse] };
180
- },
181
- surfaces: (u) => [u.selectedFile$, u.preview$, u.importPhase$, u.importMessage$, u.importResult$, u.isExporting$, u.isImporting$],
182
- invocations: (u) => [() => u.cancelImport(), () => u.doImport()],
183
- });
184
- });
185
- });
@@ -1,133 +0,0 @@
1
- import { BehaviorSubject, lastValueFrom, type Observable } from 'rxjs';
2
- import { tap } from 'rxjs/operators';
3
- import { createDisposer } from '@semiont/sdk';
4
- import type { StateUnit } from '@semiont/core';
5
- import type { ShellStateUnit } from '../../../state/shell-state-unit';
6
- import type { BackendDownload, ProgressEvent } from '@semiont/core';
7
-
8
- export interface ImportPreview {
9
- format: string;
10
- version: number;
11
- sourceUrl: string;
12
- stats: Record<string, number>;
13
- }
14
-
15
- export interface ExchangeStateUnit extends StateUnit {
16
- browse: ShellStateUnit;
17
- selectedFile$: Observable<File | null>;
18
- preview$: Observable<ImportPreview | null>;
19
- importPhase$: Observable<string | null>;
20
- importMessage$: Observable<string | undefined>;
21
- importResult$: Observable<Record<string, unknown> | undefined>;
22
- isExporting$: Observable<boolean>;
23
- isImporting$: Observable<boolean>;
24
- selectFile(file: File): void;
25
- cancelImport(): void;
26
- doExport(): Promise<{ blob: Blob; filename: string }>;
27
- doImport(): Promise<void>;
28
- }
29
-
30
- export function createExchangeStateUnit(
31
- browse: ShellStateUnit,
32
- exportFn: (params?: { includeArchived?: boolean }) => Promise<BackendDownload>,
33
- importFn: (file: File) => Observable<ProgressEvent>,
34
- ): ExchangeStateUnit {
35
- const disposer = createDisposer();
36
- // `browse` (ShellStateUnit) is a *passed-in* dependency owned by the caller
37
- // (`useShellStateUnit`), not this unit — do NOT add it to the disposer (it's the
38
- // shared, app-scoped shell). See packages/sdk/docs/STATE-UNITS.md (composition rule).
39
-
40
- const selectedFile$ = new BehaviorSubject<File | null>(null);
41
- const preview$ = new BehaviorSubject<ImportPreview | null>(null);
42
- const importPhase$ = new BehaviorSubject<string | null>(null);
43
- const importMessage$ = new BehaviorSubject<string | undefined>(undefined);
44
- const importResult$ = new BehaviorSubject<Record<string, unknown> | undefined>(undefined);
45
- const isExporting$ = new BehaviorSubject<boolean>(false);
46
- const isImporting$ = new BehaviorSubject<boolean>(false);
47
-
48
- const selectFile = (file: File): void => {
49
- selectedFile$.next(file);
50
- importPhase$.next(null);
51
- importMessage$.next(undefined);
52
- importResult$.next(undefined);
53
- preview$.next({
54
- format: file.name.endsWith('.tar.gz') || file.name.endsWith('.gz') ? 'semiont-linked-data' : 'unknown',
55
- version: 1,
56
- sourceUrl: '',
57
- stats: {} as Record<string, number>,
58
- });
59
- };
60
-
61
- const cancelImport = (): void => {
62
- selectedFile$.next(null);
63
- preview$.next(null);
64
- importPhase$.next(null);
65
- importMessage$.next(undefined);
66
- importResult$.next(undefined);
67
- };
68
-
69
- const doExport = async (): Promise<{ blob: Blob; filename: string }> => {
70
- isExporting$.next(true);
71
- try {
72
- const download = await exportFn();
73
- // Wrap the stream in a Response purely as a Blob-collection helper —
74
- // BackendDownload itself carries no fetch dependency.
75
- const blob = await new Response(download.stream).blob();
76
- const filename = download.filename ?? `semiont-export-${Date.now()}.tar.gz`;
77
- return { blob, filename };
78
- } finally {
79
- isExporting$.next(false);
80
- }
81
- };
82
-
83
- const doImport = async (): Promise<void> => {
84
- const file = selectedFile$.getValue();
85
- if (!file) return;
86
- isImporting$.next(true);
87
- importPhase$.next('started');
88
- importMessage$.next(undefined);
89
- importResult$.next(undefined);
90
- try {
91
- // The importFn is `Observable<ProgressEvent>` — every emit is a
92
- // progress event; the final emit before complete is the outcome.
93
- // `tap` mirrors each event into our state subjects; `lastValueFrom`
94
- // awaits the last value (so callers can `await vm.doImport()`).
95
- await lastValueFrom(
96
- importFn(file).pipe(
97
- tap((event) => {
98
- importPhase$.next(event.phase);
99
- importMessage$.next(event.message);
100
- if (event.result) importResult$.next(event.result);
101
- }),
102
- ),
103
- );
104
- } finally {
105
- isImporting$.next(false);
106
- }
107
- };
108
-
109
- return {
110
- browse,
111
- selectedFile$: selectedFile$.asObservable(),
112
- preview$: preview$.asObservable(),
113
- importPhase$: importPhase$.asObservable(),
114
- importMessage$: importMessage$.asObservable(),
115
- importResult$: importResult$.asObservable(),
116
- isExporting$: isExporting$.asObservable(),
117
- isImporting$: isImporting$.asObservable(),
118
- selectFile,
119
- cancelImport,
120
- doExport,
121
- doImport,
122
- dispose: () => {
123
- selectedFile$.complete();
124
- preview$.complete();
125
- importPhase$.complete();
126
- importMessage$.complete();
127
- importResult$.complete();
128
- isExporting$.complete();
129
- isImporting$.complete();
130
- disposer.dispose();
131
- },
132
- };
133
- }