@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.
- package/README.md +13 -0
- package/dist/{chunk-4NOUO3W6.mjs → chunk-7VWNZ5YX.mjs} +5032 -2876
- package/dist/chunk-7VWNZ5YX.mjs.map +1 -0
- package/dist/index.d.mts +292 -25
- package/dist/index.mjs +1021 -332
- package/dist/index.mjs.map +1 -1
- package/dist/test-utils.d.mts +1 -1
- package/dist/test-utils.mjs +4 -2352
- package/dist/test-utils.mjs.map +1 -1
- package/package.json +1 -1
- package/src/components/StatusDisplay.tsx +1 -1
- package/src/components/modals/PermissionDeniedModal.tsx +2 -2
- package/src/components/modals/SessionExpiredModal.tsx +4 -4
- package/src/components/resource/panels/AssessmentPanel.tsx +4 -0
- package/src/components/resource/panels/AssistSection.tsx +10 -1
- package/src/components/resource/panels/CollaborationPanel.tsx +1 -1
- package/src/components/resource/panels/CommentsPanel.tsx +4 -0
- package/src/components/resource/panels/HighlightPanel.tsx +4 -0
- package/src/components/resource/panels/ReferencesPanel.tsx +11 -0
- package/src/components/resource/panels/TaggingPanel.tsx +10 -0
- package/src/components/resource/panels/UnifiedAnnotationsPanel.tsx +11 -1
- package/src/components/resource/panels/__tests__/ReferencesPanel.observable-flow.test.tsx +2 -2
- package/src/features/admin-devops/components/AdminDevOpsPage.tsx +1 -1
- package/src/features/admin-exchange/components/AdminExchangePage.tsx +1 -1
- package/src/features/admin-exchange/components/ImportCard.tsx +1 -1
- package/src/features/admin-exchange/state/__tests__/exchange-state-unit.test.ts +171 -0
- package/src/features/admin-exchange/state/exchange-state-unit.ts +131 -0
- package/src/features/admin-security/components/AdminSecurityPage.tsx +1 -1
- package/src/features/admin-security/state/__tests__/admin-security-state-unit.test.ts +68 -0
- package/src/features/admin-security/state/admin-security-state-unit.ts +46 -0
- package/src/features/admin-users/components/AdminUsersPage.tsx +1 -1
- package/src/features/admin-users/state/__tests__/admin-users-state-unit.test.ts +86 -0
- package/src/features/admin-users/state/admin-users-state-unit.ts +73 -0
- package/src/features/auth-welcome/state/__tests__/welcome-state-unit.test.ts +86 -0
- package/src/features/auth-welcome/state/welcome-state-unit.ts +44 -0
- package/src/features/moderate-entity-tags/components/EntityTagsPage.tsx +1 -1
- package/src/features/moderate-entity-tags/state/__tests__/entity-tags-state-unit.test.ts +102 -0
- package/src/features/moderate-entity-tags/state/entity-tags-state-unit.ts +64 -0
- package/src/features/moderate-recent/components/RecentDocumentsPage.tsx +1 -1
- package/src/features/moderate-tag-schemas/components/TagSchemasPage.tsx +1 -1
- package/src/features/moderation-linked-data/components/LinkedDataPage.tsx +1 -1
- package/src/features/resource-compose/__tests__/UploadProgressBar.test.tsx +225 -0
- package/src/features/resource-compose/components/ResourceComposePage.tsx +19 -4
- package/src/features/resource-compose/components/UploadProgressBar.tsx +94 -0
- package/src/features/resource-compose/state/__tests__/compose-page-state-unit.test.ts +187 -0
- package/src/features/resource-compose/state/compose-page-state-unit.ts +209 -0
- package/src/features/resource-discovery/components/ResourceDiscoveryPage.tsx +1 -1
- package/src/features/resource-discovery/state/__tests__/discover-state-unit.test.ts +76 -0
- package/src/features/resource-discovery/state/discover-state-unit.ts +54 -0
- package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +4 -2
- package/src/features/resource-viewer/components/ResourceViewerPage.tsx +36 -32
- package/src/features/resource-viewer/state/__tests__/resource-loader-state-unit.test.ts +46 -0
- package/src/features/resource-viewer/state/__tests__/resource-viewer-page-state-unit.test.ts +203 -0
- package/src/features/resource-viewer/state/resource-loader-state-unit.ts +26 -0
- package/src/features/resource-viewer/state/resource-viewer-page-state-unit.ts +180 -0
- package/dist/chunk-4NOUO3W6.mjs.map +0 -1
package/package.json
CHANGED
|
@@ -6,10 +6,10 @@ import { useObservable } from '../../hooks/useObservable';
|
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Modal that surfaces when a 403 forbidden error is reported via
|
|
9
|
-
*
|
|
9
|
+
* the active session's `signals.notifyPermissionDenied(...)`.
|
|
10
10
|
*
|
|
11
11
|
* Reads `permissionDeniedAt$` and `permissionDeniedMessage$` from the
|
|
12
|
-
* active `
|
|
12
|
+
* active `SessionSignals`. The signals instance clears the
|
|
13
13
|
* flag when the user dismisses the modal. Modal state lives on
|
|
14
14
|
* signals (not the session itself) so headless sessions
|
|
15
15
|
* (workers/CLIs) don't carry dead observables.
|
|
@@ -5,11 +5,11 @@ import { useSemiont } from '../../session/SemiontProvider';
|
|
|
5
5
|
import { useObservable } from '../../hooks/useObservable';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* Modal that surfaces when the active KB's session expires (a 401
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* Modal that surfaces when the active KB's session expires (a 401
|
|
9
|
+
* surfaced by the session's own JWT validation or by the host's
|
|
10
|
+
* error-routing path).
|
|
11
11
|
*
|
|
12
|
-
* Reads `sessionExpiredAt$` from the active `
|
|
12
|
+
* Reads `sessionExpiredAt$` from the active `SessionSignals`.
|
|
13
13
|
* When the user dismisses the modal, the signals instance clears the
|
|
14
14
|
* flag. Modal state lives on signals (not the session itself) so
|
|
15
15
|
* headless sessions (workers/CLIs) don't carry dead observables.
|
|
@@ -45,6 +45,8 @@ interface AssessmentPanelProps {
|
|
|
45
45
|
isAssisting?: boolean;
|
|
46
46
|
progress?: JobProgress | null;
|
|
47
47
|
locale?: string;
|
|
48
|
+
/** BCP-47 tag of the resource being analyzed — forwarded to the assist call. */
|
|
49
|
+
sourceLanguage?: string;
|
|
48
50
|
annotateMode?: boolean;
|
|
49
51
|
scrollToAnnotationId?: string | null;
|
|
50
52
|
onScrollCompleted?: () => void;
|
|
@@ -64,6 +66,7 @@ export function AssessmentPanel({
|
|
|
64
66
|
isAssisting = false,
|
|
65
67
|
progress,
|
|
66
68
|
locale,
|
|
69
|
+
sourceLanguage,
|
|
67
70
|
annotateMode = true,
|
|
68
71
|
scrollToAnnotationId,
|
|
69
72
|
onScrollCompleted,
|
|
@@ -243,6 +246,7 @@ export function AssessmentPanel({
|
|
|
243
246
|
annotationType="assessment"
|
|
244
247
|
isAssisting={isAssisting}
|
|
245
248
|
locale={locale}
|
|
249
|
+
sourceLanguage={sourceLanguage}
|
|
246
250
|
progress={progress}
|
|
247
251
|
/>
|
|
248
252
|
)}
|
|
@@ -12,7 +12,10 @@ type JobProgress = components['schemas']['JobProgress'];
|
|
|
12
12
|
interface AssistSectionProps {
|
|
13
13
|
annotationType: 'highlight' | 'assessment' | 'comment';
|
|
14
14
|
isAssisting: boolean;
|
|
15
|
+
/** User UI locale — written into the annotation body's `language` field for comment/assessment. */
|
|
15
16
|
locale?: string;
|
|
17
|
+
/** BCP-47 tag of the resource being analyzed. Forwarded to the prompt so the LLM analyzes non-English source correctly. */
|
|
18
|
+
sourceLanguage?: string;
|
|
16
19
|
progress?: JobProgress | null | undefined;
|
|
17
20
|
}
|
|
18
21
|
|
|
@@ -34,6 +37,7 @@ export function AssistSection({
|
|
|
34
37
|
annotationType,
|
|
35
38
|
isAssisting,
|
|
36
39
|
locale,
|
|
40
|
+
sourceLanguage,
|
|
37
41
|
progress,
|
|
38
42
|
}: AssistSectionProps) {
|
|
39
43
|
|
|
@@ -74,13 +78,18 @@ export function AssistSection({
|
|
|
74
78
|
instructions: instructions.trim() || undefined,
|
|
75
79
|
tone: (annotationType === 'comment' || annotationType === 'assessment') && tone ? tone : undefined,
|
|
76
80
|
density: (annotationType === 'comment' || annotationType === 'assessment' || annotationType === 'highlight') && useDensity ? density : undefined,
|
|
81
|
+
// Body locale only applies where the LLM writes natural-language text:
|
|
82
|
+
// comment/assessment have a body, highlight does not.
|
|
77
83
|
language: (annotationType === 'comment' || annotationType === 'assessment') ? locale : undefined,
|
|
84
|
+
// Source locale applies to all three — affects analysis quality on
|
|
85
|
+
// non-English source, regardless of whether a body is produced.
|
|
86
|
+
sourceLanguage,
|
|
78
87
|
});
|
|
79
88
|
|
|
80
89
|
setInstructions('');
|
|
81
90
|
setTone('');
|
|
82
91
|
// Don't reset density/useDensity - persist across assists
|
|
83
|
-
}, [annotationType, instructions, tone, useDensity, density, locale, session]);
|
|
92
|
+
}, [annotationType, instructions, tone, useDensity, density, locale, sourceLanguage, session]);
|
|
84
93
|
|
|
85
94
|
const handleDismissProgress = useCallback(() => {
|
|
86
95
|
session?.client.mark.dismissProgress();
|
|
@@ -7,7 +7,7 @@ import './CollaborationPanel.css';
|
|
|
7
7
|
interface Props {
|
|
8
8
|
/**
|
|
9
9
|
* Connection state from `client.actor.state$`. See
|
|
10
|
-
* `packages/api-client/src/
|
|
10
|
+
* `packages/api-client/src/state/domain/actor-state-unit.ts`.
|
|
11
11
|
*
|
|
12
12
|
* UI mapping:
|
|
13
13
|
* `open` | `reconnecting` | `initial` | `connecting`
|
|
@@ -46,6 +46,8 @@ interface CommentsPanelProps {
|
|
|
46
46
|
isAssisting?: boolean;
|
|
47
47
|
progress?: JobProgress | null;
|
|
48
48
|
locale?: string;
|
|
49
|
+
/** BCP-47 tag of the resource being analyzed — forwarded to the assist call. */
|
|
50
|
+
sourceLanguage?: string;
|
|
49
51
|
scrollToAnnotationId?: string | null;
|
|
50
52
|
onScrollCompleted?: () => void;
|
|
51
53
|
hoveredAnnotationId?: string | null;
|
|
@@ -65,6 +67,7 @@ export function CommentsPanel({
|
|
|
65
67
|
isAssisting = false,
|
|
66
68
|
progress,
|
|
67
69
|
locale,
|
|
70
|
+
sourceLanguage,
|
|
68
71
|
scrollToAnnotationId,
|
|
69
72
|
onScrollCompleted,
|
|
70
73
|
hoveredAnnotationId,
|
|
@@ -253,6 +256,7 @@ export function CommentsPanel({
|
|
|
253
256
|
annotationType="comment"
|
|
254
257
|
isAssisting={isAssisting}
|
|
255
258
|
locale={locale}
|
|
259
|
+
sourceLanguage={sourceLanguage}
|
|
256
260
|
progress={progress}
|
|
257
261
|
/>
|
|
258
262
|
)}
|
|
@@ -31,6 +31,8 @@ interface HighlightPanelProps {
|
|
|
31
31
|
scrollToAnnotationId?: string | null;
|
|
32
32
|
onScrollCompleted?: () => void;
|
|
33
33
|
hoveredAnnotationId?: string | null;
|
|
34
|
+
/** BCP-47 tag of the resource being analyzed — forwarded to the assist call so the LLM analyzes non-English source correctly. */
|
|
35
|
+
sourceLanguage?: string;
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
/**
|
|
@@ -48,6 +50,7 @@ export function HighlightPanel({
|
|
|
48
50
|
scrollToAnnotationId,
|
|
49
51
|
onScrollCompleted,
|
|
50
52
|
hoveredAnnotationId,
|
|
53
|
+
sourceLanguage,
|
|
51
54
|
}: HighlightPanelProps) {
|
|
52
55
|
|
|
53
56
|
const t = useTranslations('HighlightPanel');
|
|
@@ -151,6 +154,7 @@ export function HighlightPanel({
|
|
|
151
154
|
annotationType="highlight"
|
|
152
155
|
isAssisting={isAssisting}
|
|
153
156
|
progress={progress}
|
|
157
|
+
sourceLanguage={sourceLanguage}
|
|
154
158
|
/>
|
|
155
159
|
)}
|
|
156
160
|
|
|
@@ -60,6 +60,11 @@ interface Props {
|
|
|
60
60
|
scrollToAnnotationId?: string | null;
|
|
61
61
|
onScrollCompleted?: () => void;
|
|
62
62
|
hoveredAnnotationId?: string | null;
|
|
63
|
+
|
|
64
|
+
/** User UI locale — stamped on the unresolved-reference body's `language` field. */
|
|
65
|
+
locale?: string;
|
|
66
|
+
/** BCP-47 tag of the resource being analyzed — fed into the prompt for source-aware analysis. */
|
|
67
|
+
sourceLanguage?: string;
|
|
63
68
|
}
|
|
64
69
|
|
|
65
70
|
/**
|
|
@@ -85,6 +90,8 @@ export function ReferencesPanel({
|
|
|
85
90
|
scrollToAnnotationId,
|
|
86
91
|
onScrollCompleted,
|
|
87
92
|
hoveredAnnotationId,
|
|
93
|
+
locale,
|
|
94
|
+
sourceLanguage,
|
|
88
95
|
}: Props) {
|
|
89
96
|
const t = useTranslations('ReferencesPanel');
|
|
90
97
|
const session = useObservable(useSemiont().activeSession$);
|
|
@@ -206,6 +213,10 @@ export function ReferencesPanel({
|
|
|
206
213
|
session?.client.mark.requestAssist('linking', {
|
|
207
214
|
entityTypes: selectedEntityTypes,
|
|
208
215
|
includeDescriptiveReferences,
|
|
216
|
+
// Body locale stamps the unresolved-reference body's `language`;
|
|
217
|
+
// sourceLanguage tunes the prompt for non-English source content.
|
|
218
|
+
language: locale,
|
|
219
|
+
sourceLanguage,
|
|
209
220
|
});
|
|
210
221
|
};
|
|
211
222
|
|
|
@@ -48,6 +48,10 @@ interface TaggingPanelProps {
|
|
|
48
48
|
scrollToAnnotationId?: string | null;
|
|
49
49
|
onScrollCompleted?: () => void;
|
|
50
50
|
hoveredAnnotationId?: string | null;
|
|
51
|
+
/** User UI locale — stamped on the tagging body's `language` field. */
|
|
52
|
+
locale?: string;
|
|
53
|
+
/** BCP-47 tag of the resource being analyzed — fed into the prompt for source-aware analysis. */
|
|
54
|
+
sourceLanguage?: string;
|
|
51
55
|
}
|
|
52
56
|
|
|
53
57
|
/**
|
|
@@ -67,6 +71,8 @@ export function TaggingPanel({
|
|
|
67
71
|
scrollToAnnotationId,
|
|
68
72
|
onScrollCompleted,
|
|
69
73
|
hoveredAnnotationId,
|
|
74
|
+
locale,
|
|
75
|
+
sourceLanguage,
|
|
70
76
|
}: TaggingPanelProps) {
|
|
71
77
|
const t = useTranslations('TaggingPanel');
|
|
72
78
|
const session = useObservable(useSemiont().activeSession$);
|
|
@@ -192,6 +198,10 @@ export function TaggingPanel({
|
|
|
192
198
|
session?.client.mark.requestAssist('tagging', {
|
|
193
199
|
schemaId: selectedSchemaId,
|
|
194
200
|
categories: Array.from(selectedCategories),
|
|
201
|
+
// Body locale stamps the tagging body's `language`; sourceLanguage
|
|
202
|
+
// tunes the prompt for non-English source content.
|
|
203
|
+
language: locale,
|
|
204
|
+
sourceLanguage,
|
|
195
205
|
});
|
|
196
206
|
setSelectedCategories(new Set()); // Reset after annotation
|
|
197
207
|
}
|
|
@@ -71,9 +71,16 @@ interface UnifiedAnnotationsPanelProps {
|
|
|
71
71
|
// Hover coordination (for bidirectional hover highlighting)
|
|
72
72
|
hoveredAnnotationId?: string | null;
|
|
73
73
|
|
|
74
|
-
// Locale for AI-generated text language
|
|
74
|
+
// Locale for AI-generated text language (annotation body locale)
|
|
75
75
|
locale?: string;
|
|
76
76
|
|
|
77
|
+
/**
|
|
78
|
+
* BCP-47 tag of the resource being analyzed (source-resource locale).
|
|
79
|
+
* Independent from `locale` — a German user can analyze a French source
|
|
80
|
+
* and get German bodies back. Fed into detection prompts.
|
|
81
|
+
*/
|
|
82
|
+
sourceLanguage?: string;
|
|
83
|
+
|
|
77
84
|
// Routing
|
|
78
85
|
Link: React.ComponentType<LinkComponentProps>;
|
|
79
86
|
routes: RouteBuilder;
|
|
@@ -243,6 +250,7 @@ export function UnifiedAnnotationsPanel(props: UnifiedAnnotationsPanelProps) {
|
|
|
243
250
|
progress,
|
|
244
251
|
annotateMode: props.annotateMode,
|
|
245
252
|
locale: props.locale,
|
|
253
|
+
sourceLanguage: props.sourceLanguage,
|
|
246
254
|
scrollToAnnotationId: props.scrollToAnnotationId,
|
|
247
255
|
onScrollCompleted: props.onScrollCompleted,
|
|
248
256
|
hoveredAnnotationId: props.hoveredAnnotationId
|
|
@@ -268,6 +276,8 @@ export function UnifiedAnnotationsPanel(props: UnifiedAnnotationsPanelProps) {
|
|
|
268
276
|
scrollToAnnotationId={commonProps.scrollToAnnotationId}
|
|
269
277
|
onScrollCompleted={commonProps.onScrollCompleted}
|
|
270
278
|
hoveredAnnotationId={commonProps.hoveredAnnotationId}
|
|
279
|
+
locale={commonProps.locale}
|
|
280
|
+
sourceLanguage={commonProps.sourceLanguage}
|
|
271
281
|
allEntityTypes={props.allEntityTypes || []}
|
|
272
282
|
generatingReferenceId={props.generatingReferenceId}
|
|
273
283
|
referencedBy={props.referencedBy}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Triangulation test for the
|
|
2
|
+
* Triangulation test for the state unit → useObservable → prop → chip render chain.
|
|
3
3
|
*
|
|
4
4
|
* Written after an e2e failure (test 05) where `ReferencesPanel` rendered
|
|
5
5
|
* "No entity types available" even though the client provably received a
|
|
@@ -90,7 +90,7 @@ const renderWithBus = (ui: React.ReactElement) => {
|
|
|
90
90
|
return render(<SemiontWrapper>{ui}</SemiontWrapper>);
|
|
91
91
|
};
|
|
92
92
|
|
|
93
|
-
describe('Layer 5-6 —
|
|
93
|
+
describe('Layer 5-6 — state-unit observable → useObservable → ReferencesPanel chips', () => {
|
|
94
94
|
it('an observable seeded with [9 strings] renders 9 pending-reference chips', async () => {
|
|
95
95
|
const source$ = new BehaviorSubject<string[]>(NINE_TYPES);
|
|
96
96
|
renderWithBus(<ObservableHarness source$={source$} />);
|
|
@@ -9,7 +9,7 @@ import React from 'react';
|
|
|
9
9
|
import {
|
|
10
10
|
CommandLineIcon
|
|
11
11
|
} from '@heroicons/react/24/outline';
|
|
12
|
-
import { COMMON_PANELS, type ToolbarPanelType } from '
|
|
12
|
+
import { COMMON_PANELS, type ToolbarPanelType } from '../../../state/shell-state-unit';
|
|
13
13
|
export interface DevOpsFeature {
|
|
14
14
|
title: string;
|
|
15
15
|
description: string;
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import React from 'react';
|
|
8
|
-
import { COMMON_PANELS, type ToolbarPanelType } from '
|
|
8
|
+
import { COMMON_PANELS, type ToolbarPanelType } from '../../../state/shell-state-unit';
|
|
9
9
|
import { ExportCard, type ExportCardTranslations } from './ExportCard';
|
|
10
10
|
import { ImportCard, type ImportCardProps, type ImportCardTranslations } from './ImportCard';
|
|
11
11
|
import { ImportProgress, type ImportProgressTranslations } from './ImportProgress';
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import React, { useRef, useState, useCallback } from 'react';
|
|
8
|
-
import type { ImportPreview } from '
|
|
8
|
+
import type { ImportPreview } from '../state/exchange-state-unit';
|
|
9
9
|
export type { ImportPreview };
|
|
10
10
|
|
|
11
11
|
export interface ImportCardTranslations {
|
|
@@ -0,0 +1,171 @@
|
|
|
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
|
+
|
|
6
|
+
function mockBrowse(): ShellStateUnit {
|
|
7
|
+
return { dispose: vi.fn() } as unknown as ShellStateUnit;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function makeMockFile(name: string): File {
|
|
11
|
+
return new File(['content'], name, { type: 'application/gzip' });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('createExchangeStateUnit', () => {
|
|
15
|
+
it('initializes with null/empty state', async () => {
|
|
16
|
+
const stateUnit = createExchangeStateUnit(mockBrowse(), vi.fn(), vi.fn());
|
|
17
|
+
|
|
18
|
+
expect(await firstValueFrom(stateUnit.selectedFile$)).toBeNull();
|
|
19
|
+
expect(await firstValueFrom(stateUnit.preview$)).toBeNull();
|
|
20
|
+
expect(await firstValueFrom(stateUnit.importPhase$)).toBeNull();
|
|
21
|
+
expect(await firstValueFrom(stateUnit.isExporting$)).toBe(false);
|
|
22
|
+
expect(await firstValueFrom(stateUnit.isImporting$)).toBe(false);
|
|
23
|
+
|
|
24
|
+
stateUnit.dispose();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('selectFile sets file and generates preview', async () => {
|
|
28
|
+
const stateUnit = createExchangeStateUnit(mockBrowse(), vi.fn(), vi.fn());
|
|
29
|
+
|
|
30
|
+
stateUnit.selectFile(makeMockFile('backup.tar.gz'));
|
|
31
|
+
|
|
32
|
+
const file = await firstValueFrom(stateUnit.selectedFile$);
|
|
33
|
+
expect(file?.name).toBe('backup.tar.gz');
|
|
34
|
+
|
|
35
|
+
const preview = await firstValueFrom(stateUnit.preview$);
|
|
36
|
+
expect(preview?.format).toBe('semiont-linked-data');
|
|
37
|
+
|
|
38
|
+
stateUnit.dispose();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('selectFile detects unknown format', async () => {
|
|
42
|
+
const stateUnit = createExchangeStateUnit(mockBrowse(), vi.fn(), vi.fn());
|
|
43
|
+
|
|
44
|
+
stateUnit.selectFile(makeMockFile('data.json'));
|
|
45
|
+
|
|
46
|
+
const preview = await firstValueFrom(stateUnit.preview$);
|
|
47
|
+
expect(preview?.format).toBe('unknown');
|
|
48
|
+
|
|
49
|
+
stateUnit.dispose();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('cancelImport resets all state', async () => {
|
|
53
|
+
const stateUnit = createExchangeStateUnit(mockBrowse(), vi.fn(), vi.fn());
|
|
54
|
+
|
|
55
|
+
stateUnit.selectFile(makeMockFile('backup.tar.gz'));
|
|
56
|
+
stateUnit.cancelImport();
|
|
57
|
+
|
|
58
|
+
expect(await firstValueFrom(stateUnit.selectedFile$)).toBeNull();
|
|
59
|
+
expect(await firstValueFrom(stateUnit.preview$)).toBeNull();
|
|
60
|
+
expect(await firstValueFrom(stateUnit.importPhase$)).toBeNull();
|
|
61
|
+
|
|
62
|
+
stateUnit.dispose();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// jsdom doesn't implement `Blob.prototype.stream()` portably, so build
|
|
66
|
+
// the stream literal — same shape, no environment dependency.
|
|
67
|
+
function streamOf(text: string): ReadableStream<Uint8Array> {
|
|
68
|
+
return new ReadableStream<Uint8Array>({
|
|
69
|
+
start(controller) {
|
|
70
|
+
controller.enqueue(new TextEncoder().encode(text));
|
|
71
|
+
controller.close();
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
it('doExport calls exportFn and returns blob + filename from BackendDownload', async () => {
|
|
77
|
+
// exportFn now returns a BackendDownload — a transport-neutral
|
|
78
|
+
// { stream, contentType, filename? } object. The state unit converts the
|
|
79
|
+
// stream to a Blob and threads filename through.
|
|
80
|
+
const exportFn = vi.fn().mockResolvedValue({
|
|
81
|
+
stream: streamOf('data'),
|
|
82
|
+
contentType: 'application/x-tar',
|
|
83
|
+
filename: 'export.tar.gz',
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const stateUnit = createExchangeStateUnit(mockBrowse(), exportFn, vi.fn());
|
|
87
|
+
|
|
88
|
+
const result = await stateUnit.doExport();
|
|
89
|
+
expect(result.filename).toBe('export.tar.gz');
|
|
90
|
+
expect(await result.blob.text()).toBe('data');
|
|
91
|
+
|
|
92
|
+
expect(await firstValueFrom(stateUnit.isExporting$)).toBe(false);
|
|
93
|
+
|
|
94
|
+
stateUnit.dispose();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('doExport falls back to a synthesized filename when the download omits one', async () => {
|
|
98
|
+
const exportFn = vi.fn().mockResolvedValue({
|
|
99
|
+
stream: streamOf('data'),
|
|
100
|
+
contentType: 'application/x-tar',
|
|
101
|
+
// no filename
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const stateUnit = createExchangeStateUnit(mockBrowse(), exportFn, vi.fn());
|
|
105
|
+
|
|
106
|
+
const result = await stateUnit.doExport();
|
|
107
|
+
expect(result.filename).toMatch(/^semiont-export-\d+\.tar\.gz$/);
|
|
108
|
+
|
|
109
|
+
stateUnit.dispose();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('doExport propagates errors from exportFn and clears isExporting$', async () => {
|
|
113
|
+
// The state unit no longer inspects HTTP status — non-OK responses are the
|
|
114
|
+
// transport's concern (ky throws on non-OK by default). The state unit just
|
|
115
|
+
// propagates whatever the exportFn rejects with and resets state.
|
|
116
|
+
const exportFn = vi.fn().mockRejectedValue(new Error('transport boom'));
|
|
117
|
+
|
|
118
|
+
const stateUnit = createExchangeStateUnit(mockBrowse(), exportFn, vi.fn());
|
|
119
|
+
|
|
120
|
+
await expect(stateUnit.doExport()).rejects.toThrow('transport boom');
|
|
121
|
+
expect(await firstValueFrom(stateUnit.isExporting$)).toBe(false);
|
|
122
|
+
|
|
123
|
+
stateUnit.dispose();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('doImport subscribes to importFn Observable and mirrors each progress event', async () => {
|
|
127
|
+
// importFn now returns Observable<ProgressEvent>. The state unit subscribes,
|
|
128
|
+
// mirrors each emit into its state subjects, and resolves when the
|
|
129
|
+
// observable completes.
|
|
130
|
+
const importFn = vi.fn().mockReturnValue(
|
|
131
|
+
of(
|
|
132
|
+
{ phase: 'uploading', message: '50%' },
|
|
133
|
+
{ phase: 'complete', result: { resources: 10 } },
|
|
134
|
+
),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const stateUnit = createExchangeStateUnit(mockBrowse(), vi.fn(), importFn);
|
|
138
|
+
stateUnit.selectFile(makeMockFile('import.tar.gz'));
|
|
139
|
+
|
|
140
|
+
await stateUnit.doImport();
|
|
141
|
+
|
|
142
|
+
expect(importFn).toHaveBeenCalledOnce();
|
|
143
|
+
expect(await firstValueFrom(stateUnit.importResult$)).toEqual({ resources: 10 });
|
|
144
|
+
expect(await firstValueFrom(stateUnit.importPhase$)).toBe('complete');
|
|
145
|
+
expect(await firstValueFrom(stateUnit.isImporting$)).toBe(false);
|
|
146
|
+
|
|
147
|
+
stateUnit.dispose();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('doImport propagates errors from the importFn Observable and clears isImporting$', async () => {
|
|
151
|
+
const importFn = vi.fn().mockReturnValue(throwError(() => new Error('import boom')));
|
|
152
|
+
|
|
153
|
+
const stateUnit = createExchangeStateUnit(mockBrowse(), vi.fn(), importFn);
|
|
154
|
+
stateUnit.selectFile(makeMockFile('import.tar.gz'));
|
|
155
|
+
|
|
156
|
+
await expect(stateUnit.doImport()).rejects.toThrow('import boom');
|
|
157
|
+
expect(await firstValueFrom(stateUnit.isImporting$)).toBe(false);
|
|
158
|
+
|
|
159
|
+
stateUnit.dispose();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('doImport is no-op without selected file', async () => {
|
|
163
|
+
const importFn = vi.fn();
|
|
164
|
+
const stateUnit = createExchangeStateUnit(mockBrowse(), vi.fn(), importFn);
|
|
165
|
+
|
|
166
|
+
await stateUnit.doImport();
|
|
167
|
+
expect(importFn).not.toHaveBeenCalled();
|
|
168
|
+
|
|
169
|
+
stateUnit.dispose();
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
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/sdk';
|
|
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
|
+
disposer.add(browse);
|
|
37
|
+
|
|
38
|
+
const selectedFile$ = new BehaviorSubject<File | null>(null);
|
|
39
|
+
const preview$ = new BehaviorSubject<ImportPreview | null>(null);
|
|
40
|
+
const importPhase$ = new BehaviorSubject<string | null>(null);
|
|
41
|
+
const importMessage$ = new BehaviorSubject<string | undefined>(undefined);
|
|
42
|
+
const importResult$ = new BehaviorSubject<Record<string, unknown> | undefined>(undefined);
|
|
43
|
+
const isExporting$ = new BehaviorSubject<boolean>(false);
|
|
44
|
+
const isImporting$ = new BehaviorSubject<boolean>(false);
|
|
45
|
+
|
|
46
|
+
const selectFile = (file: File): void => {
|
|
47
|
+
selectedFile$.next(file);
|
|
48
|
+
importPhase$.next(null);
|
|
49
|
+
importMessage$.next(undefined);
|
|
50
|
+
importResult$.next(undefined);
|
|
51
|
+
preview$.next({
|
|
52
|
+
format: file.name.endsWith('.tar.gz') || file.name.endsWith('.gz') ? 'semiont-linked-data' : 'unknown',
|
|
53
|
+
version: 1,
|
|
54
|
+
sourceUrl: '',
|
|
55
|
+
stats: {} as Record<string, number>,
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const cancelImport = (): void => {
|
|
60
|
+
selectedFile$.next(null);
|
|
61
|
+
preview$.next(null);
|
|
62
|
+
importPhase$.next(null);
|
|
63
|
+
importMessage$.next(undefined);
|
|
64
|
+
importResult$.next(undefined);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const doExport = async (): Promise<{ blob: Blob; filename: string }> => {
|
|
68
|
+
isExporting$.next(true);
|
|
69
|
+
try {
|
|
70
|
+
const download = await exportFn();
|
|
71
|
+
// Wrap the stream in a Response purely as a Blob-collection helper —
|
|
72
|
+
// BackendDownload itself carries no fetch dependency.
|
|
73
|
+
const blob = await new Response(download.stream).blob();
|
|
74
|
+
const filename = download.filename ?? `semiont-export-${Date.now()}.tar.gz`;
|
|
75
|
+
return { blob, filename };
|
|
76
|
+
} finally {
|
|
77
|
+
isExporting$.next(false);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const doImport = async (): Promise<void> => {
|
|
82
|
+
const file = selectedFile$.getValue();
|
|
83
|
+
if (!file) return;
|
|
84
|
+
isImporting$.next(true);
|
|
85
|
+
importPhase$.next('started');
|
|
86
|
+
importMessage$.next(undefined);
|
|
87
|
+
importResult$.next(undefined);
|
|
88
|
+
try {
|
|
89
|
+
// The importFn is `Observable<ProgressEvent>` — every emit is a
|
|
90
|
+
// progress event; the final emit before complete is the outcome.
|
|
91
|
+
// `tap` mirrors each event into our state subjects; `lastValueFrom`
|
|
92
|
+
// awaits the last value (so callers can `await vm.doImport()`).
|
|
93
|
+
await lastValueFrom(
|
|
94
|
+
importFn(file).pipe(
|
|
95
|
+
tap((event) => {
|
|
96
|
+
importPhase$.next(event.phase);
|
|
97
|
+
importMessage$.next(event.message);
|
|
98
|
+
if (event.result) importResult$.next(event.result);
|
|
99
|
+
}),
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
} finally {
|
|
103
|
+
isImporting$.next(false);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
browse,
|
|
109
|
+
selectedFile$: selectedFile$.asObservable(),
|
|
110
|
+
preview$: preview$.asObservable(),
|
|
111
|
+
importPhase$: importPhase$.asObservable(),
|
|
112
|
+
importMessage$: importMessage$.asObservable(),
|
|
113
|
+
importResult$: importResult$.asObservable(),
|
|
114
|
+
isExporting$: isExporting$.asObservable(),
|
|
115
|
+
isImporting$: isImporting$.asObservable(),
|
|
116
|
+
selectFile,
|
|
117
|
+
cancelImport,
|
|
118
|
+
doExport,
|
|
119
|
+
doImport,
|
|
120
|
+
dispose: () => {
|
|
121
|
+
selectedFile$.complete();
|
|
122
|
+
preview$.complete();
|
|
123
|
+
importPhase$.complete();
|
|
124
|
+
importMessage$.complete();
|
|
125
|
+
importResult$.complete();
|
|
126
|
+
isExporting$.complete();
|
|
127
|
+
isImporting$.complete();
|
|
128
|
+
disposer.dispose();
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
CheckCircleIcon,
|
|
13
13
|
InformationCircleIcon
|
|
14
14
|
} from '@heroicons/react/24/outline';
|
|
15
|
-
import { COMMON_PANELS, type ToolbarPanelType } from '
|
|
15
|
+
import { COMMON_PANELS, type ToolbarPanelType } from '../../../state/shell-state-unit';
|
|
16
16
|
export interface OAuthProvider {
|
|
17
17
|
name: string;
|
|
18
18
|
clientId?: string;
|