mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +01:00
* 🎨 feat: Enhance Import Conversations UI with loading state and new localization key * fix: Correct pluralization in selected items message in translation.json * Refactor Chat Input File Table Headers to Use SortFilterHeader Component - Replaced button-based sorting headers in the Chat Input Files Table with a new SortFilterHeader component for better code organization and consistency. - Updated the header for filename, updatedAt, and bytes columns to utilize the new component. Enhance Navigation Component with Skeleton Loading States - Added Skeleton loading states to the Nav component for better user experience during data fetching. - Updated Suspense fallbacks for AgentMarketplaceButton and BookmarkNav components to display Skeletons. Refactor Avatar Component for Improved UI - Enhanced the Avatar component by adding a Label for drag-and-drop functionality. - Improved styling and structure for the file upload area. Update Shared Links Component for Better Error Handling and Sorting - Improved error handling in the Shared Links component for fetching next pages and deleting shared links. - Simplified the header rendering for sorting columns and added sorting functionality to the title and createdAt columns. Refactor Archived Chats Component - Merged ArchivedChats and ArchivedChatsTable components into a single ArchivedChats component for better maintainability. - Implemented sorting and searching functionality with debouncing for improved performance. - Enhanced the UI with better loading states and error handling. Update DataTable Component for Sorting Icons - Added sorting icons (ChevronUp, ChevronDown, ChevronsUpDown) to the DataTable headers for better visual feedback on sorting state. Localization Updates - Updated translation.json to fix missing translations and improve existing ones for better user experience. * ✨ feat: Update DataTable component to streamline props and enhance sorting icons * fix: TS issues * feat: polish and redefine DataTable + shared links and archived chats * feat: enhance DataTable with column pinning and improve sorting functionality * feat: enhance deepEqual function for array support and improve column style stability * refactor: DataTable and ArchivedChats; fix: sorting ArchivedChats API * 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. * refactor: update SharedLinks and ArchivedChats to use desktopOnly instead of hideOnMobile; remove unused DataTableColumnHeader component * fix: ensure desktopOnly columns are hidden on mobile in DataTable * refactor: reorganize imports in DataTable components and update index exports * refactor: improve styling and animations in Artifacts, ArtifactsSubMenu, and MCPSubMenu components; update border-radius in style.css * refactor(Artifacts): enhance button toggle functionality and manage expanded state with useEffect * refactor: comment out desktopOnly property in SharedLinks and ArchivedChats components; update translation.json with new keys for link actions * refactor(DataTable): streamline column visibility logic and enhance type definitions; improve cleanup timers and optimize rendering * refactor(DataTable): enhance type definitions for processed data rows and update custom actions renderer type * refactor(DataTable): optimize processed data handling and improve warning for missing IDs; streamline DataTableComponents imports * refactor(DataTable): enhance accessibility features and improve localization for selection and loading states * refactor: improve padding in dialog content and enhance row selection functionality in ArchivedChats and DataTable components * refactor(DataTable): remove unnecessary role and tabindex attributes from select all button for improved accessibility * refactor(translation): remove outdated error messages and unused UI strings for cleaner localization * refactor(DataTable): enhance virtualization and scrolling performance with dynamic overscan adjustments * refactor(DataTableErrorBoundary): enhance error handling and localization support * refactor(DataTable): improve column sizing and visibility handling; remove deprecated features * refactor: enhance UI components with improved class handling and state management * refactor(DataTable): improve column width handling and responsiveness; disable row selection * refactor(DataTable): enhance accessibility with row header support and improve column visibility handling * chore(DataTable): comments update * refactor(Table): add unwrapped prop for direct table rendering; adjust minWidth calculation for responsiveness * refactor(DataTable): simplify search handling by removing unnecessary trimming; adjust column width handling for better responsiveness * refactor(translation): remove redundant drag and drop UI text for clarity * refactor(parsers): change uiResources to a constant and streamline artifacts handling * chore: remove unused file, bump @librechat/client to 0.3.2; fix(SharedLinks): missing import; * refactor: change button variant from destructive to ghost for delete actions in SharedLinks and ArchivedChats components * refactor(DataTable): simplify aria-sort assignment for better readability * refactor(DataTable): update aria-label and ariaLabel to use indexed placeholder for localization * refactor(translation): update no data messages for consistency * Refactor code structure for improved readability and maintainability * chore: restore linting fixes * chore: restore linting fixes 2; refactor: remove unused translation keys * feat(tests): add unit tests for DataTable components and error handling - Implement tests for SelectionCheckbox and SkeletonRows components in DataTable. - Add tests for DataTableErrorBoundary to ensure proper error handling and UI rendering. - Create tests for DataTableSearch to validate search functionality and accessibility. - Update DialogTemplate tests to reflect hardcoded cancel text. - Remove redundant IntersectionObserver mock in SplitText tests. - Unmock react-i18next in Translation tests to validate actual i18n functionality. * refactor: Remove jest-environment-jsdom dependency from package.json; fix: reset package-lock * chore: revert lint fixes * chore: clean up package.json by removing unused devDependencies and redundant test scripts * chore: update package dependencies in package.json and package-lock.json - Added new devDependencies: @babel/core, @babel/preset-env, @babel/preset-react, @babel/preset-typescript, @tanstack/react-table, @tanstack/react-virtual, @testing-library/jest-dom, identity-obj-proxy, jest, jest-environment-jsdom, and lucide-react. - Updated existing devDependencies to their latest versions. - Added new module @asamuzakjp/css-color to package-lock.json with its dependencies. - Updated version of @babel/plugin-transform-destructuring and added @babel/plugin-transform-explicit-resource-management in package-lock.json. --------- Co-authored-by: Danny Avila <danny@librechat.ai>
408 lines
13 KiB
TypeScript
408 lines
13 KiB
TypeScript
import { useState, useCallback, useMemo } from 'react';
|
|
import { QueryKeys } from 'librechat-data-provider';
|
|
import { TrashIcon, ArchiveRestore } from 'lucide-react';
|
|
import { useQueryClient, InfiniteData } from '@tanstack/react-query';
|
|
import {
|
|
Button,
|
|
OGDialog,
|
|
OGDialogTrigger,
|
|
OGDialogTemplate,
|
|
OGDialogContent,
|
|
OGDialogHeader,
|
|
OGDialogTitle,
|
|
Label,
|
|
TooltipAnchor,
|
|
Spinner,
|
|
useToastContext,
|
|
useMediaQuery,
|
|
DataTable,
|
|
type TableColumn,
|
|
} from '@librechat/client';
|
|
import type { ConversationListParams, TConversation } from 'librechat-data-provider';
|
|
import type { SortingState } from '@tanstack/react-table';
|
|
import {
|
|
useArchiveConvoMutation,
|
|
useConversationsInfiniteQuery,
|
|
useDeleteConversationMutation,
|
|
} from '~/data-provider';
|
|
import { MinimalIcon } from '~/components/Endpoints';
|
|
import { NotificationSeverity } from '~/common';
|
|
import { formatDate, cn } from '~/utils';
|
|
import { useLocalize } from '~/hooks';
|
|
|
|
const DEFAULT_PARAMS = {
|
|
isArchived: true,
|
|
sortBy: 'createdAt',
|
|
sortDirection: 'desc',
|
|
search: '',
|
|
} as const satisfies ConversationListParams;
|
|
|
|
type SortKey = 'createdAt' | 'title';
|
|
const isSortKey = (v: string): v is SortKey => v === 'createdAt' || v === 'title';
|
|
|
|
const defaultSort: SortingState = [
|
|
{
|
|
id: 'createdAt',
|
|
desc: true,
|
|
},
|
|
];
|
|
|
|
/**
|
|
* Helper: remove a conversation from all infinite queries whose key starts with the provided root
|
|
*/
|
|
function removeConversationFromInfinite(
|
|
queryClient: ReturnType<typeof useQueryClient>,
|
|
rootKey: string,
|
|
conversationId: string,
|
|
) {
|
|
const queries = queryClient.getQueryCache().findAll([rootKey], { exact: false });
|
|
for (const query of queries) {
|
|
queryClient.setQueryData<
|
|
InfiniteData<{ conversations: TConversation[]; nextCursor?: string | null }>
|
|
>(query.queryKey, (old) => {
|
|
if (!old) return old;
|
|
return {
|
|
...old,
|
|
pages: old.pages.map((page) => ({
|
|
...page,
|
|
conversations: page.conversations.filter((c) => c.conversationId !== conversationId),
|
|
})),
|
|
};
|
|
});
|
|
}
|
|
}
|
|
|
|
export default function ArchivedChatsTable() {
|
|
const localize = useLocalize();
|
|
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
|
const { showToast } = useToastContext();
|
|
const queryClient = useQueryClient();
|
|
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
|
const [deleteRow, setDeleteRow] = useState<TConversation | null>(null);
|
|
const [unarchivingId, setUnarchivingId] = useState<string | null>(null);
|
|
|
|
const [queryParams, setQueryParams] = useState<ConversationListParams>(DEFAULT_PARAMS);
|
|
const [sorting, setSorting] = useState<SortingState>(defaultSort);
|
|
const [searchValue, setSearchValue] = useState('');
|
|
|
|
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } =
|
|
useConversationsInfiniteQuery(queryParams, {
|
|
enabled: isOpen,
|
|
keepPreviousData: false,
|
|
staleTime: 30 * 1000,
|
|
refetchOnWindowFocus: false,
|
|
refetchOnMount: false,
|
|
});
|
|
|
|
const handleSearchChange = useCallback((value: string) => {
|
|
setSearchValue(value);
|
|
setQueryParams((prev) => ({
|
|
...prev,
|
|
search: value,
|
|
}));
|
|
}, []);
|
|
|
|
const handleSortingChange = useCallback(
|
|
(updater: SortingState | ((old: SortingState) => SortingState)) => {
|
|
setSorting((prev) => {
|
|
const next = typeof updater === 'function' ? updater(prev) : updater;
|
|
const primary = next[0];
|
|
setQueryParams((p) => {
|
|
let sortBy: SortKey = 'createdAt';
|
|
let sortDirection: 'asc' | 'desc' = 'desc';
|
|
if (primary && isSortKey(primary.id)) {
|
|
sortBy = primary.id;
|
|
sortDirection = primary.desc ? 'desc' : 'asc';
|
|
}
|
|
return {
|
|
...p,
|
|
sortBy,
|
|
sortDirection,
|
|
};
|
|
});
|
|
return next;
|
|
});
|
|
},
|
|
[],
|
|
);
|
|
|
|
const flattenedConversations = useMemo(
|
|
() => data?.pages?.flatMap((page) => page?.conversations?.filter(Boolean) ?? []) ?? [],
|
|
[data?.pages],
|
|
);
|
|
|
|
const unarchiveMutation = useArchiveConvoMutation({
|
|
onSuccess: (_res, variables) => {
|
|
const { conversationId } = variables;
|
|
if (conversationId) {
|
|
removeConversationFromInfinite(
|
|
queryClient,
|
|
QueryKeys.archivedConversations,
|
|
conversationId,
|
|
);
|
|
}
|
|
queryClient.invalidateQueries([QueryKeys.allConversations]);
|
|
setUnarchivingId(null);
|
|
},
|
|
onError: () => {
|
|
showToast({
|
|
message: localize('com_ui_unarchive_error'),
|
|
severity: NotificationSeverity.ERROR,
|
|
});
|
|
setUnarchivingId(null);
|
|
},
|
|
});
|
|
|
|
const deleteMutation = useDeleteConversationMutation({
|
|
onSuccess: (_data, variables) => {
|
|
const { conversationId } = variables;
|
|
if (conversationId) {
|
|
removeConversationFromInfinite(
|
|
queryClient,
|
|
QueryKeys.archivedConversations,
|
|
conversationId,
|
|
);
|
|
}
|
|
showToast({
|
|
message: localize('com_ui_archived_conversation_delete_success'),
|
|
severity: NotificationSeverity.SUCCESS,
|
|
});
|
|
setIsDeleteOpen(false);
|
|
},
|
|
onError: () => {
|
|
showToast({
|
|
message: localize('com_ui_archive_delete_error'),
|
|
severity: NotificationSeverity.ERROR,
|
|
});
|
|
},
|
|
});
|
|
|
|
const handleFetchNextPage = useCallback(async () => {
|
|
if (!hasNextPage || isFetchingNextPage) return;
|
|
await fetchNextPage();
|
|
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
|
|
|
const effectiveIsLoading = isLoading;
|
|
const effectiveIsFetching = isFetchingNextPage;
|
|
|
|
const confirmDelete = useCallback(() => {
|
|
if (!deleteRow?.conversationId) {
|
|
showToast({
|
|
message: localize('com_ui_convo_delete_error'),
|
|
severity: NotificationSeverity.WARNING,
|
|
});
|
|
return;
|
|
}
|
|
deleteMutation.mutate({ conversationId: deleteRow.conversationId });
|
|
}, [deleteMutation, deleteRow, localize, showToast]);
|
|
|
|
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 text-text-primary sm:text-sm">
|
|
{localize('com_nav_archive_name')}
|
|
</span>
|
|
),
|
|
cell: ({ row }) => {
|
|
const convo = row.original as TConversation;
|
|
const { conversationId, title } = convo;
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<MinimalIcon
|
|
endpoint={convo.endpoint}
|
|
size={28}
|
|
isCreatedByUser={false}
|
|
iconClassName="size-4"
|
|
aria-hidden="true"
|
|
/>
|
|
<a
|
|
href={`/c/${conversationId}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center truncate underline"
|
|
aria-label={localize('com_ui_open_conversation', { 0: title })}
|
|
>
|
|
{title}
|
|
</a>
|
|
</div>
|
|
);
|
|
},
|
|
meta: {
|
|
className: 'min-w-[150px] flex-1',
|
|
isRowHeader: true,
|
|
},
|
|
enableSorting: true,
|
|
},
|
|
{
|
|
accessorKey: 'createdAt',
|
|
accessorFn: (row: Record<string, unknown>): unknown => {
|
|
const convo = row as TConversation;
|
|
return convo.createdAt;
|
|
},
|
|
header: () => (
|
|
<span className="text-xs text-text-primary sm:text-sm">
|
|
{localize('com_nav_archive_created_at')}
|
|
</span>
|
|
),
|
|
cell: ({ row }) => {
|
|
const convo = row.original as TConversation;
|
|
return formatDate(convo.createdAt?.toString() ?? '', isSmallScreen);
|
|
},
|
|
meta: {
|
|
className: 'w-32 sm:w-40',
|
|
desktopOnly: true,
|
|
},
|
|
enableSorting: true,
|
|
},
|
|
{
|
|
id: 'actions',
|
|
accessorFn: () => null,
|
|
header: () => (
|
|
<span className="text-xs text-text-primary sm:text-sm">
|
|
{localize('com_assistants_actions')}
|
|
</span>
|
|
),
|
|
cell: ({ row }) => {
|
|
const convo = row.original as TConversation;
|
|
const { title } = convo;
|
|
const isRowUnarchiving = unarchivingId === convo.conversationId;
|
|
|
|
return (
|
|
<div className="flex items-center gap-1.5 md:gap-2">
|
|
<TooltipAnchor
|
|
description={localize('com_ui_unarchive')}
|
|
render={
|
|
<Button
|
|
variant="ghost"
|
|
className="h-9 w-9 p-0 hover:bg-surface-hover md:h-8 md:w-8"
|
|
onClick={() => {
|
|
const conversationId = convo.conversationId;
|
|
if (!conversationId) return;
|
|
handleUnarchive(conversationId);
|
|
}}
|
|
disabled={isRowUnarchiving}
|
|
aria-label={localize('com_ui_unarchive_conversation_title', { 0: title })}
|
|
>
|
|
{isRowUnarchiving ? <Spinner /> : <ArchiveRestore className="size-4" />}
|
|
</Button>
|
|
}
|
|
/>
|
|
<TooltipAnchor
|
|
description={localize('com_ui_delete')}
|
|
render={
|
|
<Button
|
|
variant="ghost"
|
|
className="h-9 w-9 p-0 md:h-8 md:w-8"
|
|
onClick={() => {
|
|
setDeleteRow(convo);
|
|
setIsDeleteOpen(true);
|
|
}}
|
|
aria-label={localize('com_ui_delete_conversation_title', { 0: title })}
|
|
>
|
|
<TrashIcon className="size-4" />
|
|
</Button>
|
|
}
|
|
/>
|
|
</div>
|
|
);
|
|
},
|
|
meta: {
|
|
className: 'w-24',
|
|
},
|
|
enableSorting: false,
|
|
},
|
|
],
|
|
[isSmallScreen, localize, handleUnarchive, unarchivingId],
|
|
);
|
|
|
|
return (
|
|
<div className="flex items-center justify-between">
|
|
<Label htmlFor="archived-chats-button" className="text-sm font-medium">
|
|
{localize('com_nav_archived_chats')}
|
|
</Label>
|
|
<OGDialog open={isOpen} onOpenChange={setIsOpen}>
|
|
<OGDialogTrigger asChild>
|
|
<Button
|
|
id="archived-chats-button"
|
|
variant="outline"
|
|
aria-label={localize('com_ui_manage_archived_chats')}
|
|
>
|
|
{localize('com_ui_manage')}
|
|
</Button>
|
|
</OGDialogTrigger>
|
|
<OGDialogContent className={cn('w-11/12 max-w-6xl', isSmallScreen && 'px-1 pb-1')}>
|
|
<OGDialogHeader>
|
|
<OGDialogTitle>{localize('com_nav_archived_chats')}</OGDialogTitle>
|
|
</OGDialogHeader>
|
|
<DataTable
|
|
columns={columns}
|
|
data={flattenedConversations}
|
|
isLoading={effectiveIsLoading}
|
|
isFetching={effectiveIsFetching}
|
|
config={{
|
|
skeleton: { count: 11 },
|
|
search: {
|
|
filterColumn: 'title',
|
|
enableSearch: true,
|
|
debounce: 300,
|
|
},
|
|
selection: {
|
|
enableRowSelection: false,
|
|
showCheckboxes: false,
|
|
},
|
|
}}
|
|
filterValue={searchValue}
|
|
onFilterChange={handleSearchChange}
|
|
fetchNextPage={handleFetchNextPage}
|
|
hasNextPage={hasNextPage}
|
|
isFetchingNextPage={isFetchingNextPage}
|
|
sorting={sorting}
|
|
onSortingChange={handleSortingChange}
|
|
/>
|
|
</OGDialogContent>
|
|
</OGDialog>
|
|
<OGDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
|
|
<OGDialogTemplate
|
|
showCloseButton={false}
|
|
title={localize('com_ui_delete_archived_chats')}
|
|
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 className="text-left text-sm font-medium">
|
|
{localize('com_ui_delete_confirm')} <strong>{deleteRow?.title}</strong>
|
|
</Label>
|
|
</div>
|
|
</div>
|
|
}
|
|
selection={{
|
|
selectHandler: confirmDelete,
|
|
selectClasses: `bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white ${
|
|
deleteMutation.isLoading ? 'cursor-not-allowed opacity-80' : ''
|
|
}`,
|
|
selectText: deleteMutation.isLoading ? <Spinner /> : localize('com_ui_delete'),
|
|
}}
|
|
/>
|
|
</OGDialog>
|
|
</div>
|
|
);
|
|
}
|