@tuturuuu/ui 0.26.1 → 0.28.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 (43) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/biome.json +1 -1
  3. package/package.json +49 -48
  4. package/src/components/ui/button.tsx +12 -8
  5. package/src/components/ui/color-picker.test.tsx +23 -0
  6. package/src/components/ui/color-picker.tsx +1 -1
  7. package/src/components/ui/custom/combobox.tsx +4 -0
  8. package/src/components/ui/custom/notification-popover-client.tsx +6 -19
  9. package/src/components/ui/custom/notification-popover-trigger.test.tsx +37 -0
  10. package/src/components/ui/custom/notification-popover-trigger.tsx +44 -0
  11. package/src/components/ui/custom/workspace-access/adapters.test.ts +89 -1
  12. package/src/components/ui/custom/workspace-access/adapters.ts +67 -2
  13. package/src/components/ui/custom/workspace-access/types.ts +13 -0
  14. package/src/components/ui/custom/workspace-access/workspace-access-invitation-role-menu.test.tsx +79 -0
  15. package/src/components/ui/custom/workspace-access/workspace-access-invitation-role-menu.tsx +125 -0
  16. package/src/components/ui/custom/workspace-access/workspace-access-invite-access-picker.tsx +108 -0
  17. package/src/components/ui/custom/workspace-access/workspace-access-invite-dialog.test.tsx +134 -0
  18. package/src/components/ui/custom/workspace-access/workspace-access-invite-dialog.tsx +94 -113
  19. package/src/components/ui/custom/workspace-access/workspace-access-invite-pos-panel.tsx +75 -0
  20. package/src/components/ui/custom/workspace-access/workspace-access-invite-role-picker.tsx +151 -0
  21. package/src/components/ui/custom/workspace-access/workspace-access-labels.ts +1 -1
  22. package/src/components/ui/custom/workspace-access/workspace-access-member-row.tsx +27 -2
  23. package/src/components/ui/custom/workspace-access/workspace-access-members.tsx +10 -0
  24. package/src/components/ui/custom/workspace-access/workspace-access-page.tsx +102 -3
  25. package/src/components/ui/custom/workspace-access/workspace-access-role-options.test.ts +35 -0
  26. package/src/components/ui/custom/workspace-access/workspace-access-role-options.ts +21 -0
  27. package/src/components/ui/custom/workspace-select-invitations.tsx +2 -1
  28. package/src/components/ui/text-editor/__tests__/editor-classes.test.ts +36 -0
  29. package/src/components/ui/text-editor/__tests__/inline-task-conversion.test.tsx +30 -0
  30. package/src/components/ui/text-editor/__tests__/keyboard.test.ts +173 -3
  31. package/src/components/ui/text-editor/__tests__/toggle-block-extension.test.ts +251 -0
  32. package/src/components/ui/text-editor/editor-classes.ts +100 -0
  33. package/src/components/ui/text-editor/editor.tsx +11 -153
  34. package/src/components/ui/text-editor/extensions.ts +19 -0
  35. package/src/components/ui/text-editor/keyboard.ts +47 -0
  36. package/src/components/ui/text-editor/toggle-block-extension.ts +153 -0
  37. package/src/components/ui/text-editor/tool-bar.tsx +24 -73
  38. package/src/components/ui/text-editor/toolbar-config.ts +67 -0
  39. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +34 -1
  40. package/src/hooks/use-board-actions.test.ts +23 -0
  41. package/src/hooks/use-board-actions.ts +48 -35
  42. package/src/hooks/use-calendar-sync.tsx +12 -12
  43. package/src/hooks/use-notifications.ts +4 -8
@@ -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
+ });