@tuturuuu/ui 0.37.1 → 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 +40 -0
- package/package.json +10 -7
- package/src/components/ui/custom/__tests__/workspace-picker-content.test.tsx +267 -0
- package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +24 -38
- 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/hidden-workspaces-settings.tsx +49 -0
- package/src/components/ui/custom/mobile-header-height.ts +16 -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/structure.tsx +37 -5
- package/src/components/ui/custom/user-filters.tsx +2 -1
- package/src/components/ui/custom/visible-workspace-filter.tsx +41 -0
- package/src/components/ui/custom/workspace-picker-content.tsx +356 -0
- package/src/components/ui/custom/workspace-select-helpers.ts +11 -0
- package/src/components/ui/custom/workspace-select-invitations.tsx +3 -0
- package/src/components/ui/custom/workspace-select.tsx +95 -359
- 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/settings/calendar-sync-dashboard.tsx +4 -23
- package/src/components/ui/legacy/calendar/settings/sync-logs-table.tsx +3 -6
- 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/resizable.tsx +2 -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-visible-workspaces.test.tsx +76 -0
- package/src/hooks/__tests__/use-workspace-presence.test.tsx +3 -3
- package/src/hooks/__tests__/use-workspace-visibility.test.tsx +155 -0
- package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +108 -0
- package/src/hooks/__tests__/workspace-actor-lifetime.test.tsx +103 -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-visible-workspaces.ts +57 -0
- package/src/hooks/use-workspace-presence.ts +9 -9
- package/src/hooks/use-workspace-user.ts +15 -3
- package/src/hooks/use-workspace-visibility.ts +234 -0
- 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');
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
2
|
+
import { renderHook, waitFor } from '@testing-library/react';
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
import { beforeEach, expect, it, vi } from 'vitest';
|
|
5
|
+
import { useVisibleWorkspaces } from '../use-visible-workspaces';
|
|
6
|
+
import { WorkspaceVisibilityProvider } from '../use-workspace-visibility';
|
|
7
|
+
|
|
8
|
+
const mocks = vi.hoisted(() => ({ list: vi.fn(), hidden: vi.fn() }));
|
|
9
|
+
vi.mock('@tuturuuu/internal-api/workspaces', () => ({
|
|
10
|
+
listWorkspaces: mocks.list,
|
|
11
|
+
}));
|
|
12
|
+
vi.mock('@tuturuuu/internal-api/users', () => ({
|
|
13
|
+
getCurrentUserHiddenWorkspaces: mocks.hidden,
|
|
14
|
+
updateCurrentUserHiddenWorkspace: vi.fn(),
|
|
15
|
+
}));
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
vi.clearAllMocks();
|
|
18
|
+
mocks.list.mockResolvedValue([{ id: 'visible' }, { id: 'hidden' }]);
|
|
19
|
+
mocks.hidden.mockResolvedValue({ hiddenWorkspaceIds: ['hidden'] });
|
|
20
|
+
});
|
|
21
|
+
function fixture() {
|
|
22
|
+
const client = new QueryClient({
|
|
23
|
+
defaultOptions: { queries: { retry: false, retryDelay: 0 } },
|
|
24
|
+
});
|
|
25
|
+
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
26
|
+
<QueryClientProvider client={client}>
|
|
27
|
+
<WorkspaceVisibilityProvider actorId="A">
|
|
28
|
+
{children}
|
|
29
|
+
</WorkspaceVisibilityProvider>
|
|
30
|
+
</QueryClientProvider>
|
|
31
|
+
);
|
|
32
|
+
return { client, wrapper };
|
|
33
|
+
}
|
|
34
|
+
it('retains an explicit zero bound and only exposes known visible choices', async () => {
|
|
35
|
+
const { client, wrapper } = fixture();
|
|
36
|
+
const { result } = renderHook(() => useVisibleWorkspaces(true, 0), {
|
|
37
|
+
wrapper,
|
|
38
|
+
});
|
|
39
|
+
await waitFor(() => expect(result.current.data).toEqual([{ id: 'visible' }]));
|
|
40
|
+
expect(mocks.list).toHaveBeenCalledWith({ limit: 0 });
|
|
41
|
+
expect(client.getQueryData(['workspace-ui-list', 'A', 0])).toHaveLength(2);
|
|
42
|
+
});
|
|
43
|
+
it('settled private discovery failure is an error rather than permanent loading', async () => {
|
|
44
|
+
mocks.hidden.mockRejectedValue(new Error('offline'));
|
|
45
|
+
const { client, wrapper } = fixture();
|
|
46
|
+
const { result } = renderHook(() => useVisibleWorkspaces(), { wrapper });
|
|
47
|
+
await waitFor(() =>
|
|
48
|
+
expect(client.getQueryState(['workspace-hidden', 'A'])?.status).toBe(
|
|
49
|
+
'error'
|
|
50
|
+
)
|
|
51
|
+
);
|
|
52
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
53
|
+
expect(result.current.isError).toBe(true);
|
|
54
|
+
expect(result.current.error?.message).toBe('offline');
|
|
55
|
+
expect(result.current.data).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('distinguishes an absent personal workspace from one excluded by Hidden preferences', async () => {
|
|
59
|
+
mocks.list.mockResolvedValue([{ id: 'team', personal: false }]);
|
|
60
|
+
const { client, wrapper } = fixture();
|
|
61
|
+
const { result } = renderHook(() => useVisibleWorkspaces(), { wrapper });
|
|
62
|
+
await waitFor(() =>
|
|
63
|
+
expect(result.current.personalWorkspaceMissing).toBe(true)
|
|
64
|
+
);
|
|
65
|
+
mocks.list.mockResolvedValue([
|
|
66
|
+
{ id: 'team', personal: false },
|
|
67
|
+
{ id: 'personal', personal: true },
|
|
68
|
+
]);
|
|
69
|
+
mocks.hidden.mockResolvedValue({ hiddenWorkspaceIds: ['personal'] });
|
|
70
|
+
await result.current.refetch();
|
|
71
|
+
await waitFor(() =>
|
|
72
|
+
expect(result.current.data).toEqual([{ id: 'team', personal: false }])
|
|
73
|
+
);
|
|
74
|
+
expect(result.current.personalWorkspaceMissing).toBe(false);
|
|
75
|
+
expect(client.getQueryData(['workspace-ui-list', 'A'])).toHaveLength(2);
|
|
76
|
+
});
|
|
@@ -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,155 @@
|
|
|
1
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
2
|
+
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import {
|
|
6
|
+
useWorkspaceVisibility,
|
|
7
|
+
WorkspaceVisibilityProvider,
|
|
8
|
+
} from '../use-workspace-visibility';
|
|
9
|
+
|
|
10
|
+
const mocks = vi.hoisted(() => ({ get: vi.fn(), put: vi.fn() }));
|
|
11
|
+
vi.mock('@tuturuuu/internal-api/users', () => ({
|
|
12
|
+
getCurrentUserHiddenWorkspaces: mocks.get,
|
|
13
|
+
updateCurrentUserHiddenWorkspace: mocks.put,
|
|
14
|
+
}));
|
|
15
|
+
function deferred<T>() {
|
|
16
|
+
let resolve!: (v: T) => void;
|
|
17
|
+
let reject!: (e: Error) => void;
|
|
18
|
+
const promise = new Promise<T>((a, b) => {
|
|
19
|
+
resolve = a;
|
|
20
|
+
reject = b;
|
|
21
|
+
});
|
|
22
|
+
return { promise, resolve, reject };
|
|
23
|
+
}
|
|
24
|
+
function setup(
|
|
25
|
+
actor = 'A',
|
|
26
|
+
client = new QueryClient({
|
|
27
|
+
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
|
28
|
+
})
|
|
29
|
+
) {
|
|
30
|
+
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
31
|
+
<QueryClientProvider client={client}>
|
|
32
|
+
<WorkspaceVisibilityProvider actorId={actor}>
|
|
33
|
+
{children}
|
|
34
|
+
</WorkspaceVisibilityProvider>
|
|
35
|
+
</QueryClientProvider>
|
|
36
|
+
);
|
|
37
|
+
return { ...renderHook(() => useWorkspaceVisibility(), { wrapper }), client };
|
|
38
|
+
}
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
vi.clearAllMocks();
|
|
41
|
+
mocks.get.mockResolvedValue({ hiddenWorkspaceIds: [] });
|
|
42
|
+
mocks.put.mockResolvedValue({});
|
|
43
|
+
});
|
|
44
|
+
describe('private owner-scoped workspace visibility', () => {
|
|
45
|
+
it('optimistically hides and rolls back only the failed workspace', async () => {
|
|
46
|
+
const write = deferred<object>();
|
|
47
|
+
mocks.put.mockReturnValue(write.promise);
|
|
48
|
+
const hook = setup();
|
|
49
|
+
await waitFor(() => expect(hook.result.current.known).toBe(true));
|
|
50
|
+
let operation!: Promise<void>;
|
|
51
|
+
act(() => {
|
|
52
|
+
operation = hook.result.current.setHidden('ws-1', true);
|
|
53
|
+
});
|
|
54
|
+
await waitFor(() =>
|
|
55
|
+
expect(hook.result.current.hiddenIds).toEqual(['ws-1'])
|
|
56
|
+
);
|
|
57
|
+
await act(async () => {
|
|
58
|
+
write.reject(new Error('offline'));
|
|
59
|
+
await expect(operation).rejects.toThrow('offline');
|
|
60
|
+
});
|
|
61
|
+
expect(hook.result.current.hiddenIds).toEqual([]);
|
|
62
|
+
expect(hook.result.current.updateError).not.toBeNull();
|
|
63
|
+
expect(mocks.put).toHaveBeenCalledWith('ws-1', true, 'A');
|
|
64
|
+
await act(async () => {
|
|
65
|
+
await hook.result.current.refetch();
|
|
66
|
+
});
|
|
67
|
+
expect(hook.result.current.updateError).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
it('a stale refresh cannot replace optimistic state', async () => {
|
|
70
|
+
const hook = setup();
|
|
71
|
+
await waitFor(() => expect(hook.result.current.known).toBe(true));
|
|
72
|
+
const read = deferred<{ hiddenWorkspaceIds: string[] }>();
|
|
73
|
+
mocks.get
|
|
74
|
+
.mockImplementationOnce(() => read.promise)
|
|
75
|
+
.mockResolvedValue({ hiddenWorkspaceIds: ['ws-1'] });
|
|
76
|
+
act(() => {
|
|
77
|
+
void hook.result.current.refetch();
|
|
78
|
+
});
|
|
79
|
+
await act(async () => {
|
|
80
|
+
await hook.result.current.setHidden('ws-1', true);
|
|
81
|
+
});
|
|
82
|
+
await act(async () => {
|
|
83
|
+
read.resolve({ hiddenWorkspaceIds: [] });
|
|
84
|
+
});
|
|
85
|
+
expect(hook.result.current.hiddenIds).toEqual(['ws-1']);
|
|
86
|
+
});
|
|
87
|
+
it('suppresses duplicate writes for the same pending workspace', async () => {
|
|
88
|
+
const write = deferred<object>();
|
|
89
|
+
mocks.put.mockReturnValue(write.promise);
|
|
90
|
+
const hook = setup();
|
|
91
|
+
await waitFor(() => expect(hook.result.current.known).toBe(true));
|
|
92
|
+
let operation!: Promise<void>;
|
|
93
|
+
act(() => {
|
|
94
|
+
operation = hook.result.current.setHidden('ws-1', true);
|
|
95
|
+
});
|
|
96
|
+
await waitFor(() =>
|
|
97
|
+
expect(hook.result.current.pending.has('ws-1')).toBe(true)
|
|
98
|
+
);
|
|
99
|
+
await act(async () => {
|
|
100
|
+
await hook.result.current.setHidden('ws-1', true);
|
|
101
|
+
});
|
|
102
|
+
expect(mocks.put).toHaveBeenCalledTimes(1);
|
|
103
|
+
await act(async () => {
|
|
104
|
+
write.resolve({});
|
|
105
|
+
await operation;
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
it('removes private owner caches on logout and ignores late response', async () => {
|
|
109
|
+
const hook = setup();
|
|
110
|
+
await waitFor(() => expect(hook.result.current.known).toBe(true));
|
|
111
|
+
const write = deferred<object>();
|
|
112
|
+
mocks.put.mockReturnValue(write.promise);
|
|
113
|
+
let operation!: Promise<void>;
|
|
114
|
+
act(() => {
|
|
115
|
+
operation = hook.result.current.setHidden('ws-1', true);
|
|
116
|
+
});
|
|
117
|
+
await waitFor(() =>
|
|
118
|
+
expect(hook.result.current.hiddenIds).toEqual(['ws-1'])
|
|
119
|
+
);
|
|
120
|
+
hook.unmount();
|
|
121
|
+
write.resolve({});
|
|
122
|
+
await operation;
|
|
123
|
+
expect(hook.client.getQueryData(['workspace-hidden', 'A'])).toBeUndefined();
|
|
124
|
+
expect(hook.client.getQueryData(['workspace-hidden', 'B'])).toBeUndefined();
|
|
125
|
+
});
|
|
126
|
+
it('two actors use independent hidden cache keys', async () => {
|
|
127
|
+
mocks.get.mockImplementation(async (actor: string) => ({
|
|
128
|
+
hiddenWorkspaceIds: actor === 'A' ? ['ws-1'] : [],
|
|
129
|
+
}));
|
|
130
|
+
const client = new QueryClient({
|
|
131
|
+
defaultOptions: { queries: { retry: false } },
|
|
132
|
+
});
|
|
133
|
+
const a = setup('A', client);
|
|
134
|
+
const b = setup('B', client);
|
|
135
|
+
await waitFor(() => expect(a.result.current.hiddenIds).toEqual(['ws-1']));
|
|
136
|
+
await waitFor(() => expect(b.result.current.known).toBe(true));
|
|
137
|
+
expect(b.result.current.hiddenIds).toEqual([]);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('settles a refetch safely after logout without returning private old-actor data', async () => {
|
|
142
|
+
const hook = setup();
|
|
143
|
+
await waitFor(() => expect(hook.result.current.known).toBe(true));
|
|
144
|
+
const read = deferred<{ hiddenWorkspaceIds: string[] }>();
|
|
145
|
+
mocks.get.mockReturnValue(read.promise);
|
|
146
|
+
let operation!: ReturnType<typeof hook.result.current.refetch>;
|
|
147
|
+
act(() => {
|
|
148
|
+
operation = hook.result.current.refetch();
|
|
149
|
+
});
|
|
150
|
+
await waitFor(() => expect(mocks.get).toHaveBeenCalledTimes(2));
|
|
151
|
+
hook.unmount();
|
|
152
|
+
read.resolve({ hiddenWorkspaceIds: ['old-private'] });
|
|
153
|
+
await expect(operation).resolves.toBeNull();
|
|
154
|
+
expect(hook.client.getQueryData(['workspace-hidden', 'A'])).toBeUndefined();
|
|
155
|
+
});
|