@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
@@ -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 { WorkspaceRole } from '@tuturuuu/types';
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: listEnhancedWorkspaceMembers,
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,
@@ -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
+ });