@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
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
|
|
2
|
+
import { newCalendarCreationRequestId } from '../../../../hooks/calendar-creation-request';
|
|
3
|
+
|
|
4
|
+
/** Existing edits send only changed fields so a color-only command neither
|
|
5
|
+
* broadcasts invitations nor accidentally becomes a calendar transfer. */
|
|
6
|
+
export function eventSavePayload(
|
|
7
|
+
draft: Partial<CalendarEvent>,
|
|
8
|
+
original?: Partial<CalendarEvent>,
|
|
9
|
+
sourceChanged = false
|
|
10
|
+
) {
|
|
11
|
+
if (!original || original.id === 'new') return draft;
|
|
12
|
+
const updates: Partial<CalendarEvent> = {};
|
|
13
|
+
for (const field of [
|
|
14
|
+
'title',
|
|
15
|
+
'description',
|
|
16
|
+
'location',
|
|
17
|
+
'start_at',
|
|
18
|
+
'end_at',
|
|
19
|
+
'locked',
|
|
20
|
+
] as const) {
|
|
21
|
+
if (
|
|
22
|
+
(draft[field] ?? (field === 'locked' ? false : '')) !==
|
|
23
|
+
(original[field] ?? (field === 'locked' ? false : ''))
|
|
24
|
+
)
|
|
25
|
+
Object.assign(updates, { [field]: draft[field] });
|
|
26
|
+
}
|
|
27
|
+
if (draft.providerColor) updates.providerColor = draft.providerColor;
|
|
28
|
+
else if (draft.color !== original.color) updates.color = draft.color;
|
|
29
|
+
if (sourceChanged) updates.source = draft.source;
|
|
30
|
+
return updates;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function eventModalSavePayload(
|
|
34
|
+
draft: Partial<CalendarEvent>,
|
|
35
|
+
original: Partial<CalendarEvent> | undefined,
|
|
36
|
+
source: CalendarEvent['source'],
|
|
37
|
+
sourceChanged: boolean
|
|
38
|
+
) {
|
|
39
|
+
return eventSavePayload(
|
|
40
|
+
{
|
|
41
|
+
title: draft.title || '',
|
|
42
|
+
description: draft.description || '',
|
|
43
|
+
start_at: draft.start_at,
|
|
44
|
+
end_at: draft.end_at,
|
|
45
|
+
color: draft.color || 'BLUE',
|
|
46
|
+
location: draft.location || '',
|
|
47
|
+
locked: draft.locked || false,
|
|
48
|
+
source,
|
|
49
|
+
providerColor: draft.providerColor,
|
|
50
|
+
},
|
|
51
|
+
original,
|
|
52
|
+
sourceChanged
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function eventModalDraft(
|
|
57
|
+
activeEvent: CalendarEvent
|
|
58
|
+
): Partial<CalendarEvent> {
|
|
59
|
+
return {
|
|
60
|
+
id: activeEvent.id,
|
|
61
|
+
requestId:
|
|
62
|
+
activeEvent.id === 'new' ? newCalendarCreationRequestId() : undefined,
|
|
63
|
+
title: activeEvent.title || '',
|
|
64
|
+
description: activeEvent.description || '',
|
|
65
|
+
start_at: activeEvent.start_at,
|
|
66
|
+
end_at: activeEvent.end_at,
|
|
67
|
+
color: activeEvent.color || 'BLUE',
|
|
68
|
+
location: activeEvent.location || '',
|
|
69
|
+
locked: activeEvent.locked || false,
|
|
70
|
+
ws_id: activeEvent.ws_id,
|
|
71
|
+
provider: activeEvent.provider,
|
|
72
|
+
source_calendar_id: activeEvent.source_calendar_id,
|
|
73
|
+
external_calendar_id: activeEvent.external_calendar_id,
|
|
74
|
+
external_event_id: activeEvent.external_event_id,
|
|
75
|
+
google_event_id: activeEvent.google_event_id,
|
|
76
|
+
google_calendar_id: activeEvent.google_calendar_id,
|
|
77
|
+
scheduling_metadata: activeEvent.scheduling_metadata,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { CalendarSourceOption } from '@tuturuuu/internal-api';
|
|
2
|
+
import { expect, it } from 'vitest';
|
|
3
|
+
import { eventModalSavePayload } from './event-save-payload';
|
|
4
|
+
import {
|
|
5
|
+
eventSourceChanged,
|
|
6
|
+
findEventSourceOption,
|
|
7
|
+
selectedEventSource,
|
|
8
|
+
} from './event-source-selection';
|
|
9
|
+
|
|
10
|
+
const options = ['one', 'two'].map((id) => ({
|
|
11
|
+
id,
|
|
12
|
+
provider: 'google',
|
|
13
|
+
connectionId: id,
|
|
14
|
+
workspaceCalendarId: id,
|
|
15
|
+
externalCalendarId: 'shared-provider-calendar',
|
|
16
|
+
})) as CalendarSourceOption[];
|
|
17
|
+
it('matches the canonical workspace source despite identical provider calendar IDs', () => {
|
|
18
|
+
expect(
|
|
19
|
+
findEventSourceOption(options, {
|
|
20
|
+
provider: 'google',
|
|
21
|
+
source_calendar_id: 'two',
|
|
22
|
+
external_calendar_id: 'shared-provider-calendar',
|
|
23
|
+
})?.id
|
|
24
|
+
).toBe('two');
|
|
25
|
+
expect(
|
|
26
|
+
findEventSourceOption(options, {
|
|
27
|
+
provider: 'google',
|
|
28
|
+
source_calendar_id: 'missing',
|
|
29
|
+
external_calendar_id: 'shared-provider-calendar',
|
|
30
|
+
})
|
|
31
|
+
).toBeUndefined();
|
|
32
|
+
});
|
|
33
|
+
it('fails closed when a legacy external calendar ID is ambiguous across accounts', () => {
|
|
34
|
+
expect(
|
|
35
|
+
findEventSourceOption(options, {
|
|
36
|
+
provider: 'google',
|
|
37
|
+
external_calendar_id: 'shared-provider-calendar',
|
|
38
|
+
})
|
|
39
|
+
).toBeUndefined();
|
|
40
|
+
expect(
|
|
41
|
+
findEventSourceOption(options.slice(0, 1), {
|
|
42
|
+
provider: 'google',
|
|
43
|
+
external_calendar_id: 'shared-provider-calendar',
|
|
44
|
+
})?.id
|
|
45
|
+
).toBe('one');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('leaves an unresolved existing event on its original source and preserves new defaults', () => {
|
|
49
|
+
const original = {
|
|
50
|
+
id: 'saved',
|
|
51
|
+
provider: 'google' as const,
|
|
52
|
+
source_calendar_id: 'missing',
|
|
53
|
+
external_calendar_id: 'shared-provider-calendar',
|
|
54
|
+
};
|
|
55
|
+
expect(
|
|
56
|
+
selectedEventSource(options, original, null, options[0])
|
|
57
|
+
).toBeUndefined();
|
|
58
|
+
expect(eventSourceChanged(options, original, null)).toBe(false);
|
|
59
|
+
expect(
|
|
60
|
+
selectedEventSource(options, { id: 'new' }, null, options[0])?.id
|
|
61
|
+
).toBe('one');
|
|
62
|
+
expect(selectedEventSource(options, original, 'two', options[0])?.id).toBe(
|
|
63
|
+
'two'
|
|
64
|
+
);
|
|
65
|
+
expect(eventSourceChanged(options, original, 'two')).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('sends only a title edit when an existing source cannot be resolved', () => {
|
|
69
|
+
const original = {
|
|
70
|
+
id: 'saved',
|
|
71
|
+
title: 'Original',
|
|
72
|
+
color: 'BLUE' as const,
|
|
73
|
+
provider: 'google' as const,
|
|
74
|
+
source_calendar_id: 'missing',
|
|
75
|
+
start_at: '2026-10-02T10:00:00Z',
|
|
76
|
+
end_at: '2026-10-02T11:00:00Z',
|
|
77
|
+
};
|
|
78
|
+
const selected = selectedEventSource(options, original, null, options[0]);
|
|
79
|
+
expect(
|
|
80
|
+
eventModalSavePayload(
|
|
81
|
+
{ ...original, title: 'Changed' },
|
|
82
|
+
original,
|
|
83
|
+
selected?.provider === 'google'
|
|
84
|
+
? { provider: 'google', connectionId: selected.connectionId }
|
|
85
|
+
: undefined,
|
|
86
|
+
eventSourceChanged(options, original, null)
|
|
87
|
+
)
|
|
88
|
+
).toEqual({ title: 'Changed' });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('recognizes a legacy Google event before considering native sources', () => {
|
|
92
|
+
const legacy = { google_event_id: 'external', source_calendar_id: 'two' };
|
|
93
|
+
expect(findEventSourceOption(options, legacy)?.id).toBe('two');
|
|
94
|
+
expect(eventSourceChanged(options, legacy, 'two')).toBe(false);
|
|
95
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CalendarSourceInput,
|
|
3
|
+
CalendarSourceOption,
|
|
4
|
+
} from '@tuturuuu/internal-api';
|
|
5
|
+
import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
|
|
6
|
+
|
|
7
|
+
export function sourceInputFromOption(
|
|
8
|
+
option?: CalendarSourceOption | null
|
|
9
|
+
): CalendarSourceInput | undefined {
|
|
10
|
+
if (!option) return undefined;
|
|
11
|
+
|
|
12
|
+
if (option.provider === 'tuturuuu') {
|
|
13
|
+
return {
|
|
14
|
+
provider: 'tuturuuu',
|
|
15
|
+
workspaceCalendarId: option.workspaceCalendarId,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
provider: option.provider,
|
|
21
|
+
connectionId: option.connectionId,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function findEventSourceOption(
|
|
26
|
+
options: CalendarSourceOption[],
|
|
27
|
+
event: Partial<CalendarEvent>
|
|
28
|
+
) {
|
|
29
|
+
const provider =
|
|
30
|
+
event.provider ?? (event.google_event_id ? 'google' : undefined);
|
|
31
|
+
if (provider === 'google' || provider === 'microsoft') {
|
|
32
|
+
const matches = options.filter((option) => {
|
|
33
|
+
if (option.provider === 'tuturuuu' || option.provider !== provider)
|
|
34
|
+
return false;
|
|
35
|
+
if (event.source_calendar_id)
|
|
36
|
+
return option.workspaceCalendarId === event.source_calendar_id;
|
|
37
|
+
return (
|
|
38
|
+
option.externalCalendarId ===
|
|
39
|
+
(event.external_calendar_id ?? event.google_calendar_id)
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
// Legacy rows without a canonical source must not choose another account
|
|
43
|
+
// merely because it contains the same provider calendar ID.
|
|
44
|
+
return matches.length === 1 ? matches[0] : undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return options.find(
|
|
48
|
+
(option) =>
|
|
49
|
+
option.provider === 'tuturuuu' &&
|
|
50
|
+
option.workspaceCalendarId === event.source_calendar_id
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function selectedEventSource(
|
|
55
|
+
options: CalendarSourceOption[],
|
|
56
|
+
event: Partial<CalendarEvent>,
|
|
57
|
+
selectedId: string | null,
|
|
58
|
+
defaultSource?: CalendarSourceOption
|
|
59
|
+
) {
|
|
60
|
+
const selected = options.find((option) => option.id === selectedId);
|
|
61
|
+
const existing = !!event.id && event.id !== 'new';
|
|
62
|
+
return (
|
|
63
|
+
selected ??
|
|
64
|
+
findEventSourceOption(options, event) ??
|
|
65
|
+
(existing ? undefined : (defaultSource ?? options[0]))
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
export function eventSourceChanged(
|
|
69
|
+
options: CalendarSourceOption[],
|
|
70
|
+
event: Partial<CalendarEvent>,
|
|
71
|
+
selectedId: string | null
|
|
72
|
+
) {
|
|
73
|
+
return (
|
|
74
|
+
selectedId !== null &&
|
|
75
|
+
options.some((option) => option.id === selectedId) &&
|
|
76
|
+
selectedId !== findEventSourceOption(options, event)?.id
|
|
77
|
+
);
|
|
78
|
+
}
|