@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
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { ArrowLeft, Eye, EyeOff, Plus, Search, Star, X } from '@tuturuuu/icons';
|
|
4
4
|
import type { InternalApiWorkspaceSummary } from '@tuturuuu/types';
|
|
5
5
|
import { ROOT_WORKSPACE_ID } from '@tuturuuu/utils/constants';
|
|
6
|
+
import { cn } from '@tuturuuu/utils/format';
|
|
6
7
|
import { useTranslations } from 'next-intl';
|
|
7
8
|
import { type ReactNode, useRef, useState } from 'react';
|
|
8
9
|
import type { useWorkspaceVisibility } from '../../../hooks/use-workspace-visibility';
|
|
@@ -14,6 +15,7 @@ import {
|
|
|
14
15
|
DialogTitle,
|
|
15
16
|
} from '../dialog';
|
|
16
17
|
import { Input } from '../input';
|
|
18
|
+
import { PopoverClose, PopoverContent } from '../popover';
|
|
17
19
|
import { TUTURUUU_LOGO_URL } from './tuturuuu-logo';
|
|
18
20
|
import { WorkspaceIcon } from './workspace-select-icon';
|
|
19
21
|
|
|
@@ -32,6 +34,8 @@ export function WorkspacePickerContent({
|
|
|
32
34
|
listLoading = false,
|
|
33
35
|
listError,
|
|
34
36
|
onRetryList,
|
|
37
|
+
presentation = 'fullscreen',
|
|
38
|
+
onClose,
|
|
35
39
|
}: {
|
|
36
40
|
workspaces: InternalApiWorkspaceSummary[];
|
|
37
41
|
currentId?: string;
|
|
@@ -46,10 +50,15 @@ export function WorkspacePickerContent({
|
|
|
46
50
|
listLoading?: boolean;
|
|
47
51
|
listError?: Error | null;
|
|
48
52
|
onRetryList?: () => void;
|
|
53
|
+
presentation?: 'fullscreen' | 'dropdown';
|
|
54
|
+
onClose?: () => void;
|
|
49
55
|
}) {
|
|
56
|
+
const dropdown = presentation === 'dropdown';
|
|
57
|
+
const Title = dropdown ? 'h2' : DialogTitle;
|
|
58
|
+
const Description = dropdown ? 'p' : DialogDescription;
|
|
50
59
|
const t = useTranslations('common');
|
|
51
60
|
const [restoring, setRestoring] = useState(restoreOnly);
|
|
52
|
-
const [searchVisible, setSearchVisible] = useState(
|
|
61
|
+
const [searchVisible, setSearchVisible] = useState(dropdown);
|
|
53
62
|
const [search, setSearch] = useState('');
|
|
54
63
|
const input = useRef<HTMLInputElement>(null);
|
|
55
64
|
const query = search.trim().toLocaleLowerCase();
|
|
@@ -75,13 +84,14 @@ export function WorkspacePickerContent({
|
|
|
75
84
|
setSearchVisible(true);
|
|
76
85
|
requestAnimationFrame(() => input.current?.focus());
|
|
77
86
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
87
|
+
const content = (
|
|
88
|
+
<>
|
|
89
|
+
<div
|
|
90
|
+
className={cn(
|
|
91
|
+
'flex items-center gap-3 border-b p-4',
|
|
92
|
+
!dropdown && 'pt-[max(1rem,env(safe-area-inset-top))]'
|
|
93
|
+
)}
|
|
94
|
+
>
|
|
85
95
|
{restoring && !restoreOnly && (
|
|
86
96
|
<Button
|
|
87
97
|
size="icon"
|
|
@@ -96,18 +106,31 @@ export function WorkspacePickerContent({
|
|
|
96
106
|
</Button>
|
|
97
107
|
)}
|
|
98
108
|
<WorkspaceIcon fallbackLogoUrl={TUTURUUU_LOGO_URL} name="Tuturuuu" />
|
|
99
|
-
<
|
|
109
|
+
<Title className="min-w-0 flex-1 font-semibold text-lg leading-none">
|
|
100
110
|
{restoring ? t('hidden_workspaces') : t('workspaces')}
|
|
101
|
-
</
|
|
102
|
-
|
|
103
|
-
<
|
|
104
|
-
<
|
|
105
|
-
|
|
106
|
-
|
|
111
|
+
</Title>
|
|
112
|
+
{dropdown ? (
|
|
113
|
+
<PopoverClose asChild>
|
|
114
|
+
<Button
|
|
115
|
+
size="icon"
|
|
116
|
+
variant="ghost"
|
|
117
|
+
aria-label={t('close')}
|
|
118
|
+
onClick={onClose}
|
|
119
|
+
>
|
|
120
|
+
<X />
|
|
121
|
+
</Button>
|
|
122
|
+
</PopoverClose>
|
|
123
|
+
) : (
|
|
124
|
+
<DialogClose asChild>
|
|
125
|
+
<Button size="icon" variant="ghost" aria-label={t('close')}>
|
|
126
|
+
<X />
|
|
127
|
+
</Button>
|
|
128
|
+
</DialogClose>
|
|
129
|
+
)}
|
|
107
130
|
</div>
|
|
108
|
-
<
|
|
131
|
+
<Description className={restoring ? 'px-4 pt-3' : 'sr-only'}>
|
|
109
132
|
{restoring ? t('hidden_workspaces_description') : t('select_workspace')}
|
|
110
|
-
</
|
|
133
|
+
</Description>
|
|
111
134
|
{searchVisible && (
|
|
112
135
|
<div className="flex gap-2 px-4 pt-3">
|
|
113
136
|
<Input
|
|
@@ -148,7 +171,12 @@ export function WorkspacePickerContent({
|
|
|
148
171
|
</Button>
|
|
149
172
|
</div>
|
|
150
173
|
)}
|
|
151
|
-
<div
|
|
174
|
+
<div
|
|
175
|
+
className={cn(
|
|
176
|
+
'min-h-0 flex-1 overflow-y-auto px-4 pt-4',
|
|
177
|
+
dropdown ? 'pb-3' : 'pb-28'
|
|
178
|
+
)}
|
|
179
|
+
>
|
|
152
180
|
{!visibility.known || listLoading || listError ? (
|
|
153
181
|
<p role="status">
|
|
154
182
|
{visibility.isError || listError
|
|
@@ -161,7 +189,10 @@ export function WorkspacePickerContent({
|
|
|
161
189
|
{results.map((workspace) => (
|
|
162
190
|
<div
|
|
163
191
|
key={workspace.id}
|
|
164
|
-
className=
|
|
192
|
+
className={cn(
|
|
193
|
+
'flex items-center gap-2',
|
|
194
|
+
dropdown ? 'mb-1 rounded-md' : 'mb-2 rounded-xl border p-2'
|
|
195
|
+
)}
|
|
165
196
|
>
|
|
166
197
|
{restoring ? (
|
|
167
198
|
<div className="flex min-w-0 flex-1 items-center gap-3 p-2">
|
|
@@ -174,7 +205,10 @@ export function WorkspacePickerContent({
|
|
|
174
205
|
) : (
|
|
175
206
|
<Button
|
|
176
207
|
variant="ghost"
|
|
177
|
-
className=
|
|
208
|
+
className={cn(
|
|
209
|
+
'h-auto min-w-0 flex-1 justify-start gap-3 whitespace-normal text-start',
|
|
210
|
+
dropdown ? 'py-2' : 'py-3'
|
|
211
|
+
)}
|
|
178
212
|
aria-label={[
|
|
179
213
|
workspace.name ?? workspace.id,
|
|
180
214
|
workspace.tier ?? 'FREE',
|
|
@@ -268,26 +302,55 @@ export function WorkspacePickerContent({
|
|
|
268
302
|
</Button>
|
|
269
303
|
)}
|
|
270
304
|
</div>
|
|
271
|
-
<div
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
{!
|
|
305
|
+
<div
|
|
306
|
+
className={
|
|
307
|
+
dropdown
|
|
308
|
+
? restoring || !onCreate
|
|
309
|
+
? 'hidden'
|
|
310
|
+
: 'flex gap-2 border-t p-2'
|
|
311
|
+
: 'absolute right-4 bottom-[max(1rem,env(safe-area-inset-bottom))] flex gap-3 rounded-full border bg-background p-2 shadow-lg'
|
|
312
|
+
}
|
|
313
|
+
>
|
|
314
|
+
{!dropdown && (
|
|
281
315
|
<Button
|
|
282
316
|
size="icon"
|
|
283
317
|
className="rounded-full"
|
|
318
|
+
aria-label={t('search_workspace')}
|
|
319
|
+
onClick={openSearch}
|
|
320
|
+
>
|
|
321
|
+
<Search />
|
|
322
|
+
</Button>
|
|
323
|
+
)}
|
|
324
|
+
{!restoring && onCreate && (
|
|
325
|
+
<Button
|
|
326
|
+
size={dropdown ? 'sm' : 'icon'}
|
|
327
|
+
className={dropdown ? 'w-full justify-start' : 'rounded-full'}
|
|
284
328
|
aria-label={t('create_workspace_action')}
|
|
285
329
|
onClick={onCreate}
|
|
286
330
|
>
|
|
287
331
|
<Plus />
|
|
332
|
+
{dropdown && t('create_workspace_action')}
|
|
288
333
|
</Button>
|
|
289
334
|
)}
|
|
290
335
|
</div>
|
|
336
|
+
</>
|
|
337
|
+
);
|
|
338
|
+
return dropdown ? (
|
|
339
|
+
<PopoverContent
|
|
340
|
+
align="start"
|
|
341
|
+
aria-label={t(restoring ? 'hidden_workspaces' : 'workspaces')}
|
|
342
|
+
collisionPadding={8}
|
|
343
|
+
className="flex max-h-[min(32rem,var(--radix-popover-content-available-height))] w-80 max-w-[min(calc(100vw-1rem),var(--radix-popover-content-available-width))] flex-col overflow-hidden p-0"
|
|
344
|
+
>
|
|
345
|
+
{content}
|
|
346
|
+
</PopoverContent>
|
|
347
|
+
) : (
|
|
348
|
+
<DialogContent
|
|
349
|
+
presentation="fullscreen"
|
|
350
|
+
showCloseButton={false}
|
|
351
|
+
className="flex-col"
|
|
352
|
+
>
|
|
353
|
+
{content}
|
|
291
354
|
</DialogContent>
|
|
292
355
|
);
|
|
293
356
|
}
|
|
@@ -54,6 +54,7 @@ import {
|
|
|
54
54
|
FormMessage,
|
|
55
55
|
} from '../form';
|
|
56
56
|
import { Input } from '../input';
|
|
57
|
+
import { Popover, PopoverTrigger } from '../popover';
|
|
57
58
|
import { TUTURUUU_LOGO_URL } from './tuturuuu-logo';
|
|
58
59
|
import { WorkspacePickerContent } from './workspace-picker-content';
|
|
59
60
|
import {
|
|
@@ -93,6 +94,7 @@ export function WorkspaceSelect({
|
|
|
93
94
|
triggerClassName,
|
|
94
95
|
platformWorkspaceSetupUrl,
|
|
95
96
|
cacheScope,
|
|
97
|
+
presentation = 'dropdown',
|
|
96
98
|
}: {
|
|
97
99
|
wsId: string;
|
|
98
100
|
hideLeading?: boolean;
|
|
@@ -113,7 +115,11 @@ export function WorkspaceSelect({
|
|
|
113
115
|
platformWorkspaceSetupUrl?: string;
|
|
114
116
|
/** Authenticated identity used to isolate user-specific picker caches. */
|
|
115
117
|
cacheScope?: string;
|
|
118
|
+
presentation?: 'fullscreen' | 'dropdown';
|
|
116
119
|
}) {
|
|
120
|
+
const Picker = presentation === 'dropdown' ? Popover : Dialog;
|
|
121
|
+
const PickerTrigger =
|
|
122
|
+
presentation === 'dropdown' ? PopoverTrigger : DialogTrigger;
|
|
117
123
|
const t = useTranslations();
|
|
118
124
|
const locale = useLocale();
|
|
119
125
|
const router = useRouter();
|
|
@@ -417,8 +423,8 @@ export function WorkspaceSelect({
|
|
|
417
423
|
setShowNewWorkspaceDialog(open);
|
|
418
424
|
}}
|
|
419
425
|
>
|
|
420
|
-
<
|
|
421
|
-
<
|
|
426
|
+
<Picker open={open} onOpenChange={setOpen}>
|
|
427
|
+
<PickerTrigger asChild disabled={!hasSelectableWorkspaces}>
|
|
422
428
|
<Button
|
|
423
429
|
size="xs"
|
|
424
430
|
variant="outline"
|
|
@@ -485,8 +491,10 @@ export function WorkspaceSelect({
|
|
|
485
491
|
<ChevronDown className="ml-1 h-4 w-4 shrink-0 opacity-50" />
|
|
486
492
|
)}
|
|
487
493
|
</Button>
|
|
488
|
-
</
|
|
494
|
+
</PickerTrigger>
|
|
489
495
|
<WorkspacePickerContent
|
|
496
|
+
presentation={presentation}
|
|
497
|
+
onClose={() => setOpen(false)}
|
|
490
498
|
workspaces={workspaces ?? []}
|
|
491
499
|
currentId={workspace?.id}
|
|
492
500
|
defaultId={defaultWorkspaceId}
|
|
@@ -517,7 +525,7 @@ export function WorkspaceSelect({
|
|
|
517
525
|
</Command>
|
|
518
526
|
}
|
|
519
527
|
/>
|
|
520
|
-
</
|
|
528
|
+
</Picker>
|
|
521
529
|
<DialogContent>
|
|
522
530
|
<DialogHeader>
|
|
523
531
|
<DialogTitle>{t('common.create_workspace')}</DialogTitle>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
|
|
2
|
+
|
|
3
|
+
export function createCalendarDraftRecovery() {
|
|
4
|
+
let submitted:
|
|
5
|
+
| {
|
|
6
|
+
payload: Partial<CalendarEvent>;
|
|
7
|
+
requestId: string;
|
|
8
|
+
recoverable: boolean;
|
|
9
|
+
}
|
|
10
|
+
| undefined;
|
|
11
|
+
let recovered: CalendarEvent | undefined;
|
|
12
|
+
return {
|
|
13
|
+
original: () => recovered,
|
|
14
|
+
submitted: () => submitted,
|
|
15
|
+
capture(
|
|
16
|
+
payload: Partial<CalendarEvent>,
|
|
17
|
+
requestId: string,
|
|
18
|
+
recoverable: boolean
|
|
19
|
+
) {
|
|
20
|
+
// Snapshot once. An uncertain request never changes its ID or contents.
|
|
21
|
+
submitted ??= {
|
|
22
|
+
payload: structuredClone(payload),
|
|
23
|
+
requestId,
|
|
24
|
+
recoverable,
|
|
25
|
+
};
|
|
26
|
+
return submitted;
|
|
27
|
+
},
|
|
28
|
+
complete(event: CalendarEvent) {
|
|
29
|
+
if (!event.id || event.id === 'new')
|
|
30
|
+
throw new Error('Unconfirmed creation');
|
|
31
|
+
recovered = event;
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -25,11 +25,7 @@ import {
|
|
|
25
25
|
Unlock,
|
|
26
26
|
X,
|
|
27
27
|
} from '@tuturuuu/icons';
|
|
28
|
-
import {
|
|
29
|
-
type CalendarSourceInput,
|
|
30
|
-
type CalendarSourceOption,
|
|
31
|
-
getWorkspaceCalendarDefaultSource,
|
|
32
|
-
} from '@tuturuuu/internal-api';
|
|
28
|
+
import { getWorkspaceCalendarDefaultSource } from '@tuturuuu/internal-api';
|
|
33
29
|
import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
|
|
34
30
|
import type { SupportedColor } from '@tuturuuu/types/primitives/SupportedColors';
|
|
35
31
|
import { Badge } from '@tuturuuu/ui/badge';
|
|
@@ -44,6 +40,7 @@ import {
|
|
|
44
40
|
FormLabel,
|
|
45
41
|
FormMessage,
|
|
46
42
|
} from '@tuturuuu/ui/form';
|
|
43
|
+
import { newCalendarCreationRequestId } from '@tuturuuu/ui/hooks/calendar-creation-request';
|
|
47
44
|
import { useCalendar } from '@tuturuuu/ui/hooks/use-calendar';
|
|
48
45
|
import { useForm } from '@tuturuuu/ui/hooks/use-form';
|
|
49
46
|
import { notifySave } from '@tuturuuu/ui/save-notification';
|
|
@@ -76,13 +73,12 @@ import {
|
|
|
76
73
|
useState,
|
|
77
74
|
} from 'react';
|
|
78
75
|
import { z } from 'zod';
|
|
76
|
+
import { useCalendarDraftSave } from './use-calendar-draft-save';
|
|
79
77
|
import { Alert, AlertDescription, AlertTitle } from '../../alert';
|
|
80
78
|
import { AutosizeTextarea } from '../../custom/autosize-textarea';
|
|
81
|
-
import { getCalendarMeetingMetadata } from './calendar-meeting-link';
|
|
82
79
|
import {
|
|
83
80
|
COLOR_OPTIONS,
|
|
84
81
|
DateError,
|
|
85
|
-
EventColorPicker,
|
|
86
82
|
EventDateTimePicker,
|
|
87
83
|
EventDescriptionInput,
|
|
88
84
|
EventLocationInput,
|
|
@@ -92,7 +88,17 @@ import {
|
|
|
92
88
|
} from './event-form-components';
|
|
93
89
|
import { EventModalHeader } from './event-modal-header';
|
|
94
90
|
import { eventEndPickerBounds } from './event-picker-bounds';
|
|
95
|
-
import {
|
|
91
|
+
import { eventModalDraft, eventModalSavePayload } from './event-save-payload';
|
|
92
|
+
import {
|
|
93
|
+
eventSourceChanged,
|
|
94
|
+
selectedEventSource,
|
|
95
|
+
sourceInputFromOption,
|
|
96
|
+
} from './event-source-selection';
|
|
97
|
+
import { ProviderColorPicker } from './provider-color-picker';
|
|
98
|
+
import {
|
|
99
|
+
prepareCalendarEventDrafts,
|
|
100
|
+
saveCalendarEventDrafts,
|
|
101
|
+
} from './save-calendar-event-drafts';
|
|
96
102
|
import { useCalendarSettings } from './settings/settings-context';
|
|
97
103
|
import { useEventDraftSession } from './use-event-draft-session';
|
|
98
104
|
|
|
@@ -107,53 +113,6 @@ const AIFormSchema = z.object({
|
|
|
107
113
|
smart_scheduling: z.boolean().default(true),
|
|
108
114
|
});
|
|
109
115
|
|
|
110
|
-
function sourceInputFromOption(
|
|
111
|
-
option?: CalendarSourceOption | null
|
|
112
|
-
): CalendarSourceInput | undefined {
|
|
113
|
-
if (!option) return undefined;
|
|
114
|
-
|
|
115
|
-
if (option.provider === 'tuturuuu') {
|
|
116
|
-
return {
|
|
117
|
-
provider: 'tuturuuu',
|
|
118
|
-
workspaceCalendarId: option.workspaceCalendarId,
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
return {
|
|
123
|
-
provider: option.provider,
|
|
124
|
-
connectionId: option.connectionId,
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function findEventSourceOption(
|
|
129
|
-
options: CalendarSourceOption[],
|
|
130
|
-
event: Partial<CalendarEvent>
|
|
131
|
-
) {
|
|
132
|
-
if (event.provider === 'google' || event.provider === 'microsoft') {
|
|
133
|
-
return options.find((option) => {
|
|
134
|
-
if (option.provider === 'tuturuuu') return false;
|
|
135
|
-
if (option.provider !== event.provider) return false;
|
|
136
|
-
if (
|
|
137
|
-
event.source_calendar_id &&
|
|
138
|
-
option.workspaceCalendarId === event.source_calendar_id
|
|
139
|
-
) {
|
|
140
|
-
return true;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
return (
|
|
144
|
-
option.externalCalendarId ===
|
|
145
|
-
(event.external_calendar_id ?? event.google_calendar_id)
|
|
146
|
-
);
|
|
147
|
-
});
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
return options.find(
|
|
151
|
-
(option) =>
|
|
152
|
-
option.provider === 'tuturuuu' &&
|
|
153
|
-
option.workspaceCalendarId === event.source_calendar_id
|
|
154
|
-
);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
116
|
export function EventModal() {
|
|
158
117
|
const toast = notifySave;
|
|
159
118
|
const startPickerRef = useRef<HTMLButtonElement>(null);
|
|
@@ -195,11 +154,12 @@ export function EventModal() {
|
|
|
195
154
|
() => sourceData?.options ?? [],
|
|
196
155
|
[sourceData?.options]
|
|
197
156
|
);
|
|
198
|
-
const selectedSourceOption =
|
|
199
|
-
sourceOptions
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
157
|
+
const selectedSourceOption = selectedEventSource(
|
|
158
|
+
sourceOptions,
|
|
159
|
+
event,
|
|
160
|
+
selectedSourceId,
|
|
161
|
+
sourceData?.defaultSource
|
|
162
|
+
);
|
|
203
163
|
|
|
204
164
|
// State for AI event generation
|
|
205
165
|
const [generatedEvents, setGeneratedEvents] =
|
|
@@ -284,7 +244,7 @@ export function EventModal() {
|
|
|
284
244
|
|
|
285
245
|
const receiveGeneratedEvents = useEffectEvent(() => {
|
|
286
246
|
const processedEvents = (object?.events || []) as Partial<CalendarEvent>[];
|
|
287
|
-
setGeneratedEvents(processedEvents);
|
|
247
|
+
setGeneratedEvents(prepareCalendarEventDrafts(processedEvents));
|
|
288
248
|
setCurrentEventIndex(0);
|
|
289
249
|
const firstEvent = processedEvents[0];
|
|
290
250
|
if (
|
|
@@ -307,32 +267,16 @@ export function EventModal() {
|
|
|
307
267
|
workspaceId: activeEvent?.ws_id,
|
|
308
268
|
initialize: () => {
|
|
309
269
|
if (activeEvent) {
|
|
310
|
-
const cleanEventData
|
|
311
|
-
id: activeEvent.id,
|
|
312
|
-
title: activeEvent.title || '',
|
|
313
|
-
description: activeEvent.description || '',
|
|
314
|
-
start_at: activeEvent.start_at,
|
|
315
|
-
end_at: activeEvent.end_at,
|
|
316
|
-
color: activeEvent.color || 'BLUE',
|
|
317
|
-
location: activeEvent.location || '',
|
|
318
|
-
locked: activeEvent.locked || false,
|
|
319
|
-
ws_id: activeEvent.ws_id,
|
|
320
|
-
provider: activeEvent.provider,
|
|
321
|
-
source_calendar_id: activeEvent.source_calendar_id,
|
|
322
|
-
external_calendar_id: activeEvent.external_calendar_id,
|
|
323
|
-
external_event_id: activeEvent.external_event_id,
|
|
324
|
-
google_event_id: activeEvent.google_event_id,
|
|
325
|
-
google_calendar_id: activeEvent.google_calendar_id,
|
|
326
|
-
scheduling_metadata: getCalendarMeetingMetadata(activeEvent),
|
|
327
|
-
};
|
|
270
|
+
const cleanEventData = eventModalDraft(activeEvent);
|
|
328
271
|
|
|
329
272
|
setEvent(cleanEventData);
|
|
330
|
-
const sourceOption = findEventSourceOption(
|
|
331
|
-
sourceOptions,
|
|
332
|
-
cleanEventData
|
|
333
|
-
);
|
|
334
273
|
setSelectedSourceId(
|
|
335
|
-
|
|
274
|
+
selectedEventSource(
|
|
275
|
+
sourceOptions,
|
|
276
|
+
cleanEventData,
|
|
277
|
+
null,
|
|
278
|
+
sourceData?.defaultSource
|
|
279
|
+
)?.id ?? null
|
|
336
280
|
);
|
|
337
281
|
|
|
338
282
|
if (activeEvent.id !== 'new') {
|
|
@@ -350,6 +294,7 @@ export function EventModal() {
|
|
|
350
294
|
const oneHourLater = new Date(now.getTime() + 60 * 60 * 1000);
|
|
351
295
|
|
|
352
296
|
const newEvent = {
|
|
297
|
+
requestId: newCalendarCreationRequestId(),
|
|
353
298
|
title: '',
|
|
354
299
|
description: '',
|
|
355
300
|
start_at: now.toISOString(),
|
|
@@ -372,61 +317,38 @@ export function EventModal() {
|
|
|
372
317
|
},
|
|
373
318
|
});
|
|
374
319
|
|
|
375
|
-
|
|
320
|
+
const draftSave = useCalendarDraftSave({
|
|
321
|
+
draft: event,
|
|
322
|
+
original: activeEvent,
|
|
323
|
+
buildPayload: () =>
|
|
324
|
+
eventModalSavePayload(
|
|
325
|
+
event,
|
|
326
|
+
activeEvent,
|
|
327
|
+
sourceInputFromOption(selectedSourceOption),
|
|
328
|
+
eventSourceChanged(sourceOptions, activeEvent ?? {}, selectedSourceId)
|
|
329
|
+
),
|
|
330
|
+
addEvent,
|
|
331
|
+
updateEvent,
|
|
332
|
+
closeModal,
|
|
333
|
+
setIsSaving,
|
|
334
|
+
isSaving,
|
|
335
|
+
source: selectedSourceOption,
|
|
336
|
+
wsId,
|
|
337
|
+
onError: () =>
|
|
338
|
+
toast({
|
|
339
|
+
title: 'Error',
|
|
340
|
+
description: 'Failed to save or sync event. Please try again.',
|
|
341
|
+
variant: 'destructive',
|
|
342
|
+
}),
|
|
343
|
+
});
|
|
376
344
|
const handleManualSave = async () => {
|
|
377
345
|
if (!event.start_at || !event.end_at) return;
|
|
378
|
-
|
|
379
|
-
const startDate = new Date(event.start_at);
|
|
380
|
-
const endDate = new Date(event.end_at);
|
|
381
|
-
|
|
382
|
-
if (endDate <= startDate) {
|
|
346
|
+
if (new Date(event.end_at) <= new Date(event.start_at)) {
|
|
383
347
|
setDateError('End date must be after start date');
|
|
384
348
|
return;
|
|
385
349
|
}
|
|
386
|
-
|
|
387
350
|
setDateError(null);
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
try {
|
|
391
|
-
// Clean event data to only include fields that should be updated
|
|
392
|
-
const eventData: Partial<CalendarEvent> = {
|
|
393
|
-
title: event.title || '',
|
|
394
|
-
description: event.description || '',
|
|
395
|
-
start_at: event.start_at,
|
|
396
|
-
end_at: event.end_at,
|
|
397
|
-
color: event.color || 'BLUE',
|
|
398
|
-
location: event.location || '',
|
|
399
|
-
locked: event.locked || false,
|
|
400
|
-
source: sourceInputFromOption(selectedSourceOption),
|
|
401
|
-
};
|
|
402
|
-
|
|
403
|
-
if (activeEvent?.id === 'new') {
|
|
404
|
-
const saved = await addEvent(eventData as Omit<CalendarEvent, 'id'>);
|
|
405
|
-
if (!saved?.id) throw new Error();
|
|
406
|
-
} else if (activeEvent?.id) {
|
|
407
|
-
// For multi-day events, always use the original event ID
|
|
408
|
-
// The activeEvent should already contain the original event from the database
|
|
409
|
-
const eventId = activeEvent.id;
|
|
410
|
-
|
|
411
|
-
if (eventId && eventId !== 'new') {
|
|
412
|
-
await updateEvent(eventId, eventData);
|
|
413
|
-
} else {
|
|
414
|
-
throw new Error('Invalid event ID');
|
|
415
|
-
}
|
|
416
|
-
} else {
|
|
417
|
-
throw new Error('No event to save');
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
closeModal();
|
|
421
|
-
} catch (_) {
|
|
422
|
-
toast({
|
|
423
|
-
title: 'Error',
|
|
424
|
-
description: 'Failed to save or sync event. Please try again.',
|
|
425
|
-
variant: 'destructive',
|
|
426
|
-
});
|
|
427
|
-
} finally {
|
|
428
|
-
setIsSaving(false);
|
|
429
|
-
}
|
|
351
|
+
await draftSave.save();
|
|
430
352
|
};
|
|
431
353
|
|
|
432
354
|
// Handle AI event generation
|
|
@@ -993,7 +915,13 @@ export function EventModal() {
|
|
|
993
915
|
</div>
|
|
994
916
|
<Select
|
|
995
917
|
value={selectedSourceOption?.id}
|
|
996
|
-
onValueChange={(value) =>
|
|
918
|
+
onValueChange={(value) => {
|
|
919
|
+
setSelectedSourceId(value);
|
|
920
|
+
setEvent((previous) => ({
|
|
921
|
+
...previous,
|
|
922
|
+
providerColor: undefined,
|
|
923
|
+
}));
|
|
924
|
+
}}
|
|
997
925
|
disabled={sourceOptions.length === 0 || isSaving}
|
|
998
926
|
>
|
|
999
927
|
<SelectTrigger id="event-source" className="w-full">
|
|
@@ -1087,10 +1015,21 @@ export function EventModal() {
|
|
|
1087
1015
|
|
|
1088
1016
|
{/* Color and Options Row */}
|
|
1089
1017
|
<div className="flex items-end justify-between gap-4">
|
|
1090
|
-
<
|
|
1018
|
+
<ProviderColorPicker
|
|
1019
|
+
wsId={wsId}
|
|
1020
|
+
source={selectedSourceOption}
|
|
1091
1021
|
value={event.color || 'BLUE'}
|
|
1092
|
-
|
|
1093
|
-
|
|
1022
|
+
metadata={event.scheduling_metadata}
|
|
1023
|
+
choice={event.providerColor}
|
|
1024
|
+
onNativeChange={(value) =>
|
|
1025
|
+
setEvent({
|
|
1026
|
+
...event,
|
|
1027
|
+
color: value,
|
|
1028
|
+
providerColor: undefined,
|
|
1029
|
+
})
|
|
1030
|
+
}
|
|
1031
|
+
onProviderChange={(providerColor) =>
|
|
1032
|
+
setEvent({ ...event, providerColor })
|
|
1094
1033
|
}
|
|
1095
1034
|
/>
|
|
1096
1035
|
<button
|
|
@@ -1147,6 +1086,7 @@ export function EventModal() {
|
|
|
1147
1086
|
|
|
1148
1087
|
{/* Action Buttons */}
|
|
1149
1088
|
<div className="mt-auto border-t p-2">
|
|
1089
|
+
{draftSave.recoveryNotice}
|
|
1150
1090
|
<div className="flex justify-between">
|
|
1151
1091
|
{isEditing ? (
|
|
1152
1092
|
<Button
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
|
|
2
|
+
import { expect, it } from 'vitest';
|
|
3
|
+
import { calendarEventUpdatePayload } from '../../../../hooks/calendar-event-write-payload';
|
|
4
|
+
import { eventSavePayload } from './event-save-payload';
|
|
5
|
+
|
|
6
|
+
const original = {
|
|
7
|
+
id: 'saved',
|
|
8
|
+
title: 'Synthetic',
|
|
9
|
+
description: '',
|
|
10
|
+
location: '',
|
|
11
|
+
start_at: '2026-10-02T10:00:00Z',
|
|
12
|
+
end_at: '2026-10-02T11:00:00Z',
|
|
13
|
+
color: 'BLUE',
|
|
14
|
+
locked: false,
|
|
15
|
+
} as CalendarEvent;
|
|
16
|
+
it('sends only a provider color command for an unchanged existing event', () => {
|
|
17
|
+
const providerColor = { connectionId: 'owned', kind: 'inherit' as const };
|
|
18
|
+
const payload = eventSavePayload(
|
|
19
|
+
{
|
|
20
|
+
...original,
|
|
21
|
+
providerColor,
|
|
22
|
+
source: { provider: 'google', connectionId: 'owned' },
|
|
23
|
+
},
|
|
24
|
+
original,
|
|
25
|
+
false
|
|
26
|
+
);
|
|
27
|
+
expect(payload).toEqual({ providerColor });
|
|
28
|
+
expect(calendarEventUpdatePayload(payload)).toEqual({ providerColor });
|
|
29
|
+
});
|
|
30
|
+
it('does not include unchanged native color or source with an ordinary title edit', () => {
|
|
31
|
+
expect(eventSavePayload({ ...original, title: 'Changed' }, original)).toEqual(
|
|
32
|
+
{ title: 'Changed' }
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
it('includes a calendar transfer only when its source actually changed', () => {
|
|
36
|
+
const source = { provider: 'google' as const, connectionId: 'new' };
|
|
37
|
+
expect(eventSavePayload({ ...original, source }, original, true)).toEqual({
|
|
38
|
+
source,
|
|
39
|
+
});
|
|
40
|
+
});
|