@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.
- package/CHANGELOG.md +36 -0
- package/README.md +29 -7
- package/biome.json +1 -1
- package/package.json +48 -54
- package/src/components/ui/calendar-app/hooks/use-calendar-settings.test.ts +32 -0
- package/src/components/ui/custom/combobox.tsx +4 -0
- package/src/components/ui/custom/nav-link.test.tsx +47 -0
- package/src/components/ui/custom/nav-link.tsx +28 -3
- package/src/components/ui/custom/workspace-access/adapters.test.ts +89 -1
- package/src/components/ui/custom/workspace-access/adapters.ts +67 -2
- package/src/components/ui/custom/workspace-access/types.ts +13 -0
- package/src/components/ui/custom/workspace-access/workspace-access-invitation-role-menu.test.tsx +79 -0
- package/src/components/ui/custom/workspace-access/workspace-access-invitation-role-menu.tsx +125 -0
- package/src/components/ui/custom/workspace-access/workspace-access-invite-access-picker.tsx +108 -0
- package/src/components/ui/custom/workspace-access/workspace-access-invite-dialog.test.tsx +134 -0
- package/src/components/ui/custom/workspace-access/workspace-access-invite-dialog.tsx +94 -113
- package/src/components/ui/custom/workspace-access/workspace-access-invite-pos-panel.tsx +75 -0
- package/src/components/ui/custom/workspace-access/workspace-access-invite-role-picker.tsx +151 -0
- package/src/components/ui/custom/workspace-access/workspace-access-labels.ts +1 -1
- package/src/components/ui/custom/workspace-access/workspace-access-member-row.tsx +27 -2
- package/src/components/ui/custom/workspace-access/workspace-access-members.tsx +10 -0
- package/src/components/ui/custom/workspace-access/workspace-access-page.tsx +102 -3
- package/src/components/ui/custom/workspace-access/workspace-access-role-options.test.ts +35 -0
- package/src/components/ui/custom/workspace-access/workspace-access-role-options.ts +21 -0
- package/src/components/ui/custom/workspace-select-invitations.tsx +2 -1
- package/src/components/ui/legacy/polls/poll-display.test.tsx +118 -0
- package/src/components/ui/legacy/polls/poll-display.tsx +8 -0
- package/src/hooks/__tests__/use-notifications-subscription.test.tsx +34 -1
- package/src/hooks/use-board-actions.test.ts +23 -0
- package/src/hooks/use-board-actions.ts +48 -35
- package/src/hooks/use-calendar-sync.tsx +12 -12
- package/src/hooks/use-notifications.ts +4 -8
- package/src/lib/calendar-settings-resolver.ts +1 -200
- 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
|
-
|
|
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={
|
|
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
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|