@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.
Files changed (63) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/package.json +70 -69
  3. package/src/components/ui/calendar-app/calendar-client-page.tsx +19 -1
  4. package/src/components/ui/calendar-app/calendar-page-shell.tsx +37 -16
  5. package/src/components/ui/calendar-app/components/calendar-connections-manager-helpers.ts +70 -0
  6. package/src/components/ui/calendar-app/components/calendar-connections-settings-content.tsx +516 -578
  7. package/src/components/ui/calendar-app/components/calendar-connections-unified.tsx +1 -0
  8. package/src/components/ui/calendar-app/components/calendar-sync-settings-panel.tsx +155 -0
  9. package/src/components/ui/calendar-app/components/merge-provider-calendars.test.ts +46 -0
  10. package/src/components/ui/calendar-app/components/merge-provider-calendars.ts +81 -0
  11. package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +22 -129
  12. package/src/components/ui/chat/chat-agent-details-operations-panel.test.tsx +17 -3
  13. package/src/components/ui/chat/chat-agent-details-utils.tsx +2 -0
  14. package/src/components/ui/chat/chat-agent-details-zalo-personal-panel.tsx +17 -6
  15. package/src/components/ui/custom/__tests__/settings-dialog-shell.test.tsx +35 -0
  16. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +17 -0
  17. package/src/components/ui/custom/nav-link.test.tsx +1 -0
  18. package/src/components/ui/custom/nav-link.tsx +4 -1
  19. package/src/components/ui/custom/settings-dialog-shell.tsx +23 -14
  20. package/src/components/ui/custom/statistic-card.tsx +3 -5
  21. package/src/components/ui/custom/structure.test.tsx +4 -0
  22. package/src/components/ui/custom/user-filters.tsx +4 -7
  23. package/src/components/ui/custom/workspace-access/workspace-access-page-header.tsx +1 -1
  24. package/src/components/ui/custom/workspace-access/workspace-access-tabs-toolbar.tsx +1 -1
  25. package/src/components/ui/custom/workspace-select.tsx +38 -53
  26. package/src/components/ui/finance/analytics/category-spending-chart.test.tsx +3 -3
  27. package/src/components/ui/finance/budgets/budget-card.test.tsx +3 -3
  28. package/src/components/ui/finance/debts/debt-loan-card.test.tsx +3 -3
  29. package/src/components/ui/finance/debts/debt-loan-detail-cards.test.tsx +3 -3
  30. package/src/components/ui/finance/invoices/charts/invoice-totals-chart.test.tsx +3 -3
  31. package/src/components/ui/finance/invoices/columns.test.tsx +3 -3
  32. package/src/components/ui/finance/invoices/components/invoice-customer-select-card.tsx +1 -1
  33. package/src/components/ui/finance/shared/numbers-visibility-toggle.test.tsx +1 -1
  34. package/src/components/ui/finance/tags/tag-manager.test.tsx +2 -2
  35. package/src/components/ui/finance/transactions/categories/columns.test.tsx +5 -5
  36. package/src/components/ui/finance/transactions/columns.test.tsx +2 -2
  37. package/src/components/ui/finance/transactions/confidential-field.test.tsx +2 -2
  38. package/src/components/ui/finance/transactions/transaction-edit-dialog.test.tsx +2 -2
  39. package/src/components/ui/finance/transactions/transaction-statistics.test.tsx +3 -3
  40. package/src/components/ui/finance/wallets/form.test.tsx +3 -3
  41. package/src/components/ui/finance/wallets/wallets-data-table.test.tsx +10 -0
  42. package/src/components/ui/legacy/calendar/calendar-cell.tsx +1 -1
  43. package/src/components/ui/storefront/accent-button.tsx +4 -1
  44. package/src/components/ui/storefront/browse-panel.tsx +6 -1
  45. package/src/components/ui/storefront/cart-summary-parts.tsx +18 -4
  46. package/src/components/ui/storefront/cart-summary.tsx +22 -6
  47. package/src/components/ui/storefront/hero-panel.tsx +13 -17
  48. package/src/components/ui/storefront/index.ts +2 -0
  49. package/src/components/ui/storefront/listing-card.tsx +6 -4
  50. package/src/components/ui/storefront/product-detail.tsx +51 -21
  51. package/src/components/ui/storefront/product-dialog.tsx +8 -1
  52. package/src/components/ui/storefront/storefront-surface.test.tsx +79 -3
  53. package/src/components/ui/storefront/storefront-surface.tsx +151 -100
  54. package/src/components/ui/storefront/types.ts +3 -0
  55. package/src/components/ui/storefront/utils.ts +5 -1
  56. package/src/components/ui/text-editor/__tests__/task-mention-checkbox.test.ts +23 -6
  57. package/src/components/ui/text-editor/__tests__/upload-placeholder.test.ts +1 -10
  58. package/src/components/ui/text-editor/color-controls.tsx +1 -1
  59. package/src/globals.css +20 -0
  60. package/src/globals.test.ts +75 -0
  61. package/src/hooks/__tests__/use-calendar-sync.test.tsx +103 -1
  62. package/src/hooks/use-calendar-sync.tsx +51 -14
  63. package/src/lib/task-personal-external.ts +1 -1
@@ -53,10 +53,20 @@ function renderCalendarSync({
53
53
  return renderHook(() => useCalendarSync(), { wrapper });
54
54
  }
55
55
 
56
- function mockCalendarFetch(events: CalendarEvent[]) {
56
+ function mockCalendarFetch(
57
+ events: CalendarEvent[],
58
+ calendars: Array<{ id: string; is_enabled: boolean }> = []
59
+ ) {
57
60
  return vi.fn(async (input: RequestInfo | URL) => {
58
61
  const url = String(input);
59
62
 
63
+ if (url.includes('/calendars')) {
64
+ return {
65
+ ok: true,
66
+ json: async () => ({ calendars, grouped: {}, total: calendars.length }),
67
+ } as Response;
68
+ }
69
+
60
70
  if (url.includes('/calendar/habit-events')) {
61
71
  return {
62
72
  ok: true,
@@ -95,6 +105,13 @@ function mockCalendarFetchByWeek(
95
105
  return vi.fn(async (input: RequestInfo | URL) => {
96
106
  const url = new URL(String(input), 'http://localhost');
97
107
 
108
+ if (url.pathname.endsWith('/calendars')) {
109
+ return {
110
+ ok: true,
111
+ json: async () => ({ calendars: [], grouped: {}, total: 0 }),
112
+ } as Response;
113
+ }
114
+
98
115
  if (url.pathname.includes('/calendar/habit-events')) {
99
116
  return {
100
117
  ok: true,
@@ -252,6 +269,60 @@ describe('CalendarSyncProvider optimistic visible events', () => {
252
269
  expect(result.current.events.map((event) => event.id)).toEqual(['event-1']);
253
270
  });
254
271
 
272
+ it('clears a transient event loading warning after the next successful fetch', async () => {
273
+ const initialEvent = createEvent('event-1');
274
+ const fetchMock = mockCalendarFetch([initialEvent]);
275
+ vi.stubGlobal('fetch', fetchMock);
276
+
277
+ const { result } = renderCalendarSync();
278
+
279
+ act(() => {
280
+ result.current.setDates([new Date('2026-06-22T00:00:00.000Z')]);
281
+ });
282
+
283
+ await waitFor(() => {
284
+ expect(result.current.events).toHaveLength(1);
285
+ });
286
+
287
+ fetchMock.mockImplementation(async (input: RequestInfo | URL) => {
288
+ if (String(input).includes('/calendar/habit-events')) {
289
+ return {
290
+ ok: true,
291
+ json: async () => ({
292
+ completedHabitEventIds: [],
293
+ habitEventIds: [],
294
+ }),
295
+ } as Response;
296
+ }
297
+
298
+ return {
299
+ ok: false,
300
+ json: async () => ({ error: 'Temporary outage' }),
301
+ } as Response;
302
+ });
303
+
304
+ act(() => {
305
+ result.current.refresh();
306
+ });
307
+
308
+ await waitFor(() => {
309
+ expect(result.current.syncStatus).toMatchObject({
310
+ message: 'failed_to_load_events',
311
+ state: 'error',
312
+ });
313
+ });
314
+
315
+ fetchMock.mockImplementation(mockCalendarFetch([initialEvent]));
316
+ act(() => {
317
+ result.current.refresh();
318
+ });
319
+
320
+ await waitFor(() => {
321
+ expect(result.current.syncStatus.state).toBe('idle');
322
+ });
323
+ expect(result.current.error).toBeNull();
324
+ });
325
+
255
326
  it('shows the active week events when navigating away and back', async () => {
256
327
  const currentWeekEvent = createEvent('current-week-event', {
257
328
  start_at: '2026-06-22T09:00:00.000Z',
@@ -443,4 +514,35 @@ describe('CalendarSyncProvider optimistic visible events', () => {
443
514
  expect.objectContaining({ method: 'DELETE' })
444
515
  );
445
516
  });
517
+
518
+ it('hides events whose workspace calendar is disabled', async () => {
519
+ const visibleEvent = createEvent('visible-event', {
520
+ source_calendar_id: 'calendar-visible',
521
+ });
522
+ const hiddenEvent = createEvent('hidden-event', {
523
+ source_calendar_id: 'calendar-hidden',
524
+ });
525
+ vi.stubGlobal(
526
+ 'fetch',
527
+ mockCalendarFetch(
528
+ [visibleEvent, hiddenEvent],
529
+ [
530
+ { id: 'calendar-visible', is_enabled: true },
531
+ { id: 'calendar-hidden', is_enabled: false },
532
+ ]
533
+ )
534
+ );
535
+
536
+ const { result } = renderCalendarSync();
537
+
538
+ act(() => {
539
+ result.current.setDates([new Date('2026-06-22T00:00:00.000Z')]);
540
+ });
541
+
542
+ await waitFor(() => {
543
+ expect(result.current.events.map((event) => event.id)).toEqual([
544
+ 'visible-event',
545
+ ]);
546
+ });
547
+ });
446
548
  });
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useQuery, useQueryClient } from '@tanstack/react-query';
4
+ import { listWorkspaceCalendars } from '@tuturuuu/internal-api/calendar';
4
5
  import type {
5
6
  Workspace,
6
7
  WorkspaceCalendarEvent,
@@ -235,6 +236,22 @@ export const CalendarSyncProvider = ({
235
236
  );
236
237
  }, [calendarConnections]);
237
238
 
239
+ const { data: workspaceCalendarsData } = useQuery({
240
+ queryKey: ['workspace-calendars', wsId],
241
+ enabled: !hasExternalEvents && !!wsId,
242
+ queryFn: () => listWorkspaceCalendars(wsId),
243
+ staleTime: 30_000,
244
+ });
245
+ const enabledWorkspaceCalendarIds = useMemo(
246
+ () =>
247
+ new Set(
248
+ (workspaceCalendarsData?.calendars ?? [])
249
+ .filter((calendar) => calendar.is_enabled)
250
+ .map((calendar) => calendar.id)
251
+ ),
252
+ [workspaceCalendarsData?.calendars]
253
+ );
254
+
238
255
  // Update calendar connection state
239
256
  const updateCalendarConnection = useCallback(
240
257
  (connectionId: string, isEnabled: boolean) => {
@@ -454,8 +471,8 @@ export const CalendarSyncProvider = ({
454
471
  );
455
472
 
456
473
  if (!response.ok) {
457
- const errorData = await response.json();
458
- throw new Error(errorData.error || 'Failed to fetch events');
474
+ const errorData = await response.json().catch(() => null);
475
+ throw new Error(errorData?.error || 'Failed to fetch events');
459
476
  }
460
477
 
461
478
  const result = await response.json();
@@ -471,6 +488,12 @@ export const CalendarSyncProvider = ({
471
488
  isForcedRef.current = false;
472
489
 
473
490
  setError(null);
491
+ setSyncStatus((currentStatus) =>
492
+ currentStatus.state === 'error' &&
493
+ currentStatus.message === 'failed_to_load_events'
494
+ ? { state: 'idle' }
495
+ : currentStatus
496
+ );
474
497
  return fetchedData;
475
498
  } catch (err) {
476
499
  const errorMessage =
@@ -745,18 +768,30 @@ export const CalendarSyncProvider = ({
745
768
  const visibleEventsWithOptimisticState = useMemo(() => {
746
769
  const removedIds = new Set(optimisticState.removedIds);
747
770
 
748
- // Filter external events by enabled provider calendars. Local Tuturuuu
749
- // events are always shown here; native calendar visibility is handled
750
- // by the workspace calendar endpoints.
751
- const filteredEvents =
752
- !hasExternalEvents && calendarConnections.length > 0
753
- ? (visibleDatabaseEvents as CalendarEvent[]).filter((event) => {
754
- const eventCalendarId =
755
- (event as any).external_calendar_id ||
756
- (event as any).google_calendar_id;
757
- return !eventCalendarId || enabledCalendarIds.has(eventCalendarId);
758
- })
759
- : (visibleDatabaseEvents as CalendarEvent[]);
771
+ // Filter native and external events using the same visibility controls as
772
+ // the sidebar. Events without a source remain visible for backwards
773
+ // compatibility with older calendar rows.
774
+ const filteredEvents = !hasExternalEvents
775
+ ? (visibleDatabaseEvents as CalendarEvent[]).filter((event) => {
776
+ const sourceCalendarId = event.source_calendar_id;
777
+ const eventCalendarId =
778
+ event.external_calendar_id || event.google_calendar_id;
779
+
780
+ if (
781
+ sourceCalendarId &&
782
+ (workspaceCalendarsData?.calendars.length ?? 0) > 0 &&
783
+ !enabledWorkspaceCalendarIds.has(sourceCalendarId)
784
+ ) {
785
+ return false;
786
+ }
787
+
788
+ return (
789
+ !eventCalendarId ||
790
+ calendarConnections.length === 0 ||
791
+ enabledCalendarIds.has(eventCalendarId)
792
+ );
793
+ })
794
+ : (visibleDatabaseEvents as CalendarEvent[]);
760
795
 
761
796
  const byId = new Map<string, CalendarEvent>();
762
797
 
@@ -782,11 +817,13 @@ export const CalendarSyncProvider = ({
782
817
  }, [
783
818
  calendarConnections.length,
784
819
  enabledCalendarIds,
820
+ enabledWorkspaceCalendarIds,
785
821
  hasExternalEvents,
786
822
  isVisibleInCurrentRange,
787
823
  optimisticState.events,
788
824
  optimisticState.removedIds,
789
825
  visibleDatabaseEvents,
826
+ workspaceCalendarsData?.calendars.length,
790
827
  ]);
791
828
 
792
829
  useEffect(() => {
@@ -1,5 +1,5 @@
1
1
  import type { Task } from '@tuturuuu/types/primitives/Task';
2
- import { isPersonalExternalStagingListId } from '@tuturuuu/utils/task-helper';
2
+ import { isPersonalExternalStagingListId } from '@tuturuuu/utils/task-helper/personal-external-staging';
3
3
 
4
4
  function hasValue(value: string | null | undefined): value is string {
5
5
  return typeof value === 'string' && value.length > 0;