@semiont/react-ui 0.5.21 → 0.5.23
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/dist/{ar-ZHKO4XDR.js → ar-JRTKCMTF.js} +11 -4
- package/dist/ar-JRTKCMTF.js.map +1 -0
- package/dist/{bn-3BUDKAK5.js → bn-LO6EDSCC.js} +11 -4
- package/dist/bn-LO6EDSCC.js.map +1 -0
- package/dist/{chunk-R52YAGHK.js → chunk-5Y5JDCKC.js} +11 -4
- package/dist/chunk-5Y5JDCKC.js.map +1 -0
- package/dist/{chunk-PFUXQPWJ.js → chunk-RO5CRVIA.js} +31 -31
- package/dist/{cs-2EFEUKTW.js → cs-LCHHOR4O.js} +11 -4
- package/dist/cs-LCHHOR4O.js.map +1 -0
- package/dist/{da-SUD3Q44Q.js → da-TARGPMJR.js} +11 -4
- package/dist/da-TARGPMJR.js.map +1 -0
- package/dist/{de-6IC5QRX7.js → de-SBH4QBPJ.js} +11 -4
- package/dist/de-SBH4QBPJ.js.map +1 -0
- package/dist/{el-Q4NAXFRC.js → el-2D2OLGWB.js} +11 -4
- package/dist/el-2D2OLGWB.js.map +1 -0
- package/dist/{en-P2L4X27N.js → en-QFIEG3CA.js} +2 -2
- package/dist/{es-YUKZBD4W.js → es-U6YK2LX6.js} +11 -4
- package/dist/es-U6YK2LX6.js.map +1 -0
- package/dist/{fa-OJ47LILE.js → fa-S7SZE37M.js} +11 -4
- package/dist/fa-S7SZE37M.js.map +1 -0
- package/dist/{fi-QFFL5CUB.js → fi-CBL3NIXN.js} +11 -4
- package/dist/fi-CBL3NIXN.js.map +1 -0
- package/dist/{fr-BOQZZWP4.js → fr-EABUHV6S.js} +11 -4
- package/dist/fr-EABUHV6S.js.map +1 -0
- package/dist/{he-LXDY3TFA.js → he-THWD244Y.js} +11 -4
- package/dist/he-THWD244Y.js.map +1 -0
- package/dist/{hi-HOJER77V.js → hi-GHGFZJ44.js} +11 -4
- package/dist/hi-GHGFZJ44.js.map +1 -0
- package/dist/{id-I5WQ6EWD.js → id-POZOUMDW.js} +11 -4
- package/dist/id-POZOUMDW.js.map +1 -0
- package/dist/index.d.ts +253 -41
- package/dist/index.js +1935 -1678
- package/dist/index.js.map +1 -1
- package/dist/{it-UYH642IJ.js → it-KDYJADSH.js} +11 -4
- package/dist/it-KDYJADSH.js.map +1 -0
- package/dist/{ja-JTVWAC4L.js → ja-Z5Z6ZF3P.js} +11 -4
- package/dist/ja-Z5Z6ZF3P.js.map +1 -0
- package/dist/{ko-IHXFMOIZ.js → ko-7X3XUXRN.js} +11 -4
- package/dist/ko-7X3XUXRN.js.map +1 -0
- package/dist/{ms-JH25VFLO.js → ms-BTWEIW43.js} +11 -4
- package/dist/ms-BTWEIW43.js.map +1 -0
- package/dist/{nl-SURXW7IQ.js → nl-IS2CGF5I.js} +11 -4
- package/dist/nl-IS2CGF5I.js.map +1 -0
- package/dist/{no-HXIKQFDZ.js → no-AAMEVA26.js} +11 -4
- package/dist/no-AAMEVA26.js.map +1 -0
- package/dist/{pl-7A4KU6XK.js → pl-PVXUQYFT.js} +11 -4
- package/dist/pl-PVXUQYFT.js.map +1 -0
- package/dist/{pt-6MGYP7Q5.js → pt-3ITQ76TL.js} +11 -4
- package/dist/pt-3ITQ76TL.js.map +1 -0
- package/dist/{ro-IROCHNIA.js → ro-FRB4IIVS.js} +11 -4
- package/dist/ro-FRB4IIVS.js.map +1 -0
- package/dist/{sv-WDJD75CV.js → sv-DS3RO24M.js} +11 -4
- package/dist/sv-DS3RO24M.js.map +1 -0
- package/dist/test-utils.js +7 -2
- package/dist/test-utils.js.map +1 -1
- package/dist/{th-RIO2E6B3.js → th-AX66MWWB.js} +11 -4
- package/dist/th-AX66MWWB.js.map +1 -0
- package/dist/{tr-TZ7ERCIN.js → tr-7KTB67TT.js} +11 -4
- package/dist/tr-7KTB67TT.js.map +1 -0
- package/dist/{uk-ZFWDKLUE.js → uk-SMQS2MAQ.js} +11 -4
- package/dist/uk-SMQS2MAQ.js.map +1 -0
- package/dist/{vi-UIOSP2JG.js → vi-D3JXL2EZ.js} +11 -4
- package/dist/vi-D3JXL2EZ.js.map +1 -0
- package/dist/{zh-6R6YUY46.js → zh-S754AWFS.js} +11 -4
- package/dist/zh-S754AWFS.js.map +1 -0
- package/package.json +4 -4
- package/src/components/modals/ResourceGenerateModal.tsx +10 -4
- package/src/components/modals/ResourceSearchModal.tsx +3 -1
- package/src/components/modals/SearchModal.tsx +3 -1
- package/src/components/modals/__tests__/ResourceGenerateModal.test.tsx +5 -0
- package/src/components/modals/__tests__/ResourceSearchModal.test.tsx +2 -1
- package/src/components/modals/__tests__/SearchModal.search-wiring.test.tsx +2 -1
- package/src/components/resource/AnnotateView.tsx +70 -138
- package/src/components/resource/AnnotationHistory.tsx +53 -17
- package/src/components/resource/BrowseView.tsx +3 -2
- package/src/components/resource/ResourceViewer.tsx +39 -22
- package/src/components/resource/__tests__/AnnotateView.registry.test.tsx +114 -0
- package/src/components/resource/__tests__/AnnotationHistory.test.tsx +63 -41
- package/src/components/resource/__tests__/BrowseView.reference-hover.test.tsx +2 -1
- package/src/components/resource/__tests__/ResourceViewer.multi-mount-invalidation.test.tsx +140 -0
- package/src/components/resource/__tests__/ResourceViewer.registry.test.tsx +89 -0
- package/src/components/resource/annotate-renderers.tsx +155 -0
- package/src/components/resource/panels/ReferencesPanel.tsx +38 -3
- package/src/components/resource/panels/TagEntry.tsx +5 -1
- package/src/components/resource/panels/TaggingPanel.tsx +6 -1
- package/src/components/resource/panels/UnifiedAnnotationsPanel.tsx +41 -0
- package/src/components/resource/panels/__tests__/ReferencesPanel.headless.test.tsx +90 -0
- package/src/components/resource/panels/__tests__/TagEntry.test.tsx +2 -2
- package/src/components/resource/panels/__tests__/TaggingPanel.test.tsx +2 -2
- package/src/components/resource/panels/__tests__/UnifiedAnnotationsPanel.failure.test.tsx +78 -0
- package/src/features/admin-security/state/__tests__/admin-security-state-unit.test.ts +9 -8
- package/src/features/admin-security/state/admin-security-state-unit.ts +3 -2
- package/src/features/admin-users/state/__tests__/admin-users-state-unit.test.ts +6 -5
- package/src/features/admin-users/state/admin-users-state-unit.ts +3 -2
- package/src/features/auth-welcome/state/__tests__/welcome-state-unit.test.ts +34 -24
- package/src/features/auth-welcome/state/welcome-state-unit.ts +7 -2
- package/src/features/moderate-entity-tags/state/__tests__/entity-tags-state-unit.test.ts +36 -11
- package/src/features/moderate-entity-tags/state/entity-tags-state-unit.ts +9 -10
- package/src/features/resource-compose/state/__tests__/compose-page-state-unit.test.ts +32 -11
- package/src/features/resource-compose/state/compose-page-state-unit.ts +9 -8
- package/src/features/resource-discovery/__tests__/ResourceDiscoveryPage.test.tsx +3 -1
- package/src/features/resource-discovery/components/ResourceDiscoveryPage.tsx +25 -0
- package/src/features/resource-discovery/state/__tests__/discover-state-unit.test.ts +89 -24
- package/src/features/resource-discovery/state/discover-state-unit.ts +30 -30
- package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +1 -0
- package/src/features/resource-viewer/components/ResourceViewerPage.tsx +67 -30
- package/src/features/resource-viewer/state/__tests__/resource-loader-state-unit.test.ts +110 -7
- package/src/features/resource-viewer/state/__tests__/resource-viewer-page-state-unit.test.ts +166 -26
- package/src/features/resource-viewer/state/resource-loader-state-unit.ts +74 -9
- package/src/features/resource-viewer/state/resource-viewer-page-state-unit.ts +27 -26
- package/translations/ar.json +10 -3
- package/translations/bn.json +10 -3
- package/translations/cs.json +10 -3
- package/translations/da.json +10 -3
- package/translations/de.json +10 -3
- package/translations/el.json +10 -3
- package/translations/en.json +10 -3
- package/translations/es.json +10 -3
- package/translations/fa.json +10 -3
- package/translations/fi.json +10 -3
- package/translations/fr.json +10 -3
- package/translations/he.json +10 -3
- package/translations/hi.json +10 -3
- package/translations/id.json +10 -3
- package/translations/it.json +10 -3
- package/translations/ja.json +10 -3
- package/translations/ko.json +10 -3
- package/translations/ms.json +10 -3
- package/translations/nl.json +10 -3
- package/translations/no.json +10 -3
- package/translations/pl.json +10 -3
- package/translations/pt.json +10 -3
- package/translations/ro.json +10 -3
- package/translations/sv.json +10 -3
- package/translations/th.json +10 -3
- package/translations/tr.json +10 -3
- package/translations/uk.json +10 -3
- package/translations/vi.json +10 -3
- package/translations/zh.json +10 -3
- package/dist/ar-ZHKO4XDR.js.map +0 -1
- package/dist/bn-3BUDKAK5.js.map +0 -1
- package/dist/chunk-R52YAGHK.js.map +0 -1
- package/dist/cs-2EFEUKTW.js.map +0 -1
- package/dist/da-SUD3Q44Q.js.map +0 -1
- package/dist/de-6IC5QRX7.js.map +0 -1
- package/dist/el-Q4NAXFRC.js.map +0 -1
- package/dist/es-YUKZBD4W.js.map +0 -1
- package/dist/fa-OJ47LILE.js.map +0 -1
- package/dist/fi-QFFL5CUB.js.map +0 -1
- package/dist/fr-BOQZZWP4.js.map +0 -1
- package/dist/he-LXDY3TFA.js.map +0 -1
- package/dist/hi-HOJER77V.js.map +0 -1
- package/dist/id-I5WQ6EWD.js.map +0 -1
- package/dist/it-UYH642IJ.js.map +0 -1
- package/dist/ja-JTVWAC4L.js.map +0 -1
- package/dist/ko-IHXFMOIZ.js.map +0 -1
- package/dist/ms-JH25VFLO.js.map +0 -1
- package/dist/nl-SURXW7IQ.js.map +0 -1
- package/dist/no-HXIKQFDZ.js.map +0 -1
- package/dist/pl-7A4KU6XK.js.map +0 -1
- package/dist/pt-6MGYP7Q5.js.map +0 -1
- package/dist/ro-IROCHNIA.js.map +0 -1
- package/dist/sv-WDJD75CV.js.map +0 -1
- package/dist/th-RIO2E6B3.js.map +0 -1
- package/dist/tr-TZ7ERCIN.js.map +0 -1
- package/dist/uk-ZFWDKLUE.js.map +0 -1
- package/dist/vi-UIOSP2JG.js.map +0 -1
- package/dist/zh-6R6YUY46.js.map +0 -1
- /package/dist/{chunk-PFUXQPWJ.js.map → chunk-RO5CRVIA.js.map} +0 -0
- /package/dist/{en-P2L4X27N.js.map → en-QFIEG3CA.js.map} +0 -0
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import {
|
|
2
|
+
import { asStates } from '../../../../__tests__/test-client';
|
|
3
|
+
import { sessionOf } from '../../../../__tests__/test-client';
|
|
4
|
+
import { BehaviorSubject, Subject, firstValueFrom } from 'rxjs';
|
|
3
5
|
import { filter } from 'rxjs/operators';
|
|
4
6
|
import { resourceId as makeResourceId } from '@semiont/core';
|
|
5
7
|
import type { SemiontClient } from '@semiont/sdk';
|
|
@@ -13,15 +15,47 @@ function mockClient(resource$?: BehaviorSubject<unknown>): SemiontClient {
|
|
|
13
15
|
const invalidate = vi.fn();
|
|
14
16
|
return {
|
|
15
17
|
browse: {
|
|
16
|
-
resource: () => subject.asObservable(),
|
|
18
|
+
resource: () => asStates(subject.asObservable()),
|
|
17
19
|
invalidateResourceDetail: invalidate,
|
|
18
20
|
},
|
|
19
21
|
} as unknown as SemiontClient;
|
|
20
22
|
}
|
|
21
23
|
|
|
24
|
+
/**
|
|
25
|
+
* A client whose `browse.resource()` mirrors the cache's B15 contract: each
|
|
26
|
+
* `observe()` call hands back a FRESH observable (the failure marker is
|
|
27
|
+
* cleared and a new attempt chain starts), and a terminal failure arrives as
|
|
28
|
+
* an RxJS error notification on that key.
|
|
29
|
+
*/
|
|
30
|
+
function failingClient() {
|
|
31
|
+
const attempts: Array<Subject<unknown>> = [];
|
|
32
|
+
const invalidate = vi.fn();
|
|
33
|
+
const client = {
|
|
34
|
+
browse: {
|
|
35
|
+
resource: () => {
|
|
36
|
+
const s = new Subject<unknown>();
|
|
37
|
+
attempts.push(s);
|
|
38
|
+
return s.asObservable();
|
|
39
|
+
},
|
|
40
|
+
invalidateResourceDetail: invalidate,
|
|
41
|
+
},
|
|
42
|
+
} as unknown as SemiontClient;
|
|
43
|
+
return {
|
|
44
|
+
client,
|
|
45
|
+
invalidate,
|
|
46
|
+
/** Fail the most recent attempt the way B15 does. */
|
|
47
|
+
failLatest: (message: string) =>
|
|
48
|
+
attempts[attempts.length - 1]!.next({ status: 'failed', error: new Error(message) }),
|
|
49
|
+
/** Resolve the most recent attempt with a value. */
|
|
50
|
+
resolveLatest: (value: unknown) =>
|
|
51
|
+
attempts[attempts.length - 1]!.next({ status: 'ready', value }),
|
|
52
|
+
attemptCount: () => attempts.length,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
22
56
|
describe('createResourceLoaderStateUnit', () => {
|
|
23
57
|
it('exposes resource from browse namespace', async () => {
|
|
24
|
-
const stateUnit = createResourceLoaderStateUnit(mockClient(), RID);
|
|
58
|
+
const stateUnit = createResourceLoaderStateUnit(sessionOf(mockClient()), RID);
|
|
25
59
|
const resource = await firstValueFrom(stateUnit.resource$.pipe(filter((r) => r !== undefined)));
|
|
26
60
|
expect((resource as { name: string }).name).toBe('Test');
|
|
27
61
|
stateUnit.dispose();
|
|
@@ -29,7 +63,7 @@ describe('createResourceLoaderStateUnit', () => {
|
|
|
29
63
|
|
|
30
64
|
it('reports loading when resource is undefined', async () => {
|
|
31
65
|
const subject = new BehaviorSubject<unknown>(undefined);
|
|
32
|
-
const stateUnit = createResourceLoaderStateUnit(mockClient(subject), RID);
|
|
66
|
+
const stateUnit = createResourceLoaderStateUnit(sessionOf(mockClient(subject)), RID);
|
|
33
67
|
expect(await firstValueFrom(stateUnit.isLoading$)).toBe(true);
|
|
34
68
|
|
|
35
69
|
subject.next({ '@id': 'res-1' });
|
|
@@ -39,18 +73,87 @@ describe('createResourceLoaderStateUnit', () => {
|
|
|
39
73
|
|
|
40
74
|
it('invalidate calls browse.invalidateResourceDetail', () => {
|
|
41
75
|
const client = mockClient();
|
|
42
|
-
const stateUnit = createResourceLoaderStateUnit(client, RID);
|
|
76
|
+
const stateUnit = createResourceLoaderStateUnit(sessionOf(client), RID);
|
|
43
77
|
stateUnit.invalidate();
|
|
44
78
|
expect(client.browse.invalidateResourceDetail).toHaveBeenCalledWith(RID);
|
|
45
79
|
stateUnit.dispose();
|
|
46
80
|
});
|
|
47
81
|
});
|
|
48
82
|
|
|
83
|
+
describe('createResourceLoaderStateUnit — terminal failure (B15)', () => {
|
|
84
|
+
// "Loading" cannot be defined as "no value yet": a key that fails with
|
|
85
|
+
// nothing cached has no value EITHER, so a two-state model reports a dead
|
|
86
|
+
// request as an eternal spinner and drops the reason on the floor. The
|
|
87
|
+
// failure must be a state of its own.
|
|
88
|
+
// See .plans/bugs/resource-page-frozen-on-disposed-client-after-kb-switch.md (D4)
|
|
89
|
+
|
|
90
|
+
it('surfaces a terminal failure on error$ instead of leaving it undelivered', async () => {
|
|
91
|
+
const h = failingClient();
|
|
92
|
+
const stateUnit = createResourceLoaderStateUnit(sessionOf(h.client), RID);
|
|
93
|
+
const seen: Array<Error | null> = [];
|
|
94
|
+
stateUnit.error$.subscribe((e) => seen.push(e));
|
|
95
|
+
|
|
96
|
+
expect(seen).toEqual([null]);
|
|
97
|
+
h.failLatest('Resource not found');
|
|
98
|
+
|
|
99
|
+
expect(seen.length).toBe(2);
|
|
100
|
+
expect(seen[1]?.message).toBe('Resource not found');
|
|
101
|
+
stateUnit.dispose();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('stops reporting loading once the request has terminally failed', async () => {
|
|
105
|
+
const h = failingClient();
|
|
106
|
+
const stateUnit = createResourceLoaderStateUnit(sessionOf(h.client), RID);
|
|
107
|
+
const loading: boolean[] = [];
|
|
108
|
+
stateUnit.isLoading$.subscribe((l) => loading.push(l));
|
|
109
|
+
|
|
110
|
+
expect(loading[loading.length - 1]).toBe(true);
|
|
111
|
+
h.failLatest('Resource not found');
|
|
112
|
+
|
|
113
|
+
// The defect: without a failure state this stays true forever, and the
|
|
114
|
+
// page sits on "Loading resource..." with no way out.
|
|
115
|
+
expect(loading[loading.length - 1]).toBe(false);
|
|
116
|
+
stateUnit.dispose();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('an error notification does not tear the unit down — invalidate starts a fresh attempt that can succeed', async () => {
|
|
120
|
+
const h = failingClient();
|
|
121
|
+
const stateUnit = createResourceLoaderStateUnit(sessionOf(h.client), RID);
|
|
122
|
+
h.failLatest('Resource not found');
|
|
123
|
+
expect(await firstValueFrom(stateUnit.error$)).not.toBeNull();
|
|
124
|
+
|
|
125
|
+
stateUnit.invalidate();
|
|
126
|
+
expect(h.invalidate).toHaveBeenCalledWith(RID);
|
|
127
|
+
// B15: a fresh observe() clears the marker and restarts the chain, so the
|
|
128
|
+
// unit must be listening to a NEW attempt, not the errored one.
|
|
129
|
+
expect(h.attemptCount()).toBeGreaterThan(1);
|
|
130
|
+
expect(await firstValueFrom(stateUnit.error$)).toBeNull();
|
|
131
|
+
|
|
132
|
+
h.resolveLatest({ '@id': 'res-1', name: 'Recovered' });
|
|
133
|
+
const resource = await firstValueFrom(stateUnit.resource$.pipe(filter((r) => r !== undefined)));
|
|
134
|
+
expect((resource as { name: string }).name).toBe('Recovered');
|
|
135
|
+
expect(await firstValueFrom(stateUnit.isLoading$)).toBe(false);
|
|
136
|
+
stateUnit.dispose();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('a value arriving after a failure clears the error', async () => {
|
|
140
|
+
const h = failingClient();
|
|
141
|
+
const stateUnit = createResourceLoaderStateUnit(sessionOf(h.client), RID);
|
|
142
|
+
h.failLatest('transient');
|
|
143
|
+
stateUnit.invalidate();
|
|
144
|
+
h.resolveLatest({ '@id': 'res-1', name: 'Test' });
|
|
145
|
+
|
|
146
|
+
expect(await firstValueFrom(stateUnit.error$)).toBeNull();
|
|
147
|
+
stateUnit.dispose();
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
49
151
|
describe('ResourceLoaderStateUnit — StateUnit axioms', () => {
|
|
50
152
|
it('satisfies the StateUnit axioms', () => {
|
|
51
|
-
//
|
|
153
|
+
// Owns resource$/error$ and a live subscription to client.browse, so
|
|
154
|
+
// dispose completes them and detaches; invalidate is inert afterwards.
|
|
52
155
|
assertStateUnitAxioms({
|
|
53
|
-
setup: () => createResourceLoaderStateUnit(mockClient(), RID),
|
|
156
|
+
setup: () => createResourceLoaderStateUnit(sessionOf(mockClient()), RID),
|
|
54
157
|
invocations: (u) => [() => u.invalidate()],
|
|
55
158
|
});
|
|
56
159
|
});
|
package/src/features/resource-viewer/state/__tests__/resource-viewer-page-state-unit.test.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
+
import { asStates } from '../../../../__tests__/test-client';
|
|
2
3
|
import { BehaviorSubject, Observable, firstValueFrom } from 'rxjs';
|
|
3
4
|
import { filter } from 'rxjs/operators';
|
|
4
5
|
import { annotationId, resourceId as makeResourceId } from '@semiont/core';
|
|
5
6
|
import type { ShellStateUnit } from '../../../../state/shell-state-unit';
|
|
6
|
-
import { createResourceViewerPageStateUnit } from '../resource-viewer-page-state-unit';
|
|
7
|
+
import { createResourceViewerPageStateUnit, type ResourceViewerPageStateUnit } from '../resource-viewer-page-state-unit';
|
|
7
8
|
import { assertStateUnitAxioms, disposeProbe } from '@semiont/core/testing';
|
|
8
|
-
import {
|
|
9
|
+
import { makeTestSession, type TestSession } from '../../../../__tests__/test-client';
|
|
9
10
|
|
|
10
11
|
const RID = makeResourceId('res-1');
|
|
11
12
|
|
|
@@ -28,18 +29,18 @@ function clientWithNamespaces(overrides: {
|
|
|
28
29
|
referencedBy$?: BehaviorSubject<unknown[] | undefined>;
|
|
29
30
|
resourceRepresentation?: ReturnType<typeof vi.fn>;
|
|
30
31
|
mediaToken?: ReturnType<typeof vi.fn>;
|
|
31
|
-
} = {}):
|
|
32
|
+
} = {}): TestSession {
|
|
32
33
|
const annotations$ = overrides.annotations$ ?? new BehaviorSubject<unknown[] | undefined>([]);
|
|
33
34
|
const entityTypes$ = overrides.entityTypes$ ?? new BehaviorSubject<string[] | undefined>(['Person']);
|
|
34
35
|
const events$ = overrides.events$ ?? new BehaviorSubject<unknown[] | undefined>([]);
|
|
35
36
|
const referencedBy$ = overrides.referencedBy$ ?? new BehaviorSubject<unknown[] | undefined>([]);
|
|
36
37
|
|
|
37
|
-
return
|
|
38
|
+
return makeTestSession({
|
|
38
39
|
browse: {
|
|
39
|
-
annotations: () => annotations$.asObservable(),
|
|
40
|
-
entityTypes: () => entityTypes$.asObservable(),
|
|
41
|
-
events: () => events$.asObservable(),
|
|
42
|
-
referencedBy: () => referencedBy$.asObservable(),
|
|
40
|
+
annotations: () => asStates(annotations$.asObservable()),
|
|
41
|
+
entityTypes: () => asStates(entityTypes$.asObservable()),
|
|
42
|
+
events: () => asStates(events$.asObservable()),
|
|
43
|
+
referencedBy: () => asStates(referencedBy$.asObservable()),
|
|
43
44
|
resourceRepresentation: overrides.resourceRepresentation ?? vi.fn().mockResolvedValue({
|
|
44
45
|
data: new TextEncoder().encode('hello').buffer,
|
|
45
46
|
contentType: 'text/plain',
|
|
@@ -61,13 +62,13 @@ function clientWithNamespaces(overrides: {
|
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
describe('createResourceViewerPageStateUnit', () => {
|
|
64
|
-
let tc:
|
|
65
|
+
let tc: TestSession;
|
|
65
66
|
|
|
66
67
|
afterEach(() => { tc?.bus.destroy(); });
|
|
67
68
|
|
|
68
69
|
it('exposes flow VMs', () => {
|
|
69
70
|
tc = clientWithNamespaces();
|
|
70
|
-
const stateUnit = createResourceViewerPageStateUnit(tc.
|
|
71
|
+
const stateUnit = createResourceViewerPageStateUnit(tc.session, RID, 'en', mockBrowse());
|
|
71
72
|
|
|
72
73
|
expect(stateUnit.beckon).toBeDefined();
|
|
73
74
|
expect(stateUnit.mark).toBeDefined();
|
|
@@ -83,9 +84,9 @@ describe('createResourceViewerPageStateUnit', () => {
|
|
|
83
84
|
{ id: 'a1', motivation: 'highlighting' },
|
|
84
85
|
]);
|
|
85
86
|
tc = clientWithNamespaces({ annotations$ });
|
|
86
|
-
const stateUnit = createResourceViewerPageStateUnit(tc.
|
|
87
|
+
const stateUnit = createResourceViewerPageStateUnit(tc.session, RID, 'en', mockBrowse());
|
|
87
88
|
|
|
88
|
-
const anns = await firstValueFrom(stateUnit.annotations$);
|
|
89
|
+
const anns = await firstValueFrom(stateUnit.annotations.value$);
|
|
89
90
|
expect(anns).toHaveLength(1);
|
|
90
91
|
|
|
91
92
|
stateUnit.dispose();
|
|
@@ -97,7 +98,7 @@ describe('createResourceViewerPageStateUnit', () => {
|
|
|
97
98
|
{ id: 'a2', motivation: 'commenting', body: [{ type: 'TextualBody', value: 'note' }], target: { selector: { type: 'TextQuoteSelector', exact: 'y' } } },
|
|
98
99
|
]);
|
|
99
100
|
tc = clientWithNamespaces({ annotations$ });
|
|
100
|
-
const stateUnit = createResourceViewerPageStateUnit(tc.
|
|
101
|
+
const stateUnit = createResourceViewerPageStateUnit(tc.session, RID, 'en', mockBrowse());
|
|
101
102
|
|
|
102
103
|
const groups = await firstValueFrom(stateUnit.annotationGroups$);
|
|
103
104
|
expect(groups.highlights.length + groups.comments.length).toBeGreaterThanOrEqual(1);
|
|
@@ -107,9 +108,9 @@ describe('createResourceViewerPageStateUnit', () => {
|
|
|
107
108
|
|
|
108
109
|
it('exposes entity types', async () => {
|
|
109
110
|
tc = clientWithNamespaces();
|
|
110
|
-
const stateUnit = createResourceViewerPageStateUnit(tc.
|
|
111
|
+
const stateUnit = createResourceViewerPageStateUnit(tc.session, RID, 'en', mockBrowse());
|
|
111
112
|
|
|
112
|
-
const types = await firstValueFrom(stateUnit.entityTypes$);
|
|
113
|
+
const types = await firstValueFrom(stateUnit.entityTypes.value$);
|
|
113
114
|
expect(types).toEqual(['Person']);
|
|
114
115
|
|
|
115
116
|
stateUnit.dispose();
|
|
@@ -118,9 +119,9 @@ describe('createResourceViewerPageStateUnit', () => {
|
|
|
118
119
|
it('exposes events from browse namespace', async () => {
|
|
119
120
|
const events$ = new BehaviorSubject<unknown[] | undefined>([{ id: 'e1', type: 'mark:added' }]);
|
|
120
121
|
tc = clientWithNamespaces({ events$ });
|
|
121
|
-
const stateUnit = createResourceViewerPageStateUnit(tc.
|
|
122
|
+
const stateUnit = createResourceViewerPageStateUnit(tc.session, RID, 'en', mockBrowse());
|
|
122
123
|
|
|
123
|
-
const events = await firstValueFrom(stateUnit.events$);
|
|
124
|
+
const events = await firstValueFrom(stateUnit.events.value$);
|
|
124
125
|
expect(events).toEqual([{ id: 'e1', type: 'mark:added' }]);
|
|
125
126
|
|
|
126
127
|
stateUnit.dispose();
|
|
@@ -129,9 +130,9 @@ describe('createResourceViewerPageStateUnit', () => {
|
|
|
129
130
|
it('exposes referencedBy from browse namespace', async () => {
|
|
130
131
|
const referencedBy$ = new BehaviorSubject<unknown[] | undefined>([{ resourceId: 'r2' }]);
|
|
131
132
|
tc = clientWithNamespaces({ referencedBy$ });
|
|
132
|
-
const stateUnit = createResourceViewerPageStateUnit(tc.
|
|
133
|
+
const stateUnit = createResourceViewerPageStateUnit(tc.session, RID, 'en', mockBrowse());
|
|
133
134
|
|
|
134
|
-
const refs = await firstValueFrom(stateUnit.referencedBy$);
|
|
135
|
+
const refs = await firstValueFrom(stateUnit.referencedBy.value$);
|
|
135
136
|
expect(refs).toEqual([{ resourceId: 'r2' }]);
|
|
136
137
|
|
|
137
138
|
stateUnit.dispose();
|
|
@@ -141,7 +142,7 @@ describe('createResourceViewerPageStateUnit', () => {
|
|
|
141
142
|
const mediaToken = vi.fn().mockResolvedValue({ token: 'tok-456' });
|
|
142
143
|
tc = clientWithNamespaces({ mediaToken });
|
|
143
144
|
const stateUnit = createResourceViewerPageStateUnit(
|
|
144
|
-
tc.
|
|
145
|
+
tc.session, RID, 'en', mockBrowse(),
|
|
145
146
|
{ mediaType: 'image/png' },
|
|
146
147
|
);
|
|
147
148
|
|
|
@@ -159,7 +160,7 @@ describe('createResourceViewerPageStateUnit', () => {
|
|
|
159
160
|
const resourceRepresentation = vi.fn();
|
|
160
161
|
tc = clientWithNamespaces({ mediaToken, resourceRepresentation });
|
|
161
162
|
const stateUnit = createResourceViewerPageStateUnit(
|
|
162
|
-
tc.
|
|
163
|
+
tc.session, RID, 'en', mockBrowse(),
|
|
163
164
|
{ mediaType: 'application/zip' },
|
|
164
165
|
);
|
|
165
166
|
|
|
@@ -178,7 +179,7 @@ describe('createResourceViewerPageStateUnit', () => {
|
|
|
178
179
|
});
|
|
179
180
|
tc = clientWithNamespaces({ mediaToken, resourceRepresentation });
|
|
180
181
|
const stateUnit = createResourceViewerPageStateUnit(
|
|
181
|
-
tc.
|
|
182
|
+
tc.session, RID, 'en', mockBrowse(),
|
|
182
183
|
{ mediaType: 'text/x-custom' },
|
|
183
184
|
);
|
|
184
185
|
|
|
@@ -190,7 +191,7 @@ describe('createResourceViewerPageStateUnit', () => {
|
|
|
190
191
|
|
|
191
192
|
it('wizard initializes closed', async () => {
|
|
192
193
|
tc = clientWithNamespaces();
|
|
193
|
-
const stateUnit = createResourceViewerPageStateUnit(tc.
|
|
194
|
+
const stateUnit = createResourceViewerPageStateUnit(tc.session, RID, 'en', mockBrowse());
|
|
194
195
|
|
|
195
196
|
const wizard = await firstValueFrom(stateUnit.wizard$);
|
|
196
197
|
expect(wizard.open).toBe(false);
|
|
@@ -200,7 +201,7 @@ describe('createResourceViewerPageStateUnit', () => {
|
|
|
200
201
|
|
|
201
202
|
it('bind:initiate opens wizard and fires gather:requested', async () => {
|
|
202
203
|
tc = clientWithNamespaces();
|
|
203
|
-
const stateUnit = createResourceViewerPageStateUnit(tc.
|
|
204
|
+
const stateUnit = createResourceViewerPageStateUnit(tc.session, RID, 'en', mockBrowse());
|
|
204
205
|
const gatherEvents: unknown[] = [];
|
|
205
206
|
tc.bus.get('gather:requested').subscribe((e) => gatherEvents.push(e));
|
|
206
207
|
|
|
@@ -220,7 +221,7 @@ describe('createResourceViewerPageStateUnit', () => {
|
|
|
220
221
|
|
|
221
222
|
it('closeWizard resets wizard state', async () => {
|
|
222
223
|
tc = clientWithNamespaces();
|
|
223
|
-
const stateUnit = createResourceViewerPageStateUnit(tc.
|
|
224
|
+
const stateUnit = createResourceViewerPageStateUnit(tc.session, RID, 'en', mockBrowse());
|
|
224
225
|
|
|
225
226
|
tc.bus.get('bind:initiate').next({
|
|
226
227
|
annotationId: annotationId('ann-1'),
|
|
@@ -239,6 +240,145 @@ describe('createResourceViewerPageStateUnit', () => {
|
|
|
239
240
|
});
|
|
240
241
|
});
|
|
241
242
|
|
|
243
|
+
describe('createResourceViewerPageStateUnit — list failure states', () => {
|
|
244
|
+
// A cache-backed list has three outcomes, not two. `browse.*()` delivers a
|
|
245
|
+
// terminal failure as an RxJS error (B15) once the B14 retry is exhausted
|
|
246
|
+
// with nothing stored — and a key that failed has no value either, so a
|
|
247
|
+
// (value | not-yet) model reports a dead request as an eternal spinner.
|
|
248
|
+
// Two panels do exactly that today: ReferencesPanel via
|
|
249
|
+
// `referencedByLoading = raw === undefined`, and AnnotationHistory via
|
|
250
|
+
// `loading = eventsData === undefined`.
|
|
251
|
+
// See .plans/PANEL-FAILURE-STATES.md
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* One case per cache-backed list. Each builds its OWN correctly-typed
|
|
255
|
+
* subject and returns the list off the unit, so the shared assertions below
|
|
256
|
+
* need no indexing by name and no cast.
|
|
257
|
+
*/
|
|
258
|
+
/** The slice of `ListState<T>` these shared assertions read. */
|
|
259
|
+
interface ListProbe {
|
|
260
|
+
value$: Observable<unknown>;
|
|
261
|
+
loading$: Observable<boolean>;
|
|
262
|
+
error$: Observable<Error | null>;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const LIST_CASES: ReadonlyArray<{
|
|
266
|
+
name: string;
|
|
267
|
+
start: () => {
|
|
268
|
+
subject: { next: (s: never) => void };
|
|
269
|
+
tc: TestSession;
|
|
270
|
+
unit: ResourceViewerPageStateUnit;
|
|
271
|
+
list: ListProbe;
|
|
272
|
+
};
|
|
273
|
+
}> = [
|
|
274
|
+
{
|
|
275
|
+
name: 'annotations',
|
|
276
|
+
start: () => {
|
|
277
|
+
const subject = new BehaviorSubject<unknown[] | undefined>(undefined);
|
|
278
|
+
const tc = clientWithNamespaces({ annotations$: subject });
|
|
279
|
+
const unit = createResourceViewerPageStateUnit(tc.session, RID, 'en', mockBrowse());
|
|
280
|
+
return { subject, tc, unit, list: unit.annotations };
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
name: 'entityTypes',
|
|
285
|
+
start: () => {
|
|
286
|
+
const subject = new BehaviorSubject<string[] | undefined>(undefined);
|
|
287
|
+
const tc = clientWithNamespaces({ entityTypes$: subject });
|
|
288
|
+
const unit = createResourceViewerPageStateUnit(tc.session, RID, 'en', mockBrowse());
|
|
289
|
+
return { subject, tc, unit, list: unit.entityTypes };
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
name: 'events',
|
|
294
|
+
start: () => {
|
|
295
|
+
const subject = new BehaviorSubject<unknown[] | undefined>(undefined);
|
|
296
|
+
const tc = clientWithNamespaces({ events$: subject });
|
|
297
|
+
const unit = createResourceViewerPageStateUnit(tc.session, RID, 'en', mockBrowse());
|
|
298
|
+
return { subject, tc, unit, list: unit.events };
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
name: 'referencedBy',
|
|
303
|
+
start: () => {
|
|
304
|
+
const subject = new BehaviorSubject<unknown[] | undefined>(undefined);
|
|
305
|
+
const tc = clientWithNamespaces({ referencedBy$: subject });
|
|
306
|
+
const unit = createResourceViewerPageStateUnit(tc.session, RID, 'en', mockBrowse());
|
|
307
|
+
return { subject, tc, unit, list: unit.referencedBy };
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
];
|
|
311
|
+
|
|
312
|
+
it.each(LIST_CASES)('$name: stops reporting loading and surfaces the reason on terminal failure', async ({ start }) => {
|
|
313
|
+
const { subject, tc, unit, list } = start();
|
|
314
|
+
|
|
315
|
+
expect(await firstValueFrom(list.loading$)).toBe(true);
|
|
316
|
+
expect(await firstValueFrom(list.error$)).toBeNull();
|
|
317
|
+
|
|
318
|
+
subject.next({ status: 'failed', error: new Error('Resource not found') } as never);
|
|
319
|
+
|
|
320
|
+
expect(await firstValueFrom(list.loading$)).toBe(false);
|
|
321
|
+
expect((await firstValueFrom(list.error$))?.message).toBe('Resource not found');
|
|
322
|
+
|
|
323
|
+
unit.dispose();
|
|
324
|
+
tc.bus.destroy();
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it.each(LIST_CASES)('$name: keeps an empty value alongside the failure, so callers still render a list', async ({ start }) => {
|
|
328
|
+
const { subject, tc, unit, list } = start();
|
|
329
|
+
|
|
330
|
+
subject.next({ status: 'failed', error: new Error('boom') } as never);
|
|
331
|
+
|
|
332
|
+
expect(await firstValueFrom(list.value$)).toEqual([]);
|
|
333
|
+
unit.dispose();
|
|
334
|
+
tc.bus.destroy();
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('retry() clears the error, re-enters loading, and re-subscribes so a fresh attempt can succeed', async () => {
|
|
338
|
+
// D1/D3: failure is an EMISSION and recovery is a fresh SUBSCRIPTION —
|
|
339
|
+
// retry() re-attaches, and the new subscription's chain delivers.
|
|
340
|
+
const attempts: Array<BehaviorSubject<unknown>> = [];
|
|
341
|
+
const tc = makeTestSession({
|
|
342
|
+
browse: {
|
|
343
|
+
annotations: () => new BehaviorSubject<unknown>({ status: 'ready', value: [] }).asObservable(),
|
|
344
|
+
entityTypes: () => new BehaviorSubject<unknown>({ status: 'ready', value: [] }).asObservable(),
|
|
345
|
+
events: () => new BehaviorSubject<unknown>({ status: 'ready', value: [] }).asObservable(),
|
|
346
|
+
referencedBy: () => {
|
|
347
|
+
const s = new BehaviorSubject<unknown>({ status: 'pending' });
|
|
348
|
+
attempts.push(s);
|
|
349
|
+
return s.asObservable();
|
|
350
|
+
},
|
|
351
|
+
resourceRepresentation: vi.fn().mockResolvedValue({
|
|
352
|
+
data: new TextEncoder().encode('hello').buffer,
|
|
353
|
+
contentType: 'text/plain',
|
|
354
|
+
}),
|
|
355
|
+
},
|
|
356
|
+
auth: { mediaToken: vi.fn().mockResolvedValue({ token: 'tok' }) },
|
|
357
|
+
mark: { annotation: vi.fn(), delete: vi.fn(), assist: vi.fn(() => new Observable(() => {})) },
|
|
358
|
+
gather: { annotation: vi.fn(() => new Observable(() => {})) },
|
|
359
|
+
match: { search: vi.fn(() => new Observable(() => {})) },
|
|
360
|
+
yield: { fromAnnotation: vi.fn(() => new Observable(() => {})) },
|
|
361
|
+
bind: { body: vi.fn().mockResolvedValue(undefined) },
|
|
362
|
+
});
|
|
363
|
+
const unit = createResourceViewerPageStateUnit(tc.session, RID, 'en', mockBrowse());
|
|
364
|
+
|
|
365
|
+
attempts[0]!.next({ status: 'failed', error: new Error('nope') });
|
|
366
|
+
expect(await firstValueFrom(unit.referencedBy.error$)).not.toBeNull();
|
|
367
|
+
|
|
368
|
+
unit.referencedBy.retry();
|
|
369
|
+
expect(attempts.length).toBeGreaterThan(1);
|
|
370
|
+
expect(await firstValueFrom(unit.referencedBy.error$)).toBeNull();
|
|
371
|
+
expect(await firstValueFrom(unit.referencedBy.loading$)).toBe(true);
|
|
372
|
+
|
|
373
|
+
attempts[attempts.length - 1]!.next({ status: 'ready', value: [{ id: 'ref-1' }] });
|
|
374
|
+
expect(await firstValueFrom(unit.referencedBy.loading$)).toBe(false);
|
|
375
|
+
expect(await firstValueFrom(unit.referencedBy.value$)).toHaveLength(1);
|
|
376
|
+
|
|
377
|
+
unit.dispose();
|
|
378
|
+
tc.bus.destroy();
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
|
|
242
382
|
describe('ResourceViewerPageStateUnit — StateUnit axioms', () => {
|
|
243
383
|
it('satisfies the StateUnit axioms (A7-passed: browse survives; A7-owned: children disposed)', () => {
|
|
244
384
|
const RID = makeResourceId('res-ax');
|
|
@@ -247,7 +387,7 @@ describe('ResourceViewerPageStateUnit — StateUnit axioms', () => {
|
|
|
247
387
|
const browse = disposeProbe();
|
|
248
388
|
const tc = clientWithNamespaces();
|
|
249
389
|
return {
|
|
250
|
-
unit: createResourceViewerPageStateUnit(tc.
|
|
390
|
+
unit: createResourceViewerPageStateUnit(tc.session, RID, 'en', browse as unknown as ShellStateUnit),
|
|
251
391
|
passedIn: [browse],
|
|
252
392
|
teardown: () => tc.bus.destroy(),
|
|
253
393
|
};
|
|
@@ -1,26 +1,91 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readyValue } from '@semiont/sdk';
|
|
2
|
+
import {
|
|
3
|
+
BehaviorSubject,
|
|
4
|
+
combineLatest,
|
|
5
|
+
distinctUntilChanged,
|
|
6
|
+
map,
|
|
7
|
+
type Observable,
|
|
8
|
+
type Subscription,
|
|
9
|
+
} from 'rxjs';
|
|
2
10
|
import type { ResourceDescriptor, ResourceId } from '@semiont/core';
|
|
3
11
|
import type { StateUnit } from '@semiont/core';
|
|
4
|
-
import type {
|
|
12
|
+
import type { SemiontSession } from '@semiont/sdk';
|
|
5
13
|
|
|
6
14
|
export interface ResourceLoaderStateUnit extends StateUnit {
|
|
7
15
|
resource$: Observable<ResourceDescriptor | undefined>;
|
|
8
16
|
isLoading$: Observable<boolean>;
|
|
17
|
+
/** Terminal load failure (B15), or null. Cleared by any value or `invalidate()`. */
|
|
18
|
+
error$: Observable<Error | null>;
|
|
9
19
|
invalidate(): void;
|
|
10
20
|
}
|
|
11
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Load one resource, in three states: loading, loaded, failed.
|
|
24
|
+
*
|
|
25
|
+
* The third state is not optional. `browse.resource()` delivers a terminal
|
|
26
|
+
* failure as an RxJS *error* notification (B15) when the B14 retry is
|
|
27
|
+
* exhausted with nothing cached — and a key that failed has no value either,
|
|
28
|
+
* so a (value | no value) model reports a dead request as an eternal spinner
|
|
29
|
+
* and drops the reason entirely. This unit catches the notification here so
|
|
30
|
+
* that (a) consumers can render the failure and retry it, and (b) it never
|
|
31
|
+
* escapes to RxJS as an unhandled rethrow.
|
|
32
|
+
* See .plans/bugs/resource-page-frozen-on-disposed-client-after-kb-switch.md (D4)
|
|
33
|
+
*/
|
|
12
34
|
export function createResourceLoaderStateUnit(
|
|
13
|
-
|
|
35
|
+
session: SemiontSession,
|
|
14
36
|
resourceId: ResourceId,
|
|
15
37
|
): ResourceLoaderStateUnit {
|
|
16
|
-
const
|
|
17
|
-
const resource$ =
|
|
18
|
-
const
|
|
38
|
+
const { client } = session;
|
|
39
|
+
const resource$ = new BehaviorSubject<ResourceDescriptor | undefined>(undefined);
|
|
40
|
+
const error$ = new BehaviorSubject<Error | null>(null);
|
|
41
|
+
|
|
42
|
+
let subscription: Subscription | null = null;
|
|
43
|
+
let disposed = false;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* (Re)subscribe to this key. An errored observable is terminated, so
|
|
47
|
+
* recovery needs a fresh subscription — which is also what re-arms the
|
|
48
|
+
* cache: `observe()` clears the B15 failure marker and starts a new
|
|
49
|
+
* attempt chain for a key it is asked about again.
|
|
50
|
+
*/
|
|
51
|
+
const attach = (): void => {
|
|
52
|
+
if (disposed) return;
|
|
53
|
+
subscription?.unsubscribe();
|
|
54
|
+
subscription = client.browse.resource(resourceId).subscribe((st) => {
|
|
55
|
+
if (st.status === 'failed') {
|
|
56
|
+
// D1: failure is an emission — the subscription stays alive.
|
|
57
|
+
error$.next(st.error);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (st.status === 'ready' && error$.getValue() !== null) error$.next(null);
|
|
61
|
+
resource$.next(readyValue(st));
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
attach();
|
|
65
|
+
|
|
66
|
+
const isLoading$ = combineLatest([resource$, error$]).pipe(
|
|
67
|
+
map(([resource, error]) => resource === undefined && error === null),
|
|
68
|
+
distinctUntilChanged(),
|
|
69
|
+
);
|
|
19
70
|
|
|
20
71
|
return {
|
|
21
|
-
|
|
72
|
+
// X1: owned subjects are published read-only.
|
|
73
|
+
resource$: resource$.asObservable(),
|
|
22
74
|
isLoading$,
|
|
23
|
-
|
|
24
|
-
|
|
75
|
+
error$: error$.asObservable(),
|
|
76
|
+
invalidate: () => {
|
|
77
|
+
if (disposed) return;
|
|
78
|
+
error$.next(null);
|
|
79
|
+
client.browse.invalidateResourceDetail(resourceId);
|
|
80
|
+
attach();
|
|
81
|
+
},
|
|
82
|
+
dispose: () => {
|
|
83
|
+
if (disposed) return;
|
|
84
|
+
disposed = true;
|
|
85
|
+
subscription?.unsubscribe();
|
|
86
|
+
subscription = null;
|
|
87
|
+
resource$.complete();
|
|
88
|
+
error$.complete();
|
|
89
|
+
},
|
|
25
90
|
};
|
|
26
91
|
}
|
|
@@ -3,12 +3,13 @@ import type { ResourceId, components } from '@semiont/core';
|
|
|
3
3
|
import { createDisposer } from '@semiont/sdk';
|
|
4
4
|
import type { StateUnit } from '@semiont/core';
|
|
5
5
|
import type { ShellStateUnit } from '../../../state/shell-state-unit';
|
|
6
|
+
import { trackList, type ListState } from '../../../state/list-state';
|
|
6
7
|
import { createBeckonStateUnit, type BeckonStateUnit } from '@semiont/sdk';
|
|
7
8
|
import { createMarkStateUnit, type MarkStateUnit } from '@semiont/sdk';
|
|
8
9
|
import { createGatherStateUnit, type GatherStateUnit } from '@semiont/sdk';
|
|
9
10
|
import { createMatchStateUnit } from '@semiont/sdk';
|
|
10
11
|
import { createYieldStateUnit, type YieldStateUnit } from '@semiont/sdk';
|
|
11
|
-
import type {
|
|
12
|
+
import type { SemiontSession } from '@semiont/sdk';
|
|
12
13
|
import { decodeWithCharset, textExtractionOf } from '@semiont/core';
|
|
13
14
|
import { groupAnnotations } from '../../../lib/annotation-groups';
|
|
14
15
|
import type { ReferencedByEntry } from '@semiont/sdk';
|
|
@@ -43,11 +44,12 @@ export interface ResourceViewerPageStateUnit extends StateUnit {
|
|
|
43
44
|
gather: GatherStateUnit;
|
|
44
45
|
yield: YieldStateUnit;
|
|
45
46
|
|
|
46
|
-
annotations
|
|
47
|
+
annotations: ListState<Annotation[]>;
|
|
48
|
+
entityTypes: ListState<string[]>;
|
|
49
|
+
events: ListState<StoredEventResponse[]>;
|
|
50
|
+
referencedBy: ListState<ReferencedByEntry[]>;
|
|
51
|
+
/** Derived from `annotations.value$`; failure/loading live on `annotations`. */
|
|
47
52
|
annotationGroups$: Observable<AnnotationGroups>;
|
|
48
|
-
entityTypes$: Observable<string[]>;
|
|
49
|
-
events$: Observable<StoredEventResponse[]>;
|
|
50
|
-
referencedBy$: Observable<ReferencedByEntry[]>;
|
|
51
53
|
content$: Observable<string>;
|
|
52
54
|
contentLoading$: Observable<boolean>;
|
|
53
55
|
mediaToken$: Observable<string | null>;
|
|
@@ -56,13 +58,18 @@ export interface ResourceViewerPageStateUnit extends StateUnit {
|
|
|
56
58
|
closeWizard(): void;
|
|
57
59
|
}
|
|
58
60
|
|
|
61
|
+
// Session-typed (SESSION-TYPED-FACTORIES.md D1): the parameter is the
|
|
62
|
+
// lifetime this unit must not outlive. The internal flow units below keep
|
|
63
|
+
// the narrower client — their lifetime is THIS unit's disposer, which is now
|
|
64
|
+
// session-bound; that is layering, not a loophole.
|
|
59
65
|
export function createResourceViewerPageStateUnit(
|
|
60
|
-
|
|
66
|
+
session: SemiontSession,
|
|
61
67
|
resourceId: ResourceId,
|
|
62
68
|
locale: string,
|
|
63
69
|
browse: ShellStateUnit,
|
|
64
70
|
options?: { mediaType?: string },
|
|
65
71
|
): ResourceViewerPageStateUnit {
|
|
72
|
+
const { client } = session;
|
|
66
73
|
const disposer = createDisposer();
|
|
67
74
|
|
|
68
75
|
const beckon = createBeckonStateUnit(client);
|
|
@@ -81,23 +88,17 @@ export function createResourceViewerPageStateUnit(
|
|
|
81
88
|
disposer.add(matchStateUnit);
|
|
82
89
|
disposer.add(yieldStateUnit);
|
|
83
90
|
|
|
84
|
-
const annotations
|
|
85
|
-
|
|
86
|
-
);
|
|
91
|
+
const annotations = trackList<Annotation[]>(() => client.browse.annotations(resourceId), []);
|
|
92
|
+
const entityTypes = trackList<string[]>(() => client.browse.entityTypes(), []);
|
|
93
|
+
const events = trackList<StoredEventResponse[]>(() => client.browse.events(resourceId), []);
|
|
94
|
+
const referencedBy = trackList<ReferencedByEntry[]>(() => client.browse.referencedBy(resourceId), []);
|
|
95
|
+
disposer.add(annotations.dispose);
|
|
96
|
+
disposer.add(entityTypes.dispose);
|
|
97
|
+
disposer.add(events.dispose);
|
|
98
|
+
disposer.add(referencedBy.dispose);
|
|
87
99
|
|
|
88
|
-
const annotationGroups$: Observable<AnnotationGroups> =
|
|
89
|
-
|
|
90
|
-
const entityTypes$: Observable<string[]> = client.browse.entityTypes().pipe(
|
|
91
|
-
map((e) => e ?? []),
|
|
92
|
-
);
|
|
93
|
-
|
|
94
|
-
const events$: Observable<StoredEventResponse[]> = client.browse.events(resourceId).pipe(
|
|
95
|
-
map((e) => e ?? []),
|
|
96
|
-
);
|
|
97
|
-
|
|
98
|
-
const referencedBy$: Observable<ReferencedByEntry[]> = client.browse.referencedBy(resourceId).pipe(
|
|
99
|
-
map((r) => r ?? []),
|
|
100
|
-
);
|
|
100
|
+
const annotationGroups$: Observable<AnnotationGroups> =
|
|
101
|
+
annotations.state.value$.pipe(map(groupAnnotations));
|
|
101
102
|
|
|
102
103
|
const content$ = new BehaviorSubject<string>('');
|
|
103
104
|
const contentLoading$ = new BehaviorSubject<boolean>(false);
|
|
@@ -157,11 +158,11 @@ export function createResourceViewerPageStateUnit(
|
|
|
157
158
|
mark,
|
|
158
159
|
gather,
|
|
159
160
|
yield: yieldStateUnit,
|
|
160
|
-
annotations
|
|
161
|
+
annotations: annotations.state,
|
|
162
|
+
entityTypes: entityTypes.state,
|
|
163
|
+
events: events.state,
|
|
164
|
+
referencedBy: referencedBy.state,
|
|
161
165
|
annotationGroups$,
|
|
162
|
-
entityTypes$,
|
|
163
|
-
events$,
|
|
164
|
-
referencedBy$,
|
|
165
166
|
content$: content$.asObservable(),
|
|
166
167
|
contentLoading$: contentLoading$.asObservable(),
|
|
167
168
|
mediaToken$: mediaToken$.asObservable(),
|