@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.
Files changed (43) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/biome.json +1 -1
  3. package/package.json +13 -12
  4. package/src/components/ui/chat/chat-sidebar-items.tsx +25 -19
  5. package/src/components/ui/chat/chat-utils.test.ts +88 -2
  6. package/src/components/ui/chat/chat-workspace.tsx +1 -0
  7. package/src/components/ui/chat/composer-attachment-chip.tsx +128 -0
  8. package/src/components/ui/chat/external-message-content.test.ts +41 -0
  9. package/src/components/ui/chat/external-message-content.ts +28 -0
  10. package/src/components/ui/chat/message-bubble.tsx +4 -2
  11. package/src/components/ui/chat/message-composer.tsx +121 -33
  12. package/src/components/ui/chat/message-links.test.tsx +14 -0
  13. package/src/components/ui/chat/utils.ts +39 -3
  14. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +2 -6
  15. package/src/components/ui/custom/__tests__/workspace-select-invitations.test.tsx +141 -0
  16. package/src/components/ui/custom/education/courses/course-row-actions.tsx +1 -1
  17. package/src/components/ui/custom/education/modules/course-module-row-actions.tsx +1 -1
  18. package/src/components/ui/custom/notification-popover-client.tsx +97 -57
  19. package/src/components/ui/custom/structure.test.tsx +55 -1
  20. package/src/components/ui/custom/structure.tsx +10 -1
  21. package/src/components/ui/custom/tables/custom-data-table.tsx +3 -2
  22. package/src/components/ui/custom/tables/data-table-column-header.tsx +4 -3
  23. package/src/components/ui/custom/tables/data-table-faceted-filter.tsx +4 -3
  24. package/src/components/ui/custom/tables/data-table-pagination.tsx +6 -6
  25. package/src/components/ui/custom/tables/data-table-toolbar.tsx +5 -5
  26. package/src/components/ui/custom/tables/data-table-view-options.tsx +4 -3
  27. package/src/components/ui/custom/tables/data-table.tsx +69 -30
  28. package/src/components/ui/custom/workspace-select-icon.tsx +58 -0
  29. package/src/components/ui/custom/workspace-select-invitations.tsx +202 -0
  30. package/src/components/ui/custom/workspace-select.tsx +43 -61
  31. package/src/components/ui/finance/invoices/columns.test.tsx +3 -3
  32. package/src/components/ui/finance/invoices/columns.tsx +4 -2
  33. package/src/components/ui/finance/invoices/pending-columns.tsx +1 -1
  34. package/src/components/ui/finance/invoices/row-actions.tsx +1 -1
  35. package/src/components/ui/finance/transactions/categories/columns.test.tsx +5 -5
  36. package/src/components/ui/finance/transactions/categories/columns.tsx +4 -2
  37. package/src/components/ui/finance/transactions/categories/row-actions.tsx +1 -1
  38. package/src/components/ui/finance/transactions/columns.test.tsx +5 -5
  39. package/src/components/ui/finance/transactions/columns.tsx +4 -2
  40. package/src/components/ui/finance/transactions/row-actions.tsx +1 -1
  41. package/src/components/ui/finance/wallets/columns.tsx +4 -2
  42. package/src/components/ui/finance/wallets/row-actions.tsx +1 -1
  43. package/src/hooks/use-notifications.ts +16 -2
@@ -1,16 +1,27 @@
1
1
  'use client';
2
2
 
3
- import { LoaderCircle, Paperclip, Send, Upload, X } from '@tuturuuu/icons';
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 { type ClipboardEvent, type FormEvent, useRef, useState } from 'react';
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
- const nextFiles = files.slice(0, remainingSlots);
73
- if (nextFiles.length === 0) return;
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
- uploaded.push(await onUploadFile(file));
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 className="border-t bg-background/95 p-3" onSubmit={handleSubmit}>
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
- <div
130
- className="flex min-w-0 max-w-full items-center gap-2 overflow-hidden rounded-md border bg-muted/40 px-2 py-1 text-sm"
233
+ <ComposerAttachmentChip
234
+ attachment={attachment}
131
235
  key={attachment.path}
132
- >
133
- <Upload className="size-4 shrink-0 text-muted-foreground" />
134
- <span className="min-w-0 truncate" title={attachment.filename}>
135
- {attachment.filename}
136
- </span>
137
- {attachment.sizeBytes ? (
138
- <span className="shrink-0 text-muted-foreground text-xs">
139
- {formatFileSize(attachment.sizeBytes)}
140
- </span>
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(latestMessage?.content) ??
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
- if (message.content.trim()) return message.content.trim();
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 workspaceSelectSource = readFileSync(
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,