@tuturuuu/ui 0.37.2 → 0.39.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 +55 -0
- package/package.json +10 -7
- 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/finance/invoices/attendance-calendar.test.tsx +294 -0
- package/src/components/ui/finance/invoices/attendance-calendar.tsx +14 -6
- package/src/components/ui/finance/invoices/components/invoice-history-actor.test.tsx +341 -0
- package/src/components/ui/finance/invoices/components/invoice-history-date.test.tsx +173 -0
- package/src/components/ui/finance/invoices/components/invoice-history-error.test.tsx +269 -0
- package/src/components/ui/finance/invoices/components/invoice-history-recovery.tsx +47 -0
- package/src/components/ui/finance/invoices/components/invoice-user-history-accordion.tsx +43 -10
- package/src/components/ui/finance/invoices/components/subscription-create-action.tsx +42 -0
- package/src/components/ui/finance/invoices/hooks/customer-search-scope.test.tsx +404 -0
- package/src/components/ui/finance/invoices/hooks/invoice-actor-lifetime-key.ts +11 -0
- package/src/components/ui/finance/invoices/hooks/use-infinite-user-invoices.ts +48 -0
- package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.test.tsx +6 -1
- package/src/components/ui/finance/invoices/hooks/use-invoice-customer-search.ts +58 -6
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.test.tsx +34 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-auto-selection.ts +8 -2
- package/src/components/ui/finance/invoices/hooks/use-subscription-create.ts +219 -0
- package/src/components/ui/finance/invoices/hooks/use-subscription-invoice-context.ts +59 -0
- package/src/components/ui/finance/invoices/hooks/use-user-groups.ts +38 -0
- package/src/components/ui/finance/invoices/hooks.ts +3 -100
- package/src/components/ui/finance/invoices/invoiceId/compact-invoice-template.tsx +2 -7
- package/src/components/ui/finance/invoices/invoiceId/format-invoice-template-date.ts +13 -0
- package/src/components/ui/finance/invoices/invoiceId/full-invoice-template.tsx +2 -7
- package/src/components/ui/finance/invoices/invoiceId/invoice-card-date.test.tsx +169 -0
- package/src/components/ui/finance/invoices/invoiceId/invoice-card-storage.test.tsx +120 -0
- package/src/components/ui/finance/invoices/invoiceId/invoice-card.tsx +25 -62
- package/src/components/ui/finance/invoices/invoiceId/invoice-details-page.tsx +1 -0
- package/src/components/ui/finance/invoices/invoiceId/invoice-png-lifetime.test.tsx +438 -0
- package/src/components/ui/finance/invoices/invoiceId/use-invoice-png-export.ts +184 -0
- package/src/components/ui/finance/invoices/subscription-admission.test.ts +77 -0
- package/src/components/ui/finance/invoices/subscription-admission.ts +101 -0
- package/src/components/ui/finance/invoices/subscription-billing-groups.test.ts +162 -1
- package/src/components/ui/finance/invoices/subscription-billing-groups.ts +50 -9
- package/src/components/ui/finance/invoices/subscription-context-actor.test.tsx +246 -0
- package/src/components/ui/finance/invoices/subscription-create-scope.test.tsx +562 -0
- package/src/components/ui/finance/invoices/subscription-invoice.test.tsx +88 -6
- package/src/components/ui/finance/invoices/subscription-invoice.tsx +76 -135
- package/src/components/ui/finance/invoices/types.ts +3 -3
- package/src/components/ui/finance/invoices/user-groups-actor.test.tsx +334 -0
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoint-adjustment-dialog.tsx +47 -10
- package/src/components/ui/finance/wallets/checkpoints/wallet-checkpoints.test.tsx +95 -1
- package/src/components/ui/legacy/calendar/agenda-view.tsx +4 -1
- package/src/components/ui/legacy/calendar/all-day-event-bar.tsx +33 -369
- package/src/components/ui/legacy/calendar/all-day-event-layout.test.ts +94 -0
- package/src/components/ui/legacy/calendar/all-day-event-layout.ts +322 -0
- package/src/components/ui/legacy/calendar/calendar-draft-recovery.ts +34 -0
- package/src/components/ui/legacy/calendar/calendar-views.test.tsx +58 -27
- package/src/components/ui/legacy/calendar/event-card.test.tsx +25 -1
- package/src/components/ui/legacy/calendar/event-card.tsx +10 -12
- package/src/components/ui/legacy/calendar/event-modal-header.tsx +17 -4
- package/src/components/ui/legacy/calendar/event-modal.tsx +26 -1614
- 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/legacy-event-modal.tsx +1559 -0
- package/src/components/ui/legacy/calendar/month-calendar.tsx +5 -2
- package/src/components/ui/legacy/calendar/native-recurrence-context-menu.tsx +21 -0
- package/src/components/ui/legacy/calendar/native-recurrence-dialog.test.tsx +245 -0
- package/src/components/ui/legacy/calendar/native-recurrence-dialog.tsx +95 -0
- package/src/components/ui/legacy/calendar/native-recurrence-fields.tsx +137 -0
- package/src/components/ui/legacy/calendar/native-recurrence-form.tsx +340 -0
- package/src/components/ui/legacy/calendar/native-recurrence-model.test.ts +234 -0
- package/src/components/ui/legacy/calendar/native-recurrence-model.ts +245 -0
- package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.test.tsx +142 -0
- package/src/components/ui/legacy/calendar/native-recurrence-pattern-fields.tsx +178 -0
- package/src/components/ui/legacy/calendar/native-recurrence-pattern-model.test.ts +205 -0
- package/src/components/ui/legacy/calendar/native-recurrence-provider-routing.test.tsx +255 -0
- package/src/components/ui/legacy/calendar/past-event-treatment.test.ts +137 -0
- package/src/components/ui/legacy/calendar/past-event-treatment.ts +25 -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/provider-readonly-dialog.test.tsx +52 -0
- package/src/components/ui/legacy/calendar/provider-readonly-dialog.tsx +48 -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/legacy/calendar/use-provider-recurrence-operation.test.tsx +185 -0
- package/src/components/ui/legacy/calendar/use-provider-recurrence-operation.ts +186 -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 +11 -3
- package/src/components/ui/text-editor/extensions.ts +8 -2
- package/src/components/ui/text-editor/fixed-toolbar-contents.test.tsx +59 -0
- package/src/components/ui/text-editor/fixed-toolbar-contents.tsx +156 -0
- 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 +45 -102
- package/src/hooks/__tests__/calendar-event-write-payload-readonly.test.ts +40 -0
- package/src/hooks/__tests__/collaboration-awareness.test.ts +106 -0
- package/src/hooks/__tests__/native-calendar-mutation-guard.test.ts +45 -0
- package/src/hooks/__tests__/supabase-provider.test.ts +110 -7
- package/src/hooks/__tests__/use-calendar-readonly.test.tsx +39 -2
- package/src/hooks/__tests__/use-local-storage.test.tsx +42 -0
- package/src/hooks/__tests__/use-native-calendar-occurrences.test.tsx +140 -0
- 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 +147 -0
- package/src/hooks/calendar-connection-types.ts +49 -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-update-queue-types.ts +12 -0
- package/src/hooks/calendar-event-write-payload.ts +57 -0
- package/src/hooks/collaboration-document-hydration.ts +35 -0
- package/src/hooks/notification-subscription.ts +5 -2
- package/src/hooks/supabase-provider.ts +76 -112
- package/src/hooks/use-calendar-sync.tsx +41 -62
- package/src/hooks/use-calendar.tsx +44 -82
- package/src/hooks/use-exchange-rates.ts +4 -15
- package/src/hooks/use-local-storage.ts +9 -11
- package/src/hooks/use-native-calendar-occurrences.ts +85 -0
- package/src/hooks/use-workspace-presence.ts +9 -9
- package/src/hooks/use-yjs-collaboration.ts +68 -27
|
@@ -35,20 +35,18 @@ import { toast } from '@tuturuuu/ui/sonner';
|
|
|
35
35
|
import { cn } from '@tuturuuu/utils/format';
|
|
36
36
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
37
37
|
import { TextEditorColorControls } from './color-controls';
|
|
38
|
-
import {
|
|
38
|
+
import type { EditorCopyLabels } from './copy-menu';
|
|
39
|
+
import { FixedToolbarContents } from './fixed-toolbar-contents';
|
|
39
40
|
import {
|
|
40
41
|
MAX_IMAGE_SIZE,
|
|
41
42
|
MAX_VIDEO_SIZE,
|
|
42
43
|
StorageQuotaError,
|
|
43
44
|
} from './media-utils';
|
|
44
|
-
import { TOOLBAR_GROUPS } from './toolbar-config';
|
|
45
45
|
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
|
]
|
|
@@ -843,6 +842,7 @@ export function ToolBar({
|
|
|
843
842
|
}
|
|
844
843
|
|
|
845
844
|
interface FixedToolbarProps {
|
|
845
|
+
toolsLabel?: string;
|
|
846
846
|
editor: Editor | null;
|
|
847
847
|
leadingContent?: React.ReactNode;
|
|
848
848
|
workspaceId?: string;
|
|
@@ -854,6 +854,7 @@ interface FixedToolbarProps {
|
|
|
854
854
|
copyLabels?: EditorCopyLabels;
|
|
855
855
|
}
|
|
856
856
|
export function FixedToolbar({
|
|
857
|
+
toolsLabel,
|
|
857
858
|
editor,
|
|
858
859
|
leadingContent,
|
|
859
860
|
workspaceId,
|
|
@@ -1109,78 +1110,20 @@ export function FixedToolbar({
|
|
|
1109
1110
|
<ToolbarSeparator />
|
|
1110
1111
|
</>
|
|
1111
1112
|
) : null}
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
onClick={opt.onClick}
|
|
1127
|
-
/>
|
|
1128
|
-
);
|
|
1129
|
-
})}
|
|
1130
|
-
</div>
|
|
1131
|
-
))}
|
|
1132
|
-
|
|
1133
|
-
<ToolbarSeparator />
|
|
1134
|
-
<TextEditorColorControls editor={editor} />
|
|
1135
|
-
|
|
1136
|
-
{/* Media group */}
|
|
1137
|
-
{workspaceId && onImageUpload && (
|
|
1138
|
-
<>
|
|
1139
|
-
<ToolbarSeparator />
|
|
1140
|
-
<ToolbarButton
|
|
1141
|
-
id="image"
|
|
1142
|
-
icon={
|
|
1143
|
-
isUploadingImage ? (
|
|
1144
|
-
<Loader2 className="size-4 animate-spin" />
|
|
1145
|
-
) : (
|
|
1146
|
-
<ImageIcon className="size-4" />
|
|
1147
|
-
)
|
|
1148
|
-
}
|
|
1149
|
-
pressed={false}
|
|
1150
|
-
onClick={() => fileInputRef.current?.click()}
|
|
1151
|
-
disabled={isUploadingImage}
|
|
1152
|
-
/>
|
|
1153
|
-
<ToolbarButton
|
|
1154
|
-
id="video"
|
|
1155
|
-
icon={
|
|
1156
|
-
isUploadingVideo ? (
|
|
1157
|
-
<Loader2 className="size-4 animate-spin" />
|
|
1158
|
-
) : (
|
|
1159
|
-
<FileVideo className="size-4" />
|
|
1160
|
-
)
|
|
1161
|
-
}
|
|
1162
|
-
pressed={false}
|
|
1163
|
-
onClick={() => videoInputRef.current?.click()}
|
|
1164
|
-
disabled={isUploadingVideo}
|
|
1165
|
-
/>
|
|
1166
|
-
</>
|
|
1167
|
-
)}
|
|
1168
|
-
|
|
1169
|
-
{/* Convert to task */}
|
|
1170
|
-
{onConvertToTask && (
|
|
1171
|
-
<>
|
|
1172
|
-
<ToolbarSeparator />
|
|
1173
|
-
<ToolbarButton
|
|
1174
|
-
id="convert-to-task"
|
|
1175
|
-
icon={<CirclePlus className="size-4" />}
|
|
1176
|
-
pressed={false}
|
|
1177
|
-
onClick={onConvertToTask}
|
|
1178
|
-
/>
|
|
1179
|
-
</>
|
|
1180
|
-
)}
|
|
1181
|
-
|
|
1182
|
-
<ToolbarSeparator />
|
|
1183
|
-
<EditorCopyMenu editor={editor} labels={copyLabels} />
|
|
1113
|
+
<FixedToolbarContents
|
|
1114
|
+
editor={editor}
|
|
1115
|
+
options={formattingOptions}
|
|
1116
|
+
toolsLabel={toolsLabel}
|
|
1117
|
+
toggleBlockLabel={toggleBlockLabel}
|
|
1118
|
+
copyLabels={copyLabels}
|
|
1119
|
+
imageUpload={!!onImageUpload}
|
|
1120
|
+
videoUpload={!!workspaceId}
|
|
1121
|
+
uploadingImage={isUploadingImage}
|
|
1122
|
+
uploadingVideo={isUploadingVideo}
|
|
1123
|
+
onImage={() => fileInputRef.current?.click()}
|
|
1124
|
+
onVideo={() => videoInputRef.current?.click()}
|
|
1125
|
+
onConvertToTask={onConvertToTask}
|
|
1126
|
+
/>
|
|
1184
1127
|
</div>
|
|
1185
1128
|
);
|
|
1186
1129
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { assertOrdinaryCalendarEvent } from '../calendar-event-write-payload';
|
|
4
|
+
|
|
5
|
+
const event = {
|
|
6
|
+
id: 'instance',
|
|
7
|
+
start_at: '2026-10-06T09:00:00Z',
|
|
8
|
+
end_at: '2026-10-06T10:00:00Z',
|
|
9
|
+
_originalId: 'original',
|
|
10
|
+
scheduling_metadata: {
|
|
11
|
+
provider_recurrence: {
|
|
12
|
+
state: 'unsupported',
|
|
13
|
+
provider: 'google',
|
|
14
|
+
master_id: 'master',
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
} as CalendarEvent;
|
|
18
|
+
describe('provider readonly ordinary mutation fence', () => {
|
|
19
|
+
it.each(['instance', 'original'])(
|
|
20
|
+
'blocks updates/deletes by %s identity even if user unlocks the local event',
|
|
21
|
+
(id) =>
|
|
22
|
+
expect(() =>
|
|
23
|
+
assertOrdinaryCalendarEvent([{ ...event, locked: false }], id)
|
|
24
|
+
).toThrow('read only')
|
|
25
|
+
);
|
|
26
|
+
it('permits an ordinary independently locked event', () =>
|
|
27
|
+
expect(() =>
|
|
28
|
+
assertOrdinaryCalendarEvent(
|
|
29
|
+
[{ id: 'ordinary', locked: true } as CalendarEvent],
|
|
30
|
+
'ordinary'
|
|
31
|
+
)
|
|
32
|
+
).not.toThrow());
|
|
33
|
+
it('retains the scoped native occurrence fence', () =>
|
|
34
|
+
expect(() =>
|
|
35
|
+
assertOrdinaryCalendarEvent(
|
|
36
|
+
[{ id: 'native', seriesId: 'series' } as CalendarEvent],
|
|
37
|
+
'native'
|
|
38
|
+
)
|
|
39
|
+
).toThrow('explicit series edit scope'));
|
|
40
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import {
|
|
4
|
+
assertOrdinaryCalendarEvent,
|
|
5
|
+
calendarEventByIdentity,
|
|
6
|
+
cleanCalendarEventUpdates,
|
|
7
|
+
} from '../calendar-event-write-payload';
|
|
8
|
+
|
|
9
|
+
const virtual: CalendarEvent = {
|
|
10
|
+
id: '123e4567-e89b-42d3-a456-426614174000',
|
|
11
|
+
seriesId: 'series',
|
|
12
|
+
originalStartLocal: '2026-10-05T09:00:00',
|
|
13
|
+
start_at: '2026-10-05T09:00:00Z',
|
|
14
|
+
end_at: '2026-10-05T10:00:00Z',
|
|
15
|
+
};
|
|
16
|
+
describe('native virtual mutation fence', () => {
|
|
17
|
+
it('resolves full UUID identities without truncating at the first dash', () => {
|
|
18
|
+
expect(calendarEventByIdentity([virtual], virtual.id)).toBe(virtual);
|
|
19
|
+
});
|
|
20
|
+
it('rejects ordinary update/delete/drag identity paths for virtual rows and split rows', () => {
|
|
21
|
+
expect(() => assertOrdinaryCalendarEvent([virtual], virtual.id)).toThrow();
|
|
22
|
+
expect(() =>
|
|
23
|
+
assertOrdinaryCalendarEvent(
|
|
24
|
+
[{ ...virtual, id: 'split', _originalId: virtual.id }],
|
|
25
|
+
virtual.id
|
|
26
|
+
)
|
|
27
|
+
).toThrow();
|
|
28
|
+
});
|
|
29
|
+
it('preserves ordinary event mutation behavior', () => {
|
|
30
|
+
expect(() =>
|
|
31
|
+
assertOrdinaryCalendarEvent(
|
|
32
|
+
[{ ...virtual, seriesId: undefined }],
|
|
33
|
+
virtual.id
|
|
34
|
+
)
|
|
35
|
+
).not.toThrow();
|
|
36
|
+
expect(
|
|
37
|
+
cleanCalendarEventUpdates({
|
|
38
|
+
title: 'Title',
|
|
39
|
+
seriesId: 'injected',
|
|
40
|
+
originalStartLocal: 'injected',
|
|
41
|
+
start_at: '2026-10-05T12:00:00Z',
|
|
42
|
+
})
|
|
43
|
+
).toEqual({ title: 'Title', start_at: '2026-10-05T12:00:00Z' });
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -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
|
|
|
@@ -57,9 +66,7 @@ describe('SupabaseProvider', () => {
|
|
|
57
66
|
|
|
58
67
|
channel.trigger('SUBSCRIBED');
|
|
59
68
|
|
|
60
|
-
await
|
|
61
|
-
await Promise.resolve();
|
|
62
|
-
expect(provider.connected).toBe(true);
|
|
69
|
+
await waitFor(() => expect(provider.connected).toBe(true));
|
|
63
70
|
expect(provider.synced).toBe(true);
|
|
64
71
|
|
|
65
72
|
doc.getMap('prosemirror').set('text', 'hello');
|
|
@@ -156,8 +163,7 @@ describe('SupabaseProvider', () => {
|
|
|
156
163
|
try {
|
|
157
164
|
channel.trigger('SUBSCRIBED');
|
|
158
165
|
|
|
159
|
-
await
|
|
160
|
-
await Promise.resolve();
|
|
166
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
161
167
|
expect(provider.connected).toBe(true);
|
|
162
168
|
|
|
163
169
|
const sendsBefore = channel.send.mock.calls.length;
|
|
@@ -214,8 +220,7 @@ describe('SupabaseProvider', () => {
|
|
|
214
220
|
try {
|
|
215
221
|
channel.trigger('SUBSCRIBED');
|
|
216
222
|
|
|
217
|
-
await
|
|
218
|
-
await Promise.resolve();
|
|
223
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
219
224
|
expect(provider.connected).toBe(true);
|
|
220
225
|
|
|
221
226
|
const sendsBefore = channel.send.mock.calls.length;
|
|
@@ -344,3 +349,101 @@ describe('SupabaseProvider', () => {
|
|
|
344
349
|
provider.destroy();
|
|
345
350
|
});
|
|
346
351
|
});
|
|
352
|
+
|
|
353
|
+
describe('durable hydration without peer transport', () => {
|
|
354
|
+
it('loads saved content even when the channel never subscribes', async () => {
|
|
355
|
+
createRealtimeChannel();
|
|
356
|
+
const saved = new Y.Doc();
|
|
357
|
+
saved.getText('description').insert(0, 'saved text');
|
|
358
|
+
const doc = new Y.Doc();
|
|
359
|
+
const provider = new SupabaseProvider(doc, {} as any, {
|
|
360
|
+
channel: 'task-editor-offline',
|
|
361
|
+
tableName: 'tasks',
|
|
362
|
+
columnName: 'state',
|
|
363
|
+
id: 'offline',
|
|
364
|
+
loadState: async () => Array.from(Y.encodeStateAsUpdate(saved)),
|
|
365
|
+
resyncInterval: false,
|
|
366
|
+
});
|
|
367
|
+
await waitFor(() => expect(provider.hydrated).toBe(true));
|
|
368
|
+
expect(doc.getText('description').toString()).toBe('saved text');
|
|
369
|
+
expect(provider.connected).toBe(false);
|
|
370
|
+
expect(provider.synced).toBe(false);
|
|
371
|
+
provider.destroy();
|
|
372
|
+
saved.destroy();
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('coalesces startup and connection loads and ignores disconnect during hydration', async () => {
|
|
376
|
+
const channel = createRealtimeChannel();
|
|
377
|
+
let finish!: (state: number[] | null) => void;
|
|
378
|
+
const loadState = vi.fn(
|
|
379
|
+
() =>
|
|
380
|
+
new Promise<number[] | null>((resolve) => {
|
|
381
|
+
finish = resolve;
|
|
382
|
+
})
|
|
383
|
+
);
|
|
384
|
+
const provider = new SupabaseProvider(new Y.Doc(), {} as any, {
|
|
385
|
+
channel: 'task-editor-pending',
|
|
386
|
+
tableName: 'tasks',
|
|
387
|
+
columnName: 'state',
|
|
388
|
+
id: 'pending',
|
|
389
|
+
loadState,
|
|
390
|
+
resyncInterval: false,
|
|
391
|
+
});
|
|
392
|
+
channel.trigger('SUBSCRIBED');
|
|
393
|
+
channel.trigger('CLOSED');
|
|
394
|
+
finish(null);
|
|
395
|
+
await waitFor(() => expect(provider.hydrated).toBe(true));
|
|
396
|
+
expect(loadState).toHaveBeenCalledTimes(1);
|
|
397
|
+
expect(provider.connected).toBe(false);
|
|
398
|
+
provider.destroy();
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('does not hydrate a denied read and retries without unhandled rejection', async () => {
|
|
402
|
+
const channel = createRealtimeChannel();
|
|
403
|
+
const loadState = vi
|
|
404
|
+
.fn()
|
|
405
|
+
.mockRejectedValueOnce(new Error('Access denied'))
|
|
406
|
+
.mockResolvedValueOnce(null);
|
|
407
|
+
const provider = new SupabaseProvider(new Y.Doc(), {} as any, {
|
|
408
|
+
channel: 'task-editor-denied',
|
|
409
|
+
tableName: 'tasks',
|
|
410
|
+
columnName: 'state',
|
|
411
|
+
id: 'denied',
|
|
412
|
+
loadState,
|
|
413
|
+
resyncInterval: false,
|
|
414
|
+
});
|
|
415
|
+
const report = vi.fn();
|
|
416
|
+
provider.on('error', report);
|
|
417
|
+
await waitFor(() => expect(report).toHaveBeenCalledTimes(1));
|
|
418
|
+
expect(provider.hydrated).toBe(false);
|
|
419
|
+
channel.trigger('SUBSCRIBED');
|
|
420
|
+
await waitFor(() => expect(provider.connected).toBe(true));
|
|
421
|
+
expect(provider.hydrated).toBe(true);
|
|
422
|
+
provider.destroy();
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it('ignores a durable read completing after teardown', async () => {
|
|
426
|
+
createRealtimeChannel();
|
|
427
|
+
let finish!: (state: number[] | null) => void;
|
|
428
|
+
const doc = new Y.Doc();
|
|
429
|
+
const provider = new SupabaseProvider(doc, {} as any, {
|
|
430
|
+
channel: 'task-editor-old',
|
|
431
|
+
tableName: 'tasks',
|
|
432
|
+
columnName: 'state',
|
|
433
|
+
id: 'old',
|
|
434
|
+
loadState: () =>
|
|
435
|
+
new Promise((resolve) => {
|
|
436
|
+
finish = resolve;
|
|
437
|
+
}),
|
|
438
|
+
resyncInterval: false,
|
|
439
|
+
});
|
|
440
|
+
const apply = vi.fn();
|
|
441
|
+
doc.on('update', apply);
|
|
442
|
+
provider.destroy();
|
|
443
|
+
finish(null);
|
|
444
|
+
await Promise.resolve();
|
|
445
|
+
await Promise.resolve();
|
|
446
|
+
expect(provider.hydrated).toBe(false);
|
|
447
|
+
expect(apply).not.toHaveBeenCalled();
|
|
448
|
+
});
|
|
449
|
+
});
|
|
@@ -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
|
});
|
|
@@ -520,4 +530,31 @@ describe('CalendarProvider Read-Only Mode', () => {
|
|
|
520
530
|
baseEvent,
|
|
521
531
|
]);
|
|
522
532
|
});
|
|
533
|
+
it('blocks ordinary update/delete/drag writes for native recurrence occurrences', async () => {
|
|
534
|
+
const occurrence = {
|
|
535
|
+
...baseEvent,
|
|
536
|
+
seriesId: 'series',
|
|
537
|
+
seriesRevision: 3,
|
|
538
|
+
originalStartLocal: '2026-06-22T09:00:00',
|
|
539
|
+
};
|
|
540
|
+
calendarMockState.events = [occurrence];
|
|
541
|
+
const { result } = renderHook(() => useCalendar(), {
|
|
542
|
+
wrapper: createWrapper(),
|
|
543
|
+
});
|
|
544
|
+
await expect(
|
|
545
|
+
result.current.updateEvent(occurrence.id, {
|
|
546
|
+
start_at: '2026-06-23T09:00:00Z',
|
|
547
|
+
})
|
|
548
|
+
).rejects.toThrow('explicit series edit scope');
|
|
549
|
+
await expect(result.current.deleteEvent(occurrence.id)).rejects.toThrow(
|
|
550
|
+
'explicit series edit scope'
|
|
551
|
+
);
|
|
552
|
+
expect(
|
|
553
|
+
internalApiMocks.updateWorkspaceCalendarEvent
|
|
554
|
+
).not.toHaveBeenCalled();
|
|
555
|
+
expect(
|
|
556
|
+
internalApiMocks.deleteWorkspaceCalendarEvent
|
|
557
|
+
).not.toHaveBeenCalled();
|
|
558
|
+
expect(result.current.isEventReadOnly(occurrence)).toBe(true);
|
|
559
|
+
});
|
|
523
560
|
});
|