@semiont/react-ui 0.2.33-build.84 → 0.2.33-build.85
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/index.mjs +9 -11
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/features/resource-viewer/__tests__/ResourceMutations.test.tsx +300 -0
- package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +2 -0
- package/src/features/resource-viewer/components/ResourceViewerPage.tsx +11 -11
- package/src/styles/core/checkboxes.css +8 -0
package/package.json
CHANGED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression test: resource mutations must be hoisted to component top level
|
|
3
|
+
*
|
|
4
|
+
* Bug: handleResourceClone (and handleResourceArchive / handleResourceUnarchive)
|
|
5
|
+
* called useMutation() inside a useCallback, violating the Rules of Hooks.
|
|
6
|
+
* React does not re-execute memoized callbacks on every render, so the mutation
|
|
7
|
+
* object was never properly initialised and .mutateAsync() threw immediately,
|
|
8
|
+
* always landing in the catch block and showing "Failed to generate clone link".
|
|
9
|
+
*
|
|
10
|
+
* Fix: the two mutations (updateMutation, generateCloneTokenMutation) are now
|
|
11
|
+
* called unconditionally at the top level of ResourceViewerPage, and the
|
|
12
|
+
* resulting objects are threaded into the useCallback dependency arrays.
|
|
13
|
+
*
|
|
14
|
+
* This test suite uses a minimal harness that:
|
|
15
|
+
* - Mounts the REAL useResources() hook (which calls useMutation internally)
|
|
16
|
+
* - Wires up a REAL EventBus and subscribes the same handlers as ResourceViewerPage
|
|
17
|
+
* - Spies on SemiontApiClient.prototype to intercept API calls
|
|
18
|
+
*
|
|
19
|
+
* It confirms that each event-driven mutation calls the API exactly once,
|
|
20
|
+
* and that the clipboard is written with the correct token URL for clone.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import React, { useCallback } from 'react';
|
|
24
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
25
|
+
import { render, waitFor } from '@testing-library/react';
|
|
26
|
+
import { act } from 'react';
|
|
27
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
28
|
+
import { SemiontApiClient, resourceUri, accessToken } from '@semiont/api-client';
|
|
29
|
+
import { EventBusProvider, useEventBus, resetEventBusForTesting } from '../../../contexts/EventBusContext';
|
|
30
|
+
import { useEventSubscriptions } from '../../../contexts/useEventSubscription';
|
|
31
|
+
import { ApiClientProvider } from '../../../contexts/ApiClientContext';
|
|
32
|
+
import { AuthTokenProvider } from '../../../contexts/AuthTokenContext';
|
|
33
|
+
import { useResources } from '../../../lib/api-hooks';
|
|
34
|
+
import type { EventMap } from '../../../contexts/EventBusContext';
|
|
35
|
+
import type { Emitter } from 'mitt';
|
|
36
|
+
|
|
37
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
const TEST_URI = resourceUri('http://localhost:4000/resources/test-resource');
|
|
40
|
+
const TEST_TOKEN = 'test-auth-token-123';
|
|
41
|
+
const BASE_URL = 'http://localhost:4000';
|
|
42
|
+
const CLONE_TOKEN = 'generated-clone-token-xyz';
|
|
43
|
+
|
|
44
|
+
// ─── Harness ──────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Minimal harness that replicates the three mutation-backed event handlers
|
|
48
|
+
* from ResourceViewerPage using the REAL useResources hook.
|
|
49
|
+
*
|
|
50
|
+
* The critical invariant under test: useMutation() is called at hook level
|
|
51
|
+
* (inside useResources), not inside the useCallback bodies.
|
|
52
|
+
*/
|
|
53
|
+
function ResourceMutationHarness({ onEventBus }: { onEventBus: (bus: Emitter<EventMap>) => void }) {
|
|
54
|
+
const eventBus = useEventBus();
|
|
55
|
+
|
|
56
|
+
// Capture the bus for the test to emit events
|
|
57
|
+
React.useEffect(() => {
|
|
58
|
+
onEventBus(eventBus);
|
|
59
|
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
60
|
+
|
|
61
|
+
// Real hook — mutations are initialised at the top level of useResources()
|
|
62
|
+
const resources = useResources();
|
|
63
|
+
|
|
64
|
+
// Mutations hoisted to this component's top level — same pattern as ResourceViewerPage fix
|
|
65
|
+
const updateMutation = resources.update.useMutation();
|
|
66
|
+
const generateCloneTokenMutation = resources.generateCloneToken.useMutation();
|
|
67
|
+
|
|
68
|
+
const handleResourceArchive = useCallback(async () => {
|
|
69
|
+
await updateMutation.mutateAsync({ rUri: TEST_URI, data: { archived: true } });
|
|
70
|
+
}, [updateMutation]);
|
|
71
|
+
|
|
72
|
+
const handleResourceUnarchive = useCallback(async () => {
|
|
73
|
+
await updateMutation.mutateAsync({ rUri: TEST_URI, data: { archived: false } });
|
|
74
|
+
}, [updateMutation]);
|
|
75
|
+
|
|
76
|
+
const handleResourceClone = useCallback(async () => {
|
|
77
|
+
const result = await generateCloneTokenMutation.mutateAsync(TEST_URI);
|
|
78
|
+
const cloneUrl = `${window.location.origin}/know/clone?token=${result.token}`;
|
|
79
|
+
await navigator.clipboard.writeText(cloneUrl);
|
|
80
|
+
}, [generateCloneTokenMutation]);
|
|
81
|
+
|
|
82
|
+
useEventSubscriptions({
|
|
83
|
+
'resource:archive': handleResourceArchive,
|
|
84
|
+
'resource:unarchive': handleResourceUnarchive,
|
|
85
|
+
'resource:clone': handleResourceClone,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ─── Test setup ───────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
function renderHarness() {
|
|
94
|
+
let capturedBus: Emitter<EventMap> | null = null;
|
|
95
|
+
|
|
96
|
+
const queryClient = new QueryClient({
|
|
97
|
+
defaultOptions: {
|
|
98
|
+
queries: { retry: false },
|
|
99
|
+
mutations: { retry: false },
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
render(
|
|
104
|
+
<AuthTokenProvider token={TEST_TOKEN}>
|
|
105
|
+
<ApiClientProvider baseUrl={BASE_URL}>
|
|
106
|
+
<QueryClientProvider client={queryClient}>
|
|
107
|
+
<EventBusProvider>
|
|
108
|
+
<ResourceMutationHarness onEventBus={(bus) => { capturedBus = bus; }} />
|
|
109
|
+
</EventBusProvider>
|
|
110
|
+
</QueryClientProvider>
|
|
111
|
+
</ApiClientProvider>
|
|
112
|
+
</AuthTokenProvider>
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const emit = <K extends keyof EventMap>(event: K, payload: EventMap[K]) => {
|
|
116
|
+
act(() => { capturedBus!.emit(event, payload); });
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
return { emit };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
describe('Resource mutations — hooks hoisted to top level', () => {
|
|
125
|
+
let generateCloneTokenSpy: ReturnType<typeof vi.spyOn>;
|
|
126
|
+
let updateResourceSpy: ReturnType<typeof vi.spyOn>;
|
|
127
|
+
let writeTextSpy: ReturnType<typeof vi.fn>;
|
|
128
|
+
|
|
129
|
+
beforeEach(() => {
|
|
130
|
+
vi.clearAllMocks();
|
|
131
|
+
resetEventBusForTesting();
|
|
132
|
+
|
|
133
|
+
generateCloneTokenSpy = vi
|
|
134
|
+
.spyOn(SemiontApiClient.prototype, 'generateCloneToken')
|
|
135
|
+
.mockResolvedValue({ token: CLONE_TOKEN } as any);
|
|
136
|
+
|
|
137
|
+
updateResourceSpy = vi
|
|
138
|
+
.spyOn(SemiontApiClient.prototype, 'updateResource')
|
|
139
|
+
.mockResolvedValue({ resource: {} } as any);
|
|
140
|
+
|
|
141
|
+
// jsdom has no clipboard — install a writable spy
|
|
142
|
+
writeTextSpy = vi.fn().mockResolvedValue(undefined);
|
|
143
|
+
Object.defineProperty(navigator, 'clipboard', {
|
|
144
|
+
value: { writeText: writeTextSpy },
|
|
145
|
+
configurable: true,
|
|
146
|
+
writable: true,
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
afterEach(() => {
|
|
151
|
+
vi.restoreAllMocks();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ── Clone ──────────────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
it('calls generateCloneToken API when resource:clone event fires', async () => {
|
|
157
|
+
const { emit } = renderHarness();
|
|
158
|
+
|
|
159
|
+
await act(async () => {
|
|
160
|
+
emit('resource:clone', undefined);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
await waitFor(() => {
|
|
164
|
+
expect(generateCloneTokenSpy).toHaveBeenCalledTimes(1);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('passes the resource URI to generateCloneToken', async () => {
|
|
169
|
+
const { emit } = renderHarness();
|
|
170
|
+
|
|
171
|
+
await act(async () => {
|
|
172
|
+
emit('resource:clone', undefined);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
await waitFor(() => {
|
|
176
|
+
expect(generateCloneTokenSpy).toHaveBeenCalledWith(
|
|
177
|
+
TEST_URI,
|
|
178
|
+
expect.anything()
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('passes auth token to generateCloneToken', async () => {
|
|
184
|
+
const { emit } = renderHarness();
|
|
185
|
+
|
|
186
|
+
await act(async () => {
|
|
187
|
+
emit('resource:clone', undefined);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
await waitFor(() => {
|
|
191
|
+
expect(generateCloneTokenSpy).toHaveBeenCalledWith(
|
|
192
|
+
TEST_URI,
|
|
193
|
+
expect.objectContaining({ auth: accessToken(TEST_TOKEN) })
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('writes a clone URL containing the returned token to the clipboard', async () => {
|
|
199
|
+
const { emit } = renderHarness();
|
|
200
|
+
|
|
201
|
+
await act(async () => {
|
|
202
|
+
emit('resource:clone', undefined);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
await waitFor(() => {
|
|
206
|
+
expect(writeTextSpy).toHaveBeenCalledTimes(1);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const writtenUrl: string = writeTextSpy.mock.calls[0][0];
|
|
210
|
+
expect(writtenUrl).toContain(CLONE_TOKEN);
|
|
211
|
+
expect(writtenUrl).toContain('/know/clone?token=');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('does NOT call updateResource when resource:clone fires', async () => {
|
|
215
|
+
const { emit } = renderHarness();
|
|
216
|
+
|
|
217
|
+
await act(async () => {
|
|
218
|
+
emit('resource:clone', undefined);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
await waitFor(() => {
|
|
222
|
+
expect(generateCloneTokenSpy).toHaveBeenCalledTimes(1);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
expect(updateResourceSpy).not.toHaveBeenCalled();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// ── Archive ────────────────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
it('calls updateResource with archived:true when resource:archive fires', async () => {
|
|
231
|
+
const { emit } = renderHarness();
|
|
232
|
+
|
|
233
|
+
await act(async () => {
|
|
234
|
+
emit('resource:archive', undefined);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
await waitFor(() => {
|
|
238
|
+
expect(updateResourceSpy).toHaveBeenCalledTimes(1);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
expect(updateResourceSpy).toHaveBeenCalledWith(
|
|
242
|
+
TEST_URI,
|
|
243
|
+
expect.objectContaining({ archived: true }),
|
|
244
|
+
expect.anything()
|
|
245
|
+
);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('does NOT call generateCloneToken when resource:archive fires', async () => {
|
|
249
|
+
const { emit } = renderHarness();
|
|
250
|
+
|
|
251
|
+
await act(async () => {
|
|
252
|
+
emit('resource:archive', undefined);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
await waitFor(() => {
|
|
256
|
+
expect(updateResourceSpy).toHaveBeenCalledTimes(1);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
expect(generateCloneTokenSpy).not.toHaveBeenCalled();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// ── Unarchive ──────────────────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
it('calls updateResource with archived:false when resource:unarchive fires', async () => {
|
|
265
|
+
const { emit } = renderHarness();
|
|
266
|
+
|
|
267
|
+
await act(async () => {
|
|
268
|
+
emit('resource:unarchive', undefined);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
await waitFor(() => {
|
|
272
|
+
expect(updateResourceSpy).toHaveBeenCalledTimes(1);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
expect(updateResourceSpy).toHaveBeenCalledWith(
|
|
276
|
+
TEST_URI,
|
|
277
|
+
expect.objectContaining({ archived: false }),
|
|
278
|
+
expect.anything()
|
|
279
|
+
);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// ── Isolation ─────────────────────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
it('resource:archive and resource:clone events each call their own API exactly once', async () => {
|
|
285
|
+
const { emit } = renderHarness();
|
|
286
|
+
|
|
287
|
+
await act(async () => {
|
|
288
|
+
emit('resource:archive', undefined);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
await act(async () => {
|
|
292
|
+
emit('resource:clone', undefined);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
await waitFor(() => {
|
|
296
|
+
expect(updateResourceSpy).toHaveBeenCalledTimes(1);
|
|
297
|
+
expect(generateCloneTokenSpy).toHaveBeenCalledTimes(1);
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
});
|
|
@@ -41,6 +41,8 @@ vi.mock('../../../lib/api-hooks', () => ({
|
|
|
41
41
|
useResources: () => ({
|
|
42
42
|
annotations: { useQuery: () => ({ data: { annotations: [] } }) },
|
|
43
43
|
referencedBy: { useQuery: () => ({ data: { referencedBy: [] }, isLoading: false }) },
|
|
44
|
+
update: { useMutation: () => ({ mutateAsync: vi.fn() }) },
|
|
45
|
+
generateCloneToken: { useMutation: () => ({ mutateAsync: vi.fn() }) },
|
|
44
46
|
}),
|
|
45
47
|
useEntityTypes: () => ({
|
|
46
48
|
list: { useQuery: () => ({ data: { entityTypes: ['Document', 'Article', 'Book'] } }) },
|
|
@@ -279,41 +279,41 @@ export function ResourceViewerPage({
|
|
|
279
279
|
}, []),
|
|
280
280
|
});
|
|
281
281
|
|
|
282
|
+
// Mutations hoisted to top level — hooks must not be called inside callbacks
|
|
283
|
+
const updateMutation = resources.update.useMutation();
|
|
284
|
+
const generateCloneTokenMutation = resources.generateCloneToken.useMutation();
|
|
285
|
+
|
|
282
286
|
// Event handlers extracted to useCallback (tenet: no inline handlers in useEventSubscriptions)
|
|
283
287
|
const handleResourceArchive = useCallback(async () => {
|
|
284
288
|
try {
|
|
285
|
-
await
|
|
289
|
+
await updateMutation.mutateAsync({ rUri, data: { archived: true } });
|
|
286
290
|
await refetchDocument();
|
|
287
|
-
showSuccess('Document archived');
|
|
288
291
|
} catch (err) {
|
|
289
292
|
console.error('Failed to archive document:', err);
|
|
290
293
|
showError('Failed to archive document');
|
|
291
294
|
}
|
|
292
|
-
}, [
|
|
295
|
+
}, [updateMutation, rUri, refetchDocument, showError]);
|
|
293
296
|
|
|
294
297
|
const handleResourceUnarchive = useCallback(async () => {
|
|
295
298
|
try {
|
|
296
|
-
await
|
|
299
|
+
await updateMutation.mutateAsync({ rUri, data: { archived: false } });
|
|
297
300
|
await refetchDocument();
|
|
298
|
-
showSuccess('Document unarchived');
|
|
299
301
|
} catch (err) {
|
|
300
302
|
console.error('Failed to unarchive document:', err);
|
|
301
303
|
showError('Failed to unarchive document');
|
|
302
304
|
}
|
|
303
|
-
}, [
|
|
305
|
+
}, [updateMutation, rUri, refetchDocument, showError]);
|
|
304
306
|
|
|
305
307
|
const handleResourceClone = useCallback(async () => {
|
|
306
308
|
try {
|
|
307
|
-
const result = await
|
|
309
|
+
const result = await generateCloneTokenMutation.mutateAsync(rUri);
|
|
308
310
|
const token = result.token;
|
|
309
|
-
|
|
310
|
-
await navigator.clipboard.writeText(cloneUrl);
|
|
311
|
-
showSuccess('Clone link copied to clipboard');
|
|
311
|
+
eventBus.emit('navigation:router-push', { path: `/know/compose?mode=clone&token=${token}`, reason: 'clone' });
|
|
312
312
|
} catch (err) {
|
|
313
313
|
console.error('Failed to generate clone token:', err);
|
|
314
314
|
showError('Failed to generate clone link');
|
|
315
315
|
}
|
|
316
|
-
}, [
|
|
316
|
+
}, [generateCloneTokenMutation, rUri, showError]);
|
|
317
317
|
|
|
318
318
|
const handleAnnotationSparkle = useCallback(({ annotationId }: { annotationId: string }) => {
|
|
319
319
|
triggerSparkleAnimation(annotationId);
|
|
@@ -31,11 +31,19 @@
|
|
|
31
31
|
.semiont-checkbox:checked {
|
|
32
32
|
background-color: var(--semiont-color-primary-500);
|
|
33
33
|
border-color: var(--semiont-color-primary-500);
|
|
34
|
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='white' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round' d='M3 8l3.5 3.5L13 4.5'/%3E%3C/svg%3E");
|
|
35
|
+
background-repeat: no-repeat;
|
|
36
|
+
background-position: center;
|
|
37
|
+
background-size: 75%;
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
[data-theme="dark"] .semiont-checkbox:checked {
|
|
37
41
|
background-color: var(--semiont-color-primary-400);
|
|
38
42
|
border-color: var(--semiont-color-primary-400);
|
|
43
|
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='white' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round' d='M3 8l3.5 3.5L13 4.5'/%3E%3C/svg%3E");
|
|
44
|
+
background-repeat: no-repeat;
|
|
45
|
+
background-position: center;
|
|
46
|
+
background-size: 75%;
|
|
39
47
|
}
|
|
40
48
|
|
|
41
49
|
/* Focus State */
|