@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.
- package/CHANGELOG.md +37 -0
- package/biome.json +1 -1
- package/package.json +49 -48
- package/src/components/ui/button.tsx +12 -8
- package/src/components/ui/color-picker.test.tsx +23 -0
- package/src/components/ui/color-picker.tsx +1 -1
- package/src/components/ui/custom/combobox.tsx +4 -0
- package/src/components/ui/custom/notification-popover-client.tsx +6 -19
- package/src/components/ui/custom/notification-popover-trigger.test.tsx +37 -0
- package/src/components/ui/custom/notification-popover-trigger.tsx +44 -0
- 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/text-editor/__tests__/editor-classes.test.ts +36 -0
- package/src/components/ui/text-editor/__tests__/inline-task-conversion.test.tsx +30 -0
- package/src/components/ui/text-editor/__tests__/keyboard.test.ts +173 -3
- package/src/components/ui/text-editor/__tests__/toggle-block-extension.test.ts +251 -0
- package/src/components/ui/text-editor/editor-classes.ts +100 -0
- package/src/components/ui/text-editor/editor.tsx +11 -153
- package/src/components/ui/text-editor/extensions.ts +19 -0
- package/src/components/ui/text-editor/keyboard.ts +47 -0
- package/src/components/ui/text-editor/toggle-block-extension.ts +153 -0
- package/src/components/ui/text-editor/tool-bar.tsx +24 -73
- package/src/components/ui/text-editor/toolbar-config.ts +67 -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/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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tuturuuu/ui",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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.
|
|
64
|
-
"@tiptap/extension-collaboration": "3.
|
|
65
|
-
"@tiptap/extension-collaboration-caret": "3.
|
|
66
|
-
"@tiptap/extension-color": "3.
|
|
67
|
-
"@tiptap/extension-
|
|
68
|
-
"@tiptap/extension-drag-handle
|
|
69
|
-
"@tiptap/extension-
|
|
70
|
-
"@tiptap/extension-
|
|
71
|
-
"@tiptap/extension-
|
|
72
|
-
"@tiptap/extension-
|
|
73
|
-
"@tiptap/extension-
|
|
74
|
-
"@tiptap/extension-
|
|
75
|
-
"@tiptap/extension-
|
|
76
|
-
"@tiptap/extension-
|
|
77
|
-
"@tiptap/extension-
|
|
78
|
-
"@tiptap/extension-
|
|
79
|
-
"@tiptap/extension-
|
|
80
|
-
"@tiptap/extension-table
|
|
81
|
-
"@tiptap/extension-table-
|
|
82
|
-
"@tiptap/extension-table-
|
|
83
|
-
"@tiptap/extension-
|
|
84
|
-
"@tiptap/extension-text-
|
|
85
|
-
"@tiptap/extension-
|
|
86
|
-
"@tiptap/
|
|
87
|
-
"@tiptap/
|
|
88
|
-
"@tiptap/
|
|
89
|
-
"@
|
|
90
|
-
"@tuturuuu/
|
|
91
|
-
"@tuturuuu/
|
|
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.
|
|
94
|
+
"@tuturuuu/internal-api": "0.31.0",
|
|
94
95
|
"@tuturuuu/supabase": "0.5.0",
|
|
95
|
-
"@tuturuuu/utils": "0.
|
|
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.
|
|
108
|
+
"framer-motion": "^13.1.0",
|
|
108
109
|
"gsap": "^3.15.0",
|
|
109
110
|
"html-entities": "^2.6.0",
|
|
110
|
-
"html2canvas-pro": "^2.3.
|
|
111
|
+
"html2canvas-pro": "^2.3.6",
|
|
111
112
|
"input-otp": "^1.4.2",
|
|
112
|
-
"katex": "^0.18.
|
|
113
|
+
"katex": "^0.18.4",
|
|
113
114
|
"lodash": "4.18.1",
|
|
114
115
|
"moment": "^2.30.1",
|
|
115
|
-
"motion": "^13.
|
|
116
|
-
"next": "^16.3.
|
|
117
|
-
"next-intl": "^4.13.
|
|
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.
|
|
128
|
-
"react-hook-form": "^7.
|
|
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.
|
|
137
|
-
"sonner": "^2.0.
|
|
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.
|
|
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.
|
|
154
|
-
"@testing-library/jest-dom": "^7.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.
|
|
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.
|
|
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
|
|
49
|
+
const classNames = cn(buttonVariants({ variant, size, className }));
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
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
|
-
<
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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 {
|
|
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
|