@tuturuuu/ui 0.18.0 → 0.19.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 +32 -0
- package/package.json +70 -69
- package/src/components/ui/calendar-app/calendar-client-page.tsx +19 -1
- package/src/components/ui/calendar-app/calendar-page-shell.tsx +37 -16
- package/src/components/ui/calendar-app/components/calendar-connections-manager-helpers.ts +70 -0
- package/src/components/ui/calendar-app/components/calendar-connections-settings-content.tsx +516 -578
- package/src/components/ui/calendar-app/components/calendar-connections-unified.tsx +1 -0
- package/src/components/ui/calendar-app/components/calendar-sync-settings-panel.tsx +155 -0
- package/src/components/ui/calendar-app/components/merge-provider-calendars.test.ts +46 -0
- package/src/components/ui/calendar-app/components/merge-provider-calendars.ts +81 -0
- package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +22 -129
- package/src/components/ui/chat/chat-agent-details-operations-panel.test.tsx +17 -3
- package/src/components/ui/chat/chat-agent-details-utils.tsx +2 -0
- package/src/components/ui/chat/chat-agent-details-zalo-personal-panel.tsx +17 -6
- package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +35 -0
- package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +17 -0
- package/src/components/ui/custom/nav-link.test.tsx +1 -0
- package/src/components/ui/custom/nav-link.tsx +4 -1
- package/src/components/ui/custom/settings-dialog-shell.tsx +23 -14
- package/src/components/ui/custom/statistic-card.tsx +3 -5
- package/src/components/ui/custom/structure.test.tsx +4 -0
- package/src/components/ui/custom/user-filters.tsx +4 -7
- package/src/components/ui/custom/workspace-access/workspace-access-page-header.tsx +1 -1
- package/src/components/ui/custom/workspace-access/workspace-access-tabs-toolbar.tsx +1 -1
- package/src/components/ui/custom/workspace-select.tsx +38 -53
- package/src/components/ui/finance/analytics/category-spending-chart.test.tsx +3 -3
- package/src/components/ui/finance/budgets/budget-card.test.tsx +3 -3
- package/src/components/ui/finance/debts/debt-loan-card.test.tsx +3 -3
- package/src/components/ui/finance/debts/debt-loan-detail-cards.test.tsx +3 -3
- package/src/components/ui/finance/invoices/charts/invoice-totals-chart.test.tsx +3 -3
- package/src/components/ui/finance/invoices/columns.test.tsx +3 -3
- package/src/components/ui/finance/invoices/components/invoice-customer-select-card.tsx +1 -1
- package/src/components/ui/finance/shared/numbers-visibility-toggle.test.tsx +1 -1
- package/src/components/ui/finance/tags/tag-manager.test.tsx +2 -2
- package/src/components/ui/finance/transactions/categories/columns.test.tsx +5 -5
- package/src/components/ui/finance/transactions/columns.test.tsx +2 -2
- package/src/components/ui/finance/transactions/confidential-field.test.tsx +2 -2
- package/src/components/ui/finance/transactions/transaction-edit-dialog.test.tsx +2 -2
- package/src/components/ui/finance/transactions/transaction-statistics.test.tsx +3 -3
- package/src/components/ui/finance/wallets/form.test.tsx +3 -3
- package/src/components/ui/finance/wallets/wallets-data-table.test.tsx +10 -0
- package/src/components/ui/legacy/calendar/calendar-cell.tsx +1 -1
- package/src/components/ui/storefront/accent-button.tsx +4 -1
- package/src/components/ui/storefront/browse-panel.tsx +6 -1
- package/src/components/ui/storefront/cart-summary-parts.tsx +18 -4
- package/src/components/ui/storefront/cart-summary.tsx +22 -6
- package/src/components/ui/storefront/hero-panel.tsx +13 -17
- package/src/components/ui/storefront/index.ts +2 -0
- package/src/components/ui/storefront/listing-card.tsx +6 -4
- package/src/components/ui/storefront/product-detail.tsx +51 -21
- package/src/components/ui/storefront/product-dialog.tsx +8 -1
- package/src/components/ui/storefront/storefront-surface.test.tsx +79 -3
- package/src/components/ui/storefront/storefront-surface.tsx +151 -100
- package/src/components/ui/storefront/types.ts +3 -0
- package/src/components/ui/storefront/utils.ts +5 -1
- package/src/components/ui/text-editor/__tests__/task-mention-checkbox.test.ts +23 -6
- package/src/components/ui/text-editor/__tests__/upload-placeholder.test.ts +1 -10
- package/src/components/ui/text-editor/color-controls.tsx +1 -1
- package/src/globals.css +20 -0
- package/src/globals.test.ts +75 -0
- package/src/hooks/__tests__/use-calendar-sync.test.tsx +103 -1
- package/src/hooks/use-calendar-sync.tsx +51 -14
- package/src/lib/task-personal-external.ts +1 -1
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { Badge } from '../../badge';
|
|
2
|
+
import {
|
|
3
|
+
Select,
|
|
4
|
+
SelectContent,
|
|
5
|
+
SelectItem,
|
|
6
|
+
SelectTrigger,
|
|
7
|
+
SelectValue,
|
|
8
|
+
} from '../../select';
|
|
9
|
+
import { Switch } from '../../switch';
|
|
10
|
+
import type { CalendarConnectionsManagerState } from './use-calendar-connections-manager';
|
|
11
|
+
|
|
12
|
+
export function CalendarSyncSettingsPanel({
|
|
13
|
+
state,
|
|
14
|
+
}: {
|
|
15
|
+
state: CalendarConnectionsManagerState;
|
|
16
|
+
}) {
|
|
17
|
+
const { syncPreferencesData, syncPreferencesMutation, t } = state;
|
|
18
|
+
const externalOptions =
|
|
19
|
+
syncPreferencesData?.options.filter(
|
|
20
|
+
(option) => option.provider !== 'tuturuuu'
|
|
21
|
+
) ?? [];
|
|
22
|
+
const unavailable = syncPreferencesData?.settingsAvailable === false;
|
|
23
|
+
const disabled =
|
|
24
|
+
!syncPreferencesData || unavailable || syncPreferencesMutation.isPending;
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div className="space-y-3 rounded-lg border p-3">
|
|
28
|
+
<div>
|
|
29
|
+
<h4 className="font-medium text-sm">
|
|
30
|
+
{t('two_way_sync') || 'Two-way sync'}
|
|
31
|
+
</h4>
|
|
32
|
+
<p className="text-muted-foreground text-xs">
|
|
33
|
+
{t('two_way_sync_desc') ||
|
|
34
|
+
'Control how Tuturuuu imports provider changes and mirrors native events outward.'}
|
|
35
|
+
</p>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
{unavailable && (
|
|
39
|
+
<p className="rounded-md bg-muted px-3 py-2 text-muted-foreground text-xs">
|
|
40
|
+
{t('calendar_sync_settings_unavailable') ||
|
|
41
|
+
'Sync controls will be available after the latest calendar migration is applied.'}
|
|
42
|
+
</p>
|
|
43
|
+
)}
|
|
44
|
+
|
|
45
|
+
<div className="space-y-3">
|
|
46
|
+
<SyncToggle
|
|
47
|
+
title={t('import_external_changes') || 'Import external changes'}
|
|
48
|
+
description={
|
|
49
|
+
t('import_external_changes_desc') ||
|
|
50
|
+
'Bring Google and Outlook event changes into Tuturuuu.'
|
|
51
|
+
}
|
|
52
|
+
checked={syncPreferencesData?.inboundSyncEnabled ?? true}
|
|
53
|
+
disabled={disabled}
|
|
54
|
+
onCheckedChange={(checked) =>
|
|
55
|
+
syncPreferencesMutation.mutate({ inboundSyncEnabled: checked })
|
|
56
|
+
}
|
|
57
|
+
/>
|
|
58
|
+
<SyncToggle
|
|
59
|
+
title={
|
|
60
|
+
t('sync_tuturuuu_events_outward') || 'Sync Tuturuuu events outward'
|
|
61
|
+
}
|
|
62
|
+
description={
|
|
63
|
+
t('sync_tuturuuu_events_outward_desc') ||
|
|
64
|
+
'Mirror native Tuturuuu event creates and edits to a connected provider calendar.'
|
|
65
|
+
}
|
|
66
|
+
checked={syncPreferencesData?.outboundSyncEnabled ?? false}
|
|
67
|
+
disabled={disabled || externalOptions.length === 0}
|
|
68
|
+
onCheckedChange={(checked) =>
|
|
69
|
+
syncPreferencesMutation.mutate({ outboundSyncEnabled: checked })
|
|
70
|
+
}
|
|
71
|
+
/>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<Select
|
|
75
|
+
value={syncPreferencesData?.defaultOutboundConnectionId ?? 'none'}
|
|
76
|
+
onValueChange={(value) =>
|
|
77
|
+
syncPreferencesMutation.mutate({
|
|
78
|
+
defaultOutboundConnectionId: value === 'none' ? null : value,
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
disabled={disabled || externalOptions.length === 0}
|
|
82
|
+
>
|
|
83
|
+
<SelectTrigger className="w-full">
|
|
84
|
+
<SelectValue
|
|
85
|
+
placeholder={
|
|
86
|
+
t('choose_outbound_calendar') || 'Choose outbound calendar'
|
|
87
|
+
}
|
|
88
|
+
/>
|
|
89
|
+
</SelectTrigger>
|
|
90
|
+
<SelectContent>
|
|
91
|
+
<SelectItem value="none">
|
|
92
|
+
{t('no_outbound_calendar') || 'No outbound calendar'}
|
|
93
|
+
</SelectItem>
|
|
94
|
+
{externalOptions.map((option) => (
|
|
95
|
+
<SelectItem key={option.id} value={option.connectionId}>
|
|
96
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
97
|
+
<span
|
|
98
|
+
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
|
99
|
+
style={{ backgroundColor: option.color ?? undefined }}
|
|
100
|
+
/>
|
|
101
|
+
<span className="truncate">{option.label}</span>
|
|
102
|
+
<Badge variant="secondary" className="capitalize">
|
|
103
|
+
{option.provider}
|
|
104
|
+
</Badge>
|
|
105
|
+
</div>
|
|
106
|
+
</SelectItem>
|
|
107
|
+
))}
|
|
108
|
+
</SelectContent>
|
|
109
|
+
</Select>
|
|
110
|
+
|
|
111
|
+
<div className="flex items-center justify-between gap-3 rounded-md bg-muted/40 px-3 py-2">
|
|
112
|
+
<div className="min-w-0">
|
|
113
|
+
<p className="font-medium text-xs">
|
|
114
|
+
{t('conflict_policy') || 'Conflict policy'}
|
|
115
|
+
</p>
|
|
116
|
+
<p className="text-muted-foreground text-xs">
|
|
117
|
+
{t('latest_write_wins_desc') ||
|
|
118
|
+
'When both sides changed, the most recently written event wins.'}
|
|
119
|
+
</p>
|
|
120
|
+
</div>
|
|
121
|
+
<Badge variant="outline">
|
|
122
|
+
{t('latest_write_wins') || 'Latest write wins'}
|
|
123
|
+
</Badge>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function SyncToggle({
|
|
130
|
+
checked,
|
|
131
|
+
description,
|
|
132
|
+
disabled,
|
|
133
|
+
onCheckedChange,
|
|
134
|
+
title,
|
|
135
|
+
}: {
|
|
136
|
+
checked: boolean;
|
|
137
|
+
description: string;
|
|
138
|
+
disabled: boolean;
|
|
139
|
+
onCheckedChange: (checked: boolean) => void;
|
|
140
|
+
title: string;
|
|
141
|
+
}) {
|
|
142
|
+
return (
|
|
143
|
+
<div className="flex items-center justify-between gap-3">
|
|
144
|
+
<div className="min-w-0">
|
|
145
|
+
<p className="font-medium text-sm">{title}</p>
|
|
146
|
+
<p className="text-muted-foreground text-xs">{description}</p>
|
|
147
|
+
</div>
|
|
148
|
+
<Switch
|
|
149
|
+
checked={checked}
|
|
150
|
+
onCheckedChange={onCheckedChange}
|
|
151
|
+
disabled={disabled}
|
|
152
|
+
/>
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { mergeProviderCalendarsByAccount } from './merge-provider-calendars';
|
|
3
|
+
|
|
4
|
+
describe('mergeProviderCalendarsByAccount', () => {
|
|
5
|
+
it('keeps persisted calendars visible while provider refresh is unavailable', () => {
|
|
6
|
+
const result = mergeProviderCalendarsByAccount({
|
|
7
|
+
wsId: 'workspace-1',
|
|
8
|
+
accounts: [
|
|
9
|
+
{
|
|
10
|
+
id: 'account-1',
|
|
11
|
+
provider: 'google',
|
|
12
|
+
account_email: 'member@example.com',
|
|
13
|
+
account_name: 'Member',
|
|
14
|
+
is_active: true,
|
|
15
|
+
created_at: '2026-07-20T00:00:00Z',
|
|
16
|
+
expires_at: null,
|
|
17
|
+
},
|
|
18
|
+
],
|
|
19
|
+
calendarConnections: [
|
|
20
|
+
{
|
|
21
|
+
id: 'connection-1',
|
|
22
|
+
ws_id: 'workspace-1',
|
|
23
|
+
calendar_id: 'provider-calendar-1',
|
|
24
|
+
calendar_name: 'Stored calendar',
|
|
25
|
+
is_enabled: true,
|
|
26
|
+
color: '#123456',
|
|
27
|
+
provider: 'google',
|
|
28
|
+
auth_token_id: 'account-1',
|
|
29
|
+
created_at: '2026-07-20T00:00:00Z',
|
|
30
|
+
updated_at: '2026-07-20T00:00:00Z',
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
liveCalendarsByAccount: { 'account-1': [] },
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
expect(result['account-1']).toEqual([
|
|
37
|
+
expect.objectContaining({
|
|
38
|
+
id: 'connection-1',
|
|
39
|
+
calendar_name: 'Stored calendar',
|
|
40
|
+
is_enabled: true,
|
|
41
|
+
connectionExists: true,
|
|
42
|
+
isFromAPI: false,
|
|
43
|
+
}),
|
|
44
|
+
]);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { CalendarConnection } from '../../../../hooks/use-calendar-sync';
|
|
2
|
+
import type { ConnectedAccount, ProviderCalendar } from './calendar-types';
|
|
3
|
+
|
|
4
|
+
type SyncableCalendarConnection = CalendarConnection & {
|
|
5
|
+
sync_delete_enabled?: boolean | null;
|
|
6
|
+
sync_inbound_enabled?: boolean | null;
|
|
7
|
+
sync_outbound_enabled?: boolean | null;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type ManagedProviderCalendar = {
|
|
11
|
+
accessRole: string;
|
|
12
|
+
accountId: string;
|
|
13
|
+
calendar_id: string;
|
|
14
|
+
calendar_name: string;
|
|
15
|
+
color: string | null;
|
|
16
|
+
connectionExists: boolean;
|
|
17
|
+
id: string;
|
|
18
|
+
is_enabled: boolean;
|
|
19
|
+
isFromAPI: boolean;
|
|
20
|
+
syncDeleteEnabled: boolean;
|
|
21
|
+
syncInboundEnabled: boolean;
|
|
22
|
+
syncOutboundEnabled: boolean;
|
|
23
|
+
ws_id: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function mergeProviderCalendarsByAccount({
|
|
27
|
+
accounts,
|
|
28
|
+
calendarConnections,
|
|
29
|
+
liveCalendarsByAccount,
|
|
30
|
+
wsId,
|
|
31
|
+
}: {
|
|
32
|
+
accounts: ConnectedAccount[];
|
|
33
|
+
calendarConnections: CalendarConnection[];
|
|
34
|
+
liveCalendarsByAccount: Record<string, ProviderCalendar[]>;
|
|
35
|
+
wsId: string;
|
|
36
|
+
}) {
|
|
37
|
+
return accounts.reduce<Record<string, ManagedProviderCalendar[]>>(
|
|
38
|
+
(result, account) => {
|
|
39
|
+
const liveCalendars = liveCalendarsByAccount[account.id] ?? [];
|
|
40
|
+
const persistedConnections = calendarConnections.filter(
|
|
41
|
+
(connection) => connection.auth_token_id === account.id
|
|
42
|
+
) as SyncableCalendarConnection[];
|
|
43
|
+
const liveByCalendarId = new Map(
|
|
44
|
+
liveCalendars.map((calendar) => [calendar.id, calendar])
|
|
45
|
+
);
|
|
46
|
+
const calendarIds = new Set([
|
|
47
|
+
...persistedConnections.map((connection) => connection.calendar_id),
|
|
48
|
+
...liveCalendars.map((calendar) => calendar.id),
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
result[account.id] = [...calendarIds].map((calendarId) => {
|
|
52
|
+
const liveCalendar = liveByCalendarId.get(calendarId);
|
|
53
|
+
const connection = persistedConnections.find(
|
|
54
|
+
(candidate) => candidate.calendar_id === calendarId
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
id: connection?.id ?? calendarId,
|
|
59
|
+
ws_id: wsId,
|
|
60
|
+
calendar_id: calendarId,
|
|
61
|
+
calendar_name:
|
|
62
|
+
liveCalendar?.name ?? connection?.calendar_name ?? 'Calendar',
|
|
63
|
+
is_enabled: connection?.is_enabled ?? false,
|
|
64
|
+
color:
|
|
65
|
+
connection?.color ?? liveCalendar?.backgroundColor ?? '#4285f4',
|
|
66
|
+
isFromAPI: !!liveCalendar,
|
|
67
|
+
connectionExists: !!connection,
|
|
68
|
+
accountId: account.id,
|
|
69
|
+
accessRole:
|
|
70
|
+
liveCalendar?.accessRole ?? connection?.access_role ?? 'reader',
|
|
71
|
+
syncDeleteEnabled: connection?.sync_delete_enabled ?? true,
|
|
72
|
+
syncInboundEnabled: connection?.sync_inbound_enabled ?? true,
|
|
73
|
+
syncOutboundEnabled: connection?.sync_outbound_enabled ?? false,
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return result;
|
|
78
|
+
},
|
|
79
|
+
{}
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
2
2
|
import {
|
|
3
|
-
type CalendarSourceOption,
|
|
4
3
|
getGoogleCalendarAuthUrl,
|
|
5
4
|
getWorkspaceCalendarDefaultSource,
|
|
6
5
|
getWorkspaceCalendarSyncPreferences,
|
|
@@ -14,67 +13,22 @@ import { useTranslations } from 'next-intl';
|
|
|
14
13
|
import { useMemo, useState } from 'react';
|
|
15
14
|
import { useCalendarSync } from '../../../../hooks/use-calendar-sync';
|
|
16
15
|
import { toast } from '../../sonner';
|
|
16
|
+
import {
|
|
17
|
+
type AccountsResponse,
|
|
18
|
+
getCalendarColor,
|
|
19
|
+
type ManualSyncResponse,
|
|
20
|
+
sourceInputFromOption,
|
|
21
|
+
type WorkspaceCalendarsResponse,
|
|
22
|
+
} from './calendar-connections-manager-helpers';
|
|
17
23
|
import type {
|
|
18
24
|
AuthResponse,
|
|
19
25
|
CalendarSyncHealth,
|
|
20
|
-
ConnectedAccount,
|
|
21
26
|
ProviderCalendar,
|
|
22
27
|
} from './calendar-types';
|
|
23
|
-
|
|
24
|
-
interface AccountsResponse {
|
|
25
|
-
accounts: ConnectedAccount[];
|
|
26
|
-
grouped: {
|
|
27
|
-
google: ConnectedAccount[];
|
|
28
|
-
microsoft: ConnectedAccount[];
|
|
29
|
-
};
|
|
30
|
-
total: number;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
interface WorkspaceCalendar {
|
|
34
|
-
id: string;
|
|
35
|
-
ws_id: string;
|
|
36
|
-
name: string;
|
|
37
|
-
description: string | null;
|
|
38
|
-
color: string | null;
|
|
39
|
-
calendar_type: 'primary' | 'tasks' | 'habits' | 'custom';
|
|
40
|
-
is_system: boolean;
|
|
41
|
-
is_enabled: boolean;
|
|
42
|
-
position: number;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
interface WorkspaceCalendarsResponse {
|
|
46
|
-
calendars: WorkspaceCalendar[];
|
|
47
|
-
grouped: {
|
|
48
|
-
system: WorkspaceCalendar[];
|
|
49
|
-
custom: WorkspaceCalendar[];
|
|
50
|
-
};
|
|
51
|
-
total: number;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
interface ManualSyncResponse {
|
|
55
|
-
ok: boolean;
|
|
56
|
-
alreadyRunning?: boolean;
|
|
57
|
-
code?: string;
|
|
58
|
-
error?: string;
|
|
59
|
-
retryAfterSeconds?: number | null;
|
|
60
|
-
}
|
|
28
|
+
import { mergeProviderCalendarsByAccount } from './merge-provider-calendars';
|
|
61
29
|
|
|
62
30
|
export type CalendarConnectionsUnifiedVariant = 'compact' | 'settings';
|
|
63
31
|
|
|
64
|
-
function sourceInputFromOption(option: CalendarSourceOption) {
|
|
65
|
-
if (option.provider === 'tuturuuu') {
|
|
66
|
-
return {
|
|
67
|
-
provider: 'tuturuuu' as const,
|
|
68
|
-
workspaceCalendarId: option.workspaceCalendarId,
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
return {
|
|
73
|
-
provider: option.provider,
|
|
74
|
-
connectionId: option.connectionId,
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
|
-
|
|
78
32
|
export function useCalendarConnectionsManager(wsId: string) {
|
|
79
33
|
const t = useTranslations('calendar');
|
|
80
34
|
const router = useRouter();
|
|
@@ -294,22 +248,6 @@ export function useCalendarConnectionsManager(wsId: string) {
|
|
|
294
248
|
const systemCalendars = workspaceCalendarsData?.grouped?.system || [];
|
|
295
249
|
const customCalendars = workspaceCalendarsData?.grouped?.custom || [];
|
|
296
250
|
|
|
297
|
-
// Calendar color mapping helper
|
|
298
|
-
const getCalendarColor = (color: string): string => {
|
|
299
|
-
const colorMap: Record<string, string> = {
|
|
300
|
-
BLUE: '#3b82f6',
|
|
301
|
-
RED: '#ef4444',
|
|
302
|
-
GREEN: '#22c55e',
|
|
303
|
-
YELLOW: '#eab308',
|
|
304
|
-
ORANGE: '#f97316',
|
|
305
|
-
PURPLE: '#a855f7',
|
|
306
|
-
PINK: '#ec4899',
|
|
307
|
-
CYAN: '#06b6d4',
|
|
308
|
-
GRAY: '#6b7280',
|
|
309
|
-
};
|
|
310
|
-
return colorMap[color.toUpperCase()] || color;
|
|
311
|
-
};
|
|
312
|
-
|
|
313
251
|
// Calculate total enabled calendars count
|
|
314
252
|
const tuturuuuEnabledCount = workspaceCalendars.filter(
|
|
315
253
|
(c) => c.is_enabled
|
|
@@ -648,75 +586,29 @@ export function useCalendarConnectionsManager(wsId: string) {
|
|
|
648
586
|
`/api/v1/calendar/auth/provider-calendars?wsId=${wsId}`,
|
|
649
587
|
{ cache: 'no-store' }
|
|
650
588
|
);
|
|
651
|
-
if (!response.ok)
|
|
589
|
+
if (!response.ok)
|
|
590
|
+
return { calendars: [], byAccount: {}, accountStatuses: {} };
|
|
652
591
|
return response.json() as Promise<{
|
|
653
592
|
calendars: ProviderCalendar[];
|
|
654
593
|
byAccount: Record<string, ProviderCalendar[]>;
|
|
594
|
+
accountStatuses: Record<
|
|
595
|
+
string,
|
|
596
|
+
{ state: 'connected' | 'reconnect_required' }
|
|
597
|
+
>;
|
|
655
598
|
}>;
|
|
656
599
|
},
|
|
657
600
|
staleTime: 30_000,
|
|
658
601
|
});
|
|
659
602
|
|
|
660
603
|
const calendarsByAccountFromAPI = googleCalendarsData?.byAccount || {};
|
|
604
|
+
const providerAccountStatuses = googleCalendarsData?.accountStatuses || {};
|
|
661
605
|
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
acc[account.id] = apiCalendars.map((apiCal) => {
|
|
669
|
-
// Find existing connection for this calendar
|
|
670
|
-
const existingConnection = calendarConnections.find(
|
|
671
|
-
(conn) =>
|
|
672
|
-
conn.calendar_id === apiCal.id && conn.auth_token_id === account.id
|
|
673
|
-
) as
|
|
674
|
-
| ((typeof calendarConnections)[number] & {
|
|
675
|
-
sync_delete_enabled?: boolean | null;
|
|
676
|
-
sync_inbound_enabled?: boolean | null;
|
|
677
|
-
sync_outbound_enabled?: boolean | null;
|
|
678
|
-
})
|
|
679
|
-
| undefined;
|
|
680
|
-
|
|
681
|
-
return {
|
|
682
|
-
id: existingConnection?.id || apiCal.id,
|
|
683
|
-
ws_id: wsId,
|
|
684
|
-
calendar_id: apiCal.id,
|
|
685
|
-
calendar_name: apiCal.name,
|
|
686
|
-
is_enabled: existingConnection?.is_enabled ?? false, // Default to hidden if no connection exists
|
|
687
|
-
color: existingConnection?.color || apiCal.backgroundColor,
|
|
688
|
-
isFromAPI: true,
|
|
689
|
-
connectionExists: !!existingConnection,
|
|
690
|
-
accountId: account.id,
|
|
691
|
-
accessRole: apiCal.accessRole,
|
|
692
|
-
syncDeleteEnabled: existingConnection?.sync_delete_enabled ?? true,
|
|
693
|
-
syncInboundEnabled: existingConnection?.sync_inbound_enabled ?? true,
|
|
694
|
-
syncOutboundEnabled:
|
|
695
|
-
existingConnection?.sync_outbound_enabled ?? false,
|
|
696
|
-
};
|
|
697
|
-
});
|
|
698
|
-
|
|
699
|
-
return acc;
|
|
700
|
-
},
|
|
701
|
-
{} as Record<
|
|
702
|
-
string,
|
|
703
|
-
Array<{
|
|
704
|
-
id: string;
|
|
705
|
-
ws_id: string;
|
|
706
|
-
calendar_id: string;
|
|
707
|
-
calendar_name: string;
|
|
708
|
-
is_enabled: boolean;
|
|
709
|
-
color: string | null;
|
|
710
|
-
isFromAPI: boolean;
|
|
711
|
-
connectionExists: boolean;
|
|
712
|
-
accountId: string;
|
|
713
|
-
accessRole: string;
|
|
714
|
-
syncDeleteEnabled: boolean;
|
|
715
|
-
syncInboundEnabled: boolean;
|
|
716
|
-
syncOutboundEnabled: boolean;
|
|
717
|
-
}>
|
|
718
|
-
>
|
|
719
|
-
);
|
|
606
|
+
const calendarsByAccount = mergeProviderCalendarsByAccount({
|
|
607
|
+
accounts,
|
|
608
|
+
calendarConnections,
|
|
609
|
+
liveCalendarsByAccount: calendarsByAccountFromAPI,
|
|
610
|
+
wsId,
|
|
611
|
+
});
|
|
720
612
|
|
|
721
613
|
// Note: Removed early return - we always show the full UI now so Tuturuuu calendars are visible
|
|
722
614
|
|
|
@@ -724,6 +616,7 @@ export function useCalendarConnectionsManager(wsId: string) {
|
|
|
724
616
|
accounts,
|
|
725
617
|
calendarConnections,
|
|
726
618
|
calendarsByAccount,
|
|
619
|
+
providerAccountStatuses,
|
|
727
620
|
createCalendarMutation,
|
|
728
621
|
customCalendars,
|
|
729
622
|
defaultSourceData,
|
|
@@ -284,7 +284,7 @@ describe('AgentOperationsPanel', () => {
|
|
|
284
284
|
});
|
|
285
285
|
});
|
|
286
286
|
|
|
287
|
-
it('keeps
|
|
287
|
+
it('keeps only phone-sync cancellation available while a job is running', async () => {
|
|
288
288
|
mocks.getAiAgentZaloPersonalStatus.mockResolvedValue({
|
|
289
289
|
phoneSyncJob: {
|
|
290
290
|
completedAt: null,
|
|
@@ -338,12 +338,26 @@ describe('AgentOperationsPanel', () => {
|
|
|
338
338
|
|
|
339
339
|
await waitFor(() => {
|
|
340
340
|
expect(
|
|
341
|
-
screen
|
|
342
|
-
|
|
341
|
+
screen
|
|
342
|
+
.getByText('agent_zalo_personal_sync_phone_cancel')
|
|
343
|
+
.closest('button')
|
|
344
|
+
).toBeEnabled();
|
|
343
345
|
});
|
|
346
|
+
expect(
|
|
347
|
+
screen.getByText('agent_zalo_personal_validate').closest('button')
|
|
348
|
+
).toBeDisabled();
|
|
344
349
|
expect(
|
|
345
350
|
screen.getByText('agent_zalo_personal_sync_phone_waiting')
|
|
346
351
|
).toBeInTheDocument();
|
|
352
|
+
|
|
353
|
+
fireEvent.click(screen.getByText('agent_zalo_personal_sync_phone_cancel'));
|
|
354
|
+
await waitFor(() => {
|
|
355
|
+
expect(mocks.runAiAgentZaloPersonalAction).toHaveBeenCalledWith(
|
|
356
|
+
'agent-1',
|
|
357
|
+
'channel-1',
|
|
358
|
+
'cancel-sync-phone'
|
|
359
|
+
);
|
|
360
|
+
});
|
|
347
361
|
});
|
|
348
362
|
|
|
349
363
|
it('warns when personal Zalo phone transfer is approved but returns no payload', async () => {
|
|
@@ -188,6 +188,8 @@ const ZALO_PERSONAL_ERROR_KEYS = {
|
|
|
188
188
|
zalo_personal_credentials_missing: 'agent_zalo_personal_credentials_missing',
|
|
189
189
|
zalo_personal_feature_disabled: 'agent_zalo_personal_feature_disabled',
|
|
190
190
|
zalo_personal_phone_sync_timed_out: 'agent_zalo_personal_sync_phone_failed',
|
|
191
|
+
zalo_personal_phone_sync_cancelled:
|
|
192
|
+
'agent_zalo_personal_sync_phone_cancelled',
|
|
191
193
|
zalo_personal_phone_sync_no_payload:
|
|
192
194
|
'agent_zalo_personal_sync_phone_no_payload',
|
|
193
195
|
zalo_personal_phone_sync_waiting_for_phone:
|
|
@@ -65,6 +65,7 @@ const QR_STATUS_KEYS = {
|
|
|
65
65
|
} as const satisfies Record<AiAgentZaloPersonalQrLoginStatus, string>;
|
|
66
66
|
|
|
67
67
|
const ACTION_LABEL_KEYS = {
|
|
68
|
+
'cancel-sync-phone': 'agent_zalo_personal_sync_phone_cancel',
|
|
68
69
|
start: 'agent_zalo_personal_start',
|
|
69
70
|
stop: 'agent_zalo_personal_stop',
|
|
70
71
|
'sync-history': 'agent_zalo_personal_sync_history',
|
|
@@ -73,6 +74,7 @@ const ACTION_LABEL_KEYS = {
|
|
|
73
74
|
} as const satisfies Record<AiAgentZaloPersonalAction, string>;
|
|
74
75
|
|
|
75
76
|
const ACTION_SUCCESS_KEYS = {
|
|
77
|
+
'cancel-sync-phone': 'agent_zalo_personal_sync_phone_cancelled',
|
|
76
78
|
start: 'agent_zalo_personal_start_success',
|
|
77
79
|
stop: 'agent_zalo_personal_stop_success',
|
|
78
80
|
validate: 'agent_zalo_personal_validate_success',
|
|
@@ -381,12 +383,21 @@ export function AgentZaloPersonalPanel({
|
|
|
381
383
|
icon={<RefreshCw className="size-4" />}
|
|
382
384
|
onRun={actionMutation.mutate}
|
|
383
385
|
/>
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
386
|
+
{phoneSyncRunning ? (
|
|
387
|
+
<ActionButton
|
|
388
|
+
action="cancel-sync-phone"
|
|
389
|
+
busy={isPending || actionMutation.isPending}
|
|
390
|
+
icon={<X className="size-4" />}
|
|
391
|
+
onRun={actionMutation.mutate}
|
|
392
|
+
/>
|
|
393
|
+
) : (
|
|
394
|
+
<ActionButton
|
|
395
|
+
action="sync-phone"
|
|
396
|
+
busy={actionBusy}
|
|
397
|
+
icon={<RefreshCw className="size-4" />}
|
|
398
|
+
onRun={actionMutation.mutate}
|
|
399
|
+
/>
|
|
400
|
+
)}
|
|
390
401
|
<ActionButton
|
|
391
402
|
action="stop"
|
|
392
403
|
busy={actionBusy}
|
|
@@ -79,6 +79,10 @@ function renderShell() {
|
|
|
79
79
|
|
|
80
80
|
describe('SettingsDialogShell keyboard navigation', () => {
|
|
81
81
|
beforeEach(() => {
|
|
82
|
+
Object.defineProperty(window, 'innerWidth', {
|
|
83
|
+
configurable: true,
|
|
84
|
+
value: 1024,
|
|
85
|
+
});
|
|
82
86
|
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
|
83
87
|
addEventListener: vi.fn(),
|
|
84
88
|
dispatchEvent: vi.fn(),
|
|
@@ -105,6 +109,37 @@ describe('SettingsDialogShell keyboard navigation', () => {
|
|
|
105
109
|
).toBeGreaterThan(0);
|
|
106
110
|
});
|
|
107
111
|
|
|
112
|
+
it('keeps the mobile selector first, closes with an X, and uses compact padding', () => {
|
|
113
|
+
Object.defineProperty(window, 'innerWidth', {
|
|
114
|
+
configurable: true,
|
|
115
|
+
value: 375,
|
|
116
|
+
});
|
|
117
|
+
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
|
118
|
+
addEventListener: vi.fn(),
|
|
119
|
+
dispatchEvent: vi.fn(),
|
|
120
|
+
matches: true,
|
|
121
|
+
media: query,
|
|
122
|
+
onchange: null,
|
|
123
|
+
removeEventListener: vi.fn(),
|
|
124
|
+
}));
|
|
125
|
+
|
|
126
|
+
renderShell();
|
|
127
|
+
|
|
128
|
+
const selector = screen.getByRole('combobox');
|
|
129
|
+
const closeIcon = screen.getByTestId('settings-mobile-close-icon');
|
|
130
|
+
const closeButton = closeIcon.closest('button');
|
|
131
|
+
|
|
132
|
+
expect(closeButton).toHaveAccessibleName('common.close');
|
|
133
|
+
expect(
|
|
134
|
+
selector.compareDocumentPosition(closeButton as Node) &
|
|
135
|
+
Node.DOCUMENT_POSITION_FOLLOWING
|
|
136
|
+
).toBeTruthy();
|
|
137
|
+
expect(closeButton?.parentElement).toHaveClass('px-2');
|
|
138
|
+
expect(
|
|
139
|
+
screen.getByTestId('active-tab').parentElement?.parentElement
|
|
140
|
+
).toHaveClass('p-2', 'md:p-6');
|
|
141
|
+
});
|
|
142
|
+
|
|
108
143
|
it('keeps settings groups expanded by default', () => {
|
|
109
144
|
renderShell();
|
|
110
145
|
|
|
@@ -58,6 +58,23 @@ describe('mergeWorkspaceSelectWorkspaces', () => {
|
|
|
58
58
|
/<AvatarFallback[\s\S]*<AvatarImage/u
|
|
59
59
|
);
|
|
60
60
|
});
|
|
61
|
+
|
|
62
|
+
it('lays out the short create and join actions in the command item grid', () => {
|
|
63
|
+
const workspaceSelectSource = readFileSync(
|
|
64
|
+
join(process.cwd(), 'src/components/ui/custom/workspace-select.tsx'),
|
|
65
|
+
'utf8'
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
expect(workspaceSelectSource).toContain(
|
|
69
|
+
'[&_[cmdk-group-items]]:grid-cols-2'
|
|
70
|
+
);
|
|
71
|
+
expect(workspaceSelectSource).toContain(
|
|
72
|
+
"t('common.create_workspace_action')"
|
|
73
|
+
);
|
|
74
|
+
expect(workspaceSelectSource).toContain(
|
|
75
|
+
"t('common.join_workspace_action')"
|
|
76
|
+
);
|
|
77
|
+
});
|
|
61
78
|
});
|
|
62
79
|
|
|
63
80
|
describe('normalizeWorkspaceSwitchPath', () => {
|
|
@@ -371,7 +371,10 @@ export function NavLink({
|
|
|
371
371
|
: undefined;
|
|
372
372
|
|
|
373
373
|
window.dispatchEvent(
|
|
374
|
-
new CustomEvent(SETTINGS_DIALOG_OPEN_INTENT_EVENT, {
|
|
374
|
+
new CustomEvent(SETTINGS_DIALOG_OPEN_INTENT_EVENT, {
|
|
375
|
+
cancelable: true,
|
|
376
|
+
detail,
|
|
377
|
+
})
|
|
375
378
|
);
|
|
376
379
|
onClick();
|
|
377
380
|
} else if (hasSubMenu) {
|