diff --git a/client/src/common/menus.ts b/client/src/common/menus.ts index d0d81460dd..97c2d1b11b 100644 --- a/client/src/common/menus.ts +++ b/client/src/common/menus.ts @@ -16,6 +16,7 @@ export interface MenuItemProps { hideOnClick?: boolean; dialog?: React.ReactElement; ref?: React.Ref; + className?: string; render?: | RenderProp & { ref?: React.Ref | undefined }> | React.ReactElement> diff --git a/client/src/components/Chat/Input/Files/Table/ColumnVisibilityDropdown.tsx b/client/src/components/Chat/Input/Files/Table/ColumnVisibilityDropdown.tsx new file mode 100644 index 0000000000..d4d1bd0cb0 --- /dev/null +++ b/client/src/components/Chat/Input/Files/Table/ColumnVisibilityDropdown.tsx @@ -0,0 +1,59 @@ +import { useState, useId, useMemo } from 'react'; +import { ListFilter } from 'lucide-react'; +import * as Menu from '@ariakit/react/menu'; +import { useReactTable } from '@tanstack/react-table'; +import { DropdownPopup } from '@librechat/client'; +import { useLocalize, TranslationKeys } from '~/hooks'; +import { cn } from '~/utils'; + +interface ColumnVisibilityDropdownProps { + table: ReturnType>; + contextMap: Record; + isSmallScreen: boolean; +} + +export function ColumnVisibilityDropdown({ + table, + contextMap, + isSmallScreen, +}: ColumnVisibilityDropdownProps) { + const localize = useLocalize(); + const menuId = useId(); + const [isOpen, setIsOpen] = useState(false); + + const dropdownItems = useMemo( + () => + table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => ({ + label: localize(contextMap[column.id]), + onClick: () => column.toggleVisibility(!column.getIsVisible()), + icon: column.getIsVisible() ? '✓' : '', + id: column.id, + })), + [table, contextMap, localize], + ); + + return ( + + + + } + items={dropdownItems} + menuId={menuId} + className="z-50 max-h-[300px] overflow-y-auto" + /> + ); +} diff --git a/client/src/components/Chat/Input/Files/Table/Columns.tsx b/client/src/components/Chat/Input/Files/Table/Columns.tsx index 6bd150823f..8f78257aa4 100644 --- a/client/src/components/Chat/Input/Files/Table/Columns.tsx +++ b/client/src/components/Chat/Input/Files/Table/Columns.tsx @@ -1,7 +1,14 @@ /* eslint-disable react-hooks/rules-of-hooks */ -import { Database } from 'lucide-react'; +import { ArrowUpDown, ArrowUp, ArrowDown, Database } from 'lucide-react'; import { FileSources, FileContext } from 'librechat-data-provider'; -import { Checkbox, OpenAIMinimalIcon, AzureMinimalIcon, useMediaQuery } from '@librechat/client'; +import { + Button, + Checkbox, + useMediaQuery, + TooltipAnchor, + AzureMinimalIcon, + OpenAIMinimalIcon, +} from '@librechat/client'; import type { ColumnDef } from '@tanstack/react-table'; import type { TFile } from 'librechat-data-provider'; import ImagePreview from '~/components/Chat/Input/Files/ImagePreview'; @@ -22,25 +29,35 @@ const contextMap: Record = { export const columns: ColumnDef[] = [ { id: 'select', + size: 40, header: ({ table }) => { + const localize = useLocalize(); return ( - table.toggleAllPageRowsSelected(!!value)} + aria-label={localize('com_ui_select_all')} + className="flex" + /> } - onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} - aria-label="Select all" - className="flex" /> ); }, cell: ({ row }) => { + const localize = useLocalize(); return ( row.toggleSelected(!!value)} - aria-label="Select row" + aria-label={localize('com_ui_select_row')} className="flex" /> ); @@ -55,7 +72,35 @@ export const columns: ColumnDef[] = [ accessorKey: 'filename', header: ({ column }) => { const localize = useLocalize(); - return ; + const sortState = column.getIsSorted(); + let SortIcon = ArrowUpDown; + let ariaSort: 'ascending' | 'descending' | 'none' = 'none'; + if (sortState === 'desc') { + SortIcon = ArrowDown; + ariaSort = 'descending'; + } else if (sortState === 'asc') { + SortIcon = ArrowUp; + ariaSort = 'ascending'; + } + return ( + column.toggleSorting(column.getIsSorted() === 'asc')} + aria-sort={ariaSort} + aria-label={localize('com_ui_name_sort')} + aria-current={sortState ? 'true' : 'false'} + > + {localize('com_ui_name')} + + + } + /> + ); }, cell: ({ row }) => { const file = row.original; @@ -85,7 +130,35 @@ export const columns: ColumnDef[] = [ accessorKey: 'updatedAt', header: ({ column }) => { const localize = useLocalize(); - return ; + const sortState = column.getIsSorted(); + let SortIcon = ArrowUpDown; + let ariaSort: 'ascending' | 'descending' | 'none' = 'none'; + if (sortState === 'desc') { + SortIcon = ArrowDown; + ariaSort = 'descending'; + } else if (sortState === 'asc') { + SortIcon = ArrowUp; + ariaSort = 'ascending'; + } + return ( + column.toggleSorting(column.getIsSorted() === 'asc')} + className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm" + aria-sort={ariaSort} + aria-label={localize('com_ui_date_sort')} + aria-current={sortState ? 'true' : 'false'} + > + {localize('com_ui_date')} + + + } + /> + ); }, cell: ({ row }) => { const isSmallScreen = useMediaQuery('(max-width: 768px)'); @@ -100,6 +173,7 @@ export const columns: ColumnDef[] = [ @@ -150,6 +224,7 @@ export const columns: ColumnDef[] = [ value === FileContext[value ?? ''], @@ -173,7 +248,35 @@ export const columns: ColumnDef[] = [ accessorKey: 'bytes', header: ({ column }) => { const localize = useLocalize(); - return ; + const sortState = column.getIsSorted(); + let SortIcon = ArrowUpDown; + let ariaSort: 'ascending' | 'descending' | 'none' = 'none'; + if (sortState === 'desc') { + SortIcon = ArrowDown; + ariaSort = 'descending'; + } else if (sortState === 'asc') { + SortIcon = ArrowUp; + ariaSort = 'ascending'; + } + return ( + column.toggleSorting(column.getIsSorted() === 'asc')} + aria-sort={ariaSort} + aria-label={localize('com_ui_size_sort')} + aria-current={sortState ? 'true' : 'false'} + > + {localize('com_ui_size')} + + + } + /> + ); }, cell: ({ row }) => { const suffix = ' MB'; diff --git a/client/src/components/Chat/Input/Files/Table/DataTable.tsx b/client/src/components/Chat/Input/Files/Table/DataTable.tsx index 7cf2909841..cb8c3fe3d1 100644 --- a/client/src/components/Chat/Input/Files/Table/DataTable.tsx +++ b/client/src/components/Chat/Input/Files/Table/DataTable.tsx @@ -1,13 +1,13 @@ import { useState } from 'react'; -import { ListFilter } from 'lucide-react'; +import { Search } from 'lucide-react'; import { useSetRecoilState } from 'recoil'; import { flexRender, + useReactTable, getCoreRowModel, + getSortedRowModel, getFilteredRowModel, getPaginationRowModel, - getSortedRowModel, - useReactTable, } from '@tanstack/react-table'; import type { ColumnDef, @@ -17,26 +17,22 @@ import type { } from '@tanstack/react-table'; import { FileContext } from 'librechat-data-provider'; import { - Button, Input, Table, + Button, + Spinner, + TableRow, TableBody, TableCell, TableHead, - TableHeader, - TableRow, - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuTrigger, TrashIcon, - Spinner, + TableHeader, useMediaQuery, } from '@librechat/client'; import type { TFile } from 'librechat-data-provider'; -import type { AugmentedColumnDef } from '~/common'; +import { ColumnVisibilityDropdown } from './ColumnVisibilityDropdown'; import { useDeleteFilesFromTable } from '~/hooks/Files'; -import { useLocalize } from '~/hooks'; +import { useLocalize, TranslationKeys } from '~/hooks'; import { cn } from '~/utils'; import store from '~/store'; @@ -45,7 +41,7 @@ interface DataTableProps { data: TData[]; } -const contextMap = { +const contextMap: Record = { [FileContext.filename]: 'com_ui_name', [FileContext.updatedAt]: 'com_ui_date', [FileContext.filterSource]: 'com_ui_storage', @@ -75,6 +71,11 @@ export default function DataTable({ columns, data }: DataTablePro const table = useReactTable({ data, columns, + defaultColumn: { + minSize: 0, + size: Number.MAX_SAFE_INTEGER, + maxSize: Number.MAX_SAFE_INTEGER, + }, onSortingChange: setSorting, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), @@ -115,62 +116,40 @@ export default function DataTable({ columns, data }: DataTablePro {!isSmallScreen && {localize('com_ui_delete')}}
+ table.getColumn('filename')?.setFilterValue(event.target.value)} - className="peer w-full text-sm" + className="peer w-full pl-10 text-sm focus-visible:ring-2 focus-visible:ring-ring" aria-label={localize('com_files_filter_input')} /> -
- - - - - - {table - .getAllColumns() - .filter((column) => column.getCanHide()) - .map((column) => ( - column.toggleVisibility(Boolean(value))} - > - {localize(contextMap[column.id])} - - ))} - - +
+ +
-
+
{table.getHeaderGroups().map((headerGroup) => ( - {headerGroup.headers.map((header, index) => { - const style: Style = {}; - if (index === 0 && header.id === 'select') { - style.width = '36px'; - style.minWidth = '36px'; - } else if (header.id === 'filename') { - style.width = isSmallScreen ? '60%' : '40%'; - } else { - style.width = isSmallScreen ? '20%' : '15%'; - } + {headerGroup.headers.map((header, _index) => { + const size = header.getSize(); + const style: Style = { + width: size === Number.MAX_SAFE_INTEGER ? 'auto' : size, + }; return ( ({ columns, data }: DataTablePro data-state={row.getIsSelected() && 'selected'} className="border-b border-border-light transition-colors hover:bg-surface-secondary [tr:last-child_&]:border-b-0" > - {row.getVisibleCells().map((cell, index) => { - const maxWidth = - (cell.column.columnDef as AugmentedColumnDef).meta?.size ?? - 'auto'; - - const style: Style = {}; - if (cell.column.id === 'filename') { - style.maxWidth = maxWidth; - } else if (index === 0) { - style.maxWidth = '20px'; - } + {row.getVisibleCells().map((cell, _index) => { + const size = cell.column.getSize(); + const style: Style = { + width: size === Number.MAX_SAFE_INTEGER ? 'auto' : size, + }; return ( ({ columns, data }: DataTablePro {localize('com_ui_page')} {table.getState().pagination.pageIndex + 1} / - {table.getPageCount()} + {Math.max(table.getPageCount(), 1)} - - - column.toggleSorting(false)} - className="cursor-pointer text-text-primary" - > - - {localize('com_ui_ascending')} - - column.toggleSorting(true)} - className="cursor-pointer text-text-primary" - > - - {localize('com_ui_descending')} - - - {filters && - Object.entries(filters).map(([key, values]) => - values.map((value?: string | number) => { - const translationKey = valueMap?.[value ?? '']; - const filterValue = - translationKey != null && translationKey.length - ? localize(translationKey) - : String(value); - if (!filterValue) { - return null; - } - return ( - { - column.setFilterValue(value); - }} - > - - {filterValue} - - ); - }), - )} - {filters && ( - { - column.setFilterValue(undefined); - }} - > - - {localize('com_ui_show_all')} - - )} - - + + {title} + {column.getIsFiltered() ? ( + + ) : ( + + )} + {(() => { + const sortState = column.getIsSorted(); + if (sortState === 'desc') { + return ; + } + if (sortState === 'asc') { + return ; + } + return ; + })()} + + } + /> + } + items={dropdownItems} + menuId={menuId} + className="z-[1001]" + /> ); } diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index cb279c338e..d8bb756324 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -805,6 +805,7 @@ "com_ui_confirm_change": "Confirm Change", "com_ui_connecting": "Connecting", "com_ui_context": "Context", + "com_ui_context_filter_sort": "Filter and Sort by Context", "com_ui_continue": "Continue", "com_ui_continue_oauth": "Continue with OAuth", "com_ui_control_bar": "Control bar", @@ -838,6 +839,7 @@ "com_ui_custom_prompt_mode": "Custom Prompt Mode", "com_ui_dashboard": "Dashboard", "com_ui_date": "Date", + "com_ui_date_sort": "Sort by Date", "com_ui_date_april": "April", "com_ui_date_august": "August", "com_ui_date_december": "December", @@ -1083,6 +1085,7 @@ "com_ui_more_info": "More info", "com_ui_my_prompts": "My Prompts", "com_ui_name": "Name", + "com_ui_name_sort": "Sort by Name", "com_ui_new": "New", "com_ui_new_chat": "New chat", "com_ui_new_conversation_title": "New Conversation Title", @@ -1248,6 +1251,7 @@ "com_ui_select_provider": "Select a provider", "com_ui_select_provider_first": "Select a provider first", "com_ui_select_region": "Select a region", + "com_ui_select_row": "Select row", "com_ui_select_search_model": "Search model by name", "com_ui_select_search_provider": "Search provider by name", "com_ui_select_search_region": "Search region by name", @@ -1273,6 +1277,7 @@ "com_ui_sign_in_to_domain": "Sign-in to {{0}}", "com_ui_simple": "Simple", "com_ui_size": "Size", + "com_ui_size_sort": "Sort by Size", "com_ui_special_var_current_date": "Current Date", "com_ui_special_var_current_datetime": "Current Date & Time", "com_ui_special_var_current_user": "Current User", @@ -1286,6 +1291,7 @@ "com_ui_status_prefix": "Status:", "com_ui_stop": "Stop", "com_ui_storage": "Storage", + "com_ui_storage_filter_sort": "Filter and Sort by Storage", "com_ui_submit": "Submit", "com_ui_support_contact": "Support Contact", "com_ui_support_contact_email": "Email", diff --git a/packages/client/src/common/menus.ts b/packages/client/src/common/menus.ts index 3e81012bbd..9d6c2174c7 100644 --- a/packages/client/src/common/menus.ts +++ b/packages/client/src/common/menus.ts @@ -28,8 +28,10 @@ export interface MenuItemProps { | undefined; ariaControls?: string; ref?: React.Ref; + className?: string; render?: | RenderProp & { ref?: React.Ref | undefined }> | React.ReactElement> | undefined; + subItems?: MenuItemProps[]; } diff --git a/packages/client/src/components/DropdownPopup.tsx b/packages/client/src/components/DropdownPopup.tsx index c5e662f51b..b4050469eb 100644 --- a/packages/client/src/components/DropdownPopup.tsx +++ b/packages/client/src/components/DropdownPopup.tsx @@ -140,6 +140,7 @@ const Menu: React.FC = ({ className={cn( 'group flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-3.5 text-sm text-text-primary outline-none transition-colors duration-200 hover:bg-surface-hover focus:bg-surface-hover md:px-2.5 md:py-2', itemClassName, + item.className, )} disabled={item.disabled} render={item.render}