mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
🩹 fix: Stop Archived and Shared Chats Dialogs Crashing on Open (#14886)
* fix: stop the virtualized data table looping on render Opening Archived chats or Shared chats with 50 or more rows threw "Too many re-renders". DataTable passed an inline getItemKey to useVirtualizer, and virtual-core lists that option among the deps of its getMeasurementOptions memo, whose onChange notifies. getVirtualItems() is read during render, so every render built a new closure, notified, and dispatched a render-phase update on the component that was still rendering, until React gave up at 25 passes. It only fired past the 50-row virtualization threshold, which is why both dialogs looked fine while empty. Memoize getItemKey and estimateSize so their identity tracks their inputs. DataTable.spec had mocked @tanstack/react-virtual away, attributing the same error to jsdom, which hid this from CI. Keep that mock, since its row assertions need every row rendered, and add a spec that drives the real virtualizer and fails without the fix. Also restyle both dialogs, which is what made them look unfinished: - add the 19 keys these components pull from @librechat/client but the app locale never defined, so the empty state rendered com_ui_no_data verbatim - rename Shared links to Shared chats, matching the sibling Archived chats - transparent table with a rounded hover highlight painted on the cells, since border-radius does not apply to a table row, which needs separated borders - row height 56 to 40, dividers dropped, skeletons follow the same height - row hover uses surface-secondary-alt: plain surface-secondary is 247 against a 255 dialog in light mode and reads as nothing - row action buttons use surface-hover-alt, because surface-hover is also 227 in light and would vanish against the row highlight - drop the focus ring from the dialog containers and stop Shared chats seating focus in its search field, so neither flashes an outline on open - narrow both dialogs and let the table height follow its content * Fix compact row actions and selection count * fix: update selected count translation test to match interpolated output
This commit is contained in:
parent
0a2f59ab86
commit
edc6cf5936
17 changed files with 312 additions and 114 deletions
2
bun.lock
2
bun.lock
|
|
@ -408,7 +408,7 @@
|
|||
},
|
||||
"packages/client": {
|
||||
"name": "@librechat/client",
|
||||
"version": "0.4.75",
|
||||
"version": "0.4.76",
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.28.5",
|
||||
"@babel/preset-env": "^7.29.5",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useState, useMemo } from 'react';
|
||||
import { useCallback, useState, useMemo, useRef } from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
|
@ -42,6 +42,7 @@ export default function SharedLinks() {
|
|||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const searchStore = useRecoilValue(store.search);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
|
|
@ -64,6 +65,17 @@ export default function SharedLinks() {
|
|||
}));
|
||||
}, []);
|
||||
|
||||
const getRowId = useCallback((row: SharedLinkRow) => row.shareId, []);
|
||||
|
||||
/** Radix would otherwise seat focus on the search field, flashing its ring every
|
||||
* time the dialog opens. Anchor focus to the content instead: it is a landing
|
||||
* spot rather than a tab stop, so it shows no ring and the first Tab reaches a
|
||||
* real control that does. */
|
||||
const handleOpenAutoFocus = useCallback((event: Event) => {
|
||||
event.preventDefault();
|
||||
contentRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const allLinks = useMemo<SharedLinkRow[]>(() => {
|
||||
if (!data?.pages) {
|
||||
return [];
|
||||
|
|
@ -184,11 +196,11 @@ export default function SharedLinks() {
|
|||
to={`/share/${shareId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-center gap-1 truncate rounded-sm text-link underline decoration-1 underline-offset-2 hover:decoration-2 focus:outline-none focus:ring-2 focus:ring-text-primary"
|
||||
className="group flex items-center gap-1.5 truncate rounded-sm font-medium text-text-primary underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-primary"
|
||||
>
|
||||
<span className="truncate">{title}</span>
|
||||
<ExternalLink
|
||||
className="size-3 flex-shrink-0 opacity-70 group-hover:opacity-100"
|
||||
className="size-3.5 flex-shrink-0 text-text-tertiary transition-colors group-hover:text-text-secondary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
|
|
@ -225,7 +237,7 @@ export default function SharedLinks() {
|
|||
<TooltipAnchor
|
||||
description={localize('com_ui_open_source_chat_new_tab')}
|
||||
render={
|
||||
<Button asChild variant="ghost" className="h-8 w-8 p-0 hover:bg-surface-hover">
|
||||
<Button asChild variant="row-action" size="icon-sm">
|
||||
<a
|
||||
href={`/c/${row.original.conversationId}`}
|
||||
target="_blank"
|
||||
|
|
@ -243,8 +255,8 @@ export default function SharedLinks() {
|
|||
description={localize('com_ui_delete_shared_link_heading')}
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 hover:bg-surface-hover"
|
||||
variant="row-action"
|
||||
size="icon-sm"
|
||||
onClick={() => {
|
||||
setDeleteRow(row.original);
|
||||
setIsDeleteOpen(true);
|
||||
|
|
@ -278,8 +290,10 @@ export default function SharedLinks() {
|
|||
</OGDialogTrigger>
|
||||
|
||||
<OGDialogContent
|
||||
title={localize('com_nav_shared_links')}
|
||||
className="w-11/12 max-w-5xl bg-surface-dialog text-text-primary shadow-2xl"
|
||||
ref={contentRef}
|
||||
tabIndex={-1}
|
||||
onOpenAutoFocus={handleOpenAutoFocus}
|
||||
className="w-11/12 max-w-3xl shadow-2xl focus:outline-none"
|
||||
>
|
||||
<OGDialogHeader>
|
||||
<OGDialogTitle>{localize('com_nav_shared_links')}</OGDialogTitle>
|
||||
|
|
@ -287,8 +301,8 @@ export default function SharedLinks() {
|
|||
<VirtualizedDataTable
|
||||
columns={columns}
|
||||
data={allLinks}
|
||||
getRowId={(row) => row.shareId}
|
||||
className="scrollbar-gutter-stable h-[60vh]"
|
||||
getRowId={getRowId}
|
||||
className="scrollbar-gutter-stable max-h-[60vh] min-h-80"
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
isFetching={isFetching}
|
||||
|
|
@ -300,6 +314,7 @@ export default function SharedLinks() {
|
|||
isLoading={isLoading}
|
||||
config={{
|
||||
selection: { enableRowSelection: false, showCheckboxes: false },
|
||||
skeleton: { count: 6 },
|
||||
search: { enableSearch: searchStore.enabled === true, debounce: 300 },
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,9 @@ export function ArchivedChatsModal({
|
|||
|
||||
/** The virtualized table has no stable focusable on mount, so Radix's default
|
||||
* autofocus lands on a row that the virtualizer tears out, dropping focus to
|
||||
* the page's top focus guard; anchor focus to the dialog content instead. */
|
||||
* the page's top focus guard; anchor focus to the dialog content instead.
|
||||
* The container is only a landing spot for focus, never a tab stop, so it
|
||||
* draws no focus ring of its own; the first Tab reveals one on a real control. */
|
||||
const handleOpenAutoFocus = (event: Event) => {
|
||||
event.preventDefault();
|
||||
contentRef.current?.focus();
|
||||
|
|
@ -30,8 +32,7 @@ export function ArchivedChatsModal({
|
|||
ref={contentRef}
|
||||
tabIndex={-1}
|
||||
onOpenAutoFocus={handleOpenAutoFocus}
|
||||
title={localize('com_nav_archived_chats')}
|
||||
className="w-11/12 max-w-[1000px] bg-surface-dialog text-text-primary shadow-2xl focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-text-primary"
|
||||
className="w-11/12 max-w-3xl shadow-2xl focus:outline-none"
|
||||
>
|
||||
<OGDialogHeader>
|
||||
<OGDialogTitle>{localize('com_nav_archived_chats')}</OGDialogTitle>
|
||||
|
|
|
|||
|
|
@ -62,6 +62,11 @@ export default function ArchivedChatsTable() {
|
|||
}));
|
||||
}, []);
|
||||
|
||||
const getRowId = useCallback(
|
||||
(row: ArchivedConversationRow, index: number) => row.conversationId ?? `archived-${index}`,
|
||||
[],
|
||||
);
|
||||
|
||||
const allConversations = useMemo<ArchivedConversationRow[]>(() => {
|
||||
if (!data?.pages) {
|
||||
return [];
|
||||
|
|
@ -151,30 +156,32 @@ export default function ArchivedChatsTable() {
|
|||
header: localize('com_nav_archive_name'),
|
||||
cell: ({ row }) => {
|
||||
const { conversationId, title } = row.original;
|
||||
const link = (
|
||||
<Link
|
||||
to={`/c/${conversationId ?? ''}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-center gap-1.5 truncate rounded-sm font-medium text-text-primary underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-primary"
|
||||
aria-label={localize('com_ui_open_archived_chat_new_tab_title', {
|
||||
title: title ?? localize('com_ui_untitled'),
|
||||
})}
|
||||
>
|
||||
<span className="truncate">{title}</span>
|
||||
<ExternalLink
|
||||
className="size-3.5 flex-shrink-0 text-text-tertiary transition-colors group-hover:text-text-secondary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<MinimalIcon
|
||||
endpoint={row.original.endpoint}
|
||||
size={28}
|
||||
isCreatedByUser={false}
|
||||
iconClassName="size-4"
|
||||
/>
|
||||
<Link
|
||||
to={`/c/${conversationId ?? ''}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group flex items-center gap-1 truncate rounded-sm text-link underline decoration-1 underline-offset-2 hover:decoration-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-primary"
|
||||
title={title ?? undefined}
|
||||
aria-label={localize('com_ui_open_archived_chat_new_tab_title', {
|
||||
title: title ?? localize('com_ui_untitled'),
|
||||
})}
|
||||
>
|
||||
<span className="truncate">{title}</span>
|
||||
<ExternalLink
|
||||
className="size-3 flex-shrink-0 opacity-70 group-hover:opacity-100"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
{title ? <TooltipAnchor description={title} render={link} /> : link}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
|
@ -204,8 +211,8 @@ export default function ArchivedChatsTable() {
|
|||
description={localize('com_ui_unarchive_conversation')}
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 hover:bg-surface-hover"
|
||||
variant="row-action"
|
||||
size="icon-sm"
|
||||
onClick={() =>
|
||||
unarchiveConversation({
|
||||
conversationId: conversation.conversationId ?? '',
|
||||
|
|
@ -223,8 +230,8 @@ export default function ArchivedChatsTable() {
|
|||
description={localize('com_ui_delete_conversation_tooltip')}
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0 hover:bg-surface-hover"
|
||||
variant="row-action"
|
||||
size="icon-sm"
|
||||
onClick={() => {
|
||||
setDeleteConversation(row.original);
|
||||
setIsDeleteOpen(true);
|
||||
|
|
@ -248,37 +255,31 @@ export default function ArchivedChatsTable() {
|
|||
|
||||
return (
|
||||
<>
|
||||
{/* Fixed height keeps the loading (skeleton) and loaded states the same
|
||||
size, so the virtualized table can't reflow the dialog on load. */}
|
||||
<div className="h-[60vh]">
|
||||
<VirtualizedDataTable
|
||||
columns={columns}
|
||||
data={allConversations}
|
||||
getRowId={(row, index) => row.conversationId ?? `archived-${index}`}
|
||||
className="scrollbar-gutter-stable h-full max-h-none"
|
||||
onFilterChange={handleFilterChange}
|
||||
filterValue={queryParams.search}
|
||||
fetchNextPage={handleFetchNextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
isFetching={isFetching}
|
||||
isLoading={isLoading}
|
||||
sorting={sorting}
|
||||
onSortingChange={handleSortingChange}
|
||||
config={{
|
||||
selection: { enableRowSelection: false, showCheckboxes: false },
|
||||
search: { enableSearch: searchState.enabled === true, debounce: 300 },
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* The skeleton count matches the minimum height so the loading and loaded
|
||||
states are close in size, while a short list still collapses the box. */}
|
||||
<VirtualizedDataTable
|
||||
columns={columns}
|
||||
data={allConversations}
|
||||
getRowId={getRowId}
|
||||
className="scrollbar-gutter-stable max-h-[60vh] min-h-80"
|
||||
onFilterChange={handleFilterChange}
|
||||
filterValue={queryParams.search}
|
||||
fetchNextPage={handleFetchNextPage}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
isFetching={isFetching}
|
||||
isLoading={isLoading}
|
||||
sorting={sorting}
|
||||
onSortingChange={handleSortingChange}
|
||||
config={{
|
||||
selection: { enableRowSelection: false, showCheckboxes: false },
|
||||
skeleton: { count: 6 },
|
||||
search: { enableSearch: searchState.enabled === true, debounce: 300 },
|
||||
}}
|
||||
/>
|
||||
|
||||
<OGDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
|
||||
<OGDialogContent
|
||||
title={localize('com_ui_delete_confirm', {
|
||||
title: deleteConversation?.title ?? localize('com_ui_untitled'),
|
||||
})}
|
||||
className="w-11/12 max-w-md"
|
||||
>
|
||||
<OGDialogContent showCloseButton={false} className="w-11/12 max-w-md">
|
||||
<OGDialogHeader>
|
||||
<OGDialogTitle>
|
||||
<Trans
|
||||
|
|
|
|||
|
|
@ -621,7 +621,7 @@
|
|||
"com_nav_setting_mcp": "MCP Settings",
|
||||
"com_nav_setting_speech": "Speech",
|
||||
"com_nav_settings": "Settings",
|
||||
"com_nav_shared_links": "Shared links",
|
||||
"com_nav_shared_links": "Shared chats",
|
||||
"com_nav_show_thinking": "Open Thinking Dropdowns by Default",
|
||||
"com_nav_slash_command": "/-Command",
|
||||
"com_nav_slash_command_description": "Toggle command \"/\" for selecting a prompt via keyboard",
|
||||
|
|
@ -966,6 +966,7 @@
|
|||
"com_ui_cache_write": "Cache write",
|
||||
"com_ui_callback_url": "Callback URL",
|
||||
"com_ui_cancel": "Cancel",
|
||||
"com_ui_cancel_dialog": "Cancel dialog",
|
||||
"com_ui_cancelled": "Cancelled",
|
||||
"com_ui_capabilities_count": "{{count}} capabilities",
|
||||
"com_ui_capabilities_count_one": "{{count}} capability",
|
||||
|
|
@ -1009,6 +1010,7 @@
|
|||
"com_ui_confirm": "Confirm",
|
||||
"com_ui_confirm_action": "Confirm Action",
|
||||
"com_ui_confirm_admin_use_change": "Changing this setting will block access for admins, including yourself. Are you sure you want to proceed?",
|
||||
"com_ui_confirm_bulk_delete": "Are you sure you want to delete the selected items? This action cannot be undone.",
|
||||
"com_ui_confirm_change": "Confirm Change",
|
||||
"com_ui_connecting": "Connecting",
|
||||
"com_ui_contact_admin_if_issue_persists": "Contact the Admin if the issue persists",
|
||||
|
|
@ -1087,6 +1089,8 @@
|
|||
"com_ui_custom_header_name": "Custom Header Name",
|
||||
"com_ui_custom_prompt_mode": "Custom Prompt Mode",
|
||||
"com_ui_dark_theme_enabled": "Dark theme enabled",
|
||||
"com_ui_data_table": "Data table",
|
||||
"com_ui_data_table_scroll_area": "Scrollable data table area",
|
||||
"com_ui_date": "Date",
|
||||
"com_ui_date_april": "April",
|
||||
"com_ui_date_august": "August",
|
||||
|
|
@ -1130,6 +1134,8 @@
|
|||
"com_ui_delete_project": "Delete project?",
|
||||
"com_ui_delete_project_confirm": "Delete \"{{name}}\"? The chats inside won't be deleted.",
|
||||
"com_ui_delete_prompt": "Delete Prompt?",
|
||||
"com_ui_delete_selected": "Delete selected",
|
||||
"com_ui_delete_selected_items": "Delete selected items",
|
||||
"com_ui_delete_shared_link": "Delete shared link?",
|
||||
"com_ui_delete_shared_link_heading": "Delete Shared Link",
|
||||
"com_ui_delete_success": "Successfully deleted",
|
||||
|
|
@ -1182,6 +1188,7 @@
|
|||
"com_ui_editing_file": "Editing {{0}}",
|
||||
"com_ui_editor_instructions": "Drag the image to reposition • Use zoom slider or buttons to adjust size",
|
||||
"com_ui_empty_category": "-",
|
||||
"com_ui_enabled": "Enabled",
|
||||
"com_ui_endpoint": "Endpoint",
|
||||
"com_ui_endpoint_menu": "LLM Endpoint Menu",
|
||||
"com_ui_enter": "Enter",
|
||||
|
|
@ -1194,6 +1201,7 @@
|
|||
"com_ui_enter_value": "Enter value",
|
||||
"com_ui_error": "Error",
|
||||
"com_ui_error_connection": "Error connecting to server, try refreshing the page.",
|
||||
"com_ui_error_details": "Error details",
|
||||
"com_ui_error_message_prefix": "Error Message:",
|
||||
"com_ui_error_save_admin_settings": "There was an error saving your admin settings.",
|
||||
"com_ui_error_try_following_prefix": "Please try one of the following",
|
||||
|
|
@ -1252,6 +1260,7 @@
|
|||
"com_ui_file_token_limit_desc": "Set maximum token limit for file processing to control costs and resource usage",
|
||||
"com_ui_files": "Files",
|
||||
"com_ui_files_count_size": "{{0}} Files ({{1}}KB)",
|
||||
"com_ui_filter_by": "Filter by {{title}}",
|
||||
"com_ui_filter_mcp_servers": "Filter MCP servers by name",
|
||||
"com_ui_filter_prompts": "Filter Prompts",
|
||||
"com_ui_filter_prompts_name": "Filter prompts by name",
|
||||
|
|
@ -1427,6 +1436,7 @@
|
|||
"com_ui_live": "live",
|
||||
"com_ui_load_more": "Load more",
|
||||
"com_ui_loading": "Loading...",
|
||||
"com_ui_loading_more_data": "Loading more results",
|
||||
"com_ui_locked": "Locked",
|
||||
"com_ui_logo": "{{0}} Logo",
|
||||
"com_ui_low": "Low",
|
||||
|
|
@ -1577,6 +1587,7 @@
|
|||
"com_ui_no_categories": "No categories available",
|
||||
"com_ui_no_category": "No category",
|
||||
"com_ui_no_changes": "No changes were made",
|
||||
"com_ui_no_data": "Nothing here yet",
|
||||
"com_ui_no_individual_resource_access": "No individual users or groups have access",
|
||||
"com_ui_no_labels": "No Labels",
|
||||
"com_ui_no_mcp_servers": "No MCP servers yet",
|
||||
|
|
@ -1584,11 +1595,14 @@
|
|||
"com_ui_no_memories": "No memories. Create them manually or prompt the AI to remember something",
|
||||
"com_ui_no_memories_match": "No memories match your search",
|
||||
"com_ui_no_memories_title": "No memories yet",
|
||||
"com_ui_no_options": "No options available",
|
||||
"com_ui_no_project_chats": "No chats yet",
|
||||
"com_ui_no_projects": "No projects yet",
|
||||
"com_ui_no_prompts_title": "No prompts yet",
|
||||
"com_ui_no_read_access": "You don't have permission to view memories",
|
||||
"com_ui_no_results_found": "No results found",
|
||||
"com_ui_no_search_results": "No results match your search",
|
||||
"com_ui_no_selection": "No selection",
|
||||
"com_ui_no_skills_found": "No skills found",
|
||||
"com_ui_no_terms_content": "No terms and conditions content to display",
|
||||
"com_ui_no_valid_items": "No valid items were selected",
|
||||
|
|
@ -1816,6 +1830,8 @@
|
|||
"com_ui_search_result_count": "{{count}} result found",
|
||||
"com_ui_search_results_count": "{{count}} results found",
|
||||
"com_ui_search_skills": "Search skills...",
|
||||
"com_ui_search_table": "Search table",
|
||||
"com_ui_search_table_description": "Type to filter results",
|
||||
"com_ui_searching_files": "Searching your files",
|
||||
"com_ui_seconds": "seconds",
|
||||
"com_ui_secret_key": "Secret Key",
|
||||
|
|
@ -1834,6 +1850,7 @@
|
|||
"com_ui_select_search_provider": "Search provider by name",
|
||||
"com_ui_select_search_region": "Search region by name",
|
||||
"com_ui_select_var": "Select {{0}}",
|
||||
"com_ui_selected_count": "{{count}} selected",
|
||||
"com_ui_send_now": "Send now",
|
||||
"com_ui_set": "Set",
|
||||
"com_ui_settings_label_2fa": "Two-factor authentication",
|
||||
|
|
@ -1854,7 +1871,7 @@
|
|||
"com_ui_settings_label_playback_rate": "Playback rate",
|
||||
"com_ui_settings_label_provider_api_keys": "Provider API keys",
|
||||
"com_ui_settings_label_revoke_keys": "Revoke keys",
|
||||
"com_ui_settings_label_shared_links": "Shared links",
|
||||
"com_ui_settings_label_shared_links": "Shared chats",
|
||||
"com_ui_settings_label_voice": "Voice",
|
||||
"com_ui_settings_no_results": "No settings match your search",
|
||||
"com_ui_settings_results_aria": "Search results",
|
||||
|
|
@ -2064,6 +2081,8 @@
|
|||
"com_ui_support_contact_name": "Name",
|
||||
"com_ui_support_contact_name_min_length": "Name must be at least {{minLength}} characters",
|
||||
"com_ui_support_contact_name_placeholder": "Support contact name",
|
||||
"com_ui_table_error": "Table error",
|
||||
"com_ui_table_error_description": "The table failed to load. Refresh the page or try again.",
|
||||
"com_ui_teach_or_explain": "Learning",
|
||||
"com_ui_temporary": "Temporary Chat",
|
||||
"com_ui_terms_and_conditions": "Terms and Conditions",
|
||||
|
|
|
|||
2
package-lock.json
generated
2
package-lock.json
generated
|
|
@ -43494,7 +43494,7 @@
|
|||
},
|
||||
"packages/client": {
|
||||
"name": "@librechat/client",
|
||||
"version": "0.4.75",
|
||||
"version": "0.4.76",
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.28.5",
|
||||
"@babel/preset-env": "^7.29.5",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@librechat/client",
|
||||
"version": "0.4.75",
|
||||
"version": "0.4.76",
|
||||
"description": "React components for LibreChat",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
|||
|
|
@ -42,4 +42,19 @@ describe('Button', () => {
|
|||
expect(themedSubtle).toContain('rounded-theme-control');
|
||||
expect(themedSubtle).not.toContain('rounded-xl');
|
||||
});
|
||||
|
||||
it('provides compact row actions with a distinct hover surface', () => {
|
||||
render(
|
||||
<Button variant="row-action" size="icon-sm">
|
||||
Open
|
||||
</Button>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Open' })).toHaveClass(
|
||||
'size-8',
|
||||
'p-0',
|
||||
'rounded-lg',
|
||||
'hover:bg-surface-hover-alt',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,9 +15,10 @@ type ButtonVariantOptions =
|
|||
| 'destructive'
|
||||
| 'secondary'
|
||||
| 'ghost'
|
||||
| 'row-action'
|
||||
| null
|
||||
| undefined;
|
||||
size?: 'default' | 'icon' | 'sm' | 'lg' | 'theme' | null | undefined;
|
||||
size?: 'default' | 'icon' | 'icon-sm' | 'sm' | 'lg' | 'theme' | null | undefined;
|
||||
shape?: 'default' | 'theme' | null | undefined;
|
||||
} & ClassProp)
|
||||
| undefined;
|
||||
|
|
@ -36,6 +37,7 @@ const buttonVariantRecipe = cva(
|
|||
'border border-border-light bg-transparent text-text-primary hover:bg-surface-secondary focus-visible:ring-text-primary focus-visible:ring-offset-0',
|
||||
secondary: 'bg-surface-secondary text-text-primary hover:bg-surface-hover',
|
||||
ghost: 'hover:bg-surface-hover hover:text-text-primary',
|
||||
'row-action': 'hover:bg-surface-hover-alt hover:text-text-primary',
|
||||
link: 'text-text-primary underline-offset-4 hover:underline',
|
||||
submit: 'bg-surface-submit text-text-on-status hover:bg-surface-submit-hover',
|
||||
},
|
||||
|
|
@ -44,6 +46,7 @@ const buttonVariantRecipe = cva(
|
|||
sm: 'h-9 rounded-lg px-3',
|
||||
lg: 'h-11 rounded-lg px-8',
|
||||
icon: 'size-10',
|
||||
'icon-sm': 'size-8 p-0',
|
||||
theme: 'h-theme-control gap-theme-compact px-theme-normal',
|
||||
},
|
||||
shape: {
|
||||
|
|
|
|||
|
|
@ -30,8 +30,9 @@ jest.mock('~/hooks', () => ({
|
|||
useMediaQuery: jest.fn(() => false),
|
||||
}));
|
||||
|
||||
// jsdom can't measure layout, so @tanstack/react-virtual's measurement-driven re-render loop
|
||||
// never converges (infinite "Too many re-renders"). Stub it to render every row deterministically.
|
||||
// jsdom reports a zero-height scroll container, so the real virtualizer resolves an empty
|
||||
// range and no row assertion below would find its row. Stub it to render every row
|
||||
// deterministically; DataTable.virtualization.spec.tsx covers the real virtualizer.
|
||||
jest.mock('@tanstack/react-virtual', () => ({
|
||||
useVirtualizer: ({
|
||||
count,
|
||||
|
|
@ -77,6 +78,12 @@ jest.mock('lucide-react', () => ({
|
|||
ArrowDownUp: ({ className }: { className?: string }) => (
|
||||
<span data-testid="arrow-down-up" className={className} />
|
||||
),
|
||||
Inbox: ({ className }: { className?: string }) => (
|
||||
<span data-testid="inbox-icon" className={className} />
|
||||
),
|
||||
SearchX: ({ className }: { className?: string }) => (
|
||||
<span data-testid="search-x-icon" className={className} />
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock Table components
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useRef, useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { JSX } from 'react/jsx-runtime';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { ArrowUp, ArrowDown, ArrowDownUp } from 'lucide-react';
|
||||
import { ArrowUp, ArrowDown, ArrowDownUp, Inbox, SearchX } from 'lucide-react';
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
|
|
@ -62,7 +62,7 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
|
|||
virtualization: {
|
||||
overscan = 10,
|
||||
minRows = 50,
|
||||
rowHeight = 56,
|
||||
rowHeight = 40,
|
||||
fastOverscanMultiplier = 4,
|
||||
} = {},
|
||||
} = config || {};
|
||||
|
|
@ -290,12 +290,22 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
|
|||
onRowSelectionChange: setOptimizedRowSelection,
|
||||
});
|
||||
|
||||
/* The virtualizer rebuilds its measurement options whenever one of them changes
|
||||
identity, and that notify re-renders this component. Both options below are
|
||||
read during render, so defining them inline would notify on every render and
|
||||
loop until React aborts with "Too many re-renders". */
|
||||
const getItemKey = useCallback(
|
||||
(index: number) => getRowId(data[index] as TData, index),
|
||||
[data, getRowId],
|
||||
);
|
||||
const estimateSize = useCallback(() => rowHeight, [rowHeight]);
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
enabled: virtualizationActive,
|
||||
count: data.length,
|
||||
getScrollElement: () => tableContainerRef.current,
|
||||
getItemKey: (index) => getRowId(data[index] as TData, index),
|
||||
estimateSize: useCallback(() => rowHeight, [rowHeight]),
|
||||
getItemKey,
|
||||
estimateSize,
|
||||
overscan: dynamicOverscan,
|
||||
});
|
||||
|
||||
|
|
@ -312,6 +322,7 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
|
|||
|
||||
const showSkeletons = isLoading || (isFetching && !isFetchingNextPage);
|
||||
const shouldShowSearch = enableSearch && onFilterChange;
|
||||
const showToolbar = Boolean(shouldShowSearch || customActionsRenderer);
|
||||
|
||||
// Render table body based on loading state and virtualization
|
||||
let tableBodyContent: React.ReactNode;
|
||||
|
|
@ -319,6 +330,7 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
|
|||
tableBodyContent = (
|
||||
<SkeletonRows
|
||||
count={skeletonCount}
|
||||
rowHeight={rowHeight}
|
||||
columns={tableColumns as ColumnDef<Record<string, unknown>>[]}
|
||||
/>
|
||||
);
|
||||
|
|
@ -548,26 +560,27 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
|
|||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex w-full flex-col overflow-hidden rounded-lg border border-border-light bg-surface-primary',
|
||||
'h-[calc(100vh-8rem)] max-h-[80vh]',
|
||||
className,
|
||||
)}
|
||||
/* Transparent so the rows read as a list on whatever surface hosts them, and
|
||||
the height follows the rows up to the cap so a short list doesn't leave a
|
||||
tall empty box below it. */
|
||||
className={cn('relative flex w-full flex-col overflow-hidden', 'max-h-[80vh]', className)}
|
||||
role="region"
|
||||
aria-label={localize('com_ui_data_table')}
|
||||
>
|
||||
<div className="flex w-full shrink-0 items-center gap-2 border-b border-border-light md:gap-3">
|
||||
{shouldShowSearch && <DataTableSearch value={searchTerm} onChange={setSearchTerm} />}
|
||||
{customActionsRenderer &&
|
||||
customActionsRenderer({
|
||||
selectedCount,
|
||||
selectedRows,
|
||||
table: table as unknown as TTable<ProcessedDataRow<TData>>,
|
||||
})}
|
||||
</div>
|
||||
{showToolbar && (
|
||||
<div className="flex w-full shrink-0 items-center gap-2 border-b border-border-light pr-2 md:gap-3">
|
||||
{shouldShowSearch && <DataTableSearch value={searchTerm} onChange={setSearchTerm} />}
|
||||
{customActionsRenderer &&
|
||||
customActionsRenderer({
|
||||
selectedCount,
|
||||
selectedRows,
|
||||
table: table as unknown as TTable<ProcessedDataRow<TData>>,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={tableContainerRef}
|
||||
className="overflow-anchor-none relative min-h-0 flex-1 overflow-auto will-change-scroll"
|
||||
className="overflow-anchor-none relative flex min-h-0 flex-1 flex-col overflow-auto will-change-scroll"
|
||||
style={
|
||||
{
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
|
|
@ -582,12 +595,14 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
|
|||
role="table"
|
||||
aria-label={localize('com_ui_data_table')}
|
||||
aria-rowcount={data.length}
|
||||
className="table-auto"
|
||||
/* Separated borders let the row cells carry a rounded hover highlight;
|
||||
collapsed borders drop `border-radius` on table cells entirely. */
|
||||
className="shrink-0 table-auto border-separate border-spacing-0"
|
||||
unwrapped={true}
|
||||
>
|
||||
<TableHeader className="sticky top-0 z-10 bg-surface-secondary">
|
||||
<TableHeader>
|
||||
{headerGroups.map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
<TableRow key={headerGroup.id} className="border-0 hover:bg-transparent">
|
||||
{headerGroup.headers.map((header) => {
|
||||
const isDesktopOnly =
|
||||
(header.column.columnDef.meta as { desktopOnly?: boolean } | undefined)
|
||||
|
|
@ -635,22 +650,28 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
|
|||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-auto w-full justify-start gap-1 px-0 py-0 font-medium hover:bg-transparent md:gap-2"
|
||||
className="group h-auto w-full justify-start gap-1 px-0 py-0 text-xs font-medium uppercase tracking-wide text-text-secondary hover:bg-transparent hover:text-text-primary md:gap-1.5"
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
>
|
||||
{renderedHeader}
|
||||
<span className="text-text-primary" aria-hidden="true">
|
||||
<span aria-hidden="true">
|
||||
{{
|
||||
asc: <ArrowUp className="size-4 text-text-primary" />,
|
||||
desc: <ArrowDown className="size-4 text-text-primary" />,
|
||||
asc: <ArrowUp className="size-3.5" />,
|
||||
desc: <ArrowDown className="size-3.5" />,
|
||||
}[header.column.getIsSorted() as string] ?? (
|
||||
<ArrowDownUp className="size-4 text-text-primary" />
|
||||
/* The neutral marker is noise on every unsorted column, so it
|
||||
only surfaces once the header is a pointer or keyboard target. */
|
||||
<ArrowDownUp className="size-3.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100" />
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
} else {
|
||||
headerContent = <div className="flex items-center">{renderedHeader}</div>;
|
||||
headerContent = (
|
||||
<div className="flex items-center text-xs font-medium uppercase tracking-wide text-text-secondary">
|
||||
{renderedHeader}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -658,9 +679,12 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
|
|||
key={header.id}
|
||||
scope="col"
|
||||
className={cn(
|
||||
'border-b border-border-light px-2 py-2 md:px-3 md:py-2',
|
||||
/* Stuck per cell rather than on <thead>, which does not stay
|
||||
put once the table uses separated borders. The fill has to
|
||||
be opaque or virtualized rows show through it. */
|
||||
'sticky top-0 z-10 h-9 border-b border-border-light bg-surface-dialog px-3 py-2 md:px-4',
|
||||
isSelectHeader && 'px-0 text-center',
|
||||
canSort && 'cursor-pointer hover:bg-surface-tertiary',
|
||||
canSort && 'cursor-pointer',
|
||||
meta?.className,
|
||||
header.column.getIsResizing() && 'bg-surface-tertiary/60',
|
||||
isDesktopOnly && 'hidden md:table-cell',
|
||||
|
|
@ -699,11 +723,18 @@ function DataTable<TData extends Record<string, unknown>, TValue>({
|
|||
|
||||
{!isLoading && !showSkeletons && rows.length === 0 && (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center py-12"
|
||||
className="flex flex-1 flex-col items-center justify-center gap-3 px-6 py-12"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Label className="text-center text-text-secondary">
|
||||
<span className="flex size-11 items-center justify-center rounded-full bg-surface-tertiary text-text-tertiary">
|
||||
{searchTerm ? (
|
||||
<SearchX className="size-5" aria-hidden="true" />
|
||||
) : (
|
||||
<Inbox className="size-5" aria-hidden="true" />
|
||||
)}
|
||||
</span>
|
||||
<Label className="text-center text-sm text-text-secondary">
|
||||
{searchTerm ? localize('com_ui_no_search_results') : localize('com_ui_no_data')}
|
||||
</Label>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
import React from 'react';
|
||||
import { Provider as JotaiProvider } from 'jotai';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import type { TableColumn } from './DataTable.types';
|
||||
import DataTable from './DataTable';
|
||||
|
||||
jest.mock('~/utils', () => ({
|
||||
cn: (...classes: (string | undefined | boolean)[]) => classes.filter(Boolean).join(' '),
|
||||
logger: {
|
||||
log: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => (key: string) => key,
|
||||
useMediaQuery: jest.fn(() => false),
|
||||
}));
|
||||
|
||||
interface TestRow extends Record<string, unknown> {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const columns: TableColumn<TestRow, unknown>[] = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Name',
|
||||
cell: ({ row }) => <span>{row.original.name}</span>,
|
||||
meta: { width: 100, isRowHeader: true },
|
||||
},
|
||||
];
|
||||
|
||||
/** `minRows` defaults to 50, so this is the smallest set that turns virtualization on. */
|
||||
const makeRows = (count: number): TestRow[] =>
|
||||
Array.from({ length: count }, (_, index) => ({ id: `row-${index}`, name: `Row ${index}` }));
|
||||
|
||||
const renderTable = (data: TestRow[]) =>
|
||||
render(
|
||||
<JotaiProvider>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
getRowId={(row) => row.id}
|
||||
config={{ selection: { enableRowSelection: false, showCheckboxes: false } }}
|
||||
/>
|
||||
</JotaiProvider>,
|
||||
);
|
||||
|
||||
/**
|
||||
* Exercises the real @tanstack/react-virtual rather than a stub: the virtualizer
|
||||
* notifies (and therefore re-renders) whenever its measurement options change
|
||||
* identity, so an option rebuilt on every render loops until React gives up.
|
||||
*/
|
||||
describe('DataTable virtualization', () => {
|
||||
it('renders a virtualized table without looping on re-renders', () => {
|
||||
expect(() => renderTable(makeRows(60))).not.toThrow();
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('survives a re-render with the same data', () => {
|
||||
const data = makeRows(60);
|
||||
const { rerender } = renderTable(data);
|
||||
|
||||
expect(() =>
|
||||
rerender(
|
||||
<JotaiProvider>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
getRowId={(row) => row.id}
|
||||
config={{ selection: { enableRowSelection: false, showCheckboxes: false } }}
|
||||
/>
|
||||
</JotaiProvider>,
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('stays below the virtualization threshold without looping', () => {
|
||||
expect(() => renderTable(makeRows(10))).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
|
@ -57,7 +57,10 @@ const TableRowComponent = <TData extends Record<string, unknown>>(
|
|||
ref={ref}
|
||||
data-state={selected ? 'selected' : undefined}
|
||||
data-index={virtualIndex}
|
||||
className="border-none hover:bg-surface-secondary"
|
||||
/* The highlight is painted by the cells, not the row: `border-radius` has no
|
||||
effect on a table row, so rounding the outer cells is what gives the hover
|
||||
its pill shape. */
|
||||
className="group border-0 hover:bg-transparent [&>*:first-child]:rounded-l-lg [&>*:last-child]:rounded-r-lg"
|
||||
style={style}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
|
|
@ -84,7 +87,8 @@ const TableRowComponent = <TData extends Record<string, unknown>>(
|
|||
<CellComponent
|
||||
key={cell.id}
|
||||
className={cn(
|
||||
'max-w-0 truncate px-2 py-2 md:px-3 md:py-3',
|
||||
'max-w-0 truncate px-3 py-1 text-sm transition-colors',
|
||||
'group-hover:bg-surface-secondary-alt group-data-[state=selected]:bg-surface-active',
|
||||
cell.column.id === 'select' && 'w-8 p-1',
|
||||
meta?.className,
|
||||
isDesktopOnly && 'hidden md:table-cell',
|
||||
|
|
@ -130,21 +134,29 @@ export const SkeletonRows: React.MemoExoticComponent<
|
|||
<TData extends Record<string, unknown>, TValue>({
|
||||
count,
|
||||
columns,
|
||||
rowHeight,
|
||||
}: {
|
||||
count?: number;
|
||||
columns: TableColumn<TData, TValue>[];
|
||||
rowHeight?: number;
|
||||
}) => JSX.Element
|
||||
> = memo(
|
||||
<TData extends Record<string, unknown>, TValue>({
|
||||
count = 10,
|
||||
columns,
|
||||
rowHeight = 40,
|
||||
}: {
|
||||
count?: number;
|
||||
columns: TableColumn<TData, TValue>[];
|
||||
rowHeight?: number;
|
||||
}): JSX.Element => (
|
||||
<>
|
||||
{Array.from({ length: count }, (_, index) => (
|
||||
<TableRow key={`skeleton-${index}`} className="h-[56px] border-b border-border-light">
|
||||
<TableRow
|
||||
key={`skeleton-${index}`}
|
||||
className="border-0 hover:bg-transparent"
|
||||
style={{ height: rowHeight }}
|
||||
>
|
||||
{columns.map((column) => {
|
||||
const columnKey = String(
|
||||
column.id ?? ('accessorKey' in column && column.accessorKey) ?? '',
|
||||
|
|
@ -154,7 +166,7 @@ export const SkeletonRows: React.MemoExoticComponent<
|
|||
<TableCell
|
||||
key={columnKey}
|
||||
className={cn(
|
||||
'px-2 py-2 md:px-3',
|
||||
'px-3 py-1',
|
||||
meta?.className,
|
||||
meta?.desktopOnly && 'hidden md:table-cell',
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -126,8 +126,12 @@ describe('DataTableSearch', () => {
|
|||
render(<DataTableSearch value="" onChange={jest.fn()} />);
|
||||
|
||||
const input = screen.getByTestId('search-input');
|
||||
expect(input.className).toContain('h-10');
|
||||
expect(input.className).toContain('bg-surface-secondary');
|
||||
expect(input.className).toContain('h-11');
|
||||
/* The field sits flush inside the table's toolbar, so it carries no chrome of
|
||||
its own and leaves room for the leading search icon. */
|
||||
expect(input.className).toContain('bg-transparent');
|
||||
expect(input.className).toContain('border-0');
|
||||
expect(input.className).toContain('pl-9');
|
||||
});
|
||||
|
||||
it('should have correct id for label association', () => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { memo, startTransition, useId, type MemoExoticComponent } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import { JSX } from 'react/jsx-runtime';
|
||||
import type { DataTableSearchProps } from './DataTable.types';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
|
@ -24,6 +25,10 @@ export const DataTableSearch: MemoExoticComponent<
|
|||
<label htmlFor={searchId} className="sr-only">
|
||||
{localize('com_ui_search_table')}
|
||||
</label>
|
||||
<Search
|
||||
className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-text-tertiary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Input
|
||||
id={searchId}
|
||||
value={value}
|
||||
|
|
@ -34,7 +39,10 @@ export const DataTableSearch: MemoExoticComponent<
|
|||
aria-label={localize('com_ui_search_table')}
|
||||
aria-describedby={descriptionId}
|
||||
placeholder={placeholder || localize('com_ui_search')}
|
||||
className={cn('h-10 rounded-b-none border-0 bg-surface-secondary md:h-12', className)}
|
||||
className={cn(
|
||||
'h-11 rounded-none border-0 bg-transparent pl-9 text-sm placeholder:text-text-tertiary focus-visible:ring-inset',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
<span id={descriptionId} className="sr-only">
|
||||
{localize('com_ui_search_table_description')}
|
||||
|
|
|
|||
|
|
@ -42,8 +42,7 @@ describe('i18next translation tests', () => {
|
|||
|
||||
it('should correctly format placeholders in the translation', () => {
|
||||
i18n.changeLanguage('en');
|
||||
// The translation uses {count} syntax (not standard i18next {{count}})
|
||||
// Verify i18next returns the template string with the placeholder
|
||||
expect(i18n.t('com_ui_selected_count', { count: 5 })).toBe('{count} selected');
|
||||
// The key uses standard i18next {{count}} interpolation, so t() substitutes the value
|
||||
expect(i18n.t('com_ui_selected_count', { count: 5 })).toBe('5 selected');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
"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_selected_count": "{{count}} selected",
|
||||
"com_ui_data_table": "Data Table",
|
||||
"com_ui_no_data": "No data",
|
||||
"com_ui_delete_selected": "Delete Selected",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue