@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
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { BehaviorSubject, type Observable, map } from 'rxjs';
|
|
2
|
+
import type { GatheredContext, AnnotationId, ContentFormat, AccessToken, ResourceDescriptor, ResourceId } from '@semiont/core';
|
|
3
|
+
import { resourceId as makeResourceId, annotationId as makeAnnotationId } from '@semiont/core';
|
|
4
|
+
import { createDisposer } from '@semiont/sdk';
|
|
5
|
+
import type { StateUnit } from '@semiont/sdk';
|
|
6
|
+
import type { ShellStateUnit } from '../../../state/shell-state-unit';
|
|
7
|
+
import type { SemiontClient } from '@semiont/sdk';
|
|
8
|
+
import { getPrimaryMediaType, decodeWithCharset } from '@semiont/core';
|
|
9
|
+
import type { UploadProgress } from '@semiont/sdk';
|
|
10
|
+
|
|
11
|
+
export type ComposeMode = 'new' | 'clone' | 'reference';
|
|
12
|
+
|
|
13
|
+
export interface ComposeParams {
|
|
14
|
+
mode?: string | undefined;
|
|
15
|
+
token?: string | undefined;
|
|
16
|
+
annotationUri?: string | undefined;
|
|
17
|
+
sourceDocumentId?: string | undefined;
|
|
18
|
+
name?: string | undefined;
|
|
19
|
+
entityTypes?: string | undefined;
|
|
20
|
+
storedContext?: string | undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CloneData {
|
|
24
|
+
sourceResource: ResourceDescriptor;
|
|
25
|
+
sourceContent: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ReferenceData {
|
|
29
|
+
annotationUri: string;
|
|
30
|
+
sourceDocumentId: string;
|
|
31
|
+
name: string;
|
|
32
|
+
entityTypes: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SaveResourceParams {
|
|
36
|
+
mode: ComposeMode;
|
|
37
|
+
name: string;
|
|
38
|
+
storageUri: string;
|
|
39
|
+
content?: string;
|
|
40
|
+
file?: File;
|
|
41
|
+
format?: string;
|
|
42
|
+
charset?: string;
|
|
43
|
+
entityTypes?: string[];
|
|
44
|
+
language: string;
|
|
45
|
+
archiveOriginal?: boolean;
|
|
46
|
+
annotationUri?: string;
|
|
47
|
+
sourceDocumentId?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ComposePageStateUnit extends StateUnit {
|
|
51
|
+
browse: ShellStateUnit;
|
|
52
|
+
mode$: Observable<ComposeMode>;
|
|
53
|
+
loading$: Observable<boolean>;
|
|
54
|
+
cloneData$: Observable<CloneData | null>;
|
|
55
|
+
referenceData$: Observable<ReferenceData | null>;
|
|
56
|
+
gatheredContext$: Observable<GatheredContext | null>;
|
|
57
|
+
entityTypes$: Observable<string[]>;
|
|
58
|
+
/**
|
|
59
|
+
* Live upload-progress for the in-flight `save(...)` call. Emits the
|
|
60
|
+
* full `UploadProgress` lifecycle (started → finished) while a save is
|
|
61
|
+
* underway; resets to `null` between saves and after completion. UI
|
|
62
|
+
* components can subscribe to render an upload-in-progress indicator.
|
|
63
|
+
*/
|
|
64
|
+
uploadProgress$: Observable<UploadProgress | null>;
|
|
65
|
+
save(params: SaveResourceParams): Promise<string>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createComposePageStateUnit(
|
|
69
|
+
client: SemiontClient,
|
|
70
|
+
browse: ShellStateUnit,
|
|
71
|
+
params: ComposeParams,
|
|
72
|
+
auth?: AccessToken,
|
|
73
|
+
): ComposePageStateUnit {
|
|
74
|
+
const disposer = createDisposer();
|
|
75
|
+
disposer.add(browse);
|
|
76
|
+
|
|
77
|
+
const isReferenceMode = Boolean(params.annotationUri && params.sourceDocumentId && params.name);
|
|
78
|
+
const isCloneMode = params.mode === 'clone' && Boolean(params.token);
|
|
79
|
+
const pageMode: ComposeMode = isCloneMode ? 'clone' : isReferenceMode ? 'reference' : 'new';
|
|
80
|
+
|
|
81
|
+
const mode$ = new BehaviorSubject<ComposeMode>(pageMode);
|
|
82
|
+
const loading$ = new BehaviorSubject<boolean>(true);
|
|
83
|
+
const cloneData$ = new BehaviorSubject<CloneData | null>(null);
|
|
84
|
+
const referenceData$ = new BehaviorSubject<ReferenceData | null>(null);
|
|
85
|
+
const gatheredContext$ = new BehaviorSubject<GatheredContext | null>(null);
|
|
86
|
+
const uploadProgress$ = new BehaviorSubject<UploadProgress | null>(null);
|
|
87
|
+
|
|
88
|
+
const entityTypes$: Observable<string[]> = client.browse.entityTypes().pipe(
|
|
89
|
+
map((e) => e ?? []),
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
// Initialize based on mode
|
|
93
|
+
if (isReferenceMode) {
|
|
94
|
+
const entityTypes = params.entityTypes ? params.entityTypes.split(',') : [];
|
|
95
|
+
referenceData$.next({
|
|
96
|
+
annotationUri: params.annotationUri!,
|
|
97
|
+
sourceDocumentId: params.sourceDocumentId!,
|
|
98
|
+
name: params.name!,
|
|
99
|
+
entityTypes,
|
|
100
|
+
});
|
|
101
|
+
if (params.storedContext) {
|
|
102
|
+
try { gatheredContext$.next(JSON.parse(params.storedContext)); } catch { /* ignore malformed */ }
|
|
103
|
+
}
|
|
104
|
+
loading$.next(false);
|
|
105
|
+
} else if (isCloneMode) {
|
|
106
|
+
void (async () => {
|
|
107
|
+
try {
|
|
108
|
+
const tokenResult = await client.yield.fromToken(params.token!);
|
|
109
|
+
if (tokenResult && auth) {
|
|
110
|
+
const rId = makeResourceId(tokenResult['@id']);
|
|
111
|
+
const mediaType = getPrimaryMediaType(tokenResult) || 'text/plain';
|
|
112
|
+
const { data } = await client.browse.resourceRepresentation(rId, {
|
|
113
|
+
accept: mediaType as ContentFormat,
|
|
114
|
+
});
|
|
115
|
+
const content = decodeWithCharset(data, mediaType);
|
|
116
|
+
cloneData$.next({ sourceResource: tokenResult, sourceContent: content });
|
|
117
|
+
}
|
|
118
|
+
} catch {
|
|
119
|
+
// Error handling is the consumer's responsibility (toast)
|
|
120
|
+
}
|
|
121
|
+
loading$.next(false);
|
|
122
|
+
})();
|
|
123
|
+
} else {
|
|
124
|
+
loading$.next(false);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const save = async (saveParams: SaveResourceParams): Promise<string> => {
|
|
128
|
+
if (saveParams.mode === 'clone') {
|
|
129
|
+
const response = await client.yield.createFromToken({
|
|
130
|
+
token: params.token!,
|
|
131
|
+
name: saveParams.name,
|
|
132
|
+
content: saveParams.content!,
|
|
133
|
+
archiveOriginal: saveParams.archiveOriginal ?? true,
|
|
134
|
+
});
|
|
135
|
+
return response.resourceId;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let fileToUpload: File;
|
|
139
|
+
let mimeType: string;
|
|
140
|
+
|
|
141
|
+
if (saveParams.file) {
|
|
142
|
+
fileToUpload = saveParams.file;
|
|
143
|
+
mimeType = saveParams.format ?? 'application/octet-stream';
|
|
144
|
+
} else {
|
|
145
|
+
const blob = new Blob([saveParams.content || ''], { type: saveParams.format ?? 'application/octet-stream' });
|
|
146
|
+
const extension = saveParams.format === 'text/plain' ? '.txt' : saveParams.format === 'text/html' ? '.html' : '.md';
|
|
147
|
+
fileToUpload = new File([blob], saveParams.name + extension, { type: saveParams.format ?? 'application/octet-stream' });
|
|
148
|
+
mimeType = saveParams.format ?? 'application/octet-stream';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const format = saveParams.charset && !saveParams.file ? `${mimeType}; charset=${saveParams.charset}` : mimeType;
|
|
152
|
+
|
|
153
|
+
// Subscribe to the upload's full progress lifecycle so the UI can
|
|
154
|
+
// render an upload-in-progress indicator. Resolve the save() promise
|
|
155
|
+
// on the `finished` event and clear the progress signal on completion
|
|
156
|
+
// (success or error).
|
|
157
|
+
const newResourceId = await new Promise<ResourceId>((resolve, reject) => {
|
|
158
|
+
client.yield.resource({
|
|
159
|
+
name: saveParams.name,
|
|
160
|
+
file: fileToUpload,
|
|
161
|
+
format,
|
|
162
|
+
entityTypes: saveParams.entityTypes || [],
|
|
163
|
+
language: saveParams.language,
|
|
164
|
+
creationMethod: 'ui',
|
|
165
|
+
storageUri: saveParams.storageUri,
|
|
166
|
+
}).subscribe({
|
|
167
|
+
next: (event) => {
|
|
168
|
+
uploadProgress$.next(event);
|
|
169
|
+
if (event.phase === 'finished') resolve(event.resourceId);
|
|
170
|
+
},
|
|
171
|
+
error: (err) => {
|
|
172
|
+
uploadProgress$.next(null);
|
|
173
|
+
reject(err);
|
|
174
|
+
},
|
|
175
|
+
complete: () => uploadProgress$.next(null),
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
if (saveParams.mode === 'reference' && saveParams.annotationUri && saveParams.sourceDocumentId) {
|
|
180
|
+
await client.bind.body(
|
|
181
|
+
makeResourceId(saveParams.sourceDocumentId),
|
|
182
|
+
makeAnnotationId(saveParams.annotationUri) as AnnotationId,
|
|
183
|
+
[{ op: 'add', item: { type: 'SpecificResource' as const, source: newResourceId, purpose: 'linking' as const } }],
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return newResourceId;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
browse,
|
|
192
|
+
mode$: mode$.asObservable(),
|
|
193
|
+
loading$: loading$.asObservable(),
|
|
194
|
+
cloneData$: cloneData$.asObservable(),
|
|
195
|
+
referenceData$: referenceData$.asObservable(),
|
|
196
|
+
gatheredContext$: gatheredContext$.asObservable(),
|
|
197
|
+
entityTypes$,
|
|
198
|
+
uploadProgress$: uploadProgress$.asObservable(),
|
|
199
|
+
save,
|
|
200
|
+
dispose: () => {
|
|
201
|
+
mode$.complete();
|
|
202
|
+
loading$.complete();
|
|
203
|
+
cloneData$.complete();
|
|
204
|
+
referenceData$.complete();
|
|
205
|
+
gatheredContext$.complete();
|
|
206
|
+
disposer.dispose();
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import React, { useState, useCallback, useRef } from 'react';
|
|
9
9
|
import { getResourceId } from '@semiont/core';
|
|
10
|
-
import { COMMON_PANELS, type ToolbarPanelType } from '
|
|
10
|
+
import { COMMON_PANELS, type ToolbarPanelType } from '../../../state/shell-state-unit';
|
|
11
11
|
import { useRovingTabIndex } from '../../../hooks/useRovingTabIndex';
|
|
12
12
|
import { Toolbar } from '../../../components/Toolbar';
|
|
13
13
|
import { ResourceCard } from './ResourceCard';
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { BehaviorSubject, 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 { createDiscoverStateUnit } from '../discover-state-unit';
|
|
7
|
+
|
|
8
|
+
function mockBrowse(): ShellStateUnit {
|
|
9
|
+
return { dispose: vi.fn() } as unknown as ShellStateUnit;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function mockClient(overrides: {
|
|
13
|
+
resources$?: BehaviorSubject<unknown[] | undefined>;
|
|
14
|
+
entityTypes$?: BehaviorSubject<string[] | undefined>;
|
|
15
|
+
} = {}): SemiontClient {
|
|
16
|
+
const resources$ = overrides.resources$ ?? new BehaviorSubject<unknown[] | undefined>([{ '@id': 'r1' }]);
|
|
17
|
+
const entityTypes$ = overrides.entityTypes$ ?? new BehaviorSubject<string[] | undefined>(['Person']);
|
|
18
|
+
return {
|
|
19
|
+
browse: {
|
|
20
|
+
resources: () => resources$.asObservable(),
|
|
21
|
+
entityTypes: () => entityTypes$.asObservable(),
|
|
22
|
+
},
|
|
23
|
+
} as unknown as SemiontClient;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('createDiscoverStateUnit', () => {
|
|
27
|
+
it('exposes recent resources from browse namespace', async () => {
|
|
28
|
+
const stateUnit = createDiscoverStateUnit(mockClient(), mockBrowse());
|
|
29
|
+
|
|
30
|
+
const recent = await firstValueFrom(stateUnit.recentResources$);
|
|
31
|
+
expect(recent).toEqual([{ '@id': 'r1' }]);
|
|
32
|
+
|
|
33
|
+
stateUnit.dispose();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('exposes entity types from browse namespace', async () => {
|
|
37
|
+
const stateUnit = createDiscoverStateUnit(mockClient(), mockBrowse());
|
|
38
|
+
|
|
39
|
+
const types = await firstValueFrom(stateUnit.entityTypes$);
|
|
40
|
+
expect(types).toEqual(['Person']);
|
|
41
|
+
|
|
42
|
+
stateUnit.dispose();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('reports loading when resources are undefined', async () => {
|
|
46
|
+
const resources$ = new BehaviorSubject<unknown[] | undefined>(undefined);
|
|
47
|
+
const stateUnit = createDiscoverStateUnit(mockClient({ resources$ }), mockBrowse());
|
|
48
|
+
|
|
49
|
+
const loading = await firstValueFrom(stateUnit.isLoadingRecent$);
|
|
50
|
+
expect(loading).toBe(true);
|
|
51
|
+
|
|
52
|
+
resources$.next([]);
|
|
53
|
+
const loaded = await firstValueFrom(stateUnit.isLoadingRecent$.pipe(filter((l) => !l)));
|
|
54
|
+
expect(loaded).toBe(false);
|
|
55
|
+
|
|
56
|
+
stateUnit.dispose();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('exposes a search pipeline', () => {
|
|
60
|
+
const stateUnit = createDiscoverStateUnit(mockClient(), mockBrowse());
|
|
61
|
+
|
|
62
|
+
expect(stateUnit.search).toBeDefined();
|
|
63
|
+
expect(typeof stateUnit.search.setQuery).toBe('function');
|
|
64
|
+
expect(stateUnit.search.state$).toBeDefined();
|
|
65
|
+
|
|
66
|
+
stateUnit.dispose();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('disposes browse and search on dispose', () => {
|
|
70
|
+
const browse = mockBrowse();
|
|
71
|
+
const stateUnit = createDiscoverStateUnit(mockClient(), browse);
|
|
72
|
+
stateUnit.dispose();
|
|
73
|
+
|
|
74
|
+
expect(browse.dispose).toHaveBeenCalled();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { map, type Observable } from 'rxjs';
|
|
2
|
+
import type { ResourceDescriptor } from '@semiont/core';
|
|
3
|
+
import type { StateUnit } from '@semiont/sdk';
|
|
4
|
+
import { createDisposer } from '@semiont/sdk';
|
|
5
|
+
import type { ShellStateUnit } from '../../../state/shell-state-unit';
|
|
6
|
+
import { createSearchPipeline, type SearchPipeline } from '@semiont/sdk';
|
|
7
|
+
import type { SemiontClient } from '@semiont/sdk';
|
|
8
|
+
|
|
9
|
+
const RECENT_LIMIT = 10;
|
|
10
|
+
const SEARCH_LIMIT = 20;
|
|
11
|
+
|
|
12
|
+
export interface DiscoverStateUnit extends StateUnit {
|
|
13
|
+
browse: ShellStateUnit;
|
|
14
|
+
search: SearchPipeline<ResourceDescriptor>;
|
|
15
|
+
recentResources$: Observable<ResourceDescriptor[]>;
|
|
16
|
+
entityTypes$: Observable<string[]>;
|
|
17
|
+
isLoadingRecent$: Observable<boolean>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createDiscoverStateUnit(
|
|
21
|
+
client: SemiontClient,
|
|
22
|
+
browse: ShellStateUnit,
|
|
23
|
+
): DiscoverStateUnit {
|
|
24
|
+
const disposer = createDisposer();
|
|
25
|
+
|
|
26
|
+
const search = createSearchPipeline<ResourceDescriptor>((q) =>
|
|
27
|
+
client.browse.resources({ search: q, limit: SEARCH_LIMIT }),
|
|
28
|
+
);
|
|
29
|
+
disposer.add(search);
|
|
30
|
+
disposer.add(browse);
|
|
31
|
+
|
|
32
|
+
const recent$ = client.browse.resources({ limit: RECENT_LIMIT, archived: false });
|
|
33
|
+
|
|
34
|
+
const recentResources$: Observable<ResourceDescriptor[]> = recent$.pipe(
|
|
35
|
+
map((r) => r ?? []),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const isLoadingRecent$: Observable<boolean> = recent$.pipe(
|
|
39
|
+
map((r) => r === undefined),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const entityTypes$: Observable<string[]> = client.browse.entityTypes().pipe(
|
|
43
|
+
map((e) => e ?? []),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
browse,
|
|
48
|
+
search,
|
|
49
|
+
recentResources$,
|
|
50
|
+
entityTypes$,
|
|
51
|
+
isLoadingRecent$,
|
|
52
|
+
dispose: () => disposer.dispose(),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -37,14 +37,16 @@ vi.mock('../../../hooks/useResourceContent', () => ({
|
|
|
37
37
|
|
|
38
38
|
// Stub SemiontBrowser whose activeSession$ emits a session carrying a real
|
|
39
39
|
// SemiontClient (wired to a dummy baseUrl). The real client surface lets
|
|
40
|
-
//
|
|
40
|
+
// createResourceViewerPageStateUnit run against the full namespace API without us
|
|
41
41
|
// hand-stubbing every method it touches.
|
|
42
42
|
const { stubBrowser } = vi.hoisted(() => {
|
|
43
43
|
const { BehaviorSubject } = require('rxjs');
|
|
44
44
|
const { SemiontClient, HttpTransport, HttpContentTransport } = require('@semiont/sdk');
|
|
45
45
|
const { baseUrl } = require('@semiont/core');
|
|
46
46
|
const transport = new HttpTransport({ baseUrl: baseUrl('http://localhost:4000') });
|
|
47
|
-
|
|
47
|
+
// HttpTransport implements both ITransport and IBackendOperations; pass it
|
|
48
|
+
// as backend so `client.auth` (used by useMediaToken) is wired.
|
|
49
|
+
const client = new SemiontClient(transport, new HttpContentTransport(transport), transport);
|
|
48
50
|
const stubActiveSession$ = new BehaviorSubject({ client });
|
|
49
51
|
const stubOpenResources$ = new BehaviorSubject([]);
|
|
50
52
|
const stubBrowser = {
|
|
@@ -31,9 +31,9 @@ import { useHoverDelay } from '../../../hooks/useHoverDelay';
|
|
|
31
31
|
import { useEventSubscriptions } from '../../../contexts/useEventSubscription';
|
|
32
32
|
import { useResourceAnnotations } from '../../../contexts/ResourceAnnotationsContext';
|
|
33
33
|
import { useSemiont } from '../../../session/SemiontProvider';
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
34
|
+
import { createResourceViewerPageStateUnit } from '../state/resource-viewer-page-state-unit';
|
|
35
|
+
import { useStateUnit } from '../../../hooks/useStateUnit';
|
|
36
|
+
import { useShellStateUnit } from '../../../hooks/useShellStateUnit';
|
|
37
37
|
import { useTranslations } from '../../../contexts/TranslationContext';
|
|
38
38
|
import { ReferenceWizardModal } from '../../../components/modals/ReferenceWizardModal';
|
|
39
39
|
import type { GenerationConfig } from '../../../components/modals/ConfigureGenerationStep';
|
|
@@ -157,29 +157,29 @@ export function ResourceViewerPage({
|
|
|
157
157
|
const content = isBinary ? binaryContent : textContent;
|
|
158
158
|
const contentLoading = isBinary ? mediaTokenLoading : textLoading;
|
|
159
159
|
|
|
160
|
-
// Composite
|
|
161
|
-
const
|
|
162
|
-
const
|
|
160
|
+
// Composite state unit — owns all flow VMs, wizard state, annotations, entity types
|
|
161
|
+
const browseStateUnit = useShellStateUnit();
|
|
162
|
+
const stateUnit = useStateUnit(() => createResourceViewerPageStateUnit(semiont!, rUri, locale, browseStateUnit));
|
|
163
163
|
|
|
164
|
-
const annotations = useObservable(
|
|
165
|
-
const groups = useObservable(
|
|
166
|
-
const allEntityTypes = useObservable(
|
|
167
|
-
const referencedByRaw = useObservable(
|
|
164
|
+
const annotations = useObservable(stateUnit.annotations$) ?? [];
|
|
165
|
+
const groups = useObservable(stateUnit.annotationGroups$);
|
|
166
|
+
const allEntityTypes = useObservable(stateUnit.entityTypes$) ?? [];
|
|
167
|
+
const referencedByRaw = useObservable(stateUnit.referencedBy$);
|
|
168
168
|
const referencedBy = referencedByRaw ?? [];
|
|
169
169
|
const referencedByLoading = referencedByRaw === undefined;
|
|
170
|
-
const hoveredAnnotationId = useObservable(
|
|
171
|
-
const pendingAnnotation = useObservable(
|
|
172
|
-
const assistingMotivation = useObservable(
|
|
173
|
-
const progress = useObservable(
|
|
174
|
-
const activePanel = useObservable(
|
|
175
|
-
const scrollToAnnotationId = useObservable(
|
|
176
|
-
const panelInitialTab = useObservable(
|
|
177
|
-
const onScrollCompleted =
|
|
178
|
-
const generationProgress = useObservable(
|
|
179
|
-
const gatherContext = useObservable(
|
|
180
|
-
const gatherLoading = useObservable(
|
|
181
|
-
const gatherError = useObservable(
|
|
182
|
-
const wizardState = useObservable(
|
|
170
|
+
const hoveredAnnotationId = useObservable(stateUnit.beckon.hoveredAnnotationId$) ?? null;
|
|
171
|
+
const pendingAnnotation = useObservable(stateUnit.mark.pendingAnnotation$) ?? null;
|
|
172
|
+
const assistingMotivation = useObservable(stateUnit.mark.assistingMotivation$) ?? null;
|
|
173
|
+
const progress = useObservable(stateUnit.mark.progress$) ?? null;
|
|
174
|
+
const activePanel = useObservable(stateUnit.browse.activePanel$) ?? null;
|
|
175
|
+
const scrollToAnnotationId = useObservable(stateUnit.browse.scrollToAnnotationId$) ?? null;
|
|
176
|
+
const panelInitialTab = useObservable(stateUnit.browse.panelInitialTab$) ?? null;
|
|
177
|
+
const onScrollCompleted = stateUnit.browse.onScrollCompleted;
|
|
178
|
+
const generationProgress = useObservable(stateUnit.yield.progress$) ?? null;
|
|
179
|
+
const gatherContext = useObservable(stateUnit.gather.context$) ?? null;
|
|
180
|
+
const gatherLoading = useObservable(stateUnit.gather.loading$) ?? false;
|
|
181
|
+
const gatherError = useObservable(stateUnit.gather.error$) ?? null;
|
|
182
|
+
const wizardState = useObservable(stateUnit.wizard$);
|
|
183
183
|
const wizardOpen = wizardState?.open ?? false;
|
|
184
184
|
const wizardAnnotationId = wizardState?.annotationId ?? null;
|
|
185
185
|
const wizardResourceId = wizardState?.resourceId ?? null;
|
|
@@ -187,21 +187,25 @@ export function ResourceViewerPage({
|
|
|
187
187
|
const wizardEntityTypes = wizardState?.entityTypes ?? [];
|
|
188
188
|
|
|
189
189
|
const handleWizardClose = useCallback(() => {
|
|
190
|
-
|
|
191
|
-
}, [
|
|
190
|
+
stateUnit.closeWizard();
|
|
191
|
+
}, [stateUnit]);
|
|
192
192
|
|
|
193
193
|
const handleWizardGenerateSubmit = useCallback((referenceId: string, config: GenerationConfig) => {
|
|
194
194
|
clearNewAnnotationId(annotationId(referenceId));
|
|
195
|
-
|
|
195
|
+
stateUnit.yield.generate(referenceId, {
|
|
196
196
|
title: config.title,
|
|
197
197
|
storageUri: config.storagePath,
|
|
198
198
|
prompt: config.prompt,
|
|
199
199
|
language: config.language,
|
|
200
|
+
// The source resource is the one the user is viewing — fed into the
|
|
201
|
+
// prompt so the LLM understands the embedded context (selected
|
|
202
|
+
// passage, surrounding text) regardless of UI/target language.
|
|
203
|
+
sourceLanguage: getLanguage(resource),
|
|
200
204
|
temperature: config.temperature,
|
|
201
205
|
maxTokens: config.maxTokens,
|
|
202
206
|
context: config.context,
|
|
203
207
|
});
|
|
204
|
-
}, [
|
|
208
|
+
}, [stateUnit, clearNewAnnotationId, resource]);
|
|
205
209
|
|
|
206
210
|
const handleWizardLinkResource = useCallback(async (referenceId: string, targetResourceId: string) => {
|
|
207
211
|
if (!semiont) return;
|
|
@@ -249,8 +253,8 @@ export function ResourceViewerPage({
|
|
|
249
253
|
}
|
|
250
254
|
}, [resource, rUri, browser]);
|
|
251
255
|
|
|
252
|
-
// Bridge: when the mark
|
|
253
|
-
// annotations panel. The mark
|
|
256
|
+
// Bridge: when the mark state unit produces a pending annotation, open the
|
|
257
|
+
// annotations panel. The mark state unit (session-scoped) can't emit `panel:open`
|
|
254
258
|
// (app-scoped) directly — the React tree is the natural seam between
|
|
255
259
|
// the two buses.
|
|
256
260
|
useEffect(() => {
|
|
@@ -259,9 +263,9 @@ export function ResourceViewerPage({
|
|
|
259
263
|
}
|
|
260
264
|
}, [pendingAnnotation, browser]);
|
|
261
265
|
|
|
262
|
-
// Domain events flow through the bus gateway (
|
|
266
|
+
// Domain events flow through the bus gateway (ActorStateUnit → local EventBus).
|
|
263
267
|
// BrowseNamespace cache invalidation handles annotation/resource updates.
|
|
264
|
-
// The resource-viewer-page-
|
|
268
|
+
// The resource-viewer-page-state-unit calls client.subscribeToResource(resourceId)
|
|
265
269
|
// which bridges scoped domain events into the local EventBus.
|
|
266
270
|
|
|
267
271
|
const handleResourceArchive = useCallback(async () => {
|
|
@@ -490,7 +494,6 @@ export function ResourceViewerPage({
|
|
|
490
494
|
activePanel={activePanel}
|
|
491
495
|
theme={theme}
|
|
492
496
|
showLineNumbers={showLineNumbers}
|
|
493
|
-
hoverDelayMs={hoverDelayMs}
|
|
494
497
|
width={
|
|
495
498
|
activePanel === 'jsonld' ? 'w-[600px]' :
|
|
496
499
|
activePanel === 'annotations' ? 'w-[400px]' :
|
|
@@ -521,6 +524,7 @@ export function ResourceViewerPage({
|
|
|
521
524
|
referencedByLoading={referencedByLoading}
|
|
522
525
|
resourceId={rUri}
|
|
523
526
|
locale={locale}
|
|
527
|
+
sourceLanguage={getLanguage(resource)}
|
|
524
528
|
scrollToAnnotationId={scrollToAnnotationId}
|
|
525
529
|
hoveredAnnotationId={hoveredAnnotationId}
|
|
526
530
|
onScrollCompleted={onScrollCompleted}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { BehaviorSubject, firstValueFrom } from 'rxjs';
|
|
3
|
+
import { filter } from 'rxjs/operators';
|
|
4
|
+
import { resourceId as makeResourceId } from '@semiont/core';
|
|
5
|
+
import type { SemiontClient } from '@semiont/sdk';
|
|
6
|
+
import { createResourceLoaderStateUnit } from '../resource-loader-state-unit';
|
|
7
|
+
|
|
8
|
+
const RID = makeResourceId('res-1');
|
|
9
|
+
|
|
10
|
+
function mockClient(resource$?: BehaviorSubject<unknown>): SemiontClient {
|
|
11
|
+
const subject = resource$ ?? new BehaviorSubject<unknown>({ '@id': 'res-1', name: 'Test' });
|
|
12
|
+
const invalidate = vi.fn();
|
|
13
|
+
return {
|
|
14
|
+
browse: {
|
|
15
|
+
resource: () => subject.asObservable(),
|
|
16
|
+
invalidateResourceDetail: invalidate,
|
|
17
|
+
},
|
|
18
|
+
} as unknown as SemiontClient;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('createResourceLoaderStateUnit', () => {
|
|
22
|
+
it('exposes resource from browse namespace', async () => {
|
|
23
|
+
const stateUnit = createResourceLoaderStateUnit(mockClient(), RID);
|
|
24
|
+
const resource = await firstValueFrom(stateUnit.resource$.pipe(filter((r) => r !== undefined)));
|
|
25
|
+
expect((resource as { name: string }).name).toBe('Test');
|
|
26
|
+
stateUnit.dispose();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('reports loading when resource is undefined', async () => {
|
|
30
|
+
const subject = new BehaviorSubject<unknown>(undefined);
|
|
31
|
+
const stateUnit = createResourceLoaderStateUnit(mockClient(subject), RID);
|
|
32
|
+
expect(await firstValueFrom(stateUnit.isLoading$)).toBe(true);
|
|
33
|
+
|
|
34
|
+
subject.next({ '@id': 'res-1' });
|
|
35
|
+
expect(await firstValueFrom(stateUnit.isLoading$.pipe(filter((l) => !l)))).toBe(false);
|
|
36
|
+
stateUnit.dispose();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('invalidate calls browse.invalidateResourceDetail', () => {
|
|
40
|
+
const client = mockClient();
|
|
41
|
+
const stateUnit = createResourceLoaderStateUnit(client, RID);
|
|
42
|
+
stateUnit.invalidate();
|
|
43
|
+
expect(client.browse.invalidateResourceDetail).toHaveBeenCalledWith(RID);
|
|
44
|
+
stateUnit.dispose();
|
|
45
|
+
});
|
|
46
|
+
});
|