@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
|
@@ -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
|
+
}
|
|
@@ -1,42 +1,23 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useQuery } from '@tanstack/react-query';
|
|
4
3
|
import { Calendar, Download, RefreshCw } from '@tuturuuu/icons';
|
|
5
|
-
import type { Workspace } from '@tuturuuu/types';
|
|
6
4
|
import { Button } from '@tuturuuu/ui/button';
|
|
7
5
|
import { AnalyticsCharts } from '@tuturuuu/ui/legacy/calendar/settings/analytics-charts';
|
|
8
6
|
import { SummaryCards } from '@tuturuuu/ui/legacy/calendar/settings/summary-cards';
|
|
9
7
|
import { SyncLogsTable } from '@tuturuuu/ui/legacy/calendar/settings/sync-logs-table';
|
|
10
8
|
import { useMemo, useState } from 'react';
|
|
9
|
+
import { useVisibleWorkspaces } from '../../../../../hooks/use-visible-workspaces';
|
|
11
10
|
import type { SyncLog } from './types';
|
|
12
11
|
|
|
13
|
-
const getWorkspaces = async () => {
|
|
14
|
-
const workspaces = await fetch('/api/workspaces');
|
|
15
|
-
if (!workspaces.ok) {
|
|
16
|
-
throw new Error(`Failed to fetch workspaces: ${workspaces.status}`);
|
|
17
|
-
}
|
|
18
|
-
return workspaces.json();
|
|
19
|
-
};
|
|
20
|
-
|
|
21
12
|
export function CalendarSyncDashboard({ syncLogs }: { syncLogs: SyncLog[] }) {
|
|
22
13
|
const [filterType, setFilterType] = useState('all');
|
|
23
14
|
const [filterWorkspace, setFilterWorkspace] = useState('all');
|
|
24
15
|
const [searchTerm, setSearchTerm] = useState('');
|
|
25
16
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
queryKey: ['workspaces'],
|
|
29
|
-
queryFn: () => getWorkspaces(),
|
|
30
|
-
refetchInterval: 1000 * 60 * 5, // 5 minutes
|
|
17
|
+
const workspacesQuery = useVisibleWorkspaces(true, undefined, {
|
|
18
|
+
refetchInterval: 300_000,
|
|
31
19
|
});
|
|
32
|
-
|
|
33
|
-
// Fallback to mock workspaces if API fails
|
|
34
|
-
const workspaces: Workspace[] = workspacesQuery.data || [
|
|
35
|
-
{ id: 'ws_1', name: 'Marketing Team' },
|
|
36
|
-
{ id: 'ws_2', name: 'Engineering' },
|
|
37
|
-
{ id: 'ws_3', name: 'Sales Department' },
|
|
38
|
-
{ id: 'ws_4', name: 'Executive Team' },
|
|
39
|
-
];
|
|
20
|
+
const workspaces = workspacesQuery.data ?? [];
|
|
40
21
|
|
|
41
22
|
const filteredLogs = useMemo(() => {
|
|
42
23
|
return syncLogs.filter((log) => {
|
|
@@ -44,7 +44,7 @@ import type { SyncLog } from './types';
|
|
|
44
44
|
|
|
45
45
|
interface SyncLogsTableProps {
|
|
46
46
|
syncLogs: SyncLog[];
|
|
47
|
-
workspaces: Workspace[];
|
|
47
|
+
workspaces: Pick<Workspace, 'id' | 'name'>[];
|
|
48
48
|
filterType: string;
|
|
49
49
|
filterWorkspace: string;
|
|
50
50
|
searchTerm: string;
|
|
@@ -221,11 +221,8 @@ export function SyncLogsTable({
|
|
|
221
221
|
</SelectTrigger>
|
|
222
222
|
<SelectContent>
|
|
223
223
|
<SelectItem value="all">All Workspaces</SelectItem>
|
|
224
|
-
{workspaces.map((workspace
|
|
225
|
-
<SelectItem
|
|
226
|
-
key={`${workspace.id}-${index}`}
|
|
227
|
-
value={workspace.id}
|
|
228
|
-
>
|
|
224
|
+
{workspaces.map((workspace) => (
|
|
225
|
+
<SelectItem key={workspace.id} value={workspace.id}>
|
|
229
226
|
<div className="flex items-center gap-2">
|
|
230
227
|
<div className="h-2 w-2 rounded-full bg-blue-500" />
|
|
231
228
|
{workspace.name}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act, renderHook } from '@testing-library/react';
|
|
3
|
+
import { expect, it, vi } from 'vitest';
|
|
4
|
+
import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
|
|
5
|
+
import { useCalendarDraftSave } from './use-calendar-draft-save';
|
|
6
|
+
|
|
7
|
+
const query = vi.hoisted(() => ({
|
|
8
|
+
data: { connectionId: 'owned', providerColorWrites: true },
|
|
9
|
+
}));
|
|
10
|
+
vi.mock('@tanstack/react-query', () => ({ useQuery: () => query }));
|
|
11
|
+
vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
|
|
12
|
+
vi.mock('@tuturuuu/ui/button', () => ({
|
|
13
|
+
Button: (props: any) => <button {...props} />,
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
function fixture() {
|
|
17
|
+
query.data = { connectionId: 'owned', providerColorWrites: true };
|
|
18
|
+
const draft = {
|
|
19
|
+
requestId: '01900000-0000-7000-8000-000000000001',
|
|
20
|
+
ws_id: 'workspace',
|
|
21
|
+
title: 'Original',
|
|
22
|
+
};
|
|
23
|
+
const args = {
|
|
24
|
+
draft,
|
|
25
|
+
wsId: 'workspace',
|
|
26
|
+
source: { provider: 'google' as const, connectionId: 'owned' } as any,
|
|
27
|
+
original: { id: 'new' } as CalendarEvent,
|
|
28
|
+
buildPayload: () => ({ title: args.draft.title }),
|
|
29
|
+
addEvent: vi.fn().mockRejectedValueOnce(new Error('lost response')),
|
|
30
|
+
updateEvent: vi.fn().mockResolvedValue(undefined),
|
|
31
|
+
closeModal: vi.fn(),
|
|
32
|
+
setIsSaving: vi.fn(),
|
|
33
|
+
onError: vi.fn(),
|
|
34
|
+
isSaving: false,
|
|
35
|
+
};
|
|
36
|
+
return args;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
it('retries immutable uncertain contents and ID, preserves edits, then explicitly updates the confirmed ID', async () => {
|
|
40
|
+
const args = fixture();
|
|
41
|
+
const { result, rerender } = renderHook(() => useCalendarDraftSave(args));
|
|
42
|
+
await act(() => result.current.save());
|
|
43
|
+
expect(result.current.recoveryNotice).not.toBeNull();
|
|
44
|
+
args.draft = { ...args.draft, title: 'Edited after uncertainty' };
|
|
45
|
+
rerender();
|
|
46
|
+
await act(() => result.current.save());
|
|
47
|
+
expect(args.addEvent).toHaveBeenCalledTimes(1);
|
|
48
|
+
args.addEvent.mockResolvedValueOnce({ id: 'confirmed', title: 'Original' });
|
|
49
|
+
const retry = (result.current.recoveryNotice!.props.children[1] as any).props
|
|
50
|
+
.onClick;
|
|
51
|
+
await act(() => retry());
|
|
52
|
+
expect(args.addEvent).toHaveBeenLastCalledWith(
|
|
53
|
+
{ title: 'Original' },
|
|
54
|
+
{ requestId: args.draft.requestId }
|
|
55
|
+
);
|
|
56
|
+
expect(args.closeModal).not.toHaveBeenCalled();
|
|
57
|
+
expect(args.draft.title).toBe('Edited after uncertainty');
|
|
58
|
+
await act(() => result.current.save());
|
|
59
|
+
expect(args.addEvent).toHaveBeenCalledTimes(2);
|
|
60
|
+
expect(args.updateEvent).toHaveBeenCalledWith('confirmed', {
|
|
61
|
+
title: 'Edited after uncertainty',
|
|
62
|
+
});
|
|
63
|
+
expect(args.closeModal).toHaveBeenCalledTimes(1);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('keeps failed recovery uncertain without dispatching edited intent', async () => {
|
|
67
|
+
const args = fixture();
|
|
68
|
+
const { result } = renderHook(() => useCalendarDraftSave(args));
|
|
69
|
+
await act(() => result.current.save());
|
|
70
|
+
args.addEvent.mockRejectedValueOnce(new Error('still uncertain'));
|
|
71
|
+
const retry = (result.current.recoveryNotice!.props.children[1] as any).props
|
|
72
|
+
.onClick;
|
|
73
|
+
await act(() => retry());
|
|
74
|
+
expect(result.current.recoveryNotice).not.toBeNull();
|
|
75
|
+
expect(args.updateEvent).not.toHaveBeenCalled();
|
|
76
|
+
expect(args.closeModal).not.toHaveBeenCalled();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('does not close or recover another draft after a stale retry response', async () => {
|
|
80
|
+
const args = fixture();
|
|
81
|
+
const { result, rerender } = renderHook(() => useCalendarDraftSave(args));
|
|
82
|
+
await act(() => result.current.save());
|
|
83
|
+
let resolve!: (event: unknown) => void;
|
|
84
|
+
args.addEvent.mockReturnValueOnce(
|
|
85
|
+
new Promise((r) => {
|
|
86
|
+
resolve = r;
|
|
87
|
+
})
|
|
88
|
+
);
|
|
89
|
+
const retry = (result.current.recoveryNotice!.props.children[1] as any).props
|
|
90
|
+
.onClick;
|
|
91
|
+
let pending!: Promise<void>;
|
|
92
|
+
act(() => {
|
|
93
|
+
pending = retry();
|
|
94
|
+
});
|
|
95
|
+
args.draft = {
|
|
96
|
+
...args.draft,
|
|
97
|
+
requestId: 'another-draft',
|
|
98
|
+
title: 'New draft',
|
|
99
|
+
};
|
|
100
|
+
rerender();
|
|
101
|
+
await act(async () => {
|
|
102
|
+
resolve({ id: 'old-confirmed', title: 'Original' });
|
|
103
|
+
await pending;
|
|
104
|
+
});
|
|
105
|
+
expect(args.closeModal).not.toHaveBeenCalled();
|
|
106
|
+
expect(result.current.recoveryNotice).toBeNull();
|
|
107
|
+
expect(args.updateEvent).not.toHaveBeenCalled();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('keeps legacy unconfirmed creates out of automatic retry', async () => {
|
|
111
|
+
const args = fixture();
|
|
112
|
+
query.data.providerColorWrites = false;
|
|
113
|
+
const { result } = renderHook(() => useCalendarDraftSave(args));
|
|
114
|
+
await act(() => result.current.save());
|
|
115
|
+
expect(result.current.recoveryNotice!.props.children[1]).toBe(false);
|
|
116
|
+
await act(() => result.current.save());
|
|
117
|
+
expect(args.addEvent).toHaveBeenCalledTimes(1);
|
|
118
|
+
expect(args.updateEvent).not.toHaveBeenCalled();
|
|
119
|
+
});
|