@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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.28.0](https://github.com/tutur3u/platform/compare/ui-v0.27.0...ui-v0.28.0) (2026-08-15)
4
+
5
+
6
+ ### Features
7
+
8
+ * **tasks:** add toggle blocks and repair editor indentation ([c0805c8](https://github.com/tutur3u/platform/commit/c0805c8cc0e26c172eb6f46582e267b084289501))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **contacts:** restore group tag labels ([9fbcb25](https://github.com/tutur3u/platform/commit/9fbcb25bd44fcedcfad4a463599940e9497ebc06))
14
+ * **notifications:** prevent pre-hydration bell clicks ([6adba83](https://github.com/tutur3u/platform/commit/6adba83f3db63817cc64e332c33a6a2939e0bf83))
15
+ * **ui:** support button slots with TypeScript 7 ([3bdc636](https://github.com/tutur3u/platform/commit/3bdc6368daf0be48719a479c63a6325d414233d6))
16
+
17
+ ## [0.27.0](https://github.com/tutur3u/platform/compare/ui-v0.26.1...ui-v0.27.0) (2026-08-14)
18
+
19
+
20
+ ### Features
21
+
22
+ * **tasks:** add board lifecycle actions ([3295657](https://github.com/tutur3u/platform/commit/329565797b8bb3cefdf8f3812e6f5391576bf394))
23
+ * **workspaces:** assign roles to pending invites ([#5125](https://github.com/tutur3u/platform/issues/5125)) ([d4052fd](https://github.com/tutur3u/platform/commit/d4052fd40de66bda4e9535740bbcfd5a8da19123))
24
+ * **workspaces:** revamp invitation access flow ([7ea94af](https://github.com/tutur3u/platform/commit/7ea94afc2c5e14af1c83d1478ad9c006268b13c0))
25
+ * **workspaces:** support multi-role invitations ([f6f78ba](https://github.com/tutur3u/platform/commit/f6f78bac6c2120fd70c09e85c075c4206be4f897))
26
+
27
+
28
+ ### Bug Fixes
29
+
30
+ * **contacts:** preserve server referral search results ([8d0941d](https://github.com/tutur3u/platform/commit/8d0941de693fb784fd2487582d905f2cbd0fb16e))
31
+ * **tasks:** allow board lifecycle actions ([9a6bc0c](https://github.com/tutur3u/platform/commit/9a6bc0ce31c4191bed81607fdb5c9ee2af8e1d04))
32
+ * **workspaces:** manage roles for pending invites ([2466f6c](https://github.com/tutur3u/platform/commit/2466f6cbbd447207d87eb0e0d78b3c713b02b739))
33
+
34
+
35
+ ### Performance Improvements
36
+
37
+ * **vercel:** reduce realtime fallback polling ([e9e44a4](https://github.com/tutur3u/platform/commit/e9e44a4eb17c971beb9e1b737c4580466e427be5))
38
+ * **vercel:** reduce realtime fallback polling ([#5126](https://github.com/tutur3u/platform/issues/5126)) ([fde2692](https://github.com/tutur3u/platform/commit/fde2692ff73a45616a40b93de9fff30ffc941fd2))
39
+
3
40
  ## [0.26.1](https://github.com/tutur3u/platform/compare/ui-v0.26.0...ui-v0.26.1) (2026-08-11)
4
41
 
5
42
 
package/biome.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "root": false,
3
- "$schema": "https://biomejs.dev/schemas/2.5.7/schema.json",
3
+ "$schema": "https://biomejs.dev/schemas/2.5.8/schema.json",
4
4
  "extends": "//"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tuturuuu/ui",
3
- "version": "0.26.1",
3
+ "version": "0.28.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -56,43 +56,44 @@
56
56
  "@streamdown/math": "^1.0.2",
57
57
  "@streamdown/mermaid": "^1.0.2",
58
58
  "@tanstack/react-hotkeys": "^0.10.0",
59
- "@tanstack/react-pacer": "^0.22.1",
59
+ "@tanstack/react-pacer": "^0.23.0",
60
60
  "@tanstack/react-query": "^5.101.2",
61
61
  "@tanstack/react-table": "^9.0.0",
62
62
  "@tanstack/react-virtual": "^3.14.9",
63
- "@tiptap/core": "3.29.2",
64
- "@tiptap/extension-collaboration": "3.29.2",
65
- "@tiptap/extension-collaboration-caret": "3.29.2",
66
- "@tiptap/extension-color": "3.29.2",
67
- "@tiptap/extension-drag-handle": "3.29.2",
68
- "@tiptap/extension-drag-handle-react": "3.29.2",
69
- "@tiptap/extension-highlight": "3.29.2",
70
- "@tiptap/extension-horizontal-rule": "3.29.2",
71
- "@tiptap/extension-image": "3.29.2",
72
- "@tiptap/extension-link": "3.29.2",
73
- "@tiptap/extension-list": "3.29.2",
74
- "@tiptap/extension-node-range": "3.29.2",
75
- "@tiptap/extension-placeholder": "3.29.2",
76
- "@tiptap/extension-strike": "3.29.2",
77
- "@tiptap/extension-subscript": "3.29.2",
78
- "@tiptap/extension-superscript": "3.29.2",
79
- "@tiptap/extension-table": "3.29.2",
80
- "@tiptap/extension-table-cell": "3.29.2",
81
- "@tiptap/extension-table-header": "3.29.2",
82
- "@tiptap/extension-table-row": "3.29.2",
83
- "@tiptap/extension-text-align": "3.29.2",
84
- "@tiptap/extension-text-style": "3.29.2",
85
- "@tiptap/extension-youtube": "3.29.2",
86
- "@tiptap/pm": "3.29.2",
87
- "@tiptap/react": "3.29.2",
88
- "@tiptap/starter-kit": "3.29.2",
89
- "@tuturuuu/ai": "0.8.1",
90
- "@tuturuuu/apis": "0.11.0",
91
- "@tuturuuu/hooks": "0.1.0",
63
+ "@tiptap/core": "3.30.0",
64
+ "@tiptap/extension-collaboration": "3.30.0",
65
+ "@tiptap/extension-collaboration-caret": "3.30.0",
66
+ "@tiptap/extension-color": "3.30.0",
67
+ "@tiptap/extension-details": "3.30.0",
68
+ "@tiptap/extension-drag-handle": "3.30.0",
69
+ "@tiptap/extension-drag-handle-react": "3.30.0",
70
+ "@tiptap/extension-highlight": "3.30.0",
71
+ "@tiptap/extension-horizontal-rule": "3.30.0",
72
+ "@tiptap/extension-image": "3.30.0",
73
+ "@tiptap/extension-link": "3.30.0",
74
+ "@tiptap/extension-list": "3.30.0",
75
+ "@tiptap/extension-node-range": "3.30.0",
76
+ "@tiptap/extension-placeholder": "3.30.0",
77
+ "@tiptap/extension-strike": "3.30.0",
78
+ "@tiptap/extension-subscript": "3.30.0",
79
+ "@tiptap/extension-superscript": "3.30.0",
80
+ "@tiptap/extension-table": "3.30.0",
81
+ "@tiptap/extension-table-cell": "3.30.0",
82
+ "@tiptap/extension-table-header": "3.30.0",
83
+ "@tiptap/extension-table-row": "3.30.0",
84
+ "@tiptap/extension-text-align": "3.30.0",
85
+ "@tiptap/extension-text-style": "3.30.0",
86
+ "@tiptap/extension-youtube": "3.30.0",
87
+ "@tiptap/pm": "3.30.0",
88
+ "@tiptap/react": "3.30.0",
89
+ "@tiptap/starter-kit": "3.30.0",
90
+ "@tuturuuu/ai": "0.8.2",
91
+ "@tuturuuu/apis": "0.11.2",
92
+ "@tuturuuu/hooks": "0.1.1",
92
93
  "@tuturuuu/icons": "0.1.0",
93
- "@tuturuuu/internal-api": "0.29.0",
94
+ "@tuturuuu/internal-api": "0.31.0",
94
95
  "@tuturuuu/supabase": "0.5.0",
95
- "@tuturuuu/utils": "0.24.1",
96
+ "@tuturuuu/utils": "0.25.1",
96
97
  "@types/debug": "^4.1.13",
97
98
  "browser-image-compression": "^2.0.2",
98
99
  "class-variance-authority": "^0.7.1",
@@ -104,17 +105,17 @@
104
105
  "debug": "^4.4.3",
105
106
  "embla-carousel-react": "^8.6.0",
106
107
  "eventemitter3": "^5.0.4",
107
- "framer-motion": "^13.0.0",
108
+ "framer-motion": "^13.1.0",
108
109
  "gsap": "^3.15.0",
109
110
  "html-entities": "^2.6.0",
110
- "html2canvas-pro": "^2.3.3",
111
+ "html2canvas-pro": "^2.3.6",
111
112
  "input-otp": "^1.4.2",
112
- "katex": "^0.18.1",
113
+ "katex": "^0.18.4",
113
114
  "lodash": "4.18.1",
114
115
  "moment": "^2.30.1",
115
- "motion": "^13.0.0",
116
- "next": "^16.3.0",
117
- "next-intl": "^4.13.5",
116
+ "motion": "^13.1.0",
117
+ "next": "^16.3.1",
118
+ "next-intl": "^4.13.6",
118
119
  "next-themes": "^0.4.6",
119
120
  "nuqs": "^2.9.5",
120
121
  "prosemirror-state": "^1.4.4",
@@ -124,8 +125,8 @@
124
125
  "react-colorful": "^5.8.0",
125
126
  "react-day-picker": "^10.0.1",
126
127
  "react-dom": "^19.2.8",
127
- "react-dropzone": "^20.0.0",
128
- "react-hook-form": "^7.84.0",
128
+ "react-dropzone": "^20.1.0",
129
+ "react-hook-form": "^7.85.0",
129
130
  "react-markdown": "^10.1.0",
130
131
  "react-papaparse": "^4.4.0",
131
132
  "react-pdf": "^10.4.1",
@@ -133,13 +134,13 @@
133
134
  "react-resizable-panels": "^4.12.2",
134
135
  "react-syntax-highlighter": "^16.1.1",
135
136
  "recharts": "3.10.1",
136
- "slot-text": "^0.3.3",
137
- "sonner": "^2.0.7",
137
+ "slot-text": "^0.3.4",
138
+ "sonner": "^2.0.8",
138
139
  "streamdown": "^2.5.0",
139
140
  "tailwind-scrollbar": "^4.0.2",
140
141
  "tailwind-scrollbar-hide": "^4.0.0",
141
142
  "tailwindcss-animate": "^1.0.7",
142
- "tiptap-extension-resize-image": "^1.4.5",
143
+ "tiptap-extension-resize-image": "^1.4.6",
143
144
  "use-debounce": "^10.1.1",
144
145
  "vaul": "^1.1.2",
145
146
  "y-protocols": "^1.0.7",
@@ -150,14 +151,14 @@
150
151
  "@tailwindcss/postcss": "^4.3.3",
151
152
  "@tailwindcss/typography": "^0.5.20",
152
153
  "@tanstack/react-query": "^5.101.4",
153
- "@tanstack/react-table": "^9.0.0",
154
- "@testing-library/jest-dom": "^7.0.0",
154
+ "@tanstack/react-table": "^9.1.2",
155
+ "@testing-library/jest-dom": "^7.0.1",
155
156
  "@testing-library/react": "^16.3.2",
156
157
  "@tuturuuu/types": "0.27.0",
157
158
  "@tuturuuu/typescript-config": "0.1.1",
158
159
  "@types/html2canvas": "^1.0.0",
159
160
  "@types/lodash": "^4.17.25",
160
- "@types/node": "^26.1.2",
161
+ "@types/node": "^26.2.0",
161
162
  "@types/react": "^19.2.18",
162
163
  "@types/react-dom": "^19.2.4",
163
164
  "@types/react-resizable": "^4.0.0",
@@ -166,7 +167,7 @@
166
167
  "@vitejs/plugin-react": "^6.0.5",
167
168
  "autoprefixer": "^10.5.4",
168
169
  "jsdom": "^30.0.1",
169
- "postcss": "^8.5.25",
170
+ "postcss": "^8.5.26",
170
171
  "postcss-load-config": "^6.0.1",
171
172
  "tailwindcss": "^4.3.3",
172
173
  "typescript": "7.0.2",
@@ -46,15 +46,19 @@ function Button({
46
46
  VariantProps<typeof buttonVariants> & {
47
47
  asChild?: boolean;
48
48
  }) {
49
- const Comp = asChild ? Slot : 'button';
49
+ const classNames = cn(buttonVariants({ variant, size, className }));
50
50
 
51
- return (
52
- <Comp
53
- data-slot="button"
54
- className={cn(buttonVariants({ variant, size, className }))}
55
- {...props}
56
- />
57
- );
51
+ if (asChild) {
52
+ return (
53
+ <Slot
54
+ data-slot="button"
55
+ className={classNames}
56
+ {...(props as React.ComponentProps<typeof Slot>)}
57
+ />
58
+ );
59
+ }
60
+
61
+ return <button data-slot="button" className={classNames} {...props} />;
58
62
  }
59
63
 
60
64
  type ButtonProps = React.ComponentProps<typeof Button>;
@@ -0,0 +1,23 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { ColorPicker } from './color-picker';
4
+
5
+ vi.mock('../../hooks/use-dom-resolved-theme', () => ({
6
+ useDomResolvedTheme: () => 'light',
7
+ }));
8
+
9
+ describe('ColorPicker', () => {
10
+ it('renders the supplied read-only label', () => {
11
+ render(<ColorPicker text="Visible workspace tag" value="#123456" />);
12
+
13
+ expect(
14
+ screen.getByRole('button', { name: 'Visible workspace tag' })
15
+ ).toBeVisible();
16
+ });
17
+
18
+ it('keeps the icon-only control when no label is supplied', () => {
19
+ const { container } = render(<ColorPicker value="#123456" />);
20
+
21
+ expect(container.querySelector('button svg')).toBeTruthy();
22
+ });
23
+ });
@@ -65,7 +65,7 @@ const ColorPicker = forwardRef<
65
65
  variant="outline"
66
66
  disabled={disabled}
67
67
  >
68
- <TextIcon className="h-4 w-4" />
68
+ {text || <TextIcon className="h-4 w-4" />}
69
69
  </Button>
70
70
  </PopoverTrigger>
71
71
  <PopoverContent className="flex w-full flex-col items-center justify-center gap-4">
@@ -101,6 +101,8 @@ interface ComboboxProps {
101
101
  onCreate?: (value: string) => unknown | Promise<unknown>;
102
102
  /** Callback when search input changes */
103
103
  onSearchChange?: (value: string) => void;
104
+ /** Whether Command should filter the provided options locally */
105
+ shouldFilter?: boolean;
104
106
  /** Callback when the popover opens or closes */
105
107
  onOpenChange?: (open: boolean) => void;
106
108
  /** Whether there are more options to load */
@@ -148,6 +150,7 @@ export function Combobox({
148
150
  useFirstValueAsDefault = false,
149
151
  onCreate,
150
152
  onSearchChange,
153
+ shouldFilter = true,
151
154
  onOpenChange,
152
155
  hasMore = false,
153
156
  onLoadMore,
@@ -543,6 +546,7 @@ export function Combobox({
543
546
  sideOffset={4}
544
547
  >
545
548
  <Command
549
+ shouldFilter={shouldFilter}
546
550
  filter={(value, search) => {
547
551
  if (value.startsWith(actionValuePrefix)) return 1;
548
552
  if (value.startsWith(createValuePrefix)) return 1;
@@ -41,7 +41,7 @@ import {
41
41
  dispatchRequestOpenTask,
42
42
  waitForTaskOpenResult,
43
43
  } from '@tuturuuu/ui/lib/task-open-events';
44
- import { Popover, PopoverContent, PopoverTrigger } from '@tuturuuu/ui/popover';
44
+ import { Popover, PopoverContent } from '@tuturuuu/ui/popover';
45
45
  import { toast } from '@tuturuuu/ui/sonner';
46
46
  import { cn } from '@tuturuuu/utils/format';
47
47
  import dayjs from 'dayjs';
@@ -49,6 +49,7 @@ import relativeTime from 'dayjs/plugin/relativeTime';
49
49
  import Link from 'next/link';
50
50
  import { useParams, usePathname, useRouter } from 'next/navigation';
51
51
  import { useCallback, useEffect, useRef, useState } from 'react';
52
+ import { NotificationPopoverTriggerButton } from './notification-popover-trigger';
52
53
 
53
54
  dayjs.extend(relativeTime);
54
55
 
@@ -190,24 +191,10 @@ export default function NotificationPopoverClient({
190
191
 
191
192
  return (
192
193
  <Popover open={open} onOpenChange={setOpen}>
193
- <PopoverTrigger asChild>
194
- <Button
195
- variant="ghost"
196
- size="icon"
197
- aria-label={notificationsText}
198
- title={notificationsText}
199
- className="group relative flex size-10 flex-none transition-all"
200
- >
201
- <Bell className="h-6 w-6" />
202
- {unreadCount > 0 && (
203
- <div className="absolute top-1 right-2 flex h-1.5 w-1.5 flex-none items-center justify-center rounded-full bg-dynamic-red p-1 text-center font-semibold text-xs transition-all group-hover:-top-2 group-hover:-right-1 group-hover:h-5 group-hover:w-auto group-hover:px-1.5 group-hover:text-background">
204
- <div className="relative opacity-0 group-hover:opacity-100">
205
- {unreadCount > 99 ? '99+' : unreadCount}
206
- </div>
207
- </div>
208
- )}
209
- </Button>
210
- </PopoverTrigger>
194
+ <NotificationPopoverTriggerButton
195
+ notificationsText={notificationsText}
196
+ unreadCount={unreadCount}
197
+ />
211
198
  <PopoverContent
212
199
  className="w-100 overflow-hidden rounded-xl border p-0 shadow-xl"
213
200
  align="end"
@@ -0,0 +1,37 @@
1
+ import '@testing-library/jest-dom';
2
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
3
+ import { Popover } from '@tuturuuu/ui/popover';
4
+ import { renderToString } from 'react-dom/server';
5
+ import { describe, expect, it } from 'vitest';
6
+ import { NotificationPopoverTriggerButton } from './notification-popover-trigger';
7
+
8
+ function TriggerHarness({ unreadCount = 0 }: { unreadCount?: number }) {
9
+ return (
10
+ <Popover>
11
+ <NotificationPopoverTriggerButton
12
+ notificationsText="Notifications"
13
+ unreadCount={unreadCount}
14
+ />
15
+ </Popover>
16
+ );
17
+ }
18
+
19
+ describe('NotificationPopoverTriggerButton', () => {
20
+ it('disables the server-rendered trigger until event handlers are attached', () => {
21
+ const markup = renderToString(<TriggerHarness unreadCount={1} />);
22
+
23
+ expect(markup).toContain('disabled=""');
24
+ });
25
+
26
+ it('enables the trigger after hydration and opens the popover on the first click', async () => {
27
+ render(<TriggerHarness unreadCount={1} />);
28
+
29
+ const trigger = screen.getByRole('button', { name: 'Notifications' });
30
+ await waitFor(() => expect(trigger).toBeEnabled());
31
+ fireEvent.click(trigger);
32
+
33
+ await waitFor(() =>
34
+ expect(trigger).toHaveAttribute('aria-expanded', 'true')
35
+ );
36
+ });
37
+ });
@@ -0,0 +1,44 @@
1
+ 'use client';
2
+
3
+ import { Bell } from '@tuturuuu/icons';
4
+ import { Button } from '@tuturuuu/ui/button';
5
+ import { PopoverTrigger } from '@tuturuuu/ui/popover';
6
+ import { useEffect, useState } from 'react';
7
+
8
+ interface NotificationPopoverTriggerProps {
9
+ notificationsText: string;
10
+ unreadCount: number;
11
+ }
12
+
13
+ export function NotificationPopoverTriggerButton({
14
+ notificationsText,
15
+ unreadCount,
16
+ }: NotificationPopoverTriggerProps) {
17
+ const [isHydrated, setIsHydrated] = useState(false);
18
+
19
+ useEffect(() => {
20
+ setIsHydrated(true);
21
+ }, []);
22
+
23
+ return (
24
+ <PopoverTrigger asChild>
25
+ <Button
26
+ variant="ghost"
27
+ size="icon"
28
+ aria-label={notificationsText}
29
+ title={notificationsText}
30
+ disabled={!isHydrated}
31
+ className="group relative flex size-10 flex-none transition-all"
32
+ >
33
+ <Bell className="h-6 w-6" />
34
+ {unreadCount > 0 && (
35
+ <div className="absolute top-1 right-2 flex h-1.5 w-1.5 flex-none items-center justify-center rounded-full bg-dynamic-red p-1 text-center font-semibold text-xs transition-all group-hover:-top-2 group-hover:-right-1 group-hover:h-5 group-hover:w-auto group-hover:px-1.5 group-hover:text-background">
36
+ <div className="relative opacity-0 group-hover:opacity-100">
37
+ {unreadCount > 99 ? '99+' : unreadCount}
38
+ </div>
39
+ </div>
40
+ )}
41
+ </Button>
42
+ </PopoverTrigger>
43
+ );
44
+ }
@@ -1,9 +1,21 @@
1
- import { describe, expect, it } from 'vitest';
1
+ import { describe, expect, it, vi } from 'vitest';
2
2
  import {
3
3
  createStandardWorkspaceAccessAdapter,
4
+ mergeWorkspaceInvitationRoles,
4
5
  normalizeWorkspaceAccessRole,
5
6
  } from './adapters';
6
7
 
8
+ const mocks = vi.hoisted(() => ({
9
+ inviteWorkspaceMember: vi.fn().mockResolvedValue({ message: 'success' }),
10
+ }));
11
+
12
+ vi.mock('@tuturuuu/internal-api/workspaces', async (importOriginal) => ({
13
+ ...(await importOriginal<
14
+ typeof import('@tuturuuu/internal-api/workspaces')
15
+ >()),
16
+ inviteWorkspaceMember: mocks.inviteWorkspaceMember,
17
+ }));
18
+
7
19
  describe('workspace access adapters', () => {
8
20
  it('normalizes role permissions into the shared access role shape', () => {
9
21
  expect(
@@ -37,4 +49,80 @@ describe('workspace access adapters', () => {
37
49
  createStandardWorkspaceAccessAdapter().updateMemberProfile
38
50
  ).toBeTypeOf('function');
39
51
  });
52
+
53
+ it('hydrates pending email and registered-user invitations with their assigned roles', () => {
54
+ const members = mergeWorkspaceInvitationRoles(
55
+ [
56
+ {
57
+ default_permissions: [],
58
+ email: 'pending@example.com',
59
+ id: null,
60
+ is_creator: false,
61
+ pending: true,
62
+ roles: [],
63
+ },
64
+ {
65
+ default_permissions: [],
66
+ email: null,
67
+ id: 'user-2',
68
+ is_creator: false,
69
+ pending: true,
70
+ roles: [],
71
+ },
72
+ {
73
+ default_permissions: [],
74
+ email: 'joined@example.com',
75
+ id: 'user-3',
76
+ is_creator: false,
77
+ pending: false,
78
+ roles: [{ id: 'role-owner', name: 'Owner', permissions: [] }],
79
+ },
80
+ ],
81
+ [
82
+ {
83
+ email: 'Pending@Example.com',
84
+ roles: [
85
+ { id: 'role-editor', name: 'Editor' },
86
+ { id: 'role-reviewer', name: 'Reviewer' },
87
+ ],
88
+ userId: null,
89
+ },
90
+ {
91
+ email: null,
92
+ roles: [{ id: 'role-reviewer', name: 'Reviewer' }],
93
+ userId: 'user-2',
94
+ },
95
+ ]
96
+ );
97
+
98
+ expect(members[0]?.roles).toEqual([
99
+ { id: 'role-editor', name: 'Editor', permissions: [] },
100
+ { id: 'role-reviewer', name: 'Reviewer', permissions: [] },
101
+ ]);
102
+ expect(members[1]?.roles).toEqual([
103
+ { id: 'role-reviewer', name: 'Reviewer', permissions: [] },
104
+ ]);
105
+ expect(members[2]?.roles).toEqual([
106
+ { id: 'role-owner', name: 'Owner', permissions: [] },
107
+ ]);
108
+ });
109
+
110
+ it('forwards multiple role assignments with every standard invite', async () => {
111
+ const adapter = createStandardWorkspaceAccessAdapter();
112
+
113
+ await adapter.inviteMembers('ws-1', {
114
+ accessPreset: 'member',
115
+ emails: ['editor@example.com'],
116
+ memberType: 'MEMBER',
117
+ roleIds: ['role-editor', 'role-reviewer'],
118
+ });
119
+
120
+ expect(mocks.inviteWorkspaceMember).toHaveBeenCalledWith('ws-1', {
121
+ accessPreset: 'member',
122
+ confirmDefaultAdminMigration: undefined,
123
+ email: 'editor@example.com',
124
+ memberType: 'MEMBER',
125
+ roleIds: ['role-editor', 'role-reviewer'],
126
+ });
127
+ });
40
128
  });
@@ -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