@tuturuuu/ui 0.25.2 → 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/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/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/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
|
@@ -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
|
}
|
|
@@ -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,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import '@testing-library/jest-dom';
|
|
2
|
-
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import { render, screen, within } from '@testing-library/react';
|
|
3
3
|
import { describe, expect, it, vi } from 'vitest';
|
|
4
4
|
import { Structure } from './structure';
|
|
5
5
|
|
|
@@ -30,4 +30,58 @@ describe('Structure', () => {
|
|
|
30
30
|
'md:p-4'
|
|
31
31
|
);
|
|
32
32
|
});
|
|
33
|
+
|
|
34
|
+
it('keeps the account and notification controls in the collapsed footer', () => {
|
|
35
|
+
const { container } = render(
|
|
36
|
+
<Structure
|
|
37
|
+
actions={<span>Expanded account actions</span>}
|
|
38
|
+
isCollapsed
|
|
39
|
+
notificationPopover={<button type="button">Notifications</button>}
|
|
40
|
+
setIsCollapsed={vi.fn()}
|
|
41
|
+
userPopover={<button type="button">Account</button>}
|
|
42
|
+
>
|
|
43
|
+
<span>Page content</span>
|
|
44
|
+
</Structure>
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const sidebar = container.querySelector('aside');
|
|
48
|
+
expect(sidebar).not.toBeNull();
|
|
49
|
+
const sidebarQueries = within(sidebar as HTMLElement);
|
|
50
|
+
|
|
51
|
+
expect(
|
|
52
|
+
sidebarQueries.getByRole('button', { name: 'Account' })
|
|
53
|
+
).toBeVisible();
|
|
54
|
+
expect(
|
|
55
|
+
sidebarQueries.getByRole('button', { name: 'Notifications' })
|
|
56
|
+
).toBeVisible();
|
|
57
|
+
expect(
|
|
58
|
+
sidebarQueries.queryByText('Expanded account actions')
|
|
59
|
+
).not.toBeInTheDocument();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('renders the combined account actions only once when expanded', () => {
|
|
63
|
+
const { container } = render(
|
|
64
|
+
<Structure
|
|
65
|
+
actions={<span>Expanded account actions</span>}
|
|
66
|
+
isCollapsed={false}
|
|
67
|
+
notificationPopover={<button type="button">Notifications</button>}
|
|
68
|
+
setIsCollapsed={vi.fn()}
|
|
69
|
+
userPopover={<button type="button">Account</button>}
|
|
70
|
+
>
|
|
71
|
+
<span>Page content</span>
|
|
72
|
+
</Structure>
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const sidebar = container.querySelector('aside');
|
|
76
|
+
expect(sidebar).not.toBeNull();
|
|
77
|
+
const sidebarQueries = within(sidebar as HTMLElement);
|
|
78
|
+
|
|
79
|
+
expect(sidebarQueries.getByText('Expanded account actions')).toBeVisible();
|
|
80
|
+
expect(
|
|
81
|
+
sidebarQueries.queryByRole('button', { name: 'Notifications' })
|
|
82
|
+
).not.toBeInTheDocument();
|
|
83
|
+
expect(
|
|
84
|
+
sidebarQueries.queryByRole('button', { name: 'Account' })
|
|
85
|
+
).not.toBeInTheDocument();
|
|
86
|
+
});
|
|
33
87
|
});
|
|
@@ -17,6 +17,7 @@ interface StructureProps {
|
|
|
17
17
|
sidebarContent?: ReactNode;
|
|
18
18
|
actions?: ReactNode;
|
|
19
19
|
userPopover?: ReactNode;
|
|
20
|
+
notificationPopover?: ReactNode;
|
|
20
21
|
sidebarUtility?: ReactNode;
|
|
21
22
|
feedbackButton?: ReactNode;
|
|
22
23
|
children: ReactNode;
|
|
@@ -40,6 +41,7 @@ export function Structure({
|
|
|
40
41
|
sidebarContent,
|
|
41
42
|
actions,
|
|
42
43
|
userPopover,
|
|
44
|
+
notificationPopover,
|
|
43
45
|
sidebarUtility,
|
|
44
46
|
feedbackButton,
|
|
45
47
|
children,
|
|
@@ -191,7 +193,14 @@ export function Structure({
|
|
|
191
193
|
isCollapsed ? 'justify-center' : ''
|
|
192
194
|
)}
|
|
193
195
|
>
|
|
194
|
-
{isCollapsed ?
|
|
196
|
+
{isCollapsed ? (
|
|
197
|
+
<div className="flex w-full flex-col items-center gap-1">
|
|
198
|
+
{userPopover}
|
|
199
|
+
{notificationPopover}
|
|
200
|
+
</div>
|
|
201
|
+
) : (
|
|
202
|
+
actions
|
|
203
|
+
)}
|
|
195
204
|
</div>
|
|
196
205
|
|
|
197
206
|
{!hideSizeToggle && (
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
+
import type { RowData } from '@tanstack/react-table';
|
|
3
4
|
import {
|
|
4
5
|
DataTable,
|
|
5
6
|
type DataTableProps,
|
|
@@ -8,7 +9,7 @@ import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
|
|
8
9
|
import { useTranslations } from 'next-intl';
|
|
9
10
|
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
|
10
11
|
|
|
11
|
-
function CustomDataTableInner<TData, TValue>({
|
|
12
|
+
function CustomDataTableInner<TData extends RowData, TValue>({
|
|
12
13
|
namespace,
|
|
13
14
|
hideToolbar,
|
|
14
15
|
hidePagination,
|
|
@@ -119,7 +120,7 @@ function CustomDataTableInner<TData, TValue>({
|
|
|
119
120
|
);
|
|
120
121
|
}
|
|
121
122
|
|
|
122
|
-
export function CustomDataTable<TData, TValue>(
|
|
123
|
+
export function CustomDataTable<TData extends RowData, TValue>(
|
|
123
124
|
props: DataTableProps<TData, TValue>
|
|
124
125
|
) {
|
|
125
126
|
return (
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { RowData } from '@tanstack/react-table';
|
|
2
2
|
import { ArrowDown, ArrowUp, ChevronDown, EyeOff } from '@tuturuuu/icons';
|
|
3
3
|
import { cn } from '@tuturuuu/utils/format';
|
|
4
4
|
import type React from 'react';
|
|
@@ -10,15 +10,16 @@ import {
|
|
|
10
10
|
DropdownMenuSeparator,
|
|
11
11
|
DropdownMenuTrigger,
|
|
12
12
|
} from '../../dropdown-menu';
|
|
13
|
+
import type { Column } from './data-table';
|
|
13
14
|
|
|
14
|
-
interface DataTableColumnHeaderProps<TData, TValue>
|
|
15
|
+
interface DataTableColumnHeaderProps<TData extends RowData, TValue>
|
|
15
16
|
extends React.HTMLAttributes<HTMLDivElement> {
|
|
16
17
|
t: any;
|
|
17
18
|
column: Column<TData, TValue>;
|
|
18
19
|
title?: string;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
export function DataTableColumnHeader<TData, TValue>({
|
|
22
|
+
export function DataTableColumnHeader<TData extends RowData, TValue>({
|
|
22
23
|
t,
|
|
23
24
|
column,
|
|
24
25
|
title,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { RowData } from '@tanstack/react-table';
|
|
2
2
|
import { Check, PlusCircle } from '@tuturuuu/icons';
|
|
3
3
|
import { cn } from '@tuturuuu/utils/format';
|
|
4
4
|
import type * as React from 'react';
|
|
@@ -15,8 +15,9 @@ import {
|
|
|
15
15
|
} from '../../command';
|
|
16
16
|
import { Popover, PopoverContent, PopoverTrigger } from '../../popover';
|
|
17
17
|
import { Separator } from '../../separator';
|
|
18
|
+
import type { Column } from './data-table';
|
|
18
19
|
|
|
19
|
-
interface DataTableFacetedFilterProps<TData, TValue> {
|
|
20
|
+
interface DataTableFacetedFilterProps<TData extends RowData, TValue> {
|
|
20
21
|
column?: Column<TData, TValue>;
|
|
21
22
|
title?: string;
|
|
22
23
|
options: {
|
|
@@ -26,7 +27,7 @@ interface DataTableFacetedFilterProps<TData, TValue> {
|
|
|
26
27
|
}[];
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
export function DataTableFacetedFilter<TData, TValue>({
|
|
30
|
+
export function DataTableFacetedFilter<TData extends RowData, TValue>({
|
|
30
31
|
column,
|
|
31
32
|
title,
|
|
32
33
|
options,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import type {
|
|
3
|
+
import type { RowData } from '@tanstack/react-table';
|
|
4
4
|
import {
|
|
5
5
|
ArrowLeftToLine,
|
|
6
6
|
ArrowRightToLine,
|
|
@@ -17,8 +17,9 @@ import {
|
|
|
17
17
|
SelectValue,
|
|
18
18
|
} from '../../select';
|
|
19
19
|
import { Separator } from '../../separator';
|
|
20
|
+
import type { Table } from './data-table';
|
|
20
21
|
|
|
21
|
-
interface DataTablePaginationProps<TData> {
|
|
22
|
+
interface DataTablePaginationProps<TData extends RowData> {
|
|
22
23
|
table?: Table<TData>;
|
|
23
24
|
count?: number | null;
|
|
24
25
|
className?: string;
|
|
@@ -31,7 +32,7 @@ interface DataTablePaginationProps<TData> {
|
|
|
31
32
|
setParams?: (params: { page?: number; pageSize?: string }) => void;
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
export function DataTablePagination<TData>({
|
|
35
|
+
export function DataTablePagination<TData extends RowData>({
|
|
35
36
|
table,
|
|
36
37
|
count,
|
|
37
38
|
className,
|
|
@@ -46,9 +47,8 @@ export function DataTablePagination<TData>({
|
|
|
46
47
|
// When setParams is provided, we're in server-side pagination mode
|
|
47
48
|
const isServerSide = !!setParams;
|
|
48
49
|
|
|
49
|
-
const pageIndex =
|
|
50
|
-
|
|
51
|
-
const pageSize = pageSizeProp ?? table?.getState().pagination.pageSize ?? 10;
|
|
50
|
+
const pageIndex = pageIndexProp ?? table?.state.pagination.pageIndex ?? 0;
|
|
51
|
+
const pageSize = pageSizeProp ?? table?.state.pagination.pageSize ?? 10;
|
|
52
52
|
const pageCount = pageCountProp ?? table?.getPageCount() ?? 0;
|
|
53
53
|
|
|
54
54
|
// filter duplicate and sort sizes
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import type {
|
|
3
|
+
import type { RowData } from '@tanstack/react-table';
|
|
4
4
|
import { Download, RotateCcw, Upload } from '@tuturuuu/icons';
|
|
5
5
|
import { Dialog, DialogContent, DialogTrigger } from '@tuturuuu/ui/dialog';
|
|
6
6
|
import type { ReactNode } from 'react';
|
|
7
7
|
import { Button } from '../../button';
|
|
8
8
|
import SearchBar from '../search-bar';
|
|
9
|
+
import type { Table } from './data-table';
|
|
9
10
|
import { DataTableCreateButton } from './data-table-create-button';
|
|
10
11
|
import { DataTableRefreshButton } from './data-table-refresh-button';
|
|
11
12
|
import { DataTableViewOptions } from './data-table-view-options';
|
|
@@ -19,7 +20,7 @@ type DataTableTranslator = ((key: string) => string) & {
|
|
|
19
20
|
has?: (key: string) => boolean;
|
|
20
21
|
};
|
|
21
22
|
|
|
22
|
-
interface DataTableToolbarProps<TData> {
|
|
23
|
+
interface DataTableToolbarProps<TData extends RowData> {
|
|
23
24
|
hasData: boolean;
|
|
24
25
|
newObjectTitle?: string;
|
|
25
26
|
editContent?: ReactNode;
|
|
@@ -42,7 +43,7 @@ interface DataTableToolbarProps<TData> {
|
|
|
42
43
|
resetParams: () => void;
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
export function DataTableToolbar<TData>({
|
|
46
|
+
export function DataTableToolbar<TData extends RowData>({
|
|
46
47
|
hasData,
|
|
47
48
|
newObjectTitle,
|
|
48
49
|
editContent,
|
|
@@ -65,8 +66,7 @@ export function DataTableToolbar<TData>({
|
|
|
65
66
|
const isFiltered =
|
|
66
67
|
isFilteredProp !== undefined
|
|
67
68
|
? isFilteredProp
|
|
68
|
-
: table.
|
|
69
|
-
(defaultQuery?.length || 0) > 0;
|
|
69
|
+
: table.state.columnFilters.length > 0 || (defaultQuery?.length || 0) > 0;
|
|
70
70
|
|
|
71
71
|
return (
|
|
72
72
|
<div className="flex flex-col items-start justify-between gap-2 lg:flex-row">
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { DropdownMenuTrigger } from '@radix-ui/react-dropdown-menu';
|
|
4
|
-
import type {
|
|
4
|
+
import type { RowData } from '@tanstack/react-table';
|
|
5
5
|
import { Settings2, UserCog } from '@tuturuuu/icons';
|
|
6
6
|
import { Fragment } from 'react';
|
|
7
7
|
import { Button } from '../../button';
|
|
@@ -13,15 +13,16 @@ import {
|
|
|
13
13
|
DropdownMenuSeparator,
|
|
14
14
|
} from '../../dropdown-menu';
|
|
15
15
|
import { ScrollArea } from '../../scroll-area';
|
|
16
|
+
import type { Table } from './data-table';
|
|
16
17
|
|
|
17
|
-
interface DataTableViewOptionsProps<TData> {
|
|
18
|
+
interface DataTableViewOptionsProps<TData extends RowData> {
|
|
18
19
|
table: Table<TData>;
|
|
19
20
|
extraColumns?: any[];
|
|
20
21
|
namespace: string | undefined;
|
|
21
22
|
t?: any;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
export function DataTableViewOptions<TData>({
|
|
25
|
+
export function DataTableViewOptions<TData extends RowData>({
|
|
25
26
|
t,
|
|
26
27
|
namespace,
|
|
27
28
|
table,
|