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