@tuturuuu/ui 0.37.2 → 0.38.0
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/CHANGELOG.md +28 -0
- package/package.json +9 -6
- package/src/components/ui/custom/__tests__/workspace-picker-content.test.tsx +86 -0
- package/src/components/ui/custom/education-todo.test.tsx +131 -0
- package/src/components/ui/custom/education-todo.tsx +191 -0
- package/src/components/ui/custom/satellite-notification-polling.test.tsx +61 -0
- package/src/components/ui/custom/satellite-notification-popover.tsx +7 -3
- package/src/components/ui/custom/workspace-picker-content.tsx +93 -30
- package/src/components/ui/custom/workspace-select.tsx +12 -4
- package/src/components/ui/legacy/calendar/calendar-draft-recovery.ts +34 -0
- package/src/components/ui/legacy/calendar/event-modal.tsx +77 -137
- package/src/components/ui/legacy/calendar/event-save-payload.test.ts +40 -0
- package/src/components/ui/legacy/calendar/event-save-payload.ts +79 -0
- package/src/components/ui/legacy/calendar/event-source-selection.test.ts +95 -0
- package/src/components/ui/legacy/calendar/event-source-selection.ts +78 -0
- package/src/components/ui/legacy/calendar/provider-color-picker.test.tsx +147 -0
- package/src/components/ui/legacy/calendar/provider-color-picker.tsx +115 -0
- package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +35 -1
- package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +23 -11
- package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +119 -0
- package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +141 -0
- package/src/components/ui/popover.tsx +3 -1
- package/src/components/ui/text-editor/__tests__/collaboration-carets.test.ts +142 -0
- package/src/components/ui/text-editor/__tests__/markdown-document.test.ts +50 -0
- package/src/components/ui/text-editor/clipboard-serialization.ts +10 -0
- package/src/components/ui/text-editor/collaboration-carets.ts +56 -0
- package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +2 -0
- package/src/components/ui/text-editor/editor.tsx +8 -3
- package/src/components/ui/text-editor/extensions.ts +8 -2
- package/src/components/ui/text-editor/markdown-paste-extension.ts +1 -1
- package/src/components/ui/text-editor/markdown.ts +11 -0
- package/src/components/ui/text-editor/tool-bar.tsx +43 -44
- package/src/hooks/__tests__/collaboration-awareness.test.ts +106 -0
- package/src/hooks/__tests__/supabase-provider.test.ts +9 -0
- package/src/hooks/__tests__/use-calendar-readonly.test.tsx +12 -2
- package/src/hooks/__tests__/use-notifications-subscription.test.tsx +31 -1
- package/src/hooks/__tests__/use-workspace-presence.test.tsx +3 -3
- package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +108 -0
- package/src/hooks/calendar-creation-request.test.ts +62 -0
- package/src/hooks/calendar-creation-request.ts +34 -0
- package/src/hooks/calendar-event-write-payload.ts +24 -0
- package/src/hooks/notification-subscription.ts +5 -2
- package/src/hooks/supabase-provider.ts +38 -72
- package/src/hooks/use-calendar.tsx +21 -45
- package/src/hooks/use-exchange-rates.ts +4 -15
- package/src/hooks/use-workspace-presence.ts +9 -9
- package/src/hooks/use-yjs-collaboration.ts +45 -27
|
@@ -46,9 +46,7 @@ import { ToolbarButton, ToolbarSeparator } from './toolbar-controls';
|
|
|
46
46
|
|
|
47
47
|
type LinkEditorContext = 'bubble' | 'popover' | null;
|
|
48
48
|
|
|
49
|
-
// ---------------------------------------------------------------------------
|
|
50
49
|
// Main ToolBar (BubbleMenu)
|
|
51
|
-
// ---------------------------------------------------------------------------
|
|
52
50
|
|
|
53
51
|
interface ToolBarProps {
|
|
54
52
|
editor: Editor | null;
|
|
@@ -59,12 +57,14 @@ interface ToolBarProps {
|
|
|
59
57
|
onConvertToTask?: () => void | Promise<void>;
|
|
60
58
|
/** When true, the BubbleMenu is hidden because the fixed toolbar is visible */
|
|
61
59
|
fixedToolbarVisible?: boolean;
|
|
60
|
+
allowEmbeds?: boolean;
|
|
62
61
|
toggleBlockLabel?: string;
|
|
63
62
|
}
|
|
64
63
|
|
|
65
64
|
export function ToolBar({
|
|
66
65
|
editor,
|
|
67
66
|
fixedToolbarVisible,
|
|
67
|
+
allowEmbeds = true,
|
|
68
68
|
workspaceId,
|
|
69
69
|
onImageUpload,
|
|
70
70
|
onConvertToTask,
|
|
@@ -459,9 +459,7 @@ export function ToolBar({
|
|
|
459
459
|
toast.success('YouTube video added');
|
|
460
460
|
}, [editor, youtubeUrl]);
|
|
461
461
|
|
|
462
|
-
// -----------------------------------------------------------------------
|
|
463
462
|
// Render helpers
|
|
464
|
-
// -----------------------------------------------------------------------
|
|
465
463
|
|
|
466
464
|
/** Renders the core formatting buttons (used by both fixed and bubble toolbar) */
|
|
467
465
|
const renderFormattingOptions = useCallback(
|
|
@@ -478,11 +476,9 @@ export function ToolBar({
|
|
|
478
476
|
/>
|
|
479
477
|
))}
|
|
480
478
|
|
|
481
|
-
{/* Color controls */}
|
|
482
479
|
<ToolbarSeparator />
|
|
483
480
|
<TextEditorColorControls editor={editor} />
|
|
484
481
|
|
|
485
|
-
{/* Link */}
|
|
486
482
|
<ToolbarSeparator />
|
|
487
483
|
<ToolbarButton
|
|
488
484
|
id="link"
|
|
@@ -491,8 +487,7 @@ export function ToolBar({
|
|
|
491
487
|
onClick={() => openLinkEditor(source)}
|
|
492
488
|
/>
|
|
493
489
|
|
|
494
|
-
{
|
|
495
|
-
{workspaceId && onImageUpload && (
|
|
490
|
+
{onImageUpload && (
|
|
496
491
|
<>
|
|
497
492
|
<ToolbarSeparator />
|
|
498
493
|
<ToolbarButton
|
|
@@ -508,30 +503,33 @@ export function ToolBar({
|
|
|
508
503
|
onClick={triggerImageUpload}
|
|
509
504
|
disabled={isUploadingImage}
|
|
510
505
|
/>
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
506
|
+
{workspaceId && (
|
|
507
|
+
<ToolbarButton
|
|
508
|
+
id="video"
|
|
509
|
+
icon={
|
|
510
|
+
isUploadingVideo ? (
|
|
511
|
+
<Loader2 className="size-4 animate-spin" />
|
|
512
|
+
) : (
|
|
513
|
+
<FileVideo className="size-4" />
|
|
514
|
+
)
|
|
515
|
+
}
|
|
516
|
+
pressed={false}
|
|
517
|
+
onClick={triggerVideoUpload}
|
|
518
|
+
disabled={isUploadingVideo}
|
|
519
|
+
/>
|
|
520
|
+
)}
|
|
524
521
|
</>
|
|
525
522
|
)}
|
|
526
523
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
524
|
+
{allowEmbeds && (
|
|
525
|
+
<ToolbarButton
|
|
526
|
+
id="youtube"
|
|
527
|
+
icon={<YoutubeIcon className="size-4" />}
|
|
528
|
+
pressed={showYoutubeInput}
|
|
529
|
+
onClick={() => setShowYoutubeInput(!showYoutubeInput)}
|
|
530
|
+
/>
|
|
531
|
+
)}
|
|
533
532
|
|
|
534
|
-
{/* Convert to task */}
|
|
535
533
|
{onConvertToTask && (
|
|
536
534
|
<>
|
|
537
535
|
<ToolbarSeparator />
|
|
@@ -557,6 +555,7 @@ export function ToolBar({
|
|
|
557
555
|
triggerVideoUpload,
|
|
558
556
|
isUploadingVideo,
|
|
559
557
|
showYoutubeInput,
|
|
558
|
+
allowEmbeds,
|
|
560
559
|
onConvertToTask,
|
|
561
560
|
toggleBlockLabel,
|
|
562
561
|
]
|
|
@@ -1133,8 +1132,7 @@ export function FixedToolbar({
|
|
|
1133
1132
|
<ToolbarSeparator />
|
|
1134
1133
|
<TextEditorColorControls editor={editor} />
|
|
1135
1134
|
|
|
1136
|
-
{
|
|
1137
|
-
{workspaceId && onImageUpload && (
|
|
1135
|
+
{onImageUpload && (
|
|
1138
1136
|
<>
|
|
1139
1137
|
<ToolbarSeparator />
|
|
1140
1138
|
<ToolbarButton
|
|
@@ -1150,23 +1148,24 @@ export function FixedToolbar({
|
|
|
1150
1148
|
onClick={() => fileInputRef.current?.click()}
|
|
1151
1149
|
disabled={isUploadingImage}
|
|
1152
1150
|
/>
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1151
|
+
{workspaceId && (
|
|
1152
|
+
<ToolbarButton
|
|
1153
|
+
id="video"
|
|
1154
|
+
icon={
|
|
1155
|
+
isUploadingVideo ? (
|
|
1156
|
+
<Loader2 className="size-4 animate-spin" />
|
|
1157
|
+
) : (
|
|
1158
|
+
<FileVideo className="size-4" />
|
|
1159
|
+
)
|
|
1160
|
+
}
|
|
1161
|
+
pressed={false}
|
|
1162
|
+
onClick={() => videoInputRef.current?.click()}
|
|
1163
|
+
disabled={isUploadingVideo}
|
|
1164
|
+
/>
|
|
1165
|
+
)}
|
|
1166
1166
|
</>
|
|
1167
1167
|
)}
|
|
1168
1168
|
|
|
1169
|
-
{/* Convert to task */}
|
|
1170
1169
|
{onConvertToTask && (
|
|
1171
1170
|
<>
|
|
1172
1171
|
<ToolbarSeparator />
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import * as awarenessProtocol from 'y-protocols/awareness';
|
|
3
|
+
import * as Y from 'yjs';
|
|
4
|
+
import SupabaseProvider from '../supabase-provider';
|
|
5
|
+
|
|
6
|
+
vi.mock('@tuturuuu/internal-api/realtime', () => ({
|
|
7
|
+
createRealtimeClient: () => {
|
|
8
|
+
const channel = {
|
|
9
|
+
on: () => channel,
|
|
10
|
+
subscribe: () => channel,
|
|
11
|
+
send: vi.fn(),
|
|
12
|
+
};
|
|
13
|
+
return { channel: () => channel, removeChannel: vi.fn() };
|
|
14
|
+
},
|
|
15
|
+
}));
|
|
16
|
+
afterEach(() => vi.useRealTimers());
|
|
17
|
+
describe('collaboration cursor batching', () => {
|
|
18
|
+
it('merges all changed client IDs and flushes during continuous updates', async () => {
|
|
19
|
+
vi.useFakeTimers();
|
|
20
|
+
const doc = new Y.Doc();
|
|
21
|
+
const provider = new SupabaseProvider(
|
|
22
|
+
doc,
|
|
23
|
+
{} as ConstructorParameters<typeof SupabaseProvider>[1],
|
|
24
|
+
{
|
|
25
|
+
channel: 'task-editor-fixture',
|
|
26
|
+
id: 'fixture',
|
|
27
|
+
tableName: 'tasks',
|
|
28
|
+
columnName: 'description_yjs_state',
|
|
29
|
+
loadState: async () => null,
|
|
30
|
+
saveState: async () => true,
|
|
31
|
+
resyncInterval: false,
|
|
32
|
+
}
|
|
33
|
+
);
|
|
34
|
+
const a = new Y.Doc();
|
|
35
|
+
const b = new Y.Doc();
|
|
36
|
+
const first = new awarenessProtocol.Awareness(a);
|
|
37
|
+
const second = new awarenessProtocol.Awareness(b);
|
|
38
|
+
first.setLocalState({ user: { id: 'a' }, cursor: 1 });
|
|
39
|
+
second.setLocalState({ user: { id: 'b' }, cursor: 2 });
|
|
40
|
+
const sent = vi.fn();
|
|
41
|
+
provider.on('awareness', sent);
|
|
42
|
+
awarenessProtocol.applyAwarenessUpdate(
|
|
43
|
+
provider.awareness,
|
|
44
|
+
awarenessProtocol.encodeAwarenessUpdate(first, [a.clientID]),
|
|
45
|
+
'local-fixture'
|
|
46
|
+
);
|
|
47
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
48
|
+
awarenessProtocol.applyAwarenessUpdate(
|
|
49
|
+
provider.awareness,
|
|
50
|
+
awarenessProtocol.encodeAwarenessUpdate(second, [b.clientID]),
|
|
51
|
+
'local-fixture'
|
|
52
|
+
);
|
|
53
|
+
await vi.advanceTimersByTimeAsync(50);
|
|
54
|
+
expect(sent).toHaveBeenCalledOnce();
|
|
55
|
+
const receiverDoc = new Y.Doc();
|
|
56
|
+
const receiver = new awarenessProtocol.Awareness(receiverDoc);
|
|
57
|
+
awarenessProtocol.applyAwarenessUpdate(
|
|
58
|
+
receiver,
|
|
59
|
+
sent.mock.calls[0]![0],
|
|
60
|
+
'fixture'
|
|
61
|
+
);
|
|
62
|
+
expect(receiver.getStates().get(a.clientID)).toMatchObject({ cursor: 1 });
|
|
63
|
+
expect(receiver.getStates().get(b.clientID)).toMatchObject({ cursor: 2 });
|
|
64
|
+
provider.destroy();
|
|
65
|
+
provider.awareness.destroy();
|
|
66
|
+
first.destroy();
|
|
67
|
+
second.destroy();
|
|
68
|
+
receiver.destroy();
|
|
69
|
+
for (const d of [doc, a, b, receiverDoc]) d.destroy();
|
|
70
|
+
});
|
|
71
|
+
it('does not rebroadcast peer awareness back to the room', async () => {
|
|
72
|
+
vi.useFakeTimers();
|
|
73
|
+
const doc = new Y.Doc();
|
|
74
|
+
const peerDoc = new Y.Doc();
|
|
75
|
+
const provider = new SupabaseProvider(
|
|
76
|
+
doc,
|
|
77
|
+
{} as ConstructorParameters<typeof SupabaseProvider>[1],
|
|
78
|
+
{
|
|
79
|
+
channel: 'task-editor-fixture',
|
|
80
|
+
id: 'fixture',
|
|
81
|
+
tableName: 'tasks',
|
|
82
|
+
columnName: 'description_yjs_state',
|
|
83
|
+
loadState: async () => null,
|
|
84
|
+
saveState: async () => true,
|
|
85
|
+
resyncInterval: false,
|
|
86
|
+
}
|
|
87
|
+
);
|
|
88
|
+
const peer = new awarenessProtocol.Awareness(peerDoc);
|
|
89
|
+
peer.setLocalState({ cursor: 4 });
|
|
90
|
+
const sent = vi.fn();
|
|
91
|
+
provider.on('awareness', sent);
|
|
92
|
+
provider.onAwareness(
|
|
93
|
+
awarenessProtocol.encodeAwarenessUpdate(peer, [peerDoc.clientID])
|
|
94
|
+
);
|
|
95
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
96
|
+
expect(sent).not.toHaveBeenCalled();
|
|
97
|
+
expect(provider.awareness.getStates().get(peerDoc.clientID)).toMatchObject({
|
|
98
|
+
cursor: 4,
|
|
99
|
+
});
|
|
100
|
+
provider.destroy();
|
|
101
|
+
provider.awareness.destroy();
|
|
102
|
+
peer.destroy();
|
|
103
|
+
doc.destroy();
|
|
104
|
+
peerDoc.destroy();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -5,6 +5,14 @@ import SupabaseProvider, {
|
|
|
5
5
|
SUPABASE_PROVIDER_SYNC_ORIGIN,
|
|
6
6
|
} from '../supabase-provider';
|
|
7
7
|
|
|
8
|
+
const realtimeMock = vi.hoisted(() => ({ channel: null as unknown }));
|
|
9
|
+
vi.mock('@tuturuuu/internal-api/realtime', () => ({
|
|
10
|
+
createRealtimeClient: () => ({
|
|
11
|
+
channel: () => realtimeMock.channel,
|
|
12
|
+
removeChannel: vi.fn(),
|
|
13
|
+
}),
|
|
14
|
+
}));
|
|
15
|
+
|
|
8
16
|
function createRealtimeChannel() {
|
|
9
17
|
let subscribeHandler:
|
|
10
18
|
| ((status: string, err?: { message?: string }) => void)
|
|
@@ -22,6 +30,7 @@ function createRealtimeChannel() {
|
|
|
22
30
|
},
|
|
23
31
|
};
|
|
24
32
|
|
|
33
|
+
realtimeMock.channel = channel;
|
|
25
34
|
return channel;
|
|
26
35
|
}
|
|
27
36
|
|
|
@@ -294,10 +294,15 @@ describe('CalendarProvider Read-Only Mode', () => {
|
|
|
294
294
|
await createPromise!;
|
|
295
295
|
});
|
|
296
296
|
|
|
297
|
+
const optimisticId = calendarMockState.patchVisibleEvents.mock.calls.find(
|
|
298
|
+
([, options]) => options?.status === 'creating'
|
|
299
|
+
)?.[0]?.[0]?.id;
|
|
300
|
+
expect(optimisticId).toMatch(/^optimistic[0-9a-f]{32}$/);
|
|
301
|
+
|
|
297
302
|
expect(calendarMockState.patchVisibleEvents).toHaveBeenLastCalledWith(
|
|
298
303
|
[serverEvent],
|
|
299
304
|
expect.objectContaining({
|
|
300
|
-
clearIds: [
|
|
305
|
+
clearIds: [optimisticId],
|
|
301
306
|
})
|
|
302
307
|
);
|
|
303
308
|
});
|
|
@@ -322,10 +327,15 @@ describe('CalendarProvider Read-Only Mode', () => {
|
|
|
322
327
|
).rejects.toThrow('create failed');
|
|
323
328
|
});
|
|
324
329
|
|
|
330
|
+
const optimisticId = calendarMockState.patchVisibleEvents.mock.calls.find(
|
|
331
|
+
([, options]) => options?.status === 'creating'
|
|
332
|
+
)?.[0]?.[0]?.id;
|
|
333
|
+
expect(optimisticId).toMatch(/^optimistic[0-9a-f]{32}$/);
|
|
334
|
+
|
|
325
335
|
expect(calendarMockState.patchVisibleEvents).toHaveBeenLastCalledWith(
|
|
326
336
|
[],
|
|
327
337
|
expect.objectContaining({
|
|
328
|
-
clearIds: [
|
|
338
|
+
clearIds: [optimisticId],
|
|
329
339
|
})
|
|
330
340
|
);
|
|
331
341
|
});
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
} from '../use-notifications';
|
|
12
12
|
|
|
13
13
|
const createRealtimeClient = vi.hoisted(() => vi.fn());
|
|
14
|
-
vi.mock('@tuturuuu/
|
|
14
|
+
vi.mock('@tuturuuu/internal-api/realtime', () => ({
|
|
15
15
|
createRealtimeClient,
|
|
16
16
|
}));
|
|
17
17
|
|
|
@@ -31,6 +31,10 @@ describe('notification API refresh subscriptions', () => {
|
|
|
31
31
|
configurable: true,
|
|
32
32
|
value: 'visible',
|
|
33
33
|
});
|
|
34
|
+
Object.defineProperty(navigator, 'onLine', {
|
|
35
|
+
configurable: true,
|
|
36
|
+
value: true,
|
|
37
|
+
});
|
|
34
38
|
});
|
|
35
39
|
afterEach(() => {
|
|
36
40
|
__resetNotificationSubscriptionRegistryForTests();
|
|
@@ -51,6 +55,32 @@ describe('notification API refresh subscriptions', () => {
|
|
|
51
55
|
client.clear();
|
|
52
56
|
});
|
|
53
57
|
|
|
58
|
+
it('makes 30 shared refreshes per visible hour, pauses offline, and refreshes on reconnect', () => {
|
|
59
|
+
const client = new QueryClient();
|
|
60
|
+
const invalidate = vi.spyOn(client, 'invalidateQueries');
|
|
61
|
+
const hook = renderHook(() => useNotificationSubscription(null, 'user-1'), {
|
|
62
|
+
wrapper: wrapper(client),
|
|
63
|
+
});
|
|
64
|
+
act(() => vi.advanceTimersByTime(60 * 60_000));
|
|
65
|
+
expect(invalidate).toHaveBeenCalledTimes(30);
|
|
66
|
+
Object.defineProperty(navigator, 'onLine', {
|
|
67
|
+
configurable: true,
|
|
68
|
+
value: false,
|
|
69
|
+
});
|
|
70
|
+
act(() => vi.advanceTimersByTime(60 * 60_000));
|
|
71
|
+
expect(invalidate).toHaveBeenCalledTimes(30);
|
|
72
|
+
Object.defineProperty(navigator, 'onLine', {
|
|
73
|
+
configurable: true,
|
|
74
|
+
value: true,
|
|
75
|
+
});
|
|
76
|
+
act(() => window.dispatchEvent(new Event('online')));
|
|
77
|
+
expect(invalidate).toHaveBeenCalledTimes(31);
|
|
78
|
+
hook.unmount();
|
|
79
|
+
act(() => window.dispatchEvent(new Event('online')));
|
|
80
|
+
expect(invalidate).toHaveBeenCalledTimes(31);
|
|
81
|
+
client.clear();
|
|
82
|
+
});
|
|
83
|
+
|
|
54
84
|
it('shares one refresh per user and query client without opening a denied realtime channel', () => {
|
|
55
85
|
const client = new QueryClient();
|
|
56
86
|
const invalidate = vi.spyOn(client, 'invalidateQueries');
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
6
|
-
import type { RealtimePresenceState } from '@tuturuuu/
|
|
6
|
+
import type { RealtimePresenceState } from '@tuturuuu/internal-api/realtime';
|
|
7
7
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
8
8
|
import {
|
|
9
9
|
type UseWorkspacePresenceResult,
|
|
@@ -16,8 +16,8 @@ const { createClientMock, getCurrentUserProfileMock } = vi.hoisted(() => ({
|
|
|
16
16
|
getCurrentUserProfileMock: vi.fn(),
|
|
17
17
|
}));
|
|
18
18
|
|
|
19
|
-
vi.mock('@tuturuuu/
|
|
20
|
-
|
|
19
|
+
vi.mock('@tuturuuu/internal-api/realtime', () => ({
|
|
20
|
+
createRealtimeClient: createClientMock,
|
|
21
21
|
}));
|
|
22
22
|
|
|
23
23
|
vi.mock('@tuturuuu/internal-api/users', () => ({
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { act, renderHook } from '@testing-library/react';
|
|
2
|
+
import { type ReactNode, StrictMode } from 'react';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { useYjsCollaboration } from '../use-yjs-collaboration';
|
|
5
|
+
|
|
6
|
+
const instances = vi.hoisted(
|
|
7
|
+
() =>
|
|
8
|
+
[] as {
|
|
9
|
+
id: number;
|
|
10
|
+
destroyed: boolean;
|
|
11
|
+
emit: (event: string, value: unknown) => void;
|
|
12
|
+
}[]
|
|
13
|
+
);
|
|
14
|
+
vi.mock('@tuturuuu/supabase/next/client', () => ({ createClient: () => ({}) }));
|
|
15
|
+
vi.mock('@tuturuuu/ui/hooks/supabase-provider', () => ({
|
|
16
|
+
default: class {
|
|
17
|
+
id: number;
|
|
18
|
+
destroyed = false;
|
|
19
|
+
awareness: { destroy: () => void };
|
|
20
|
+
private listeners = new Map<string, (value: unknown) => void>();
|
|
21
|
+
constructor(
|
|
22
|
+
private doc: { clientID: number; destroy: () => void },
|
|
23
|
+
_client: unknown,
|
|
24
|
+
config: { awareness: { destroy: () => void } }
|
|
25
|
+
) {
|
|
26
|
+
this.id = doc.clientID;
|
|
27
|
+
this.awareness = config.awareness;
|
|
28
|
+
instances.push(this);
|
|
29
|
+
}
|
|
30
|
+
on(event: string, callback: (value: unknown) => void) {
|
|
31
|
+
this.listeners.set(event, callback);
|
|
32
|
+
}
|
|
33
|
+
emit(event: string, value: unknown) {
|
|
34
|
+
this.listeners.get(event)?.(value);
|
|
35
|
+
}
|
|
36
|
+
destroy() {
|
|
37
|
+
this.destroyed = true;
|
|
38
|
+
this.awareness.destroy();
|
|
39
|
+
this.doc.destroy();
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
}));
|
|
43
|
+
const user = { id: 'actor', name: 'Ada', color: '#123456' };
|
|
44
|
+
const config = {
|
|
45
|
+
channel: 'task-editor-one',
|
|
46
|
+
tableName: 'tasks',
|
|
47
|
+
columnName: 'description_yjs_state',
|
|
48
|
+
id: 'one',
|
|
49
|
+
user,
|
|
50
|
+
};
|
|
51
|
+
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
52
|
+
<StrictMode>{children}</StrictMode>
|
|
53
|
+
);
|
|
54
|
+
describe('collaboration hook lifecycle', () => {
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
vi.useFakeTimers();
|
|
57
|
+
instances.length = 0;
|
|
58
|
+
});
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
vi.useRealTimers();
|
|
61
|
+
});
|
|
62
|
+
it('does not start awareness before authentication and reuses StrictMode providers', async () => {
|
|
63
|
+
const { result, rerender, unmount } = renderHook(
|
|
64
|
+
(actor) => useYjsCollaboration({ ...config, user: actor }),
|
|
65
|
+
{ wrapper, initialProps: null as typeof user | null }
|
|
66
|
+
);
|
|
67
|
+
expect(result.current.doc).toBeNull();
|
|
68
|
+
expect(result.current.awareness).toBeNull();
|
|
69
|
+
expect(instances).toHaveLength(0);
|
|
70
|
+
rerender(user);
|
|
71
|
+
expect(instances).toHaveLength(1);
|
|
72
|
+
const provider = result.current.provider;
|
|
73
|
+
expect(provider).not.toBeNull();
|
|
74
|
+
rerender({ ...user, name: 'Ada updated' });
|
|
75
|
+
expect(result.current.provider).toBe(provider);
|
|
76
|
+
expect(result.current.awareness?.getLocalState()?.user.name).toBe(
|
|
77
|
+
'Ada updated'
|
|
78
|
+
);
|
|
79
|
+
unmount();
|
|
80
|
+
await act(() => vi.advanceTimersByTimeAsync(100));
|
|
81
|
+
expect(instances[0]?.destroyed).toBe(true);
|
|
82
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
83
|
+
});
|
|
84
|
+
it('isolates account/resource switches and ignores late callbacks from old providers', async () => {
|
|
85
|
+
const { result, rerender, unmount } = renderHook(
|
|
86
|
+
(id) =>
|
|
87
|
+
useYjsCollaboration({ ...config, id, channel: `task-editor-${id}` }),
|
|
88
|
+
{ wrapper, initialProps: 'one' }
|
|
89
|
+
);
|
|
90
|
+
const first = instances[0]!;
|
|
91
|
+
const firstDoc = result.current.doc;
|
|
92
|
+
rerender('two');
|
|
93
|
+
expect(instances).toHaveLength(2);
|
|
94
|
+
expect(first.destroyed).toBe(true);
|
|
95
|
+
expect(result.current.doc).not.toBe(firstDoc);
|
|
96
|
+
const second = instances[1]!;
|
|
97
|
+
act(() => second.emit('status', [{ status: 'connected' }]));
|
|
98
|
+
expect(result.current.connected).toBe(true);
|
|
99
|
+
act(() => first.emit('disconnect', []));
|
|
100
|
+
expect(result.current.connected).toBe(true);
|
|
101
|
+
act(() => first.emit('synced', [true]));
|
|
102
|
+
expect(result.current.synced).toBe(false);
|
|
103
|
+
unmount();
|
|
104
|
+
await act(() => vi.advanceTimersByTimeAsync(100));
|
|
105
|
+
expect(second.destroyed).toBe(true);
|
|
106
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { validate, version } from 'uuid';
|
|
2
|
+
import { expect, it } from 'vitest';
|
|
3
|
+
import {
|
|
4
|
+
createCalendarCreationRequests,
|
|
5
|
+
createOptimisticEventId,
|
|
6
|
+
newCalendarCreationRequestId,
|
|
7
|
+
} from './calendar-creation-request';
|
|
8
|
+
|
|
9
|
+
it('keeps explicit draft IDs on lost responses and separates manual and AI drafts', () => {
|
|
10
|
+
const ids = createCalendarCreationRequests();
|
|
11
|
+
const manual = newCalendarCreationRequestId();
|
|
12
|
+
const ai = [newCalendarCreationRequestId(), newCalendarCreationRequestId()];
|
|
13
|
+
expect(new Set([manual, ...ai]).size).toBe(3);
|
|
14
|
+
for (const [index, id] of [manual, ...ai].entries()) {
|
|
15
|
+
expect(validate(id)).toBe(true);
|
|
16
|
+
expect(version(id)).toBe(7);
|
|
17
|
+
const payload = {
|
|
18
|
+
title: `draft ${index}`,
|
|
19
|
+
source: { provider: 'google', connectionId: 'owned' },
|
|
20
|
+
};
|
|
21
|
+
expect(ids.forAttempt(payload, id)).toBe(id);
|
|
22
|
+
expect(ids.forAttempt({ ...payload }, id)).toBe(id);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
it('rejects changed intent for an uncertain draft before dispatch', () => {
|
|
26
|
+
const ids = createCalendarCreationRequests();
|
|
27
|
+
const id = newCalendarCreationRequestId();
|
|
28
|
+
ids.forAttempt({ title: 'Original' }, id);
|
|
29
|
+
expect(() => ids.forAttempt({ title: 'Edited' }, id)).toThrow(
|
|
30
|
+
'draft changed'
|
|
31
|
+
);
|
|
32
|
+
expect(
|
|
33
|
+
ids.forAttempt({ title: 'Edited' }, newCalendarCreationRequestId())
|
|
34
|
+
).not.toBe(id);
|
|
35
|
+
});
|
|
36
|
+
it('allocates a new independent intent per call unless the caller supplies a retry ID', () => {
|
|
37
|
+
const ids = createCalendarCreationRequests();
|
|
38
|
+
const event = { title: 'Same independent contents' };
|
|
39
|
+
expect(ids.forAttempt(event)).not.toBe(ids.forAttempt(event));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('accepts the same serialized intent when object key order changes', () => {
|
|
43
|
+
const ids = createCalendarCreationRequests();
|
|
44
|
+
const id = newCalendarCreationRequestId();
|
|
45
|
+
ids.forAttempt(
|
|
46
|
+
{ title: 'Same', source: { provider: 'google', connectionId: 'owned' } },
|
|
47
|
+
id
|
|
48
|
+
);
|
|
49
|
+
expect(
|
|
50
|
+
ids.forAttempt(
|
|
51
|
+
{ source: { connectionId: 'owned', provider: 'google' }, title: 'Same' },
|
|
52
|
+
id
|
|
53
|
+
)
|
|
54
|
+
).toBe(id);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('keeps optimistic identifiers separate from multi-day segment delimiters', () => {
|
|
58
|
+
const id = createOptimisticEventId();
|
|
59
|
+
expect(id).toMatch(/^optimistic[a-zA-Z0-9]+$/);
|
|
60
|
+
expect(id).not.toContain('-');
|
|
61
|
+
expect(createOptimisticEventId()).not.toBe(id);
|
|
62
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { v7 } from 'uuid';
|
|
2
|
+
|
|
3
|
+
export const newCalendarCreationRequestId = () => v7();
|
|
4
|
+
/** Explicit draft IDs survive retries. Independent calls receive separate IDs;
|
|
5
|
+
* changed intent after an uncertain response must start an explicit new draft. */
|
|
6
|
+
export function createCalendarCreationRequests() {
|
|
7
|
+
const intents = new Map<string, string>();
|
|
8
|
+
return {
|
|
9
|
+
forAttempt(event: object, requestId?: string) {
|
|
10
|
+
const id = requestId ?? v7();
|
|
11
|
+
const intent = JSON.stringify(event, (_key, value) =>
|
|
12
|
+
value && typeof value === 'object' && !Array.isArray(value)
|
|
13
|
+
? Object.fromEntries(
|
|
14
|
+
Object.keys(value)
|
|
15
|
+
.sort()
|
|
16
|
+
.map((key) => [key, value[key]])
|
|
17
|
+
)
|
|
18
|
+
: value
|
|
19
|
+
);
|
|
20
|
+
const previous = intents.get(id);
|
|
21
|
+
if (previous !== undefined && previous !== intent)
|
|
22
|
+
throw new Error(
|
|
23
|
+
'This calendar draft changed after submission; recover it before starting a new draft'
|
|
24
|
+
);
|
|
25
|
+
if (requestId) intents.set(id, intent);
|
|
26
|
+
return id;
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export const createOptimisticEventId = () => {
|
|
31
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')
|
|
32
|
+
return `optimistic${crypto.randomUUID().replaceAll('-', '')}`;
|
|
33
|
+
return `optimistic${Date.now()}${Math.random().toString(36).slice(2)}`;
|
|
34
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { WorkspaceCalendarEventUpdatePayload } from '@tuturuuu/internal-api';
|
|
2
|
+
import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
|
|
3
|
+
|
|
4
|
+
export function calendarEventUpdatePayload(
|
|
5
|
+
input: Partial<CalendarEvent>
|
|
6
|
+
): WorkspaceCalendarEventUpdatePayload {
|
|
7
|
+
const payload: WorkspaceCalendarEventUpdatePayload = {};
|
|
8
|
+
for (const field of [
|
|
9
|
+
'title',
|
|
10
|
+
'description',
|
|
11
|
+
'location',
|
|
12
|
+
'start_at',
|
|
13
|
+
'end_at',
|
|
14
|
+
'locked',
|
|
15
|
+
'source',
|
|
16
|
+
'providerColor',
|
|
17
|
+
] as const) {
|
|
18
|
+
if (input[field] !== undefined)
|
|
19
|
+
Object.assign(payload, { [field]: input[field] });
|
|
20
|
+
}
|
|
21
|
+
if (!input.providerColor && input.color !== undefined)
|
|
22
|
+
payload.color = input.color;
|
|
23
|
+
return payload;
|
|
24
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type QueryClient, useQueryClient } from '@tanstack/react-query';
|
|
2
2
|
import { useEffect } from 'react';
|
|
3
3
|
|
|
4
|
-
export const NOTIFICATION_REFRESH_INTERVAL_MS =
|
|
4
|
+
export const NOTIFICATION_REFRESH_INTERVAL_MS = 120_000;
|
|
5
5
|
|
|
6
6
|
interface NotificationSubscriptionEntry {
|
|
7
7
|
queryClients: Map<QueryClient, number>;
|
|
@@ -12,7 +12,8 @@ interface NotificationSubscriptionEntry {
|
|
|
12
12
|
const registry = new Map<string, NotificationSubscriptionEntry>();
|
|
13
13
|
|
|
14
14
|
function invalidateVisibleNotifications(entry: NotificationSubscriptionEntry) {
|
|
15
|
-
if (document.visibilityState === 'hidden')
|
|
15
|
+
if (document.visibilityState === 'hidden' || navigator.onLine === false)
|
|
16
|
+
return;
|
|
16
17
|
for (const queryClient of entry.queryClients.keys()) {
|
|
17
18
|
// Only active queries refetch; closed inboxes remain lazy.
|
|
18
19
|
void queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
|
@@ -22,6 +23,7 @@ function invalidateVisibleNotifications(entry: NotificationSubscriptionEntry) {
|
|
|
22
23
|
function stopEntry(entry: NotificationSubscriptionEntry) {
|
|
23
24
|
clearInterval(entry.interval);
|
|
24
25
|
document.removeEventListener('visibilitychange', entry.onVisibilityChange);
|
|
26
|
+
window.removeEventListener('online', entry.onVisibilityChange);
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
/**
|
|
@@ -50,6 +52,7 @@ export function useNotificationSubscription(
|
|
|
50
52
|
};
|
|
51
53
|
registry.set(userId, entry);
|
|
52
54
|
document.addEventListener('visibilitychange', refresh);
|
|
55
|
+
window.addEventListener('online', refresh);
|
|
53
56
|
}
|
|
54
57
|
entry.queryClients.set(
|
|
55
58
|
queryClient,
|