@tuturuuu/ui 0.25.0 → 0.25.3
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 +26 -0
- package/biome.json +1 -1
- package/package.json +13 -12
- package/src/components/ui/chat/chat-sidebar-items.tsx +25 -19
- package/src/components/ui/chat/chat-utils.test.ts +88 -2
- package/src/components/ui/chat/chat-workspace.tsx +1 -0
- package/src/components/ui/chat/composer-attachment-chip.tsx +128 -0
- package/src/components/ui/chat/external-message-content.test.ts +41 -0
- package/src/components/ui/chat/external-message-content.ts +28 -0
- package/src/components/ui/chat/message-bubble.tsx +4 -2
- package/src/components/ui/chat/message-composer.tsx +121 -33
- package/src/components/ui/chat/message-links.test.tsx +14 -0
- package/src/components/ui/chat/utils.ts +39 -3
- package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +2 -6
- package/src/components/ui/custom/__tests__/workspace-select-invitations.test.tsx +141 -0
- package/src/components/ui/custom/education/courses/course-row-actions.tsx +1 -1
- package/src/components/ui/custom/education/modules/course-module-row-actions.tsx +1 -1
- package/src/components/ui/custom/notification-popover-client.tsx +97 -57
- package/src/components/ui/custom/structure.test.tsx +55 -1
- package/src/components/ui/custom/structure.tsx +10 -1
- package/src/components/ui/custom/tables/custom-data-table.tsx +3 -2
- package/src/components/ui/custom/tables/data-table-column-header.tsx +4 -3
- package/src/components/ui/custom/tables/data-table-faceted-filter.tsx +4 -3
- package/src/components/ui/custom/tables/data-table-pagination.tsx +6 -6
- package/src/components/ui/custom/tables/data-table-toolbar.tsx +5 -5
- package/src/components/ui/custom/tables/data-table-view-options.tsx +4 -3
- package/src/components/ui/custom/tables/data-table.tsx +69 -30
- package/src/components/ui/custom/workspace-select-icon.tsx +58 -0
- package/src/components/ui/custom/workspace-select-invitations.tsx +202 -0
- package/src/components/ui/custom/workspace-select.tsx +43 -61
- package/src/components/ui/finance/invoices/columns.test.tsx +3 -3
- package/src/components/ui/finance/invoices/columns.tsx +4 -2
- package/src/components/ui/finance/invoices/pending-columns.tsx +1 -1
- package/src/components/ui/finance/invoices/row-actions.tsx +1 -1
- package/src/components/ui/finance/transactions/categories/columns.test.tsx +5 -5
- package/src/components/ui/finance/transactions/categories/columns.tsx +4 -2
- package/src/components/ui/finance/transactions/categories/row-actions.tsx +1 -1
- package/src/components/ui/finance/transactions/columns.test.tsx +5 -5
- package/src/components/ui/finance/transactions/columns.tsx +4 -2
- package/src/components/ui/finance/transactions/row-actions.tsx +1 -1
- package/src/components/ui/finance/wallets/columns.tsx +4 -2
- package/src/components/ui/finance/wallets/row-actions.tsx +1 -1
- package/src/hooks/use-notifications.ts +16 -2
|
@@ -1,16 +1,27 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { LoaderCircle, Paperclip, Send
|
|
3
|
+
import { LoaderCircle, Paperclip, Send } from '@tuturuuu/icons';
|
|
4
4
|
import type { ChatAttachmentDraft } from '@tuturuuu/internal-api';
|
|
5
5
|
import { cn } from '@tuturuuu/utils/format';
|
|
6
6
|
import { useTranslations } from 'next-intl';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
type ClipboardEvent,
|
|
9
|
+
type DragEvent,
|
|
10
|
+
type FormEvent,
|
|
11
|
+
useEffect,
|
|
12
|
+
useRef,
|
|
13
|
+
useState,
|
|
14
|
+
} from 'react';
|
|
8
15
|
import { Button } from '../button';
|
|
9
16
|
import { toast } from '../sonner';
|
|
10
17
|
import { Textarea } from '../textarea';
|
|
18
|
+
import { ComposerAttachmentChip } from './composer-attachment-chip';
|
|
11
19
|
import { formatFileSize } from './utils';
|
|
12
20
|
|
|
13
21
|
const MAX_COMPOSER_ATTACHMENTS = 20;
|
|
22
|
+
// Mirrors MAX_AI_ATTACHMENT_BYTES on the server. Rejecting here means the user
|
|
23
|
+
// finds out before the upload instead of after it.
|
|
24
|
+
const MAX_ATTACHMENT_BYTES = 100 * 1024 * 1024;
|
|
14
25
|
|
|
15
26
|
interface MessageComposerProps {
|
|
16
27
|
allowAttachments?: boolean;
|
|
@@ -37,6 +48,32 @@ export function MessageComposer({
|
|
|
37
48
|
const [content, setContent] = useState('');
|
|
38
49
|
const [attachments, setAttachments] = useState<ChatAttachmentDraft[]>([]);
|
|
39
50
|
const [uploadingCount, setUploadingCount] = useState(0);
|
|
51
|
+
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
|
52
|
+
// Thumbnails come from the local File, so they render immediately and cost
|
|
53
|
+
// no round trip. Object URLs leak unless revoked, hence the cleanup below.
|
|
54
|
+
const [previewUrls, setPreviewUrls] = useState<Record<string, string>>({});
|
|
55
|
+
const previewUrlsRef = useRef(previewUrls);
|
|
56
|
+
previewUrlsRef.current = previewUrls;
|
|
57
|
+
|
|
58
|
+
useEffect(
|
|
59
|
+
() => () => {
|
|
60
|
+
for (const url of Object.values(previewUrlsRef.current)) {
|
|
61
|
+
URL.revokeObjectURL(url);
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
[]
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
function releasePreview(path: string) {
|
|
68
|
+
setPreviewUrls((current) => {
|
|
69
|
+
const url = current[path];
|
|
70
|
+
if (!url) return current;
|
|
71
|
+
URL.revokeObjectURL(url);
|
|
72
|
+
const next = { ...current };
|
|
73
|
+
delete next[path];
|
|
74
|
+
return next;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
40
77
|
|
|
41
78
|
const busy = disabled || isSending || isUploading || uploadingCount > 0;
|
|
42
79
|
const canSend = content.trim().length > 0 || attachments.length > 0;
|
|
@@ -50,6 +87,7 @@ export function MessageComposer({
|
|
|
50
87
|
|
|
51
88
|
setContent('');
|
|
52
89
|
setAttachments([]);
|
|
90
|
+
for (const draft of draftAttachments) releasePreview(draft.path);
|
|
53
91
|
|
|
54
92
|
try {
|
|
55
93
|
await onSend({
|
|
@@ -65,19 +103,47 @@ export function MessageComposer({
|
|
|
65
103
|
async function handleFileCandidates(files: File[]) {
|
|
66
104
|
if (!allowAttachments || disabled || files.length === 0) return;
|
|
67
105
|
|
|
106
|
+
const withinSizeLimit = files.filter(
|
|
107
|
+
(file) => file.size <= MAX_ATTACHMENT_BYTES
|
|
108
|
+
);
|
|
109
|
+
if (withinSizeLimit.length < files.length) {
|
|
110
|
+
toast.error(
|
|
111
|
+
t('attachment_too_large', {
|
|
112
|
+
size: formatFileSize(MAX_ATTACHMENT_BYTES),
|
|
113
|
+
})
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
if (withinSizeLimit.length === 0) return;
|
|
117
|
+
|
|
68
118
|
const remainingSlots = Math.max(
|
|
69
119
|
MAX_COMPOSER_ATTACHMENTS - attachments.length,
|
|
70
120
|
0
|
|
71
121
|
);
|
|
72
|
-
|
|
73
|
-
|
|
122
|
+
if (remainingSlots === 0) {
|
|
123
|
+
toast.error(
|
|
124
|
+
t('attachment_limit_reached', { count: MAX_COMPOSER_ATTACHMENTS })
|
|
125
|
+
);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const nextFiles = withinSizeLimit.slice(0, remainingSlots);
|
|
130
|
+
if (nextFiles.length < withinSizeLimit.length) {
|
|
131
|
+
toast.error(
|
|
132
|
+
t('attachment_limit_reached', { count: MAX_COMPOSER_ATTACHMENTS })
|
|
133
|
+
);
|
|
134
|
+
}
|
|
74
135
|
|
|
75
136
|
setUploadingCount((count) => count + nextFiles.length);
|
|
76
137
|
const uploaded: ChatAttachmentDraft[] = [];
|
|
77
138
|
|
|
78
139
|
for (const file of nextFiles) {
|
|
79
140
|
try {
|
|
80
|
-
|
|
141
|
+
const draft = await onUploadFile(file);
|
|
142
|
+
uploaded.push(draft);
|
|
143
|
+
if (file.type.startsWith('image/') || file.type.startsWith('video/')) {
|
|
144
|
+
const url = URL.createObjectURL(file);
|
|
145
|
+
setPreviewUrls((current) => ({ ...current, [draft.path]: url }));
|
|
146
|
+
}
|
|
81
147
|
} catch {
|
|
82
148
|
toast.error(t('upload_failed'));
|
|
83
149
|
} finally {
|
|
@@ -94,6 +160,17 @@ export function MessageComposer({
|
|
|
94
160
|
}
|
|
95
161
|
}
|
|
96
162
|
|
|
163
|
+
function handleDrop(event: DragEvent<HTMLFormElement>) {
|
|
164
|
+
setIsDraggingOver(false);
|
|
165
|
+
if (!allowAttachments || disabled) return;
|
|
166
|
+
|
|
167
|
+
const dropped = Array.from(event.dataTransfer?.files ?? []);
|
|
168
|
+
if (dropped.length === 0) return;
|
|
169
|
+
|
|
170
|
+
event.preventDefault();
|
|
171
|
+
void handleFileCandidates(dropped);
|
|
172
|
+
}
|
|
173
|
+
|
|
97
174
|
async function handleFiles(files: FileList | null) {
|
|
98
175
|
if (!files?.length) return;
|
|
99
176
|
await handleFileCandidates(Array.from(files));
|
|
@@ -122,38 +199,49 @@ export function MessageComposer({
|
|
|
122
199
|
}
|
|
123
200
|
|
|
124
201
|
return (
|
|
125
|
-
<form
|
|
202
|
+
<form
|
|
203
|
+
className={cn(
|
|
204
|
+
'relative border-t bg-background/95 p-3 transition-colors',
|
|
205
|
+
isDraggingOver && 'bg-primary/5 ring-2 ring-primary ring-inset'
|
|
206
|
+
)}
|
|
207
|
+
onDragLeave={(event) => {
|
|
208
|
+
// Only clear when the pointer actually leaves the composer, not when it
|
|
209
|
+
// crosses into a child element.
|
|
210
|
+
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
|
211
|
+
setIsDraggingOver(false);
|
|
212
|
+
}
|
|
213
|
+
}}
|
|
214
|
+
onDragOver={(event) => {
|
|
215
|
+
if (!allowAttachments || disabled) return;
|
|
216
|
+
if (!Array.from(event.dataTransfer?.types ?? []).includes('Files')) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
event.preventDefault();
|
|
220
|
+
setIsDraggingOver(true);
|
|
221
|
+
}}
|
|
222
|
+
onDrop={handleDrop}
|
|
223
|
+
onSubmit={handleSubmit}
|
|
224
|
+
>
|
|
225
|
+
{isDraggingOver && (
|
|
226
|
+
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-sm bg-background/80 font-medium text-primary text-sm">
|
|
227
|
+
{t('drop_files_here')}
|
|
228
|
+
</div>
|
|
229
|
+
)}
|
|
126
230
|
{attachments.length > 0 && (
|
|
127
231
|
<div className="mb-2 flex flex-wrap gap-2">
|
|
128
232
|
{attachments.map((attachment) => (
|
|
129
|
-
<
|
|
130
|
-
|
|
233
|
+
<ComposerAttachmentChip
|
|
234
|
+
attachment={attachment}
|
|
131
235
|
key={attachment.path}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
) : null}
|
|
142
|
-
<Button
|
|
143
|
-
aria-label={t('remove_attachment')}
|
|
144
|
-
className="size-6"
|
|
145
|
-
onClick={() =>
|
|
146
|
-
setAttachments((current) =>
|
|
147
|
-
current.filter((item) => item.path !== attachment.path)
|
|
148
|
-
)
|
|
149
|
-
}
|
|
150
|
-
size="icon"
|
|
151
|
-
type="button"
|
|
152
|
-
variant="ghost"
|
|
153
|
-
>
|
|
154
|
-
<X className="size-3.5" />
|
|
155
|
-
</Button>
|
|
156
|
-
</div>
|
|
236
|
+
onRemove={() => {
|
|
237
|
+
releasePreview(attachment.path);
|
|
238
|
+
setAttachments((current) =>
|
|
239
|
+
current.filter((item) => item.path !== attachment.path)
|
|
240
|
+
);
|
|
241
|
+
}}
|
|
242
|
+
previewUrl={previewUrls[attachment.path]}
|
|
243
|
+
removeLabel={t('remove_attachment')}
|
|
244
|
+
/>
|
|
157
245
|
))}
|
|
158
246
|
</div>
|
|
159
247
|
)}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { render } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { MessageText } from './message-links';
|
|
4
|
+
|
|
5
|
+
describe('MessageText', () => {
|
|
6
|
+
it('renders HTML-like message content as inert text', () => {
|
|
7
|
+
const { container } = render(
|
|
8
|
+
<MessageText content="<img src=x onerror=alert(1)>" />
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
expect(container.querySelector('img')).toBeNull();
|
|
12
|
+
expect(container.textContent).toBe('<img src=x onerror=alert(1)>');
|
|
13
|
+
});
|
|
14
|
+
});
|
|
@@ -4,6 +4,10 @@ import type {
|
|
|
4
4
|
ChatMessage,
|
|
5
5
|
ChatUserProfile,
|
|
6
6
|
} from '@tuturuuu/internal-api';
|
|
7
|
+
import {
|
|
8
|
+
decodeExternalChatContent,
|
|
9
|
+
getChatMessageDisplayContent,
|
|
10
|
+
} from './external-message-content';
|
|
7
11
|
|
|
8
12
|
export type ChatConversationScope = 'external' | 'personal' | 'workspaces';
|
|
9
13
|
export type ChatConversationArchiveFilter = 'active' | 'all' | 'archived';
|
|
@@ -102,6 +106,12 @@ export function getChatConversationQueueDetails(
|
|
|
102
106
|
) {
|
|
103
107
|
const latestMessage = conversation.latestMessage;
|
|
104
108
|
const phone = readNonEmptyString(conversation.metadata.phone);
|
|
109
|
+
const previewContent = latestMessage
|
|
110
|
+
? decodeExternalChatContent(
|
|
111
|
+
latestMessage.content,
|
|
112
|
+
conversation.metadata.externalChat === true
|
|
113
|
+
)
|
|
114
|
+
: null;
|
|
105
115
|
|
|
106
116
|
return {
|
|
107
117
|
deliveryState: latestMessage
|
|
@@ -110,7 +120,7 @@ export function getChatConversationQueueDetails(
|
|
|
110
120
|
phone,
|
|
111
121
|
preview: latestMessage?.deletedAt
|
|
112
122
|
? null
|
|
113
|
-
: (readNonEmptyString(
|
|
123
|
+
: (readNonEmptyString(previewContent) ??
|
|
114
124
|
readNonEmptyString(latestMessage?.attachments[0]?.filename)),
|
|
115
125
|
timestamp: latestMessage?.createdAt ?? conversation.updatedAt,
|
|
116
126
|
};
|
|
@@ -210,7 +220,7 @@ export function getChatMessageSenderLabel(
|
|
|
210
220
|
const displayName =
|
|
211
221
|
readNonEmptyString(externalSender.displayName) ??
|
|
212
222
|
readNonEmptyString(externalSender.name);
|
|
213
|
-
if (displayName) return displayName;
|
|
223
|
+
if (displayName) return decodeExternalChatContent(displayName, true);
|
|
214
224
|
}
|
|
215
225
|
|
|
216
226
|
return message.metadata?.externalChat === true
|
|
@@ -234,9 +244,29 @@ export function getConversationTitle(
|
|
|
234
244
|
channel?: string;
|
|
235
245
|
chat?: string;
|
|
236
246
|
direct?: string;
|
|
247
|
+
external?: string;
|
|
237
248
|
group?: string;
|
|
238
249
|
}
|
|
239
250
|
) {
|
|
251
|
+
if (conversation.metadata.externalChat === true) {
|
|
252
|
+
const profileTitle =
|
|
253
|
+
readNonEmptyString(conversation.metadata.displayName) ??
|
|
254
|
+
readNonEmptyString(conversation.metadata.name);
|
|
255
|
+
if (profileTitle) return decodeExternalChatContent(profileTitle, true);
|
|
256
|
+
|
|
257
|
+
const persistedTitle = readNonEmptyString(conversation.title);
|
|
258
|
+
if (persistedTitle && !isGenericExternalTitle(persistedTitle)) {
|
|
259
|
+
return decodeExternalChatContent(persistedTitle, true);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const reference = conversation.id
|
|
263
|
+
.replaceAll('-', '')
|
|
264
|
+
.slice(-6)
|
|
265
|
+
.toUpperCase();
|
|
266
|
+
const label = fallback?.external ?? fallback?.channel ?? 'External visitor';
|
|
267
|
+
return reference ? `${label} #${reference}` : label;
|
|
268
|
+
}
|
|
269
|
+
|
|
240
270
|
if (conversation.title) return conversation.title;
|
|
241
271
|
|
|
242
272
|
if (conversation.type === 'direct') {
|
|
@@ -257,6 +287,11 @@ export function getConversationTitle(
|
|
|
257
287
|
return fallback?.chat ?? 'Untitled chat';
|
|
258
288
|
}
|
|
259
289
|
|
|
290
|
+
function isGenericExternalTitle(title: string) {
|
|
291
|
+
const normalized = title.trim().toLowerCase();
|
|
292
|
+
return normalized === 'external visitor' || normalized === 'website visitor';
|
|
293
|
+
}
|
|
294
|
+
|
|
260
295
|
export function getCurrentChatConversationMember(
|
|
261
296
|
conversation: ChatConversation,
|
|
262
297
|
currentUserId: string
|
|
@@ -412,7 +447,8 @@ export function getLastMessagePreview(
|
|
|
412
447
|
if (message.deletedAt) return labels.messageDeleted ?? '';
|
|
413
448
|
if (message.kind === 'system')
|
|
414
449
|
return labels.systemEvent ?? labels.message ?? '';
|
|
415
|
-
|
|
450
|
+
const displayContent = getChatMessageDisplayContent(message).trim();
|
|
451
|
+
if (displayContent) return displayContent;
|
|
416
452
|
if (message.attachments.length > 0) {
|
|
417
453
|
return message.attachments[0]?.filename ?? labels.attachment ?? '';
|
|
418
454
|
}
|
|
@@ -115,14 +115,10 @@ describe('mergeWorkspaceSelectWorkspaces', () => {
|
|
|
115
115
|
});
|
|
116
116
|
|
|
117
117
|
it('keeps workspace fallback images outside Radix AvatarImage context', () => {
|
|
118
|
-
const
|
|
119
|
-
join(process.cwd(), 'src/components/ui/custom/workspace-select.tsx'),
|
|
118
|
+
const workspaceIconSource = readFileSync(
|
|
119
|
+
join(process.cwd(), 'src/components/ui/custom/workspace-select-icon.tsx'),
|
|
120
120
|
'utf8'
|
|
121
121
|
);
|
|
122
|
-
const workspaceIconSource = workspaceSelectSource.slice(
|
|
123
|
-
workspaceSelectSource.indexOf('function WorkspaceIcon'),
|
|
124
|
-
workspaceSelectSource.indexOf('export function WorkspaceSelect')
|
|
125
|
-
);
|
|
126
122
|
|
|
127
123
|
expect(workspaceIconSource).toContain('<AvatarFallback');
|
|
128
124
|
expect(workspaceIconSource).toContain('<Image');
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
2
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
3
|
+
import type { WorkspaceInvitationRecord } from '@tuturuuu/internal-api/workspaces';
|
|
4
|
+
import { NextIntlClientProvider } from 'next-intl';
|
|
5
|
+
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import { Command, CommandInput, CommandList } from '../../command';
|
|
7
|
+
import {
|
|
8
|
+
useWorkspaceInvitations,
|
|
9
|
+
WorkspaceInvitationItems,
|
|
10
|
+
} from '../workspace-select-invitations';
|
|
11
|
+
|
|
12
|
+
const mocks = vi.hoisted(() => ({
|
|
13
|
+
acceptWorkspaceInvite: vi.fn(),
|
|
14
|
+
declineWorkspaceInvite: vi.fn(),
|
|
15
|
+
listWorkspaceInvitations: vi.fn(),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
vi.mock('@tuturuuu/internal-api/workspaces', () => ({
|
|
19
|
+
acceptWorkspaceInvite: mocks.acceptWorkspaceInvite,
|
|
20
|
+
declineWorkspaceInvite: mocks.declineWorkspaceInvite,
|
|
21
|
+
listWorkspaceInvitations: mocks.listWorkspaceInvitations,
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
const invitation: WorkspaceInvitationRecord = {
|
|
25
|
+
createdAt: null,
|
|
26
|
+
matchedEmail: 'invitee@example.com',
|
|
27
|
+
source: 'email',
|
|
28
|
+
type: 'MEMBER',
|
|
29
|
+
workspace: {
|
|
30
|
+
avatar_url: null,
|
|
31
|
+
handle: 'acme',
|
|
32
|
+
id: 'workspace-1',
|
|
33
|
+
logo_url: null,
|
|
34
|
+
name: 'Acme',
|
|
35
|
+
personal: false,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const messages = {
|
|
40
|
+
common: {
|
|
41
|
+
guest_access: 'Guest',
|
|
42
|
+
members: 'Member',
|
|
43
|
+
retry: 'Retry',
|
|
44
|
+
},
|
|
45
|
+
'workspace-invitation': {
|
|
46
|
+
accept: 'Accept',
|
|
47
|
+
'accept-error': 'Could not accept',
|
|
48
|
+
'accept-success': 'Accepted',
|
|
49
|
+
'decline-error': 'Could not decline',
|
|
50
|
+
'decline-success': 'Declined',
|
|
51
|
+
'direct-invite': 'Direct invitation',
|
|
52
|
+
'email-invite': 'Email invitation',
|
|
53
|
+
'list-eyebrow': 'Pending invitations',
|
|
54
|
+
reject: 'Decline',
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function Harness({
|
|
59
|
+
onAccepted = vi.fn(),
|
|
60
|
+
onDeclined = vi.fn(),
|
|
61
|
+
}: {
|
|
62
|
+
onAccepted?: (value: WorkspaceInvitationRecord) => void;
|
|
63
|
+
onDeclined?: () => void;
|
|
64
|
+
}) {
|
|
65
|
+
const controller = useWorkspaceInvitations({
|
|
66
|
+
cacheScope: 'user-1',
|
|
67
|
+
enabled: true,
|
|
68
|
+
onAccepted,
|
|
69
|
+
onDeclined,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<Command>
|
|
74
|
+
<CommandInput aria-label="Search" />
|
|
75
|
+
<CommandList>
|
|
76
|
+
<WorkspaceInvitationItems
|
|
77
|
+
controller={controller}
|
|
78
|
+
fallbackLogoUrl="/logo.svg"
|
|
79
|
+
/>
|
|
80
|
+
</CommandList>
|
|
81
|
+
</Command>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function renderHarness(props: Parameters<typeof Harness>[0] = {}) {
|
|
86
|
+
const queryClient = new QueryClient({
|
|
87
|
+
defaultOptions: { queries: { retry: false } },
|
|
88
|
+
});
|
|
89
|
+
return render(
|
|
90
|
+
<QueryClientProvider client={queryClient}>
|
|
91
|
+
<NextIntlClientProvider locale="en" messages={messages}>
|
|
92
|
+
<Harness {...props} />
|
|
93
|
+
</NextIntlClientProvider>
|
|
94
|
+
</QueryClientProvider>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
describe('workspace invitation picker items', () => {
|
|
99
|
+
beforeAll(() => {
|
|
100
|
+
globalThis.ResizeObserver = class ResizeObserver {
|
|
101
|
+
disconnect() {}
|
|
102
|
+
observe() {}
|
|
103
|
+
unobserve() {}
|
|
104
|
+
};
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
beforeEach(() => {
|
|
108
|
+
vi.clearAllMocks();
|
|
109
|
+
mocks.listWorkspaceInvitations.mockResolvedValue({
|
|
110
|
+
invitations: [invitation],
|
|
111
|
+
});
|
|
112
|
+
mocks.acceptWorkspaceInvite.mockResolvedValue(undefined);
|
|
113
|
+
mocks.declineWorkspaceInvite.mockResolvedValue(undefined);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('accepts the highlighted invitation and reports the accepted workspace', async () => {
|
|
117
|
+
const onAccepted = vi.fn();
|
|
118
|
+
renderHarness({ onAccepted });
|
|
119
|
+
|
|
120
|
+
const option = await screen.findByRole('option', { name: /Acme/ });
|
|
121
|
+
fireEvent.click(option);
|
|
122
|
+
|
|
123
|
+
await waitFor(() =>
|
|
124
|
+
expect(mocks.acceptWorkspaceInvite).toHaveBeenCalledWith('workspace-1')
|
|
125
|
+
);
|
|
126
|
+
await waitFor(() => expect(onAccepted).toHaveBeenCalledWith(invitation));
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('declines in place without activating the accept option', async () => {
|
|
130
|
+
const onDeclined = vi.fn();
|
|
131
|
+
renderHarness({ onDeclined });
|
|
132
|
+
|
|
133
|
+
fireEvent.click(await screen.findByRole('button', { name: 'Decline' }));
|
|
134
|
+
|
|
135
|
+
await waitFor(() =>
|
|
136
|
+
expect(mocks.declineWorkspaceInvite).toHaveBeenCalledWith('workspace-1')
|
|
137
|
+
);
|
|
138
|
+
expect(mocks.acceptWorkspaceInvite).not.toHaveBeenCalled();
|
|
139
|
+
await waitFor(() => expect(onDeclined).toHaveBeenCalledOnce());
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useMutation } from '@tanstack/react-query';
|
|
4
|
-
import type { Row } from '@tanstack/react-table';
|
|
5
4
|
import { Ellipsis } from '@tuturuuu/icons';
|
|
6
5
|
import { deleteWorkspaceCourse } from '@tuturuuu/internal-api';
|
|
7
6
|
import type { WorkspaceCourse } from '@tuturuuu/types';
|
|
8
7
|
import { Button } from '@tuturuuu/ui/button';
|
|
9
8
|
import { CourseForm } from '@tuturuuu/ui/custom/education/courses/course-form';
|
|
10
9
|
import ModifiableDialogTrigger from '@tuturuuu/ui/custom/modifiable-dialog-trigger';
|
|
10
|
+
import type { Row } from '@tuturuuu/ui/custom/tables/data-table';
|
|
11
11
|
import {
|
|
12
12
|
DropdownMenu,
|
|
13
13
|
DropdownMenuContent,
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useMutation } from '@tanstack/react-query';
|
|
4
|
-
import type { Row } from '@tanstack/react-table';
|
|
5
4
|
import { Ellipsis } from '@tuturuuu/icons';
|
|
6
5
|
import { deleteWorkspaceCourseModule } from '@tuturuuu/internal-api';
|
|
7
6
|
import type { WorkspaceCourseModule } from '@tuturuuu/types';
|
|
8
7
|
import { Button } from '@tuturuuu/ui/button';
|
|
9
8
|
import { CourseModuleForm } from '@tuturuuu/ui/custom/education/modules/course-module-form';
|
|
10
9
|
import ModifiableDialogTrigger from '@tuturuuu/ui/custom/modifiable-dialog-trigger';
|
|
10
|
+
import type { Row } from '@tuturuuu/ui/custom/tables/data-table';
|
|
11
11
|
import {
|
|
12
12
|
DropdownMenu,
|
|
13
13
|
DropdownMenuContent,
|