@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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
3
|
+
import type { GoogleProviderColorOptions } from '@tuturuuu/types/primitives/google-calendar-color';
|
|
4
|
+
import type { ComponentProps, ReactNode } from 'react';
|
|
5
|
+
import { beforeEach, expect, it, vi } from 'vitest';
|
|
6
|
+
import { ProviderColorPicker } from './provider-color-picker';
|
|
7
|
+
|
|
8
|
+
const mocks = vi.hoisted(() => ({ query: vi.fn(), get: vi.fn() }));
|
|
9
|
+
vi.mock('@tanstack/react-query', () => ({ useQuery: mocks.query }));
|
|
10
|
+
vi.mock('@tuturuuu/internal-api', () => ({
|
|
11
|
+
getGoogleCalendarColorOptions: mocks.get,
|
|
12
|
+
}));
|
|
13
|
+
vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
|
|
14
|
+
vi.mock('@tuturuuu/ui/label', () => ({
|
|
15
|
+
Label: ({ children }: { children: ReactNode }) => <span>{children}</span>,
|
|
16
|
+
}));
|
|
17
|
+
vi.mock('./event-form-components', () => ({
|
|
18
|
+
EventColorPicker: () => <div>Legacy palette</div>,
|
|
19
|
+
}));
|
|
20
|
+
vi.mock('@tuturuuu/ui/select', () => ({
|
|
21
|
+
Select: ({
|
|
22
|
+
children,
|
|
23
|
+
value,
|
|
24
|
+
onValueChange,
|
|
25
|
+
}: {
|
|
26
|
+
children: ReactNode;
|
|
27
|
+
value?: string;
|
|
28
|
+
onValueChange: (value: string) => void;
|
|
29
|
+
}) => (
|
|
30
|
+
<select
|
|
31
|
+
aria-label="Provider palette"
|
|
32
|
+
value={value}
|
|
33
|
+
onChange={(event) => onValueChange(event.target.value)}
|
|
34
|
+
>
|
|
35
|
+
{children}
|
|
36
|
+
</select>
|
|
37
|
+
),
|
|
38
|
+
SelectContent: ({ children }: { children: ReactNode }) => children,
|
|
39
|
+
SelectTrigger: () => null,
|
|
40
|
+
SelectValue: () => null,
|
|
41
|
+
SelectItem: ({ children, value }: { children: ReactNode; value: string }) => (
|
|
42
|
+
<option value={value}>{children}</option>
|
|
43
|
+
),
|
|
44
|
+
}));
|
|
45
|
+
const options: GoogleProviderColorOptions = {
|
|
46
|
+
provider: 'google',
|
|
47
|
+
providerColorWrites: true,
|
|
48
|
+
connectionId: 'owned',
|
|
49
|
+
calendarId: 'source',
|
|
50
|
+
sourceColor: { background: '#123456', foreground: '#ffffff' },
|
|
51
|
+
options: [
|
|
52
|
+
{
|
|
53
|
+
kind: 'inherit',
|
|
54
|
+
id: null,
|
|
55
|
+
name: null,
|
|
56
|
+
background: '#123456',
|
|
57
|
+
foreground: null,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
kind: 'event',
|
|
61
|
+
id: '3',
|
|
62
|
+
name: null,
|
|
63
|
+
background: '#987654',
|
|
64
|
+
foreground: null,
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
kind: 'label',
|
|
68
|
+
id: 'label-id',
|
|
69
|
+
name: 'Synthetic label',
|
|
70
|
+
background: '#abcdef',
|
|
71
|
+
foreground: null,
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
};
|
|
75
|
+
const props = (): ComponentProps<typeof ProviderColorPicker> => ({
|
|
76
|
+
wsId: 'workspace',
|
|
77
|
+
source: { id: 'source', provider: 'google', connectionId: 'owned' } as never,
|
|
78
|
+
value: 'BLUE',
|
|
79
|
+
onNativeChange: vi.fn(),
|
|
80
|
+
onProviderChange: vi.fn(),
|
|
81
|
+
});
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
vi.clearAllMocks();
|
|
84
|
+
mocks.query.mockReturnValue({ data: options });
|
|
85
|
+
});
|
|
86
|
+
it('keeps the legacy picker when capability is disabled', () => {
|
|
87
|
+
mocks.query.mockReturnValue({
|
|
88
|
+
data: { ...options, providerColorWrites: false },
|
|
89
|
+
});
|
|
90
|
+
render(<ProviderColorPicker {...props()} />);
|
|
91
|
+
expect(screen.getByText('Legacy palette')).toBeTruthy();
|
|
92
|
+
expect(screen.queryByRole('combobox')).toBeNull();
|
|
93
|
+
});
|
|
94
|
+
it('keeps another connection palette from supplying choices', () => {
|
|
95
|
+
mocks.query.mockReturnValue({
|
|
96
|
+
data: { ...options, connectionId: 'foreign' },
|
|
97
|
+
});
|
|
98
|
+
render(<ProviderColorPicker {...props()} />);
|
|
99
|
+
expect(screen.getByText('Legacy palette')).toBeTruthy();
|
|
100
|
+
});
|
|
101
|
+
it('sends exact provider namespace and ID for label and inherited choices', () => {
|
|
102
|
+
const input = props();
|
|
103
|
+
render(<ProviderColorPicker {...input} />);
|
|
104
|
+
fireEvent.change(screen.getByRole('combobox'), {
|
|
105
|
+
target: { value: 'label:label-id' },
|
|
106
|
+
});
|
|
107
|
+
expect(input.onProviderChange).toHaveBeenLastCalledWith({
|
|
108
|
+
connectionId: 'owned',
|
|
109
|
+
kind: 'label',
|
|
110
|
+
id: 'label-id',
|
|
111
|
+
});
|
|
112
|
+
fireEvent.change(screen.getByRole('combobox'), {
|
|
113
|
+
target: { value: 'inherit' },
|
|
114
|
+
});
|
|
115
|
+
expect(input.onProviderChange).toHaveBeenLastCalledWith({
|
|
116
|
+
connectionId: 'owned',
|
|
117
|
+
kind: 'inherit',
|
|
118
|
+
});
|
|
119
|
+
expect(input.onNativeChange).not.toHaveBeenCalled();
|
|
120
|
+
mocks.query.mock.calls[0]?.[0].queryFn();
|
|
121
|
+
expect(mocks.get).toHaveBeenCalledWith('workspace', 'owned');
|
|
122
|
+
});
|
|
123
|
+
it('initializes an imported label identity rather than the compatibility color', () => {
|
|
124
|
+
render(
|
|
125
|
+
<ProviderColorPicker
|
|
126
|
+
{...props()}
|
|
127
|
+
metadata={{
|
|
128
|
+
google_color: {
|
|
129
|
+
version: 1,
|
|
130
|
+
event_label_id: 'label-id',
|
|
131
|
+
inherited: false,
|
|
132
|
+
resolved_rgb: '#abcdef',
|
|
133
|
+
},
|
|
134
|
+
}}
|
|
135
|
+
/>
|
|
136
|
+
);
|
|
137
|
+
expect((screen.getByRole('combobox') as HTMLSelectElement).value).toBe(
|
|
138
|
+
'label:label-id'
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('keeps the legacy picker while query data is unavailable', () => {
|
|
143
|
+
mocks.query.mockReturnValue({ data: undefined });
|
|
144
|
+
render(<ProviderColorPicker {...props()} />);
|
|
145
|
+
expect(screen.getByText('Legacy palette')).toBeTruthy();
|
|
146
|
+
expect(screen.queryByRole('combobox')).toBeNull();
|
|
147
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useQuery } from '@tanstack/react-query';
|
|
3
|
+
import {
|
|
4
|
+
type CalendarSourceOption,
|
|
5
|
+
type GoogleProviderColorChoice,
|
|
6
|
+
getGoogleCalendarColorOptions,
|
|
7
|
+
} from '@tuturuuu/internal-api';
|
|
8
|
+
import type { SupportedColor } from '@tuturuuu/types/primitives/SupportedColors';
|
|
9
|
+
import { Label } from '@tuturuuu/ui/label';
|
|
10
|
+
import {
|
|
11
|
+
Select,
|
|
12
|
+
SelectContent,
|
|
13
|
+
SelectItem,
|
|
14
|
+
SelectTrigger,
|
|
15
|
+
SelectValue,
|
|
16
|
+
} from '@tuturuuu/ui/select';
|
|
17
|
+
import { readGoogleEventColor } from '@tuturuuu/utils/google-calendar-colors';
|
|
18
|
+
import { useTranslations } from 'next-intl';
|
|
19
|
+
import { EventColorPicker } from './event-form-components';
|
|
20
|
+
|
|
21
|
+
export function ProviderColorPicker({
|
|
22
|
+
wsId,
|
|
23
|
+
source,
|
|
24
|
+
value,
|
|
25
|
+
metadata,
|
|
26
|
+
choice,
|
|
27
|
+
onNativeChange,
|
|
28
|
+
onProviderChange,
|
|
29
|
+
}: {
|
|
30
|
+
wsId?: string;
|
|
31
|
+
source?: CalendarSourceOption;
|
|
32
|
+
value: SupportedColor;
|
|
33
|
+
metadata?: unknown;
|
|
34
|
+
choice?: GoogleProviderColorChoice;
|
|
35
|
+
onNativeChange: (value: SupportedColor) => void;
|
|
36
|
+
onProviderChange: (choice: GoogleProviderColorChoice) => void;
|
|
37
|
+
}) {
|
|
38
|
+
const t = useTranslations('calendar');
|
|
39
|
+
const connectionId =
|
|
40
|
+
source?.provider === 'google' ? source.connectionId : null;
|
|
41
|
+
const { data } = useQuery({
|
|
42
|
+
queryKey: ['google-calendar-color-options', wsId, connectionId],
|
|
43
|
+
enabled: !!wsId && !!connectionId,
|
|
44
|
+
queryFn: () => getGoogleCalendarColorOptions(wsId!, connectionId!),
|
|
45
|
+
});
|
|
46
|
+
if (
|
|
47
|
+
!connectionId ||
|
|
48
|
+
!data?.providerColorWrites ||
|
|
49
|
+
data.connectionId !== connectionId ||
|
|
50
|
+
!data.options.length
|
|
51
|
+
)
|
|
52
|
+
return <EventColorPicker value={value} onChange={onNativeChange} />;
|
|
53
|
+
const current = readGoogleEventColor(metadata);
|
|
54
|
+
const selected =
|
|
55
|
+
choice?.connectionId === connectionId
|
|
56
|
+
? choice.kind === 'inherit'
|
|
57
|
+
? 'inherit'
|
|
58
|
+
: `${choice.kind}:${choice.id}`
|
|
59
|
+
: current?.inherited
|
|
60
|
+
? 'inherit'
|
|
61
|
+
: current?.event_label_id
|
|
62
|
+
? `label:${current.event_label_id}`
|
|
63
|
+
: current?.color_id
|
|
64
|
+
? `event:${current.color_id}`
|
|
65
|
+
: undefined;
|
|
66
|
+
return (
|
|
67
|
+
<div className="space-y-1">
|
|
68
|
+
<Label>{t('provider_color_label')}</Label>
|
|
69
|
+
<Select
|
|
70
|
+
value={selected}
|
|
71
|
+
onValueChange={(key) => {
|
|
72
|
+
const option = data.options.find(
|
|
73
|
+
(item) =>
|
|
74
|
+
(item.kind === 'inherit'
|
|
75
|
+
? 'inherit'
|
|
76
|
+
: `${item.kind}:${item.id}`) === key
|
|
77
|
+
);
|
|
78
|
+
if (option)
|
|
79
|
+
onProviderChange(
|
|
80
|
+
option.kind === 'inherit'
|
|
81
|
+
? { connectionId, kind: 'inherit' }
|
|
82
|
+
: { connectionId, kind: option.kind, id: option.id! }
|
|
83
|
+
);
|
|
84
|
+
}}
|
|
85
|
+
>
|
|
86
|
+
<SelectTrigger aria-label={t('provider_color_label')}>
|
|
87
|
+
<SelectValue placeholder={t('provider_color_label')} />
|
|
88
|
+
</SelectTrigger>
|
|
89
|
+
<SelectContent>
|
|
90
|
+
{data.options.map((option) => (
|
|
91
|
+
<SelectItem
|
|
92
|
+
key={`${option.kind}:${option.id}`}
|
|
93
|
+
value={
|
|
94
|
+
option.kind === 'inherit'
|
|
95
|
+
? 'inherit'
|
|
96
|
+
: `${option.kind}:${option.id}`
|
|
97
|
+
}
|
|
98
|
+
>
|
|
99
|
+
<span className="flex items-center gap-2">
|
|
100
|
+
<span
|
|
101
|
+
className="size-3 rounded-full"
|
|
102
|
+
style={{ backgroundColor: option.background }}
|
|
103
|
+
/>
|
|
104
|
+
{option.kind === 'inherit'
|
|
105
|
+
? t('inherit_calendar_color')
|
|
106
|
+
: (option.name ??
|
|
107
|
+
t('google_event_color', { id: option.id ?? '' }))}
|
|
108
|
+
</span>
|
|
109
|
+
</SelectItem>
|
|
110
|
+
))}
|
|
111
|
+
</SelectContent>
|
|
112
|
+
</Select>
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
|
|
2
2
|
import { expect, it, vi } from 'vitest';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
createCalendarCreationRequests,
|
|
5
|
+
newCalendarCreationRequestId,
|
|
6
|
+
} from '../../../../hooks/calendar-creation-request';
|
|
7
|
+
import {
|
|
8
|
+
prepareCalendarEventDrafts,
|
|
9
|
+
saveCalendarEventDrafts,
|
|
10
|
+
} from './save-calendar-event-drafts';
|
|
4
11
|
|
|
5
12
|
it('retains rejected or unconfirmed drafts and retries only those events', async () => {
|
|
6
13
|
const drafts = [
|
|
@@ -23,3 +30,30 @@ it('retains rejected or unconfirmed drafts and retries only those events', async
|
|
|
23
30
|
'Unconfirmed',
|
|
24
31
|
]);
|
|
25
32
|
});
|
|
33
|
+
|
|
34
|
+
it('retains distinct AI draft IDs and immutable payload across a lost-response batch retry', async () => {
|
|
35
|
+
const controller = createCalendarCreationRequests();
|
|
36
|
+
const manualId = newCalendarCreationRequestId();
|
|
37
|
+
controller.forAttempt({ title: 'Manual' }, manualId);
|
|
38
|
+
const drafts = prepareCalendarEventDrafts(
|
|
39
|
+
['First AI', 'Second AI'].map((title) => ({ title }))
|
|
40
|
+
);
|
|
41
|
+
const attempts: { id: string; event: unknown }[] = [];
|
|
42
|
+
const addEvent = vi.fn(async (event, options) => {
|
|
43
|
+
attempts.push({
|
|
44
|
+
id: controller.forAttempt(event, options?.requestId),
|
|
45
|
+
event,
|
|
46
|
+
});
|
|
47
|
+
if (attempts.length <= 2) throw new Error('lost response');
|
|
48
|
+
return { id: 'confirmed' } as CalendarEvent;
|
|
49
|
+
});
|
|
50
|
+
const failed = await saveCalendarEventDrafts(drafts, addEvent, undefined);
|
|
51
|
+
expect(failed.failedEvents).toEqual(drafts);
|
|
52
|
+
await saveCalendarEventDrafts(failed.failedEvents, addEvent, undefined);
|
|
53
|
+
expect(new Set([manualId, attempts[0]!.id, attempts[1]!.id]).size).toBe(3);
|
|
54
|
+
expect(attempts[2]).toEqual(attempts[0]);
|
|
55
|
+
expect(attempts[3]).toEqual(attempts[1]);
|
|
56
|
+
expect(drafts.map((d) => d.requestId)).toEqual(
|
|
57
|
+
attempts.slice(0, 2).map((a) => a.id)
|
|
58
|
+
);
|
|
59
|
+
});
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
|
|
2
|
+
import { newCalendarCreationRequestId } from '../../../../hooks/calendar-creation-request';
|
|
2
3
|
|
|
3
4
|
/** Keep only unconfirmed drafts after a batch so retry never resends saved events. */
|
|
4
5
|
export async function saveCalendarEventDrafts(
|
|
5
6
|
drafts: Partial<CalendarEvent>[],
|
|
6
7
|
addEvent: (
|
|
7
|
-
event: Omit<CalendarEvent, 'id'
|
|
8
|
+
event: Omit<CalendarEvent, 'id'>,
|
|
9
|
+
options?: { requestId?: string }
|
|
8
10
|
) => Promise<CalendarEvent | undefined>,
|
|
9
11
|
source: CalendarEvent['source']
|
|
10
12
|
) {
|
|
@@ -12,16 +14,19 @@ export async function saveCalendarEventDrafts(
|
|
|
12
14
|
const failedEvents: Partial<CalendarEvent>[] = [];
|
|
13
15
|
for (const draft of drafts) {
|
|
14
16
|
try {
|
|
15
|
-
const saved = await addEvent(
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
17
|
+
const saved = await addEvent(
|
|
18
|
+
{
|
|
19
|
+
title: draft.title || 'New Event',
|
|
20
|
+
description: draft.description || '',
|
|
21
|
+
start_at: draft.start_at || '',
|
|
22
|
+
end_at: draft.end_at || '',
|
|
23
|
+
color: draft.color || 'BLUE',
|
|
24
|
+
location: draft.location || '',
|
|
25
|
+
locked: draft.locked || false,
|
|
26
|
+
source,
|
|
27
|
+
},
|
|
28
|
+
{ requestId: draft.requestId }
|
|
29
|
+
);
|
|
25
30
|
if (saved?.id) savedEvents.push(saved);
|
|
26
31
|
else failedEvents.push(draft);
|
|
27
32
|
} catch {
|
|
@@ -30,3 +35,10 @@ export async function saveCalendarEventDrafts(
|
|
|
30
35
|
}
|
|
31
36
|
return { savedEvents, failedEvents };
|
|
32
37
|
}
|
|
38
|
+
|
|
39
|
+
export function prepareCalendarEventDrafts(drafts: Partial<CalendarEvent>[]) {
|
|
40
|
+
return drafts.map((draft) => ({
|
|
41
|
+
...draft,
|
|
42
|
+
requestId: newCalendarCreationRequestId(),
|
|
43
|
+
}));
|
|
44
|
+
}
|