🏷️ chore: Add Missing Localizations for Agents, Categories, Bookmarks (#9266)

* fix: error when updating bookmarks if no query data

* feat: localize bookmark dialog, form labels and validation messages, also improve validation

* feat: add localization for EmptyPromptPreview component and update translation.json

* chore: add missing localizations for static UI text

* chore: update AgentPanelContextType and useGetAgentsConfig to support null configurations

* refactor: update agent categories to support localization and custom properties, improve related typing

* ci: add localization for 'All' category and update tab names in accessibility tests

* chore: remove unused AgentCategoryDisplay component and its tests

* chore: add localization handling for agent category selector

* chore: enhance AgentCard to support localized category labels and add related tests

* chore: enhance i18n unused keys detection to include additional source directories and improve handling for agent category keys
This commit is contained in:
Danny Avila 2025-08-25 13:54:13 -04:00 committed by GitHub
parent 94426a3cae
commit bbfe4002eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 328 additions and 252 deletions

View file

@ -1,8 +1,8 @@
import React from 'react';
import React, { useMemo } from 'react';
import { Label } from '@librechat/client';
import type t from 'librechat-data-provider';
import { useLocalize, TranslationKeys, useAgentCategories } from '~/hooks';
import { cn, renderAgentAvatar, getContactDisplayName } from '~/utils';
import { useLocalize } from '~/hooks';
interface AgentCardProps {
agent: t.Agent; // The agent data to display
@ -15,6 +15,21 @@ interface AgentCardProps {
*/
const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' }) => {
const localize = useLocalize();
const { categories } = useAgentCategories();
const categoryLabel = useMemo(() => {
if (!agent.category) return '';
const category = categories.find((cat) => cat.value === agent.category);
if (category) {
if (category.label && category.label.startsWith('com_')) {
return localize(category.label as TranslationKeys);
}
return category.label;
}
return agent.category.charAt(0).toUpperCase() + agent.category.slice(1);
}, [agent.category, categories, localize]);
return (
<div
@ -49,9 +64,7 @@ const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' })
{/* Category tag */}
{agent.category && (
<div className="inline-flex items-center rounded-md border-border-xheavy bg-surface-active-alt px-2 py-1 text-xs font-medium">
<Label className="line-clamp-1 font-normal">
{agent.category.charAt(0).toUpperCase() + agent.category.slice(1)}
</Label>
<Label className="line-clamp-1 font-normal">{categoryLabel}</Label>
</div>
)}
</div>

View file

@ -1,62 +0,0 @@
import React from 'react';
import { useAgentCategories } from '~/hooks/Agents';
import { cn } from '~/utils';
interface AgentCategoryDisplayProps {
category?: string;
className?: string;
showIcon?: boolean;
iconClassName?: string;
showEmptyFallback?: boolean;
}
/**
* Component to display an agent category with proper translation
*
* @param category - The category value (e.g., "general", "hr", etc.)
* @param className - Optional className for the container
* @param showIcon - Whether to show the category icon
* @param iconClassName - Optional className for the icon
* @param showEmptyFallback - Whether to show a fallback for empty categories
*/
const AgentCategoryDisplay: React.FC<AgentCategoryDisplayProps> = ({
category,
className = '',
showIcon = true,
iconClassName = 'h-4 w-4 mr-2',
showEmptyFallback = false,
}) => {
const { categories, emptyCategory } = useAgentCategories();
// Find the category in our processed categories list
const categoryItem = categories.find((c) => c.value === category);
// Handle empty string case differently than undefined/null
if (category === '') {
if (!showEmptyFallback) {
return null;
}
// Show the empty category placeholder
return (
<div className={cn('flex items-center text-gray-400', className)}>
<span>{emptyCategory.label}</span>
</div>
);
}
// No category or unknown category
if (!category || !categoryItem) {
return null;
}
return (
<div className={cn('flex items-center', className)}>
{showIcon && categoryItem.icon && (
<span className={cn('flex-shrink-0', iconClassName)}>{categoryItem.icon}</span>
)}
<span>{categoryItem.label}</span>
</div>
);
};
export default AgentCategoryDisplay;

View file

@ -1,8 +1,8 @@
import React from 'react';
import type t from 'librechat-data-provider';
import { useMediaQuery } from '@librechat/client';
import type t from 'librechat-data-provider';
import { useLocalize, TranslationKeys } from '~/hooks';
import { SmartLoader } from './SmartLoader';
import { useLocalize } from '~/hooks/';
import { cn } from '~/utils';
/**
@ -36,14 +36,17 @@ const CategoryTabs: React.FC<CategoryTabsProps> = ({
const localize = useLocalize();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
// Helper function to get category display name from database data
/** Helper function to get category display name from database data */
const getCategoryDisplayName = (category: t.TCategory) => {
// Special cases for system categories
if (category.value === 'promoted') {
return localize('com_agents_top_picks');
}
if (category.value === 'all') {
return 'All';
return localize('com_agents_all_category');
}
if (category.label && category.label.startsWith('com_')) {
return localize(category.label as TranslationKeys);
}
// Use database label or fallback to capitalized value
return category.label || category.value.charAt(0).toUpperCase() + category.value.slice(1);
@ -158,7 +161,11 @@ const CategoryTabs: React.FC<CategoryTabsProps> = ({
aria-selected={activeTab === category.value}
aria-controls={`tabpanel-${category.value}`}
tabIndex={activeTab === category.value ? 0 : -1}
aria-label={`${getCategoryDisplayName(category)} tab (${index + 1} of ${categories.length})`}
aria-label={localize('com_agents_category_tab_label', {
category: getCategoryDisplayName(category),
position: index + 1,
total: categories.length,
})}
>
{getCategoryDisplayName(category)}
{/* Underline for active tab */}

View file

@ -7,8 +7,8 @@ import { TooltipAnchor, Button, NewChatIcon, useMediaQuery } from '@librechat/cl
import { PermissionTypes, Permissions, QueryKeys, Constants } from 'librechat-data-provider';
import type t from 'librechat-data-provider';
import type { ContextType } from '~/common';
import { useDocumentTitle, useHasAccess, useLocalize, TranslationKeys } from '~/hooks';
import { useGetEndpointsQuery, useGetAgentCategoriesQuery } from '~/data-provider';
import { useDocumentTitle, useHasAccess, useLocalize } from '~/hooks';
import MarketplaceAdminSettings from './MarketplaceAdminSettings';
import { SidePanelProvider, useChatContext } from '~/Providers';
import { MarketplaceProvider } from './MarketplaceContext';
@ -381,8 +381,8 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
}
if (displayCategory === 'all') {
return {
name: 'All Agents',
description: 'Browse all shared agents across all categories',
name: localize('com_agents_all'),
description: localize('com_agents_all_description'),
};
}
@ -392,8 +392,12 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
);
if (categoryData) {
return {
name: categoryData.label,
description: categoryData.description || '',
name: categoryData.label?.startsWith('com_')
? localize(categoryData.label as TranslationKeys)
: categoryData.label,
description: categoryData.description?.startsWith('com_')
? localize(categoryData.description as TranslationKeys)
: categoryData.description || '',
};
}
@ -455,8 +459,8 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
}
if (nextCategory === 'all') {
return {
name: 'All Agents',
description: 'Browse all shared agents across all categories',
name: localize('com_agents_all'),
description: localize('com_agents_all_description'),
};
}
@ -466,8 +470,16 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
);
if (categoryData) {
return {
name: categoryData.label,
description: categoryData.description || '',
name: categoryData.label?.startsWith('com_')
? localize(categoryData.label as TranslationKeys)
: categoryData.label,
description: categoryData.description?.startsWith('com_')
? localize(
categoryData.description as Parameters<
typeof localize
>[0],
)
: categoryData.description || '',
};
}

View file

@ -61,6 +61,7 @@ const mockLocalize = jest.fn((key: string, options?: any) => {
com_agents_search_empty_heading: 'No search results',
com_agents_created_by: 'by',
com_agents_top_picks: 'Top Picks',
com_agents_all_category: 'All',
// ErrorDisplay translations
com_agents_error_suggestion_generic: 'Try refreshing the page or check your network connection',
com_agents_error_network_title: 'Network Error',
@ -199,7 +200,7 @@ describe('Accessibility Improvements', () => {
/>,
);
const promotedTab = screen.getByRole('tab', { name: /Top Picks tab/ });
const promotedTab = screen.getByRole('tab', { name: /Top Picks category/ });
// Test arrow key navigation
fireEvent.keyDown(promotedTab, { key: 'ArrowRight' });
@ -226,8 +227,8 @@ describe('Accessibility Improvements', () => {
/>,
);
const promotedTab = screen.getByRole('tab', { name: /Top Picks tab/ });
const allTab = screen.getByRole('tab', { name: /All tab/ });
const promotedTab = screen.getByRole('tab', { name: /Top Picks category/ });
const allTab = screen.getByRole('tab', { name: /All category/ });
// Active tab should be focusable
expect(promotedTab).toHaveAttribute('tabIndex', '0');

View file

@ -8,10 +8,42 @@ import type t from 'librechat-data-provider';
jest.mock('~/hooks/useLocalize', () => () => (key: string) => {
const mockTranslations: Record<string, string> = {
com_agents_created_by: 'Created by',
com_agents_agent_card_label: '{{name}} agent. {{description}}',
com_agents_category_general: 'General',
com_agents_category_hr: 'Human Resources',
};
return mockTranslations[key] || key;
});
// Mock useAgentCategories hook
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string, values?: Record<string, string>) => {
const mockTranslations: Record<string, string> = {
com_agents_created_by: 'Created by',
com_agents_agent_card_label: '{{name}} agent. {{description}}',
com_agents_category_general: 'General',
com_agents_category_hr: 'Human Resources',
};
let translation = mockTranslations[key] || key;
// Replace placeholders with actual values
if (values) {
Object.entries(values).forEach(([placeholder, value]) => {
translation = translation.replace(new RegExp(`{{${placeholder}}}`, 'g'), value);
});
}
return translation;
},
useAgentCategories: () => ({
categories: [
{ value: 'general', label: 'com_agents_category_general' },
{ value: 'hr', label: 'com_agents_category_hr' },
{ value: 'custom', label: 'Custom Category' }, // Non-localized custom category
],
}),
}));
describe('AgentCard', () => {
const mockAgent: t.Agent = {
id: '1',
@ -200,6 +232,49 @@ describe('AgentCard', () => {
const card = screen.getByRole('button');
expect(card).toHaveAttribute('tabIndex', '0');
expect(card).toHaveAttribute('aria-label', 'com_agents_agent_card_label');
expect(card).toHaveAttribute(
'aria-label',
'Test Agent agent. A test agent for testing purposes',
);
});
it('displays localized category label', () => {
const agentWithCategory = {
...mockAgent,
category: 'general',
};
render(<AgentCard agent={agentWithCategory} onClick={mockOnClick} />);
expect(screen.getByText('General')).toBeInTheDocument();
});
it('displays custom category label', () => {
const agentWithCustomCategory = {
...mockAgent,
category: 'custom',
};
render(<AgentCard agent={agentWithCustomCategory} onClick={mockOnClick} />);
expect(screen.getByText('Custom Category')).toBeInTheDocument();
});
it('displays capitalized fallback for unknown category', () => {
const agentWithUnknownCategory = {
...mockAgent,
category: 'unknown',
};
render(<AgentCard agent={agentWithUnknownCategory} onClick={mockOnClick} />);
expect(screen.getByText('Unknown')).toBeInTheDocument();
});
it('does not display category tag when category is not provided', () => {
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
expect(screen.queryByText('General')).not.toBeInTheDocument();
expect(screen.queryByText('Unknown')).not.toBeInTheDocument();
});
});

View file

@ -1,90 +0,0 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import AgentCategoryDisplay from '../AgentCategoryDisplay';
// Mock the useAgentCategories hook
jest.mock('~/hooks/Agents', () => ({
useAgentCategories: () => ({
categories: [
{
value: 'general',
label: 'General',
icon: <span data-testid="icon-general">{''}</span>,
className: 'w-full',
},
{
value: 'hr',
label: 'HR',
icon: <span data-testid="icon-hr">{''}</span>,
className: 'w-full',
},
{
value: 'rd',
label: 'R&D',
icon: <span data-testid="icon-rd">{''}</span>,
className: 'w-full',
},
{
value: 'finance',
label: 'Finance',
icon: <span data-testid="icon-finance">{''}</span>,
className: 'w-full',
},
],
emptyCategory: {
value: '',
label: 'General',
className: 'w-full',
},
}),
}));
describe('AgentCategoryDisplay', () => {
it('should display the proper label for a category', () => {
render(<AgentCategoryDisplay category="rd" />);
expect(screen.getByText('R&D')).toBeInTheDocument();
});
it('should display the icon when showIcon is true', () => {
render(<AgentCategoryDisplay category="finance" showIcon={true} />);
expect(screen.getByTestId('icon-finance')).toBeInTheDocument();
expect(screen.getByText('Finance')).toBeInTheDocument();
});
it('should not display the icon when showIcon is false', () => {
render(<AgentCategoryDisplay category="hr" showIcon={false} />);
expect(screen.queryByTestId('icon-hr')).not.toBeInTheDocument();
expect(screen.getByText('HR')).toBeInTheDocument();
});
it('should apply custom classnames', () => {
render(<AgentCategoryDisplay category="general" className="test-class" />);
expect(screen.getByText('General').parentElement).toHaveClass('test-class');
});
it('should not render anything for unknown categories', () => {
const { container } = render(<AgentCategoryDisplay category="unknown" />);
expect(container).toBeEmptyDOMElement();
});
it('should not render anything when no category is provided', () => {
const { container } = render(<AgentCategoryDisplay />);
expect(container).toBeEmptyDOMElement();
});
it('should not render anything for empty category when showEmptyFallback is false', () => {
const { container } = render(<AgentCategoryDisplay category="" />);
expect(container).toBeEmptyDOMElement();
});
it('should render empty category placeholder when showEmptyFallback is true', () => {
render(<AgentCategoryDisplay category="" showEmptyFallback={true} />);
expect(screen.getByText('General')).toBeInTheDocument();
});
it('should apply custom iconClassName to the icon', () => {
render(<AgentCategoryDisplay category="general" iconClassName="custom-icon-class" />);
const iconElement = screen.getByTestId('icon-general').parentElement;
expect(iconElement).toHaveClass('custom-icon-class');
});
});

View file

@ -9,7 +9,8 @@ import type t from 'librechat-data-provider';
jest.mock('~/hooks/useLocalize', () => () => (key: string) => {
const mockTranslations: Record<string, string> = {
com_agents_top_picks: 'Top Picks',
com_agents_all: 'All',
com_agents_all: 'All Agents',
com_agents_all_category: 'All',
com_ui_no_categories: 'No categories available',
com_agents_category_tabs_label: 'Agent Categories',
com_ui_agent_category_general: 'General',

View file

@ -89,7 +89,7 @@ const BookmarkEditDialog = ({
<OGDialog open={open} onOpenChange={setOpen} triggerRef={triggerRef}>
{children}
<OGDialogTemplate
title="Bookmark"
title={bookmark ? localize('com_ui_bookmarks_edit') : localize('com_ui_bookmarks_new')}
showCloseButton={false}
className="w-11/12 md:max-w-2xl"
main={

View file

@ -38,6 +38,8 @@ const BookmarkForm = ({
control,
formState: { errors },
} = useForm<TConversationTagRequest>({
mode: 'onBlur',
reValidateMode: 'onChange',
defaultValues: {
tag: bookmark?.tag ?? '',
description: bookmark?.description ?? '',
@ -98,23 +100,30 @@ const BookmarkForm = ({
<Input
type="text"
id="bookmark-tag"
aria-label="Bookmark"
aria-label={
bookmark ? localize('com_ui_bookmarks_edit') : localize('com_ui_bookmarks_new')
}
{...register('tag', {
required: 'tag is required',
required: localize('com_ui_field_required'),
maxLength: {
value: 128,
message: localize('com_auth_password_max_length'),
message: localize('com_ui_field_max_length', {
field: localize('com_ui_bookmarks_title'),
length: 128,
}),
},
validate: (value) => {
return (
value === bookmark?.tag ||
bookmarks.every((bookmark) => bookmark.tag !== value) ||
'tag must be unique'
localize('com_ui_bookmarks_tag_exists')
);
},
})}
aria-invalid={!!errors.tag}
placeholder="Bookmark"
placeholder={
bookmark ? localize('com_ui_bookmarks_edit') : localize('com_ui_bookmarks_new')
}
/>
{errors.tag && <span className="text-sm text-red-500">{errors.tag.message}</span>}
</div>
@ -127,7 +136,10 @@ const BookmarkForm = ({
{...register('description', {
maxLength: {
value: 1048,
message: 'Maximum 1048 characters',
message: localize('com_ui_field_max_length', {
field: localize('com_ui_bookmarks_description'),
length: 1048,
}),
},
})}
id="bookmark-description"

View file

@ -1,9 +1,12 @@
import React from 'react';
import { useLocalize } from '~/hooks';
export default function EmptyPromptPreview() {
const localize = useLocalize();
return (
<div className="h-full w-full content-center text-center font-bold dark:text-gray-200">
Select or Create a Prompt
<div className="h-full w-full content-center text-center font-bold text-text-secondary">
{localize('com_ui_select_or_create_prompt')}
</div>
);
}

View file

@ -1,5 +1,4 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ControlCombobox } from '@librechat/client';
import {
useWatch,
@ -9,7 +8,7 @@ import {
useFormContext,
ControllerRenderProps,
} from 'react-hook-form';
import { useAgentCategories } from '~/hooks/Agents';
import { TranslationKeys, useLocalize, useAgentCategories } from '~/hooks';
import { cn } from '~/utils';
/**
@ -35,22 +34,25 @@ const useCategorySync = (agent_id: string | null) => {
* A component for selecting agent categories with form validation
*/
const AgentCategorySelector: React.FC<{ className?: string }> = ({ className }) => {
const { t } = useTranslation();
const localize = useLocalize();
const formContext = useFormContext();
const { categories } = useAgentCategories();
// Always call useWatch
const agent_id = useWatch({
name: 'id',
control: formContext.control,
});
// Use custom hook for category sync
const { syncCategory } = useCategorySync(agent_id);
const getCategoryLabel = (category: { label: string; value: string }) => {
if (category.label && category.label.startsWith('com_')) {
return localize(category.label as TranslationKeys);
}
return category.label;
};
// Transform categories to the format expected by ControlCombobox
const comboboxItems = categories.map((category) => ({
label: category.label,
label: getCategoryLabel(category),
value: category.value,
}));
@ -59,8 +61,8 @@ const AgentCategorySelector: React.FC<{ className?: string }> = ({ className })
return categoryItem?.label || comboboxItems.find((c) => c.value === 'general')?.label;
};
const searchPlaceholder = t('com_ui_search_agent_category', 'Search categories...');
const ariaLabel = t('com_ui_agent_category_selector_aria', "Agent's category selector");
const searchPlaceholder = localize('com_ui_search_agent_category');
const ariaLabel = localize('com_ui_agent_category_selector_aria');
return (
<Controller