@tuturuuu/ui 0.26.1 → 0.27.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 (26) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/biome.json +1 -1
  3. package/package.json +48 -48
  4. package/src/components/ui/custom/combobox.tsx +4 -0
  5. package/src/components/ui/custom/workspace-access/adapters.test.ts +89 -1
  6. package/src/components/ui/custom/workspace-access/adapters.ts +67 -2
  7. package/src/components/ui/custom/workspace-access/types.ts +13 -0
  8. package/src/components/ui/custom/workspace-access/workspace-access-invitation-role-menu.test.tsx +79 -0
  9. package/src/components/ui/custom/workspace-access/workspace-access-invitation-role-menu.tsx +125 -0
  10. package/src/components/ui/custom/workspace-access/workspace-access-invite-access-picker.tsx +108 -0
  11. package/src/components/ui/custom/workspace-access/workspace-access-invite-dialog.test.tsx +134 -0
  12. package/src/components/ui/custom/workspace-access/workspace-access-invite-dialog.tsx +94 -113
  13. package/src/components/ui/custom/workspace-access/workspace-access-invite-pos-panel.tsx +75 -0
  14. package/src/components/ui/custom/workspace-access/workspace-access-invite-role-picker.tsx +151 -0
  15. package/src/components/ui/custom/workspace-access/workspace-access-labels.ts +1 -1
  16. package/src/components/ui/custom/workspace-access/workspace-access-member-row.tsx +27 -2
  17. package/src/components/ui/custom/workspace-access/workspace-access-members.tsx +10 -0
  18. package/src/components/ui/custom/workspace-access/workspace-access-page.tsx +102 -3
  19. package/src/components/ui/custom/workspace-access/workspace-access-role-options.test.ts +35 -0
  20. package/src/components/ui/custom/workspace-access/workspace-access-role-options.ts +21 -0
  21. package/src/components/ui/custom/workspace-select-invitations.tsx +2 -1
  22. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +34 -1
  23. package/src/hooks/use-board-actions.test.ts +23 -0
  24. package/src/hooks/use-board-actions.ts +48 -35
  25. package/src/hooks/use-calendar-sync.tsx +12 -12
  26. package/src/hooks/use-notifications.ts +4 -8
@@ -9,6 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
9
9
  import {
10
10
  __resetNotificationSubscriptionRegistryForTests,
11
11
  UNREAD_COUNT_FALLBACK_INTERVAL_MS,
12
+ UNREAD_COUNT_STALE_TIME_MS,
12
13
  useInfiniteNotifications,
13
14
  useNotificationSubscription,
14
15
  useUnreadCount,
@@ -16,6 +17,7 @@ import {
16
17
 
17
18
  type RealtimePayload = {
18
19
  new?: {
20
+ read_at?: string | null;
19
21
  data?: {
20
22
  action_taken?: boolean;
21
23
  };
@@ -126,8 +128,15 @@ describe('useNotificationSubscription', () => {
126
128
  wrapper: createWrapper(queryClient),
127
129
  });
128
130
 
131
+ const queryOptions = queryClient.getQueryCache().find({
132
+ queryKey: ['notifications', 'unread-count', 'all'],
133
+ })?.options as { refetchInterval?: number; staleTime?: number } | undefined;
134
+
129
135
  expect(fetchMock).not.toHaveBeenCalled();
130
- expect(UNREAD_COUNT_FALLBACK_INTERVAL_MS).toBe(5 * 60 * 1000);
136
+ expect(queryOptions?.staleTime).toBe(UNREAD_COUNT_STALE_TIME_MS);
137
+ expect(queryOptions?.refetchInterval).toBe(
138
+ UNREAD_COUNT_FALLBACK_INTERVAL_MS
139
+ );
131
140
  });
132
141
 
133
142
  it('shares one realtime channel across multiple consumers for the same user', async () => {
@@ -157,6 +166,30 @@ describe('useNotificationSubscription', () => {
157
166
  expect(removeChannelMock).toHaveBeenCalledTimes(1);
158
167
  });
159
168
 
169
+ it('invalidates unread counts for read-state-only realtime updates', async () => {
170
+ const queryClient = createQueryClient();
171
+ const invalidate = vi.spyOn(queryClient, 'invalidateQueries');
172
+ const subscription = renderHook(
173
+ () => useNotificationSubscription(null, 'user-1'),
174
+ { wrapper: createWrapper(queryClient) }
175
+ );
176
+
177
+ await waitFor(() => {
178
+ expect(postgresCallbacks).toHaveLength(3);
179
+ });
180
+
181
+ postgresCallbacks[1]?.({ new: { read_at: new Date().toISOString() } });
182
+
183
+ expect(invalidate).toHaveBeenCalledWith({
184
+ queryKey: ['notifications'],
185
+ });
186
+ expect(invalidate).toHaveBeenCalledWith({
187
+ queryKey: ['notifications', 'unread-count'],
188
+ });
189
+
190
+ subscription.unmount();
191
+ });
192
+
160
193
  it('invalidates every mounted query client from the shared subscription', async () => {
161
194
  const firstQueryClient = createQueryClient();
162
195
  const secondQueryClient = createQueryClient();
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getBoardActionError } from './use-board-actions';
3
+
4
+ describe('getBoardActionError', () => {
5
+ it('uses the API error field returned by board lifecycle routes', async () => {
6
+ const response = Response.json(
7
+ { error: "You don't have access to this workspace" },
8
+ { status: 403 }
9
+ );
10
+
11
+ await expect(getBoardActionError(response, 'fallback')).resolves.toBe(
12
+ "You don't have access to this workspace"
13
+ );
14
+ });
15
+
16
+ it('falls back when the response is not JSON', async () => {
17
+ const response = new Response('Bad gateway', { status: 502 });
18
+
19
+ await expect(
20
+ getBoardActionError(response, 'Board action failed')
21
+ ).resolves.toBe('Board action failed');
22
+ });
23
+ });
@@ -2,6 +2,20 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
2
2
  import { toast } from '@tuturuuu/ui/sonner';
3
3
  import { getTaskApiUrl } from '../lib/tasks-app-url';
4
4
 
5
+ export async function getBoardActionError(
6
+ response: Response,
7
+ fallback: string
8
+ ) {
9
+ const errorData = (await response.json().catch(() => null)) as {
10
+ error?: unknown;
11
+ message?: unknown;
12
+ } | null;
13
+
14
+ if (typeof errorData?.error === 'string') return errorData.error;
15
+ if (typeof errorData?.message === 'string') return errorData.message;
16
+ return fallback;
17
+ }
18
+
5
19
  async function boardAction(
6
20
  wsId: string,
7
21
  boardId: string,
@@ -21,8 +35,7 @@ async function boardAction(
21
35
  );
22
36
 
23
37
  if (!response.ok) {
24
- const errorData = await response.json();
25
- throw new Error(errorData.message || 'Board action failed');
38
+ throw new Error(await getBoardActionError(response, 'Board action failed'));
26
39
  }
27
40
 
28
41
  return response.json();
@@ -45,8 +58,9 @@ async function archiveAction(
45
58
  );
46
59
 
47
60
  if (!response.ok) {
48
- const errorData = await response.json();
49
- throw new Error(errorData.message || 'Archive action failed');
61
+ throw new Error(
62
+ await getBoardActionError(response, 'Archive action failed')
63
+ );
50
64
  }
51
65
 
52
66
  return response.json();
@@ -59,19 +73,28 @@ interface BoardActionOptions {
59
73
  export function useBoardActions(wsId: string) {
60
74
  const queryClient = useQueryClient();
61
75
 
76
+ const invalidateBoardQueries = (boardId: string) => {
77
+ void queryClient.invalidateQueries({ queryKey: ['boards', wsId] });
78
+ void queryClient.invalidateQueries({
79
+ queryKey: ['accessible-task-boards'],
80
+ });
81
+ void queryClient.invalidateQueries({
82
+ queryKey: ['task-board', wsId, boardId],
83
+ });
84
+ void queryClient.invalidateQueries({
85
+ queryKey: ['task-board-settings', wsId, boardId],
86
+ });
87
+ };
88
+
62
89
  const softDeleteMutation = useMutation<
63
90
  any,
64
91
  Error,
65
92
  { boardId: string; options?: BoardActionOptions }
66
93
  >({
67
94
  mutationFn: ({ boardId }) => boardAction(wsId, boardId, 'PUT'),
68
- onSuccess: (_, { options }) => {
95
+ onSuccess: (_, { boardId, options }) => {
69
96
  toast.success('Board moved to trash successfully');
70
- // Invalidate all queries that start with ['boards', wsId]
71
- // Using exact: false (default) to match all queries with this prefix
72
- queryClient.invalidateQueries({
73
- queryKey: ['boards', wsId],
74
- });
97
+ invalidateBoardQueries(boardId);
75
98
  options?.onSuccess?.();
76
99
  },
77
100
  onError: (error: Error) => {
@@ -87,12 +110,9 @@ export function useBoardActions(wsId: string) {
87
110
  { boardId: string; options?: BoardActionOptions }
88
111
  >({
89
112
  mutationFn: ({ boardId }) => boardAction(wsId, boardId, 'DELETE'),
90
- onSuccess: (_, { options }) => {
113
+ onSuccess: (_, { boardId, options }) => {
91
114
  toast.success('Board permanently deleted successfully');
92
- // Invalidate all queries that start with ['boards', wsId]
93
- queryClient.invalidateQueries({
94
- queryKey: ['boards', wsId],
95
- });
115
+ invalidateBoardQueries(boardId);
96
116
  options?.onSuccess?.();
97
117
  },
98
118
  onError: (error: Error) => {
@@ -109,12 +129,9 @@ export function useBoardActions(wsId: string) {
109
129
  >({
110
130
  mutationFn: ({ boardId }) =>
111
131
  boardAction(wsId, boardId, 'PATCH', { restore: true }),
112
- onSuccess: (_, { options }) => {
132
+ onSuccess: (_, { boardId, options }) => {
113
133
  toast.success('Board restored successfully');
114
- // Invalidate all queries that start with ['boards', wsId]
115
- queryClient.invalidateQueries({
116
- queryKey: ['boards', wsId],
117
- });
134
+ invalidateBoardQueries(boardId);
118
135
  options?.onSuccess?.();
119
136
  },
120
137
  onError: (error: Error) => {
@@ -130,12 +147,9 @@ export function useBoardActions(wsId: string) {
130
147
  { boardId: string; options?: BoardActionOptions }
131
148
  >({
132
149
  mutationFn: ({ boardId }) => archiveAction(wsId, boardId, 'POST'),
133
- onSuccess: (_, { options }) => {
150
+ onSuccess: (_, { boardId, options }) => {
134
151
  toast.success('Board archived successfully');
135
- // Invalidate all queries that start with ['boards', wsId]
136
- queryClient.invalidateQueries({
137
- queryKey: ['boards', wsId],
138
- });
152
+ invalidateBoardQueries(boardId);
139
153
  options?.onSuccess?.();
140
154
  },
141
155
  onError: (error: Error) => {
@@ -151,12 +165,9 @@ export function useBoardActions(wsId: string) {
151
165
  { boardId: string; options?: BoardActionOptions }
152
166
  >({
153
167
  mutationFn: ({ boardId }) => archiveAction(wsId, boardId, 'DELETE'),
154
- onSuccess: (_, { options }) => {
168
+ onSuccess: (_, { boardId, options }) => {
155
169
  toast.success('Board unarchived successfully');
156
- // Invalidate all queries that start with ['boards', wsId]
157
- queryClient.invalidateQueries({
158
- queryKey: ['boards', wsId],
159
- });
170
+ invalidateBoardQueries(boardId);
160
171
  options?.onSuccess?.();
161
172
  },
162
173
  onError: (error: Error) => {
@@ -191,12 +202,9 @@ export function useBoardActions(wsId: string) {
191
202
  }
192
203
  return res.json();
193
204
  }),
194
- onSuccess: (_, { options }) => {
205
+ onSuccess: (_, { boardId, options }) => {
195
206
  toast.success('Board duplicated successfully');
196
- // Invalidate all queries that start with ['boards', wsId]
197
- queryClient.invalidateQueries({
198
- queryKey: ['boards', wsId],
199
- });
207
+ invalidateBoardQueries(boardId);
200
208
  options?.onSuccess?.();
201
209
  },
202
210
  onError: (error: Error) => {
@@ -219,5 +227,10 @@ export function useBoardActions(wsId: string) {
219
227
  unarchiveMutation.mutate({ boardId, options }),
220
228
  duplicateBoard: (boardId: string, options?: BoardActionOptions) =>
221
229
  duplicateMutation.mutate({ boardId, options }),
230
+ isArchiving: archiveMutation.isPending,
231
+ isDeleting: softDeleteMutation.isPending,
232
+ isPermanentlyDeleting: permanentDeleteMutation.isPending,
233
+ isRestoring: restoreMutation.isPending,
234
+ isUnarchiving: unarchiveMutation.isPending,
222
235
  };
223
236
  }
@@ -240,7 +240,7 @@ export const CalendarSyncProvider = ({
240
240
  queryKey: ['workspace-calendars', wsId],
241
241
  enabled: !hasExternalEvents && !!wsId,
242
242
  queryFn: () => listWorkspaceCalendars(wsId),
243
- staleTime: 30_000,
243
+ staleTime: 5 * 60_000,
244
244
  });
245
245
  const enabledWorkspaceCalendarIds = useMemo(
246
246
  () =>
@@ -438,12 +438,11 @@ export const CalendarSyncProvider = ({
438
438
  [isVisibleInCurrentRange]
439
439
  );
440
440
 
441
- // Fetch database events with caching
442
441
  const { data: fetchedData, isLoading: isDatabaseLoading } = useQuery({
443
442
  queryKey: ['databaseCalendarEvents', wsId, activeCacheKey],
444
443
  enabled: !hasExternalEvents && !!wsId && dates.length > 0,
445
- staleTime: 30000, // Consider data fresh for 30 seconds
446
- gcTime: 5 * 60 * 1000, // Keep in cache for 5 minutes
444
+ staleTime: 2 * 60_000,
445
+ gcTime: 30 * 60_000,
447
446
  queryFn: async () => {
448
447
  if (!activeCacheKey) return null;
449
448
 
@@ -512,10 +511,11 @@ export const CalendarSyncProvider = ({
512
511
  lastSyncTime: new Date(),
513
512
  });
514
513
 
515
- return cachedData?.dbEvents ?? [];
514
+ throw err instanceof Error ? err : new Error(errorMessage);
516
515
  }
517
516
  },
518
- refetchInterval: 60000, // Reduced from 30s to 60s to lower load
517
+ refetchInterval: 5 * 60_000,
518
+ refetchIntervalInBackground: false,
519
519
  });
520
520
 
521
521
  // Legacy direct Google fetch/reconcile is disabled. Provider inbound sync is
@@ -523,18 +523,17 @@ export const CalendarSyncProvider = ({
523
523
  const { isLoading: isGoogleLoading } = useQuery({
524
524
  queryKey: ['googleCalendarEvents', wsId, activeCacheKey],
525
525
  enabled: false,
526
- staleTime: 30000, // Consider data fresh for 30 seconds
527
- gcTime: 5 * 60 * 1000, // Keep in cache for 5 minutes
526
+ staleTime: 2 * 60_000,
527
+ gcTime: 30 * 60_000,
528
528
  queryFn: async () => null,
529
- refetchInterval: 60000, // Reduced from 30s to 60s to lower load
530
529
  });
531
530
 
532
531
  // Fetch habit calendar events to identify which events are habits
533
532
  const { data: habitEventData } = useQuery({
534
533
  queryKey: ['habitCalendarEvents', wsId, activeCacheKey],
535
534
  enabled: !hasExternalEvents && !!wsId && dates.length > 0,
536
- staleTime: 60000, // Consider data fresh for 1 minute
537
- gcTime: 5 * 60 * 1000, // Keep in cache for 5 minutes
535
+ staleTime: 2 * 60_000,
536
+ gcTime: 30 * 60_000,
538
537
  queryFn: async () => {
539
538
  const startDate = dayjs(dates[0]).startOf('day');
540
539
  const endDate = dayjs(dates[dates.length - 1])
@@ -571,7 +570,8 @@ export const CalendarSyncProvider = ({
571
570
  };
572
571
  }
573
572
  },
574
- refetchInterval: 60000, // Refetch every minute
573
+ refetchInterval: 5 * 60_000,
574
+ refetchIntervalInBackground: false,
575
575
  });
576
576
 
577
577
  // Helper to check if dates have actually changed
@@ -91,7 +91,8 @@ interface NotificationSubscriptionEntry {
91
91
  supabase: SupabaseClient;
92
92
  }
93
93
 
94
- export const UNREAD_COUNT_FALLBACK_INTERVAL_MS = 5 * 60 * 1000;
94
+ export const UNREAD_COUNT_STALE_TIME_MS = 5 * 60 * 1000;
95
+ export const UNREAD_COUNT_FALLBACK_INTERVAL_MS = 15 * 60 * 1000;
95
96
 
96
97
  const notificationSubscriptionRegistry = new Map<
97
98
  string,
@@ -164,12 +165,7 @@ function createNotificationSubscriptionEntry(
164
165
  table: 'notifications',
165
166
  filter: `user_id=eq.${userId}`,
166
167
  },
167
- (payload) => {
168
- const newRecord = payload.new as Notification;
169
- if (newRecord?.data?.action_taken) {
170
- invalidateQueries();
171
- }
172
- }
168
+ invalidateQueries
173
169
  )
174
170
  .on(
175
171
  'postgres_changes',
@@ -348,7 +344,7 @@ export function useUnreadCount(
348
344
  return data.count as number;
349
345
  },
350
346
  enabled: options?.enabled ?? true,
351
- staleTime: 60_000,
347
+ staleTime: UNREAD_COUNT_STALE_TIME_MS,
352
348
  // Realtime invalidation is the primary update path. Keep a low-frequency
353
349
  // refresh as a safety net for disconnected or suspended browser sessions.
354
350
  refetchInterval: UNREAD_COUNT_FALLBACK_INTERVAL_MS,