mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-27 05:38:51 +01:00
feat(DataTable): Implement new DataTable component with hooks and optimized features
- Added DataTable component with support for virtual scrolling, row selection, and customizable columns. - Introduced hooks for debouncing search input, managing row selection, and calculating column styles. - Enhanced accessibility with keyboard navigation and selection checkboxes. - Implemented skeleton loading state for better user experience during data fetching. - Added DataTableSearch component for filtering data with debounced input. - Created utility logger for improved debugging in development. - Updated translations to support new UI elements and actions.
This commit is contained in:
parent
ecadc2ec88
commit
76b34775f0
14 changed files with 1215 additions and 3294 deletions
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useState, useMemo } from 'react';
|
||||
import { useCallback, useState, useMemo, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { TrashIcon, MessageSquare } from 'lucide-react';
|
||||
import type { ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import type { SharedLinkItem, SharedLinksListParams } from 'librechat-data-provider';
|
||||
import {
|
||||
OGDialog,
|
||||
|
|
@ -19,8 +20,8 @@ import {
|
|||
} from '@librechat/client';
|
||||
import { useDeleteSharedLinkMutation, useSharedLinksQuery } from '~/data-provider';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
import { formatDate, cn } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { formatDate } from '~/utils';
|
||||
|
||||
const DEFAULT_PARAMS: SharedLinksListParams = {
|
||||
pageSize: 25,
|
||||
|
|
@ -30,14 +31,35 @@ const DEFAULT_PARAMS: SharedLinksListParams = {
|
|||
search: '',
|
||||
};
|
||||
|
||||
type SortKey = 'createdAt' | 'title';
|
||||
const isSortKey = (v: string): v is SortKey => v === 'createdAt' || v === 'title';
|
||||
|
||||
const defaultSort: SortingState = [
|
||||
{
|
||||
id: 'createdAt',
|
||||
desc: true,
|
||||
},
|
||||
];
|
||||
|
||||
type TableColumn<TData, TValue> = ColumnDef<TData, TValue> & {
|
||||
meta?: {
|
||||
className?: string;
|
||||
hideOnMobile?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export default function SharedLinks() {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
const [queryParams, setQueryParams] = useState<SharedLinksListParams>(DEFAULT_PARAMS);
|
||||
const [deleteRow, setDeleteRow] = useState<SharedLinkItem | null>(null);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const [deleteRow, setDeleteRow] = useState<SharedLinkItem | null>(null);
|
||||
|
||||
const [queryParams, setQueryParams] = useState<SharedLinksListParams>(DEFAULT_PARAMS);
|
||||
const [sorting, setSorting] = useState<SortingState>(defaultSort);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isFetching, refetch, isLoading } =
|
||||
useSharedLinksQuery(queryParams, {
|
||||
|
|
@ -48,38 +70,115 @@ export default function SharedLinks() {
|
|||
refetchOnMount: false,
|
||||
});
|
||||
|
||||
const handleSort = useCallback((sortField: string, sortOrder: 'asc' | 'desc') => {
|
||||
const [allKnownLinks, setAllKnownLinks] = useState<SharedLinkItem[]>([]);
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
const trimmedValue = value.trim();
|
||||
setSearchValue(trimmedValue);
|
||||
setAllKnownLinks([]);
|
||||
setQueryParams((prev) => ({
|
||||
...prev,
|
||||
sortBy: sortField as 'title' | 'createdAt',
|
||||
sortDirection: sortOrder,
|
||||
search: trimmedValue,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleFilterChange = useCallback((value: string) => {
|
||||
const encodedValue = encodeURIComponent(value.trim());
|
||||
setQueryParams((prev) => ({
|
||||
...prev,
|
||||
search: encodedValue,
|
||||
}));
|
||||
}, []);
|
||||
const handleSortingChange = useCallback(
|
||||
(updater: SortingState | ((old: SortingState) => SortingState)) => {
|
||||
setSorting((prev) => {
|
||||
const next = typeof updater === 'function' ? updater(prev) : updater;
|
||||
|
||||
const allLinks = useMemo(() => {
|
||||
if (!data?.pages) {
|
||||
return [];
|
||||
const coerced = next;
|
||||
const primary = coerced[0];
|
||||
|
||||
// Seed allKnown with current data before changing params
|
||||
if (data?.pages) {
|
||||
const currentFlattened = data.pages.flatMap((page) => page?.links?.filter(Boolean) ?? []);
|
||||
setAllKnownLinks(currentFlattened);
|
||||
}
|
||||
|
||||
setQueryParams((p) => {
|
||||
let sortBy: SortKey;
|
||||
let sortDirection: 'asc' | 'desc';
|
||||
|
||||
if (primary && isSortKey(primary.id)) {
|
||||
sortBy = primary.id;
|
||||
sortDirection = primary.desc ? 'desc' : 'asc';
|
||||
} else {
|
||||
sortBy = 'createdAt';
|
||||
sortDirection = 'desc';
|
||||
}
|
||||
|
||||
const newParams = {
|
||||
...p,
|
||||
sortBy,
|
||||
sortDirection,
|
||||
};
|
||||
|
||||
return newParams;
|
||||
});
|
||||
|
||||
return coerced;
|
||||
});
|
||||
},
|
||||
[setQueryParams, data?.pages],
|
||||
);
|
||||
|
||||
const handleError = useCallback(
|
||||
(error: Error) => {
|
||||
console.error('DataTable error:', error);
|
||||
showToast({
|
||||
message: localize('com_ui_share_error'),
|
||||
severity: NotificationSeverity.ERROR,
|
||||
});
|
||||
},
|
||||
[showToast, localize],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data?.pages) return;
|
||||
|
||||
const newFlattened = data.pages.flatMap((page) => page?.links?.filter(Boolean) ?? []);
|
||||
|
||||
const toAdd = newFlattened.filter(
|
||||
(link: SharedLinkItem) => !allKnownLinks.some((known) => known.shareId === link.shareId),
|
||||
);
|
||||
|
||||
if (toAdd.length > 0) {
|
||||
setAllKnownLinks((prev) => [...prev, ...toAdd]);
|
||||
}
|
||||
|
||||
return data.pages.flatMap((page) => page.links.filter(Boolean));
|
||||
}, [data?.pages]);
|
||||
|
||||
const displayData = useMemo(() => {
|
||||
const primary = sorting[0];
|
||||
if (!primary || allKnownLinks.length === 0) return allKnownLinks;
|
||||
|
||||
return [...allKnownLinks].sort((a: SharedLinkItem, b: SharedLinkItem) => {
|
||||
let compare: number;
|
||||
if (primary.id === 'createdAt') {
|
||||
const aDate = new Date(a.createdAt || 0);
|
||||
const bDate = new Date(b.createdAt || 0);
|
||||
compare = aDate.getTime() - bDate.getTime();
|
||||
} else if (primary.id === 'title') {
|
||||
compare = (a.title || '').localeCompare(b.title || '');
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
return primary.desc ? -compare : compare;
|
||||
});
|
||||
}, [allKnownLinks, sorting]);
|
||||
|
||||
const deleteMutation = useDeleteSharedLinkMutation({
|
||||
onSuccess: async () => {
|
||||
onSuccess: (data, variables) => {
|
||||
const { shareId } = variables;
|
||||
setAllKnownLinks((prev) => prev.filter((link) => link.shareId !== shareId));
|
||||
showToast({
|
||||
message: localize('com_ui_shared_link_delete_success'),
|
||||
severity: NotificationSeverity.SUCCESS,
|
||||
});
|
||||
setIsDeleteOpen(false);
|
||||
setDeleteRow(null);
|
||||
await refetch();
|
||||
refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Delete error:', error);
|
||||
onError: () => {
|
||||
showToast({
|
||||
message: localize('com_ui_share_delete_error'),
|
||||
severity: NotificationSeverity.ERROR,
|
||||
|
|
@ -87,78 +186,47 @@ export default function SharedLinks() {
|
|||
},
|
||||
});
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (selectedRows: SharedLinkItem[]) => {
|
||||
const validRows = selectedRows.filter(
|
||||
(row) => typeof row.shareId === 'string' && row.shareId.length > 0,
|
||||
);
|
||||
|
||||
if (validRows.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
for (const row of validRows) {
|
||||
await deleteMutation.mutateAsync({ shareId: row.shareId });
|
||||
}
|
||||
|
||||
showToast({
|
||||
message: localize(
|
||||
validRows.length === 1
|
||||
? 'com_ui_shared_link_delete_success'
|
||||
: 'com_ui_shared_link_bulk_delete_success',
|
||||
),
|
||||
severity: NotificationSeverity.SUCCESS,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to delete shared links:', error);
|
||||
showToast({
|
||||
message: localize('com_ui_share_delete_error'),
|
||||
severity: NotificationSeverity.ERROR,
|
||||
});
|
||||
}
|
||||
},
|
||||
[deleteMutation, showToast, localize],
|
||||
);
|
||||
|
||||
const handleFetchNextPage = useCallback(async () => {
|
||||
if (hasNextPage !== true || isFetchingNextPage) {
|
||||
return;
|
||||
}
|
||||
if (!hasNextPage || isFetchingNextPage) return;
|
||||
await fetchNextPage();
|
||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
||||
|
||||
try {
|
||||
await fetchNextPage();
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch next page:', error);
|
||||
showToast({
|
||||
message: localize('com_ui_share_delete_error'),
|
||||
severity: NotificationSeverity.ERROR,
|
||||
});
|
||||
}
|
||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage, showToast, localize]);
|
||||
const effectiveIsLoading = isLoading && displayData.length === 0;
|
||||
const effectiveIsFetching = isFetchingNextPage;
|
||||
|
||||
const confirmDelete = useCallback(() => {
|
||||
if (deleteRow) {
|
||||
handleDelete([deleteRow]);
|
||||
if (!deleteRow?.shareId) {
|
||||
showToast({
|
||||
message: localize('com_ui_share_delete_error'),
|
||||
severity: NotificationSeverity.WARNING,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setIsDeleteOpen(false);
|
||||
}, [deleteRow, handleDelete]);
|
||||
deleteMutation.mutate({ shareId: deleteRow.shareId });
|
||||
}, [deleteMutation, deleteRow, localize, showToast]);
|
||||
|
||||
const columns = useMemo(
|
||||
const columns: TableColumn<Record<string, unknown>, unknown>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'title',
|
||||
header: () => <span className="text-xs sm:text-sm">{localize('com_ui_name')}</span>,
|
||||
accessorFn: (row: Record<string, unknown>): unknown => {
|
||||
const link = row as SharedLinkItem;
|
||||
return link.title;
|
||||
},
|
||||
header: () => (
|
||||
<span className="text-xs text-text-primary sm:text-sm">{localize('com_ui_name')}</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const { title, shareId } = row.original;
|
||||
const link = row.original as SharedLinkItem;
|
||||
const { title, shareId } = link;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
to={`/share/${shareId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block truncate text-blue-500 hover:underline"
|
||||
title={title}
|
||||
className="flex items-center truncate text-blue-500 hover:underline"
|
||||
aria-label={localize('com_ui_open_link', { 0: title })}
|
||||
>
|
||||
{title}
|
||||
</Link>
|
||||
|
|
@ -166,64 +234,81 @@ export default function SharedLinks() {
|
|||
);
|
||||
},
|
||||
meta: {
|
||||
size: '35%',
|
||||
mobileSize: '50%',
|
||||
enableSorting: true,
|
||||
className: 'min-w-[150px] flex-1',
|
||||
},
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
header: () => <span className="text-xs sm:text-sm">{localize('com_ui_date')}</span>,
|
||||
cell: ({ row }) => formatDate(row.original.createdAt?.toString() ?? '', isSmallScreen),
|
||||
meta: {
|
||||
size: '10%',
|
||||
mobileSize: '20%',
|
||||
enableSorting: true,
|
||||
accessorFn: (row: Record<string, unknown>): unknown => {
|
||||
const link = row as SharedLinkItem;
|
||||
return link.createdAt;
|
||||
},
|
||||
header: () => (
|
||||
<span className="text-xs text-text-primary sm:text-sm">{localize('com_ui_date')}</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const link = row.original as SharedLinkItem;
|
||||
return formatDate(link.createdAt?.toString() ?? '', isSmallScreen);
|
||||
},
|
||||
meta: {
|
||||
className: 'w-32 sm:w-40',
|
||||
hideOnMobile: true,
|
||||
},
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: 'actions',
|
||||
header: () => <Label>{localize('com_assistants_actions')}</Label>,
|
||||
meta: {
|
||||
size: '7%',
|
||||
mobileSize: '25%',
|
||||
enableSorting: false,
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_view_source')}
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 hover:bg-surface-hover"
|
||||
onClick={() => {
|
||||
window.open(`/c/${row.original.conversationId}`, '_blank');
|
||||
}}
|
||||
title={localize('com_ui_view_source')}
|
||||
>
|
||||
<MessageSquare className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_delete')}
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 hover:bg-surface-hover"
|
||||
onClick={() => {
|
||||
setDeleteRow(row.original);
|
||||
setIsDeleteOpen(true);
|
||||
}}
|
||||
title={localize('com_ui_delete')}
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
id: 'actions',
|
||||
accessorFn: (row: Record<string, unknown>): unknown => null,
|
||||
header: () => (
|
||||
<span className="text-xs text-text-primary sm:text-sm">
|
||||
{localize('com_assistants_actions')}
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const link = row.original as SharedLinkItem;
|
||||
const { title, conversationId, shareId } = link;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_view_source')}
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 hover:bg-surface-hover"
|
||||
onClick={() => {
|
||||
window.open(`/c/${conversationId}`, '_blank');
|
||||
}}
|
||||
aria-label={localize('com_ui_view_source_conversation', { 0: title })}
|
||||
>
|
||||
<MessageSquare className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_delete')}
|
||||
render={
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => {
|
||||
setDeleteRow(link);
|
||||
setIsDeleteOpen(true);
|
||||
}}
|
||||
aria-label={localize('com_ui_delete_link_title', { 0: title })}
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
className: 'w-24',
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
],
|
||||
[isSmallScreen, localize],
|
||||
|
|
@ -233,40 +318,40 @@ export default function SharedLinks() {
|
|||
<div className="flex items-center justify-between">
|
||||
<Label id="shared-links-label">{localize('com_nav_shared_links')}</Label>
|
||||
<OGDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<OGDialogTrigger asChild onClick={() => setIsOpen(true)}>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button aria-labelledby="shared-links-label" variant="outline">
|
||||
{localize('com_ui_manage')}
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogContent className="w-11/12 max-w-5xl">
|
||||
<OGDialogContent className={cn('w-11/12 max-w-6xl', isSmallScreen && 'px-0 pb-0')}>
|
||||
<OGDialogHeader>
|
||||
<OGDialogTitle>{localize('com_nav_shared_links')}</OGDialogTitle>
|
||||
</OGDialogHeader>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={allLinks}
|
||||
onDelete={handleDelete}
|
||||
data={displayData}
|
||||
isLoading={effectiveIsLoading}
|
||||
isFetching={effectiveIsFetching}
|
||||
config={{
|
||||
skeleton: { count: 10 },
|
||||
skeleton: { count: 11 },
|
||||
search: {
|
||||
filterColumn: 'title',
|
||||
enableSearch: true,
|
||||
debounce: 300,
|
||||
},
|
||||
selection: {
|
||||
enableRowSelection: true,
|
||||
showCheckboxes: true,
|
||||
enableRowSelection: false,
|
||||
showCheckboxes: false,
|
||||
},
|
||||
}}
|
||||
filterValue={searchValue}
|
||||
onFilterChange={handleSearchChange}
|
||||
fetchNextPage={handleFetchNextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
isFetching={isFetching}
|
||||
fetchNextPage={handleFetchNextPage}
|
||||
onFilterChange={handleFilterChange}
|
||||
isLoading={isLoading}
|
||||
onSortingChange={handleSort}
|
||||
sortBy={queryParams.sortBy}
|
||||
sortDirection={queryParams.sortDirection}
|
||||
sorting={sorting}
|
||||
onSortingChange={handleSortingChange}
|
||||
onError={handleError}
|
||||
/>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
|
|
@ -276,15 +361,13 @@ export default function SharedLinks() {
|
|||
title={localize('com_ui_delete_shared_link')}
|
||||
className="w-11/12 max-w-md"
|
||||
main={
|
||||
<>
|
||||
<div className="flex w-full flex-col items-center gap-2">
|
||||
<div className="grid w-full items-center gap-2">
|
||||
<Label htmlFor="dialog-confirm-delete" className="text-left text-sm font-medium">
|
||||
{localize('com_ui_delete_confirm')} <strong>{deleteRow?.title}</strong>
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-center gap-2">
|
||||
<div className="grid w-full items-center gap-2">
|
||||
<Label className="text-left text-sm font-medium">
|
||||
{localize('com_ui_delete_confirm')} <strong>{deleteRow?.title}</strong>
|
||||
</Label>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
}
|
||||
selection={{
|
||||
selectHandler: confirmDelete,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { TrashIcon, ArchiveRestore } from 'lucide-react';
|
||||
import type { ColumnDef, SortingState } from '@tanstack/react-table';
|
||||
import {
|
||||
|
|
@ -14,6 +14,7 @@ import {
|
|||
Spinner,
|
||||
useToastContext,
|
||||
useMediaQuery,
|
||||
DataTable,
|
||||
} from '@librechat/client';
|
||||
import type { ConversationListParams, TConversation } from 'librechat-data-provider';
|
||||
import {
|
||||
|
|
@ -23,9 +24,8 @@ import {
|
|||
} from '~/data-provider';
|
||||
import { MinimalIcon } from '~/components/Endpoints';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
import { formatDate, cn } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { formatDate } from '~/utils';
|
||||
import DataTable from './DataTable';
|
||||
|
||||
const DEFAULT_PARAMS = {
|
||||
isArchived: true,
|
||||
|
|
@ -76,9 +76,12 @@ export default function ArchivedChatsTable() {
|
|||
refetchOnMount: false,
|
||||
});
|
||||
|
||||
const [allKnownConversations, setAllKnownConversations] = useState<TConversation[]>([]);
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
const trimmedValue = value.trim();
|
||||
setSearchValue(trimmedValue);
|
||||
setAllKnownConversations([]);
|
||||
setQueryParams((prev) => ({
|
||||
...prev,
|
||||
search: trimmedValue,
|
||||
|
|
@ -93,25 +96,31 @@ export default function ArchivedChatsTable() {
|
|||
const coerced = next;
|
||||
const primary = coerced[0];
|
||||
|
||||
setQueryParams((p) => {
|
||||
const newParams = (() => {
|
||||
if (primary && isSortKey(primary.id)) {
|
||||
return {
|
||||
...p,
|
||||
sortBy: primary.id,
|
||||
sortDirection: primary.desc ? 'desc' : 'asc',
|
||||
};
|
||||
}
|
||||
return {
|
||||
...p,
|
||||
sortBy: 'createdAt',
|
||||
sortDirection: 'desc',
|
||||
};
|
||||
})();
|
||||
// Seed allKnown with current data before changing params
|
||||
if (data?.pages) {
|
||||
const currentFlattened = data.pages.flatMap(
|
||||
(page) => page?.conversations?.filter(Boolean) ?? [],
|
||||
);
|
||||
setAllKnownConversations(currentFlattened);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
refetch();
|
||||
}, 0);
|
||||
setQueryParams((p) => {
|
||||
let sortBy: SortKey;
|
||||
let sortDirection: 'asc' | 'desc';
|
||||
|
||||
if (primary && isSortKey(primary.id)) {
|
||||
sortBy = primary.id;
|
||||
sortDirection = primary.desc ? 'desc' : 'asc';
|
||||
} else {
|
||||
sortBy = 'createdAt';
|
||||
sortDirection = 'desc';
|
||||
}
|
||||
|
||||
const newParams = {
|
||||
...p,
|
||||
sortBy,
|
||||
sortDirection,
|
||||
};
|
||||
|
||||
return newParams;
|
||||
});
|
||||
|
|
@ -119,7 +128,7 @@ export default function ArchivedChatsTable() {
|
|||
return coerced;
|
||||
});
|
||||
},
|
||||
[setQueryParams, setSorting, refetch],
|
||||
[setQueryParams, data?.pages],
|
||||
);
|
||||
|
||||
const handleError = useCallback(
|
||||
|
|
@ -133,14 +142,45 @@ export default function ArchivedChatsTable() {
|
|||
[showToast, localize],
|
||||
);
|
||||
|
||||
const allConversations = useMemo(() => {
|
||||
if (!data?.pages) return [];
|
||||
return data.pages.flatMap((page) => page?.conversations?.filter(Boolean) ?? []);
|
||||
useEffect(() => {
|
||||
if (!data?.pages) return;
|
||||
|
||||
const newFlattened = data.pages.flatMap((page) => page?.conversations?.filter(Boolean) ?? []);
|
||||
|
||||
const toAdd = newFlattened.filter(
|
||||
(convo: TConversation) =>
|
||||
!allKnownConversations.some((known) => known.conversationId === convo.conversationId),
|
||||
);
|
||||
|
||||
if (toAdd.length > 0) {
|
||||
setAllKnownConversations((prev) => [...prev, ...toAdd]);
|
||||
}
|
||||
}, [data?.pages]);
|
||||
|
||||
const displayData = useMemo(() => {
|
||||
const primary = sorting[0];
|
||||
if (!primary || allKnownConversations.length === 0) return allKnownConversations;
|
||||
|
||||
return [...allKnownConversations].sort((a: TConversation, b: TConversation) => {
|
||||
let compare: number;
|
||||
if (primary.id === 'createdAt') {
|
||||
const aDate = new Date(a.createdAt || 0);
|
||||
const bDate = new Date(b.createdAt || 0);
|
||||
compare = aDate.getTime() - bDate.getTime();
|
||||
} else if (primary.id === 'title') {
|
||||
compare = (a.title || '').localeCompare(b.title || '');
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
return primary.desc ? -compare : compare;
|
||||
});
|
||||
}, [allKnownConversations, sorting]);
|
||||
|
||||
const unarchiveMutation = useArchiveConvoMutation({
|
||||
onSuccess: async () => {
|
||||
await refetch();
|
||||
onSuccess: (data, variables) => {
|
||||
const { conversationId } = variables;
|
||||
setAllKnownConversations((prev) => prev.filter((c) => c.conversationId !== conversationId));
|
||||
refetch();
|
||||
},
|
||||
onError: () => {
|
||||
showToast({
|
||||
|
|
@ -151,13 +191,15 @@ export default function ArchivedChatsTable() {
|
|||
});
|
||||
|
||||
const deleteMutation = useDeleteConversationMutation({
|
||||
onSuccess: async () => {
|
||||
onSuccess: (data, variables) => {
|
||||
const { conversationId } = variables;
|
||||
setAllKnownConversations((prev) => prev.filter((c) => c.conversationId !== conversationId));
|
||||
showToast({
|
||||
message: localize('com_ui_archived_conversation_delete_success'),
|
||||
severity: NotificationSeverity.SUCCESS,
|
||||
});
|
||||
setIsDeleteOpen(false);
|
||||
await refetch();
|
||||
refetch();
|
||||
},
|
||||
onError: () => {
|
||||
showToast({
|
||||
|
|
@ -172,6 +214,9 @@ export default function ArchivedChatsTable() {
|
|||
await fetchNextPage();
|
||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
||||
|
||||
const effectiveIsLoading = isLoading && displayData.length === 0;
|
||||
const effectiveIsFetching = isFetchingNextPage;
|
||||
|
||||
const confirmDelete = useCallback(() => {
|
||||
if (!deleteRow?.conversationId) {
|
||||
showToast({
|
||||
|
|
@ -183,19 +228,37 @@ export default function ArchivedChatsTable() {
|
|||
deleteMutation.mutate({ conversationId: deleteRow.conversationId });
|
||||
}, [deleteMutation, deleteRow, localize, showToast]);
|
||||
|
||||
const columns: TableColumn<TConversation, any>[] = useMemo(
|
||||
const handleUnarchive = useCallback(
|
||||
(conversationId: string) => {
|
||||
setUnarchivingId(conversationId);
|
||||
unarchiveMutation.mutate(
|
||||
{ conversationId, isArchived: false },
|
||||
{ onSettled: () => setUnarchivingId(null) },
|
||||
);
|
||||
},
|
||||
[unarchiveMutation],
|
||||
);
|
||||
|
||||
const columns: TableColumn<Record<string, unknown>, unknown>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'title',
|
||||
accessorFn: (row: Record<string, unknown>): unknown => {
|
||||
const convo = row as TConversation;
|
||||
return convo.title;
|
||||
},
|
||||
header: () => (
|
||||
<span className="text-xs sm:text-sm">{localize('com_nav_archive_name')}</span>
|
||||
<span className="text-xs text-text-primary sm:text-sm">
|
||||
{localize('com_nav_archive_name')}
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const { conversationId, title } = row.original;
|
||||
const convo = row.original as TConversation;
|
||||
const { conversationId, title } = convo;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<MinimalIcon
|
||||
endpoint={row.original.endpoint}
|
||||
endpoint={convo.endpoint}
|
||||
size={28}
|
||||
isCreatedByUser={false}
|
||||
iconClassName="size-4"
|
||||
|
|
@ -214,34 +277,43 @@ export default function ArchivedChatsTable() {
|
|||
);
|
||||
},
|
||||
meta: {
|
||||
priority: 3,
|
||||
minWidth: 'min-content',
|
||||
className: 'min-w-[150px] flex-1',
|
||||
},
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
accessorFn: (row: Record<string, unknown>): unknown => {
|
||||
const convo = row as TConversation;
|
||||
return convo.createdAt;
|
||||
},
|
||||
header: () => (
|
||||
<span className="text-xs sm:text-sm">{localize('com_nav_archive_created_at')}</span>
|
||||
<span className="text-xs text-text-primary sm:text-sm">
|
||||
{localize('com_nav_archive_created_at')}
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => formatDate(row.original.createdAt?.toString() ?? '', isSmallScreen),
|
||||
cell: ({ row }) => {
|
||||
const convo = row.original as TConversation;
|
||||
return formatDate(convo.createdAt?.toString() ?? '', isSmallScreen);
|
||||
},
|
||||
meta: {
|
||||
priority: 2,
|
||||
minWidth: '80px',
|
||||
className: 'w-32 sm:w-40',
|
||||
hideOnMobile: true,
|
||||
},
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
accessorFn: (row: Record<string, unknown>): unknown => null,
|
||||
header: () => (
|
||||
<Label className="px-2 py-0 text-xs sm:px-2 sm:py-2 sm:text-sm">
|
||||
<span className="text-xs text-text-primary sm:text-sm">
|
||||
{localize('com_assistants_actions')}
|
||||
</Label>
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const conversation = row.original;
|
||||
const { title } = conversation;
|
||||
const isRowUnarchiving = unarchivingId === conversation.conversationId;
|
||||
const convo = row.original as TConversation;
|
||||
const { title } = convo;
|
||||
const isRowUnarchiving = unarchivingId === convo.conversationId;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -252,13 +324,9 @@ export default function ArchivedChatsTable() {
|
|||
variant="ghost"
|
||||
className="h-8 w-8 p-0 hover:bg-surface-hover"
|
||||
onClick={() => {
|
||||
const conversationId = conversation.conversationId;
|
||||
const conversationId = convo.conversationId;
|
||||
if (!conversationId) return;
|
||||
setUnarchivingId(conversationId);
|
||||
unarchiveMutation.mutate(
|
||||
{ conversationId, isArchived: false },
|
||||
{ onSettled: () => setUnarchivingId(null) },
|
||||
);
|
||||
handleUnarchive(conversationId);
|
||||
}}
|
||||
disabled={isRowUnarchiving}
|
||||
aria-label={localize('com_ui_unarchive_conversation_title', { 0: title })}
|
||||
|
|
@ -272,9 +340,9 @@ export default function ArchivedChatsTable() {
|
|||
render={
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="h-8 w-8 p-0 hover:bg-surface-hover"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => {
|
||||
setDeleteRow(row.original);
|
||||
setDeleteRow(convo);
|
||||
setIsDeleteOpen(true);
|
||||
}}
|
||||
aria-label={localize('com_ui_delete_conversation_title', { 0: title })}
|
||||
|
|
@ -287,13 +355,12 @@ export default function ArchivedChatsTable() {
|
|||
);
|
||||
},
|
||||
meta: {
|
||||
priority: 1,
|
||||
minWidth: '120px',
|
||||
className: 'w-24',
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
],
|
||||
[isSmallScreen, localize, unarchiveMutation, unarchivingId],
|
||||
[isSmallScreen, localize, handleUnarchive, unarchivingId],
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -305,17 +372,17 @@ export default function ArchivedChatsTable() {
|
|||
{localize('com_ui_manage')}
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogContent className="w-11/12 max-w-5xl">
|
||||
<OGDialogContent className={cn('w-11/12 max-w-6xl', isSmallScreen && 'px-0 pb-0')}>
|
||||
<OGDialogHeader>
|
||||
<OGDialogTitle>{localize('com_nav_archived_chats')}</OGDialogTitle>
|
||||
</OGDialogHeader>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={allConversations}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
data={displayData}
|
||||
isLoading={effectiveIsLoading}
|
||||
isFetching={effectiveIsFetching}
|
||||
config={{
|
||||
skeleton: { count: 10 },
|
||||
skeleton: { count: 11 },
|
||||
search: {
|
||||
filterColumn: 'title',
|
||||
enableSearch: true,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ const plugins = [
|
|||
}),
|
||||
replace({
|
||||
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'production'),
|
||||
'process.env.VITE_ENABLE_LOGGER': JSON.stringify(process.env.VITE_ENABLE_LOGGER || 'false'),
|
||||
'process.env.VITE_LOGGER_FILTER': JSON.stringify(process.env.VITE_LOGGER_FILTER || ''),
|
||||
preventAssignment: true,
|
||||
}),
|
||||
commonjs(),
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ const Checkbox = React.forwardRef<
|
|||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||
'peer h-4 w-4 shrink-0 rounded-sm border border-border-xheavy ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
135
packages/client/src/components/DataTable/DataTable.hooks.ts
Normal file
135
packages/client/src/components/DataTable/DataTable.hooks.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import { useState, useEffect, useMemo } from 'react';
|
||||
import type { TableColumn } from './DataTable.types';
|
||||
|
||||
export function useDebounced<T>(value: T, delay: number) {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setDebounced(value), delay);
|
||||
return () => clearTimeout(id);
|
||||
}, [value, delay]);
|
||||
|
||||
return debounced;
|
||||
}
|
||||
|
||||
export const useOptimizedRowSelection = (initialSelection: Record<string, boolean> = {}) => {
|
||||
const [selection, setSelection] = useState(initialSelection);
|
||||
return [selection, setSelection] as const;
|
||||
};
|
||||
|
||||
export const useColumnStyles = <TData, TValue>(
|
||||
columns: TableColumn<TData, TValue>[],
|
||||
isSmallScreen: boolean,
|
||||
containerRef: React.RefObject<HTMLDivElement>,
|
||||
) => {
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const updateWidth = () => {
|
||||
setContainerWidth(container.clientWidth);
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateWidth);
|
||||
resizeObserver.observe(container);
|
||||
updateWidth();
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [containerRef]);
|
||||
|
||||
return useMemo(() => {
|
||||
if (containerWidth === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {};
|
||||
let totalFixedWidth = 0;
|
||||
const flexibleColumns: (TableColumn<TData, TValue> & { priority: number })[] = [];
|
||||
|
||||
columns.forEach((column) => {
|
||||
const key = String(column.id ?? column.accessorKey ?? '');
|
||||
const size = isSmallScreen ? column.meta?.mobileSize : column.meta?.size;
|
||||
|
||||
if (size) {
|
||||
const width = parseInt(String(size), 10);
|
||||
totalFixedWidth += width;
|
||||
styles[key] = {
|
||||
width: size,
|
||||
minWidth: column.meta?.minWidth || size,
|
||||
};
|
||||
} else {
|
||||
flexibleColumns.push({ ...column, priority: column.meta?.priority ?? 1 });
|
||||
}
|
||||
});
|
||||
|
||||
const availableWidth = containerWidth - totalFixedWidth;
|
||||
const totalPriority = flexibleColumns.reduce((sum, col) => sum + col.priority, 0);
|
||||
|
||||
if (availableWidth > 0 && totalPriority > 0) {
|
||||
flexibleColumns.forEach((column) => {
|
||||
const key = String(column.id ?? column.accessorKey ?? '');
|
||||
const proportion = column.priority / totalPriority;
|
||||
const width = Math.max(Math.floor(availableWidth * proportion), 80); // min width of 80px
|
||||
styles[key] = {
|
||||
width: `${width}px`,
|
||||
minWidth: column.meta?.minWidth ?? `${isSmallScreen ? 60 : 80}px`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return styles;
|
||||
}, [columns, containerWidth, isSmallScreen]);
|
||||
};
|
||||
|
||||
export const useDynamicColumnWidths = useColumnStyles;
|
||||
|
||||
export const useKeyboardNavigation = (
|
||||
tableRef: React.RefObject<HTMLDivElement>,
|
||||
rowCount: number,
|
||||
onRowSelect?: (index: number) => void,
|
||||
) => {
|
||||
const [focusedRowIndex, setFocusedRowIndex] = useState<number>(-1);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!tableRef.current?.contains(event.target as Node)) return;
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
setFocusedRowIndex((prev) => Math.min(prev + 1, rowCount - 1));
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
setFocusedRowIndex((prev) => Math.max(prev - 1, 0));
|
||||
break;
|
||||
case 'Home':
|
||||
event.preventDefault();
|
||||
setFocusedRowIndex(0);
|
||||
break;
|
||||
case 'End':
|
||||
event.preventDefault();
|
||||
setFocusedRowIndex(rowCount - 1);
|
||||
break;
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
if (focusedRowIndex >= 0 && onRowSelect) {
|
||||
event.preventDefault();
|
||||
onRowSelect(focusedRowIndex);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
setFocusedRowIndex(-1);
|
||||
(event.target as HTMLElement).blur();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [tableRef, rowCount, focusedRowIndex, onRowSelect]);
|
||||
|
||||
return { focusedRowIndex, setFocusedRowIndex };
|
||||
};
|
||||
439
packages/client/src/components/DataTable/DataTable.tsx
Normal file
439
packages/client/src/components/DataTable/DataTable.tsx
Normal file
|
|
@ -0,0 +1,439 @@
|
|||
import React, { useRef, useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { ArrowUp, ArrowDown, ArrowDownUp } from 'lucide-react';
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
flexRender,
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
type ColumnDef,
|
||||
type CellContext,
|
||||
type Row,
|
||||
} from '@tanstack/react-table';
|
||||
import type { DataTableProps } from './DataTable.types';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
TableRow,
|
||||
Button,
|
||||
Label,
|
||||
} from '~/components';
|
||||
import { SelectionCheckbox, MemoizedTableRow, SkeletonRows } from './DataTableComponents';
|
||||
import { useDebounced, useOptimizedRowSelection } from './DataTable.hooks';
|
||||
import { DataTableErrorBoundary } from './DataTableErrorBoundary';
|
||||
import { DataTableSearch } from './DataTableSearch';
|
||||
import { useMediaQuery, useLocalize } from '~/hooks';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { cn, logger } from '~/utils';
|
||||
import { Spinner } from '~/svgs';
|
||||
|
||||
function DataTable<TData extends Record<string, unknown>, TValue>({
|
||||
columns,
|
||||
data,
|
||||
className = '',
|
||||
isLoading = false,
|
||||
isFetching = false,
|
||||
config,
|
||||
filterValue = '',
|
||||
onFilterChange,
|
||||
defaultSort = [],
|
||||
isFetchingNextPage = false,
|
||||
hasNextPage = false,
|
||||
fetchNextPage,
|
||||
onReset,
|
||||
sorting,
|
||||
onSortingChange,
|
||||
customActionsRenderer,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
const isDesktop = useMediaQuery('(min-width: 1024px)');
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
const scrollTimeoutRef = useRef<number | null>(null);
|
||||
const scrollRAFRef = useRef<number | null>(null);
|
||||
|
||||
const {
|
||||
selection: { enableRowSelection = true, showCheckboxes = true } = {},
|
||||
search: { enableSearch = true, debounce: debounceDelay = 300 } = {},
|
||||
skeleton: { count: skeletonCount = 10 } = {},
|
||||
virtualization: { overscan = 5 } = {},
|
||||
} = config || {};
|
||||
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
const [optimizedRowSelection, setOptimizedRowSelection] = useOptimizedRowSelection();
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState(filterValue);
|
||||
const [internalSorting, setInternalSorting] = useState<SortingState>(defaultSort);
|
||||
const [isScrollingFetching, setIsScrollingFetching] = useState(false);
|
||||
|
||||
const debouncedTerm = useDebounced(searchTerm, debounceDelay);
|
||||
const finalSorting = sorting ?? internalSorting;
|
||||
|
||||
// Memoize column visibility calculations
|
||||
const calculatedVisibility = useMemo(() => {
|
||||
const newVisibility: VisibilityState = {};
|
||||
if (isSmallScreen) {
|
||||
columns.forEach((col: ColumnDef<TData, TValue> & { meta?: { hideOnMobile?: boolean } }) => {
|
||||
if (col.id && col.meta?.hideOnMobile) {
|
||||
newVisibility[col.id] = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
return newVisibility;
|
||||
}, [isSmallScreen, columns]);
|
||||
|
||||
useEffect(() => {
|
||||
setColumnVisibility(calculatedVisibility);
|
||||
}, [calculatedVisibility]);
|
||||
|
||||
const processedData = useMemo(
|
||||
() =>
|
||||
data.map((item, index) => {
|
||||
if (item.id === null || item.id === undefined) {
|
||||
logger.warn(
|
||||
'DataTable Warning: A data row is missing a unique "id" property. Using index as a fallback. This can lead to unexpected behavior with selection and sorting.',
|
||||
item,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
_index: index,
|
||||
_id: String(item.id ?? `row-${index}`),
|
||||
};
|
||||
}),
|
||||
[data],
|
||||
);
|
||||
|
||||
// Enhanced columns with desktop-only cell rendering
|
||||
const enhancedColumns = useMemo(() => {
|
||||
return columns.map((col) => {
|
||||
const originalCol = col as ColumnDef<TData, TValue> & {
|
||||
meta?: {
|
||||
hideOnMobile?: boolean;
|
||||
desktopOnly?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
};
|
||||
|
||||
if (originalCol.meta?.desktopOnly && originalCol.cell) {
|
||||
const originalCell = originalCol.cell;
|
||||
return {
|
||||
...originalCol,
|
||||
cell: (props: CellContext<TData, TValue>) => {
|
||||
if (!isDesktop) {
|
||||
return null;
|
||||
}
|
||||
return typeof originalCell === 'function' ? originalCell(props) : originalCell;
|
||||
},
|
||||
};
|
||||
}
|
||||
return originalCol;
|
||||
});
|
||||
}, [columns, isDesktop]);
|
||||
|
||||
const tableColumns = useMemo(() => {
|
||||
if (!enableRowSelection || !showCheckboxes) {
|
||||
return enhancedColumns as ColumnDef<TData & { _id: string }, TValue>[];
|
||||
}
|
||||
|
||||
const selectColumn: ColumnDef<TData & { _id: string }, boolean> = {
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<SelectionCheckbox
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
onChange={(value) => table.toggleAllRowsSelected(value)}
|
||||
ariaLabel={localize('com_ui_select_all' as string)}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<SelectionCheckbox
|
||||
checked={row.getIsSelected()}
|
||||
onChange={(value) => row.toggleSelected(value)}
|
||||
ariaLabel={`Select row ${row.index + 1}`}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
className: 'w-12',
|
||||
},
|
||||
};
|
||||
|
||||
return [
|
||||
selectColumn,
|
||||
...(enhancedColumns as ColumnDef<TData & { _id: string }, TValue>[]),
|
||||
] as ColumnDef<TData & { _id: string }, TValue>[];
|
||||
}, [enhancedColumns, enableRowSelection, showCheckboxes, localize]);
|
||||
|
||||
const table = useReactTable<TData & { _id: string }>({
|
||||
data: processedData,
|
||||
columns: tableColumns,
|
||||
getRowId: (row) => row._id,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
enableRowSelection,
|
||||
enableMultiRowSelection: true,
|
||||
manualSorting: true,
|
||||
manualFiltering: true,
|
||||
state: {
|
||||
sorting: finalSorting,
|
||||
columnVisibility,
|
||||
rowSelection: optimizedRowSelection,
|
||||
},
|
||||
onSortingChange: onSortingChange ?? setInternalSorting,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onRowSelectionChange: setOptimizedRowSelection,
|
||||
});
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: processedData.length,
|
||||
getScrollElement: () => tableContainerRef.current,
|
||||
estimateSize: useCallback(() => 50, []),
|
||||
overscan,
|
||||
measureElement:
|
||||
typeof window !== 'undefined' && navigator.userAgent.indexOf('Firefox') === -1
|
||||
? (element) => element?.getBoundingClientRect().height ?? 50
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||
const totalSize = rowVirtualizer.getTotalSize();
|
||||
const paddingTop = virtualRows.length > 0 ? (virtualRows[0]?.start ?? 0) : 0;
|
||||
const paddingBottom =
|
||||
virtualRows.length > 0 ? totalSize - (virtualRows[virtualRows.length - 1]?.end ?? 0) : 0;
|
||||
|
||||
const { rows } = table.getRowModel();
|
||||
const headerGroups = table.getHeaderGroups();
|
||||
const selectedCount = Object.keys(optimizedRowSelection).length;
|
||||
|
||||
const showSkeletons = isLoading || (isFetching && !isFetchingNextPage);
|
||||
const shouldShowSearch = enableSearch && onFilterChange;
|
||||
|
||||
useEffect(() => {
|
||||
setSearchTerm(filterValue);
|
||||
}, [filterValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedTerm !== filterValue && onFilterChange) {
|
||||
onFilterChange(debouncedTerm);
|
||||
setOptimizedRowSelection({});
|
||||
}
|
||||
}, [debouncedTerm, filterValue, onFilterChange, setOptimizedRowSelection]);
|
||||
|
||||
// Optimized scroll handler with RAF
|
||||
const handleScroll = useCallback(() => {
|
||||
if (scrollRAFRef.current !== null) {
|
||||
cancelAnimationFrame(scrollRAFRef.current);
|
||||
}
|
||||
|
||||
scrollRAFRef.current = requestAnimationFrame(() => {
|
||||
if (scrollTimeoutRef.current !== null) {
|
||||
clearTimeout(scrollTimeoutRef.current);
|
||||
}
|
||||
|
||||
scrollTimeoutRef.current = window.setTimeout(() => {
|
||||
if (
|
||||
!fetchNextPage ||
|
||||
!hasNextPage ||
|
||||
isFetchingNextPage ||
|
||||
isScrollingFetching ||
|
||||
!tableContainerRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = tableContainerRef.current;
|
||||
const scrollBottom = scrollTop + clientHeight;
|
||||
const threshold = scrollHeight - 200;
|
||||
|
||||
if (scrollBottom >= threshold) {
|
||||
setIsScrollingFetching(true);
|
||||
fetchNextPage().finally(() => {
|
||||
setIsScrollingFetching(false);
|
||||
});
|
||||
}
|
||||
|
||||
scrollTimeoutRef.current = null;
|
||||
}, 150); // Slightly increased debounce for better performance
|
||||
|
||||
scrollRAFRef.current = null;
|
||||
});
|
||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage, isScrollingFetching]);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollElement = tableContainerRef.current;
|
||||
if (!scrollElement) return;
|
||||
|
||||
scrollElement.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => {
|
||||
scrollElement.removeEventListener('scroll', handleScroll);
|
||||
if (scrollTimeoutRef.current) {
|
||||
clearTimeout(scrollTimeoutRef.current);
|
||||
}
|
||||
if (scrollRAFRef.current) {
|
||||
cancelAnimationFrame(scrollRAFRef.current);
|
||||
}
|
||||
};
|
||||
}, [handleScroll]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setError(null);
|
||||
setOptimizedRowSelection({});
|
||||
setSearchTerm('');
|
||||
onReset?.();
|
||||
}, [onReset, setOptimizedRowSelection]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<DataTableErrorBoundary onReset={handleReset}>
|
||||
<div className="flex flex-col items-center justify-center p-8">
|
||||
<p className="mb-4 text-red-500">{error.message}</p>
|
||||
<Button onClick={handleReset}>{localize('com_ui_retry' as string)}</Button>
|
||||
</div>
|
||||
</DataTableErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex w-full flex-col overflow-hidden rounded-lg border border-border-light bg-background',
|
||||
'h-[calc(100vh-8rem)] max-h-[80vh]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full shrink-0 items-center gap-3 border-b border-border-light">
|
||||
{shouldShowSearch && <DataTableSearch value={searchTerm} onChange={setSearchTerm} />}
|
||||
{customActionsRenderer &&
|
||||
customActionsRenderer({
|
||||
selectedCount,
|
||||
selectedRows: table.getSelectedRowModel().rows.map((r) => r.original),
|
||||
table,
|
||||
showToast,
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={tableContainerRef}
|
||||
className="overflow-anchor-none relative min-h-0 flex-1 overflow-auto will-change-scroll"
|
||||
style={
|
||||
{
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
overscrollBehavior: 'contain',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<Table className="w-full">
|
||||
<TableHeader className="sticky top-0 z-10 bg-surface-secondary">
|
||||
{headerGroups.map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const isSelectHeader = header.id === 'select';
|
||||
const meta = header.column.columnDef.meta as { className?: string } | undefined;
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={cn(
|
||||
'border-b border-border-light py-2',
|
||||
isSelectHeader ? 'px-0 text-center' : 'px-3',
|
||||
header.column.getCanSort() && 'cursor-pointer hover:bg-surface-tertiary',
|
||||
meta?.className,
|
||||
)}
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
>
|
||||
{isSelectHeader ? (
|
||||
flexRender(header.column.columnDef.header, header.getContext())
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
{header.column.getCanSort() && (
|
||||
<span className="text-text-primary">
|
||||
{{
|
||||
asc: <ArrowUp className="size-4 text-text-primary" />,
|
||||
desc: <ArrowDown className="size-4 text-text-primary" />,
|
||||
}[header.column.getIsSorted() as string] ?? (
|
||||
<ArrowDownUp className="size-4 text-text-primary" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{showSkeletons ? (
|
||||
<SkeletonRows
|
||||
count={skeletonCount}
|
||||
columns={tableColumns}
|
||||
containerRef={tableContainerRef}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{paddingTop > 0 && (
|
||||
<TableRow aria-hidden="true">
|
||||
<TableCell
|
||||
colSpan={tableColumns.length}
|
||||
style={{ height: paddingTop, padding: 0, border: 0 }}
|
||||
/>
|
||||
</TableRow>
|
||||
)}
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const row = rows[virtualRow.index];
|
||||
if (!row) return null;
|
||||
return (
|
||||
<MemoizedTableRow
|
||||
key={virtualRow.key}
|
||||
row={row as Row<TData & { _id: string }>}
|
||||
columns={tableColumns}
|
||||
index={virtualRow.index}
|
||||
virtualIndex={virtualRow.index}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{paddingBottom > 0 && (
|
||||
<TableRow aria-hidden="true">
|
||||
<TableCell
|
||||
colSpan={tableColumns.length}
|
||||
style={{ height: paddingBottom, padding: 0, border: 0 }}
|
||||
/>
|
||||
</TableRow>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isFetchingNextPage && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={tableColumns.length} className="p-4 text-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Spinner className="h-5 w-5" />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{!isLoading && !showSkeletons && rows.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<Label className="text-center text-text-secondary">
|
||||
{searchTerm ? 'No search results' : localize('com_ui_no_data')}
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DataTable;
|
||||
68
packages/client/src/components/DataTable/DataTable.types.ts
Normal file
68
packages/client/src/components/DataTable/DataTable.types.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import type { ColumnDef, SortingState, Table } from '@tanstack/react-table';
|
||||
import type React from 'react';
|
||||
|
||||
export type TableColumn<TData, TValue> = ColumnDef<TData, TValue> & {
|
||||
accessorKey?: string | number;
|
||||
meta?: {
|
||||
size?: string | number;
|
||||
mobileSize?: string | number;
|
||||
minWidth?: string | number;
|
||||
priority?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export interface DataTableConfig {
|
||||
selection?: {
|
||||
enableRowSelection?: boolean;
|
||||
showCheckboxes?: boolean;
|
||||
};
|
||||
search?: {
|
||||
enableSearch?: boolean;
|
||||
debounce?: number;
|
||||
filterColumn?: string;
|
||||
};
|
||||
skeleton?: {
|
||||
count?: number;
|
||||
};
|
||||
virtualization?: {
|
||||
overscan?: number;
|
||||
};
|
||||
pinning?: {
|
||||
enableColumnPinning?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DataTableProps<TData extends Record<string, unknown>, TValue> {
|
||||
columns: TableColumn<TData, TValue>[];
|
||||
data: TData[];
|
||||
className?: string;
|
||||
isLoading?: boolean;
|
||||
isFetching?: boolean;
|
||||
config?: DataTableConfig;
|
||||
onDelete?: (selectedRows: TData[]) => Promise<void>;
|
||||
filterValue?: string;
|
||||
onFilterChange?: (value: string) => void;
|
||||
defaultSort?: SortingState;
|
||||
isFetchingNextPage?: boolean;
|
||||
hasNextPage?: boolean;
|
||||
fetchNextPage?: () => Promise<unknown>;
|
||||
onError?: (error: Error) => void;
|
||||
onReset?: () => void;
|
||||
sorting?: SortingState;
|
||||
onSortingChange?: (updater: SortingState | ((old: SortingState) => SortingState)) => void;
|
||||
conversationIndex?: number;
|
||||
customActionsRenderer?: (params: {
|
||||
selectedCount: number;
|
||||
selectedRows: TData[];
|
||||
table: Table<TData & { _id: string }>;
|
||||
showToast: (message: string) => void;
|
||||
}) => React.ReactNode;
|
||||
}
|
||||
|
||||
export interface DataTableSearchProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
105
packages/client/src/components/DataTable/DataTableComponents.tsx
Normal file
105
packages/client/src/components/DataTable/DataTableComponents.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { memo } from 'react';
|
||||
import { flexRender } from '@tanstack/react-table';
|
||||
import type { Row, ColumnDef } from '@tanstack/react-table';
|
||||
import type { TableColumn } from './DataTable.types';
|
||||
import { Checkbox, TableCell, TableRow, Skeleton } from '~/components';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export const SelectionCheckbox = memo(
|
||||
({
|
||||
checked,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (value: boolean) => void;
|
||||
ariaLabel: string;
|
||||
}) => (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onChange(!checked);
|
||||
}
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="flex h-full w-[30px] items-center justify-center"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onChange(!checked);
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={checked} onCheckedChange={onChange} aria-label={ariaLabel} />
|
||||
</div>
|
||||
),
|
||||
);
|
||||
|
||||
SelectionCheckbox.displayName = 'SelectionCheckbox';
|
||||
|
||||
const TableRowComponent = <TData extends Record<string, unknown>>({
|
||||
row,
|
||||
virtualIndex,
|
||||
}: {
|
||||
row: Row<TData>;
|
||||
columns: ColumnDef<TData, unknown>[];
|
||||
index: number;
|
||||
virtualIndex?: number;
|
||||
}) => (
|
||||
<TableRow
|
||||
data-state={row.getIsSelected() ? 'selected' : undefined}
|
||||
data-index={virtualIndex}
|
||||
className="border-none hover:bg-surface-secondary"
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const meta = cell.column.columnDef.meta as { className?: string } | undefined;
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className={cn('truncate p-3', cell.column.id === 'select' && 'p-1', meta?.className)}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
|
||||
export const MemoizedTableRow = memo(
|
||||
TableRowComponent,
|
||||
(prev, next) =>
|
||||
prev.row.original === next.row.original &&
|
||||
prev.row.getIsSelected() === next.row.getIsSelected() &&
|
||||
prev.columns === next.columns,
|
||||
);
|
||||
|
||||
export const SkeletonRows = memo(
|
||||
<TData extends Record<string, unknown>, TValue>({
|
||||
count = 10,
|
||||
columns,
|
||||
}: {
|
||||
count?: number;
|
||||
columns: TableColumn<TData, TValue>[];
|
||||
}) => (
|
||||
<>
|
||||
{Array.from({ length: count }, (_, index) => (
|
||||
<TableRow key={`skeleton-${index}`} className="h-[56px] border-b border-border-light">
|
||||
{columns.map((column) => {
|
||||
const columnKey = String(
|
||||
column.id ?? ('accessorKey' in column && column.accessorKey) ?? '',
|
||||
);
|
||||
const meta = column.meta as { className?: string } | undefined;
|
||||
return (
|
||||
<TableCell key={columnKey} className={cn('px-2 py-2 md:px-3', meta?.className)}>
|
||||
<Skeleton className="h-6 w-full" />
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
),
|
||||
);
|
||||
|
||||
SkeletonRows.displayName = 'SkeletonRows';
|
||||
37
packages/client/src/components/DataTable/DataTableSearch.tsx
Normal file
37
packages/client/src/components/DataTable/DataTableSearch.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { memo } from 'react';
|
||||
import { startTransition } from 'react';
|
||||
import type { DataTableSearchProps } from './DataTable.types';
|
||||
import { Input } from '~/components';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export const DataTableSearch = memo(
|
||||
({ value, onChange, placeholder, className, disabled = false }: DataTableSearchProps) => {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<div className="relative flex-1">
|
||||
<label htmlFor="table-search" className="sr-only">
|
||||
{localize('com_ui_search_table')}
|
||||
</label>
|
||||
<Input
|
||||
id="table-search"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
startTransition(() => onChange(e.target.value));
|
||||
}}
|
||||
disabled={disabled}
|
||||
aria-label={localize('com_ui_search_table')}
|
||||
aria-describedby="search-description"
|
||||
placeholder={placeholder || localize('com_ui_search')}
|
||||
className={cn('h-12 rounded-b-none border-0 bg-surface-secondary', className)}
|
||||
/>
|
||||
<span id="search-description" className="sr-only">
|
||||
{localize('com_ui_search_table_description')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
DataTableSearch.displayName = 'DataTableSearch';
|
||||
|
|
@ -31,14 +31,12 @@ export * from './InputOTP';
|
|||
export * from './MultiSearch';
|
||||
export * from './Resizable';
|
||||
export * from './Select';
|
||||
export { default as DataTableErrorBoundary } from './DataTable/DataTableErrorBoundary';
|
||||
export { default as Radio } from './Radio';
|
||||
export { default as Badge } from './Badge';
|
||||
export { default as Avatar } from './Avatar';
|
||||
export { default as Combobox } from './Combobox';
|
||||
export { default as Dropdown } from './Dropdown';
|
||||
export { default as SplitText } from './SplitText';
|
||||
export { default as DataTable } from './DataTable';
|
||||
export { default as FormInput } from './FormInput';
|
||||
export { default as PixelCard } from './PixelCard';
|
||||
export { default as FileUpload } from './FileUpload';
|
||||
|
|
@ -47,6 +45,7 @@ export { default as DropdownPopup } from './DropdownPopup';
|
|||
export { default as DelayedRender } from './DelayedRender';
|
||||
export { default as ThemeSelector } from './ThemeSelector';
|
||||
export { default as InfoHoverCard } from './InfoHoverCard';
|
||||
export { default as DataTable } from './DataTable/DataTable';
|
||||
export { default as CheckboxButton } from './CheckboxButton';
|
||||
export { default as DialogTemplate } from './DialogTemplate';
|
||||
export { default as SelectDropDown } from './SelectDropDown';
|
||||
|
|
|
|||
|
|
@ -2,5 +2,18 @@
|
|||
"com_ui_cancel": "Cancel",
|
||||
"com_ui_no_options": "No options available",
|
||||
"com_ui_no_results_found": "No results found",
|
||||
"com_ui_no_data_available": "No data available"
|
||||
"com_ui_no_data_available": "No data available",
|
||||
"com_ui_select_all": "Select All",
|
||||
"com_ui_select_row": "Select Row",
|
||||
"com_ui_no_selection": "No selection",
|
||||
"com_ui_confirm_bulk_delete": "Are you sure you want to delete the selected items? This action cannot be undone.",
|
||||
"com_ui_delete_success": "Items deleted successfully",
|
||||
"com_ui_retry": "Retry",
|
||||
"com_ui_selected_count": "{count} selected",
|
||||
"com_ui_data_table": "Data Table",
|
||||
"com_ui_no_data": "No data",
|
||||
"com_ui_delete_selected": "Delete Selected",
|
||||
"com_ui_search_table": "Search table",
|
||||
"com_ui_search_table_description": "Type to filter results",
|
||||
"com_ui_search": "Search"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
export * from './utils';
|
||||
export * from './theme';
|
||||
export { default as logger } from './logger';
|
||||
|
|
|
|||
49
packages/client/src/utils/logger.ts
Normal file
49
packages/client/src/utils/logger.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
const isDevelopment = process.env.NODE_ENV === 'development';
|
||||
const isLoggerEnabled = process.env.VITE_ENABLE_LOGGER === 'true';
|
||||
const loggerFilter = process.env.VITE_LOGGER_FILTER || '';
|
||||
|
||||
type LogFunction = (...args: unknown[]) => void;
|
||||
|
||||
const createLogFunction = (
|
||||
consoleMethod: LogFunction,
|
||||
type?: 'log' | 'warn' | 'error' | 'info' | 'debug' | 'dir',
|
||||
): LogFunction => {
|
||||
return (...args: unknown[]) => {
|
||||
if (isDevelopment || isLoggerEnabled) {
|
||||
const tag = typeof args[0] === 'string' ? args[0] : '';
|
||||
if (shouldLog(tag)) {
|
||||
if (tag && typeof args[1] === 'string' && type === 'error') {
|
||||
consoleMethod(`[${tag}] ${args[1]}`, ...args.slice(2));
|
||||
} else if (tag && args.length > 1) {
|
||||
consoleMethod(`[${tag}]`, ...args.slice(1));
|
||||
} else {
|
||||
consoleMethod(...args);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const logger = {
|
||||
log: createLogFunction(console.log, 'log'),
|
||||
dir: createLogFunction(console.dir, 'dir'),
|
||||
warn: createLogFunction(console.warn, 'warn'),
|
||||
info: createLogFunction(console.info, 'info'),
|
||||
error: createLogFunction(console.error, 'error'),
|
||||
debug: createLogFunction(console.debug, 'debug'),
|
||||
};
|
||||
|
||||
function shouldLog(tag: string): boolean {
|
||||
if (!loggerFilter) {
|
||||
return true;
|
||||
}
|
||||
/* If no tag is provided, always log */
|
||||
if (!tag) {
|
||||
return true;
|
||||
}
|
||||
return loggerFilter
|
||||
.split(',')
|
||||
.some((filter) => tag.toLowerCase().includes(filter.trim().toLowerCase()));
|
||||
}
|
||||
|
||||
export default logger;
|
||||
Loading…
Add table
Add a link
Reference in a new issue