@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@semiont/react-ui",
3
- "version": "0.2.33-build.84",
3
+ "version": "0.2.33-build.85",
4
4
  "description": "React components and hooks for Semiont",
5
5
  "main": "./dist/index.mjs",
6
6
  "types": "./dist/index.d.mts",
@@ -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 resources.update.useMutation().mutateAsync({ rUri, data: { archived: true } });
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
- }, [resources.update, rUri, refetchDocument, showSuccess, showError]);
295
+ }, [updateMutation, rUri, refetchDocument, showError]);
293
296
 
294
297
  const handleResourceUnarchive = useCallback(async () => {
295
298
  try {
296
- await resources.update.useMutation().mutateAsync({ rUri, data: { archived: false } });
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
- }, [resources.update, rUri, refetchDocument, showSuccess, showError]);
305
+ }, [updateMutation, rUri, refetchDocument, showError]);
304
306
 
305
307
  const handleResourceClone = useCallback(async () => {
306
308
  try {
307
- const result = await resources.generateCloneToken.useMutation().mutateAsync(rUri);
309
+ const result = await generateCloneTokenMutation.mutateAsync(rUri);
308
310
  const token = result.token;
309
- const cloneUrl = `${typeof window !== 'undefined' ? window.location.origin : ''}/know/clone?token=${token}`;
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
- }, [resources.generateCloneToken, rUri, showSuccess, showError]);
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 */