@tuturuuu/ui 0.26.0 → 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 (34) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +29 -7
  3. package/biome.json +1 -1
  4. package/package.json +48 -54
  5. package/src/components/ui/calendar-app/hooks/use-calendar-settings.test.ts +32 -0
  6. package/src/components/ui/custom/combobox.tsx +4 -0
  7. package/src/components/ui/custom/nav-link.test.tsx +47 -0
  8. package/src/components/ui/custom/nav-link.tsx +28 -3
  9. package/src/components/ui/custom/workspace-access/adapters.test.ts +89 -1
  10. package/src/components/ui/custom/workspace-access/adapters.ts +67 -2
  11. package/src/components/ui/custom/workspace-access/types.ts +13 -0
  12. package/src/components/ui/custom/workspace-access/workspace-access-invitation-role-menu.test.tsx +79 -0
  13. package/src/components/ui/custom/workspace-access/workspace-access-invitation-role-menu.tsx +125 -0
  14. package/src/components/ui/custom/workspace-access/workspace-access-invite-access-picker.tsx +108 -0
  15. package/src/components/ui/custom/workspace-access/workspace-access-invite-dialog.test.tsx +134 -0
  16. package/src/components/ui/custom/workspace-access/workspace-access-invite-dialog.tsx +94 -113
  17. package/src/components/ui/custom/workspace-access/workspace-access-invite-pos-panel.tsx +75 -0
  18. package/src/components/ui/custom/workspace-access/workspace-access-invite-role-picker.tsx +151 -0
  19. package/src/components/ui/custom/workspace-access/workspace-access-labels.ts +1 -1
  20. package/src/components/ui/custom/workspace-access/workspace-access-member-row.tsx +27 -2
  21. package/src/components/ui/custom/workspace-access/workspace-access-members.tsx +10 -0
  22. package/src/components/ui/custom/workspace-access/workspace-access-page.tsx +102 -3
  23. package/src/components/ui/custom/workspace-access/workspace-access-role-options.test.ts +35 -0
  24. package/src/components/ui/custom/workspace-access/workspace-access-role-options.ts +21 -0
  25. package/src/components/ui/custom/workspace-select-invitations.tsx +2 -1
  26. package/src/components/ui/legacy/polls/poll-display.test.tsx +118 -0
  27. package/src/components/ui/legacy/polls/poll-display.tsx +8 -0
  28. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +34 -1
  29. package/src/hooks/use-board-actions.test.ts +23 -0
  30. package/src/hooks/use-board-actions.ts +48 -35
  31. package/src/hooks/use-calendar-sync.tsx +12 -12
  32. package/src/hooks/use-notifications.ts +4 -8
  33. package/src/lib/calendar-settings-resolver.ts +1 -200
  34. package/src/readme-contract.test.tsx +57 -0
@@ -37,9 +37,16 @@ import { WorkspaceAccessMembers } from './workspace-access-members';
37
37
  import { WorkspaceAccessPageHeader } from './workspace-access-page-header';
38
38
  import { WorkspaceAccessPeopleFilters } from './workspace-access-people-filters';
39
39
  import { WorkspaceAccessRoleEditorDialog } from './workspace-access-role-editor-dialog';
40
+ import { listAllWorkspaceAccessRoles } from './workspace-access-role-options';
40
41
  import { WorkspaceAccessRoles } from './workspace-access-roles';
41
42
  import { WorkspaceAccessTabsToolbar } from './workspace-access-tabs-toolbar';
42
43
 
44
+ type InvitationRoleUpdate = {
45
+ email?: null | string;
46
+ roleIds: string[];
47
+ userId?: null | string;
48
+ };
49
+
43
50
  export function WorkspaceAccessPage({
44
51
  adapter,
45
52
  disableInvite = false,
@@ -57,6 +64,7 @@ export function WorkspaceAccessPage({
57
64
  const [inviteAccessPreset, setInviteAccessPreset] = useState<
58
65
  'guest' | 'member' | 'pos_operator'
59
66
  >('member');
67
+ const [inviteRoleIds, setInviteRoleIds] = useState<string[]>([]);
60
68
  const [confirmDefaultAdminMigration, setConfirmDefaultAdminMigration] =
61
69
  useState(false);
62
70
  const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
@@ -92,6 +100,7 @@ export function WorkspaceAccessPage({
92
100
  const workspaceId = context.workspaceId;
93
101
  const canManageMembers = context.canManageMembers;
94
102
  const canManageRoles = context.canManageRoles;
103
+ const canAssignInviteRoles = canManageRoles && mode === 'workspace';
95
104
  const canInvite = canManageMembers && !disableInvite;
96
105
  const permissionUser = useMemo(
97
106
  () =>
@@ -145,6 +154,12 @@ export function WorkspaceAccessPage({
145
154
  queryKey: ['workspace-access', workspaceId, 'roles', search],
146
155
  staleTime: 30_000,
147
156
  });
157
+ const inviteRolesQuery = useQuery({
158
+ enabled: inviteDialogOpen && canAssignInviteRoles,
159
+ queryFn: () => listAllWorkspaceAccessRoles(adapter, workspaceId),
160
+ queryKey: ['workspace-access', workspaceId, 'invite-roles'],
161
+ staleTime: 30_000,
162
+ });
148
163
  const memberDefaultQuery = useQuery({
149
164
  enabled: canManageRoles,
150
165
  queryFn: () => adapter.getDefaultRole(workspaceId, 'MEMBER'),
@@ -166,6 +181,9 @@ export function WorkspaceAccessPage({
166
181
  queryClient.invalidateQueries({
167
182
  queryKey: ['workspace-access', workspaceId, 'roles'],
168
183
  }),
184
+ queryClient.invalidateQueries({
185
+ queryKey: ['workspace-access', workspaceId, 'invite-roles'],
186
+ }),
169
187
  queryClient.invalidateQueries({
170
188
  queryKey: ['workspace-access', workspaceId, 'defaults'],
171
189
  }),
@@ -179,12 +197,17 @@ export function WorkspaceAccessPage({
179
197
  confirmDefaultAdminMigration,
180
198
  emails: parseInviteEmails(inviteEmails),
181
199
  memberType: inviteAccessPreset === 'guest' ? 'GUEST' : 'MEMBER',
200
+ roleIds:
201
+ canAssignInviteRoles && inviteAccessPreset === 'member'
202
+ ? inviteRoleIds
203
+ : [],
182
204
  }),
183
205
  onError: (error) =>
184
206
  toast.error(error instanceof Error ? error.message : t('common.error')),
185
207
  onSuccess: async (result) => {
186
208
  setInviteEmails('');
187
209
  setInviteAccessPreset('member');
210
+ setInviteRoleIds([]);
188
211
  setConfirmDefaultAdminMigration(false);
189
212
  setInviteDialogOpen(false);
190
213
  toast.success(result.message ?? t('ws-members.invitation-sent'));
@@ -247,6 +270,66 @@ export function WorkspaceAccessPage({
247
270
  await invalidateAccessData();
248
271
  },
249
272
  });
273
+ const invitationRoleMutation = useMutation({
274
+ mutationFn: (payload: InvitationRoleUpdate) => {
275
+ if (!adapter.updateInvitationRole) {
276
+ throw new Error(t('common.error'));
277
+ }
278
+ return adapter.updateInvitationRole(workspaceId, payload);
279
+ },
280
+ onMutate: async (payload) => {
281
+ const queryKey = ['workspace-access', workspaceId, 'members'] as const;
282
+ await queryClient.cancelQueries({ queryKey });
283
+ const previousMembers =
284
+ queryClient.getQueryData<InternalApiEnhancedWorkspaceMember[]>(
285
+ queryKey
286
+ );
287
+ const roleById = new Map(
288
+ (inviteRolesQuery.data?.data ?? rolesQuery.data?.data ?? []).map(
289
+ (role) => [role.id, role]
290
+ )
291
+ );
292
+ const nextRoles = payload.roleIds.flatMap((roleId) => {
293
+ const role = roleById.get(roleId);
294
+ return role ? [{ id: role.id, name: role.name, permissions: [] }] : [];
295
+ });
296
+ const normalizedEmail = payload.email?.trim().toLowerCase() ?? null;
297
+
298
+ queryClient.setQueryData<InternalApiEnhancedWorkspaceMember[]>(
299
+ queryKey,
300
+ (members = []) =>
301
+ members.map((member) => {
302
+ const matchesUser = Boolean(
303
+ payload.userId && member.id === payload.userId
304
+ );
305
+ const matchesEmail = Boolean(
306
+ normalizedEmail &&
307
+ member.email?.trim().toLowerCase() === normalizedEmail
308
+ );
309
+ if (!member.pending || (!matchesUser && !matchesEmail)) {
310
+ return member;
311
+ }
312
+
313
+ return {
314
+ ...member,
315
+ roles: nextRoles,
316
+ };
317
+ })
318
+ );
319
+
320
+ return { previousMembers, queryKey };
321
+ },
322
+ onError: (error, _payload, context) => {
323
+ if (context?.previousMembers) {
324
+ queryClient.setQueryData(context.queryKey, context.previousMembers);
325
+ }
326
+ toast.error(error instanceof Error ? error.message : t('common.error'));
327
+ },
328
+ onSettled: invalidateAccessData,
329
+ onSuccess: () => {
330
+ toast.success(t('ws-members.invitation_role_updated'));
331
+ },
332
+ });
250
333
  const deleteRoleMutation = useMutation({
251
334
  mutationFn: (roleId: string) => adapter.deleteRole(workspaceId, roleId),
252
335
  onError: (error) =>
@@ -394,11 +477,15 @@ export function WorkspaceAccessPage({
394
477
  canEditProfiles={Boolean(adapter.updateMemberProfile)}
395
478
  canManageMembers={canManageMembers}
396
479
  canManageRoles={canManageRoles}
480
+ canUpdateInvitationRoles={
481
+ canManageRoles && Boolean(adapter.updateInvitationRole)
482
+ }
397
483
  defaultAdminEnabled={defaultAdminEnabled}
398
484
  isLoading={membersQuery.isPending}
399
485
  isMutating={
400
486
  removeMemberMutation.isPending ||
401
487
  roleMembershipMutation.isPending ||
488
+ invitationRoleMutation.isPending ||
402
489
  updateMemberProfileMutation.isPending
403
490
  }
404
491
  labels={labels}
@@ -411,7 +498,10 @@ export function WorkspaceAccessPage({
411
498
  onRemoveRole={(payload) =>
412
499
  roleMembershipMutation.mutate({ ...payload, action: 'remove' })
413
500
  }
414
- roles={roles}
501
+ onUpdateInvitationRole={(payload) =>
502
+ invitationRoleMutation.mutate(payload)
503
+ }
504
+ roles={inviteRolesQuery.data?.data ?? roles}
415
505
  searchTerm={search}
416
506
  status={status}
417
507
  />
@@ -475,23 +565,32 @@ export function WorkspaceAccessPage({
475
565
 
476
566
  <WorkspaceAccessInviteDialog
477
567
  accessPreset={inviteAccessPreset}
478
- canManageRoles={canManageRoles}
568
+ canManageRoles={canAssignInviteRoles}
479
569
  confirmDefaultAdminMigration={confirmDefaultAdminMigration}
480
570
  defaultAdminEnabled={defaultAdminEnabled}
481
571
  emails={inviteEmails}
482
572
  isSubmitting={inviteMutation.isPending}
483
573
  joinedMemberCount={joinedCount}
574
+ noRoleLabel={t('ws-members.no_role_assigned')}
484
575
  onAccessPresetChange={(value) => {
485
576
  setInviteAccessPreset(value);
486
- if (value !== 'pos_operator') {
577
+ if (value === 'member') {
487
578
  setConfirmDefaultAdminMigration(false);
579
+ } else {
580
+ setInviteRoleIds([]);
581
+ if (value === 'guest') {
582
+ setConfirmDefaultAdminMigration(false);
583
+ }
488
584
  }
489
585
  }}
490
586
  onConfirmDefaultAdminMigrationChange={setConfirmDefaultAdminMigration}
491
587
  onEmailsChange={setInviteEmails}
492
588
  onOpenChange={setInviteDialogOpen}
589
+ onRoleIdsChange={setInviteRoleIds}
493
590
  onSubmit={() => inviteMutation.mutate()}
494
591
  open={inviteDialogOpen}
592
+ roleIds={inviteRoleIds}
593
+ roles={inviteRolesQuery.data?.data ?? []}
495
594
  />
496
595
 
497
596
  {profileMember ? (
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import type { WorkspaceAccessAdapter, WorkspaceAccessRole } from './types';
3
+ import { listAllWorkspaceAccessRoles } from './workspace-access-role-options';
4
+
5
+ function createRoles(start: number, count: number): WorkspaceAccessRole[] {
6
+ return Array.from({ length: count }, (_, index) => ({
7
+ id: `role-${start + index}`,
8
+ name: `Role ${start + index}`,
9
+ permissions: [],
10
+ }));
11
+ }
12
+
13
+ describe('listAllWorkspaceAccessRoles', () => {
14
+ it('paginates beyond the first 100 assignable roles', async () => {
15
+ const listRoleOptions = vi
16
+ .fn()
17
+ .mockResolvedValueOnce({ count: 125, data: createRoles(0, 100) })
18
+ .mockResolvedValueOnce({ count: 125, data: createRoles(100, 25) });
19
+
20
+ const result = await listAllWorkspaceAccessRoles(
21
+ { listRoleOptions } as unknown as WorkspaceAccessAdapter,
22
+ 'workspace-1'
23
+ );
24
+
25
+ expect(result.data).toHaveLength(125);
26
+ expect(listRoleOptions).toHaveBeenNthCalledWith(1, 'workspace-1', {
27
+ page: '1',
28
+ pageSize: '100',
29
+ });
30
+ expect(listRoleOptions).toHaveBeenNthCalledWith(2, 'workspace-1', {
31
+ page: '2',
32
+ pageSize: '100',
33
+ });
34
+ });
35
+ });
@@ -0,0 +1,21 @@
1
+ import type { WorkspaceAccessAdapter, WorkspaceAccessRole } from './types';
2
+
3
+ export async function listAllWorkspaceAccessRoles(
4
+ adapter: WorkspaceAccessAdapter,
5
+ workspaceId: string
6
+ ) {
7
+ const pageSize = 100;
8
+ const roles: WorkspaceAccessRole[] = [];
9
+
10
+ for (let page = 1; ; page += 1) {
11
+ const result = await adapter.listRoleOptions(workspaceId, {
12
+ page: String(page),
13
+ pageSize: String(pageSize),
14
+ });
15
+ roles.push(...result.data.map((role) => ({ ...role, permissions: [] })));
16
+
17
+ if (roles.length >= result.count || result.data.length < pageSize) {
18
+ return { count: result.count, data: roles };
19
+ }
20
+ }
21
+ }
@@ -31,8 +31,9 @@ export function useWorkspaceInvitations({
31
31
  queryKey: ['workspace-invitations', ...(cacheScope ? [cacheScope] : [])],
32
32
  queryFn: async () => (await listWorkspaceInvitations()).invitations,
33
33
  enabled,
34
+ gcTime: 30 * 60_000,
34
35
  retry: 1,
35
- staleTime: 30_000,
36
+ staleTime: 5 * 60_000,
36
37
  });
37
38
  const invitations = query.data ?? [];
38
39
  const mutation = useMutation({
@@ -0,0 +1,118 @@
1
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { PlanDetailsPollContent } from './poll-display';
4
+
5
+ const mocks = vi.hoisted(() => ({
6
+ addPollOption: vi.fn(),
7
+ createPoll: vi.fn(),
8
+ deletePoll: vi.fn(),
9
+ deletePollOption: vi.fn(),
10
+ refresh: vi.fn(),
11
+ submitVote: vi.fn(),
12
+ toast: vi.fn(),
13
+ toggleWherePoll: vi.fn(),
14
+ useTimeBlocking: vi.fn(),
15
+ }));
16
+
17
+ vi.mock('@tuturuuu/apis/meet/actions', () => ({
18
+ addPollOption: (...args: unknown[]) => mocks.addPollOption(...args),
19
+ createPoll: (...args: unknown[]) => mocks.createPoll(...args),
20
+ deletePoll: (...args: unknown[]) => mocks.deletePoll(...args),
21
+ deletePollOption: (...args: unknown[]) => mocks.deletePollOption(...args),
22
+ submitVote: (...args: unknown[]) => mocks.submitVote(...args),
23
+ toggleWherePoll: (...args: unknown[]) => mocks.toggleWherePoll(...args),
24
+ }));
25
+
26
+ vi.mock('@tuturuuu/ui/hooks/time-blocking-provider', () => ({
27
+ useTimeBlocking: () => mocks.useTimeBlocking(),
28
+ }));
29
+
30
+ vi.mock('@tuturuuu/ui/hooks/use-toast', () => ({
31
+ toast: (...args: unknown[]) => mocks.toast(...args),
32
+ }));
33
+
34
+ vi.mock('next/navigation', () => ({
35
+ useRouter: () => ({ refresh: mocks.refresh }),
36
+ }));
37
+
38
+ vi.mock('next-intl', () => ({
39
+ useTranslations: () => (key: string) => key,
40
+ }));
41
+
42
+ vi.mock('./where-tu-meet', () => ({
43
+ DefaultWherePollContent: ({
44
+ onAddOption,
45
+ onVote,
46
+ }: {
47
+ onAddOption: (pollId: string, value: string) => Promise<unknown>;
48
+ onVote: (pollId: string, optionIds: string[]) => Promise<void>;
49
+ }) => (
50
+ <div>
51
+ <button type="button" onClick={() => onAddOption('poll-1', 'Cafe')}>
52
+ add guest option
53
+ </button>
54
+ <button type="button" onClick={() => onVote('poll-1', ['option-1'])}>
55
+ submit guest vote
56
+ </button>
57
+ </div>
58
+ ),
59
+ }));
60
+
61
+ describe('PlanDetailsPollContent guest authorization', () => {
62
+ beforeEach(() => {
63
+ vi.clearAllMocks();
64
+ mocks.useTimeBlocking.mockReturnValue({
65
+ user: {
66
+ display_name: 'Guest',
67
+ id: 'guest-1',
68
+ is_guest: true,
69
+ password_hash: 'plan-bound-credential',
70
+ },
71
+ });
72
+ mocks.addPollOption.mockResolvedValue({
73
+ data: {
74
+ option: {
75
+ created_at: '2026-08-10T00:00:00.000Z',
76
+ guestVotes: [],
77
+ id: 'option-1',
78
+ poll_id: 'poll-1',
79
+ totalVotes: 0,
80
+ userVotes: [],
81
+ value: 'Cafe',
82
+ },
83
+ },
84
+ });
85
+ mocks.submitVote.mockResolvedValue({ data: { success: true } });
86
+ });
87
+
88
+ it('forwards the selected guest credential to option and vote actions', async () => {
89
+ render(
90
+ <PlanDetailsPollContent
91
+ plan={{ id: 'plan-1', is_confirmed: false, where_to_meet: true }}
92
+ isCreator={false}
93
+ platformUser={null}
94
+ polls={null}
95
+ />
96
+ );
97
+
98
+ fireEvent.click(screen.getByRole('button', { name: 'add guest option' }));
99
+ fireEvent.click(screen.getByRole('button', { name: 'submit guest vote' }));
100
+
101
+ await waitFor(() => {
102
+ expect(mocks.addPollOption).toHaveBeenCalledWith('plan-1', {
103
+ guestId: 'guest-1',
104
+ guestPasswordHash: 'plan-bound-credential',
105
+ pollId: 'poll-1',
106
+ userType: 'GUEST',
107
+ value: 'Cafe',
108
+ });
109
+ expect(mocks.submitVote).toHaveBeenCalledWith('plan-1', {
110
+ guestId: 'guest-1',
111
+ guestPasswordHash: 'plan-bound-credential',
112
+ optionIds: ['option-1'],
113
+ pollId: 'poll-1',
114
+ userType: 'GUEST',
115
+ });
116
+ });
117
+ });
118
+ });
@@ -94,6 +94,10 @@ export function PlanDetailsPollContent({
94
94
  optionIds,
95
95
  userType: userType as 'PLATFORM' | 'GUEST',
96
96
  guestId: userType === 'GUEST' ? (user?.id ?? undefined) : undefined,
97
+ guestPasswordHash:
98
+ userType === 'GUEST' && guestUser?.is_guest
99
+ ? guestUser.password_hash
100
+ : undefined,
97
101
  });
98
102
  if (result.error) {
99
103
  toast({
@@ -111,6 +115,10 @@ export function PlanDetailsPollContent({
111
115
  value,
112
116
  userType: userType as 'PLATFORM' | 'GUEST',
113
117
  guestId: userType === 'GUEST' ? (user?.id ?? undefined) : undefined,
118
+ guestPasswordHash:
119
+ userType === 'GUEST' && guestUser?.is_guest
120
+ ? guestUser.password_hash
121
+ : undefined,
114
122
  });
115
123
  if (result.error) {
116
124
  toast({
@@ -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
  }