@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
|
@@ -17,17 +17,24 @@ import {
|
|
|
17
17
|
createWorkspaceRole,
|
|
18
18
|
deleteWorkspaceRole,
|
|
19
19
|
getWorkspaceDefaultPermissions,
|
|
20
|
+
listWorkspaceRoleOptions,
|
|
20
21
|
listWorkspaceRoles,
|
|
21
22
|
updateWorkspaceDefaultPermissions,
|
|
22
23
|
updateWorkspaceRole,
|
|
23
24
|
} from '@tuturuuu/internal-api/settings';
|
|
25
|
+
import type { WorkspaceInvitationRoleAssignment } from '@tuturuuu/internal-api/workspaces';
|
|
24
26
|
import {
|
|
25
27
|
inviteWorkspaceMember,
|
|
26
28
|
listEnhancedWorkspaceMembers,
|
|
29
|
+
listWorkspaceInvitationRoles,
|
|
27
30
|
removeWorkspaceMember,
|
|
31
|
+
updateWorkspaceInvitationRole,
|
|
28
32
|
updateWorkspaceMemberProfile,
|
|
29
33
|
} from '@tuturuuu/internal-api/workspaces';
|
|
30
|
-
import type {
|
|
34
|
+
import type {
|
|
35
|
+
InternalApiEnhancedWorkspaceMember,
|
|
36
|
+
WorkspaceRole,
|
|
37
|
+
} from '@tuturuuu/types';
|
|
31
38
|
import type {
|
|
32
39
|
WorkspaceAccessAdapter,
|
|
33
40
|
WorkspaceAccessInvitePayload,
|
|
@@ -60,6 +67,54 @@ export function normalizeWorkspaceAccessRole(role: RoleLike) {
|
|
|
60
67
|
} satisfies WorkspaceAccessRole;
|
|
61
68
|
}
|
|
62
69
|
|
|
70
|
+
export function mergeWorkspaceInvitationRoles(
|
|
71
|
+
members: InternalApiEnhancedWorkspaceMember[],
|
|
72
|
+
invitations: WorkspaceInvitationRoleAssignment[]
|
|
73
|
+
) {
|
|
74
|
+
const byUserId = new Map(
|
|
75
|
+
invitations
|
|
76
|
+
.filter((invitation) => invitation.userId)
|
|
77
|
+
.map((invitation) => [invitation.userId, invitation])
|
|
78
|
+
);
|
|
79
|
+
const byEmail = new Map(
|
|
80
|
+
invitations
|
|
81
|
+
.filter((invitation) => invitation.email)
|
|
82
|
+
.map((invitation) => [invitation.email?.trim().toLowerCase(), invitation])
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
return members.map((member) => {
|
|
86
|
+
if (!member.pending) return member;
|
|
87
|
+
|
|
88
|
+
const invitation =
|
|
89
|
+
(member.id ? byUserId.get(member.id) : undefined) ??
|
|
90
|
+
(member.email
|
|
91
|
+
? byEmail.get(member.email.trim().toLowerCase())
|
|
92
|
+
: undefined);
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
...member,
|
|
96
|
+
roles: (invitation?.roles ?? []).map((role) => ({
|
|
97
|
+
...role,
|
|
98
|
+
permissions: [],
|
|
99
|
+
})),
|
|
100
|
+
};
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function listStandardWorkspaceMembers(
|
|
105
|
+
workspaceId: string,
|
|
106
|
+
status?: 'all' | 'invited' | 'joined'
|
|
107
|
+
) {
|
|
108
|
+
const [members, invitations] = await Promise.all([
|
|
109
|
+
listEnhancedWorkspaceMembers(workspaceId, status),
|
|
110
|
+
status === 'joined'
|
|
111
|
+
? Promise.resolve([])
|
|
112
|
+
: listWorkspaceInvitationRoles(workspaceId),
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
return mergeWorkspaceInvitationRoles(members, invitations);
|
|
116
|
+
}
|
|
117
|
+
|
|
63
118
|
async function inviteStandardWorkspaceMembers(
|
|
64
119
|
workspaceId: string,
|
|
65
120
|
payload: WorkspaceAccessInvitePayload
|
|
@@ -74,6 +129,7 @@ async function inviteStandardWorkspaceMembers(
|
|
|
74
129
|
confirmDefaultAdminMigration: payload.confirmDefaultAdminMigration,
|
|
75
130
|
email,
|
|
76
131
|
memberType: payload.memberType,
|
|
132
|
+
roleIds: payload.roleIds,
|
|
77
133
|
};
|
|
78
134
|
return inviteWorkspaceMember(workspaceId, invitePayload);
|
|
79
135
|
})
|
|
@@ -132,7 +188,8 @@ export function createStandardWorkspaceAccessAdapter(): WorkspaceAccessAdapter {
|
|
|
132
188
|
return role;
|
|
133
189
|
},
|
|
134
190
|
inviteMembers: inviteStandardWorkspaceMembers,
|
|
135
|
-
listMembers:
|
|
191
|
+
listMembers: listStandardWorkspaceMembers,
|
|
192
|
+
listRoleOptions: listWorkspaceRoleOptions,
|
|
136
193
|
listRoles: async (workspaceId, query) => {
|
|
137
194
|
const result = await listWorkspaceRoles(workspaceId, {
|
|
138
195
|
page: query?.page ?? '1',
|
|
@@ -148,6 +205,7 @@ export function createStandardWorkspaceAccessAdapter(): WorkspaceAccessAdapter {
|
|
|
148
205
|
removeMember: removeWorkspaceMember,
|
|
149
206
|
removeRoleMember,
|
|
150
207
|
updateMemberProfile: updateWorkspaceMemberProfile,
|
|
208
|
+
updateInvitationRole: updateWorkspaceInvitationRole,
|
|
151
209
|
updateDefaultRole: (workspaceId, memberType, payload) =>
|
|
152
210
|
updateWorkspaceDefaultPermissions(workspaceId, memberType, {
|
|
153
211
|
permissions: payload.permissions as WorkspaceRole['permissions'],
|
|
@@ -170,6 +228,13 @@ export function createExternalProjectWorkspaceAccessAdapter(): WorkspaceAccessAd
|
|
|
170
228
|
inviteMembers: (workspaceId, payload) =>
|
|
171
229
|
inviteWorkspaceExternalProjectMembers(workspaceId, payload.emails),
|
|
172
230
|
listMembers: listWorkspaceExternalProjectMembers,
|
|
231
|
+
listRoleOptions: async (workspaceId) => {
|
|
232
|
+
const roles = await listWorkspaceExternalProjectRoles(workspaceId);
|
|
233
|
+
return {
|
|
234
|
+
count: roles.length,
|
|
235
|
+
data: roles.map(({ id, name }) => ({ id, name })),
|
|
236
|
+
};
|
|
237
|
+
},
|
|
173
238
|
listRoles: async (workspaceId, query) => {
|
|
174
239
|
const roles = (await listWorkspaceExternalProjectRoles(workspaceId)).map(
|
|
175
240
|
normalizeWorkspaceAccessRole
|
|
@@ -56,6 +56,7 @@ export type WorkspaceAccessInvitePayload = {
|
|
|
56
56
|
confirmDefaultAdminMigration?: boolean;
|
|
57
57
|
emails: string[];
|
|
58
58
|
memberType: WorkspaceDefaultPermissionMemberType;
|
|
59
|
+
roleIds: string[];
|
|
59
60
|
};
|
|
60
61
|
|
|
61
62
|
export type WorkspaceAccessAdapter = {
|
|
@@ -91,6 +92,10 @@ export type WorkspaceAccessAdapter = {
|
|
|
91
92
|
workspaceId: string,
|
|
92
93
|
status?: WorkspaceAccessMemberStatus
|
|
93
94
|
) => Promise<InternalApiEnhancedWorkspaceMember[]>;
|
|
95
|
+
listRoleOptions: (
|
|
96
|
+
workspaceId: string,
|
|
97
|
+
query?: { page?: string; pageSize?: string }
|
|
98
|
+
) => Promise<{ count: number; data: Array<{ id: string; name: string }> }>;
|
|
94
99
|
listRoles: (
|
|
95
100
|
workspaceId: string,
|
|
96
101
|
query?: { page?: string; pageSize?: string; q?: string }
|
|
@@ -112,6 +117,14 @@ export type WorkspaceAccessAdapter = {
|
|
|
112
117
|
userId?: null | string;
|
|
113
118
|
}
|
|
114
119
|
) => Promise<unknown>;
|
|
120
|
+
updateInvitationRole?: (
|
|
121
|
+
workspaceId: string,
|
|
122
|
+
payload: {
|
|
123
|
+
email?: null | string;
|
|
124
|
+
roleIds: string[];
|
|
125
|
+
userId?: null | string;
|
|
126
|
+
}
|
|
127
|
+
) => Promise<unknown>;
|
|
115
128
|
updateDefaultRole: (
|
|
116
129
|
workspaceId: string,
|
|
117
130
|
memberType: WorkspaceDefaultPermissionMemberType,
|
package/src/components/ui/custom/workspace-access/workspace-access-invitation-role-menu.test.tsx
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { WorkspaceAccessInvitationRoleMenu } from './workspace-access-invitation-role-menu';
|
|
4
|
+
|
|
5
|
+
vi.mock('next-intl', () => ({
|
|
6
|
+
useTranslations: () => (key: string, values?: Record<string, number>) =>
|
|
7
|
+
values?.count === undefined ? key : `${values.count} ${key}`,
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
describe('WorkspaceAccessInvitationRoleMenu', () => {
|
|
11
|
+
it('presents an actionable empty state and adds roles to an email invite', async () => {
|
|
12
|
+
const onUpdate = vi.fn();
|
|
13
|
+
render(
|
|
14
|
+
<WorkspaceAccessInvitationRoleMenu
|
|
15
|
+
email="pending@example.com"
|
|
16
|
+
isMutating={false}
|
|
17
|
+
onUpdate={onUpdate}
|
|
18
|
+
assignedRoles={[]}
|
|
19
|
+
roles={[
|
|
20
|
+
{ id: 'role-editor', name: 'Editor' },
|
|
21
|
+
{ id: 'role-reviewer', name: 'Reviewer' },
|
|
22
|
+
]}
|
|
23
|
+
/>
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
expect(
|
|
27
|
+
screen.getByRole('button', {
|
|
28
|
+
name: /ws-members.assign_invitation_role/i,
|
|
29
|
+
})
|
|
30
|
+
).toBeDefined();
|
|
31
|
+
expect(screen.getByText('ws-members.invitation_role_helper')).toBeDefined();
|
|
32
|
+
|
|
33
|
+
fireEvent.pointerDown(
|
|
34
|
+
screen.getByRole('button', {
|
|
35
|
+
name: /ws-members.assign_invitation_role/i,
|
|
36
|
+
}),
|
|
37
|
+
{ button: 0, ctrlKey: false }
|
|
38
|
+
);
|
|
39
|
+
fireEvent.click(await screen.findByText('Editor'));
|
|
40
|
+
|
|
41
|
+
expect(onUpdate).toHaveBeenCalledWith({
|
|
42
|
+
email: 'pending@example.com',
|
|
43
|
+
roleIds: ['role-editor'],
|
|
44
|
+
userId: undefined,
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('shows all assigned roles and removes only the selected role', async () => {
|
|
49
|
+
const onUpdate = vi.fn();
|
|
50
|
+
render(
|
|
51
|
+
<WorkspaceAccessInvitationRoleMenu
|
|
52
|
+
isMutating={false}
|
|
53
|
+
onUpdate={onUpdate}
|
|
54
|
+
assignedRoles={[
|
|
55
|
+
{ id: 'role-editor', name: 'Editor' },
|
|
56
|
+
{ id: 'role-reviewer', name: 'Reviewer' },
|
|
57
|
+
]}
|
|
58
|
+
roles={[
|
|
59
|
+
{ id: 'role-editor', name: 'Editor' },
|
|
60
|
+
{ id: 'role-reviewer', name: 'Reviewer' },
|
|
61
|
+
]}
|
|
62
|
+
userId="user-2"
|
|
63
|
+
/>
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
fireEvent.pointerDown(
|
|
67
|
+
screen.getByRole('button', { name: /2 ws-members.roles_selected/i }),
|
|
68
|
+
{ button: 0, ctrlKey: false }
|
|
69
|
+
);
|
|
70
|
+
const reviewerLabels = await screen.findAllByText('Reviewer');
|
|
71
|
+
fireEvent.click(reviewerLabels.at(-1)!);
|
|
72
|
+
|
|
73
|
+
expect(onUpdate).toHaveBeenCalledWith({
|
|
74
|
+
email: undefined,
|
|
75
|
+
roleIds: ['role-editor'],
|
|
76
|
+
userId: 'user-2',
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { ChevronDown, Plus, ShieldUser, X } from '@tuturuuu/icons';
|
|
4
|
+
import { Badge } from '@tuturuuu/ui/badge';
|
|
5
|
+
import { Button } from '@tuturuuu/ui/button';
|
|
6
|
+
import {
|
|
7
|
+
DropdownMenu,
|
|
8
|
+
DropdownMenuCheckboxItem,
|
|
9
|
+
DropdownMenuContent,
|
|
10
|
+
DropdownMenuItem,
|
|
11
|
+
DropdownMenuLabel,
|
|
12
|
+
DropdownMenuSeparator,
|
|
13
|
+
DropdownMenuTrigger,
|
|
14
|
+
} from '@tuturuuu/ui/dropdown-menu';
|
|
15
|
+
import { useTranslations } from 'next-intl';
|
|
16
|
+
import type { WorkspaceAccessRole } from './types';
|
|
17
|
+
|
|
18
|
+
type Props = {
|
|
19
|
+
email?: null | string;
|
|
20
|
+
isMutating: boolean;
|
|
21
|
+
onUpdate: (payload: {
|
|
22
|
+
email?: null | string;
|
|
23
|
+
roleIds: string[];
|
|
24
|
+
userId?: null | string;
|
|
25
|
+
}) => void;
|
|
26
|
+
assignedRoles: Array<Pick<WorkspaceAccessRole, 'id' | 'name'>>;
|
|
27
|
+
roles: Array<Pick<WorkspaceAccessRole, 'id' | 'name'>>;
|
|
28
|
+
userId?: null | string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function WorkspaceAccessInvitationRoleMenu({
|
|
32
|
+
email,
|
|
33
|
+
isMutating,
|
|
34
|
+
onUpdate,
|
|
35
|
+
assignedRoles,
|
|
36
|
+
roles,
|
|
37
|
+
userId,
|
|
38
|
+
}: Props) {
|
|
39
|
+
const t = useTranslations();
|
|
40
|
+
const assignedRoleIds = new Set(assignedRoles.map((role) => role.id));
|
|
41
|
+
const updateRole = (roleId: string) => {
|
|
42
|
+
const roleIds = assignedRoleIds.has(roleId)
|
|
43
|
+
? assignedRoles
|
|
44
|
+
.filter((role) => role.id !== roleId)
|
|
45
|
+
.map((role) => role.id)
|
|
46
|
+
: [...assignedRoles.map((role) => role.id), roleId];
|
|
47
|
+
onUpdate({ email, roleIds, userId });
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div className="space-y-2">
|
|
52
|
+
{assignedRoles.length > 0 ? (
|
|
53
|
+
<div className="flex flex-wrap gap-1.5">
|
|
54
|
+
{assignedRoles.map((role) => (
|
|
55
|
+
<Badge
|
|
56
|
+
key={role.id}
|
|
57
|
+
className="h-6 gap-1 border-dynamic-purple/35 bg-dynamic-purple/10 px-2 text-dynamic-purple text-xs"
|
|
58
|
+
>
|
|
59
|
+
<ShieldUser className="size-3" />
|
|
60
|
+
{role.name}
|
|
61
|
+
</Badge>
|
|
62
|
+
))}
|
|
63
|
+
</div>
|
|
64
|
+
) : null}
|
|
65
|
+
<DropdownMenu>
|
|
66
|
+
<DropdownMenuTrigger asChild>
|
|
67
|
+
<Button
|
|
68
|
+
variant="outline"
|
|
69
|
+
size="sm"
|
|
70
|
+
className={`h-7 max-w-full rounded-full px-2.5 text-xs active:scale-[0.98] ${assignedRoles.length > 0 ? 'border-dynamic-purple/40 bg-dynamic-purple/10 text-dynamic-purple hover:bg-dynamic-purple/15 hover:text-dynamic-purple' : 'border-dashed text-muted-foreground hover:text-foreground'}`}
|
|
71
|
+
disabled={isMutating}
|
|
72
|
+
>
|
|
73
|
+
{assignedRoles.length > 0 ? (
|
|
74
|
+
<ShieldUser className="size-3.5 shrink-0" />
|
|
75
|
+
) : (
|
|
76
|
+
<Plus className="size-3.5 shrink-0" />
|
|
77
|
+
)}
|
|
78
|
+
<span className="truncate">
|
|
79
|
+
{assignedRoles.length > 0
|
|
80
|
+
? t('ws-members.roles_selected', {
|
|
81
|
+
count: assignedRoles.length,
|
|
82
|
+
})
|
|
83
|
+
: t('ws-members.assign_invitation_role')}
|
|
84
|
+
</span>
|
|
85
|
+
<ChevronDown className="size-3 shrink-0 opacity-60" />
|
|
86
|
+
</Button>
|
|
87
|
+
</DropdownMenuTrigger>
|
|
88
|
+
<DropdownMenuContent align="start" className="w-60">
|
|
89
|
+
<DropdownMenuLabel className="flex items-center gap-2">
|
|
90
|
+
<ShieldUser className="size-4 text-muted-foreground" />
|
|
91
|
+
{t('ws-members.invitation_role')}
|
|
92
|
+
</DropdownMenuLabel>
|
|
93
|
+
<DropdownMenuSeparator />
|
|
94
|
+
{roles.map((option) => (
|
|
95
|
+
<DropdownMenuCheckboxItem
|
|
96
|
+
checked={assignedRoleIds.has(option.id)}
|
|
97
|
+
key={option.id}
|
|
98
|
+
onSelect={(event) => {
|
|
99
|
+
event.preventDefault();
|
|
100
|
+
updateRole(option.id);
|
|
101
|
+
}}
|
|
102
|
+
>
|
|
103
|
+
<ShieldUser className="size-4" />
|
|
104
|
+
<span className="flex-1 truncate">{option.name}</span>
|
|
105
|
+
</DropdownMenuCheckboxItem>
|
|
106
|
+
))}
|
|
107
|
+
{assignedRoles.length > 0 ? (
|
|
108
|
+
<>
|
|
109
|
+
<DropdownMenuSeparator />
|
|
110
|
+
<DropdownMenuItem
|
|
111
|
+
onSelect={() => onUpdate({ email, roleIds: [], userId })}
|
|
112
|
+
>
|
|
113
|
+
<X className="size-4" />
|
|
114
|
+
{t('ws-members.clear_all_roles')}
|
|
115
|
+
</DropdownMenuItem>
|
|
116
|
+
</>
|
|
117
|
+
) : null}
|
|
118
|
+
</DropdownMenuContent>
|
|
119
|
+
</DropdownMenu>
|
|
120
|
+
<p className="text-muted-foreground text-xs leading-4">
|
|
121
|
+
{t('ws-members.invitation_role_helper')}
|
|
122
|
+
</p>
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { CreditCard, ShieldUser, UserPlus } from '@tuturuuu/icons';
|
|
4
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@tuturuuu/ui/tabs';
|
|
5
|
+
import { useTranslations } from 'next-intl';
|
|
6
|
+
|
|
7
|
+
type AccessPreset = 'guest' | 'member' | 'pos_operator';
|
|
8
|
+
|
|
9
|
+
const OPTIONS: Array<{
|
|
10
|
+
descriptionKey: string;
|
|
11
|
+
icon: typeof UserPlus;
|
|
12
|
+
labelKey: string;
|
|
13
|
+
shortLabelKey: string;
|
|
14
|
+
value: AccessPreset;
|
|
15
|
+
}> = [
|
|
16
|
+
{
|
|
17
|
+
descriptionKey: 'ws-members.invite_membership_member_description',
|
|
18
|
+
icon: UserPlus,
|
|
19
|
+
labelKey: 'ws-members.invite_membership_member',
|
|
20
|
+
shortLabelKey: 'ws-members.invite_access_member_tab',
|
|
21
|
+
value: 'member',
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
descriptionKey: 'ws-members.invite_membership_guest_description',
|
|
25
|
+
icon: ShieldUser,
|
|
26
|
+
labelKey: 'ws-members.invite_membership_guest',
|
|
27
|
+
shortLabelKey: 'ws-members.invite_access_guest_tab',
|
|
28
|
+
value: 'guest',
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
descriptionKey: 'ws-members.pos_operator_description',
|
|
32
|
+
icon: CreditCard,
|
|
33
|
+
labelKey: 'ws-members.invite_membership_pos_operator',
|
|
34
|
+
shortLabelKey: 'ws-members.invite_access_pos_tab',
|
|
35
|
+
value: 'pos_operator',
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
export function WorkspaceAccessInviteAccessPicker({
|
|
40
|
+
canManageRoles,
|
|
41
|
+
onChange,
|
|
42
|
+
value,
|
|
43
|
+
}: {
|
|
44
|
+
canManageRoles: boolean;
|
|
45
|
+
onChange: (value: AccessPreset) => void;
|
|
46
|
+
value: AccessPreset;
|
|
47
|
+
}) {
|
|
48
|
+
const t = useTranslations() as (key: string) => string;
|
|
49
|
+
const options = canManageRoles
|
|
50
|
+
? OPTIONS
|
|
51
|
+
: OPTIONS.filter((option) => option.value !== 'pos_operator');
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div className="space-y-2">
|
|
55
|
+
<p className="font-medium text-sm">
|
|
56
|
+
{t('ws-members.invite_membership_label')}
|
|
57
|
+
</p>
|
|
58
|
+
<Tabs
|
|
59
|
+
value={value}
|
|
60
|
+
onValueChange={(nextValue) => onChange(nextValue as AccessPreset)}
|
|
61
|
+
>
|
|
62
|
+
<TabsList
|
|
63
|
+
aria-label={t('ws-members.invite_membership_label')}
|
|
64
|
+
className={`grid h-auto w-full gap-1 rounded-xl bg-muted/60 p-1 ${canManageRoles ? 'grid-cols-3' : 'grid-cols-2'}`}
|
|
65
|
+
>
|
|
66
|
+
{options.map((option) => {
|
|
67
|
+
const Icon = option.icon;
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<TabsTrigger
|
|
71
|
+
key={option.value}
|
|
72
|
+
value={option.value}
|
|
73
|
+
className="min-h-10 min-w-0 rounded-lg px-2.5 py-2 data-[state=active]:text-dynamic-blue"
|
|
74
|
+
>
|
|
75
|
+
<Icon className="size-4" />
|
|
76
|
+
<span className="truncate">{t(option.shortLabelKey)}</span>
|
|
77
|
+
</TabsTrigger>
|
|
78
|
+
);
|
|
79
|
+
})}
|
|
80
|
+
</TabsList>
|
|
81
|
+
|
|
82
|
+
{options.map((option) => {
|
|
83
|
+
const Icon = option.icon;
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<TabsContent
|
|
87
|
+
key={option.value}
|
|
88
|
+
value={option.value}
|
|
89
|
+
className="mt-1 rounded-xl border bg-background px-3.5 py-3"
|
|
90
|
+
>
|
|
91
|
+
<div className="flex items-start gap-3">
|
|
92
|
+
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg border bg-muted/30 text-dynamic-blue">
|
|
93
|
+
<Icon className="size-4" />
|
|
94
|
+
</span>
|
|
95
|
+
<div className="min-w-0">
|
|
96
|
+
<p className="font-medium text-sm">{t(option.labelKey)}</p>
|
|
97
|
+
<p className="mt-0.5 text-muted-foreground text-xs leading-5">
|
|
98
|
+
{t(option.descriptionKey)}
|
|
99
|
+
</p>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
</TabsContent>
|
|
103
|
+
);
|
|
104
|
+
})}
|
|
105
|
+
</Tabs>
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import type { ComponentProps } from 'react';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { WorkspaceAccessInviteDialog } from './workspace-access-invite-dialog';
|
|
5
|
+
|
|
6
|
+
vi.mock('next-intl', () => ({
|
|
7
|
+
useTranslations: () => (key: string, values?: Record<string, number>) =>
|
|
8
|
+
values?.count === undefined ? key : `${values.count} ${key}`,
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
describe('WorkspaceAccessInviteDialog', () => {
|
|
12
|
+
it('selects multiple workspace roles before the invitation is sent', () => {
|
|
13
|
+
const props = {
|
|
14
|
+
accessPreset: 'member',
|
|
15
|
+
canManageRoles: true,
|
|
16
|
+
confirmDefaultAdminMigration: false,
|
|
17
|
+
defaultAdminEnabled: false,
|
|
18
|
+
emails: 'editor@example.com',
|
|
19
|
+
isSubmitting: false,
|
|
20
|
+
joinedMemberCount: 1,
|
|
21
|
+
noRoleLabel: 'No assigned roles',
|
|
22
|
+
onAccessPresetChange: vi.fn(),
|
|
23
|
+
onConfirmDefaultAdminMigrationChange: vi.fn(),
|
|
24
|
+
onEmailsChange: vi.fn(),
|
|
25
|
+
onOpenChange: vi.fn(),
|
|
26
|
+
onRoleIdsChange: vi.fn(),
|
|
27
|
+
onSubmit: vi.fn(),
|
|
28
|
+
open: true,
|
|
29
|
+
roleIds: [],
|
|
30
|
+
roles: [
|
|
31
|
+
{ id: 'role-editor', name: 'Editor' },
|
|
32
|
+
{ id: 'role-reviewer', name: 'Reviewer' },
|
|
33
|
+
],
|
|
34
|
+
} as ComponentProps<typeof WorkspaceAccessInviteDialog> & {
|
|
35
|
+
noRoleLabel: string;
|
|
36
|
+
onRoleIdsChange: (roleIds: string[]) => void;
|
|
37
|
+
roleIds: string[];
|
|
38
|
+
roles: Array<{ id: string; name: string }>;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const { rerender } = render(<WorkspaceAccessInviteDialog {...props} />);
|
|
42
|
+
|
|
43
|
+
expect(
|
|
44
|
+
screen.getByRole('tab', {
|
|
45
|
+
name: 'ws-members.invite_access_member_tab',
|
|
46
|
+
})
|
|
47
|
+
).toHaveAttribute('data-state', 'active');
|
|
48
|
+
expect(screen.getByText('ws-members.role-placeholder')).toBeDefined();
|
|
49
|
+
expect(
|
|
50
|
+
screen.getByPlaceholderText('ws-members.invite_roles_search')
|
|
51
|
+
).toBeDefined();
|
|
52
|
+
|
|
53
|
+
fireEvent.click(screen.getByRole('checkbox', { name: 'Editor' }));
|
|
54
|
+
expect(props.onRoleIdsChange).toHaveBeenLastCalledWith(['role-editor']);
|
|
55
|
+
|
|
56
|
+
rerender(
|
|
57
|
+
<WorkspaceAccessInviteDialog {...props} roleIds={['role-editor']} />
|
|
58
|
+
);
|
|
59
|
+
fireEvent.click(screen.getByRole('checkbox', { name: 'Reviewer' }));
|
|
60
|
+
expect(props.onRoleIdsChange).toHaveBeenLastCalledWith([
|
|
61
|
+
'role-editor',
|
|
62
|
+
'role-reviewer',
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
fireEvent.click(screen.getByRole('checkbox', { name: 'Editor' }));
|
|
66
|
+
expect(props.onRoleIdsChange).toHaveBeenLastCalledWith([]);
|
|
67
|
+
|
|
68
|
+
fireEvent.mouseDown(
|
|
69
|
+
screen.getByRole('tab', {
|
|
70
|
+
name: 'ws-members.invite_access_guest_tab',
|
|
71
|
+
})
|
|
72
|
+
);
|
|
73
|
+
expect(props.onAccessPresetChange).toHaveBeenLastCalledWith('guest');
|
|
74
|
+
|
|
75
|
+
rerender(
|
|
76
|
+
<WorkspaceAccessInviteDialog
|
|
77
|
+
{...props}
|
|
78
|
+
accessPreset="guest"
|
|
79
|
+
roleIds={[]}
|
|
80
|
+
/>
|
|
81
|
+
);
|
|
82
|
+
expect(screen.queryByRole('checkbox', { name: 'Editor' })).toBeNull();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('filters roles, clears the selection, and lets the role section collapse', () => {
|
|
86
|
+
const onRoleIdsChange = vi.fn();
|
|
87
|
+
|
|
88
|
+
render(
|
|
89
|
+
<WorkspaceAccessInviteDialog
|
|
90
|
+
accessPreset="member"
|
|
91
|
+
canManageRoles
|
|
92
|
+
confirmDefaultAdminMigration={false}
|
|
93
|
+
defaultAdminEnabled={false}
|
|
94
|
+
emails="editor@example.com"
|
|
95
|
+
isSubmitting={false}
|
|
96
|
+
joinedMemberCount={1}
|
|
97
|
+
noRoleLabel="No assigned roles"
|
|
98
|
+
onAccessPresetChange={vi.fn()}
|
|
99
|
+
onConfirmDefaultAdminMigrationChange={vi.fn()}
|
|
100
|
+
onEmailsChange={vi.fn()}
|
|
101
|
+
onOpenChange={vi.fn()}
|
|
102
|
+
onRoleIdsChange={onRoleIdsChange}
|
|
103
|
+
onSubmit={vi.fn()}
|
|
104
|
+
open
|
|
105
|
+
roleIds={['role-editor', 'role-reviewer']}
|
|
106
|
+
roles={[
|
|
107
|
+
{ id: 'role-editor', name: 'Editor' },
|
|
108
|
+
{ id: 'role-reviewer', name: 'Reviewer' },
|
|
109
|
+
]}
|
|
110
|
+
/>
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
fireEvent.change(
|
|
114
|
+
screen.getByPlaceholderText('ws-members.invite_roles_search'),
|
|
115
|
+
{ target: { value: 'review' } }
|
|
116
|
+
);
|
|
117
|
+
expect(screen.queryByRole('checkbox', { name: 'Editor' })).toBeNull();
|
|
118
|
+
expect(screen.getByRole('checkbox', { name: 'Reviewer' })).toBeDefined();
|
|
119
|
+
|
|
120
|
+
fireEvent.click(
|
|
121
|
+
screen.getByRole('button', { name: 'ws-members.clear_all_roles' })
|
|
122
|
+
);
|
|
123
|
+
expect(onRoleIdsChange).toHaveBeenLastCalledWith([]);
|
|
124
|
+
|
|
125
|
+
fireEvent.click(
|
|
126
|
+
screen.getByRole('button', {
|
|
127
|
+
name: /ws-members\.role-placeholder/,
|
|
128
|
+
})
|
|
129
|
+
);
|
|
130
|
+
expect(
|
|
131
|
+
screen.queryByPlaceholderText('ws-members.invite_roles_search')
|
|
132
|
+
).toBeNull();
|
|
133
|
+
});
|
|
134
|
+
});
|