mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-30 23:28:52 +01:00
🔧 refactor: Organize Sharing/Agent Components and Improve Type Safety
refactor: organize Sharing/Agent components, improve type safety for resource types and access role ids, rename enums to PascalCase refactor: organize Sharing/Agent components, improve type safety for resource types and access role ids chore: move sharing related components to dedicated "Sharing" directory chore: remove PublicSharingToggle component and update index exports chore: move non-sidepanel agent components to `~/components/Agents` chore: move AgentCategoryDisplay component with tests chore: remove commented out code refactor: change PERMISSION_BITS from const to enum for better type safety refactor: reorganize imports in GenericGrantAccessDialog and update index exports for hooks refactor: update type definitions to use ACCESS_ROLE_IDS for improved type safety refactor: remove unused canAccessPromptResource middleware and related code refactor: remove unused prompt access roles from createAccessRoleMethods refactor: update resourceType in AclEntry type definition to remove unused 'prompt' value refactor: introduce ResourceType enum and update resourceType usage across data provider files for improved type safety refactor: update resourceType usage to ResourceType enum across sharing and permissions components for improved type safety refactor: standardize resourceType usage to ResourceType enum across agent and prompt models, permissions controller, and middleware for enhanced type safety refactor: update resourceType references from PROMPT_GROUP to PROMPTGROUP for consistency across models, middleware, and components refactor: standardize access role IDs and resource type usage across agent, file, and prompt models for improved type safety and consistency chore: add typedefs for TUpdateResourcePermissionsRequest and TUpdateResourcePermissionsResponse to enhance type definitions chore: move SearchPicker to PeoplePicker dir refactor: implement debouncing for query changes in SearchPicker for improved performance chore: fix typing, import order for agent admin settings fix: agent admin settings, prevent agent form submission refactor: rename `ACCESS_ROLE_IDS` to `AccessRoleIds` refactor: replace PermissionBits with PERMISSION_BITS refactor: replace PERMISSION_BITS with PermissionBits
This commit is contained in:
parent
ae732b2ebc
commit
81b32e400a
96 changed files with 781 additions and 798 deletions
|
|
@ -1,16 +1,16 @@
|
|||
import * as Ariakit from '@ariakit/react';
|
||||
import { useMemo, useEffect, useState } from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { ShieldEllipsis } from 'lucide-react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { Permissions, SystemRoles, roleDefaults, PermissionTypes } from 'librechat-data-provider';
|
||||
import {
|
||||
Button,
|
||||
Switch,
|
||||
OGDialog,
|
||||
DropdownPopup,
|
||||
OGDialogTitle,
|
||||
OGDialogContent,
|
||||
OGDialogTrigger,
|
||||
Button,
|
||||
Switch,
|
||||
DropdownPopup,
|
||||
useToastContext,
|
||||
} from '@librechat/client';
|
||||
import type { Control, UseFormSetValue, UseFormGetValues } from 'react-hook-form';
|
||||
|
|
@ -64,8 +64,8 @@ const LabelController: React.FC<LabelControllerProps> = ({
|
|||
|
||||
const AdminSettings = () => {
|
||||
const localize = useLocalize();
|
||||
const { user, roles } = useAuthContext();
|
||||
const { showToast } = useToastContext();
|
||||
const { user, roles } = useAuthContext();
|
||||
const { mutate, isLoading } = useUpdateAgentPermissionsMutation({
|
||||
onSuccess: () => {
|
||||
showToast({ status: 'success', message: localize('com_ui_saved') });
|
||||
|
|
@ -79,8 +79,9 @@ const AdminSettings = () => {
|
|||
const [selectedRole, setSelectedRole] = useState<SystemRoles>(SystemRoles.USER);
|
||||
|
||||
const defaultValues = useMemo(() => {
|
||||
if (roles?.[selectedRole]?.permissions) {
|
||||
return roles[selectedRole].permissions[PermissionTypes.AGENTS];
|
||||
const rolePerms = roles?.[selectedRole]?.permissions;
|
||||
if (rolePerms) {
|
||||
return rolePerms[PermissionTypes.AGENTS];
|
||||
}
|
||||
return roleDefaults[selectedRole].permissions[PermissionTypes.AGENTS];
|
||||
}, [roles, selectedRole]);
|
||||
|
|
@ -98,8 +99,9 @@ const AdminSettings = () => {
|
|||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (roles?.[selectedRole]?.permissions?.[PermissionTypes.AGENTS]) {
|
||||
reset(roles[selectedRole].permissions[PermissionTypes.AGENTS]);
|
||||
const value = roles?.[selectedRole]?.permissions?.[PermissionTypes.AGENTS];
|
||||
if (value) {
|
||||
reset(value);
|
||||
} else {
|
||||
reset(roleDefaults[selectedRole].permissions[PermissionTypes.AGENTS]);
|
||||
}
|
||||
|
|
@ -211,7 +213,8 @@ const AdminSettings = () => {
|
|||
</div>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
type="button"
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
disabled={isSubmitting || isLoading}
|
||||
className="btn rounded bg-green-500 font-bold text-white transition-all hover:bg-green-600"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,95 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import type t from 'librechat-data-provider';
|
||||
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
import { renderAgentAvatar, getContactDisplayName } from '~/utils/agents';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface AgentCardProps {
|
||||
agent: t.Agent; // The agent data to display
|
||||
onClick: () => void; // Callback when card is clicked
|
||||
className?: string; // Additional CSS classes
|
||||
}
|
||||
|
||||
/**
|
||||
* Card component to display agent information
|
||||
*/
|
||||
const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' }) => {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group relative flex overflow-hidden rounded-2xl',
|
||||
'cursor-pointer transition-colors duration-200',
|
||||
'aspect-[5/2.5] w-full',
|
||||
'bg-surface-tertiary hover:bg-surface-hover-alt',
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
aria-label={localize('com_agents_agent_card_label', {
|
||||
name: agent.name,
|
||||
description: agent.description || localize('com_agents_no_description'),
|
||||
})}
|
||||
aria-describedby={`agent-${agent.id}-description`}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex h-full gap-2 px-3 py-2 sm:gap-3 sm:px-4 sm:py-3">
|
||||
{/* Agent avatar section - left side, responsive */}
|
||||
<div className="flex flex-shrink-0 items-center">
|
||||
{renderAgentAvatar(agent, { size: 'md' })}
|
||||
</div>
|
||||
|
||||
{/* Agent info section - right side, responsive */}
|
||||
<div className="flex min-w-0 flex-1 flex-col justify-center">
|
||||
{/* Agent name - responsive text sizing */}
|
||||
<h3 className="mb-1 line-clamp-1 text-base font-bold text-text-primary sm:mb-2 sm:text-lg">
|
||||
{agent.name}
|
||||
</h3>
|
||||
|
||||
{/* Agent description - responsive text sizing and spacing */}
|
||||
<p
|
||||
id={`agent-${agent.id}-description`}
|
||||
className={cn(
|
||||
'mb-1 line-clamp-2 text-xs leading-relaxed text-text-secondary',
|
||||
'sm:mb-2 sm:text-sm',
|
||||
)}
|
||||
aria-label={`Description: ${agent.description || localize('com_agents_no_description')}`}
|
||||
>
|
||||
{agent.description || (
|
||||
<span className="italic text-text-secondary">
|
||||
{localize('com_agents_no_description')}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* Owner info - responsive text sizing */}
|
||||
{(() => {
|
||||
const displayName = getContactDisplayName(agent);
|
||||
|
||||
if (displayName) {
|
||||
return (
|
||||
<div className="flex items-center text-xs text-text-tertiary sm:text-sm">
|
||||
<span className="font-light">{localize('com_agents_created_by')}</span>
|
||||
<span className="ml-1 font-bold">{displayName}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentCard;
|
||||
|
|
@ -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;
|
||||
|
|
@ -1,182 +0,0 @@
|
|||
import React, { useRef } from 'react';
|
||||
import { Link } from 'lucide-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { OGDialog, OGDialogContent, Button, useToastContext } from '@librechat/client';
|
||||
import {
|
||||
QueryKeys,
|
||||
Constants,
|
||||
EModelEndpoint,
|
||||
PERMISSION_BITS,
|
||||
LocalStorageKeys,
|
||||
AgentListResponse,
|
||||
} from 'librechat-data-provider';
|
||||
import type t from 'librechat-data-provider';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import { renderAgentAvatar } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface SupportContact {
|
||||
name?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
interface AgentWithSupport extends t.Agent {
|
||||
support_contact?: SupportContact;
|
||||
}
|
||||
interface AgentDetailProps {
|
||||
agent: AgentWithSupport; // The agent data to display
|
||||
isOpen: boolean; // Whether the detail dialog is open
|
||||
onClose: () => void; // Callback when dialog is closed
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog for displaying agent details
|
||||
*/
|
||||
const AgentDetail: React.FC<AgentDetailProps> = ({ agent, isOpen, onClose }) => {
|
||||
const localize = useLocalize();
|
||||
// const navigate = useNavigate();
|
||||
const { conversation, newConversation } = useChatContext();
|
||||
const { showToast } = useToastContext();
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
/**
|
||||
* Navigate to chat with the selected agent
|
||||
*/
|
||||
const handleStartChat = () => {
|
||||
if (agent) {
|
||||
const keys = [QueryKeys.agents, { requiredPermission: PERMISSION_BITS.EDIT }];
|
||||
const listResp = queryClient.getQueryData<AgentListResponse>(keys);
|
||||
if (listResp != null) {
|
||||
if (!listResp.data.some((a) => a.id === agent.id)) {
|
||||
const currentAgents = [agent, ...JSON.parse(JSON.stringify(listResp.data))];
|
||||
queryClient.setQueryData<AgentListResponse>(keys, { ...listResp, data: currentAgents });
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem(`${LocalStorageKeys.AGENT_ID_PREFIX}0`, agent.id);
|
||||
|
||||
queryClient.setQueryData<t.TMessage[]>(
|
||||
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
|
||||
[],
|
||||
);
|
||||
queryClient.invalidateQueries([QueryKeys.messages]);
|
||||
|
||||
newConversation({
|
||||
template: {
|
||||
conversationId: Constants.NEW_CONVO as string,
|
||||
endpoint: EModelEndpoint.agents,
|
||||
agent_id: agent.id,
|
||||
title: `Chat with ${agent.name || 'Agent'}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Copy the agent's shareable link to clipboard
|
||||
*/
|
||||
const handleCopyLink = () => {
|
||||
const baseUrl = new URL(window.location.origin);
|
||||
const chatUrl = `${baseUrl.origin}/c/new?agent_id=${agent.id}`;
|
||||
navigator.clipboard
|
||||
.writeText(chatUrl)
|
||||
.then(() => {
|
||||
showToast({
|
||||
message: localize('com_agents_link_copied'),
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
showToast({
|
||||
message: localize('com_agents_link_copy_failed'),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Format contact information with mailto links when appropriate
|
||||
*/
|
||||
const formatContact = () => {
|
||||
if (!agent?.support_contact) return null;
|
||||
|
||||
const { name, email } = agent.support_contact;
|
||||
|
||||
if (name && email) {
|
||||
return (
|
||||
<a href={`mailto:${email}`} className="text-primary hover:underline">
|
||||
{name}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
if (email) {
|
||||
return (
|
||||
<a href={`mailto:${email}`} className="text-primary hover:underline">
|
||||
{email}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
if (name) {
|
||||
return <span>{name}</span>;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<OGDialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<OGDialogContent
|
||||
ref={dialogRef}
|
||||
className="max-h-[90vh] overflow-y-auto py-8 sm:max-w-[450px]"
|
||||
>
|
||||
{/* Copy link button - positioned next to close button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-11 top-4 h-4 w-4 rounded-sm p-0 opacity-70 ring-ring-primary ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
aria-label={localize('com_agents_copy_link')}
|
||||
onClick={handleCopyLink}
|
||||
title={localize('com_agents_copy_link')}
|
||||
>
|
||||
<Link />
|
||||
</Button>
|
||||
|
||||
{/* Agent avatar - top center */}
|
||||
<div className="mt-6 flex justify-center">{renderAgentAvatar(agent, { size: 'xl' })}</div>
|
||||
|
||||
{/* Agent name - center aligned below image */}
|
||||
<div className="mt-3 text-center">
|
||||
<h2 className="text-2xl font-bold text-text-primary">
|
||||
{agent?.name || localize('com_agents_loading')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Contact info - center aligned below name */}
|
||||
{agent?.support_contact && formatContact() && (
|
||||
<div className="mt-1 text-center text-sm text-text-secondary">
|
||||
{localize('com_agents_contact')}: {formatContact()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent description - below contact */}
|
||||
<div className="mt-4 whitespace-pre-wrap px-6 text-center text-base text-text-primary">
|
||||
{agent?.description || (
|
||||
<span className="italic text-text-tertiary">
|
||||
{localize('com_agents_no_description')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action button */}
|
||||
<div className="mb-4 mt-6 flex justify-center">
|
||||
<Button className="w-full max-w-xs" onClick={handleStartChat} disabled={!agent}>
|
||||
{localize('com_agents_start_chat')}
|
||||
</Button>
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentDetail;
|
||||
|
|
@ -3,8 +3,9 @@ import { useWatch, useFormContext } from 'react-hook-form';
|
|||
import {
|
||||
SystemRoles,
|
||||
Permissions,
|
||||
ResourceType,
|
||||
PermissionTypes,
|
||||
PERMISSION_BITS,
|
||||
PermissionBits,
|
||||
} from 'librechat-data-provider';
|
||||
import type { AgentForm, AgentPanelProps } from '~/common';
|
||||
import { useLocalize, useAuthContext, useHasAccess, useResourcePermissions } from '~/hooks';
|
||||
|
|
@ -46,8 +47,8 @@ export default function AgentFooter({
|
|||
agent?._id || '',
|
||||
);
|
||||
|
||||
const canShareThisAgent = hasPermission(PERMISSION_BITS.SHARE);
|
||||
const canDeleteThisAgent = hasPermission(PERMISSION_BITS.DELETE);
|
||||
const canShareThisAgent = hasPermission(PermissionBits.SHARE);
|
||||
const canDeleteThisAgent = hasPermission(PermissionBits.DELETE);
|
||||
const renderSaveButton = () => {
|
||||
if (createMutation.isLoading || updateMutation.isLoading) {
|
||||
return <Spinner className="icon-md" aria-hidden="true" />;
|
||||
|
|
@ -84,7 +85,7 @@ export default function AgentFooter({
|
|||
resourceDbId={agent?._id}
|
||||
resourceId={agent_id}
|
||||
resourceName={agent?.name ?? ''}
|
||||
resourceType="agent"
|
||||
resourceType={ResourceType.AGENT}
|
||||
/>
|
||||
)}
|
||||
{agent && agent.author === user?.id && <DuplicateAgent agent_id={agent_id} />}
|
||||
|
|
|
|||
|
|
@ -1,289 +0,0 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { Button, Spinner } from '@librechat/client';
|
||||
import { PERMISSION_BITS } from 'librechat-data-provider';
|
||||
import type t from 'librechat-data-provider';
|
||||
import { useMarketplaceAgentsInfiniteQuery } from '~/data-provider/Agents';
|
||||
import { useAgentCategories, useLocalize } from '~/hooks';
|
||||
import { useHasData } from './SmartLoader';
|
||||
import ErrorDisplay from './ErrorDisplay';
|
||||
import AgentCard from './AgentCard';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface AgentGridProps {
|
||||
category: string; // Currently selected category
|
||||
searchQuery: string; // Current search query
|
||||
onSelectAgent: (agent: t.Agent) => void; // Callback when agent is selected
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for displaying a grid of agent cards
|
||||
*/
|
||||
const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAgent }) => {
|
||||
const localize = useLocalize();
|
||||
|
||||
// Get category data from API
|
||||
const { categories } = useAgentCategories();
|
||||
|
||||
// Build query parameters based on current state
|
||||
const queryParams = useMemo(() => {
|
||||
const params: {
|
||||
requiredPermission: number;
|
||||
category?: string;
|
||||
search?: string;
|
||||
limit: number;
|
||||
promoted?: 0 | 1;
|
||||
} = {
|
||||
requiredPermission: PERMISSION_BITS.VIEW, // View permission for marketplace viewing
|
||||
limit: 6,
|
||||
};
|
||||
|
||||
// Handle search
|
||||
if (searchQuery) {
|
||||
params.search = searchQuery;
|
||||
// Include category filter for search if it's not 'all' or 'promoted'
|
||||
if (category !== 'all' && category !== 'promoted') {
|
||||
params.category = category;
|
||||
}
|
||||
} else {
|
||||
// Handle category-based queries
|
||||
if (category === 'promoted') {
|
||||
params.promoted = 1;
|
||||
} else if (category !== 'all') {
|
||||
params.category = category;
|
||||
}
|
||||
// For 'all' category, no additional filters needed
|
||||
}
|
||||
|
||||
return params;
|
||||
}, [category, searchQuery]);
|
||||
|
||||
// Use infinite query for marketplace agents
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
isFetching,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
refetch,
|
||||
isFetchingNextPage,
|
||||
} = useMarketplaceAgentsInfiniteQuery(queryParams);
|
||||
|
||||
// Flatten all pages into a single array of agents
|
||||
const currentAgents = useMemo(() => {
|
||||
if (!data?.pages) return [];
|
||||
return data.pages.flatMap((page) => page.data || []);
|
||||
}, [data?.pages]);
|
||||
|
||||
// Check if we have meaningful data to prevent unnecessary loading states
|
||||
const hasData = useHasData(data?.pages?.[0]);
|
||||
|
||||
/**
|
||||
* Get category display name from API data or use fallback
|
||||
*/
|
||||
const getCategoryDisplayName = (categoryValue: string) => {
|
||||
const categoryData = categories.find((cat) => cat.value === categoryValue);
|
||||
if (categoryData) {
|
||||
return categoryData.label;
|
||||
}
|
||||
|
||||
// Fallback for special categories or unknown categories
|
||||
if (categoryValue === 'promoted') {
|
||||
return localize('com_agents_top_picks');
|
||||
}
|
||||
if (categoryValue === 'all') {
|
||||
return 'All';
|
||||
}
|
||||
|
||||
// Simple capitalization for unknown categories
|
||||
return categoryValue.charAt(0).toUpperCase() + categoryValue.slice(1);
|
||||
};
|
||||
|
||||
/**
|
||||
* Load more agents when "See More" button is clicked
|
||||
*/
|
||||
const handleLoadMore = () => {
|
||||
if (hasNextPage && !isFetching) {
|
||||
fetchNextPage();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the appropriate title for the agents grid based on current state
|
||||
*/
|
||||
const getGridTitle = () => {
|
||||
if (searchQuery) {
|
||||
return localize('com_agents_results_for', { query: searchQuery });
|
||||
}
|
||||
|
||||
return getCategoryDisplayName(category);
|
||||
};
|
||||
|
||||
// Loading skeleton component
|
||||
const loadingSkeleton = (
|
||||
<div className="space-y-6">
|
||||
<div className="mb-4">
|
||||
<div className="mb-2 h-6 w-48 animate-pulse rounded-md bg-surface-tertiary"></div>
|
||||
<div className="h-4 w-64 animate-pulse rounded-md bg-surface-tertiary"></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{Array(6)
|
||||
.fill(0)
|
||||
.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'flex animate-pulse overflow-hidden rounded-2xl',
|
||||
'aspect-[5/2.5] w-full',
|
||||
'bg-surface-tertiary',
|
||||
)}
|
||||
>
|
||||
<div className="flex h-full gap-2 px-3 py-2 sm:gap-3 sm:px-4 sm:py-3">
|
||||
{/* Avatar skeleton */}
|
||||
<div className="flex flex-shrink-0 items-center">
|
||||
<div className="h-10 w-10 rounded-full bg-surface-secondary sm:h-12 sm:w-12"></div>
|
||||
</div>
|
||||
{/* Content skeleton */}
|
||||
<div className="flex flex-1 flex-col justify-center space-y-2">
|
||||
<div className="h-4 w-3/4 rounded bg-surface-secondary"></div>
|
||||
<div className="h-3 w-full rounded bg-surface-secondary"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Handle error state with enhanced error display
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorDisplay
|
||||
error={error || 'Unknown error occurred'}
|
||||
onRetry={() => refetch()}
|
||||
context={{
|
||||
searchQuery,
|
||||
category,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Main content component with proper semantic structure
|
||||
const mainContent = (
|
||||
<div
|
||||
className="space-y-6"
|
||||
role="tabpanel"
|
||||
id={`category-panel-${category}`}
|
||||
aria-labelledby={`category-tab-${category}`}
|
||||
aria-live="polite"
|
||||
aria-busy={isLoading && !hasData}
|
||||
>
|
||||
{/* Grid title - only show for search results */}
|
||||
{searchQuery && (
|
||||
<div className="mb-4">
|
||||
<h2
|
||||
className="text-xl font-bold text-text-primary"
|
||||
id={`category-heading-${category}`}
|
||||
aria-label={`${getGridTitle()}, ${currentAgents.length || 0} agents available`}
|
||||
>
|
||||
{getGridTitle()}
|
||||
</h2>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Handle empty results with enhanced accessibility */}
|
||||
{(!currentAgents || currentAgents.length === 0) && !isLoading && !isFetching ? (
|
||||
<div
|
||||
className="py-12 text-center text-text-secondary"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={
|
||||
searchQuery
|
||||
? localize('com_agents_search_empty_heading')
|
||||
: localize('com_agents_empty_state_heading')
|
||||
}
|
||||
>
|
||||
<h3 className="mb-2 text-lg font-medium">
|
||||
{searchQuery
|
||||
? localize('com_agents_search_empty_heading')
|
||||
: localize('com_agents_empty_state_heading')}
|
||||
</h3>
|
||||
<p className="text-sm">
|
||||
{searchQuery
|
||||
? localize('com_agents_no_results')
|
||||
: localize('com_agents_none_in_category')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Announcement for screen readers */}
|
||||
<div id="search-results-count" className="sr-only" aria-live="polite" aria-atomic="true">
|
||||
{localize('com_agents_grid_announcement', {
|
||||
count: currentAgents?.length || 0,
|
||||
category: getCategoryDisplayName(category),
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Agent grid - 2 per row with proper semantic structure */}
|
||||
{currentAgents && currentAgents.length > 0 && (
|
||||
<div
|
||||
className="grid grid-cols-1 gap-6 md:grid-cols-2"
|
||||
role="grid"
|
||||
aria-label={localize('com_agents_grid_announcement', {
|
||||
count: currentAgents.length,
|
||||
category: getCategoryDisplayName(category),
|
||||
})}
|
||||
>
|
||||
{currentAgents.map((agent: t.Agent, index: number) => (
|
||||
<div key={`${agent.id}-${index}`} role="gridcell">
|
||||
<AgentCard agent={agent} onClick={() => onSelectAgent(agent)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading indicator when fetching more with accessibility */}
|
||||
{isFetching && hasNextPage && (
|
||||
<div
|
||||
className="flex justify-center py-4"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={localize('com_agents_loading')}
|
||||
>
|
||||
<Spinner className="h-6 w-6 text-primary" />
|
||||
<span className="sr-only">{localize('com_agents_loading')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Load more button with enhanced accessibility */}
|
||||
{hasNextPage && !isFetching && (
|
||||
<div className="mt-8 flex justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleLoadMore}
|
||||
className={cn(
|
||||
'min-w-[160px] border-2 border-border-medium bg-surface-primary px-6 py-3 font-medium text-text-primary',
|
||||
'shadow-sm transition-all duration-200 hover:border-border-heavy hover:bg-surface-hover',
|
||||
'hover:shadow-md focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
)}
|
||||
aria-label={localize('com_agents_load_more_label', {
|
||||
category: getCategoryDisplayName(category),
|
||||
})}
|
||||
>
|
||||
{localize('com_agents_see_more')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isLoading || (isFetching && !isFetchingNextPage)) {
|
||||
return loadingSkeleton;
|
||||
}
|
||||
return mainContent;
|
||||
};
|
||||
|
||||
export default AgentGrid;
|
||||
|
|
@ -1,355 +0,0 @@
|
|||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useSearchParams, useParams, useNavigate } from 'react-router-dom';
|
||||
import { TooltipAnchor, Button, NewChatIcon, useMediaQuery } from '@librechat/client';
|
||||
import { PermissionTypes, Permissions, QueryKeys, Constants } from 'librechat-data-provider';
|
||||
import type t from 'librechat-data-provider';
|
||||
import type { ContextType } from '~/common';
|
||||
import { useGetEndpointsQuery, useGetAgentCategoriesQuery } from '~/data-provider';
|
||||
import { useDocumentTitle, useHasAccess, useLocalize } from '~/hooks';
|
||||
import { SidePanelProvider, useChatContext } from '~/Providers';
|
||||
import { MarketplaceProvider } from './MarketplaceContext';
|
||||
import { SidePanelGroup } from '~/components/SidePanel';
|
||||
import { OpenSidebar } from '~/components/Chat/Menus';
|
||||
import CategoryTabs from './CategoryTabs';
|
||||
import AgentDetail from './AgentDetail';
|
||||
import SearchBar from './SearchBar';
|
||||
import AgentGrid from './AgentGrid';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
interface AgentMarketplaceProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AgentMarketplace - Main component for browsing and discovering agents
|
||||
*
|
||||
* Provides tabbed navigation for different agent categories,
|
||||
* search functionality, and detailed agent view through a modal dialog.
|
||||
* Uses URL parameters for state persistence and deep linking.
|
||||
*/
|
||||
const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) => {
|
||||
const localize = useLocalize();
|
||||
const navigate = useNavigate();
|
||||
const { category } = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { conversation, newConversation } = useChatContext();
|
||||
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
const { navVisible, setNavVisible } = useOutletContext<ContextType>();
|
||||
const [hideSidePanel, setHideSidePanel] = useRecoilState(store.hideSidePanel);
|
||||
|
||||
// Get URL parameters (default to 'promoted' instead of 'all')
|
||||
const activeTab = category || 'promoted';
|
||||
const searchQuery = searchParams.get('q') || '';
|
||||
const selectedAgentId = searchParams.get('agent_id') || '';
|
||||
|
||||
// Local state
|
||||
const [isDetailOpen, setIsDetailOpen] = useState(false);
|
||||
const [selectedAgent, setSelectedAgent] = useState<t.Agent | null>(null);
|
||||
|
||||
// Set page title
|
||||
useDocumentTitle(`${localize('com_agents_marketplace')} | LibreChat`);
|
||||
|
||||
// Ensure right sidebar is always visible in marketplace
|
||||
useEffect(() => {
|
||||
setHideSidePanel(false);
|
||||
|
||||
// Also try to force expand via localStorage
|
||||
localStorage.setItem('hideSidePanel', 'false');
|
||||
localStorage.setItem('fullPanelCollapse', 'false');
|
||||
}, [setHideSidePanel, hideSidePanel]);
|
||||
|
||||
// Ensure endpoints config is loaded first (required for agent queries)
|
||||
useGetEndpointsQuery();
|
||||
|
||||
// Fetch categories using existing query pattern
|
||||
const categoriesQuery = useGetAgentCategoriesQuery({
|
||||
staleTime: 1000 * 60 * 15, // 15 minutes - categories rarely change
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnMount: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* Handle agent card selection
|
||||
*
|
||||
* @param agent - The selected agent object
|
||||
*/
|
||||
const handleAgentSelect = (agent: t.Agent) => {
|
||||
// Update URL with selected agent
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set('agent_id', agent.id);
|
||||
setSearchParams(newParams);
|
||||
setSelectedAgent(agent);
|
||||
setIsDetailOpen(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle closing the agent detail dialog
|
||||
*/
|
||||
const handleDetailClose = () => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.delete('agent_id');
|
||||
setSearchParams(newParams);
|
||||
setSelectedAgent(null);
|
||||
setIsDetailOpen(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle category tab selection changes
|
||||
*
|
||||
* @param tabValue - The selected category value
|
||||
*/
|
||||
const handleTabChange = (tabValue: string) => {
|
||||
const currentSearchParams = searchParams.toString();
|
||||
const searchParamsStr = currentSearchParams ? `?${currentSearchParams}` : '';
|
||||
|
||||
// Navigate to the selected category
|
||||
if (tabValue === 'promoted') {
|
||||
navigate(`/agents${searchParamsStr}`);
|
||||
} else {
|
||||
navigate(`/agents/${tabValue}${searchParamsStr}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle search query changes
|
||||
*
|
||||
* @param query - The search query string
|
||||
*/
|
||||
const handleSearch = (query: string) => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
if (query.trim()) {
|
||||
newParams.set('q', query.trim());
|
||||
// Switch to "all" category when starting a new search
|
||||
navigate(`/agents/all?${newParams.toString()}`);
|
||||
} else {
|
||||
newParams.delete('q');
|
||||
// Preserve current category when clearing search
|
||||
const currentCategory = activeTab;
|
||||
if (currentCategory === 'promoted') {
|
||||
navigate(`/agents${newParams.toString() ? `?${newParams.toString()}` : ''}`);
|
||||
} else {
|
||||
navigate(
|
||||
`/agents/${currentCategory}${newParams.toString() ? `?${newParams.toString()}` : ''}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle new chat button click
|
||||
*/
|
||||
|
||||
const handleNewChat = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (e.button === 0 && (e.ctrlKey || e.metaKey)) {
|
||||
window.open('/c/new', '_blank');
|
||||
return;
|
||||
}
|
||||
queryClient.setQueryData<t.TMessage[]>(
|
||||
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
|
||||
[],
|
||||
);
|
||||
queryClient.invalidateQueries([QueryKeys.messages]);
|
||||
newConversation();
|
||||
};
|
||||
|
||||
// Check if a detail view should be open based on URL
|
||||
useEffect(() => {
|
||||
setIsDetailOpen(!!selectedAgentId);
|
||||
}, [selectedAgentId]);
|
||||
|
||||
// Layout configuration for SidePanelGroup
|
||||
const defaultLayout = useMemo(() => {
|
||||
const resizableLayout = localStorage.getItem('react-resizable-panels:layout');
|
||||
return typeof resizableLayout === 'string' ? JSON.parse(resizableLayout) : undefined;
|
||||
}, []);
|
||||
|
||||
const defaultCollapsed = useMemo(() => {
|
||||
const collapsedPanels = localStorage.getItem('react-resizable-panels:collapsed');
|
||||
return typeof collapsedPanels === 'string' ? JSON.parse(collapsedPanels) : true;
|
||||
}, []);
|
||||
|
||||
const fullCollapse = useMemo(() => localStorage.getItem('fullPanelCollapse') === 'true', []);
|
||||
|
||||
const hasAccessToMarketplace = useHasAccess({
|
||||
permissionType: PermissionTypes.MARKETPLACE,
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
useEffect(() => {
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
if (!hasAccessToMarketplace) {
|
||||
timeoutId = setTimeout(() => {
|
||||
navigate('/c/new');
|
||||
}, 1000);
|
||||
}
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [hasAccessToMarketplace, navigate]);
|
||||
|
||||
if (!hasAccessToMarketplace) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className={`relative flex w-full grow overflow-hidden bg-presentation ${className}`}>
|
||||
<MarketplaceProvider>
|
||||
<SidePanelProvider>
|
||||
<SidePanelGroup
|
||||
defaultLayout={defaultLayout}
|
||||
fullPanelCollapse={fullCollapse}
|
||||
defaultCollapsed={defaultCollapsed}
|
||||
>
|
||||
<main className="flex h-full flex-col overflow-hidden" role="main">
|
||||
{/* Scrollable container */}
|
||||
<div className="scrollbar-gutter-stable flex h-full flex-col overflow-y-auto overflow-x-hidden">
|
||||
{/* Simplified header for agents marketplace - only show nav controls when needed */}
|
||||
{!isSmallScreen && (
|
||||
<div className="sticky top-0 z-20 flex items-center justify-between bg-surface-secondary p-2 font-semibold text-text-primary md:h-14">
|
||||
<div className="mx-1 flex items-center gap-2">
|
||||
{!navVisible ? (
|
||||
<>
|
||||
<OpenSidebar setNavVisible={setNavVisible} />
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_new_chat')}
|
||||
render={
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
data-testid="agents-new-chat-button"
|
||||
aria-label={localize('com_ui_new_chat')}
|
||||
className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover max-md:hidden"
|
||||
onClick={handleNewChat}
|
||||
>
|
||||
<NewChatIcon />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
// Invisible placeholder to maintain height
|
||||
<div className="h-10 w-10" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hero Section - scrolls away */}
|
||||
<div className="container mx-auto max-w-4xl">
|
||||
<div className={cn('mb-8 text-center', isSmallScreen ? 'mt-6' : 'mt-12')}>
|
||||
<h1 className="mb-3 text-3xl font-bold tracking-tight text-text-primary md:text-5xl">
|
||||
{localize('com_agents_marketplace')}
|
||||
</h1>
|
||||
<p className="mx-auto mb-6 max-w-2xl text-lg text-text-secondary">
|
||||
{localize('com_agents_marketplace_subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sticky wrapper for search bar and categories */}
|
||||
<div
|
||||
className={cn(
|
||||
'sticky z-10 bg-presentation pb-4',
|
||||
isSmallScreen ? 'top-0' : 'top-14',
|
||||
)}
|
||||
>
|
||||
<div className="container mx-auto max-w-4xl px-4">
|
||||
{/* Search bar */}
|
||||
<div className="mx-auto max-w-2xl pb-6">
|
||||
<SearchBar value={searchQuery} onSearch={handleSearch} />
|
||||
</div>
|
||||
|
||||
{/* Category tabs */}
|
||||
<CategoryTabs
|
||||
categories={categoriesQuery.data || []}
|
||||
activeTab={activeTab}
|
||||
isLoading={categoriesQuery.isLoading}
|
||||
onChange={handleTabChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content area */}
|
||||
<div className="container mx-auto max-w-4xl px-4 pb-8">
|
||||
{/* Category header - only show when not searching */}
|
||||
{!searchQuery && (
|
||||
<div className="mb-6 mt-6">
|
||||
{(() => {
|
||||
// Get category data for display
|
||||
const getCategoryData = () => {
|
||||
if (activeTab === 'promoted') {
|
||||
return {
|
||||
name: localize('com_agents_top_picks'),
|
||||
description: localize('com_agents_recommended'),
|
||||
};
|
||||
}
|
||||
if (activeTab === 'all') {
|
||||
return {
|
||||
name: 'All Agents',
|
||||
description: 'Browse all shared agents across all categories',
|
||||
};
|
||||
}
|
||||
|
||||
// Find the category in the API data
|
||||
const categoryData = categoriesQuery.data?.find(
|
||||
(cat) => cat.value === activeTab,
|
||||
);
|
||||
if (categoryData) {
|
||||
return {
|
||||
name: categoryData.label,
|
||||
description: categoryData.description || '',
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback for unknown categories
|
||||
return {
|
||||
name: activeTab.charAt(0).toUpperCase() + activeTab.slice(1),
|
||||
description: '',
|
||||
};
|
||||
};
|
||||
|
||||
const { name, description } = getCategoryData();
|
||||
|
||||
return (
|
||||
<div className="text-left">
|
||||
<h2 className="text-2xl font-bold text-text-primary">{name}</h2>
|
||||
{description && (
|
||||
<p className="mt-2 text-text-secondary">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent grid */}
|
||||
<AgentGrid
|
||||
category={activeTab}
|
||||
searchQuery={searchQuery}
|
||||
onSelectAgent={handleAgentSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Agent detail dialog */}
|
||||
{isDetailOpen && selectedAgent && (
|
||||
<AgentDetail
|
||||
agent={selectedAgent}
|
||||
isOpen={isDetailOpen}
|
||||
onClose={handleDetailClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</SidePanelGroup>
|
||||
</SidePanelProvider>
|
||||
</MarketplaceProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentMarketplace;
|
||||
|
|
@ -8,7 +8,7 @@ import {
|
|||
Constants,
|
||||
SystemRoles,
|
||||
EModelEndpoint,
|
||||
PERMISSION_BITS,
|
||||
PermissionBits,
|
||||
isAssistantsEndpoint,
|
||||
} from 'librechat-data-provider';
|
||||
import type { AgentForm, StringOption } from '~/common';
|
||||
|
|
@ -57,7 +57,7 @@ export default function AgentPanel() {
|
|||
basicAgentQuery.data?._id || '',
|
||||
);
|
||||
|
||||
const canEdit = hasPermission(PERMISSION_BITS.EDIT);
|
||||
const canEdit = hasPermission(PermissionBits.EDIT);
|
||||
|
||||
const expandedAgentQuery = useGetExpandedAgentByIdQuery(current_agent_id ?? '', {
|
||||
enabled:
|
||||
|
|
|
|||
|
|
@ -1,170 +0,0 @@
|
|||
import React from 'react';
|
||||
import type t from 'librechat-data-provider';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
import { SmartLoader } from './SmartLoader';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
/**
|
||||
* Props for the CategoryTabs component
|
||||
*/
|
||||
interface CategoryTabsProps {
|
||||
/** Array of agent categories to display as tabs */
|
||||
categories: t.TMarketplaceCategory[];
|
||||
/** Currently selected tab value */
|
||||
activeTab: string;
|
||||
/** Whether categories are currently loading */
|
||||
isLoading: boolean;
|
||||
/** Callback fired when a tab is selected */
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* CategoryTabs - Component for displaying category tabs with counts
|
||||
*
|
||||
* Renders a tabbed navigation interface showing agent categories.
|
||||
* Includes loading states, empty state handling, and displays counts for each category.
|
||||
* Uses database-driven category labels with no hardcoded values.
|
||||
*/
|
||||
const CategoryTabs: React.FC<CategoryTabsProps> = ({
|
||||
categories,
|
||||
activeTab,
|
||||
isLoading,
|
||||
onChange,
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
|
||||
// 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';
|
||||
}
|
||||
// Use database label or fallback to capitalized value
|
||||
return category.label || category.value.charAt(0).toUpperCase() + category.value.slice(1);
|
||||
};
|
||||
|
||||
// Loading skeleton component
|
||||
const loadingSkeleton = (
|
||||
<div className="w-full pb-2">
|
||||
<div className="no-scrollbar flex gap-1.5 overflow-x-auto px-4">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-[36px] min-w-[80px] animate-pulse rounded-md bg-surface-tertiary"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Handle keyboard navigation between tabs
|
||||
const handleKeyDown = (e: React.KeyboardEvent, currentCategory: string) => {
|
||||
const currentIndex = categories.findIndex((cat) => cat.value === currentCategory);
|
||||
let newIndex = currentIndex;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
newIndex = currentIndex > 0 ? currentIndex - 1 : categories.length - 1;
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
newIndex = currentIndex < categories.length - 1 ? currentIndex + 1 : 0;
|
||||
break;
|
||||
case 'Home':
|
||||
e.preventDefault();
|
||||
newIndex = 0;
|
||||
break;
|
||||
case 'End':
|
||||
e.preventDefault();
|
||||
newIndex = categories.length - 1;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
const newCategory = categories[newIndex];
|
||||
if (newCategory) {
|
||||
onChange(newCategory.value);
|
||||
// Focus the new tab
|
||||
setTimeout(() => {
|
||||
const newTab = document.getElementById(`category-tab-${newCategory.value}`);
|
||||
newTab?.focus();
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
// Early return if no categories available
|
||||
if (!isLoading && (!categories || categories.length === 0)) {
|
||||
return (
|
||||
<div className="text-center text-text-secondary">{localize('com_ui_no_categories')}</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Main tabs content
|
||||
const tabsContent = (
|
||||
<div className="relative w-full pb-2">
|
||||
<div
|
||||
className="no-scrollbar flex gap-1.5 overflow-x-auto overscroll-x-contain px-4 [-webkit-overflow-scrolling:touch] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
role="tablist"
|
||||
aria-label={localize('com_agents_category_tabs_label')}
|
||||
aria-orientation="horizontal"
|
||||
style={{
|
||||
scrollSnapType: 'x mandatory',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
}}
|
||||
>
|
||||
{categories.map((category, index) => (
|
||||
<button
|
||||
key={category.value}
|
||||
id={`category-tab-${category.value}`}
|
||||
onClick={() => onChange(category.value)}
|
||||
onKeyDown={(e) => handleKeyDown(e, category.value)}
|
||||
className={cn(
|
||||
'relative mt-1 cursor-pointer select-none whitespace-nowrap rounded-md px-3 py-2',
|
||||
activeTab === category.value
|
||||
? 'bg-surface-tertiary text-text-primary'
|
||||
: 'bg-surface-secondary text-text-secondary hover:bg-surface-hover hover:text-text-primary',
|
||||
)}
|
||||
style={{
|
||||
scrollSnapAlign: 'start',
|
||||
}}
|
||||
role="tab"
|
||||
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})`}
|
||||
>
|
||||
{getCategoryDisplayName(category)}
|
||||
{/* Underline for active tab */}
|
||||
{activeTab === category.value && (
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 h-0.5 bg-text-primary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Use SmartLoader to prevent category loading flashes
|
||||
return (
|
||||
<SmartLoader
|
||||
isLoading={isLoading}
|
||||
hasData={categories?.length > 0}
|
||||
delay={100} // Very short delay since categories should load quickly
|
||||
loadingComponent={loadingSkeleton}
|
||||
>
|
||||
{tabsContent}
|
||||
</SmartLoader>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoryTabs;
|
||||
|
|
@ -1,251 +0,0 @@
|
|||
import React from 'react';
|
||||
import { Button } from '@librechat/client';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
// Comprehensive error type that handles all possible error structures
|
||||
type ApiError =
|
||||
| string
|
||||
| Error
|
||||
| {
|
||||
message?: string;
|
||||
status?: number;
|
||||
code?: string;
|
||||
response?: {
|
||||
data?: {
|
||||
userMessage?: string;
|
||||
suggestion?: string;
|
||||
message?: string;
|
||||
};
|
||||
status?: number;
|
||||
};
|
||||
data?: {
|
||||
userMessage?: string;
|
||||
suggestion?: string;
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
|
||||
interface ErrorDisplayProps {
|
||||
error: ApiError;
|
||||
onRetry?: () => void;
|
||||
context?: {
|
||||
searchQuery?: string;
|
||||
category?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* User-friendly error display component with actionable suggestions
|
||||
*/
|
||||
export const ErrorDisplay: React.FC<ErrorDisplayProps> = ({ error, onRetry, context }) => {
|
||||
const localize = useLocalize();
|
||||
|
||||
// Type guards
|
||||
const isErrorObject = (err: ApiError): err is { [key: string]: unknown } => {
|
||||
return typeof err === 'object' && err !== null && !(err instanceof Error);
|
||||
};
|
||||
|
||||
const isErrorInstance = (err: ApiError): err is Error => {
|
||||
return err instanceof Error;
|
||||
};
|
||||
|
||||
// Extract user-friendly error information
|
||||
const getErrorInfo = (): { title: string; message: string; suggestion: string } => {
|
||||
// Handle different error types
|
||||
let errorData: unknown;
|
||||
|
||||
if (typeof error === 'string') {
|
||||
errorData = { message: error };
|
||||
} else if (isErrorInstance(error)) {
|
||||
errorData = { message: error.message };
|
||||
} else if (isErrorObject(error)) {
|
||||
// Handle axios error response structure
|
||||
errorData = (error as any)?.response?.data || (error as any)?.data || error;
|
||||
} else {
|
||||
errorData = error;
|
||||
}
|
||||
|
||||
// Handle network errors first
|
||||
let errorMessage = '';
|
||||
if (isErrorInstance(error)) {
|
||||
errorMessage = error.message;
|
||||
} else if (isErrorObject(error) && (error as any)?.message) {
|
||||
errorMessage = (error as any).message;
|
||||
}
|
||||
|
||||
const errorCode = isErrorObject(error) ? (error as any)?.code : '';
|
||||
|
||||
// Handle timeout errors specifically
|
||||
if (errorCode === 'ECONNABORTED' || errorMessage?.includes('timeout')) {
|
||||
return {
|
||||
title: localize('com_agents_error_timeout_title'),
|
||||
message: localize('com_agents_error_timeout_message'),
|
||||
suggestion: localize('com_agents_error_timeout_suggestion'),
|
||||
};
|
||||
}
|
||||
|
||||
if (errorCode === 'NETWORK_ERROR' || errorMessage?.includes('Network Error')) {
|
||||
return {
|
||||
title: localize('com_agents_error_network_title'),
|
||||
message: localize('com_agents_error_network_message'),
|
||||
suggestion: localize('com_agents_error_network_suggestion'),
|
||||
};
|
||||
}
|
||||
|
||||
// Handle specific HTTP status codes before generic userMessage
|
||||
const status = isErrorObject(error) ? (error as any)?.response?.status : null;
|
||||
if (status) {
|
||||
if (status === 404) {
|
||||
return {
|
||||
title: localize('com_agents_error_not_found_title'),
|
||||
message: getNotFoundMessage(),
|
||||
suggestion: localize('com_agents_error_not_found_suggestion'),
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 400) {
|
||||
return {
|
||||
title: localize('com_agents_error_invalid_request'),
|
||||
message:
|
||||
(errorData as any)?.userMessage || localize('com_agents_error_bad_request_message'),
|
||||
suggestion:
|
||||
(errorData as any)?.suggestion || localize('com_agents_error_bad_request_suggestion'),
|
||||
};
|
||||
}
|
||||
|
||||
if (status >= 500) {
|
||||
return {
|
||||
title: localize('com_agents_error_server_title'),
|
||||
message: localize('com_agents_error_server_message'),
|
||||
suggestion: localize('com_agents_error_server_suggestion'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Use user-friendly message from backend if available (after specific status code handling)
|
||||
if (errorData && typeof errorData === 'object' && (errorData as any)?.userMessage) {
|
||||
return {
|
||||
title: getContextualTitle(),
|
||||
message: (errorData as any).userMessage,
|
||||
suggestion:
|
||||
(errorData as any).suggestion || localize('com_agents_error_suggestion_generic'),
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback to generic error with contextual title
|
||||
return {
|
||||
title: getContextualTitle(),
|
||||
message: localize('com_agents_error_generic'),
|
||||
suggestion: localize('com_agents_error_suggestion_generic'),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Get contextual title based on current operation
|
||||
*/
|
||||
const getContextualTitle = (): string => {
|
||||
if (context?.searchQuery) {
|
||||
return localize('com_agents_error_search_title');
|
||||
}
|
||||
|
||||
if (context?.category) {
|
||||
return localize('com_agents_error_category_title');
|
||||
}
|
||||
|
||||
return localize('com_agents_error_title');
|
||||
};
|
||||
|
||||
/**
|
||||
* Get context-specific not found message
|
||||
*/
|
||||
const getNotFoundMessage = (): string => {
|
||||
if (context?.searchQuery) {
|
||||
return localize('com_agents_search_no_results', { query: context.searchQuery });
|
||||
}
|
||||
|
||||
if (context?.category && context.category !== 'all') {
|
||||
return localize('com_agents_category_empty', { category: context.category });
|
||||
}
|
||||
|
||||
return localize('com_agents_error_not_found_message');
|
||||
};
|
||||
|
||||
const { title, message, suggestion } = getErrorInfo();
|
||||
|
||||
return (
|
||||
<div className="py-12 text-center" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div className="mx-auto max-w-md space-y-4">
|
||||
{/* Error icon with proper accessibility */}
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-12 w-12 items-center justify-center rounded-full',
|
||||
'bg-red-100 dark:bg-red-900/20',
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
className="h-6 w-6 text-red-600 dark:text-red-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
aria-label="Error icon"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error content with proper headings and structure */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white" id="error-title">
|
||||
{title}
|
||||
</h3>
|
||||
<p
|
||||
className="text-gray-600 dark:text-gray-400"
|
||||
id="error-message"
|
||||
aria-describedby="error-title"
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
<p
|
||||
className="text-sm text-gray-500 dark:text-gray-500"
|
||||
id="error-suggestion"
|
||||
role="note"
|
||||
aria-label={`Suggestion: ${suggestion}`}
|
||||
>
|
||||
💡 {suggestion}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Retry button with enhanced accessibility */}
|
||||
{onRetry && (
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
onClick={onRetry}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'border-red-300 text-red-700 hover:bg-red-50 focus:ring-2 focus:ring-red-500',
|
||||
'dark:border-red-600 dark:text-red-400 dark:hover:bg-red-900/20 dark:focus:ring-red-400',
|
||||
)}
|
||||
aria-describedby="error-message error-suggestion"
|
||||
aria-label={`Retry action. ${message}`}
|
||||
>
|
||||
{localize('com_agents_error_retry')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ErrorDisplay;
|
||||
|
|
@ -7,7 +7,6 @@ import { Controller, useFormContext } from 'react-hook-form';
|
|||
import type { TSpecialVarLabel } from 'librechat-data-provider';
|
||||
import type { AgentForm } from '~/common';
|
||||
import { cn, defaultTextProps, removeFocusOutlines } from '~/utils';
|
||||
// import { ControlCombobox } from '@librechat/client';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const inputClass = cn(
|
||||
|
|
@ -49,26 +48,6 @@ export default function Instructions() {
|
|||
{localize('com_ui_instructions')}
|
||||
</label>
|
||||
<div className="ml-auto" title="Add variables to instructions">
|
||||
{/* ControlCombobox implementation
|
||||
<ControlCombobox
|
||||
selectedValue=""
|
||||
displayValue="Add variables"
|
||||
items={variableOptions.map((option) => ({
|
||||
label: option.label,
|
||||
value: option.value,
|
||||
}))}
|
||||
setValue={handleAddVariable}
|
||||
ariaLabel="Add variable to instructions"
|
||||
searchPlaceholder="Search variables"
|
||||
selectPlaceholder="Add"
|
||||
isCollapsed={false}
|
||||
SelectIcon={<PlusCircle className="h-3 w-3 text-text-secondary" />}
|
||||
containerClassName="w-fit"
|
||||
className="h-7 gap-1 rounded-md border border-border-medium bg-surface-secondary px-2 py-0 text-sm text-text-primary transition-colors duration-200 hover:bg-surface-tertiary"
|
||||
iconSide="left"
|
||||
showCarat={false}
|
||||
/>
|
||||
*/}
|
||||
<DropdownPopup
|
||||
portal={true}
|
||||
mountByState={true}
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
import React from 'react';
|
||||
import { ChatContext } from '~/Providers';
|
||||
import { useChatHelpers } from '~/hooks';
|
||||
|
||||
/**
|
||||
* Minimal marketplace provider that provides only what SidePanel actually needs
|
||||
* Replaces the bloated 44-function ChatContext implementation
|
||||
*/
|
||||
interface MarketplaceProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const MarketplaceProvider: React.FC<MarketplaceProviderProps> = ({ children }) => {
|
||||
const chatHelpers = useChatHelpers(0, 'new');
|
||||
|
||||
return <ChatContext.Provider value={chatHelpers as any}>{children}</ChatContext.Provider>;
|
||||
};
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Search, X } from 'lucide-react';
|
||||
import { Input } from '@librechat/client';
|
||||
import { useDebounce, useLocalize } from '~/hooks';
|
||||
|
||||
/**
|
||||
* Props for the SearchBar component
|
||||
*/
|
||||
interface SearchBarProps {
|
||||
/** Current search query value */
|
||||
value: string;
|
||||
/** Callback fired when the search query changes */
|
||||
onSearch: (query: string) => void;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* SearchBar - Component for searching agents with debounced input
|
||||
*
|
||||
* Provides a search input with clear button and debounced search functionality.
|
||||
* Includes proper ARIA attributes for accessibility and visual indicators.
|
||||
* Uses 300ms debounce delay to prevent excessive API calls during typing.
|
||||
*/
|
||||
const SearchBar: React.FC<SearchBarProps> = ({ value, onSearch, className = '' }) => {
|
||||
const localize = useLocalize();
|
||||
const [searchTerm, setSearchTerm] = useState(value);
|
||||
|
||||
// Debounced search value (300ms delay)
|
||||
const debouncedSearchTerm = useDebounce(searchTerm, 300);
|
||||
|
||||
// Update internal state when props change
|
||||
useEffect(() => {
|
||||
setSearchTerm(value);
|
||||
}, [value]);
|
||||
|
||||
// Trigger search when debounced value changes
|
||||
useEffect(() => {
|
||||
// Only trigger search if the debounced value matches current searchTerm
|
||||
// This prevents stale debounced values from triggering after clear
|
||||
if (debouncedSearchTerm !== value && debouncedSearchTerm === searchTerm) {
|
||||
onSearch(debouncedSearchTerm);
|
||||
}
|
||||
}, [debouncedSearchTerm, onSearch, value, searchTerm]);
|
||||
|
||||
/**
|
||||
* Handle search input changes
|
||||
*
|
||||
* @param e - Input change event
|
||||
*/
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchTerm(e.target.value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear the search input and reset results
|
||||
*/
|
||||
const handleClear = useCallback(() => {
|
||||
// Immediately call parent onSearch to clear the URL parameter
|
||||
onSearch('');
|
||||
// Also clear local state
|
||||
setSearchTerm('');
|
||||
}, [onSearch]);
|
||||
|
||||
return (
|
||||
<div className={`relative w-full max-w-4xl ${className}`} role="search">
|
||||
<label htmlFor="agent-search" className="sr-only">
|
||||
{localize('com_agents_search_instructions')}
|
||||
</label>
|
||||
<Input
|
||||
id="agent-search"
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={handleChange}
|
||||
placeholder={localize('com_agents_search_placeholder')}
|
||||
className="h-14 rounded-2xl border-2 border-border-medium bg-transparent pl-12 pr-12 text-lg text-text-primary shadow-lg placeholder:text-text-tertiary focus:border-border-heavy focus:ring-0"
|
||||
aria-label={localize('com_agents_search_aria')}
|
||||
aria-describedby="search-instructions search-results-count"
|
||||
autoComplete="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
|
||||
{/* Search icon with proper accessibility */}
|
||||
<div className="absolute inset-y-0 left-0 flex items-center pl-4" aria-hidden="true">
|
||||
<Search className="h-6 w-6 text-text-tertiary" />
|
||||
</div>
|
||||
|
||||
{/* Hidden instructions for screen readers */}
|
||||
<div id="search-instructions" className="sr-only">
|
||||
{localize('com_agents_search_instructions')}
|
||||
</div>
|
||||
|
||||
{/* Show clear button only when search has value - Google style */}
|
||||
{searchTerm && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="group absolute right-3 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full bg-text-tertiary transition-colors duration-150 hover:bg-text-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
aria-label={localize('com_agents_clear_search')}
|
||||
title={localize('com_agents_clear_search')}
|
||||
>
|
||||
<X className="h-3 w-3 text-white group-hover:text-white" strokeWidth={2.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchBar;
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
import React from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { DropdownPopup } from '@librechat/client';
|
||||
import { ACCESS_ROLE_IDS } from 'librechat-data-provider';
|
||||
import { useGetAccessRolesQuery } from 'librechat-data-provider/react-query';
|
||||
import type { AccessRole } from 'librechat-data-provider';
|
||||
import type * as t from '~/common';
|
||||
import { cn, getRoleLocalizationKeys } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface AccessRolesPickerProps {
|
||||
resourceType?: string;
|
||||
selectedRoleId?: ACCESS_ROLE_IDS;
|
||||
onRoleChange: (roleId: ACCESS_ROLE_IDS) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function AccessRolesPicker({
|
||||
resourceType = 'agent',
|
||||
selectedRoleId = ACCESS_ROLE_IDS.AGENT_VIEWER,
|
||||
onRoleChange,
|
||||
className = '',
|
||||
}: AccessRolesPickerProps) {
|
||||
const localize = useLocalize();
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const { data: accessRoles, isLoading: rolesLoading } = useGetAccessRolesQuery(resourceType);
|
||||
|
||||
/** Helper function to get localized role name and description */
|
||||
const getLocalizedRoleInfo = (roleId: ACCESS_ROLE_IDS) => {
|
||||
const keys = getRoleLocalizationKeys(roleId);
|
||||
return {
|
||||
name: localize(keys.name),
|
||||
description: localize(keys.description),
|
||||
};
|
||||
};
|
||||
|
||||
const selectedRole = accessRoles?.find((role) => role.accessRoleId === selectedRoleId);
|
||||
const selectedRoleInfo = selectedRole ? getLocalizedRoleInfo(selectedRole.accessRoleId) : null;
|
||||
|
||||
if (rolesLoading || !accessRoles) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-border-light border-t-blue-600"></div>
|
||||
<span className="ml-2 text-sm text-text-secondary">{localize('com_ui_loading')}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const dropdownItems: t.MenuItemProps[] = accessRoles.map((role: AccessRole) => {
|
||||
const localizedInfo = getLocalizedRoleInfo(role.accessRoleId);
|
||||
return {
|
||||
id: role.accessRoleId,
|
||||
label: localizedInfo.name,
|
||||
onClick: () => {
|
||||
onRoleChange(role.accessRoleId);
|
||||
setIsOpen(false);
|
||||
},
|
||||
render: (props) => (
|
||||
<button {...props}>
|
||||
<div className="flex flex-col items-start gap-0.5 text-left">
|
||||
<span className="font-medium text-text-primary">{localizedInfo.name}</span>
|
||||
<span className="text-xs text-text-secondary">{localizedInfo.description}</span>
|
||||
</div>
|
||||
</button>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<DropdownPopup
|
||||
menuId="access-roles-menu"
|
||||
isOpen={isOpen}
|
||||
setIsOpen={setIsOpen}
|
||||
trigger={
|
||||
<Ariakit.MenuButton
|
||||
aria-label={selectedRoleInfo?.description || 'Select role'}
|
||||
className={cn(
|
||||
'flex items-center justify-between gap-2 rounded-lg border border-border-light bg-surface-primary px-3 py-2 text-sm transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-ring-primary',
|
||||
'min-w-[200px]',
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">
|
||||
{selectedRoleInfo?.name || localize('com_ui_select')}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 text-text-secondary" />
|
||||
</Ariakit.MenuButton>
|
||||
}
|
||||
items={dropdownItems}
|
||||
className="w-[280px]"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,295 +0,0 @@
|
|||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { ACCESS_ROLE_IDS, PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import { Share2Icon, Users, Loader, Shield, Link, CopyCheck } from 'lucide-react';
|
||||
import {
|
||||
useGetResourcePermissionsQuery,
|
||||
useUpdateResourcePermissionsMutation,
|
||||
} from 'librechat-data-provider/react-query';
|
||||
import {
|
||||
Button,
|
||||
OGDialog,
|
||||
OGDialogTitle,
|
||||
OGDialogClose,
|
||||
OGDialogContent,
|
||||
OGDialogTrigger,
|
||||
useToastContext,
|
||||
} from '@librechat/client';
|
||||
import type { TPrincipal } from 'librechat-data-provider';
|
||||
import { useLocalize, useCopyToClipboard, useHasAccess } from '~/hooks';
|
||||
import ManagePermissionsDialog from './ManagePermissionsDialog';
|
||||
import PublicSharingToggle from './PublicSharingToggle';
|
||||
import PeoplePicker from './PeoplePicker/PeoplePicker';
|
||||
import AccessRolesPicker from './AccessRolesPicker';
|
||||
import { cn, removeFocusOutlines } from '~/utils';
|
||||
|
||||
export default function GrantAccessDialog({
|
||||
agentName,
|
||||
onGrantAccess,
|
||||
resourceType = 'agent',
|
||||
agentDbId,
|
||||
agentId,
|
||||
}: {
|
||||
agentDbId?: string | null;
|
||||
agentId?: string | null;
|
||||
agentName?: string;
|
||||
onGrantAccess?: (shares: TPrincipal[], isPublic: boolean, publicRole: string) => void;
|
||||
resourceType?: string;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
|
||||
// Check if user has permission to access people picker
|
||||
const canViewUsers = useHasAccess({
|
||||
permissionType: PermissionTypes.PEOPLE_PICKER,
|
||||
permission: Permissions.VIEW_USERS,
|
||||
});
|
||||
const canViewGroups = useHasAccess({
|
||||
permissionType: PermissionTypes.PEOPLE_PICKER,
|
||||
permission: Permissions.VIEW_GROUPS,
|
||||
});
|
||||
const hasPeoplePickerAccess = canViewUsers || canViewGroups;
|
||||
|
||||
/** Type filter based on permissions */
|
||||
const peoplePickerTypeFilter = useMemo(() => {
|
||||
if (canViewUsers && canViewGroups) {
|
||||
return null; // Both types allowed
|
||||
} else if (canViewUsers) {
|
||||
return 'user' as const;
|
||||
} else if (canViewGroups) {
|
||||
return 'group' as const;
|
||||
}
|
||||
return null;
|
||||
}, [canViewUsers, canViewGroups]);
|
||||
|
||||
const {
|
||||
data: permissionsData,
|
||||
// isLoading: isLoadingPermissions,
|
||||
// error: permissionsError,
|
||||
} = useGetResourcePermissionsQuery(resourceType, agentDbId!, {
|
||||
enabled: !!agentDbId,
|
||||
});
|
||||
|
||||
const updatePermissionsMutation = useUpdateResourcePermissionsMutation();
|
||||
|
||||
const [newShares, setNewShares] = useState<TPrincipal[]>([]);
|
||||
const [defaultPermissionId, setDefaultPermissionId] = useState<string>(
|
||||
ACCESS_ROLE_IDS.AGENT_VIEWER,
|
||||
);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isCopying, setIsCopying] = useState(false);
|
||||
|
||||
const agentUrl = `${window.location.origin}/c/new?agent_id=${agentId}`;
|
||||
const copyAgentUrl = useCopyToClipboard({ text: agentUrl });
|
||||
|
||||
const currentShares: TPrincipal[] =
|
||||
permissionsData?.principals?.map((principal) => ({
|
||||
type: principal.type,
|
||||
id: principal.id,
|
||||
name: principal.name,
|
||||
email: principal.email,
|
||||
source: principal.source,
|
||||
avatar: principal.avatar,
|
||||
description: principal.description,
|
||||
accessRoleId: principal.accessRoleId,
|
||||
})) || [];
|
||||
|
||||
const currentIsPublic = permissionsData?.public ?? false;
|
||||
const currentPublicRole = permissionsData?.publicAccessRoleId || ACCESS_ROLE_IDS.AGENT_VIEWER;
|
||||
|
||||
const [isPublic, setIsPublic] = useState(false);
|
||||
const [publicRole, setPublicRole] = useState<string>(ACCESS_ROLE_IDS.AGENT_VIEWER);
|
||||
|
||||
useEffect(() => {
|
||||
if (permissionsData && isModalOpen) {
|
||||
setIsPublic(currentIsPublic ?? false);
|
||||
setPublicRole(currentPublicRole);
|
||||
}
|
||||
}, [permissionsData, isModalOpen, currentIsPublic, currentPublicRole]);
|
||||
|
||||
if (!agentDbId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleGrantAccess = async () => {
|
||||
try {
|
||||
const sharesToAdd = newShares.map((share) => ({
|
||||
...share,
|
||||
accessRoleId: defaultPermissionId,
|
||||
}));
|
||||
|
||||
const allShares = [...currentShares, ...sharesToAdd];
|
||||
|
||||
await updatePermissionsMutation.mutateAsync({
|
||||
resourceType,
|
||||
resourceId: agentDbId,
|
||||
data: {
|
||||
updated: sharesToAdd,
|
||||
removed: [],
|
||||
public: isPublic,
|
||||
publicAccessRoleId: isPublic ? publicRole : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
if (onGrantAccess) {
|
||||
onGrantAccess(allShares, isPublic, publicRole);
|
||||
}
|
||||
|
||||
showToast({
|
||||
message: `Access granted successfully to ${newShares.length} ${newShares.length === 1 ? 'person' : 'people'}${isPublic ? ' and made public' : ''}`,
|
||||
status: 'success',
|
||||
});
|
||||
|
||||
setNewShares([]);
|
||||
setDefaultPermissionId(ACCESS_ROLE_IDS.AGENT_VIEWER);
|
||||
setIsPublic(false);
|
||||
setPublicRole(ACCESS_ROLE_IDS.AGENT_VIEWER);
|
||||
setIsModalOpen(false);
|
||||
} catch (error) {
|
||||
console.error('Error granting access:', error);
|
||||
showToast({
|
||||
message: 'Failed to grant access. Please try again.',
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setNewShares([]);
|
||||
setDefaultPermissionId(ACCESS_ROLE_IDS.AGENT_VIEWER);
|
||||
setIsPublic(false);
|
||||
setPublicRole(ACCESS_ROLE_IDS.AGENT_VIEWER);
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
const totalCurrentShares = currentShares.length + (currentIsPublic ? 1 : 0);
|
||||
const submitButtonActive =
|
||||
newShares.length > 0 || isPublic !== currentIsPublic || publicRole !== currentPublicRole;
|
||||
return (
|
||||
<OGDialog open={isModalOpen} onOpenChange={setIsModalOpen} modal>
|
||||
<OGDialogTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'btn btn-neutral border-token-border-light relative h-9 rounded-lg font-medium',
|
||||
removeFocusOutlines,
|
||||
)}
|
||||
aria-label={localize('com_ui_share_var', {
|
||||
0: agentName != null && agentName !== '' ? `"${agentName}"` : localize('com_ui_agent'),
|
||||
})}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2 text-blue-500">
|
||||
<Share2Icon className="icon-md h-4 w-4" />
|
||||
{totalCurrentShares > 0 && (
|
||||
<span className="rounded-full bg-blue-100 px-1.5 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900 dark:text-blue-300">
|
||||
{totalCurrentShares}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</OGDialogTrigger>
|
||||
|
||||
<OGDialogContent className="max-h-[90vh] w-11/12 overflow-y-auto md:max-w-3xl">
|
||||
<OGDialogTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
{localize('com_ui_share_var', {
|
||||
0:
|
||||
agentName != null && agentName !== '' ? `"${agentName}"` : localize('com_ui_agent'),
|
||||
})}
|
||||
</div>
|
||||
</OGDialogTitle>
|
||||
|
||||
<div className="space-y-6 p-2">
|
||||
{hasPeoplePickerAccess && (
|
||||
<>
|
||||
<PeoplePicker
|
||||
onSelectionChange={setNewShares}
|
||||
placeholder={localize('com_ui_search_people_placeholder')}
|
||||
typeFilter={peoplePickerTypeFilter}
|
||||
/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4 text-text-secondary" />
|
||||
<label className="text-sm font-medium text-text-primary">
|
||||
{localize('com_ui_permission_level')}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<AccessRolesPicker
|
||||
resourceType={resourceType}
|
||||
selectedRoleId={defaultPermissionId}
|
||||
onRoleChange={setDefaultPermissionId}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<PublicSharingToggle
|
||||
isPublic={isPublic}
|
||||
publicRole={publicRole}
|
||||
onPublicToggle={setIsPublic}
|
||||
onPublicRoleChange={setPublicRole}
|
||||
resourceType={resourceType}
|
||||
/>
|
||||
<div className="flex justify-between border-t pt-4">
|
||||
<div className="flex gap-2">
|
||||
{hasPeoplePickerAccess && (
|
||||
<ManagePermissionsDialog
|
||||
agentDbId={agentDbId}
|
||||
agentName={agentName}
|
||||
resourceType={resourceType}
|
||||
/>
|
||||
)}
|
||||
{agentId && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (isCopying) return;
|
||||
copyAgentUrl(setIsCopying);
|
||||
showToast({
|
||||
message: localize('com_ui_agent_url_copied'),
|
||||
status: 'success',
|
||||
});
|
||||
}}
|
||||
disabled={isCopying}
|
||||
className={cn('shrink-0', isCopying ? 'cursor-default' : '')}
|
||||
aria-label={localize('com_ui_copy_url_to_clipboard')}
|
||||
title={
|
||||
isCopying
|
||||
? localize('com_ui_agent_url_copied')
|
||||
: localize('com_ui_copy_url_to_clipboard')
|
||||
}
|
||||
>
|
||||
{isCopying ? <CopyCheck className="h-4 w-4" /> : <Link className="h-4 w-4" />}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<OGDialogClose asChild>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
{localize('com_ui_cancel')}
|
||||
</Button>
|
||||
</OGDialogClose>
|
||||
<Button
|
||||
onClick={handleGrantAccess}
|
||||
disabled={updatePermissionsMutation.isLoading || !submitButtonActive}
|
||||
className="min-w-[120px]"
|
||||
>
|
||||
{updatePermissionsMutation.isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader className="h-4 w-4 animate-spin" />
|
||||
{localize('com_ui_granting')}
|
||||
</div>
|
||||
) : (
|
||||
localize('com_ui_grant_access')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,352 +0,0 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { ACCESS_ROLE_IDS, TPrincipal } from 'librechat-data-provider';
|
||||
import { Settings, Users, Loader, UserCheck, Trash2, Shield } from 'lucide-react';
|
||||
import {
|
||||
useGetAccessRolesQuery,
|
||||
useGetResourcePermissionsQuery,
|
||||
useUpdateResourcePermissionsMutation,
|
||||
} from 'librechat-data-provider/react-query';
|
||||
import {
|
||||
Button,
|
||||
OGDialog,
|
||||
OGDialogTitle,
|
||||
OGDialogClose,
|
||||
OGDialogContent,
|
||||
OGDialogTrigger,
|
||||
useToastContext,
|
||||
} from '@librechat/client';
|
||||
import SelectedPrincipalsList from './PeoplePicker/SelectedPrincipalsList';
|
||||
import PublicSharingToggle from './PublicSharingToggle';
|
||||
import { cn, removeFocusOutlines } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export default function ManagePermissionsDialog({
|
||||
agentDbId,
|
||||
agentName,
|
||||
resourceType = 'agent',
|
||||
onUpdatePermissions,
|
||||
}: {
|
||||
agentDbId: string;
|
||||
agentName?: string;
|
||||
resourceType?: string;
|
||||
onUpdatePermissions?: (shares: TPrincipal[], isPublic: boolean, publicRole: string) => void;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
|
||||
const {
|
||||
data: permissionsData,
|
||||
isLoading: isLoadingPermissions,
|
||||
error: permissionsError,
|
||||
} = useGetResourcePermissionsQuery(resourceType, agentDbId, {
|
||||
enabled: !!agentDbId,
|
||||
});
|
||||
const {
|
||||
data: accessRoles,
|
||||
// isLoading,
|
||||
} = useGetAccessRolesQuery(resourceType);
|
||||
|
||||
const updatePermissionsMutation = useUpdateResourcePermissionsMutation();
|
||||
|
||||
const [managedShares, setManagedShares] = useState<TPrincipal[]>([]);
|
||||
const [managedIsPublic, setManagedIsPublic] = useState(false);
|
||||
const [managedPublicRole, setManagedPublicRole] = useState<string>(ACCESS_ROLE_IDS.AGENT_VIEWER);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
const currentShares: TPrincipal[] = permissionsData?.principals || [];
|
||||
|
||||
const isPublic = permissionsData?.public || false;
|
||||
const publicRole = permissionsData?.publicAccessRoleId || ACCESS_ROLE_IDS.AGENT_VIEWER;
|
||||
|
||||
useEffect(() => {
|
||||
if (permissionsData) {
|
||||
const shares = permissionsData.principals || [];
|
||||
const isPublicValue = permissionsData.public || false;
|
||||
const publicRoleValue = permissionsData.publicAccessRoleId || ACCESS_ROLE_IDS.AGENT_VIEWER;
|
||||
|
||||
setManagedShares(shares);
|
||||
setManagedIsPublic(isPublicValue);
|
||||
setManagedPublicRole(publicRoleValue);
|
||||
setHasChanges(false);
|
||||
}
|
||||
}, [permissionsData, isModalOpen]);
|
||||
|
||||
if (!agentDbId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (permissionsError) {
|
||||
return <div className="text-sm text-red-600">{localize('com_ui_permissions_failed_load')}</div>;
|
||||
}
|
||||
|
||||
const handleRemoveShare = (idOnTheSource: string) => {
|
||||
setManagedShares(managedShares.filter((s) => s.idOnTheSource !== idOnTheSource));
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleRoleChange = (idOnTheSource: string, newRole: string) => {
|
||||
setManagedShares(
|
||||
managedShares.map((s) =>
|
||||
s.idOnTheSource === idOnTheSource ? { ...s, accessRoleId: newRole } : s,
|
||||
),
|
||||
);
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleSaveChanges = async () => {
|
||||
try {
|
||||
const originalSharesMap = new Map(
|
||||
currentShares.map((share) => [`${share.type}-${share.idOnTheSource}`, share]),
|
||||
);
|
||||
const managedSharesMap = new Map(
|
||||
managedShares.map((share) => [`${share.type}-${share.idOnTheSource}`, share]),
|
||||
);
|
||||
|
||||
const updated = managedShares.filter((share) => {
|
||||
const key = `${share.type}-${share.idOnTheSource}`;
|
||||
const original = originalSharesMap.get(key);
|
||||
return !original || original.accessRoleId !== share.accessRoleId;
|
||||
});
|
||||
|
||||
const removed = currentShares.filter((share) => {
|
||||
const key = `${share.type}-${share.idOnTheSource}`;
|
||||
return !managedSharesMap.has(key);
|
||||
});
|
||||
|
||||
await updatePermissionsMutation.mutateAsync({
|
||||
resourceType,
|
||||
resourceId: agentDbId,
|
||||
data: {
|
||||
updated,
|
||||
removed,
|
||||
public: managedIsPublic,
|
||||
publicAccessRoleId: managedIsPublic ? managedPublicRole : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
if (onUpdatePermissions) {
|
||||
onUpdatePermissions(managedShares, managedIsPublic, managedPublicRole);
|
||||
}
|
||||
|
||||
showToast({
|
||||
message: localize('com_ui_permissions_updated_success'),
|
||||
status: 'success',
|
||||
});
|
||||
|
||||
setIsModalOpen(false);
|
||||
} catch (error) {
|
||||
console.error('Error updating permissions:', error);
|
||||
showToast({
|
||||
message: localize('com_ui_permissions_failed_update'),
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setManagedShares(currentShares);
|
||||
setManagedIsPublic(isPublic);
|
||||
setManagedPublicRole(publicRole);
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
const handleRevokeAll = () => {
|
||||
setManagedShares([]);
|
||||
setManagedIsPublic(false);
|
||||
setHasChanges(true);
|
||||
};
|
||||
const handlePublicToggle = (isPublic: boolean) => {
|
||||
setManagedIsPublic(isPublic);
|
||||
setHasChanges(true);
|
||||
if (!isPublic) {
|
||||
setManagedPublicRole(ACCESS_ROLE_IDS.AGENT_VIEWER);
|
||||
}
|
||||
};
|
||||
const handlePublicRoleChange = (role: string) => {
|
||||
setManagedPublicRole(role);
|
||||
setHasChanges(true);
|
||||
};
|
||||
const totalShares = managedShares.length + (managedIsPublic ? 1 : 0);
|
||||
const originalTotalShares = currentShares.length + (isPublic ? 1 : 0);
|
||||
|
||||
/** Check if there's at least one owner (user, group, or public with owner role) */
|
||||
const hasAtLeastOneOwner =
|
||||
managedShares.some((share) => share.accessRoleId === ACCESS_ROLE_IDS.AGENT_OWNER) ||
|
||||
(managedIsPublic && managedPublicRole === ACCESS_ROLE_IDS.AGENT_OWNER);
|
||||
|
||||
let peopleLabel = localize('com_ui_people');
|
||||
if (managedShares.length === 1) {
|
||||
peopleLabel = localize('com_ui_person');
|
||||
}
|
||||
|
||||
let buttonAriaLabel = localize('com_ui_manage_permissions_for') + ' agent';
|
||||
if (agentName != null && agentName !== '') {
|
||||
buttonAriaLabel = localize('com_ui_manage_permissions_for') + ` "${agentName}"`;
|
||||
}
|
||||
|
||||
let dialogTitle = localize('com_ui_manage_permissions_for') + ' Agent';
|
||||
if (agentName != null && agentName !== '') {
|
||||
dialogTitle = localize('com_ui_manage_permissions_for') + ` "${agentName}"`;
|
||||
}
|
||||
|
||||
let publicSuffix = '';
|
||||
if (managedIsPublic) {
|
||||
publicSuffix = localize('com_ui_and_public');
|
||||
}
|
||||
|
||||
return (
|
||||
<OGDialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<OGDialogTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'btn btn-neutral border-token-border-light relative h-9 rounded-lg font-medium',
|
||||
removeFocusOutlines,
|
||||
)}
|
||||
aria-label={buttonAriaLabel}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2 text-blue-500">
|
||||
<Settings className="icon-md h-4 w-4" />
|
||||
<span className="hidden sm:inline">{localize('com_ui_manage')}</span>
|
||||
{originalTotalShares > 0 && `(${originalTotalShares})`}
|
||||
</div>
|
||||
</button>
|
||||
</OGDialogTrigger>
|
||||
|
||||
<OGDialogContent className="max-h-[90vh] w-11/12 overflow-y-auto md:max-w-3xl">
|
||||
<OGDialogTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-blue-500" />
|
||||
{dialogTitle}
|
||||
</div>
|
||||
</OGDialogTitle>
|
||||
|
||||
<div className="space-y-6 p-2">
|
||||
<div className="rounded-lg bg-surface-tertiary p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-text-primary">
|
||||
{localize('com_ui_current_access')}
|
||||
</h3>
|
||||
<p className="text-xs text-text-secondary">
|
||||
{(() => {
|
||||
if (totalShares === 0) {
|
||||
return localize('com_ui_no_users_groups_access');
|
||||
}
|
||||
return localize('com_ui_shared_with_count', {
|
||||
0: managedShares.length,
|
||||
1: peopleLabel,
|
||||
2: publicSuffix,
|
||||
});
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
{(managedShares.length > 0 || managedIsPublic) && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRevokeAll}
|
||||
className="text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{localize('com_ui_revoke_all')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(() => {
|
||||
if (isLoadingPermissions) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader className="h-6 w-6 animate-spin" />
|
||||
<span className="ml-2 text-sm text-text-secondary">
|
||||
{localize('com_ui_loading_permissions')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (managedShares.length > 0) {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-3 flex items-center gap-2 text-sm font-medium text-text-primary">
|
||||
<UserCheck className="h-4 w-4" />
|
||||
{localize('com_ui_user_group_permissions')} ({managedShares.length})
|
||||
</h3>
|
||||
<SelectedPrincipalsList
|
||||
principles={managedShares}
|
||||
onRemoveHandler={handleRemoveShare}
|
||||
availableRoles={accessRoles || []}
|
||||
onRoleChange={(id, newRole) => handleRoleChange(id, newRole)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border-2 border-dashed border-border-light p-8 text-center">
|
||||
<Users className="mx-auto h-8 w-8 text-text-secondary" />
|
||||
<p className="mt-2 text-sm text-text-secondary">
|
||||
{localize('com_ui_no_individual_access')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-medium text-text-primary">
|
||||
{localize('com_ui_public_access')}
|
||||
</h3>
|
||||
<PublicSharingToggle
|
||||
isPublic={managedIsPublic}
|
||||
publicRole={managedPublicRole}
|
||||
onPublicToggle={handlePublicToggle}
|
||||
onPublicRoleChange={handlePublicRoleChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 border-t pt-4">
|
||||
<OGDialogClose asChild>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
{localize('com_ui_cancel')}
|
||||
</Button>
|
||||
</OGDialogClose>
|
||||
<Button
|
||||
onClick={handleSaveChanges}
|
||||
disabled={
|
||||
updatePermissionsMutation.isLoading ||
|
||||
!hasChanges ||
|
||||
isLoadingPermissions ||
|
||||
!hasAtLeastOneOwner
|
||||
}
|
||||
className="min-w-[120px]"
|
||||
>
|
||||
{updatePermissionsMutation.isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader className="h-4 w-4 animate-spin" />
|
||||
{localize('com_ui_saving')}
|
||||
</div>
|
||||
) : (
|
||||
localize('com_ui_save_changes')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{hasChanges && (
|
||||
<div className="text-xs text-orange-600 dark:text-orange-400">
|
||||
* {localize('com_ui_unsaved_changes')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasAtLeastOneOwner && hasChanges && (
|
||||
<div className="text-xs text-red-600 dark:text-red-400">
|
||||
* {localize('com_ui_at_least_one_owner_required')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
import React, { useState, useMemo } from 'react';
|
||||
import type { TPrincipal, PrincipalSearchParams } from 'librechat-data-provider';
|
||||
import { useSearchPrincipalsQuery } from 'librechat-data-provider/react-query';
|
||||
import PeoplePickerSearchItem from './PeoplePickerSearchItem';
|
||||
import SelectedPrincipalsList from './SelectedPrincipalsList';
|
||||
import { SearchPicker } from '~/components/ui/SearchPicker';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface PeoplePickerProps {
|
||||
onSelectionChange: (principals: TPrincipal[]) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
typeFilter?: 'user' | 'group' | null;
|
||||
}
|
||||
|
||||
export default function PeoplePicker({
|
||||
onSelectionChange,
|
||||
placeholder,
|
||||
className = '',
|
||||
typeFilter = null,
|
||||
}: PeoplePickerProps) {
|
||||
const localize = useLocalize();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedShares, setSelectedShares] = useState<TPrincipal[]>([]);
|
||||
|
||||
const searchParams: PrincipalSearchParams = useMemo(
|
||||
() => ({
|
||||
q: searchQuery,
|
||||
limit: 30,
|
||||
...(typeFilter && { type: typeFilter }),
|
||||
}),
|
||||
[searchQuery, typeFilter],
|
||||
);
|
||||
|
||||
const {
|
||||
data: searchResponse,
|
||||
isLoading: queryIsLoading,
|
||||
error,
|
||||
} = useSearchPrincipalsQuery(searchParams, {
|
||||
enabled: searchQuery.length >= 2,
|
||||
});
|
||||
|
||||
const isLoading = searchQuery.length >= 2 && queryIsLoading;
|
||||
|
||||
const selectableResults = useMemo(() => {
|
||||
const results = searchResponse?.results || [];
|
||||
|
||||
return results.filter(
|
||||
(result) => !selectedShares.some((share) => share.idOnTheSource === result.idOnTheSource),
|
||||
);
|
||||
}, [searchResponse?.results, selectedShares]);
|
||||
|
||||
if (error) {
|
||||
console.error('Principal search error:', error);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`space-y-3 ${className}`}>
|
||||
<div className="relative">
|
||||
<SearchPicker<TPrincipal & { key: string; value: string }>
|
||||
options={selectableResults.map((s) => {
|
||||
const key = s.idOnTheSource || 'unknown' + 'picker_key';
|
||||
const value = s.idOnTheSource || 'Unknown';
|
||||
return {
|
||||
...s,
|
||||
id: s.id ?? undefined,
|
||||
key,
|
||||
value,
|
||||
};
|
||||
})}
|
||||
renderOptions={(o) => <PeoplePickerSearchItem principal={o} />}
|
||||
placeholder={placeholder || localize('com_ui_search_default_placeholder')}
|
||||
query={searchQuery}
|
||||
onQueryChange={(query: string) => {
|
||||
setSearchQuery(query);
|
||||
}}
|
||||
onPick={(principal) => {
|
||||
console.log('Selected Principal:', principal);
|
||||
setSelectedShares((prev) => {
|
||||
const newArray = [...prev, principal];
|
||||
onSelectionChange([...newArray]);
|
||||
return newArray;
|
||||
});
|
||||
setSearchQuery('');
|
||||
}}
|
||||
label={localize('com_ui_search_users_groups')}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SelectedPrincipalsList
|
||||
principles={selectedShares}
|
||||
onRemoveHandler={(idOnTheSource: string) => {
|
||||
setSelectedShares((prev) => {
|
||||
const newArray = prev.filter((share) => share.idOnTheSource !== idOnTheSource);
|
||||
onSelectionChange(newArray);
|
||||
return newArray;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
import React, { forwardRef } from 'react';
|
||||
import type { TPrincipal } from 'librechat-data-provider';
|
||||
import { cn } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import PrincipalAvatar from '../PrincipalAvatar';
|
||||
|
||||
interface PeoplePickerSearchItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
principal: TPrincipal;
|
||||
}
|
||||
|
||||
const PeoplePickerSearchItem = forwardRef<HTMLDivElement, PeoplePickerSearchItemProps>(
|
||||
function PeoplePickerSearchItem(
|
||||
{ principal, className, style, onClick, ...props },
|
||||
forwardedRef,
|
||||
) {
|
||||
const localize = useLocalize();
|
||||
const { name, email, type } = principal;
|
||||
|
||||
// Display name with fallback
|
||||
const displayName = name || localize('com_ui_unknown');
|
||||
const subtitle = email || `${type} (${principal.source || 'local'})`;
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
ref={forwardedRef}
|
||||
className={cn('flex items-center gap-3 p-2', className)}
|
||||
style={style}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
}}
|
||||
>
|
||||
<PrincipalAvatar principal={principal} size="md" />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-text-primary">{displayName}</div>
|
||||
<div className="truncate text-xs text-text-secondary">{subtitle}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full px-2 py-1 text-xs font-medium',
|
||||
type === 'user'
|
||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300'
|
||||
: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300',
|
||||
)}
|
||||
>
|
||||
{type === 'user' ? localize('com_ui_user') : localize('com_ui_group')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default PeoplePickerSearchItem;
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
import React, { useState, useId } from 'react';
|
||||
import * as Menu from '@ariakit/react/menu';
|
||||
import { Button, DropdownPopup } from '@librechat/client';
|
||||
import { Users, X, ExternalLink, ChevronDown } from 'lucide-react';
|
||||
import type { TPrincipal, TAccessRole, ACCESS_ROLE_IDS } from 'librechat-data-provider';
|
||||
import { getRoleLocalizationKeys } from '~/utils';
|
||||
import PrincipalAvatar from '../PrincipalAvatar';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface SelectedPrincipalsListProps {
|
||||
principles: TPrincipal[];
|
||||
onRemoveHandler: (idOnTheSource: string) => void;
|
||||
onRoleChange?: (idOnTheSource: string, newRoleId: string) => void;
|
||||
availableRoles?: Omit<TAccessRole, 'resourceType'>[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function SelectedPrincipalsList({
|
||||
principles,
|
||||
onRemoveHandler,
|
||||
className = '',
|
||||
onRoleChange,
|
||||
availableRoles,
|
||||
}: SelectedPrincipalsListProps) {
|
||||
const localize = useLocalize();
|
||||
|
||||
const getPrincipalDisplayInfo = (principal: TPrincipal) => {
|
||||
const displayName = principal.name || localize('com_ui_unknown');
|
||||
const subtitle = principal.email || `${principal.type} (${principal.source || 'local'})`;
|
||||
|
||||
return { displayName, subtitle };
|
||||
};
|
||||
|
||||
if (principles.length === 0) {
|
||||
return (
|
||||
<div className={`space-y-3 ${className}`}>
|
||||
<div className="rounded-lg border border-dashed border-border py-8 text-center text-muted-foreground">
|
||||
<Users className="mx-auto mb-2 h-8 w-8 opacity-50" />
|
||||
<p className="mt-1 text-xs">{localize('com_ui_search_above_to_add')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`space-y-3 ${className}`}>
|
||||
<div className="space-y-2">
|
||||
{principles.map((share) => {
|
||||
const { displayName, subtitle } = getPrincipalDisplayInfo(share);
|
||||
return (
|
||||
<div
|
||||
key={share.idOnTheSource + '-principalList'}
|
||||
className="bg-surface flex items-center justify-between rounded-lg border border-border p-3"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<PrincipalAvatar principal={share} size="md" />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{displayName}</div>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<span>{subtitle}</span>
|
||||
{share.source === 'entra' && (
|
||||
<>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
<span>{localize('com_ui_azure_ad')}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
{!!share.accessRoleId && !!onRoleChange && (
|
||||
<RoleSelector
|
||||
currentRole={share.accessRoleId}
|
||||
onRoleChange={(newRole) => {
|
||||
onRoleChange?.(share.idOnTheSource!, newRole);
|
||||
}}
|
||||
availableRoles={availableRoles ?? []}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onRemoveHandler(share.idOnTheSource!)}
|
||||
className="h-8 w-8 p-0 hover:bg-destructive/10 hover:text-destructive"
|
||||
aria-label={localize('com_ui_remove_user', { 0: displayName })}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RoleSelectorProps {
|
||||
currentRole: ACCESS_ROLE_IDS;
|
||||
onRoleChange: (newRole: ACCESS_ROLE_IDS) => void;
|
||||
availableRoles: Omit<TAccessRole, 'resourceType'>[];
|
||||
}
|
||||
|
||||
function RoleSelector({ currentRole, onRoleChange, availableRoles }: RoleSelectorProps) {
|
||||
const menuId = useId();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const localize = useLocalize();
|
||||
|
||||
const getLocalizedRoleName = (roleId: ACCESS_ROLE_IDS) => {
|
||||
const keys = getRoleLocalizationKeys(roleId);
|
||||
return localize(keys.name);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownPopup
|
||||
portal={true}
|
||||
mountByState={true}
|
||||
unmountOnHide={true}
|
||||
preserveTabOrder={true}
|
||||
isOpen={isMenuOpen}
|
||||
setIsOpen={setIsMenuOpen}
|
||||
trigger={
|
||||
<Menu.MenuButton className="flex h-8 items-center gap-2 rounded-md border border-border-medium bg-surface-secondary px-2 py-1 text-sm font-medium transition-colors duration-200 hover:bg-surface-tertiary">
|
||||
<span className="hidden sm:inline">{getLocalizedRoleName(currentRole)}</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Menu.MenuButton>
|
||||
}
|
||||
items={availableRoles?.map((role) => ({
|
||||
id: role.accessRoleId,
|
||||
label: getLocalizedRoleName(role.accessRoleId),
|
||||
onClick: () => onRoleChange(role.accessRoleId),
|
||||
}))}
|
||||
menuId={menuId}
|
||||
className="z-50 [pointer-events:auto]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
import React from 'react';
|
||||
import { Users, User } from 'lucide-react';
|
||||
import type { TPrincipal } from 'librechat-data-provider';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface PrincipalAvatarProps {
|
||||
principal: TPrincipal;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function PrincipalAvatar({
|
||||
principal,
|
||||
size = 'md',
|
||||
className,
|
||||
}: PrincipalAvatarProps) {
|
||||
const { avatar, type, name } = principal;
|
||||
const displayName = name || 'Unknown';
|
||||
|
||||
// Size variants
|
||||
const sizeClasses = {
|
||||
sm: 'h-6 w-6',
|
||||
md: 'h-8 w-8',
|
||||
lg: 'h-10 w-10',
|
||||
};
|
||||
|
||||
const iconSizeClasses = {
|
||||
sm: 'h-3 w-3',
|
||||
md: 'h-4 w-4',
|
||||
lg: 'h-5 w-5',
|
||||
};
|
||||
|
||||
const avatarSizeClass = sizeClasses[size];
|
||||
const iconSizeClass = iconSizeClasses[size];
|
||||
|
||||
// Avatar or icon logic
|
||||
if (avatar) {
|
||||
return (
|
||||
<div className={cn('flex-shrink-0', className)}>
|
||||
<img
|
||||
src={avatar}
|
||||
alt={`${displayName} avatar`}
|
||||
className={cn(avatarSizeClass, 'rounded-full object-cover')}
|
||||
onError={(e) => {
|
||||
// Fallback to icon if image fails to load
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
target.nextElementSibling?.classList.remove('hidden');
|
||||
}}
|
||||
/>
|
||||
{/* Hidden fallback icon that shows if image fails */}
|
||||
<div className={cn('hidden', avatarSizeClass)}>
|
||||
{type === 'user' ? (
|
||||
<div
|
||||
className={cn(
|
||||
avatarSizeClass,
|
||||
'flex items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900',
|
||||
)}
|
||||
>
|
||||
<User className={cn(iconSizeClass, 'text-blue-600 dark:text-blue-400')} />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
avatarSizeClass,
|
||||
'flex items-center justify-center rounded-full bg-green-100 dark:bg-green-900',
|
||||
)}
|
||||
>
|
||||
<Users className={cn(iconSizeClass, 'text-green-600 dark:text-green-400')} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback icon based on type
|
||||
return (
|
||||
<div className={cn('flex-shrink-0', className)}>
|
||||
{type === 'user' ? (
|
||||
<div
|
||||
className={cn(
|
||||
avatarSizeClass,
|
||||
'flex items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900',
|
||||
)}
|
||||
>
|
||||
<User className={cn(iconSizeClass, 'text-blue-600 dark:text-blue-400')} />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
avatarSizeClass,
|
||||
'flex items-center justify-center rounded-full bg-green-100 dark:bg-green-900',
|
||||
)}
|
||||
>
|
||||
<Users className={cn(iconSizeClass, 'text-green-600 dark:text-green-400')} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
import React from 'react';
|
||||
import { Globe } from 'lucide-react';
|
||||
import { Switch } from '@librechat/client';
|
||||
import AccessRolesPicker from './AccessRolesPicker';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface PublicSharingToggleProps {
|
||||
isPublic: boolean;
|
||||
publicRole: string;
|
||||
onPublicToggle: (isPublic: boolean) => void;
|
||||
onPublicRoleChange: (role: string) => void;
|
||||
className?: string;
|
||||
resourceType?: string;
|
||||
}
|
||||
|
||||
export default function PublicSharingToggle({
|
||||
isPublic,
|
||||
publicRole,
|
||||
onPublicToggle,
|
||||
onPublicRoleChange,
|
||||
className = '',
|
||||
resourceType = 'agent',
|
||||
}: PublicSharingToggleProps) {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<div className={`space-y-3 border-t pt-4 ${className}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="flex items-center gap-2 text-sm font-medium">
|
||||
<Globe className="h-4 w-4" />
|
||||
{localize('com_ui_share_with_everyone')}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{localize('com_ui_make_agent_available_all_users')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={isPublic}
|
||||
onCheckedChange={onPublicToggle}
|
||||
aria-label={localize('com_ui_share_with_everyone')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isPublic && (
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium">
|
||||
{localize('com_ui_public_access_level')}
|
||||
</label>
|
||||
<AccessRolesPicker
|
||||
resourceType={resourceType}
|
||||
selectedRoleId={publicRole}
|
||||
onRoleChange={onPublicRoleChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
import { AgentListResponse } from 'librechat-data-provider';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
interface SmartLoaderProps {
|
||||
/** Whether the content is currently loading */
|
||||
isLoading: boolean;
|
||||
/** Whether there is existing data to show */
|
||||
hasData: boolean;
|
||||
/** Delay before showing loading state (in ms) - prevents flashing for quick loads */
|
||||
delay?: number;
|
||||
/** Loading skeleton/spinner component */
|
||||
loadingComponent: React.ReactNode;
|
||||
/** Content to show when loaded */
|
||||
children: React.ReactNode;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* SmartLoader - Intelligent loading wrapper that prevents flashing
|
||||
*
|
||||
* Only shows loading states when:
|
||||
* 1. Actually loading AND no existing data
|
||||
* 2. Loading has lasted longer than the delay threshold
|
||||
*
|
||||
* This prevents brief loading flashes for cached/fast responses
|
||||
*/
|
||||
export const SmartLoader: React.FC<SmartLoaderProps> = ({
|
||||
isLoading,
|
||||
hasData,
|
||||
delay = 150,
|
||||
loadingComponent,
|
||||
children,
|
||||
className = '',
|
||||
}) => {
|
||||
const [shouldShowLoading, setShouldShowLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
|
||||
if (isLoading && !hasData) {
|
||||
// Only show loading after delay to prevent flashing
|
||||
timeoutId = setTimeout(() => {
|
||||
setShouldShowLoading(true);
|
||||
}, delay);
|
||||
} else {
|
||||
// Immediately hide loading when done
|
||||
setShouldShowLoading(false);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
}, [isLoading, hasData, delay]);
|
||||
|
||||
// Show loading state only if we've determined it should be shown
|
||||
if (shouldShowLoading) {
|
||||
return <div className={className}>{loadingComponent}</div>;
|
||||
}
|
||||
|
||||
// Show content (including when loading but we have existing data)
|
||||
return <div className={className}>{children}</div>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to determine if we have meaningful data to show
|
||||
* Helps prevent loading states when we already have cached content
|
||||
*/
|
||||
export const useHasData = (data: AgentListResponse | undefined): boolean => {
|
||||
if (!data) return false;
|
||||
|
||||
// Type guard for object data
|
||||
if (typeof data === 'object' && data !== null) {
|
||||
// Check for agent list data
|
||||
if ('agents' in data) {
|
||||
const agents = (data as any).agents;
|
||||
return Array.isArray(agents) && agents.length > 0;
|
||||
}
|
||||
|
||||
// Check for single agent data
|
||||
if ('id' in data || 'name' in data) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for categories data (array)
|
||||
if (Array.isArray(data)) {
|
||||
return data.length > 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export default SmartLoader;
|
||||
|
|
@ -1,536 +0,0 @@
|
|||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import CategoryTabs from '../CategoryTabs';
|
||||
import AgentGrid from '../AgentGrid';
|
||||
import AgentCard from '../AgentCard';
|
||||
import SearchBar from '../SearchBar';
|
||||
import ErrorDisplay from '../ErrorDisplay';
|
||||
import * as t from 'librechat-data-provider';
|
||||
|
||||
// Mock matchMedia
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation(() => ({
|
||||
matches: false,
|
||||
media: '',
|
||||
onchange: null,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
// Mock Recoil
|
||||
jest.mock('recoil', () => ({
|
||||
useRecoilValue: jest.fn(() => 'en'),
|
||||
RecoilRoot: ({ children }: any) => children,
|
||||
atom: jest.fn(() => ({})),
|
||||
atomFamily: jest.fn(() => ({})),
|
||||
selector: jest.fn(() => ({})),
|
||||
selectorFamily: jest.fn(() => ({})),
|
||||
useRecoilState: jest.fn(() => ['en', jest.fn()]),
|
||||
useSetRecoilState: jest.fn(() => jest.fn()),
|
||||
}));
|
||||
|
||||
// Mock react-i18next
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: { changeLanguage: jest.fn() },
|
||||
}),
|
||||
}));
|
||||
|
||||
// Create the localize function once to be reused
|
||||
const mockLocalize = jest.fn((key: string, options?: any) => {
|
||||
const translations: Record<string, string> = {
|
||||
com_agents_category_tabs_label: 'Agent Categories',
|
||||
com_agents_category_tab_label: `${options?.category} category, ${options?.position} of ${options?.total}`,
|
||||
com_agents_search_instructions: 'Type to search agents by name or description',
|
||||
com_agents_search_aria: 'Search agents',
|
||||
com_agents_search_placeholder: 'Search agents...',
|
||||
com_agents_clear_search: 'Clear search',
|
||||
com_agents_agent_card_label: `${options?.name} agent. ${options?.description}`,
|
||||
com_agents_no_description: 'No description available',
|
||||
com_agents_grid_announcement: `Showing ${options?.count} agents in ${options?.category} category`,
|
||||
com_agents_load_more_label: `Load more agents from ${options?.category} category`,
|
||||
com_agents_error_retry: 'Try Again',
|
||||
com_agents_loading: 'Loading...',
|
||||
com_agents_empty_state_heading: 'No agents found',
|
||||
com_agents_search_empty_heading: 'No search results',
|
||||
com_agents_created_by: 'by',
|
||||
com_agents_top_picks: 'Top Picks',
|
||||
// ErrorDisplay translations
|
||||
com_agents_error_suggestion_generic: 'Try refreshing the page or check your network connection',
|
||||
com_agents_error_network_title: 'Network Error',
|
||||
com_agents_error_network_message: 'Unable to connect to the server',
|
||||
com_agents_error_network_suggestion: 'Check your internet connection and try again',
|
||||
com_agents_error_not_found_title: 'Not Found',
|
||||
com_agents_error_not_found_suggestion: 'The requested resource could not be found',
|
||||
com_agents_error_invalid_request: 'Invalid Request',
|
||||
com_agents_error_bad_request_message: 'The request was invalid',
|
||||
com_agents_error_bad_request_suggestion: 'Please check your input and try again',
|
||||
com_agents_error_server_title: 'Server Error',
|
||||
com_agents_error_server_message: 'An internal server error occurred',
|
||||
com_agents_error_server_suggestion: 'Please try again later',
|
||||
com_agents_error_title: 'Error',
|
||||
com_agents_error_generic: 'An unexpected error occurred',
|
||||
com_agents_error_search_title: 'Search Error',
|
||||
com_agents_error_category_title: 'Category Error',
|
||||
com_agents_search_no_results: `No results found for "${options?.query}"`,
|
||||
com_agents_category_empty: `No agents found in ${options?.category} category`,
|
||||
com_agents_error_not_found_message: 'The requested resource could not be found',
|
||||
};
|
||||
return translations[key] || key;
|
||||
});
|
||||
|
||||
// Mock useLocalize specifically
|
||||
jest.mock('~/hooks/useLocalize', () => ({
|
||||
__esModule: true,
|
||||
default: () => mockLocalize,
|
||||
}));
|
||||
|
||||
// Mock hooks
|
||||
jest.mock('~/hooks', () => ({
|
||||
useLocalize: () => mockLocalize,
|
||||
useDebounce: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/data-provider/Agents', () => ({
|
||||
useMarketplaceAgentsInfiniteQuery: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks/Agents', () => ({
|
||||
useAgentCategories: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock utility functions
|
||||
jest.mock('~/utils/agents', () => ({
|
||||
renderAgentAvatar: jest.fn(() => <div data-testid="agent-avatar" />),
|
||||
getContactDisplayName: jest.fn((agent) => agent.authorName),
|
||||
}));
|
||||
|
||||
// Mock SmartLoader
|
||||
jest.mock('../SmartLoader', () => ({
|
||||
SmartLoader: ({ children, isLoading }: any) => (isLoading ? <div>Loading...</div> : children),
|
||||
useHasData: jest.fn(() => true),
|
||||
}));
|
||||
|
||||
// Import the actual modules to get the mocked functions
|
||||
import { useMarketplaceAgentsInfiniteQuery } from '~/data-provider/Agents';
|
||||
import { useAgentCategories } from '~/hooks/Agents';
|
||||
import { useDebounce } from '~/hooks';
|
||||
|
||||
// Get typed mock functions
|
||||
const mockUseMarketplaceAgentsInfiniteQuery = jest.mocked(useMarketplaceAgentsInfiniteQuery);
|
||||
const mockUseAgentCategories = jest.mocked(useAgentCategories);
|
||||
const mockUseDebounce = jest.mocked(useDebounce);
|
||||
|
||||
// Create wrapper with QueryClient
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('Accessibility Improvements', () => {
|
||||
beforeEach(() => {
|
||||
mockUseMarketplaceAgentsInfiniteQuery.mockClear();
|
||||
mockUseAgentCategories.mockClear();
|
||||
mockUseDebounce.mockClear();
|
||||
|
||||
// Default mock implementations
|
||||
mockUseDebounce.mockImplementation((value) => value);
|
||||
mockUseAgentCategories.mockReturnValue({
|
||||
categories: [
|
||||
{ value: 'promoted', label: 'Top Picks' },
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'productivity', label: 'Productivity' },
|
||||
],
|
||||
emptyCategory: { value: 'all', label: 'All' },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
describe('CategoryTabs Accessibility', () => {
|
||||
const categories = [
|
||||
{ value: 'promoted', label: 'Top Picks', count: 5 },
|
||||
{ value: 'all', label: 'All', count: 20 },
|
||||
{ value: 'productivity', label: 'Productivity', count: 8 },
|
||||
];
|
||||
|
||||
it('implements proper tablist role and ARIA attributes', () => {
|
||||
render(
|
||||
<CategoryTabs
|
||||
categories={categories}
|
||||
activeTab="promoted"
|
||||
isLoading={false}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Check tablist role
|
||||
const tablist = screen.getByRole('tablist');
|
||||
expect(tablist).toBeInTheDocument();
|
||||
expect(tablist).toHaveAttribute('aria-label', 'Agent Categories');
|
||||
expect(tablist).toHaveAttribute('aria-orientation', 'horizontal');
|
||||
|
||||
// Check individual tabs
|
||||
const tabs = screen.getAllByRole('tab');
|
||||
expect(tabs).toHaveLength(3);
|
||||
|
||||
tabs.forEach((tab) => {
|
||||
expect(tab).toHaveAttribute('aria-selected');
|
||||
expect(tab).toHaveAttribute('aria-controls');
|
||||
expect(tab).toHaveAttribute('id');
|
||||
});
|
||||
});
|
||||
|
||||
it('supports keyboard navigation', () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<CategoryTabs
|
||||
categories={categories}
|
||||
activeTab="promoted"
|
||||
isLoading={false}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const promotedTab = screen.getByRole('tab', { name: /Top Picks tab/ });
|
||||
|
||||
// Test arrow key navigation
|
||||
fireEvent.keyDown(promotedTab, { key: 'ArrowRight' });
|
||||
expect(onChange).toHaveBeenCalledWith('all');
|
||||
|
||||
fireEvent.keyDown(promotedTab, { key: 'ArrowLeft' });
|
||||
expect(onChange).toHaveBeenCalledWith('productivity');
|
||||
|
||||
fireEvent.keyDown(promotedTab, { key: 'Home' });
|
||||
expect(onChange).toHaveBeenCalledWith('promoted');
|
||||
|
||||
fireEvent.keyDown(promotedTab, { key: 'End' });
|
||||
expect(onChange).toHaveBeenCalledWith('productivity');
|
||||
});
|
||||
|
||||
it('manages focus correctly', () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<CategoryTabs
|
||||
categories={categories}
|
||||
activeTab="promoted"
|
||||
isLoading={false}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const promotedTab = screen.getByRole('tab', { name: /Top Picks tab/ });
|
||||
const allTab = screen.getByRole('tab', { name: /All tab/ });
|
||||
|
||||
// Active tab should be focusable
|
||||
expect(promotedTab).toHaveAttribute('tabIndex', '0');
|
||||
expect(allTab).toHaveAttribute('tabIndex', '-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SearchBar Accessibility', () => {
|
||||
it('provides proper search role and labels', () => {
|
||||
render(<SearchBar value="" onSearch={jest.fn()} />);
|
||||
|
||||
// Check search landmark
|
||||
const searchRegion = screen.getByRole('search');
|
||||
expect(searchRegion).toBeInTheDocument();
|
||||
|
||||
// Check input accessibility
|
||||
const searchInput = screen.getByRole('textbox');
|
||||
expect(searchInput).toHaveAttribute('id', 'agent-search');
|
||||
expect(searchInput).toHaveAttribute('aria-label', 'Search agents');
|
||||
expect(searchInput).toHaveAttribute(
|
||||
'aria-describedby',
|
||||
'search-instructions search-results-count',
|
||||
);
|
||||
|
||||
// Check hidden label exists
|
||||
const hiddenLabel = screen.getByLabelText('Search agents');
|
||||
expect(hiddenLabel).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('provides accessible clear button', () => {
|
||||
render(<SearchBar value="test" onSearch={jest.fn()} />);
|
||||
|
||||
const clearButton = screen.getByRole('button', { name: 'Clear search' });
|
||||
expect(clearButton).toBeInTheDocument();
|
||||
expect(clearButton).toHaveAttribute('aria-label', 'Clear search');
|
||||
expect(clearButton).toHaveAttribute('title', 'Clear search');
|
||||
});
|
||||
|
||||
it('hides decorative icons from screen readers', () => {
|
||||
render(<SearchBar value="" onSearch={jest.fn()} />);
|
||||
|
||||
// Search icon should be hidden
|
||||
const iconContainer = document.querySelector('[aria-hidden="true"]');
|
||||
expect(iconContainer).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentCard Accessibility', () => {
|
||||
const mockAgent = {
|
||||
id: 'test-agent',
|
||||
name: 'Test Agent',
|
||||
description: 'A test agent for testing',
|
||||
authorName: 'Test Author',
|
||||
created_at: 1704067200000,
|
||||
avatar: null,
|
||||
instructions: 'Test instructions',
|
||||
provider: 'openai' as const,
|
||||
model: 'gpt-4',
|
||||
model_parameters: {
|
||||
temperature: 0.7,
|
||||
maxContextTokens: 4096,
|
||||
max_context_tokens: 4096,
|
||||
max_output_tokens: 1024,
|
||||
top_p: 1,
|
||||
frequency_penalty: 0,
|
||||
presence_penalty: 0,
|
||||
},
|
||||
};
|
||||
|
||||
it('provides comprehensive ARIA labels', () => {
|
||||
render(<AgentCard agent={mockAgent as t.Agent} onClick={jest.fn()} />);
|
||||
|
||||
const card = screen.getByRole('button');
|
||||
expect(card).toHaveAttribute('aria-label', 'Test Agent agent. A test agent for testing');
|
||||
expect(card).toHaveAttribute('aria-describedby', 'agent-test-agent-description');
|
||||
expect(card).toHaveAttribute('role', 'button');
|
||||
});
|
||||
|
||||
it('handles agents without descriptions', () => {
|
||||
const agentWithoutDesc = { ...mockAgent, description: undefined };
|
||||
render(<AgentCard agent={agentWithoutDesc as any as t.Agent} onClick={jest.fn()} />);
|
||||
|
||||
expect(screen.getByText('No description available')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('supports keyboard interaction', () => {
|
||||
const onClick = jest.fn();
|
||||
render(<AgentCard agent={mockAgent as t.Agent} onClick={onClick} />);
|
||||
|
||||
const card = screen.getByRole('button');
|
||||
|
||||
fireEvent.keyDown(card, { key: 'Enter' });
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.keyDown(card, { key: ' ' });
|
||||
expect(onClick).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentGrid Accessibility', () => {
|
||||
beforeEach(() => {
|
||||
mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({
|
||||
data: {
|
||||
pages: [
|
||||
{
|
||||
data: [
|
||||
{ id: '1', name: 'Agent 1', description: 'First agent' },
|
||||
{ id: '2', name: 'Agent 2', description: 'Second agent' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
});
|
||||
|
||||
it('implements proper tabpanel structure', () => {
|
||||
const Wrapper = createWrapper();
|
||||
render(
|
||||
<Wrapper>
|
||||
<AgentGrid category="all" searchQuery="" onSelectAgent={jest.fn()} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
// Check tabpanel role
|
||||
const tabpanel = screen.getByRole('tabpanel');
|
||||
expect(tabpanel).toHaveAttribute('id', 'category-panel-all');
|
||||
expect(tabpanel).toHaveAttribute('aria-labelledby', 'category-tab-all');
|
||||
expect(tabpanel).toHaveAttribute('aria-live', 'polite');
|
||||
});
|
||||
|
||||
it('provides grid structure with proper roles', () => {
|
||||
const Wrapper = createWrapper();
|
||||
render(
|
||||
<Wrapper>
|
||||
<AgentGrid category="all" searchQuery="" onSelectAgent={jest.fn()} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
// Check grid role
|
||||
const grid = screen.getByRole('grid');
|
||||
expect(grid).toBeInTheDocument();
|
||||
expect(grid).toHaveAttribute('aria-label', 'Showing 2 agents in All category');
|
||||
|
||||
// Check gridcells
|
||||
const gridcells = screen.getAllByRole('gridcell');
|
||||
expect(gridcells).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('announces loading states to screen readers', () => {
|
||||
mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({
|
||||
data: {
|
||||
pages: [{ data: [{ id: '1', name: 'Agent 1' }] }],
|
||||
},
|
||||
isFetching: true,
|
||||
hasNextPage: true,
|
||||
isFetchingNextPage: true,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
const Wrapper = createWrapper();
|
||||
render(
|
||||
<Wrapper>
|
||||
<AgentGrid category="all" searchQuery="" onSelectAgent={jest.fn()} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
// Check for loading announcement when fetching more data
|
||||
const loadingStatus = screen.getByRole('status');
|
||||
expect(loadingStatus).toBeInTheDocument();
|
||||
expect(loadingStatus).toHaveAttribute('aria-live', 'polite');
|
||||
expect(loadingStatus).toHaveAttribute('aria-label', 'Loading...');
|
||||
|
||||
// Check for screen reader text
|
||||
const srText = screen.getByText('Loading...');
|
||||
expect(srText).toHaveClass('sr-only');
|
||||
});
|
||||
|
||||
it('provides accessible empty states', () => {
|
||||
mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({
|
||||
data: {
|
||||
pages: [{ data: [] }],
|
||||
},
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
} as any);
|
||||
|
||||
const Wrapper = createWrapper();
|
||||
render(
|
||||
<Wrapper>
|
||||
<AgentGrid category="all" searchQuery="" onSelectAgent={jest.fn()} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
// Check empty state accessibility
|
||||
const emptyState = screen.getByRole('status');
|
||||
expect(emptyState).toHaveAttribute('aria-live', 'polite');
|
||||
expect(emptyState).toHaveAttribute('aria-label', 'No agents found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ErrorDisplay Accessibility', () => {
|
||||
const mockError = {
|
||||
response: {
|
||||
data: {
|
||||
userMessage: 'Unable to load agents. Please try refreshing the page.',
|
||||
suggestion: 'Try refreshing the page or check your network connection',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it('implements proper alert role and ARIA attributes', () => {
|
||||
render(<ErrorDisplay error={mockError} onRetry={jest.fn()} />);
|
||||
|
||||
// Check alert role
|
||||
const alert = screen.getByRole('alert');
|
||||
expect(alert).toHaveAttribute('aria-live', 'assertive');
|
||||
expect(alert).toHaveAttribute('aria-atomic', 'true');
|
||||
|
||||
// Check heading structure
|
||||
const heading = screen.getByRole('heading', { level: 3 });
|
||||
expect(heading).toHaveAttribute('id', 'error-title');
|
||||
});
|
||||
|
||||
it('provides accessible retry button', () => {
|
||||
const onRetry = jest.fn();
|
||||
render(<ErrorDisplay error={mockError} onRetry={onRetry} />);
|
||||
|
||||
const retryButton = screen.getByRole('button', { name: /retry action/i });
|
||||
expect(retryButton).toHaveAttribute('aria-describedby', 'error-message error-suggestion');
|
||||
|
||||
fireEvent.click(retryButton);
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('structures error content with proper semantics', () => {
|
||||
render(<ErrorDisplay error={mockError} />);
|
||||
|
||||
// Check error message structure
|
||||
expect(screen.getByText(/unable to load agents/i)).toHaveAttribute('id', 'error-message');
|
||||
|
||||
// Check suggestion note
|
||||
const suggestion = screen.getByRole('note');
|
||||
expect(suggestion).toHaveAttribute('aria-label', expect.stringContaining('Suggestion:'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Focus Management', () => {
|
||||
it('maintains proper focus ring styles', () => {
|
||||
const { container } = render(<SearchBar value="" onSearch={jest.fn()} />);
|
||||
|
||||
// Check for focus styles in CSS classes
|
||||
const searchInput = container.querySelector('input');
|
||||
expect(searchInput?.className).toContain('focus:');
|
||||
});
|
||||
|
||||
it('provides visible focus indicators on interactive elements', () => {
|
||||
render(
|
||||
<CategoryTabs
|
||||
categories={[{ value: 'test', label: 'Test', count: 1 }]}
|
||||
activeTab="test"
|
||||
isLoading={false}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const tab = screen.getByRole('tab');
|
||||
// Check that the tab has proper ARIA attributes for accessibility
|
||||
expect(tab).toHaveAttribute('aria-selected', 'true');
|
||||
expect(tab).toHaveAttribute('tabIndex', '0');
|
||||
// Check that tab has proper role and can receive focus
|
||||
expect(tab).toHaveAttribute('role', 'tab');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Screen Reader Announcements', () => {
|
||||
it('includes live regions for dynamic content', () => {
|
||||
const Wrapper = createWrapper();
|
||||
render(
|
||||
<Wrapper>
|
||||
<AgentGrid category="all" searchQuery="" onSelectAgent={jest.fn()} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
// Check for live region
|
||||
const liveRegion = document.querySelector('[aria-live="polite"]');
|
||||
expect(liveRegion).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('provides screen reader only content', () => {
|
||||
render(<SearchBar value="" onSearch={jest.fn()} />);
|
||||
|
||||
// Check for screen reader only instructions
|
||||
const srOnlyElement = document.querySelector('.sr-only');
|
||||
expect(srOnlyElement).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,210 +0,0 @@
|
|||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import AgentCard from '../AgentCard';
|
||||
import type t from 'librechat-data-provider';
|
||||
|
||||
// Mock useLocalize hook
|
||||
jest.mock('~/hooks/useLocalize', () => () => (key: string) => {
|
||||
const mockTranslations: Record<string, string> = {
|
||||
com_agents_created_by: 'Created by',
|
||||
};
|
||||
return mockTranslations[key] || key;
|
||||
});
|
||||
|
||||
describe('AgentCard', () => {
|
||||
const mockAgent: t.Agent = {
|
||||
id: '1',
|
||||
name: 'Test Agent',
|
||||
description: 'A test agent for testing purposes',
|
||||
support_contact: {
|
||||
name: 'Test Support',
|
||||
email: 'test@example.com',
|
||||
},
|
||||
avatar: { filepath: '/test-avatar.png', source: 'local' },
|
||||
created_at: 1672531200000,
|
||||
instructions: 'Test instructions',
|
||||
provider: 'openai' as const,
|
||||
model: 'gpt-4',
|
||||
model_parameters: {
|
||||
temperature: 0.7,
|
||||
maxContextTokens: 4096,
|
||||
max_context_tokens: 4096,
|
||||
max_output_tokens: 1024,
|
||||
top_p: 1,
|
||||
frequency_penalty: 0,
|
||||
presence_penalty: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const mockOnClick = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnClick.mockClear();
|
||||
});
|
||||
|
||||
it('renders agent information correctly', () => {
|
||||
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
|
||||
|
||||
expect(screen.getByText('Test Agent')).toBeInTheDocument();
|
||||
expect(screen.getByText('A test agent for testing purposes')).toBeInTheDocument();
|
||||
expect(screen.getByText('Created by')).toBeInTheDocument();
|
||||
expect(screen.getByText('Test Support')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays avatar when provided as object', () => {
|
||||
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
|
||||
|
||||
const avatarImg = screen.getByAltText('Test Agent avatar');
|
||||
expect(avatarImg).toBeInTheDocument();
|
||||
expect(avatarImg).toHaveAttribute('src', '/test-avatar.png');
|
||||
});
|
||||
|
||||
it('displays avatar when provided as string', () => {
|
||||
const agentWithStringAvatar = {
|
||||
...mockAgent,
|
||||
avatar: '/string-avatar.png' as any, // Legacy support for string avatars
|
||||
};
|
||||
|
||||
render(<AgentCard agent={agentWithStringAvatar} onClick={mockOnClick} />);
|
||||
|
||||
const avatarImg = screen.getByAltText('Test Agent avatar');
|
||||
expect(avatarImg).toBeInTheDocument();
|
||||
expect(avatarImg).toHaveAttribute('src', '/string-avatar.png');
|
||||
});
|
||||
|
||||
it('displays Bot icon fallback when no avatar is provided', () => {
|
||||
const agentWithoutAvatar = {
|
||||
...mockAgent,
|
||||
avatar: undefined,
|
||||
};
|
||||
|
||||
render(<AgentCard agent={agentWithoutAvatar as any as t.Agent} onClick={mockOnClick} />);
|
||||
|
||||
// Check for Bot icon presence by looking for the svg with lucide-bot class
|
||||
const botIcon = document.querySelector('.lucide-bot');
|
||||
expect(botIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onClick when card is clicked', () => {
|
||||
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
|
||||
|
||||
const card = screen.getByRole('button');
|
||||
fireEvent.click(card);
|
||||
|
||||
expect(mockOnClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onClick when Enter key is pressed', () => {
|
||||
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
|
||||
|
||||
const card = screen.getByRole('button');
|
||||
fireEvent.keyDown(card, { key: 'Enter' });
|
||||
|
||||
expect(mockOnClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('calls onClick when Space key is pressed', () => {
|
||||
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
|
||||
|
||||
const card = screen.getByRole('button');
|
||||
fireEvent.keyDown(card, { key: ' ' });
|
||||
|
||||
expect(mockOnClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not call onClick for other keys', () => {
|
||||
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
|
||||
|
||||
const card = screen.getByRole('button');
|
||||
fireEvent.keyDown(card, { key: 'Escape' });
|
||||
|
||||
expect(mockOnClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies additional className when provided', () => {
|
||||
render(<AgentCard agent={mockAgent} onClick={mockOnClick} className="custom-class" />);
|
||||
|
||||
const card = screen.getByRole('button');
|
||||
expect(card).toHaveClass('custom-class');
|
||||
});
|
||||
|
||||
it('handles missing support contact gracefully', () => {
|
||||
const agentWithoutContact = {
|
||||
...mockAgent,
|
||||
support_contact: undefined,
|
||||
authorName: undefined,
|
||||
};
|
||||
|
||||
render(<AgentCard agent={agentWithoutContact} onClick={mockOnClick} />);
|
||||
|
||||
expect(screen.getByText('Test Agent')).toBeInTheDocument();
|
||||
expect(screen.getByText('A test agent for testing purposes')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Created by/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays authorName when support_contact is missing', () => {
|
||||
const agentWithAuthorName = {
|
||||
...mockAgent,
|
||||
support_contact: undefined,
|
||||
authorName: 'John Doe',
|
||||
};
|
||||
|
||||
render(<AgentCard agent={agentWithAuthorName} onClick={mockOnClick} />);
|
||||
|
||||
expect(screen.getByText('Created by')).toBeInTheDocument();
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays support_contact email when name is missing', () => {
|
||||
const agentWithEmailOnly = {
|
||||
...mockAgent,
|
||||
support_contact: { email: 'contact@example.com' },
|
||||
authorName: undefined,
|
||||
};
|
||||
|
||||
render(<AgentCard agent={agentWithEmailOnly} onClick={mockOnClick} />);
|
||||
|
||||
expect(screen.getByText('Created by')).toBeInTheDocument();
|
||||
expect(screen.getByText('contact@example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prioritizes support_contact name over authorName', () => {
|
||||
const agentWithBoth = {
|
||||
...mockAgent,
|
||||
support_contact: { name: 'Support Team' },
|
||||
authorName: 'John Doe',
|
||||
};
|
||||
|
||||
render(<AgentCard agent={agentWithBoth} onClick={mockOnClick} />);
|
||||
|
||||
expect(screen.getByText('Created by')).toBeInTheDocument();
|
||||
expect(screen.getByText('Support Team')).toBeInTheDocument();
|
||||
expect(screen.queryByText('John Doe')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prioritizes name over email in support_contact', () => {
|
||||
const agentWithNameAndEmail = {
|
||||
...mockAgent,
|
||||
support_contact: {
|
||||
name: 'Support Team',
|
||||
email: 'support@example.com',
|
||||
},
|
||||
authorName: undefined,
|
||||
};
|
||||
|
||||
render(<AgentCard agent={agentWithNameAndEmail} onClick={mockOnClick} />);
|
||||
|
||||
expect(screen.getByText('Created by')).toBeInTheDocument();
|
||||
expect(screen.getByText('Support Team')).toBeInTheDocument();
|
||||
expect(screen.queryByText('support@example.com')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has proper accessibility attributes', () => {
|
||||
render(<AgentCard agent={mockAgent} onClick={mockOnClick} />);
|
||||
|
||||
const card = screen.getByRole('button');
|
||||
expect(card).toHaveAttribute('tabIndex', '0');
|
||||
expect(card).toHaveAttribute('aria-label', 'com_agents_agent_card_label');
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,384 +0,0 @@
|
|||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { MemoryRouter, useNavigate } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { RecoilRoot } from 'recoil';
|
||||
|
||||
import type t from 'librechat-data-provider';
|
||||
import { Constants, EModelEndpoint } from 'librechat-data-provider';
|
||||
|
||||
import AgentDetail from '../AgentDetail';
|
||||
import { useToast } from '~/hooks';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useNavigate: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks', () => ({
|
||||
useToast: jest.fn(),
|
||||
useMediaQuery: jest.fn(() => false), // Mock as desktop by default
|
||||
useLocalize: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/utils/agents', () => ({
|
||||
renderAgentAvatar: jest.fn((agent, options) => (
|
||||
<div data-testid="agent-avatar" data-size={options?.size} />
|
||||
)),
|
||||
}));
|
||||
|
||||
jest.mock('~/Providers', () => ({
|
||||
useChatContext: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@tanstack/react-query', () => ({
|
||||
...jest.requireActual('@tanstack/react-query'),
|
||||
useQueryClient: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock clipboard API
|
||||
const mockWriteText = jest.fn();
|
||||
|
||||
const mockNavigate = jest.fn();
|
||||
const mockShowToast = jest.fn();
|
||||
const mockLocalize = jest.fn((key: string) => key);
|
||||
|
||||
const mockAgent: t.Agent = {
|
||||
id: 'test-agent-id',
|
||||
name: 'Test Agent',
|
||||
description: 'This is a test agent for unit testing',
|
||||
avatar: {
|
||||
filepath: '/path/to/avatar.png',
|
||||
source: 'local' as const,
|
||||
},
|
||||
model: 'gpt-4',
|
||||
provider: 'openai',
|
||||
instructions: 'You are a helpful test agent',
|
||||
tools: [],
|
||||
author: 'test-user-id',
|
||||
created_at: new Date().getTime(),
|
||||
version: 1,
|
||||
support_contact: {
|
||||
name: 'Support Team',
|
||||
email: 'support@test.com',
|
||||
},
|
||||
model_parameters: {
|
||||
model: undefined,
|
||||
temperature: null,
|
||||
maxContextTokens: null,
|
||||
max_context_tokens: null,
|
||||
max_output_tokens: null,
|
||||
top_p: null,
|
||||
frequency_penalty: null,
|
||||
presence_penalty: null,
|
||||
},
|
||||
};
|
||||
|
||||
// Helper function to render with providers
|
||||
const renderWithProviders = (ui: React.ReactElement, options = {}) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
const Wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RecoilRoot>
|
||||
<MemoryRouter>{children}</MemoryRouter>
|
||||
</RecoilRoot>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
return render(ui, { wrapper: Wrapper, ...options });
|
||||
};
|
||||
|
||||
describe('AgentDetail', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(useNavigate as jest.Mock).mockReturnValue(mockNavigate);
|
||||
(useToast as jest.Mock).mockReturnValue({ showToast: mockShowToast });
|
||||
const { useLocalize } = require('~/hooks');
|
||||
(useLocalize as jest.Mock).mockReturnValue(mockLocalize);
|
||||
|
||||
// Mock useChatContext
|
||||
const { useChatContext } = require('~/Providers');
|
||||
(useChatContext as jest.Mock).mockReturnValue({
|
||||
conversation: { conversationId: 'test-convo-id' },
|
||||
newConversation: jest.fn(),
|
||||
});
|
||||
|
||||
// Mock useQueryClient
|
||||
const { useQueryClient } = require('@tanstack/react-query');
|
||||
(useQueryClient as jest.Mock).mockReturnValue({
|
||||
getQueryData: jest.fn(),
|
||||
setQueryData: jest.fn(),
|
||||
invalidateQueries: jest.fn(),
|
||||
});
|
||||
|
||||
// Setup clipboard mock if it doesn't exist
|
||||
if (!navigator.clipboard) {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: {
|
||||
writeText: mockWriteText,
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
} else {
|
||||
// If clipboard exists, spy on it
|
||||
jest.spyOn(navigator.clipboard, 'writeText').mockImplementation(mockWriteText);
|
||||
}
|
||||
mockWriteText.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
agent: mockAgent,
|
||||
isOpen: true,
|
||||
onClose: jest.fn(),
|
||||
};
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render agent details correctly', () => {
|
||||
renderWithProviders(<AgentDetail {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Test Agent')).toBeInTheDocument();
|
||||
expect(screen.getByText('This is a test agent for unit testing')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('agent-avatar')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('agent-avatar')).toHaveAttribute('data-size', 'xl');
|
||||
});
|
||||
|
||||
it('should render contact information when available', () => {
|
||||
renderWithProviders(<AgentDetail {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('com_agents_contact:')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Support Team' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Support Team' })).toHaveAttribute(
|
||||
'href',
|
||||
'mailto:support@test.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not render contact information when not available', () => {
|
||||
const agentWithoutContact = { ...mockAgent };
|
||||
delete (agentWithoutContact as any).support_contact;
|
||||
|
||||
renderWithProviders(<AgentDetail {...defaultProps} agent={agentWithoutContact} />);
|
||||
|
||||
expect(screen.queryByText('com_agents_contact:')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render loading state when agent is null', () => {
|
||||
renderWithProviders(<AgentDetail {...defaultProps} agent={null as any} />);
|
||||
|
||||
expect(screen.getByText('com_agents_loading')).toBeInTheDocument();
|
||||
expect(screen.getByText('com_agents_no_description')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render copy link button', () => {
|
||||
renderWithProviders(<AgentDetail {...defaultProps} />);
|
||||
|
||||
const copyLinkButton = screen.getByRole('button', { name: 'com_agents_copy_link' });
|
||||
expect(copyLinkButton).toBeInTheDocument();
|
||||
expect(copyLinkButton).toHaveAttribute('aria-label', 'com_agents_copy_link');
|
||||
});
|
||||
|
||||
it('should render Start Chat button', () => {
|
||||
renderWithProviders(<AgentDetail {...defaultProps} />);
|
||||
|
||||
const startChatButton = screen.getByRole('button', { name: 'com_agents_start_chat' });
|
||||
expect(startChatButton).toBeInTheDocument();
|
||||
expect(startChatButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Interactions', () => {
|
||||
it('should navigate to chat when Start Chat button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockNewConversation = jest.fn();
|
||||
const mockQueryClient = {
|
||||
getQueryData: jest.fn().mockReturnValue(null),
|
||||
setQueryData: jest.fn(),
|
||||
invalidateQueries: jest.fn(),
|
||||
};
|
||||
|
||||
// Update mocks for this test
|
||||
const { useChatContext } = require('~/Providers');
|
||||
(useChatContext as jest.Mock).mockReturnValue({
|
||||
conversation: { conversationId: 'test-convo-id' },
|
||||
newConversation: mockNewConversation,
|
||||
});
|
||||
|
||||
const { useQueryClient } = require('@tanstack/react-query');
|
||||
(useQueryClient as jest.Mock).mockReturnValue(mockQueryClient);
|
||||
|
||||
renderWithProviders(<AgentDetail {...defaultProps} />);
|
||||
|
||||
const startChatButton = screen.getByRole('button', { name: 'com_agents_start_chat' });
|
||||
await user.click(startChatButton);
|
||||
|
||||
expect(mockNewConversation).toHaveBeenCalledWith({
|
||||
template: {
|
||||
conversationId: Constants.NEW_CONVO,
|
||||
endpoint: EModelEndpoint.agents,
|
||||
agent_id: 'test-agent-id',
|
||||
title: 'Chat with Test Agent',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not navigate when agent is null', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<AgentDetail {...defaultProps} agent={null as any} />);
|
||||
|
||||
const startChatButton = screen.getByRole('button', { name: 'com_agents_start_chat' });
|
||||
expect(startChatButton).toBeDisabled();
|
||||
|
||||
await user.click(startChatButton);
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should copy link and show success toast when Copy Link is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<AgentDetail {...defaultProps} />);
|
||||
|
||||
// Click copy link button directly (no dropdown needed)
|
||||
const copyLinkButton = screen.getByRole('button', { name: 'com_agents_copy_link' });
|
||||
await user.click(copyLinkButton);
|
||||
|
||||
// Wait for async clipboard operation to complete
|
||||
await waitFor(() => {
|
||||
expect(mockWriteText).toHaveBeenCalledWith(
|
||||
`${window.location.origin}/c/new?agent_id=test-agent-id`,
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockShowToast).toHaveBeenCalledWith({
|
||||
message: 'com_agents_link_copied',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should show error toast when clipboard write fails', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockWriteText.mockRejectedValue(new Error('Clipboard error'));
|
||||
|
||||
renderWithProviders(<AgentDetail {...defaultProps} />);
|
||||
|
||||
// Click copy link button directly
|
||||
const copyLinkButton = screen.getByRole('button', { name: 'com_agents_copy_link' });
|
||||
await user.click(copyLinkButton);
|
||||
|
||||
// Wait for clipboard operation to fail and error toast to show
|
||||
await waitFor(() => {
|
||||
expect(mockWriteText).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockShowToast).toHaveBeenCalledWith({
|
||||
message: 'com_agents_link_copy_failed',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onClose when dialog is closed', () => {
|
||||
const mockOnClose = jest.fn();
|
||||
renderWithProviders(<AgentDetail {...defaultProps} onClose={mockOnClose} isOpen={false} />);
|
||||
|
||||
// Since we're testing the onOpenChange callback, we need to trigger it
|
||||
// This would normally be done by the Dialog component when ESC is pressed or overlay is clicked
|
||||
// We'll test this by checking that onClose is properly passed to the Dialog
|
||||
expect(mockOnClose).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have proper ARIA attributes', () => {
|
||||
renderWithProviders(<AgentDetail {...defaultProps} />);
|
||||
|
||||
const copyLinkButton = screen.getByRole('button', { name: 'com_agents_copy_link' });
|
||||
expect(copyLinkButton).toHaveAttribute('aria-label', 'com_agents_copy_link');
|
||||
});
|
||||
|
||||
it('should support keyboard navigation', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<AgentDetail {...defaultProps} />);
|
||||
|
||||
const copyLinkButton = screen.getByRole('button', { name: 'com_agents_copy_link' });
|
||||
|
||||
// Focus and activate with Enter key
|
||||
copyLinkButton.focus();
|
||||
await user.keyboard('{Enter}');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockWriteText).toHaveBeenCalledWith(
|
||||
`${window.location.origin}/c/new?agent_id=test-agent-id`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should have proper focus management', async () => {
|
||||
renderWithProviders(<AgentDetail {...defaultProps} />);
|
||||
|
||||
const copyLinkButton = screen.getByRole('button', { name: 'com_agents_copy_link' });
|
||||
expect(copyLinkButton).toHaveClass('focus:outline-none', 'focus:ring-2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle agent with only email contact', () => {
|
||||
const agentWithEmailOnly = {
|
||||
...mockAgent,
|
||||
support_contact: {
|
||||
email: 'support@test.com',
|
||||
},
|
||||
};
|
||||
|
||||
renderWithProviders(<AgentDetail {...defaultProps} agent={agentWithEmailOnly} />);
|
||||
|
||||
expect(screen.getByRole('link', { name: 'support@test.com' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle agent with only name contact', () => {
|
||||
const agentWithNameOnly = {
|
||||
...mockAgent,
|
||||
support_contact: {
|
||||
name: 'Support Team',
|
||||
},
|
||||
};
|
||||
|
||||
renderWithProviders(<AgentDetail {...defaultProps} agent={agentWithNameOnly} />);
|
||||
|
||||
expect(screen.getByText('Support Team')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle very long description with proper text wrapping', () => {
|
||||
const agentWithLongDescription = {
|
||||
...mockAgent,
|
||||
description:
|
||||
'This is a very long description that should wrap properly and be displayed in multiple lines when the content exceeds the available width of the container.',
|
||||
};
|
||||
|
||||
renderWithProviders(<AgentDetail {...defaultProps} agent={agentWithLongDescription} />);
|
||||
|
||||
const description = screen.getByText(agentWithLongDescription.description);
|
||||
expect(description).toHaveClass('whitespace-pre-wrap');
|
||||
});
|
||||
|
||||
it('should handle special characters in agent name', () => {
|
||||
const agentWithSpecialChars = {
|
||||
...mockAgent,
|
||||
name: 'Test Agent™ & Co. (v2.0)',
|
||||
};
|
||||
|
||||
renderWithProviders(<AgentDetail {...defaultProps} agent={agentWithSpecialChars} />);
|
||||
|
||||
expect(screen.getByText('Test Agent™ & Co. (v2.0)')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -3,7 +3,7 @@ import { SystemRoles } from 'librechat-data-provider';
|
|||
import { render, screen } from '@testing-library/react';
|
||||
import type { UseMutationResult } from '@tanstack/react-query';
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import type { Agent, AgentCreateParams, TUser } from 'librechat-data-provider';
|
||||
import type { Agent, AgentCreateParams, TUser, ResourceType } from 'librechat-data-provider';
|
||||
import AgentFooter from '../AgentFooter';
|
||||
import { Panel } from '~/common';
|
||||
|
||||
|
|
@ -171,7 +171,7 @@ jest.mock('~/components/Sharing', () => ({
|
|||
resourceDbId: string;
|
||||
resourceId: string;
|
||||
resourceName: string;
|
||||
resourceType: string;
|
||||
resourceType: ResourceType;
|
||||
}) => (
|
||||
<div
|
||||
data-testid="grant-access-dialog"
|
||||
|
|
|
|||
|
|
@ -1,376 +0,0 @@
|
|||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import AgentGrid from '../AgentGrid';
|
||||
import type t from 'librechat-data-provider';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
// Mock the marketplace agent query hook
|
||||
jest.mock('~/data-provider/Agents', () => ({
|
||||
useMarketplaceAgentsInfiniteQuery: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/hooks/Agents', () => ({
|
||||
useAgentCategories: jest.fn(() => ({
|
||||
categories: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock SmartLoader
|
||||
jest.mock('../SmartLoader', () => ({
|
||||
useHasData: jest.fn(() => true),
|
||||
}));
|
||||
|
||||
// Mock useLocalize hook
|
||||
jest.mock('~/hooks/useLocalize', () => () => (key: string, options?: any) => {
|
||||
const mockTranslations: Record<string, string> = {
|
||||
com_agents_top_picks: 'Top Picks',
|
||||
com_agents_all: 'All Agents',
|
||||
com_agents_recommended: 'Our recommended agents',
|
||||
com_agents_results_for: 'Results for "{{query}}"',
|
||||
com_agents_see_more: 'See more',
|
||||
com_agents_error_loading: 'Error loading agents',
|
||||
com_agents_error_searching: 'Error searching agents',
|
||||
com_agents_no_results: 'No agents found. Try another search term.',
|
||||
com_agents_none_in_category: 'No agents found in this category',
|
||||
com_agents_search_empty_heading: 'No results found',
|
||||
com_agents_empty_state_heading: 'No agents available',
|
||||
com_agents_loading: 'Loading...',
|
||||
com_agents_grid_announcement: '{{count}} agents in {{category}}',
|
||||
com_agents_load_more_label: 'Load more agents from {{category}}',
|
||||
};
|
||||
|
||||
let translation = mockTranslations[key] || key;
|
||||
|
||||
if (options) {
|
||||
Object.keys(options).forEach((optionKey) => {
|
||||
translation = translation.replace(new RegExp(`{{${optionKey}}}`, 'g'), options[optionKey]);
|
||||
});
|
||||
}
|
||||
|
||||
return translation;
|
||||
});
|
||||
|
||||
// Mock ErrorDisplay component
|
||||
jest.mock('../ErrorDisplay', () => ({
|
||||
__esModule: true,
|
||||
default: ({ error, onRetry }: { error: any; onRetry: () => void }) => (
|
||||
<div>
|
||||
<div>
|
||||
{`Error: `}
|
||||
{typeof error === 'string' ? error : error?.message || 'Unknown error'}
|
||||
</div>
|
||||
<button onClick={onRetry}>{`Retry`}</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock AgentCard component
|
||||
jest.mock('../AgentCard', () => ({
|
||||
__esModule: true,
|
||||
default: ({ agent, onClick }: { agent: t.Agent; onClick: () => void }) => (
|
||||
<div data-testid={`agent-card-${agent.id}`} onClick={onClick}>
|
||||
<h3>{agent.name}</h3>
|
||||
<p>{agent.description}</p>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Import the actual modules to get the mocked functions
|
||||
import { useMarketplaceAgentsInfiniteQuery } from '~/data-provider/Agents';
|
||||
|
||||
const mockUseMarketplaceAgentsInfiniteQuery = jest.mocked(useMarketplaceAgentsInfiniteQuery);
|
||||
|
||||
describe('AgentGrid Integration with useGetMarketplaceAgentsQuery', () => {
|
||||
const mockOnSelectAgent = jest.fn();
|
||||
|
||||
const mockAgents: t.Agent[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Test Agent 1',
|
||||
description: 'First test agent',
|
||||
avatar: { filepath: '/avatar1.png', source: 'local' },
|
||||
category: 'finance',
|
||||
authorName: 'Author 1',
|
||||
created_at: 1672531200000,
|
||||
instructions: null,
|
||||
provider: 'custom',
|
||||
model: 'gpt-4',
|
||||
model_parameters: {
|
||||
temperature: null,
|
||||
maxContextTokens: null,
|
||||
max_context_tokens: null,
|
||||
max_output_tokens: null,
|
||||
top_p: null,
|
||||
frequency_penalty: null,
|
||||
presence_penalty: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Test Agent 2',
|
||||
description: 'Second test agent',
|
||||
avatar: { filepath: '/avatar2.png', source: 'local' },
|
||||
category: 'finance',
|
||||
authorName: 'Author 2',
|
||||
created_at: 1672531200000,
|
||||
instructions: null,
|
||||
provider: 'custom',
|
||||
model: 'gpt-4',
|
||||
model_parameters: {
|
||||
temperature: 0.7,
|
||||
top_p: 0.9,
|
||||
frequency_penalty: 0,
|
||||
maxContextTokens: null,
|
||||
max_context_tokens: null,
|
||||
max_output_tokens: null,
|
||||
presence_penalty: null,
|
||||
},
|
||||
},
|
||||
];
|
||||
const defaultMockQueryResult = {
|
||||
data: {
|
||||
pages: [
|
||||
{
|
||||
data: mockAgents,
|
||||
},
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
isFetching: false,
|
||||
isFetchingNextPage: false,
|
||||
hasNextPage: true,
|
||||
fetchNextPage: jest.fn(),
|
||||
refetch: jest.fn(),
|
||||
} as any;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue(defaultMockQueryResult);
|
||||
});
|
||||
|
||||
describe('Query Integration', () => {
|
||||
it('should call useGetMarketplaceAgentsQuery with correct parameters for category search', () => {
|
||||
render(
|
||||
<AgentGrid category="finance" searchQuery="test query" onSelectAgent={mockOnSelectAgent} />,
|
||||
);
|
||||
|
||||
expect(mockUseMarketplaceAgentsInfiniteQuery).toHaveBeenCalledWith({
|
||||
requiredPermission: 1,
|
||||
category: 'finance',
|
||||
search: 'test query',
|
||||
limit: 6,
|
||||
});
|
||||
});
|
||||
|
||||
it('should call useGetMarketplaceAgentsQuery with promoted=1 for promoted category', () => {
|
||||
render(<AgentGrid category="promoted" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
|
||||
|
||||
expect(mockUseMarketplaceAgentsInfiniteQuery).toHaveBeenCalledWith({
|
||||
requiredPermission: 1,
|
||||
promoted: 1,
|
||||
limit: 6,
|
||||
});
|
||||
});
|
||||
|
||||
it('should call useGetMarketplaceAgentsQuery without category filter for "all" category', () => {
|
||||
render(<AgentGrid category="all" searchQuery="" onSelectAgent={mockOnSelectAgent} />);
|
||||
|
||||
expect(mockUseMarketplaceAgentsInfiniteQuery).toHaveBeenCalledWith({
|
||||
requiredPermission: 1,
|
||||
limit: 6,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not include category in search when category is "all" or "promoted"', () => {
|
||||
render(<AgentGrid category="all" searchQuery="test" onSelectAgent={mockOnSelectAgent} />);
|
||||
|
||||
expect(mockUseMarketplaceAgentsInfiniteQuery).toHaveBeenCalledWith({
|
||||
requiredPermission: 1,
|
||||
search: 'test',
|
||||
limit: 6,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Create wrapper with QueryClient
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('Agent Display', () => {
|
||||
it('should render agent cards when data is available', () => {
|
||||
const Wrapper = createWrapper();
|
||||
render(
|
||||
<Wrapper>
|
||||
<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('agent-card-1')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('agent-card-2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Test Agent 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Test Agent 2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onSelectAgent when agent card is clicked', () => {
|
||||
const Wrapper = createWrapper();
|
||||
render(
|
||||
<Wrapper>
|
||||
<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('agent-card-1'));
|
||||
expect(mockOnSelectAgent).toHaveBeenCalledWith(mockAgents[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Loading States', () => {
|
||||
it('should show loading state when isLoading is true', () => {
|
||||
mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({
|
||||
...defaultMockQueryResult,
|
||||
isLoading: true,
|
||||
data: undefined,
|
||||
});
|
||||
|
||||
const Wrapper = createWrapper();
|
||||
render(
|
||||
<Wrapper>
|
||||
<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
// Should show skeleton loading state
|
||||
expect(document.querySelector('.animate-pulse')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show empty state when no agents are available', () => {
|
||||
mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({
|
||||
...defaultMockQueryResult,
|
||||
data: {
|
||||
pages: [
|
||||
{
|
||||
data: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const Wrapper = createWrapper();
|
||||
render(
|
||||
<Wrapper>
|
||||
<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('No agents available')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should show error display when query has error', () => {
|
||||
const mockError = new Error('Failed to fetch agents');
|
||||
mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({
|
||||
...defaultMockQueryResult,
|
||||
error: mockError,
|
||||
isError: true,
|
||||
data: undefined,
|
||||
});
|
||||
|
||||
const Wrapper = createWrapper();
|
||||
render(
|
||||
<Wrapper>
|
||||
<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Error: Failed to fetch agents')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Search Results', () => {
|
||||
it('should show search results title when searching', () => {
|
||||
const Wrapper = createWrapper();
|
||||
render(
|
||||
<Wrapper>
|
||||
<AgentGrid
|
||||
category="finance"
|
||||
searchQuery="automation"
|
||||
onSelectAgent={mockOnSelectAgent}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Results for "automation"')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show empty search results message', () => {
|
||||
mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({
|
||||
...defaultMockQueryResult,
|
||||
data: {
|
||||
pages: [
|
||||
{
|
||||
data: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const Wrapper = createWrapper();
|
||||
render(
|
||||
<Wrapper>
|
||||
<AgentGrid
|
||||
category="finance"
|
||||
searchQuery="nonexistent"
|
||||
onSelectAgent={mockOnSelectAgent}
|
||||
/>
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('No results found')).toBeInTheDocument();
|
||||
expect(screen.getByText('No agents found. Try another search term.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Load More Functionality', () => {
|
||||
it('should show "See more" button when hasNextPage is true', () => {
|
||||
const Wrapper = createWrapper();
|
||||
render(
|
||||
<Wrapper>
|
||||
<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Load more agents from Finance' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show "See more" button when hasNextPage is false', () => {
|
||||
mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({
|
||||
...defaultMockQueryResult,
|
||||
hasNextPage: false,
|
||||
});
|
||||
|
||||
const Wrapper = createWrapper();
|
||||
render(
|
||||
<Wrapper>
|
||||
<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />
|
||||
</Wrapper>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('button', { name: /Load more agents/ })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,230 +0,0 @@
|
|||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import '@testing-library/jest-dom';
|
||||
import CategoryTabs from '../CategoryTabs';
|
||||
import type t from 'librechat-data-provider';
|
||||
|
||||
// Mock useLocalize hook
|
||||
jest.mock('~/hooks/useLocalize', () => () => (key: string) => {
|
||||
const mockTranslations: Record<string, string> = {
|
||||
com_agents_top_picks: 'Top Picks',
|
||||
com_agents_all: 'All',
|
||||
com_ui_no_categories: 'No categories available',
|
||||
com_agents_category_tabs_label: 'Agent Categories',
|
||||
com_ui_agent_category_general: 'General',
|
||||
com_ui_agent_category_hr: 'HR',
|
||||
com_ui_agent_category_rd: 'R&D',
|
||||
com_ui_agent_category_finance: 'Finance',
|
||||
com_ui_agent_category_it: 'IT',
|
||||
com_ui_agent_category_sales: 'Sales',
|
||||
com_ui_agent_category_aftersales: 'After Sales',
|
||||
};
|
||||
return mockTranslations[key] || key;
|
||||
});
|
||||
|
||||
describe('CategoryTabs', () => {
|
||||
const mockCategories: t.TMarketplaceCategory[] = [
|
||||
{ value: 'promoted', label: 'Top Picks', description: 'Our recommended agents', count: 5 },
|
||||
{ value: 'all', label: 'All', description: 'All available agents', count: 20 },
|
||||
{ value: 'general', label: 'General', description: 'General purpose agents', count: 8 },
|
||||
{ value: 'hr', label: 'HR', description: 'HR agents', count: 3 },
|
||||
{ value: 'finance', label: 'Finance', description: 'Finance agents', count: 4 },
|
||||
];
|
||||
|
||||
const mockOnChange = jest.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnChange.mockClear();
|
||||
});
|
||||
|
||||
it('renders provided categories', () => {
|
||||
render(
|
||||
<CategoryTabs
|
||||
categories={mockCategories}
|
||||
activeTab="promoted"
|
||||
isLoading={false}
|
||||
onChange={mockOnChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Check for provided categories
|
||||
expect(screen.getByText('Top Picks')).toBeInTheDocument();
|
||||
expect(screen.getByText('All')).toBeInTheDocument();
|
||||
expect(screen.getByText('General')).toBeInTheDocument();
|
||||
expect(screen.getByText('HR')).toBeInTheDocument();
|
||||
expect(screen.getByText('Finance')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles loading state properly', () => {
|
||||
render(
|
||||
<CategoryTabs
|
||||
categories={[]}
|
||||
activeTab="promoted"
|
||||
isLoading={true}
|
||||
onChange={mockOnChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
// SmartLoader should handle loading behavior correctly
|
||||
// The component should render without crashing during loading
|
||||
expect(screen.queryByText('No categories available')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('highlights the active tab', () => {
|
||||
render(
|
||||
<CategoryTabs
|
||||
categories={mockCategories}
|
||||
activeTab="general"
|
||||
isLoading={false}
|
||||
onChange={mockOnChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const generalTab = screen.getByText('General').closest('button');
|
||||
expect(generalTab).toHaveClass('bg-surface-tertiary');
|
||||
|
||||
// Should have active underline
|
||||
const underline = generalTab?.querySelector('.absolute.bottom-0');
|
||||
expect(underline).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onChange when a tab is clicked', async () => {
|
||||
render(
|
||||
<CategoryTabs
|
||||
categories={mockCategories}
|
||||
activeTab="promoted"
|
||||
isLoading={false}
|
||||
onChange={mockOnChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const hrTab = screen.getByText('HR');
|
||||
await user.click(hrTab);
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('hr');
|
||||
});
|
||||
|
||||
it('handles promoted tab click correctly', async () => {
|
||||
render(
|
||||
<CategoryTabs
|
||||
categories={mockCategories}
|
||||
activeTab="general"
|
||||
isLoading={false}
|
||||
onChange={mockOnChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const topPicksTab = screen.getByText('Top Picks');
|
||||
await user.click(topPicksTab);
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('promoted');
|
||||
});
|
||||
|
||||
it('handles all tab click correctly', async () => {
|
||||
render(
|
||||
<CategoryTabs
|
||||
categories={mockCategories}
|
||||
activeTab="promoted"
|
||||
isLoading={false}
|
||||
onChange={mockOnChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const allTab = screen.getByText('All');
|
||||
await user.click(allTab);
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('all');
|
||||
});
|
||||
|
||||
it('shows inactive state for non-selected tabs', () => {
|
||||
render(
|
||||
<CategoryTabs
|
||||
categories={mockCategories}
|
||||
activeTab="promoted"
|
||||
isLoading={false}
|
||||
onChange={mockOnChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const generalTab = screen.getByText('General').closest('button');
|
||||
expect(generalTab).toHaveClass('bg-surface-secondary');
|
||||
expect(generalTab).toHaveClass('text-text-secondary');
|
||||
|
||||
// Should not have active underline
|
||||
const underline = generalTab?.querySelector('.absolute.bottom-0');
|
||||
expect(underline).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with proper accessibility', () => {
|
||||
render(
|
||||
<CategoryTabs
|
||||
categories={mockCategories}
|
||||
activeTab="promoted"
|
||||
isLoading={false}
|
||||
onChange={mockOnChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const tabs = screen.getAllByRole('tab');
|
||||
expect(tabs.length).toBe(5);
|
||||
// Verify all tabs are properly clickable buttons
|
||||
tabs.forEach((tab) => {
|
||||
expect(tab.tagName).toBe('BUTTON');
|
||||
});
|
||||
});
|
||||
|
||||
it('handles keyboard navigation', async () => {
|
||||
render(
|
||||
<CategoryTabs
|
||||
categories={mockCategories}
|
||||
activeTab="promoted"
|
||||
isLoading={false}
|
||||
onChange={mockOnChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const generalTab = screen.getByText('General').closest('button')!;
|
||||
|
||||
// Focus the button and click it
|
||||
generalTab.focus();
|
||||
expect(document.activeElement).toBe(generalTab);
|
||||
|
||||
await user.click(generalTab);
|
||||
expect(mockOnChange).toHaveBeenCalledWith('general');
|
||||
});
|
||||
|
||||
it('shows empty state when categories prop is empty', () => {
|
||||
render(
|
||||
<CategoryTabs
|
||||
categories={[]}
|
||||
activeTab="promoted"
|
||||
isLoading={false}
|
||||
onChange={mockOnChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Should show empty state message (localized)
|
||||
expect(screen.getByText('No categories available')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('maintains consistent ordering of categories', () => {
|
||||
render(
|
||||
<CategoryTabs
|
||||
categories={mockCategories}
|
||||
activeTab="promoted"
|
||||
isLoading={false}
|
||||
onChange={mockOnChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const tabs = screen.getAllByRole('tab');
|
||||
const tabTexts = tabs.map((tab) => tab.textContent);
|
||||
|
||||
// Check that promoted is first and all is second
|
||||
expect(tabTexts[0]).toBe('Top Picks');
|
||||
expect(tabTexts[1]).toBe('All');
|
||||
expect(tabTexts.length).toBe(5);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,303 +0,0 @@
|
|||
import React from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { ErrorDisplay } from '../ErrorDisplay';
|
||||
|
||||
// Mock matchMedia
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
// Mock the localize hook
|
||||
const mockLocalize = jest.fn((key: string, options?: any) => {
|
||||
const translations: Record<string, string> = {
|
||||
com_agents_error_title: 'Something went wrong',
|
||||
com_agents_error_generic: 'We encountered an issue while loading the content.',
|
||||
com_agents_error_suggestion_generic: 'Please try refreshing the page or try again later.',
|
||||
com_agents_error_network_title: 'Connection Problem',
|
||||
com_agents_error_network_message: 'Unable to connect to the server.',
|
||||
com_agents_error_network_suggestion: 'Check your internet connection and try again.',
|
||||
com_agents_error_not_found_title: 'Not Found',
|
||||
com_agents_error_not_found_message: 'The requested content could not be found.',
|
||||
com_agents_error_not_found_suggestion:
|
||||
'Try browsing other options or go back to the marketplace.',
|
||||
com_agents_error_invalid_request: 'Invalid Request',
|
||||
com_agents_error_bad_request_message: 'The request could not be processed.',
|
||||
com_agents_error_bad_request_suggestion: 'Please check your input and try again.',
|
||||
com_agents_error_server_title: 'Server Error',
|
||||
com_agents_error_server_message: 'The server is temporarily unavailable.',
|
||||
com_agents_error_server_suggestion: 'Please try again in a few moments.',
|
||||
com_agents_error_search_title: 'Search Error',
|
||||
com_agents_error_category_title: 'Category Error',
|
||||
com_agents_error_timeout_title: 'Connection Timeout',
|
||||
com_agents_error_timeout_message: 'The request took too long to complete.',
|
||||
com_agents_error_timeout_suggestion: 'Please check your internet connection and try again.',
|
||||
com_agents_search_no_results: `No agents found for "${options?.query}"`,
|
||||
com_agents_category_empty: `No agents found in the ${options?.category} category`,
|
||||
com_agents_error_retry: 'Try Again',
|
||||
};
|
||||
|
||||
return translations[key] || key;
|
||||
});
|
||||
|
||||
jest.mock('~/hooks/useLocalize', () => () => mockLocalize);
|
||||
|
||||
describe('ErrorDisplay', () => {
|
||||
beforeEach(() => {
|
||||
mockLocalize.mockClear();
|
||||
});
|
||||
|
||||
describe('Backend error responses', () => {
|
||||
it('displays user-friendly message from backend response', () => {
|
||||
const error = {
|
||||
response: {
|
||||
data: {
|
||||
userMessage: 'Unable to load agents. Please try refreshing the page.',
|
||||
suggestion: 'Try refreshing the page or check your network connection',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
render(<ErrorDisplay error={error} />);
|
||||
|
||||
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Unable to load agents. Please try refreshing the page.'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('💡 Try refreshing the page or check your network connection'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles search context with backend response', () => {
|
||||
const error = {
|
||||
response: {
|
||||
data: {
|
||||
userMessage: 'Search is temporarily unavailable. Please try again.',
|
||||
suggestion: 'Try a different search term or check your network connection',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
render(<ErrorDisplay error={error} context={{ searchQuery: 'test query' }} />);
|
||||
|
||||
expect(screen.getByText('Search Error')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Search is temporarily unavailable. Please try again.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Network errors', () => {
|
||||
it('displays network error message', () => {
|
||||
const error = {
|
||||
code: 'NETWORK_ERROR',
|
||||
message: 'Network Error',
|
||||
};
|
||||
|
||||
render(<ErrorDisplay error={error} />);
|
||||
|
||||
expect(screen.getByText('Connection Problem')).toBeInTheDocument();
|
||||
expect(screen.getByText('Unable to connect to the server.')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('💡 Check your internet connection and try again.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles timeout errors', () => {
|
||||
const error = {
|
||||
code: 'ECONNABORTED',
|
||||
message: 'timeout of 5000ms exceeded',
|
||||
};
|
||||
|
||||
render(<ErrorDisplay error={error} />);
|
||||
|
||||
expect(mockLocalize).toHaveBeenCalledWith('com_agents_error_timeout_title');
|
||||
expect(mockLocalize).toHaveBeenCalledWith('com_agents_error_timeout_message');
|
||||
expect(mockLocalize).toHaveBeenCalledWith('com_agents_error_timeout_suggestion');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTTP status codes', () => {
|
||||
it('handles 404 errors with search context', () => {
|
||||
const error = {
|
||||
response: {
|
||||
status: 404,
|
||||
data: {},
|
||||
},
|
||||
};
|
||||
|
||||
render(<ErrorDisplay error={error} context={{ searchQuery: 'nonexistent agent' }} />);
|
||||
|
||||
expect(screen.getByText('Not Found')).toBeInTheDocument();
|
||||
expect(screen.getByText('No agents found for "nonexistent agent"')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles 404 errors with category context', () => {
|
||||
const error = {
|
||||
response: {
|
||||
status: 404,
|
||||
data: {},
|
||||
},
|
||||
};
|
||||
|
||||
render(<ErrorDisplay error={error} context={{ category: 'productivity' }} />);
|
||||
|
||||
expect(screen.getByText('Not Found')).toBeInTheDocument();
|
||||
expect(screen.getByText('No agents found in the productivity category')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles 400 bad request errors', () => {
|
||||
const error = {
|
||||
response: {
|
||||
status: 400,
|
||||
data: {
|
||||
error: 'Search query is required',
|
||||
userMessage: 'Please enter a search term to find agents',
|
||||
suggestion: 'Enter a search term to find agents by name or description',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
render(<ErrorDisplay error={error} />);
|
||||
|
||||
expect(screen.getByText('Invalid Request')).toBeInTheDocument();
|
||||
expect(screen.getByText('Please enter a search term to find agents')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('💡 Enter a search term to find agents by name or description'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles 500 server errors', () => {
|
||||
const error = {
|
||||
response: {
|
||||
status: 500,
|
||||
data: {},
|
||||
},
|
||||
};
|
||||
|
||||
render(<ErrorDisplay error={error} />);
|
||||
|
||||
expect(screen.getByText('Server Error')).toBeInTheDocument();
|
||||
expect(screen.getByText('The server is temporarily unavailable.')).toBeInTheDocument();
|
||||
expect(screen.getByText('💡 Please try again in a few moments.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Retry functionality', () => {
|
||||
it('displays retry button when onRetry is provided', () => {
|
||||
const mockRetry = jest.fn();
|
||||
const error = {
|
||||
response: {
|
||||
data: {
|
||||
userMessage: 'Unable to load agents. Please try refreshing the page.',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
render(<ErrorDisplay error={error} onRetry={mockRetry} />);
|
||||
|
||||
const retryButton = screen.getByText('Try Again');
|
||||
expect(retryButton).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(retryButton);
|
||||
expect(mockRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not display retry button when onRetry is not provided', () => {
|
||||
const error = {
|
||||
response: {
|
||||
data: {
|
||||
userMessage: 'Unable to load agents. Please try refreshing the page.',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
render(<ErrorDisplay error={error} />);
|
||||
|
||||
expect(screen.queryByText('Try Again')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Context-aware titles', () => {
|
||||
it('shows search error title for search context', () => {
|
||||
const error = { message: 'Some error' };
|
||||
|
||||
render(<ErrorDisplay error={error} context={{ searchQuery: 'test' }} />);
|
||||
|
||||
expect(mockLocalize).toHaveBeenCalledWith('com_agents_error_search_title');
|
||||
});
|
||||
|
||||
it('shows category error title for category context', () => {
|
||||
const error = { message: 'Some error' };
|
||||
|
||||
render(<ErrorDisplay error={error} context={{ category: 'productivity' }} />);
|
||||
|
||||
expect(mockLocalize).toHaveBeenCalledWith('com_agents_error_category_title');
|
||||
});
|
||||
|
||||
it('shows generic error title when no context', () => {
|
||||
const error = { message: 'Some error' };
|
||||
|
||||
render(<ErrorDisplay error={error} />);
|
||||
|
||||
expect(mockLocalize).toHaveBeenCalledWith('com_agents_error_title');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fallback error handling', () => {
|
||||
it('handles unknown errors gracefully', () => {
|
||||
const error = {
|
||||
message: 'Unknown error occurred',
|
||||
};
|
||||
|
||||
render(<ErrorDisplay error={error} />);
|
||||
|
||||
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('We encountered an issue while loading the content.'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('💡 Please try refreshing the page or try again later.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles null/undefined errors', () => {
|
||||
render(<ErrorDisplay error={null} />);
|
||||
|
||||
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('We encountered an issue while loading the content.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('renders error icon with proper accessibility', () => {
|
||||
const error = { message: 'Test error' };
|
||||
|
||||
render(<ErrorDisplay error={error} />);
|
||||
|
||||
const errorIcon = screen.getByRole('img', { hidden: true });
|
||||
expect(errorIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has proper heading structure', () => {
|
||||
const error = { message: 'Test error' };
|
||||
|
||||
render(<ErrorDisplay error={error} />);
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 3 });
|
||||
expect(heading).toHaveTextContent('Something went wrong');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,150 +0,0 @@
|
|||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import { MarketplaceProvider } from '../MarketplaceContext';
|
||||
import { useChatContext } from '~/Providers';
|
||||
|
||||
// Mock the ChatContext from Providers
|
||||
jest.mock('~/Providers', () => ({
|
||||
ChatContext: {
|
||||
Provider: ({ children, value }: { children: React.ReactNode; value: any }) => (
|
||||
<div data-testid="chat-context-provider" data-value={JSON.stringify(value)}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
useChatContext: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock useChatHelpers to avoid Recoil dependency
|
||||
jest.mock('~/hooks', () => ({
|
||||
useChatHelpers: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedUseChatContext = useChatContext as jest.MockedFunction<typeof useChatContext>;
|
||||
|
||||
// Test component that consumes the context
|
||||
const TestConsumer: React.FC = () => {
|
||||
const context = mockedUseChatContext();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="endpoint">{context?.conversation?.endpoint}</div>
|
||||
<div data-testid="conversation-id">{context?.conversation?.conversationId}</div>
|
||||
<div data-testid="title">{context?.conversation?.title}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
describe('MarketplaceProvider', () => {
|
||||
beforeEach(() => {
|
||||
mockedUseChatContext.mockClear();
|
||||
|
||||
// Mock useChatHelpers return value
|
||||
const { useChatHelpers } = require('~/hooks');
|
||||
(useChatHelpers as jest.Mock).mockReturnValue({
|
||||
conversation: {
|
||||
endpoint: EModelEndpoint.agents,
|
||||
conversationId: 'marketplace',
|
||||
title: 'Agent Marketplace',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('provides correct marketplace context values', () => {
|
||||
const mockContext = {
|
||||
conversation: {
|
||||
endpoint: EModelEndpoint.agents,
|
||||
conversationId: 'marketplace',
|
||||
title: 'Agent Marketplace',
|
||||
},
|
||||
};
|
||||
|
||||
mockedUseChatContext.mockReturnValue(mockContext as ReturnType<typeof useChatContext>);
|
||||
|
||||
render(
|
||||
<MarketplaceProvider>
|
||||
<TestConsumer />
|
||||
</MarketplaceProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('endpoint')).toHaveTextContent(EModelEndpoint.agents);
|
||||
expect(screen.getByTestId('conversation-id')).toHaveTextContent('marketplace');
|
||||
expect(screen.getByTestId('title')).toHaveTextContent('Agent Marketplace');
|
||||
});
|
||||
|
||||
it('creates ChatContext.Provider with correct structure', () => {
|
||||
render(
|
||||
<MarketplaceProvider>
|
||||
<div>{/* eslint-disable-line i18next/no-literal-string */}Test Child</div>
|
||||
</MarketplaceProvider>,
|
||||
);
|
||||
|
||||
const provider = screen.getByTestId('chat-context-provider');
|
||||
expect(provider).toBeInTheDocument();
|
||||
|
||||
const valueData = JSON.parse(provider.getAttribute('data-value') || '{}');
|
||||
expect(valueData.conversation).toEqual({
|
||||
endpoint: EModelEndpoint.agents,
|
||||
conversationId: 'marketplace',
|
||||
title: 'Agent Marketplace',
|
||||
});
|
||||
});
|
||||
|
||||
it('renders children correctly', () => {
|
||||
render(
|
||||
<MarketplaceProvider>
|
||||
<div data-testid="test-child">
|
||||
{/* eslint-disable-line i18next/no-literal-string */}Test Content
|
||||
</div>
|
||||
</MarketplaceProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('test-child')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('test-child')).toHaveTextContent('Test Content');
|
||||
});
|
||||
|
||||
it('provides stable context value (memoization)', () => {
|
||||
const { rerender } = render(
|
||||
<MarketplaceProvider>
|
||||
<TestConsumer />
|
||||
</MarketplaceProvider>,
|
||||
);
|
||||
|
||||
const firstProvider = screen.getByTestId('chat-context-provider');
|
||||
const firstValue = firstProvider.getAttribute('data-value');
|
||||
|
||||
// Rerender should provide the same memoized value
|
||||
rerender(
|
||||
<MarketplaceProvider>
|
||||
<TestConsumer />
|
||||
</MarketplaceProvider>,
|
||||
);
|
||||
|
||||
const secondProvider = screen.getByTestId('chat-context-provider');
|
||||
const secondValue = secondProvider.getAttribute('data-value');
|
||||
|
||||
expect(firstValue).toBe(secondValue);
|
||||
});
|
||||
|
||||
it('provides minimal context without bloated functions', () => {
|
||||
render(
|
||||
<MarketplaceProvider>
|
||||
<div>{/* eslint-disable-line i18next/no-literal-string */}Test</div>
|
||||
</MarketplaceProvider>,
|
||||
);
|
||||
|
||||
const provider = screen.getByTestId('chat-context-provider');
|
||||
const valueData = JSON.parse(provider.getAttribute('data-value') || '{}');
|
||||
|
||||
// Should only have conversation object, not 44 empty functions
|
||||
expect(Object.keys(valueData)).toContain('conversation');
|
||||
expect(valueData.conversation).toEqual({
|
||||
endpoint: EModelEndpoint.agents,
|
||||
conversationId: 'marketplace',
|
||||
title: 'Agent Marketplace',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,141 +0,0 @@
|
|||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import '@testing-library/jest-dom';
|
||||
import SearchBar from '../SearchBar';
|
||||
|
||||
// Mock useLocalize hook
|
||||
jest.mock('~/hooks/useLocalize', () => () => (key: string) => key);
|
||||
|
||||
// Mock useDebounce hook
|
||||
jest.mock('~/hooks', () => ({
|
||||
useDebounce: (value: string) => value, // Return value immediately for testing
|
||||
}));
|
||||
|
||||
describe('SearchBar', () => {
|
||||
const mockOnSearch = jest.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnSearch.mockClear();
|
||||
});
|
||||
|
||||
it('renders with correct placeholder', () => {
|
||||
render(<SearchBar value="" onSearch={mockOnSearch} />);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input).toHaveAttribute('placeholder', 'com_agents_search_placeholder');
|
||||
});
|
||||
|
||||
it('displays the provided value', () => {
|
||||
render(<SearchBar value="test query" onSearch={mockOnSearch} />);
|
||||
|
||||
const input = screen.getByDisplayValue('test query');
|
||||
expect(input).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onSearch when user types', async () => {
|
||||
render(<SearchBar value="" onSearch={mockOnSearch} />);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
await user.type(input, 'test');
|
||||
|
||||
// Should call onSearch for each character due to debounce mock
|
||||
expect(mockOnSearch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows clear button when there is text', () => {
|
||||
render(<SearchBar value="test" onSearch={mockOnSearch} />);
|
||||
|
||||
const clearButton = screen.getByRole('button', { name: 'com_agents_clear_search' });
|
||||
expect(clearButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show clear button when text is empty', () => {
|
||||
render(<SearchBar value="" onSearch={mockOnSearch} />);
|
||||
|
||||
const clearButton = screen.queryByRole('button', { name: 'com_agents_clear_search' });
|
||||
expect(clearButton).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears search when clear button is clicked', async () => {
|
||||
render(<SearchBar value="test" onSearch={mockOnSearch} />);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
const clearButton = screen.getByRole('button', { name: 'com_agents_clear_search' });
|
||||
|
||||
// Verify initial state
|
||||
expect(input).toHaveValue('test');
|
||||
|
||||
await user.click(clearButton);
|
||||
|
||||
// Verify onSearch is called and input is cleared
|
||||
expect(mockOnSearch).toHaveBeenCalledWith('');
|
||||
expect(input).toHaveValue('');
|
||||
});
|
||||
|
||||
it('updates internal state when value prop changes', () => {
|
||||
const { rerender } = render(<SearchBar value="initial" onSearch={mockOnSearch} />);
|
||||
|
||||
expect(screen.getByDisplayValue('initial')).toBeInTheDocument();
|
||||
|
||||
rerender(<SearchBar value="updated" onSearch={mockOnSearch} />);
|
||||
|
||||
expect(screen.getByDisplayValue('updated')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has proper accessibility attributes', () => {
|
||||
render(<SearchBar value="" onSearch={mockOnSearch} />);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
expect(input).toHaveAttribute('aria-label', 'com_agents_search_aria');
|
||||
});
|
||||
|
||||
it('applies custom className', () => {
|
||||
render(<SearchBar value="" onSearch={mockOnSearch} className="custom-class" />);
|
||||
|
||||
const container = screen.getByRole('textbox').closest('div');
|
||||
expect(container).toHaveClass('custom-class');
|
||||
});
|
||||
|
||||
it('prevents form submission on clear button click', async () => {
|
||||
const handleSubmit = jest.fn();
|
||||
|
||||
render(
|
||||
<form onSubmit={handleSubmit}>
|
||||
<SearchBar value="test" onSearch={mockOnSearch} />
|
||||
</form>,
|
||||
);
|
||||
|
||||
const clearButton = screen.getByRole('button', { name: 'com_agents_clear_search' });
|
||||
await user.click(clearButton);
|
||||
|
||||
expect(handleSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles rapid typing correctly', async () => {
|
||||
render(<SearchBar value="" onSearch={mockOnSearch} />);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
|
||||
// Type multiple characters quickly
|
||||
await user.type(input, 'quick');
|
||||
|
||||
// Should handle all characters
|
||||
expect(input).toHaveValue('quick');
|
||||
});
|
||||
|
||||
it('maintains focus after clear button click', async () => {
|
||||
render(<SearchBar value="test" onSearch={mockOnSearch} />);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
const clearButton = screen.getByRole('button', { name: 'com_agents_clear_search' });
|
||||
|
||||
input.focus();
|
||||
await user.click(clearButton);
|
||||
|
||||
// Input should still be in the document and ready for new input
|
||||
expect(input).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,370 +0,0 @@
|
|||
import React from 'react';
|
||||
import { render, screen, waitFor, act } from '@testing-library/react';
|
||||
import { SmartLoader, useHasData } from '../SmartLoader';
|
||||
|
||||
// Mock setTimeout and clearTimeout for testing
|
||||
jest.useFakeTimers();
|
||||
|
||||
describe('SmartLoader', () => {
|
||||
const LoadingComponent = () => <div data-testid="loading">Loading...</div>;
|
||||
const ContentComponent = () => (
|
||||
<div data-testid="content">
|
||||
{/* eslint-disable-line i18next/no-literal-string */}Content loaded
|
||||
</div>
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
describe('Basic functionality', () => {
|
||||
it('shows content immediately when not loading', () => {
|
||||
render(
|
||||
<SmartLoader isLoading={false} hasData={true} loadingComponent={<LoadingComponent />}>
|
||||
<ContentComponent />
|
||||
</SmartLoader>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('content')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows content immediately when loading but has existing data', () => {
|
||||
render(
|
||||
<SmartLoader isLoading={true} hasData={true} loadingComponent={<LoadingComponent />}>
|
||||
<ContentComponent />
|
||||
</SmartLoader>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('content')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows content initially, then loading after delay when loading with no data', async () => {
|
||||
render(
|
||||
<SmartLoader
|
||||
isLoading={true}
|
||||
hasData={false}
|
||||
delay={150}
|
||||
loadingComponent={<LoadingComponent />}
|
||||
>
|
||||
<ContentComponent />
|
||||
</SmartLoader>,
|
||||
);
|
||||
|
||||
// Initially shows content
|
||||
expect(screen.getByTestId('content')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
|
||||
|
||||
// After delay, shows loading
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(150);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('loading')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('content')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('prevents loading flash for quick responses', async () => {
|
||||
const { rerender } = render(
|
||||
<SmartLoader
|
||||
isLoading={true}
|
||||
hasData={false}
|
||||
delay={150}
|
||||
loadingComponent={<LoadingComponent />}
|
||||
>
|
||||
<ContentComponent />
|
||||
</SmartLoader>,
|
||||
);
|
||||
|
||||
// Initially shows content
|
||||
expect(screen.getByTestId('content')).toBeInTheDocument();
|
||||
|
||||
// Advance time but not past delay
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
// Loading finishes before delay
|
||||
rerender(
|
||||
<SmartLoader
|
||||
isLoading={false}
|
||||
hasData={true}
|
||||
delay={150}
|
||||
loadingComponent={<LoadingComponent />}
|
||||
>
|
||||
<ContentComponent />
|
||||
</SmartLoader>,
|
||||
);
|
||||
|
||||
// Should still show content, never showed loading
|
||||
expect(screen.getByTestId('content')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
|
||||
|
||||
// Advance past original delay to ensure loading doesn't appear
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('content')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Delay behavior', () => {
|
||||
it('respects custom delay times', async () => {
|
||||
render(
|
||||
<SmartLoader
|
||||
isLoading={true}
|
||||
hasData={false}
|
||||
delay={300}
|
||||
loadingComponent={<LoadingComponent />}
|
||||
>
|
||||
<ContentComponent />
|
||||
</SmartLoader>,
|
||||
);
|
||||
|
||||
// Should show content initially
|
||||
expect(screen.getByTestId('content')).toBeInTheDocument();
|
||||
|
||||
// Should not show loading before delay
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(250);
|
||||
});
|
||||
expect(screen.getByTestId('content')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
|
||||
|
||||
// Should show loading after delay
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(60);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('loading')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('uses default delay when not specified', async () => {
|
||||
render(
|
||||
<SmartLoader isLoading={true} hasData={false} loadingComponent={<LoadingComponent />}>
|
||||
<ContentComponent />
|
||||
</SmartLoader>,
|
||||
);
|
||||
|
||||
// Should show content initially
|
||||
expect(screen.getByTestId('content')).toBeInTheDocument();
|
||||
|
||||
// Should show loading after default delay (150ms)
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(150);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('loading')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('State transitions', () => {
|
||||
it('immediately hides loading when loading completes', async () => {
|
||||
const { rerender } = render(
|
||||
<SmartLoader
|
||||
isLoading={true}
|
||||
hasData={false}
|
||||
delay={100}
|
||||
loadingComponent={<LoadingComponent />}
|
||||
>
|
||||
<ContentComponent />
|
||||
</SmartLoader>,
|
||||
);
|
||||
|
||||
// Advance past delay to show loading
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('loading')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Loading completes
|
||||
rerender(
|
||||
<SmartLoader
|
||||
isLoading={false}
|
||||
hasData={true}
|
||||
delay={100}
|
||||
loadingComponent={<LoadingComponent />}
|
||||
>
|
||||
<ContentComponent />
|
||||
</SmartLoader>,
|
||||
);
|
||||
|
||||
// Should immediately show content
|
||||
expect(screen.getByTestId('content')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles rapid loading state changes correctly', async () => {
|
||||
const { rerender } = render(
|
||||
<SmartLoader
|
||||
isLoading={true}
|
||||
hasData={false}
|
||||
delay={100}
|
||||
loadingComponent={<LoadingComponent />}
|
||||
>
|
||||
<ContentComponent />
|
||||
</SmartLoader>,
|
||||
);
|
||||
|
||||
// Rapid state changes
|
||||
rerender(
|
||||
<SmartLoader
|
||||
isLoading={false}
|
||||
hasData={true}
|
||||
delay={100}
|
||||
loadingComponent={<LoadingComponent />}
|
||||
>
|
||||
<ContentComponent />
|
||||
</SmartLoader>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<SmartLoader
|
||||
isLoading={true}
|
||||
hasData={false}
|
||||
delay={100}
|
||||
loadingComponent={<LoadingComponent />}
|
||||
>
|
||||
<ContentComponent />
|
||||
</SmartLoader>,
|
||||
);
|
||||
|
||||
// Should show content throughout rapid changes
|
||||
expect(screen.getByTestId('content')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CSS classes', () => {
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(
|
||||
<SmartLoader
|
||||
isLoading={false}
|
||||
hasData={true}
|
||||
loadingComponent={<LoadingComponent />}
|
||||
className="custom-class"
|
||||
>
|
||||
<ContentComponent />
|
||||
</SmartLoader>,
|
||||
);
|
||||
|
||||
const wrapper = container.firstChild as HTMLElement;
|
||||
expect(wrapper).toHaveClass('custom-class');
|
||||
});
|
||||
|
||||
it('applies className to both loading and content states', async () => {
|
||||
const { container } = render(
|
||||
<SmartLoader
|
||||
isLoading={true}
|
||||
hasData={false}
|
||||
delay={50}
|
||||
loadingComponent={<LoadingComponent />}
|
||||
className="custom-class"
|
||||
>
|
||||
<ContentComponent />
|
||||
</SmartLoader>,
|
||||
);
|
||||
|
||||
// Content state
|
||||
expect(container.firstChild).toHaveClass('custom-class');
|
||||
|
||||
// Loading state
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(50);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.firstChild).toHaveClass('custom-class');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useHasData', () => {
|
||||
const TestComponent: React.FC<{ data: any }> = ({ data }) => {
|
||||
const hasData = useHasData(data);
|
||||
return <div data-testid="result">{hasData ? 'has-data' : 'no-data'}</div>;
|
||||
};
|
||||
|
||||
it('returns false for null data', () => {
|
||||
render(<TestComponent data={null} />);
|
||||
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
|
||||
});
|
||||
|
||||
it('returns false for undefined data', () => {
|
||||
render(<TestComponent data={undefined} />);
|
||||
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
|
||||
});
|
||||
|
||||
it('detects empty agents array as no data', () => {
|
||||
render(<TestComponent data={{ agents: [] }} />);
|
||||
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
|
||||
});
|
||||
|
||||
it('detects non-empty agents array as has data', () => {
|
||||
render(<TestComponent data={{ agents: [{ id: '1', name: 'Test' }] }} />);
|
||||
expect(screen.getByTestId('result')).toHaveTextContent('has-data');
|
||||
});
|
||||
|
||||
it('detects invalid agents property as no data', () => {
|
||||
render(<TestComponent data={{ agents: 'not-array' }} />);
|
||||
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
|
||||
});
|
||||
|
||||
it('detects empty array as no data', () => {
|
||||
render(<TestComponent data={[]} />);
|
||||
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
|
||||
});
|
||||
|
||||
it('detects non-empty array as has data', () => {
|
||||
render(<TestComponent data={[{ name: 'category1' }]} />);
|
||||
expect(screen.getByTestId('result')).toHaveTextContent('has-data');
|
||||
});
|
||||
|
||||
it('detects agent with id as has data', () => {
|
||||
render(<TestComponent data={{ id: '123', name: 'Test Agent' }} />);
|
||||
expect(screen.getByTestId('result')).toHaveTextContent('has-data');
|
||||
});
|
||||
|
||||
it('detects agent with name only as has data', () => {
|
||||
render(<TestComponent data={{ name: 'Test Agent' }} />);
|
||||
expect(screen.getByTestId('result')).toHaveTextContent('has-data');
|
||||
});
|
||||
|
||||
it('detects object without id or name as no data', () => {
|
||||
render(<TestComponent data={{ description: 'Some description' }} />);
|
||||
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
|
||||
});
|
||||
|
||||
it('handles string data as no data', () => {
|
||||
render(<TestComponent data="some string" />);
|
||||
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
|
||||
});
|
||||
|
||||
it('handles number data as no data', () => {
|
||||
render(<TestComponent data={42} />);
|
||||
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
|
||||
});
|
||||
|
||||
it('handles boolean data as no data', () => {
|
||||
render(<TestComponent data={true} />);
|
||||
expect(screen.getByTestId('result')).toHaveTextContent('no-data');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue