mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +01:00
🖼️ style: Improve Marketplace & Sharing Dialog UI
feat: Enhance CategoryTabs and Marketplace components for better responsiveness and navigation feat: Refactor AgentCard and AgentGrid components for improved layout and accessibility feat: Implement animated category transitions in AgentMarketplace and update NewChat component layout feat: Refactor UI components for improved styling and accessibility in sharing dialogs refactor: remove GenericManagePermissionsDialog and GrantAccessDialog components - Deleted GenericManagePermissionsDialog and GrantAccessDialog components to streamline sharing functionality. - Updated ManagePermissionsDialog to utilize AccessRolesPicker directly. - Introduced UnifiedPeopleSearch for improved people selection experience. - Enhanced PublicSharingToggle with InfoHoverCard for better user guidance. - Adjusted AgentPanel to change error status to warning for duplicate agent versions. - Updated translations to include new keys for search and access management. feat: Add responsive design for SelectedPrincipalsList and improve layout in GenericGrantAccessDialog feat: Enhance styling in SelectedPrincipalsList and SearchPicker components for improved UI consistency feat: Improve PublicSharingToggle component with enhanced styling and accessibility features feat: Introduce InfoHoverCard component and refactor enums for better organization feat: Implement infinite scroll for agent grids and enhance performance - Added `useInfiniteScroll` hook to manage infinite scrolling behavior in agent grids. - Integrated infinite scroll functionality into `AgentGrid` and `VirtualizedAgentGrid` components. - Updated `AgentMarketplace` to pass the scroll container to the agent grid components. - Refactored loading indicators to show a spinner instead of a "Load More" button. - Created `VirtualizedAgentGrid` component for optimized rendering of agent cards using virtualization. - Added performance tests for `VirtualizedAgentGrid` to ensure efficient handling of large datasets. - Updated translations to include new messages for end-of-results scenarios. chore: Remove unused permission-related UI localization keys ci: Update Agent model tests to handle duplicate support_contact updates - Modified tests to ensure that updating an agent with the same support_contact does not create a new version and returns successfully. - Enhanced verification for partial changes in support_contact, confirming no new version is created when content remains the same. chore: Address ESLint, clean up unused imports and improve prop definitions in various components ci: fix tests ci: update tests chore: remove unused search localization keys
This commit is contained in:
parent
9585db14ba
commit
d82a63642d
51 changed files with 2074 additions and 1311 deletions
|
|
@ -1346,18 +1346,21 @@ describe('models/Agent', () => {
|
||||||
expect(secondUpdate.versions).toHaveLength(3);
|
expect(secondUpdate.versions).toHaveLength(3);
|
||||||
expect(secondUpdate.support_contact.email).toBe('updated@support.com');
|
expect(secondUpdate.support_contact.email).toBe('updated@support.com');
|
||||||
|
|
||||||
// Try to update with same support_contact - should be detected as duplicate
|
// Try to update with same support_contact - should be detected as duplicate but return successfully
|
||||||
await expect(
|
const duplicateUpdate = await updateAgent(
|
||||||
updateAgent(
|
{ id: agentId },
|
||||||
{ id: agentId },
|
{
|
||||||
{
|
support_contact: {
|
||||||
support_contact: {
|
name: 'Updated Support',
|
||||||
name: 'Updated Support',
|
email: 'updated@support.com',
|
||||||
email: 'updated@support.com',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
),
|
},
|
||||||
).rejects.toThrow('Duplicate version');
|
);
|
||||||
|
|
||||||
|
// Should not create a new version
|
||||||
|
expect(duplicateUpdate.versions).toHaveLength(3);
|
||||||
|
expect(duplicateUpdate.version).toBe(3);
|
||||||
|
expect(duplicateUpdate.support_contact.email).toBe('updated@support.com');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should handle support_contact from empty to populated', async () => {
|
test('should handle support_contact from empty to populated', async () => {
|
||||||
|
|
@ -1541,18 +1544,22 @@ describe('models/Agent', () => {
|
||||||
expect(updated.support_contact.name).toBe('New Name');
|
expect(updated.support_contact.name).toBe('New Name');
|
||||||
expect(updated.support_contact.email).toBe('');
|
expect(updated.support_contact.email).toBe('');
|
||||||
|
|
||||||
// Verify isDuplicateVersion works with partial changes
|
// Verify isDuplicateVersion works with partial changes - should return successfully without creating new version
|
||||||
await expect(
|
const duplicateUpdate = await updateAgent(
|
||||||
updateAgent(
|
{ id: agentId },
|
||||||
{ id: agentId },
|
{
|
||||||
{
|
support_contact: {
|
||||||
support_contact: {
|
name: 'New Name',
|
||||||
name: 'New Name',
|
email: '',
|
||||||
email: '',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
),
|
},
|
||||||
).rejects.toThrow('Duplicate version');
|
);
|
||||||
|
|
||||||
|
// Should not create a new version since content is the same
|
||||||
|
expect(duplicateUpdate.versions).toHaveLength(2);
|
||||||
|
expect(duplicateUpdate.version).toBe(2);
|
||||||
|
expect(duplicateUpdate.support_contact.name).toBe('New Name');
|
||||||
|
expect(duplicateUpdate.support_contact.email).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Edge Cases
|
// Edge Cases
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { Label } from '@librechat/client';
|
||||||
import type t from 'librechat-data-provider';
|
import type t from 'librechat-data-provider';
|
||||||
import useLocalize from '~/hooks/useLocalize';
|
|
||||||
import { cn, renderAgentAvatar, getContactDisplayName } from '~/utils';
|
import { cn, renderAgentAvatar, getContactDisplayName } from '~/utils';
|
||||||
|
import { useLocalize } from '~/hooks';
|
||||||
|
|
||||||
interface AgentCardProps {
|
interface AgentCardProps {
|
||||||
agent: t.Agent; // The agent data to display
|
agent: t.Agent; // The agent data to display
|
||||||
|
|
@ -18,10 +19,10 @@ const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' })
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'group relative flex overflow-hidden rounded-2xl',
|
'group relative h-40 overflow-hidden rounded-xl border border-border-light',
|
||||||
'cursor-pointer transition-colors duration-200',
|
'cursor-pointer shadow-sm transition-all duration-200 hover:border-border-medium hover:shadow-lg',
|
||||||
'aspect-[5/2.5] w-full',
|
'bg-surface-tertiary hover:bg-surface-hover',
|
||||||
'bg-surface-tertiary hover:bg-surface-hover-alt',
|
'space-y-3 p-4',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
|
@ -39,50 +40,57 @@ const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' })
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex h-full gap-2 px-3 py-2 sm:gap-3 sm:px-4 sm:py-3">
|
{/* Two column layout */}
|
||||||
{/* Agent avatar section - left side, responsive */}
|
<div className="flex h-full items-start gap-3">
|
||||||
<div className="flex flex-shrink-0 items-center">
|
{/* Left column: Avatar and Category */}
|
||||||
{renderAgentAvatar(agent, { size: 'md' })}
|
<div className="flex h-full flex-shrink-0 flex-col justify-between space-y-4">
|
||||||
|
<div className="flex-shrink-0">{renderAgentAvatar(agent, { size: 'sm' })}</div>
|
||||||
|
|
||||||
|
{/* Category tag */}
|
||||||
|
<div className="inline-flex items-center rounded-md border-border-xheavy bg-surface-active-alt px-2 py-1 text-xs font-medium">
|
||||||
|
{agent.category && (
|
||||||
|
<Label className="line-clamp-1 font-normal">
|
||||||
|
{agent.category.charAt(0).toUpperCase() + agent.category.slice(1)}
|
||||||
|
</Label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Agent info section - right side, responsive */}
|
{/* Right column: Name, description, and other content */}
|
||||||
<div className="flex min-w-0 flex-1 flex-col justify-center">
|
<div className="min-w-0 flex-1 space-y-1">
|
||||||
{/* Agent name - responsive text sizing */}
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="mb-1 line-clamp-1 text-base font-bold text-text-primary sm:mb-2 sm:text-lg">
|
{/* Agent name */}
|
||||||
{agent.name}
|
<Label className="mb-1 line-clamp-1 text-xl font-semibold text-text-primary">
|
||||||
</h3>
|
{agent.name}
|
||||||
|
</Label>
|
||||||
|
|
||||||
{/* Agent description - responsive text sizing and spacing */}
|
{/* Owner info */}
|
||||||
|
{(() => {
|
||||||
|
const displayName = getContactDisplayName(agent);
|
||||||
|
if (displayName) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center text-sm text-text-secondary">
|
||||||
|
<Label className="mr-1">🔹</Label>
|
||||||
|
<Label>{displayName}</Label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Agent description */}
|
||||||
<p
|
<p
|
||||||
id={`agent-${agent.id}-description`}
|
id={`agent-${agent.id}-description`}
|
||||||
className={cn(
|
className="line-clamp-3 text-sm leading-relaxed text-text-primary"
|
||||||
'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')}`}
|
aria-label={`Description: ${agent.description || localize('com_agents_no_description')}`}
|
||||||
>
|
>
|
||||||
{agent.description || (
|
{agent.description || (
|
||||||
<span className="italic text-text-secondary">
|
<Label className="font-normal italic text-text-primary">
|
||||||
{localize('com_agents_no_description')}
|
{localize('com_agents_no_description')}
|
||||||
</span>
|
</Label>
|
||||||
)}
|
)}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,30 @@
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo, useEffect } from 'react';
|
||||||
import { Button, Spinner } from '@librechat/client';
|
import { Spinner } from '@librechat/client';
|
||||||
import { PermissionBits } from 'librechat-data-provider';
|
import { PermissionBits } from 'librechat-data-provider';
|
||||||
import type t from 'librechat-data-provider';
|
import type t from 'librechat-data-provider';
|
||||||
import { useMarketplaceAgentsInfiniteQuery } from '~/data-provider/Agents';
|
import { useMarketplaceAgentsInfiniteQuery } from '~/data-provider/Agents';
|
||||||
import { useAgentCategories, useLocalize } from '~/hooks';
|
import { useAgentCategories, useLocalize } from '~/hooks';
|
||||||
|
import { useInfiniteScroll } from '~/hooks/useInfiniteScroll';
|
||||||
import { useHasData } from './SmartLoader';
|
import { useHasData } from './SmartLoader';
|
||||||
import ErrorDisplay from './ErrorDisplay';
|
import ErrorDisplay from './ErrorDisplay';
|
||||||
import AgentCard from './AgentCard';
|
import AgentCard from './AgentCard';
|
||||||
import { cn } from '~/utils';
|
|
||||||
|
|
||||||
interface AgentGridProps {
|
interface AgentGridProps {
|
||||||
category: string; // Currently selected category
|
category: string; // Currently selected category
|
||||||
searchQuery: string; // Current search query
|
searchQuery: string; // Current search query
|
||||||
onSelectAgent: (agent: t.Agent) => void; // Callback when agent is selected
|
onSelectAgent: (agent: t.Agent) => void; // Callback when agent is selected
|
||||||
|
scrollElement?: HTMLElement | null; // Parent scroll container for infinite scroll
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component for displaying a grid of agent cards
|
* Component for displaying a grid of agent cards
|
||||||
*/
|
*/
|
||||||
const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAgent }) => {
|
const AgentGrid: React.FC<AgentGridProps> = ({
|
||||||
|
category,
|
||||||
|
searchQuery,
|
||||||
|
onSelectAgent,
|
||||||
|
scrollElement,
|
||||||
|
}) => {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
|
|
||||||
// Get category data from API
|
// Get category data from API
|
||||||
|
|
@ -78,6 +84,26 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
|
||||||
// Check if we have meaningful data to prevent unnecessary loading states
|
// Check if we have meaningful data to prevent unnecessary loading states
|
||||||
const hasData = useHasData(data?.pages?.[0]);
|
const hasData = useHasData(data?.pages?.[0]);
|
||||||
|
|
||||||
|
// Set up infinite scroll
|
||||||
|
const { setScrollElement } = useInfiniteScroll({
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
fetchNextPage: () => {
|
||||||
|
if (hasNextPage && !isFetching) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
threshold: 0.8, // Trigger when 80% scrolled
|
||||||
|
throttleMs: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connect the scroll element when it's provided
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollElement) {
|
||||||
|
setScrollElement(scrollElement);
|
||||||
|
}
|
||||||
|
}, [scrollElement, setScrollElement]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get category display name from API data or use fallback
|
* Get category display name from API data or use fallback
|
||||||
*/
|
*/
|
||||||
|
|
@ -99,59 +125,10 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
|
||||||
return categoryValue.charAt(0).toUpperCase() + categoryValue.slice(1);
|
return categoryValue.charAt(0).toUpperCase() + categoryValue.slice(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
// Simple loading spinner
|
||||||
* Load more agents when "See More" button is clicked
|
const loadingSpinner = (
|
||||||
*/
|
<div className="flex justify-center py-12">
|
||||||
const handleLoadMore = () => {
|
<Spinner className="h-8 w-8 text-primary" />
|
||||||
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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -179,19 +156,6 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
aria-busy={isLoading && !hasData}
|
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 */}
|
{/* Handle empty results with enhanced accessibility */}
|
||||||
{(!currentAgents || currentAgents.length === 0) && !isLoading && !isFetching ? (
|
{(!currentAgents || currentAgents.length === 0) && !isLoading && !isFetching ? (
|
||||||
<div
|
<div
|
||||||
|
|
@ -204,16 +168,7 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
|
||||||
: localize('com_agents_empty_state_heading')
|
: localize('com_agents_empty_state_heading')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<h3 className="mb-2 text-lg font-medium">
|
<h3 className="mb-2 text-lg font-medium">{localize('com_agents_empty_state_heading')}</h3>
|
||||||
{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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|
@ -244,9 +199,9 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Loading indicator when fetching more with accessibility */}
|
{/* Loading indicator when fetching more with accessibility */}
|
||||||
{isFetching && hasNextPage && (
|
{isFetchingNextPage && (
|
||||||
<div
|
<div
|
||||||
className="flex justify-center py-4"
|
className="flex justify-center py-8"
|
||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
aria-label={localize('com_agents_loading')}
|
aria-label={localize('com_agents_loading')}
|
||||||
|
|
@ -256,23 +211,12 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Load more button with enhanced accessibility */}
|
{/* End of results indicator */}
|
||||||
{hasNextPage && !isFetching && (
|
{!hasNextPage && currentAgents && currentAgents.length > 0 && (
|
||||||
<div className="mt-8 flex justify-center">
|
<div className="mt-8 text-center">
|
||||||
<Button
|
<p className="text-sm text-text-secondary">
|
||||||
variant="outline"
|
{localize('com_agents_no_more_results')}
|
||||||
onClick={handleLoadMore}
|
</p>
|
||||||
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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
@ -281,7 +225,7 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isLoading || (isFetching && !isFetchingNextPage)) {
|
if (isLoading || (isFetching && !isFetchingNextPage)) {
|
||||||
return loadingSkeleton;
|
return loadingSpinner;
|
||||||
}
|
}
|
||||||
return mainContent;
|
return mainContent;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ interface CategoryTabsProps {
|
||||||
* Renders a tabbed navigation interface showing agent categories.
|
* Renders a tabbed navigation interface showing agent categories.
|
||||||
* Includes loading states, empty state handling, and displays counts for each category.
|
* Includes loading states, empty state handling, and displays counts for each category.
|
||||||
* Uses database-driven category labels with no hardcoded values.
|
* Uses database-driven category labels with no hardcoded values.
|
||||||
|
* Features multi-row wrapping for better responsive behavior.
|
||||||
*/
|
*/
|
||||||
const CategoryTabs: React.FC<CategoryTabsProps> = ({
|
const CategoryTabs: React.FC<CategoryTabsProps> = ({
|
||||||
categories,
|
categories,
|
||||||
|
|
@ -46,14 +47,13 @@ const CategoryTabs: React.FC<CategoryTabsProps> = ({
|
||||||
return category.label || category.value.charAt(0).toUpperCase() + category.value.slice(1);
|
return category.label || category.value.charAt(0).toUpperCase() + category.value.slice(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Loading skeleton component
|
|
||||||
const loadingSkeleton = (
|
const loadingSkeleton = (
|
||||||
<div className="w-full pb-2">
|
<div className="w-full pb-2">
|
||||||
<div className="no-scrollbar flex gap-1.5 overflow-x-auto px-4">
|
<div className="flex flex-wrap justify-center gap-1.5 px-4">
|
||||||
{[...Array(6)].map((_, i) => (
|
{[...Array(6)].map((_, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="h-[36px] min-w-[80px] animate-pulse rounded-md bg-surface-tertiary"
|
className="h-[36px] min-w-[80px] animate-pulse rounded-lg bg-surface-tertiary"
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -67,15 +67,23 @@ const CategoryTabs: React.FC<CategoryTabsProps> = ({
|
||||||
|
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case 'ArrowLeft':
|
case 'ArrowLeft':
|
||||||
case 'ArrowUp':
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
newIndex = currentIndex > 0 ? currentIndex - 1 : categories.length - 1;
|
newIndex = currentIndex > 0 ? currentIndex - 1 : categories.length - 1;
|
||||||
break;
|
break;
|
||||||
case 'ArrowRight':
|
case 'ArrowRight':
|
||||||
case 'ArrowDown':
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
newIndex = currentIndex < categories.length - 1 ? currentIndex + 1 : 0;
|
newIndex = currentIndex < categories.length - 1 ? currentIndex + 1 : 0;
|
||||||
break;
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
// Move up a row (approximate by moving back ~4-6 items)
|
||||||
|
newIndex = Math.max(0, currentIndex - 5);
|
||||||
|
break;
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
// Move down a row (approximate by moving forward ~4-6 items)
|
||||||
|
newIndex = Math.min(categories.length - 1, currentIndex + 5);
|
||||||
|
break;
|
||||||
case 'Home':
|
case 'Home':
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
newIndex = 0;
|
newIndex = 0;
|
||||||
|
|
@ -94,7 +102,9 @@ const CategoryTabs: React.FC<CategoryTabsProps> = ({
|
||||||
// Focus the new tab
|
// Focus the new tab
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const newTab = document.getElementById(`category-tab-${newCategory.value}`);
|
const newTab = document.getElementById(`category-tab-${newCategory.value}`);
|
||||||
newTab?.focus();
|
if (newTab) {
|
||||||
|
newTab.focus();
|
||||||
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -108,16 +118,12 @@ const CategoryTabs: React.FC<CategoryTabsProps> = ({
|
||||||
|
|
||||||
// Main tabs content
|
// Main tabs content
|
||||||
const tabsContent = (
|
const tabsContent = (
|
||||||
<div className="relative w-full pb-2">
|
<div className="w-full pb-2">
|
||||||
<div
|
<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"
|
className="flex flex-wrap justify-center gap-1.5 px-4"
|
||||||
role="tablist"
|
role="tablist"
|
||||||
aria-label={localize('com_agents_category_tabs_label')}
|
aria-label={localize('com_agents_category_tabs_label')}
|
||||||
aria-orientation="horizontal"
|
aria-orientation="horizontal"
|
||||||
style={{
|
|
||||||
scrollSnapType: 'x mandatory',
|
|
||||||
WebkitOverflowScrolling: 'touch',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{categories.map((category, index) => (
|
{categories.map((category, index) => (
|
||||||
<button
|
<button
|
||||||
|
|
@ -126,14 +132,11 @@ const CategoryTabs: React.FC<CategoryTabsProps> = ({
|
||||||
onClick={() => onChange(category.value)}
|
onClick={() => onChange(category.value)}
|
||||||
onKeyDown={(e) => handleKeyDown(e, category.value)}
|
onKeyDown={(e) => handleKeyDown(e, category.value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative mt-1 cursor-pointer select-none whitespace-nowrap rounded-md px-3 py-2',
|
'relative cursor-pointer select-none whitespace-nowrap px-3 py-2 transition-colors',
|
||||||
activeTab === category.value
|
activeTab === category.value
|
||||||
? 'bg-surface-tertiary text-text-primary'
|
? 'rounded-t-lg bg-surface-hover text-text-primary'
|
||||||
: 'bg-surface-secondary text-text-secondary hover:bg-surface-hover hover:text-text-primary',
|
: 'rounded-lg bg-surface-secondary text-text-secondary hover:bg-surface-hover hover:text-text-primary',
|
||||||
)}
|
)}
|
||||||
style={{
|
|
||||||
scrollSnapAlign: 'start',
|
|
||||||
}}
|
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={activeTab === category.value}
|
aria-selected={activeTab === category.value}
|
||||||
aria-controls={`tabpanel-${category.value}`}
|
aria-controls={`tabpanel-${category.value}`}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||||
import { useRecoilState } from 'recoil';
|
import { useRecoilState } from 'recoil';
|
||||||
import { useOutletContext } from 'react-router-dom';
|
import { useOutletContext } from 'react-router-dom';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
@ -43,11 +43,24 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
||||||
const { navVisible, setNavVisible } = useOutletContext<ContextType>();
|
const { navVisible, setNavVisible } = useOutletContext<ContextType>();
|
||||||
const [hideSidePanel, setHideSidePanel] = useRecoilState(store.hideSidePanel);
|
const [hideSidePanel, setHideSidePanel] = useRecoilState(store.hideSidePanel);
|
||||||
|
|
||||||
// Get URL parameters (default to 'promoted' instead of 'all')
|
// Get URL parameters (default to 'all' to ensure users see agents)
|
||||||
const activeTab = category || 'promoted';
|
const activeTab = category || 'all';
|
||||||
const searchQuery = searchParams.get('q') || '';
|
const searchQuery = searchParams.get('q') || '';
|
||||||
const selectedAgentId = searchParams.get('agent_id') || '';
|
const selectedAgentId = searchParams.get('agent_id') || '';
|
||||||
|
|
||||||
|
// Animation state
|
||||||
|
type Direction = 'left' | 'right';
|
||||||
|
const [displayCategory, setDisplayCategory] = useState<string>(activeTab);
|
||||||
|
const [nextCategory, setNextCategory] = useState<string | null>(null);
|
||||||
|
const [isTransitioning, setIsTransitioning] = useState<boolean>(false);
|
||||||
|
const [animationDirection, setAnimationDirection] = useState<Direction>('right');
|
||||||
|
|
||||||
|
// Keep a ref of initial mount to avoid animating first sync
|
||||||
|
const didInitRef = useRef(false);
|
||||||
|
|
||||||
|
// Ref for the scrollable container to enable infinite scroll
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Local state
|
// Local state
|
||||||
const [isDetailOpen, setIsDetailOpen] = useState(false);
|
const [isDetailOpen, setIsDetailOpen] = useState(false);
|
||||||
const [selectedAgent, setSelectedAgent] = useState<t.Agent | null>(null);
|
const [selectedAgent, setSelectedAgent] = useState<t.Agent | null>(null);
|
||||||
|
|
@ -64,6 +77,15 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
||||||
localStorage.setItem('fullPanelCollapse', 'false');
|
localStorage.setItem('fullPanelCollapse', 'false');
|
||||||
}, [setHideSidePanel, hideSidePanel]);
|
}, [setHideSidePanel, hideSidePanel]);
|
||||||
|
|
||||||
|
// Redirect base /agents route to /agents/all for consistency
|
||||||
|
useEffect(() => {
|
||||||
|
if (!category && window.location.pathname === '/agents') {
|
||||||
|
const currentSearchParams = searchParams.toString();
|
||||||
|
const searchParamsStr = currentSearchParams ? `?${currentSearchParams}` : '';
|
||||||
|
navigate(`/agents/all${searchParamsStr}`, { replace: true });
|
||||||
|
}
|
||||||
|
}, [category, navigate, searchParams]);
|
||||||
|
|
||||||
// Ensure endpoints config is loaded first (required for agent queries)
|
// Ensure endpoints config is loaded first (required for agent queries)
|
||||||
useGetEndpointsQuery();
|
useGetEndpointsQuery();
|
||||||
|
|
||||||
|
|
@ -101,22 +123,92 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle category tab selection changes
|
* Determine ordered tabs to compute indices for direction
|
||||||
*
|
*/
|
||||||
* @param tabValue - The selected category value
|
const orderedTabs = useMemo<string[]>(() => {
|
||||||
|
const dynamic = (categoriesQuery.data || []).map((c) => c.value);
|
||||||
|
// Ensure unique and stable order - 'all' should be last to match server response
|
||||||
|
const set = new Set<string>(['promoted', ...dynamic, 'all']);
|
||||||
|
return Array.from(set);
|
||||||
|
}, [categoriesQuery.data]);
|
||||||
|
|
||||||
|
const getTabIndex = useCallback(
|
||||||
|
(tab: string): number => {
|
||||||
|
const idx = orderedTabs.indexOf(tab);
|
||||||
|
return idx >= 0 ? idx : 0;
|
||||||
|
},
|
||||||
|
[orderedTabs],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle category tab selection changes with directional animation
|
||||||
*/
|
*/
|
||||||
const handleTabChange = (tabValue: string) => {
|
const handleTabChange = (tabValue: string) => {
|
||||||
|
if (tabValue === activeTab || isTransitioning) {
|
||||||
|
// Ignore redundant or rapid clicks during transition
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentIndex = getTabIndex(displayCategory);
|
||||||
|
const newIndex = getTabIndex(tabValue);
|
||||||
|
const direction: Direction = newIndex > currentIndex ? 'right' : 'left';
|
||||||
|
|
||||||
|
setAnimationDirection(direction);
|
||||||
|
setNextCategory(tabValue);
|
||||||
|
setIsTransitioning(true);
|
||||||
|
|
||||||
|
// Update URL immediately, preserving current search params
|
||||||
const currentSearchParams = searchParams.toString();
|
const currentSearchParams = searchParams.toString();
|
||||||
const searchParamsStr = currentSearchParams ? `?${currentSearchParams}` : '';
|
const searchParamsStr = currentSearchParams ? `?${currentSearchParams}` : '';
|
||||||
|
|
||||||
// Navigate to the selected category
|
|
||||||
if (tabValue === 'promoted') {
|
if (tabValue === 'promoted') {
|
||||||
navigate(`/agents${searchParamsStr}`);
|
navigate(`/agents${searchParamsStr}`);
|
||||||
} else {
|
} else {
|
||||||
navigate(`/agents/${tabValue}${searchParamsStr}`);
|
navigate(`/agents/${tabValue}${searchParamsStr}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Complete transition after 300ms
|
||||||
|
window.setTimeout(() => {
|
||||||
|
setDisplayCategory(tabValue);
|
||||||
|
setNextCategory(null);
|
||||||
|
setIsTransitioning(false);
|
||||||
|
}, 300);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync animation when URL changes externally (back/forward or deep links)
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!didInitRef.current) {
|
||||||
|
// First render: do not animate; just set display to current active tab
|
||||||
|
didInitRef.current = true;
|
||||||
|
setDisplayCategory(activeTab);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isTransitioning || activeTab === displayCategory) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Compute direction vs current displayCategory and animate
|
||||||
|
const currentIndex = getTabIndex(displayCategory);
|
||||||
|
const newIndex = getTabIndex(activeTab);
|
||||||
|
const direction: Direction = newIndex > currentIndex ? 'right' : 'left';
|
||||||
|
|
||||||
|
setAnimationDirection(direction);
|
||||||
|
setNextCategory(activeTab);
|
||||||
|
setIsTransitioning(true);
|
||||||
|
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
setDisplayCategory(activeTab);
|
||||||
|
setNextCategory(null);
|
||||||
|
setIsTransitioning(false);
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
};
|
||||||
|
}, [activeTab, displayCategory, isTransitioning, getTabIndex]);
|
||||||
|
|
||||||
|
// No longer needed with keyframes
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle search query changes
|
* Handle search query changes
|
||||||
*
|
*
|
||||||
|
|
@ -207,7 +299,10 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
||||||
>
|
>
|
||||||
<main className="flex h-full flex-col overflow-hidden" role="main">
|
<main className="flex h-full flex-col overflow-hidden" role="main">
|
||||||
{/* Scrollable container */}
|
{/* Scrollable container */}
|
||||||
<div className="scrollbar-gutter-stable flex h-full flex-col overflow-y-auto overflow-x-hidden">
|
<div
|
||||||
|
ref={scrollContainerRef}
|
||||||
|
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 */}
|
{/* Simplified header for agents marketplace - only show nav controls when needed */}
|
||||||
{!isSmallScreen && (
|
{!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="sticky top-0 z-20 flex items-center justify-between bg-surface-secondary p-2 font-semibold text-text-primary md:h-14">
|
||||||
|
|
@ -276,63 +371,158 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
||||||
|
|
||||||
{/* Scrollable content area */}
|
{/* Scrollable content area */}
|
||||||
<div className="container mx-auto max-w-4xl px-4 pb-8">
|
<div className="container mx-auto max-w-4xl px-4 pb-8">
|
||||||
{/* Category header - only show when not searching */}
|
{/* Two-pane animated container wrapping category header + grid */}
|
||||||
{!searchQuery && (
|
<div className="relative overflow-hidden">
|
||||||
<div className="mb-6 mt-6">
|
{/* Current content pane */}
|
||||||
{(() => {
|
<div
|
||||||
// Get category data for display
|
className={cn(
|
||||||
const getCategoryData = () => {
|
isTransitioning &&
|
||||||
if (activeTab === 'promoted') {
|
(animationDirection === 'right'
|
||||||
return {
|
? 'motion-safe:animate-slide-out-left'
|
||||||
name: localize('com_agents_top_picks'),
|
: 'motion-safe:animate-slide-out-right'),
|
||||||
description: localize('com_agents_recommended'),
|
)}
|
||||||
|
key={`pane-current-${displayCategory}`}
|
||||||
|
>
|
||||||
|
{/* Category header - only show when not searching */}
|
||||||
|
{!searchQuery && (
|
||||||
|
<div className="mb-6 mt-6">
|
||||||
|
{(() => {
|
||||||
|
// Get category data for display
|
||||||
|
const getCategoryData = () => {
|
||||||
|
if (displayCategory === 'promoted') {
|
||||||
|
return {
|
||||||
|
name: localize('com_agents_top_picks'),
|
||||||
|
description: localize('com_agents_recommended'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (displayCategory === '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 === displayCategory,
|
||||||
|
);
|
||||||
|
if (categoryData) {
|
||||||
|
return {
|
||||||
|
name: categoryData.label,
|
||||||
|
description: categoryData.description || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for unknown categories
|
||||||
|
return {
|
||||||
|
name:
|
||||||
|
displayCategory.charAt(0).toUpperCase() +
|
||||||
|
displayCategory.slice(1),
|
||||||
|
description: '',
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
|
||||||
if (activeTab === 'all') {
|
|
||||||
return {
|
|
||||||
name: 'All Agents',
|
|
||||||
description: 'Browse all shared agents across all categories',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the category in the API data
|
const { name, description } = getCategoryData();
|
||||||
const categoryData = categoriesQuery.data?.find(
|
|
||||||
(cat) => cat.value === activeTab,
|
|
||||||
);
|
|
||||||
if (categoryData) {
|
|
||||||
return {
|
|
||||||
name: categoryData.label,
|
|
||||||
description: categoryData.description || '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback for unknown categories
|
return (
|
||||||
return {
|
<div className="text-left">
|
||||||
name: activeTab.charAt(0).toUpperCase() + activeTab.slice(1),
|
<h2 className="text-2xl font-bold text-text-primary">{name}</h2>
|
||||||
description: '',
|
{description && (
|
||||||
};
|
<p className="mt-2 text-text-secondary">{description}</p>
|
||||||
};
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
const { name, description } = getCategoryData();
|
{/* Agent grid */}
|
||||||
|
<AgentGrid
|
||||||
return (
|
key={`grid-${displayCategory}`}
|
||||||
<div className="text-left">
|
category={displayCategory}
|
||||||
<h2 className="text-2xl font-bold text-text-primary">{name}</h2>
|
searchQuery={searchQuery}
|
||||||
{description && (
|
onSelectAgent={handleAgentSelect}
|
||||||
<p className="mt-2 text-text-secondary">{description}</p>
|
scrollElement={scrollContainerRef.current}
|
||||||
)}
|
/>
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Agent grid */}
|
{/* Next content pane, only during transition */}
|
||||||
<AgentGrid
|
{isTransitioning && nextCategory && (
|
||||||
category={activeTab}
|
<div
|
||||||
searchQuery={searchQuery}
|
className={cn(
|
||||||
onSelectAgent={handleAgentSelect}
|
'absolute inset-0',
|
||||||
/>
|
animationDirection === 'right'
|
||||||
|
? 'motion-safe:animate-slide-in-right'
|
||||||
|
: 'motion-safe:animate-slide-in-left',
|
||||||
|
)}
|
||||||
|
key={`pane-next-${nextCategory}-${animationDirection}`}
|
||||||
|
>
|
||||||
|
{/* Category header - only show when not searching */}
|
||||||
|
{!searchQuery && (
|
||||||
|
<div className="mb-6 mt-6">
|
||||||
|
{(() => {
|
||||||
|
// Get category data for display
|
||||||
|
const getCategoryData = () => {
|
||||||
|
if (nextCategory === 'promoted') {
|
||||||
|
return {
|
||||||
|
name: localize('com_agents_top_picks'),
|
||||||
|
description: localize('com_agents_recommended'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (nextCategory === '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 === nextCategory,
|
||||||
|
);
|
||||||
|
if (categoryData) {
|
||||||
|
return {
|
||||||
|
name: categoryData.label,
|
||||||
|
description: categoryData.description || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for unknown categories
|
||||||
|
return {
|
||||||
|
name:
|
||||||
|
(nextCategory || '').charAt(0).toUpperCase() +
|
||||||
|
(nextCategory || '').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
|
||||||
|
key={`grid-${nextCategory}`}
|
||||||
|
category={nextCategory}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
onSelectAgent={handleAgentSelect}
|
||||||
|
scrollElement={scrollContainerRef.current}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Note: Using Tailwind keyframes for slide in/out animations */}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Agent detail dialog */}
|
{/* Agent detail dialog */}
|
||||||
|
|
|
||||||
|
|
@ -73,33 +73,33 @@ const SearchBar: React.FC<SearchBarProps> = ({ value, onSearch, className = '' }
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder={localize('com_agents_search_placeholder')}
|
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"
|
className="h-14 rounded-2xl border-border-medium bg-transparent pl-12 pr-12 text-lg text-text-primary shadow-md transition-[border-color,box-shadow] duration-200 placeholder:text-text-secondary focus:border-border-heavy focus:shadow-lg focus:ring-0"
|
||||||
aria-label={localize('com_agents_search_aria')}
|
aria-label={localize('com_agents_search_aria')}
|
||||||
aria-describedby="search-instructions search-results-count"
|
aria-describedby="search-instructions search-results-count"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
spellCheck="false"
|
spellCheck="false"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Search icon with proper accessibility */}
|
|
||||||
<div className="absolute inset-y-0 left-0 flex items-center pl-4" aria-hidden="true">
|
<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" />
|
<Search className="size-5 text-text-secondary" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hidden instructions for screen readers */}
|
{/* Hidden instructions for screen readers */}
|
||||||
<div id="search-instructions" className="sr-only">
|
<div id="search-instructions" className="sr-only">
|
||||||
{localize('com_agents_search_instructions')}
|
{localize('com_agents_search_instructions')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Show clear button only when search has value - Google style */}
|
{/* Show clear button only when search has value - Google style */}
|
||||||
{searchTerm && (
|
{searchTerm && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleClear}
|
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"
|
className="group absolute right-4 top-1/2 flex size-5 -translate-y-1/2 items-center justify-center rounded-full transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||||
aria-label={localize('com_agents_clear_search')}
|
aria-label={localize('com_agents_clear_search')}
|
||||||
title={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} />
|
<X
|
||||||
|
className="size-5 text-text-secondary transition-colors duration-200 group-hover:text-text-primary"
|
||||||
|
strokeWidth={2.5}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
348
client/src/components/Agents/VirtualizedAgentGrid.tsx
Normal file
348
client/src/components/Agents/VirtualizedAgentGrid.tsx
Normal file
|
|
@ -0,0 +1,348 @@
|
||||||
|
import React, { useMemo, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { AutoSizer, List as VirtualList, WindowScroller } from 'react-virtualized';
|
||||||
|
import { throttle } from 'lodash';
|
||||||
|
import { Spinner } from '@librechat/client';
|
||||||
|
import { PermissionBits } 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 VirtualizedAgentGridProps {
|
||||||
|
category: string;
|
||||||
|
searchQuery: string;
|
||||||
|
onSelectAgent: (agent: t.Agent) => void;
|
||||||
|
scrollElement?: HTMLElement | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constants for layout calculations
|
||||||
|
const CARD_HEIGHT = 160; // h-40 in pixels
|
||||||
|
const GAP_SIZE = 24; // gap-6 in pixels
|
||||||
|
const ROW_HEIGHT = CARD_HEIGHT + GAP_SIZE;
|
||||||
|
const CARDS_PER_ROW_MOBILE = 1;
|
||||||
|
const CARDS_PER_ROW_DESKTOP = 2;
|
||||||
|
const OVERSCAN_ROW_COUNT = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Virtualized grid component for displaying agent cards with high performance
|
||||||
|
*/
|
||||||
|
const VirtualizedAgentGrid: React.FC<VirtualizedAgentGridProps> = ({
|
||||||
|
category,
|
||||||
|
searchQuery,
|
||||||
|
onSelectAgent,
|
||||||
|
scrollElement,
|
||||||
|
}) => {
|
||||||
|
const localize = useLocalize();
|
||||||
|
const listRef = useRef<VirtualList>(null);
|
||||||
|
const { categories } = useAgentCategories();
|
||||||
|
|
||||||
|
// Build query parameters
|
||||||
|
const queryParams = useMemo(() => {
|
||||||
|
const params: {
|
||||||
|
requiredPermission: number;
|
||||||
|
category?: string;
|
||||||
|
search?: string;
|
||||||
|
limit: number;
|
||||||
|
promoted?: 0 | 1;
|
||||||
|
} = {
|
||||||
|
requiredPermission: PermissionBits.VIEW,
|
||||||
|
// Align with AgentGrid to eliminate API mismatch as a factor
|
||||||
|
limit: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (searchQuery) {
|
||||||
|
params.search = searchQuery;
|
||||||
|
if (category !== 'all' && category !== 'promoted') {
|
||||||
|
params.category = category;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (category === 'promoted') {
|
||||||
|
params.promoted = 1;
|
||||||
|
} else if (category !== 'all') {
|
||||||
|
params.category = category;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}, [category, searchQuery]);
|
||||||
|
|
||||||
|
// Use infinite query
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
isFetching,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
refetch,
|
||||||
|
isFetchingNextPage,
|
||||||
|
} = useMarketplaceAgentsInfiniteQuery(queryParams);
|
||||||
|
|
||||||
|
// Flatten pages into single array
|
||||||
|
const currentAgents = useMemo(() => {
|
||||||
|
if (!data?.pages) return [];
|
||||||
|
return data.pages.flatMap((page) => page.data || []);
|
||||||
|
}, [data?.pages]);
|
||||||
|
|
||||||
|
const hasData = useHasData(data?.pages?.[0]);
|
||||||
|
|
||||||
|
// Direct scroll handling for virtualized component to avoid hook conflicts
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scrollElement) return;
|
||||||
|
|
||||||
|
const throttledScrollHandler = throttle(() => {
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = scrollElement;
|
||||||
|
const scrollPosition = (scrollTop + clientHeight) / scrollHeight;
|
||||||
|
|
||||||
|
if (scrollPosition >= 0.8 && hasNextPage && !isFetchingNextPage && !isFetching) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
scrollElement.addEventListener('scroll', throttledScrollHandler, { passive: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
scrollElement.removeEventListener('scroll', throttledScrollHandler);
|
||||||
|
throttledScrollHandler.cancel?.();
|
||||||
|
};
|
||||||
|
}, [scrollElement, hasNextPage, isFetchingNextPage, isFetching, fetchNextPage, category]);
|
||||||
|
|
||||||
|
// Separate effect for list re-rendering on data changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (listRef.current) {
|
||||||
|
listRef.current.forceUpdateGrid();
|
||||||
|
}
|
||||||
|
}, [currentAgents]);
|
||||||
|
|
||||||
|
// Helper functions for grid calculations
|
||||||
|
const getCardsPerRow = useCallback((width: number) => {
|
||||||
|
return width >= 768 ? CARDS_PER_ROW_DESKTOP : CARDS_PER_ROW_MOBILE;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getRowCount = useCallback((agentCount: number, cardsPerRow: number) => {
|
||||||
|
return Math.ceil(agentCount / cardsPerRow);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getRowItems = useCallback(
|
||||||
|
(rowIndex: number, cardsPerRow: number) => {
|
||||||
|
const startIndex = rowIndex * cardsPerRow;
|
||||||
|
const endIndex = Math.min(startIndex + cardsPerRow, currentAgents.length);
|
||||||
|
return currentAgents.slice(startIndex, endIndex);
|
||||||
|
},
|
||||||
|
[currentAgents],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getCategoryDisplayName = (categoryValue: string) => {
|
||||||
|
const categoryData = categories.find((cat) => cat.value === categoryValue);
|
||||||
|
if (categoryData) {
|
||||||
|
return categoryData.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (categoryValue === 'promoted') {
|
||||||
|
return localize('com_agents_top_picks');
|
||||||
|
}
|
||||||
|
if (categoryValue === 'all') {
|
||||||
|
return 'All';
|
||||||
|
}
|
||||||
|
|
||||||
|
return categoryValue.charAt(0).toUpperCase() + categoryValue.slice(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Row renderer for virtual list
|
||||||
|
const rowRenderer = useCallback(
|
||||||
|
({ index, key, style, parent }: any) => {
|
||||||
|
const containerWidth = parent?.props?.width || 800;
|
||||||
|
const cardsPerRow = getCardsPerRow(containerWidth);
|
||||||
|
const rowAgents = getRowItems(index, cardsPerRow);
|
||||||
|
const totalRows = getRowCount(currentAgents.length, cardsPerRow);
|
||||||
|
const isLastRow = index === totalRows - 1;
|
||||||
|
const showLoading = isFetchingNextPage && isLastRow;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={key} style={style}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'grid gap-6 px-0',
|
||||||
|
cardsPerRow === 1 ? 'grid-cols-1' : 'grid-cols-1 md:grid-cols-2',
|
||||||
|
)}
|
||||||
|
role="row"
|
||||||
|
aria-rowindex={index + 1}
|
||||||
|
>
|
||||||
|
{rowAgents.map((agent: t.Agent, cardIndex: number) => {
|
||||||
|
const globalIndex = index * cardsPerRow + cardIndex;
|
||||||
|
return (
|
||||||
|
<div key={`${agent.id}-${globalIndex}`} role="gridcell">
|
||||||
|
<AgentCard agent={agent} onClick={() => onSelectAgent(agent)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showLoading && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
currentAgents,
|
||||||
|
getCardsPerRow,
|
||||||
|
getRowItems,
|
||||||
|
getRowCount,
|
||||||
|
isFetchingNextPage,
|
||||||
|
localize,
|
||||||
|
onSelectAgent,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simple loading spinner
|
||||||
|
const loadingSpinner = (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<Spinner className="h-8 w-8 text-primary" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle error state
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<ErrorDisplay
|
||||||
|
error={error || 'Unknown error occurred'}
|
||||||
|
onRetry={() => refetch()}
|
||||||
|
context={{ searchQuery, category }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle loading state
|
||||||
|
if (isLoading || (isFetching && !isFetchingNextPage)) {
|
||||||
|
return loadingSpinner;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle empty results
|
||||||
|
if ((!currentAgents || currentAgents.length === 0) && !isLoading && !isFetching) {
|
||||||
|
return (
|
||||||
|
<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">{localize('com_agents_empty_state_heading')}</h3>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main virtualized content
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="space-y-6"
|
||||||
|
role="tabpanel"
|
||||||
|
id={`category-panel-${category}`}
|
||||||
|
aria-labelledby={`category-tab-${category}`}
|
||||||
|
aria-live="polite"
|
||||||
|
aria-busy={isLoading && !hasData}
|
||||||
|
>
|
||||||
|
{/* Screen reader announcement */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Virtualized grid with external scroll integration */}
|
||||||
|
<div
|
||||||
|
role="grid"
|
||||||
|
aria-label={localize('com_agents_grid_announcement', {
|
||||||
|
count: currentAgents.length,
|
||||||
|
category: getCategoryDisplayName(category),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{scrollElement ? (
|
||||||
|
<WindowScroller scrollElement={scrollElement}>
|
||||||
|
{({ height, isScrolling, registerChild, onChildScroll, scrollTop }) => (
|
||||||
|
<AutoSizer disableHeight>
|
||||||
|
{({ width }) => {
|
||||||
|
const cardsPerRow = getCardsPerRow(width);
|
||||||
|
const rowCount = getRowCount(currentAgents.length, cardsPerRow);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={registerChild}>
|
||||||
|
<VirtualList
|
||||||
|
ref={listRef}
|
||||||
|
autoHeight
|
||||||
|
height={height}
|
||||||
|
isScrolling={isScrolling}
|
||||||
|
onScroll={onChildScroll}
|
||||||
|
overscanRowCount={OVERSCAN_ROW_COUNT}
|
||||||
|
rowCount={rowCount}
|
||||||
|
rowHeight={ROW_HEIGHT}
|
||||||
|
rowRenderer={rowRenderer}
|
||||||
|
scrollTop={scrollTop}
|
||||||
|
width={width}
|
||||||
|
style={{ outline: 'none' }}
|
||||||
|
aria-rowcount={rowCount}
|
||||||
|
data-testid="virtual-list"
|
||||||
|
data-total-rows={rowCount}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</AutoSizer>
|
||||||
|
)}
|
||||||
|
</WindowScroller>
|
||||||
|
) : (
|
||||||
|
// Fallback for when no external scroll element is provided
|
||||||
|
<div style={{ height: 600 }}>
|
||||||
|
<AutoSizer>
|
||||||
|
{({ width, height }) => {
|
||||||
|
const cardsPerRow = getCardsPerRow(width);
|
||||||
|
const rowCount = getRowCount(currentAgents.length, cardsPerRow);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VirtualList
|
||||||
|
ref={listRef}
|
||||||
|
height={height}
|
||||||
|
overscanRowCount={OVERSCAN_ROW_COUNT}
|
||||||
|
rowCount={rowCount}
|
||||||
|
rowHeight={ROW_HEIGHT}
|
||||||
|
rowRenderer={rowRenderer}
|
||||||
|
width={width}
|
||||||
|
style={{ outline: 'none' }}
|
||||||
|
aria-rowcount={rowCount}
|
||||||
|
data-testid="virtual-list"
|
||||||
|
data-total-rows={rowCount}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</AutoSizer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* End of results indicator */}
|
||||||
|
{!hasNextPage && currentAgents && currentAgents.length > 0 && (
|
||||||
|
<div className="mt-8 text-center">
|
||||||
|
<p className="text-sm text-text-secondary">{localize('com_agents_no_more_results')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VirtualizedAgentGrid;
|
||||||
|
|
@ -48,7 +48,7 @@ describe('AgentCard', () => {
|
||||||
|
|
||||||
expect(screen.getByText('Test Agent')).toBeInTheDocument();
|
expect(screen.getByText('Test Agent')).toBeInTheDocument();
|
||||||
expect(screen.getByText('A test agent for testing purposes')).toBeInTheDocument();
|
expect(screen.getByText('A test agent for testing purposes')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Created by')).toBeInTheDocument();
|
expect(screen.getByText('🔹')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Test Support')).toBeInTheDocument();
|
expect(screen.getByText('Test Support')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -152,7 +152,7 @@ describe('AgentCard', () => {
|
||||||
|
|
||||||
render(<AgentCard agent={agentWithAuthorName} onClick={mockOnClick} />);
|
render(<AgentCard agent={agentWithAuthorName} onClick={mockOnClick} />);
|
||||||
|
|
||||||
expect(screen.getByText('Created by')).toBeInTheDocument();
|
expect(screen.getByText('🔹')).toBeInTheDocument();
|
||||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -165,7 +165,7 @@ describe('AgentCard', () => {
|
||||||
|
|
||||||
render(<AgentCard agent={agentWithEmailOnly} onClick={mockOnClick} />);
|
render(<AgentCard agent={agentWithEmailOnly} onClick={mockOnClick} />);
|
||||||
|
|
||||||
expect(screen.getByText('Created by')).toBeInTheDocument();
|
expect(screen.getByText('🔹')).toBeInTheDocument();
|
||||||
expect(screen.getByText('contact@example.com')).toBeInTheDocument();
|
expect(screen.getByText('contact@example.com')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -178,7 +178,7 @@ describe('AgentCard', () => {
|
||||||
|
|
||||||
render(<AgentCard agent={agentWithBoth} onClick={mockOnClick} />);
|
render(<AgentCard agent={agentWithBoth} onClick={mockOnClick} />);
|
||||||
|
|
||||||
expect(screen.getByText('Created by')).toBeInTheDocument();
|
expect(screen.getByText('🔹')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Support Team')).toBeInTheDocument();
|
expect(screen.getByText('Support Team')).toBeInTheDocument();
|
||||||
expect(screen.queryByText('John Doe')).not.toBeInTheDocument();
|
expect(screen.queryByText('John Doe')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
@ -195,7 +195,7 @@ describe('AgentCard', () => {
|
||||||
|
|
||||||
render(<AgentCard agent={agentWithNameAndEmail} onClick={mockOnClick} />);
|
render(<AgentCard agent={agentWithNameAndEmail} onClick={mockOnClick} />);
|
||||||
|
|
||||||
expect(screen.getByText('Created by')).toBeInTheDocument();
|
expect(screen.getByText('🔹')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Support Team')).toBeInTheDocument();
|
expect(screen.getByText('Support Team')).toBeInTheDocument();
|
||||||
expect(screen.queryByText('support@example.com')).not.toBeInTheDocument();
|
expect(screen.queryByText('support@example.com')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -33,13 +33,11 @@ jest.mock('~/hooks/useLocalize', () => () => (key: string, options?: any) => {
|
||||||
com_agents_see_more: 'See more',
|
com_agents_see_more: 'See more',
|
||||||
com_agents_error_loading: 'Error loading agents',
|
com_agents_error_loading: 'Error loading agents',
|
||||||
com_agents_error_searching: 'Error searching 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_search_empty_heading: 'No results found',
|
||||||
com_agents_empty_state_heading: 'No agents available',
|
com_agents_empty_state_heading: 'No agents available',
|
||||||
com_agents_loading: 'Loading...',
|
com_agents_loading: 'Loading...',
|
||||||
com_agents_grid_announcement: '{{count}} agents in {{category}}',
|
com_agents_grid_announcement: '{{count}} agents in {{category}}',
|
||||||
com_agents_load_more_label: 'Load more agents from {{category}}',
|
com_agents_no_more_results: "You've reached the end of the results",
|
||||||
};
|
};
|
||||||
|
|
||||||
let translation = mockTranslations[key] || key;
|
let translation = mockTranslations[key] || key;
|
||||||
|
|
@ -250,8 +248,9 @@ describe('AgentGrid Integration with useGetMarketplaceAgentsQuery', () => {
|
||||||
</Wrapper>,
|
</Wrapper>,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Should show skeleton loading state
|
// Should show loading spinner
|
||||||
expect(document.querySelector('.animate-pulse')).toBeInTheDocument();
|
const spinner = document.querySelector('.text-primary');
|
||||||
|
expect(spinner).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show empty state when no agents are available', () => {
|
it('should show empty state when no agents are available', () => {
|
||||||
|
|
@ -312,7 +311,8 @@ describe('AgentGrid Integration with useGetMarketplaceAgentsQuery', () => {
|
||||||
</Wrapper>,
|
</Wrapper>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText('Results for "automation"')).toBeInTheDocument();
|
// The component doesn't show search result titles, just displays the filtered agents
|
||||||
|
expect(screen.getByTestId('agent-card-1')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show empty search results message', () => {
|
it('should show empty search results message', () => {
|
||||||
|
|
@ -338,29 +338,16 @@ describe('AgentGrid Integration with useGetMarketplaceAgentsQuery', () => {
|
||||||
</Wrapper>,
|
</Wrapper>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText('No results found')).toBeInTheDocument();
|
expect(screen.getByText('No agents available')).toBeInTheDocument();
|
||||||
expect(screen.getByText('No agents found. Try another search term.')).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Load More Functionality', () => {
|
describe('Infinite Scroll Functionality', () => {
|
||||||
it('should show "See more" button when hasNextPage is true', () => {
|
it('should show loading indicator when fetching next page', () => {
|
||||||
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({
|
mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({
|
||||||
...defaultMockQueryResult,
|
...defaultMockQueryResult,
|
||||||
hasNextPage: false,
|
isFetchingNextPage: true,
|
||||||
|
hasNextPage: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const Wrapper = createWrapper();
|
const Wrapper = createWrapper();
|
||||||
|
|
@ -370,7 +357,44 @@ describe('AgentGrid Integration with useGetMarketplaceAgentsQuery', () => {
|
||||||
</Wrapper>,
|
</Wrapper>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.queryByRole('button', { name: /Load more agents/ })).not.toBeInTheDocument();
|
expect(screen.getByRole('status', { name: 'Loading...' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Loading...')).toHaveClass('sr-only');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show end of results message when hasNextPage is false and agents exist', () => {
|
||||||
|
mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({
|
||||||
|
...defaultMockQueryResult,
|
||||||
|
hasNextPage: false,
|
||||||
|
isFetchingNextPage: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const Wrapper = createWrapper();
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />
|
||||||
|
</Wrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("You've reached the end of the results")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not show end of results message when no agents exist', () => {
|
||||||
|
mockUseMarketplaceAgentsInfiniteQuery.mockReturnValue({
|
||||||
|
...defaultMockQueryResult,
|
||||||
|
hasNextPage: false,
|
||||||
|
data: {
|
||||||
|
pages: [{ data: [] }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const Wrapper = createWrapper();
|
||||||
|
render(
|
||||||
|
<Wrapper>
|
||||||
|
<AgentGrid category="finance" searchQuery="" onSelectAgent={mockOnSelectAgent} />
|
||||||
|
</Wrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByText("You've reached the end of the results")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ describe('CategoryTabs', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const generalTab = screen.getByText('General').closest('button');
|
const generalTab = screen.getByText('General').closest('button');
|
||||||
expect(generalTab).toHaveClass('bg-surface-tertiary');
|
expect(generalTab).toHaveClass('bg-surface-hover');
|
||||||
|
|
||||||
// Should have active underline
|
// Should have active underline
|
||||||
const underline = generalTab?.querySelector('.absolute.bottom-0');
|
const underline = generalTab?.querySelector('.absolute.bottom-0');
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,275 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { jest } from '@jest/globals';
|
||||||
|
import VirtualizedAgentGrid from '../VirtualizedAgentGrid';
|
||||||
|
import type * as t from 'librechat-data-provider';
|
||||||
|
|
||||||
|
// Mock react-virtualized for performance testing
|
||||||
|
const mockRowRenderer = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('react-virtualized', () => {
|
||||||
|
const mockRowRendererRef = { current: jest.fn() };
|
||||||
|
|
||||||
|
return {
|
||||||
|
AutoSizer: ({
|
||||||
|
children,
|
||||||
|
disableHeight,
|
||||||
|
}: {
|
||||||
|
children: (props: { width: number; height?: number }) => React.ReactNode;
|
||||||
|
disableHeight?: boolean;
|
||||||
|
}) => {
|
||||||
|
if (disableHeight) {
|
||||||
|
return children({ width: 1200 });
|
||||||
|
}
|
||||||
|
return children({ width: 1200, height: 800 });
|
||||||
|
},
|
||||||
|
List: ({
|
||||||
|
rowRenderer,
|
||||||
|
rowCount,
|
||||||
|
autoHeight,
|
||||||
|
height,
|
||||||
|
width,
|
||||||
|
rowHeight,
|
||||||
|
overscanRowCount,
|
||||||
|
scrollTop,
|
||||||
|
isScrolling,
|
||||||
|
onScroll,
|
||||||
|
style,
|
||||||
|
'aria-rowcount': ariaRowCount,
|
||||||
|
'data-testid': dataTestId,
|
||||||
|
'data-total-rows': dataTotalRows,
|
||||||
|
}: {
|
||||||
|
rowRenderer: any;
|
||||||
|
rowCount: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}) => {
|
||||||
|
// Store the row renderer for testing
|
||||||
|
if (typeof rowRenderer === 'function') {
|
||||||
|
mockRowRendererRef.current = rowRenderer;
|
||||||
|
mockRowRenderer.mockImplementation(rowRenderer);
|
||||||
|
}
|
||||||
|
// Only render visible rows to simulate virtualization
|
||||||
|
const visibleRows = Math.min(10, rowCount); // Simulate 10 visible rows
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid={dataTestId || 'virtual-list'}
|
||||||
|
data-total-rows={dataTotalRows || rowCount}
|
||||||
|
aria-rowcount={ariaRowCount}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{Array.from({ length: visibleRows }, (_, index) =>
|
||||||
|
rowRenderer({
|
||||||
|
index,
|
||||||
|
key: `row-${index}`,
|
||||||
|
style: { height: 184 },
|
||||||
|
parent: { props: { width: width || 1200 } },
|
||||||
|
}),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
WindowScroller: ({
|
||||||
|
children,
|
||||||
|
scrollElement,
|
||||||
|
}: {
|
||||||
|
children: (props: any) => React.ReactNode;
|
||||||
|
scrollElement?: HTMLElement | null;
|
||||||
|
}) => {
|
||||||
|
return children({
|
||||||
|
height: 800,
|
||||||
|
isScrolling: false,
|
||||||
|
registerChild: (ref: any) => {},
|
||||||
|
onChildScroll: () => {},
|
||||||
|
scrollTop: 0,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate large dataset for performance testing
|
||||||
|
const generateLargeDataset = (count: number) => {
|
||||||
|
const agents: Partial<t.Agent>[] = [];
|
||||||
|
for (let i = 1; i <= count; i++) {
|
||||||
|
agents.push({
|
||||||
|
id: `agent-${i}`,
|
||||||
|
name: `Performance Test Agent ${i}`,
|
||||||
|
description: `This is agent ${i} for performance testing virtual scrolling with large datasets`,
|
||||||
|
category: i % 2 === 0 ? 'productivity' : 'development',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return agents;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock the data provider with large dataset
|
||||||
|
const createMockInfiniteQuery = (agentCount: number) => ({
|
||||||
|
data: {
|
||||||
|
pages: [{ data: generateLargeDataset(agentCount) }],
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
isFetching: false,
|
||||||
|
fetchNextPage: jest.fn(),
|
||||||
|
hasNextPage: false,
|
||||||
|
refetch: jest.fn(),
|
||||||
|
isFetchingNextPage: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock must be hoisted before imports
|
||||||
|
jest.mock('~/data-provider/Agents', () => ({
|
||||||
|
useMarketplaceAgentsInfiniteQuery: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mock('~/hooks', () => ({
|
||||||
|
useAgentCategories: () => ({
|
||||||
|
categories: [
|
||||||
|
{ value: 'productivity', label: 'Productivity' },
|
||||||
|
{ value: 'development', label: 'Development' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
useLocalize: () => (key: string, params?: any) => {
|
||||||
|
if (key === 'com_agents_grid_announcement') {
|
||||||
|
return `Found ${params?.count || 0} agents in ${params?.category || 'category'}`;
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../SmartLoader', () => ({
|
||||||
|
useHasData: () => true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../AgentCard', () => {
|
||||||
|
return function MockAgentCard({ agent }: { agent: any }) {
|
||||||
|
return (
|
||||||
|
<div data-testid={`agent-card-${agent.id}`} style={{ height: '160px' }}>
|
||||||
|
<h3>{agent.name}</h3>
|
||||||
|
<p>{agent.description}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Virtual Scrolling Performance', () => {
|
||||||
|
let queryClient: QueryClient;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mockRowRenderer.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderComponent = (agentCount: number) => {
|
||||||
|
const mockQuery = createMockInfiniteQuery(agentCount);
|
||||||
|
const useMarketplaceAgentsInfiniteQuery =
|
||||||
|
jest.requireMock('~/data-provider/Agents').useMarketplaceAgentsInfiniteQuery;
|
||||||
|
useMarketplaceAgentsInfiniteQuery.mockReturnValue(mockQuery);
|
||||||
|
|
||||||
|
// Clear previous mock calls
|
||||||
|
mockRowRenderer.mockClear();
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<VirtualizedAgentGrid category="all" searchQuery="" onSelectAgent={jest.fn()} />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('efficiently handles 1000 agents without rendering all DOM nodes', () => {
|
||||||
|
const startTime = performance.now();
|
||||||
|
renderComponent(1000);
|
||||||
|
const endTime = performance.now();
|
||||||
|
|
||||||
|
const virtualList = screen.getByTestId('virtual-list');
|
||||||
|
expect(virtualList).toBeInTheDocument();
|
||||||
|
expect(virtualList).toHaveAttribute('data-total-rows', '500'); // 1000 agents / 2 per row
|
||||||
|
|
||||||
|
// Should only render visible cards, not all 1000
|
||||||
|
const renderedCards = screen.getAllByTestId(/agent-card-/);
|
||||||
|
expect(renderedCards.length).toBeLessThan(50); // Much less than 1000
|
||||||
|
expect(renderedCards.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Performance check: rendering should be fast
|
||||||
|
const renderTime = endTime - startTime;
|
||||||
|
expect(renderTime).toBeLessThan(600); // Should render in less than 600ms
|
||||||
|
|
||||||
|
console.log(`Rendered 1000 agents in ${renderTime.toFixed(2)}ms`);
|
||||||
|
console.log(`Only ${renderedCards.length} DOM nodes created for 1000 agents`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('efficiently handles 5000 agents (stress test)', () => {
|
||||||
|
const startTime = performance.now();
|
||||||
|
renderComponent(5000);
|
||||||
|
const endTime = performance.now();
|
||||||
|
|
||||||
|
const virtualList = screen.getByTestId('virtual-list');
|
||||||
|
expect(virtualList).toBeInTheDocument();
|
||||||
|
expect(virtualList).toHaveAttribute('data-total-rows', '2500'); // 5000 agents / 2 per row
|
||||||
|
|
||||||
|
// Should still only render visible cards
|
||||||
|
const renderedCards = screen.getAllByTestId(/agent-card-/);
|
||||||
|
expect(renderedCards.length).toBeLessThan(50);
|
||||||
|
expect(renderedCards.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Performance should still be reasonable
|
||||||
|
const renderTime = endTime - startTime;
|
||||||
|
expect(renderTime).toBeLessThan(200); // Should render in less than 200ms
|
||||||
|
|
||||||
|
console.log(`Rendered 5000 agents in ${renderTime.toFixed(2)}ms`);
|
||||||
|
console.log(`Only ${renderedCards.length} DOM nodes created for 5000 agents`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculates correct number of virtual rows for different screen sizes', () => {
|
||||||
|
// Test desktop layout (2 cards per row)
|
||||||
|
renderComponent(100);
|
||||||
|
|
||||||
|
const virtualList = screen.getByTestId('virtual-list');
|
||||||
|
expect(virtualList).toHaveAttribute('data-total-rows', '50'); // 100 agents / 2 per row
|
||||||
|
});
|
||||||
|
|
||||||
|
it('row renderer is called efficiently', () => {
|
||||||
|
// Reset the mock before testing
|
||||||
|
mockRowRenderer.mockClear();
|
||||||
|
|
||||||
|
renderComponent(1000);
|
||||||
|
|
||||||
|
// Check that virtual list was rendered
|
||||||
|
const virtualList = screen.getByTestId('virtual-list');
|
||||||
|
expect(virtualList).toBeInTheDocument();
|
||||||
|
|
||||||
|
// With virtualization, we should only render visible rows
|
||||||
|
// Our mock renders 10 visible rows max
|
||||||
|
const renderedCards = screen.getAllByTestId(/agent-card-/);
|
||||||
|
expect(renderedCards.length).toBeLessThanOrEqual(20); // At most 10 rows * 2 cards per row
|
||||||
|
expect(renderedCards.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('memory usage remains stable with large datasets', () => {
|
||||||
|
// Test that memory doesn't grow linearly with data size
|
||||||
|
const measureMemory = () => {
|
||||||
|
const cards = screen.queryAllByTestId(/agent-card-/);
|
||||||
|
return cards.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
renderComponent(100);
|
||||||
|
const memory100 = measureMemory();
|
||||||
|
|
||||||
|
renderComponent(1000);
|
||||||
|
const memory1000 = measureMemory();
|
||||||
|
|
||||||
|
renderComponent(5000);
|
||||||
|
const memory5000 = measureMemory();
|
||||||
|
|
||||||
|
// Memory usage should not scale linearly with data size
|
||||||
|
// All should render roughly the same number of DOM nodes
|
||||||
|
expect(Math.abs(memory100 - memory1000)).toBeLessThan(30);
|
||||||
|
expect(Math.abs(memory1000 - memory5000)).toBeLessThan(30);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Memory usage: 100 agents=${memory100}, 1000 agents=${memory1000}, 5000 agents=${memory5000}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
248
client/src/components/Agents/tests/VirtualizedAgentGrid.test.tsx
Normal file
248
client/src/components/Agents/tests/VirtualizedAgentGrid.test.tsx
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { jest } from '@jest/globals';
|
||||||
|
import VirtualizedAgentGrid from '../VirtualizedAgentGrid';
|
||||||
|
import type t from 'librechat-data-provider';
|
||||||
|
|
||||||
|
// Mock react-virtualized
|
||||||
|
jest.mock('react-virtualized', () => ({
|
||||||
|
AutoSizer: ({
|
||||||
|
children,
|
||||||
|
disableHeight,
|
||||||
|
}: {
|
||||||
|
children: (props: { width: number; height?: number }) => React.ReactNode;
|
||||||
|
disableHeight?: boolean;
|
||||||
|
}) => {
|
||||||
|
if (disableHeight) {
|
||||||
|
return children({ width: 800 });
|
||||||
|
}
|
||||||
|
return children({ width: 800, height: 600 });
|
||||||
|
},
|
||||||
|
List: ({
|
||||||
|
rowRenderer,
|
||||||
|
rowCount,
|
||||||
|
width,
|
||||||
|
style,
|
||||||
|
'aria-rowcount': ariaRowCount,
|
||||||
|
'data-testid': dataTestId,
|
||||||
|
'data-total-rows': dataTotalRows,
|
||||||
|
}: {
|
||||||
|
rowRenderer: any;
|
||||||
|
rowCount: number;
|
||||||
|
autoHeight?: boolean;
|
||||||
|
height?: number;
|
||||||
|
width?: number;
|
||||||
|
rowHeight?: number;
|
||||||
|
overscanRowCount?: number;
|
||||||
|
scrollTop?: number;
|
||||||
|
isScrolling?: boolean;
|
||||||
|
onScroll?: any;
|
||||||
|
style?: any;
|
||||||
|
'aria-rowcount'?: number;
|
||||||
|
'data-testid'?: string;
|
||||||
|
'data-total-rows'?: number;
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
data-testid={dataTestId || 'virtual-list'}
|
||||||
|
aria-rowcount={ariaRowCount}
|
||||||
|
data-total-rows={dataTotalRows}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{Array.from({ length: Math.min(rowCount, 5) }, (_, index) =>
|
||||||
|
rowRenderer({
|
||||||
|
index,
|
||||||
|
key: `row-${index}`,
|
||||||
|
style: {},
|
||||||
|
parent: { props: { width: width || 800 } },
|
||||||
|
}),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
WindowScroller: ({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: (props: any) => React.ReactNode;
|
||||||
|
scrollElement?: HTMLElement | null;
|
||||||
|
}) => {
|
||||||
|
return children({
|
||||||
|
height: 600,
|
||||||
|
isScrolling: false,
|
||||||
|
registerChild: (_ref: any) => {},
|
||||||
|
onChildScroll: () => {},
|
||||||
|
scrollTop: 0,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the data provider
|
||||||
|
const mockInfiniteQuery = {
|
||||||
|
data: {
|
||||||
|
pages: [
|
||||||
|
{
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Test Agent 1',
|
||||||
|
description: 'A test agent for virtual scrolling',
|
||||||
|
category: 'productivity',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Test Agent 2',
|
||||||
|
description: 'Another test agent',
|
||||||
|
category: 'development',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
isFetching: false,
|
||||||
|
fetchNextPage: jest.fn(),
|
||||||
|
hasNextPage: true,
|
||||||
|
refetch: jest.fn(),
|
||||||
|
isFetchingNextPage: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.mock('~/data-provider/Agents', () => ({
|
||||||
|
useMarketplaceAgentsInfiniteQuery: jest.fn(() => mockInfiniteQuery),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock other hooks
|
||||||
|
jest.mock('~/hooks', () => ({
|
||||||
|
useAgentCategories: () => ({
|
||||||
|
categories: [
|
||||||
|
{ value: 'productivity', label: 'Productivity' },
|
||||||
|
{ value: 'development', label: 'Development' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
useLocalize: () => (key: string, params?: any) => {
|
||||||
|
if (key === 'com_agents_grid_announcement') {
|
||||||
|
return `Found ${params?.count || 0} agents in ${params?.category || 'category'}`;
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../SmartLoader', () => ({
|
||||||
|
useHasData: () => true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../AgentCard', () => {
|
||||||
|
return function MockAgentCard({ agent, onClick }: { agent: t.Agent; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<div data-testid={`agent-card-${agent.id}`} onClick={onClick}>
|
||||||
|
<h3>{agent.name}</h3>
|
||||||
|
<p>{agent.description}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('VirtualizedAgentGrid', () => {
|
||||||
|
let queryClient: QueryClient;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderComponent = (props = {}) => {
|
||||||
|
const defaultProps = {
|
||||||
|
category: 'all',
|
||||||
|
searchQuery: '',
|
||||||
|
onSelectAgent: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<VirtualizedAgentGrid {...defaultProps} {...props} />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('renders virtual list container', async () => {
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('virtual-list')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays agent cards in virtual rows', async () => {
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
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('calls onSelectAgent when agent card is clicked', async () => {
|
||||||
|
const onSelectAgent = jest.fn();
|
||||||
|
renderComponent({ onSelectAgent });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('agent-card-1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
screen.getByTestId('agent-card-1').click();
|
||||||
|
|
||||||
|
expect(onSelectAgent).toHaveBeenCalledWith({
|
||||||
|
id: '1',
|
||||||
|
name: 'Test Agent 1',
|
||||||
|
description: 'A test agent for virtual scrolling',
|
||||||
|
category: 'productivity',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows loading spinner when loading', async () => {
|
||||||
|
const mockQuery = jest.fn(() => ({
|
||||||
|
...mockInfiniteQuery,
|
||||||
|
isLoading: true,
|
||||||
|
data: undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const useMarketplaceAgentsInfiniteQuery =
|
||||||
|
jest.requireMock('~/data-provider/Agents').useMarketplaceAgentsInfiniteQuery;
|
||||||
|
useMarketplaceAgentsInfiniteQuery.mockImplementation(mockQuery);
|
||||||
|
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
// Should show loading spinner
|
||||||
|
const spinner = document.querySelector('.spinner');
|
||||||
|
expect(spinner).toBeInTheDocument();
|
||||||
|
expect(spinner).toHaveClass('h-8 w-8 text-primary');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has proper accessibility attributes', async () => {
|
||||||
|
// Reset the mock to ensure we have data
|
||||||
|
const useMarketplaceAgentsInfiniteQuery =
|
||||||
|
jest.requireMock('~/data-provider/Agents').useMarketplaceAgentsInfiniteQuery;
|
||||||
|
useMarketplaceAgentsInfiniteQuery.mockImplementation(() => mockInfiniteQuery);
|
||||||
|
|
||||||
|
renderComponent({ category: 'productivity' });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('virtual-list')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const gridContainer = screen.getByRole('grid');
|
||||||
|
expect(gridContainer).toHaveAttribute('aria-label');
|
||||||
|
expect(gridContainer.getAttribute('aria-label')).toContain('2');
|
||||||
|
expect(gridContainer.getAttribute('aria-label')).toContain('Productivity');
|
||||||
|
|
||||||
|
const tabpanel = screen.getByRole('tabpanel');
|
||||||
|
expect(tabpanel).toHaveAttribute('id', 'category-panel-productivity');
|
||||||
|
expect(tabpanel).toHaveAttribute('aria-labelledby', 'category-tab-productivity');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -92,7 +92,26 @@ export default function NewChat({
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
|
{showAgentMarketplace && (
|
||||||
|
<div className="flex">
|
||||||
|
<TooltipAnchor
|
||||||
|
description={localize('com_agents_marketplace')}
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
data-testid="nav-agents-marketplace-button"
|
||||||
|
aria-label={localize('com_agents_marketplace')}
|
||||||
|
className="rounded-full border-none bg-transparent p-2 hover:bg-surface-hover md:rounded-xl"
|
||||||
|
onClick={handleAgentMarketplace}
|
||||||
|
>
|
||||||
|
<LayoutGrid className="icon-md md:h-6 md:w-6" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{headerButtons}
|
{headerButtons}
|
||||||
|
|
||||||
<TooltipAnchor
|
<TooltipAnchor
|
||||||
description={localize('com_ui_new_chat')}
|
description={localize('com_ui_new_chat')}
|
||||||
render={
|
render={
|
||||||
|
|
@ -110,29 +129,6 @@ export default function NewChat({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Agent Marketplace button - separate row like ChatGPT */}
|
|
||||||
{showAgentMarketplace && (
|
|
||||||
<div className="flex">
|
|
||||||
<TooltipAnchor
|
|
||||||
description={localize('com_agents_marketplace')}
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
data-testid="nav-agents-marketplace-button"
|
|
||||||
aria-label={localize('com_agents_marketplace')}
|
|
||||||
className="flex w-full items-center justify-start gap-3 rounded-xl border-none bg-transparent p-3 text-left hover:bg-surface-hover"
|
|
||||||
onClick={handleAgentMarketplace}
|
|
||||||
>
|
|
||||||
<LayoutGrid className="h-5 w-5 flex-shrink-0" />
|
|
||||||
<span className="truncate text-sm font-medium">
|
|
||||||
{localize('com_agents_marketplace')}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{subHeaders != null ? subHeaders : null}
|
{subHeaders != null ? subHeaders : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useRecoilState } from 'recoil';
|
import { useRecoilState } from 'recoil';
|
||||||
import { Switch, Label } from '@librechat/client';
|
import { Switch, Label, InfoHoverCard, ESide } from '@librechat/client';
|
||||||
import HoverCardSettings from '../HoverCardSettings';
|
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
|
||||||
|
|
@ -17,7 +16,7 @@ export default function DisplayUsernameMessages() {
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Label className="font-light">{localize('com_nav_user_name_display')}</Label>
|
<Label className="font-light">{localize('com_nav_user_name_display')}</Label>
|
||||||
<HoverCardSettings side="bottom" text="com_nav_info_user_name_display" />
|
<InfoHoverCard side={ESide.Bottom} text={localize('com_nav_info_user_name_display')} />
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
id="UsernameDisplay"
|
id="UsernameDisplay"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Label } from '@librechat/client';
|
import { Label, InfoHoverCard, ESide } from '@librechat/client';
|
||||||
import HoverCardSettings from '~/components/Nav/SettingsTabs/HoverCardSettings';
|
|
||||||
import { TranslationKeys, useLocalize } from '~/hooks';
|
import { TranslationKeys, useLocalize } from '~/hooks';
|
||||||
|
|
||||||
interface AutoRefillSettingsProps {
|
interface AutoRefillSettingsProps {
|
||||||
|
|
@ -114,7 +113,7 @@ const AutoRefillSettings: React.FC<AutoRefillSettingsProps> = ({
|
||||||
{/* Left Section: Label */}
|
{/* Left Section: Label */}
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Label className="font-light">{localize('com_nav_balance_next_refill')}</Label>
|
<Label className="font-light">{localize('com_nav_balance_next_refill')}</Label>
|
||||||
<HoverCardSettings side="bottom" text="com_nav_balance_next_refill_info" />
|
<InfoHoverCard side={ESide.Bottom} text={localize('com_nav_balance_next_refill_info')} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Section: tokenCredits Value */}
|
{/* Right Section: tokenCredits Value */}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Label } from '@librechat/client';
|
import { Label, InfoHoverCard, ESide } from '@librechat/client';
|
||||||
import HoverCardSettings from '~/components/Nav/SettingsTabs/HoverCardSettings';
|
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
|
|
||||||
interface TokenCreditsItemProps {
|
interface TokenCreditsItemProps {
|
||||||
|
|
@ -15,7 +14,7 @@ const TokenCreditsItem: React.FC<TokenCreditsItemProps> = ({ tokenCredits }) =>
|
||||||
{/* Left Section: Label */}
|
{/* Left Section: Label */}
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Label className="font-light">{localize('com_nav_balance')}</Label>
|
<Label className="font-light">{localize('com_nav_balance')}</Label>
|
||||||
<HoverCardSettings side="bottom" text="com_nav_info_balance" />
|
<InfoHoverCard side={ESide.Bottom} text={localize('com_nav_info_balance')} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Section: tokenCredits Value */}
|
{/* Right Section: tokenCredits Value */}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { useRecoilState } from 'recoil';
|
import { useRecoilState } from 'recoil';
|
||||||
import { Dropdown, Switch } from '@librechat/client';
|
|
||||||
import { ForkOptions } from 'librechat-data-provider';
|
import { ForkOptions } from 'librechat-data-provider';
|
||||||
import HoverCardSettings from '../HoverCardSettings';
|
import { Dropdown, Switch, InfoHoverCard, ESide } from '@librechat/client';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
|
||||||
|
|
@ -36,7 +35,10 @@ export const ForkSettings = () => {
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div>{localize('com_ui_fork_change_default')}</div>
|
<div>{localize('com_ui_fork_change_default')}</div>
|
||||||
<HoverCardSettings side="bottom" text="com_nav_info_fork_change_default" />
|
<InfoHoverCard
|
||||||
|
side={ESide.Bottom}
|
||||||
|
text={localize('com_nav_info_fork_change_default')}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
value={forkSetting}
|
value={forkSetting}
|
||||||
|
|
@ -53,7 +55,10 @@ export const ForkSettings = () => {
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div>{localize('com_ui_fork_split_target_setting')}</div>
|
<div>{localize('com_ui_fork_split_target_setting')}</div>
|
||||||
<HoverCardSettings side="bottom" text="com_nav_info_fork_split_target_setting" />
|
<InfoHoverCard
|
||||||
|
side={ESide.Bottom}
|
||||||
|
text={localize('com_nav_info_fork_split_target_setting')}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
id="splitAtTarget"
|
id="splitAtTarget"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { useRecoilState } from 'recoil';
|
import { useRecoilState } from 'recoil';
|
||||||
import { Switch } from '@librechat/client';
|
import { Switch, InfoHoverCard, ESide } from '@librechat/client';
|
||||||
import HoverCardSettings from '../HoverCardSettings';
|
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
|
||||||
|
|
@ -23,7 +22,7 @@ export default function SaveBadgesState({
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div>{localize('com_nav_save_badges_state')}</div>
|
<div>{localize('com_nav_save_badges_state')}</div>
|
||||||
<HoverCardSettings side="bottom" text="com_nav_info_save_badges_state" />
|
<InfoHoverCard side={ESide.Bottom} text={localize('com_nav_info_save_badges_state')} />
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
id="saveBadgesState"
|
id="saveBadgesState"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { useRecoilState } from 'recoil';
|
import { useRecoilState } from 'recoil';
|
||||||
import { Switch } from '@librechat/client';
|
import { Switch, InfoHoverCard, ESide } from '@librechat/client';
|
||||||
import HoverCardSettings from '../HoverCardSettings';
|
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
|
||||||
|
|
@ -23,7 +22,7 @@ export default function SaveDraft({
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div>{localize('com_nav_show_thinking')}</div>
|
<div>{localize('com_nav_show_thinking')}</div>
|
||||||
<HoverCardSettings side="bottom" text="com_nav_info_show_thinking" />
|
<InfoHoverCard side={ESide.Bottom} text={localize('com_nav_info_show_thinking')} />
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
id="showThinking"
|
id="showThinking"
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
import { InfoHoverCard, ESide } from '@librechat/client';
|
||||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||||
import HoverCardSettings from '~/components/Nav/SettingsTabs/HoverCardSettings';
|
|
||||||
import { useLocalize, useHasAccess } from '~/hooks';
|
|
||||||
import SlashCommandSwitch from './SlashCommandSwitch';
|
import SlashCommandSwitch from './SlashCommandSwitch';
|
||||||
|
import { useLocalize, useHasAccess } from '~/hooks';
|
||||||
import PlusCommandSwitch from './PlusCommandSwitch';
|
import PlusCommandSwitch from './PlusCommandSwitch';
|
||||||
import AtCommandSwitch from './AtCommandSwitch';
|
import AtCommandSwitch from './AtCommandSwitch';
|
||||||
|
|
||||||
|
|
@ -25,7 +25,7 @@ function Commands() {
|
||||||
<h3 className="text-lg font-medium text-text-primary">
|
<h3 className="text-lg font-medium text-text-primary">
|
||||||
{localize('com_nav_chat_commands')}
|
{localize('com_nav_chat_commands')}
|
||||||
</h3>
|
</h3>
|
||||||
<HoverCardSettings side="bottom" text="com_nav_chat_commands_info" />
|
<InfoHoverCard side={ESide.Bottom} text={localize('com_nav_chat_commands_info')} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3 text-sm text-text-primary">
|
<div className="flex flex-col gap-3 text-sm text-text-primary">
|
||||||
<div className="pb-3">
|
<div className="pb-3">
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import { forwardRef } from 'react';
|
import { forwardRef } from 'react';
|
||||||
import type { ForwardedRef } from 'react';
|
|
||||||
import { CheckIcon } from 'lucide-react';
|
import { CheckIcon } from 'lucide-react';
|
||||||
import { Spinner, DialogButton } from '@librechat/client';
|
import { Spinner, DialogButton, InfoHoverCard, ESide } from '@librechat/client';
|
||||||
import HoverCardSettings from './HoverCardSettings';
|
|
||||||
import type { TDangerButtonProps } from '~/common';
|
import type { TDangerButtonProps } from '~/common';
|
||||||
|
import type { ForwardedRef } from 'react';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
import { cn } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
|
|
@ -37,7 +36,7 @@ const DangerButton = (props: TDangerButtonProps, ref: ForwardedRef<HTMLButtonEle
|
||||||
{showText && (
|
{showText && (
|
||||||
<div className={`flex items-center ${infoDescriptionCode ? 'space-x-2' : ''}`}>
|
<div className={`flex items-center ${infoDescriptionCode ? 'space-x-2' : ''}`}>
|
||||||
<div>{localize(infoTextCode)}</div>
|
<div>{localize(infoTextCode)}</div>
|
||||||
{infoDescriptionCode && <HoverCardSettings side="bottom" text={infoDescriptionCode} />}
|
{infoDescriptionCode && <InfoHoverCard side={ESide.Bottom} text={infoDescriptionCode} />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<DialogButton
|
<DialogButton
|
||||||
|
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import {
|
|
||||||
CircleHelpIcon,
|
|
||||||
HoverCard,
|
|
||||||
HoverCardTrigger,
|
|
||||||
HoverCardPortal,
|
|
||||||
HoverCardContent,
|
|
||||||
} from '@librechat/client';
|
|
||||||
import { useLocalize } from '~/hooks';
|
|
||||||
|
|
||||||
const HoverCardSettings = ({ side, text }) => {
|
|
||||||
const localize = useLocalize();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<HoverCard openDelay={500}>
|
|
||||||
<HoverCardTrigger>
|
|
||||||
<CircleHelpIcon className="h-5 w-5 text-text-tertiary" />{' '}
|
|
||||||
</HoverCardTrigger>
|
|
||||||
<HoverCardPortal>
|
|
||||||
<HoverCardContent side={side} className="z-[999] w-80">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-sm text-text-secondary">{localize(text)}</p>
|
|
||||||
</div>
|
|
||||||
</HoverCardContent>
|
|
||||||
</HoverCardPortal>
|
|
||||||
</HoverCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default HoverCardSettings;
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
import { Switch } from '@librechat/client';
|
|
||||||
import { RecoilState, useRecoilState } from 'recoil';
|
import { RecoilState, useRecoilState } from 'recoil';
|
||||||
import HoverCardSettings from './HoverCardSettings';
|
import { Switch, InfoHoverCard, ESide } from '@librechat/client';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
|
|
||||||
|
type LocalizeFn = ReturnType<typeof useLocalize>;
|
||||||
|
type LocalizeKey = Parameters<LocalizeFn>[0];
|
||||||
|
|
||||||
interface ToggleSwitchProps {
|
interface ToggleSwitchProps {
|
||||||
stateAtom: RecoilState<boolean>;
|
stateAtom: RecoilState<boolean>;
|
||||||
localizationKey: string;
|
localizationKey: LocalizeKey;
|
||||||
hoverCardText?: string;
|
hoverCardText?: LocalizeKey;
|
||||||
switchId: string;
|
switchId: string;
|
||||||
onCheckedChange?: (value: boolean) => void;
|
onCheckedChange?: (value: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
@ -18,21 +20,19 @@ const ToggleSwitch: React.FC<ToggleSwitchProps> = ({
|
||||||
switchId,
|
switchId,
|
||||||
onCheckedChange,
|
onCheckedChange,
|
||||||
}) => {
|
}) => {
|
||||||
const [switchState, setSwitchState] = useRecoilState<boolean>(stateAtom);
|
const [switchState, setSwitchState] = useRecoilState(stateAtom);
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
|
|
||||||
const handleCheckedChange = (value: boolean) => {
|
const handleCheckedChange = (value: boolean) => {
|
||||||
setSwitchState(value);
|
setSwitchState(value);
|
||||||
if (onCheckedChange) {
|
onCheckedChange?.(value);
|
||||||
onCheckedChange(value);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div>{localize(localizationKey as any)}</div>
|
<div>{localize(localizationKey)}</div>
|
||||||
{hoverCardText && <HoverCardSettings side="bottom" text={hoverCardText} />}
|
{hoverCardText && <InfoHoverCard side={ESide.Bottom} text={localize(hoverCardText)} />}
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
id={switchId}
|
id={switchId}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import * as Ariakit from '@ariakit/react';
|
import * as Ariakit from '@ariakit/react';
|
||||||
import { ChevronDown } from 'lucide-react';
|
import { ChevronDown } from 'lucide-react';
|
||||||
import { DropdownPopup } from '@librechat/client';
|
import { DropdownPopup, Skeleton } from '@librechat/client';
|
||||||
import { AccessRoleIds, ResourceType } from 'librechat-data-provider';
|
import { AccessRoleIds, ResourceType } from 'librechat-data-provider';
|
||||||
import { useGetAccessRolesQuery } from 'librechat-data-provider/react-query';
|
import { useGetAccessRolesQuery } from 'librechat-data-provider/react-query';
|
||||||
import type { AccessRole } from 'librechat-data-provider';
|
import type { AccessRole } from 'librechat-data-provider';
|
||||||
|
|
@ -10,6 +10,7 @@ import { cn, getRoleLocalizationKeys } from '~/utils';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
|
|
||||||
interface AccessRolesPickerProps {
|
interface AccessRolesPickerProps {
|
||||||
|
id?: string;
|
||||||
resourceType?: ResourceType;
|
resourceType?: ResourceType;
|
||||||
selectedRoleId?: AccessRoleIds;
|
selectedRoleId?: AccessRoleIds;
|
||||||
onRoleChange: (roleId: AccessRoleIds) => void;
|
onRoleChange: (roleId: AccessRoleIds) => void;
|
||||||
|
|
@ -17,6 +18,7 @@ interface AccessRolesPickerProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AccessRolesPicker({
|
export default function AccessRolesPicker({
|
||||||
|
id,
|
||||||
resourceType = ResourceType.AGENT,
|
resourceType = ResourceType.AGENT,
|
||||||
selectedRoleId = AccessRoleIds.AGENT_VIEWER,
|
selectedRoleId = AccessRoleIds.AGENT_VIEWER,
|
||||||
onRoleChange,
|
onRoleChange,
|
||||||
|
|
@ -39,14 +41,7 @@ export default function AccessRolesPicker({
|
||||||
const selectedRoleInfo = selectedRole ? getLocalizedRoleInfo(selectedRole.accessRoleId) : null;
|
const selectedRoleInfo = selectedRole ? getLocalizedRoleInfo(selectedRole.accessRoleId) : null;
|
||||||
|
|
||||||
if (rolesLoading || !accessRoles) {
|
if (rolesLoading || !accessRoles) {
|
||||||
return (
|
return <Skeleton className="h-10 w-24 rounded-lg" />;
|
||||||
<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 dropdownItems: t.MenuItemProps[] = accessRoles.map((role: AccessRole) => {
|
||||||
|
|
@ -70,7 +65,7 @@ export default function AccessRolesPicker({
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className} id={id}>
|
||||||
<DropdownPopup
|
<DropdownPopup
|
||||||
menuId="access-roles-menu"
|
menuId="access-roles-menu"
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
|
|
@ -79,8 +74,7 @@ export default function AccessRolesPicker({
|
||||||
<Ariakit.MenuButton
|
<Ariakit.MenuButton
|
||||||
aria-label={selectedRoleInfo?.description || 'Select role'}
|
aria-label={selectedRoleInfo?.description || 'Select role'}
|
||||||
className={cn(
|
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',
|
'flex items-center justify-between gap-2 rounded-xl border border-border-light bg-transparent px-3 py-2 text-sm transition-colors hover:bg-surface-tertiary',
|
||||||
'min-w-[200px]',
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { AccessRoleIds, ResourceType } from 'librechat-data-provider';
|
import { AccessRoleIds, ResourceType } from 'librechat-data-provider';
|
||||||
import { Share2Icon, Users, Loader, Shield, Link, CopyCheck } from 'lucide-react';
|
import { Share2Icon, Users, Link, CopyCheck, UserX, UserCheck } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
|
Label,
|
||||||
Button,
|
Button,
|
||||||
|
Spinner,
|
||||||
|
Skeleton,
|
||||||
OGDialog,
|
OGDialog,
|
||||||
OGDialogTitle,
|
OGDialogTitle,
|
||||||
OGDialogClose,
|
OGDialogClose,
|
||||||
|
|
@ -17,11 +20,10 @@ import {
|
||||||
useCopyToClipboard,
|
useCopyToClipboard,
|
||||||
useLocalize,
|
useLocalize,
|
||||||
} from '~/hooks';
|
} from '~/hooks';
|
||||||
import GenericManagePermissionsDialog from './GenericManagePermissionsDialog';
|
import UnifiedPeopleSearch from './PeoplePicker/UnifiedPeopleSearch';
|
||||||
import PublicSharingToggle from './PublicSharingToggle';
|
import PublicSharingToggle from './PublicSharingToggle';
|
||||||
import AccessRolesPicker from './AccessRolesPicker';
|
import { SelectedPrincipalsList } from './PeoplePicker';
|
||||||
import { cn, removeFocusOutlines } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
import { PeoplePicker } from './PeoplePicker';
|
|
||||||
|
|
||||||
export default function GenericGrantAccessDialog({
|
export default function GenericGrantAccessDialog({
|
||||||
resourceName,
|
resourceName,
|
||||||
|
|
@ -49,6 +51,9 @@ export default function GenericGrantAccessDialog({
|
||||||
const { hasPeoplePickerAccess, peoplePickerTypeFilter } = usePeoplePickerPermissions();
|
const { hasPeoplePickerAccess, peoplePickerTypeFilter } = usePeoplePickerPermissions();
|
||||||
const {
|
const {
|
||||||
config,
|
config,
|
||||||
|
permissionsData,
|
||||||
|
isLoadingPermissions,
|
||||||
|
permissionsError,
|
||||||
updatePermissionsMutation,
|
updatePermissionsMutation,
|
||||||
currentShares,
|
currentShares,
|
||||||
currentIsPublic,
|
currentIsPublic,
|
||||||
|
|
@ -59,11 +64,22 @@ export default function GenericGrantAccessDialog({
|
||||||
setPublicRole,
|
setPublicRole,
|
||||||
} = useResourcePermissionState(resourceType, resourceDbId, isModalOpen);
|
} = useResourcePermissionState(resourceType, resourceDbId, isModalOpen);
|
||||||
|
|
||||||
const [newShares, setNewShares] = useState<TPrincipal[]>([]);
|
// State for unified list of all shares (existing + newly added)
|
||||||
|
const [allShares, setAllShares] = useState<TPrincipal[]>([]);
|
||||||
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
const [defaultPermissionId, setDefaultPermissionId] = useState<AccessRoleIds | undefined>(
|
const [defaultPermissionId, setDefaultPermissionId] = useState<AccessRoleIds | undefined>(
|
||||||
config?.defaultViewerRoleId,
|
config?.defaultViewerRoleId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Sync all shares with current shares when modal opens, marking existing vs new
|
||||||
|
useEffect(() => {
|
||||||
|
if (permissionsData && isModalOpen) {
|
||||||
|
const shares = permissionsData.principals || [];
|
||||||
|
setAllShares(shares.map((share) => ({ ...share, isExisting: true })));
|
||||||
|
setHasChanges(false);
|
||||||
|
}
|
||||||
|
}, [permissionsData, isModalOpen]);
|
||||||
|
|
||||||
const resourceUrl = config?.getResourceUrl ? config?.getResourceUrl(resourceId || '') : '';
|
const resourceUrl = config?.getResourceUrl ? config?.getResourceUrl(resourceId || '') : '';
|
||||||
const copyResourceUrl = useCopyToClipboard({ text: resourceUrl });
|
const copyResourceUrl = useCopyToClipboard({ text: resourceUrl });
|
||||||
|
|
||||||
|
|
@ -76,21 +92,88 @@ export default function GenericGrantAccessDialog({
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleGrantAccess = async () => {
|
// Handler for adding users from search (immediate add to unified list)
|
||||||
try {
|
const handleAddFromSearch = (newShares: TPrincipal[]) => {
|
||||||
const sharesToAdd = newShares.map((share) => ({
|
const sharesToAdd = newShares.filter(
|
||||||
...share,
|
(newShare) =>
|
||||||
accessRoleId: defaultPermissionId,
|
!allShares.some((existing) => existing.idOnTheSource === newShare.idOnTheSource),
|
||||||
}));
|
);
|
||||||
|
|
||||||
const allShares = [...currentShares, ...sharesToAdd];
|
const sharesWithDefaults = sharesToAdd.map((share) => ({
|
||||||
|
...share,
|
||||||
|
accessRoleId: defaultPermissionId || config?.defaultViewerRoleId,
|
||||||
|
isExisting: false, // Mark as newly added
|
||||||
|
}));
|
||||||
|
|
||||||
|
setAllShares((prev) => [...prev, ...sharesWithDefaults]);
|
||||||
|
setHasChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handler for removing individual shares
|
||||||
|
const handleRemoveShare = (idOnTheSource: string) => {
|
||||||
|
setAllShares(allShares.filter((s) => s.idOnTheSource !== idOnTheSource));
|
||||||
|
setHasChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handler for changing individual share permissions
|
||||||
|
const handleRoleChange = (idOnTheSource: string, newRole: string) => {
|
||||||
|
setAllShares(
|
||||||
|
allShares.map((s) =>
|
||||||
|
s.idOnTheSource === idOnTheSource ? { ...s, accessRoleId: newRole as AccessRoleIds } : s,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setHasChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handler for public access toggle
|
||||||
|
const handlePublicToggle = (isPublicValue: boolean) => {
|
||||||
|
setIsPublic(isPublicValue);
|
||||||
|
setHasChanges(true);
|
||||||
|
if (!isPublicValue) {
|
||||||
|
setPublicRole(config?.defaultViewerRoleId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handler for public role change
|
||||||
|
const handlePublicRoleChange = (role: string) => {
|
||||||
|
setPublicRole(role as AccessRoleIds);
|
||||||
|
setHasChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save all changes (unified save handler)
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!allShares.length && !isPublic && !hasChanges) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Calculate changes for unified list
|
||||||
|
const originalSharesMap = new Map(
|
||||||
|
currentShares.map((share) => [`${share.type}-${share.idOnTheSource}`, share]),
|
||||||
|
);
|
||||||
|
const allSharesMap = new Map(
|
||||||
|
allShares.map((share) => [`${share.type}-${share.idOnTheSource}`, share]),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find newly added and updated shares
|
||||||
|
const updated = allShares.filter((share) => {
|
||||||
|
const key = `${share.type}-${share.idOnTheSource}`;
|
||||||
|
const original = originalSharesMap.get(key);
|
||||||
|
return !original || original.accessRoleId !== share.accessRoleId;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find removed shares
|
||||||
|
const removed = currentShares.filter((share) => {
|
||||||
|
const key = `${share.type}-${share.idOnTheSource}`;
|
||||||
|
return !allSharesMap.has(key);
|
||||||
|
});
|
||||||
|
|
||||||
await updatePermissionsMutation.mutateAsync({
|
await updatePermissionsMutation.mutateAsync({
|
||||||
resourceType,
|
resourceType,
|
||||||
resourceId: resourceDbId,
|
resourceId: resourceDbId,
|
||||||
data: {
|
data: {
|
||||||
updated: sharesToAdd,
|
updated,
|
||||||
removed: [],
|
removed,
|
||||||
public: isPublic,
|
public: isPublic,
|
||||||
publicAccessRoleId: isPublic ? publicRole : undefined,
|
publicAccessRoleId: isPublic ? publicRole : undefined,
|
||||||
},
|
},
|
||||||
|
|
@ -101,65 +184,74 @@ export default function GenericGrantAccessDialog({
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast({
|
showToast({
|
||||||
message: `Access granted successfully to ${newShares.length} ${newShares.length === 1 ? 'person' : 'people'}${isPublic ? ' and made public' : ''}`,
|
message: localize('com_ui_permissions_updated_success'),
|
||||||
status: 'success',
|
status: 'success',
|
||||||
});
|
});
|
||||||
|
|
||||||
setNewShares([]);
|
setHasChanges(false);
|
||||||
setDefaultPermissionId(config?.defaultViewerRoleId);
|
|
||||||
setIsPublic(false);
|
|
||||||
setPublicRole(config?.defaultViewerRoleId);
|
|
||||||
setIsModalOpen(false);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error granting access:', error);
|
console.error('Error updating permissions:', error);
|
||||||
showToast({
|
showToast({
|
||||||
message: 'Failed to grant access. Please try again.',
|
message: localize('com_ui_permissions_failed_update'),
|
||||||
status: 'error',
|
status: 'error',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
setNewShares([]);
|
// Reset to original state
|
||||||
|
const shares = permissionsData?.principals || [];
|
||||||
|
setAllShares(shares.map((share) => ({ ...share, isExisting: true })));
|
||||||
setDefaultPermissionId(config?.defaultViewerRoleId);
|
setDefaultPermissionId(config?.defaultViewerRoleId);
|
||||||
setIsPublic(false);
|
setIsPublic(currentIsPublic);
|
||||||
setPublicRole(config?.defaultViewerRoleId);
|
setPublicRole(currentPublicRole || config?.defaultViewerRoleId || '');
|
||||||
|
setHasChanges(false);
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Validation and calculated values
|
||||||
const totalCurrentShares = currentShares.length + (currentIsPublic ? 1 : 0);
|
const totalCurrentShares = currentShares.length + (currentIsPublic ? 1 : 0);
|
||||||
const submitButtonActive =
|
|
||||||
newShares.length > 0 || isPublic !== currentIsPublic || publicRole !== currentPublicRole;
|
// Check if there's at least one owner (user, group, or public with owner role)
|
||||||
|
const hasAtLeastOneOwner =
|
||||||
|
allShares.some((share) => share.accessRoleId === config?.defaultOwnerRoleId) ||
|
||||||
|
(isPublic && publicRole === config?.defaultOwnerRoleId);
|
||||||
|
|
||||||
|
// Check if there are any changes to save
|
||||||
|
const hasPublicChanges = isPublic !== currentIsPublic || publicRole !== currentPublicRole;
|
||||||
|
const submitButtonActive = hasChanges || hasPublicChanges;
|
||||||
|
|
||||||
|
// Error handling
|
||||||
|
if (permissionsError) {
|
||||||
|
return <div className="text-sm text-red-600">{localize('com_ui_permissions_failed_load')}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
const TriggerComponent = children ? (
|
const TriggerComponent = children ? (
|
||||||
children
|
children
|
||||||
) : (
|
) : (
|
||||||
<button
|
<Button
|
||||||
className={cn(
|
size="sm"
|
||||||
'btn btn-neutral border-token-border-light relative h-9 rounded-lg font-medium',
|
variant="outline"
|
||||||
removeFocusOutlines,
|
|
||||||
)}
|
|
||||||
aria-label={localize('com_ui_share_var', {
|
aria-label={localize('com_ui_share_var', {
|
||||||
0: config?.getShareMessage(resourceName),
|
0: config?.getShareMessage(resourceName),
|
||||||
})}
|
})}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center gap-2 text-blue-500">
|
<div className="flex min-w-[32px] items-center justify-center gap-2 text-blue-500">
|
||||||
<Share2Icon className="icon-md h-4 w-4" />
|
<span className="flex h-6 w-6 items-center justify-center">
|
||||||
|
<Share2Icon className="icon-md h-4 w-4" />
|
||||||
|
</span>
|
||||||
{totalCurrentShares > 0 && (
|
{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">
|
<Label className="text-sm font-medium text-text-secondary">{totalCurrentShares}</Label>
|
||||||
{totalCurrentShares}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OGDialog open={isModalOpen} onOpenChange={setIsModalOpen} modal>
|
<OGDialog open={isModalOpen} onOpenChange={setIsModalOpen} modal>
|
||||||
<OGDialogTrigger asChild>{TriggerComponent}</OGDialogTrigger>
|
<OGDialogTrigger asChild>{TriggerComponent}</OGDialogTrigger>
|
||||||
|
|
||||||
<OGDialogContent className="max-h-[90vh] w-11/12 overflow-y-auto md:max-w-3xl">
|
<OGDialogContent className="max-h-[90vh] w-11/12 overflow-y-auto md:max-w-3xl">
|
||||||
<OGDialogTitle>
|
<OGDialogTitle>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -171,51 +263,88 @@ export default function GenericGrantAccessDialog({
|
||||||
</OGDialogTitle>
|
</OGDialogTitle>
|
||||||
|
|
||||||
<div className="space-y-6 p-2">
|
<div className="space-y-6 p-2">
|
||||||
{hasPeoplePickerAccess && (
|
{/* Unified Search and Management Section */}
|
||||||
<>
|
<div className="space-y-4">
|
||||||
<PeoplePicker
|
{/* Search Bar with Default Permission Setting */}
|
||||||
onSelectionChange={setNewShares}
|
{hasPeoplePickerAccess && (
|
||||||
placeholder={localize('com_ui_search_people_placeholder')}
|
<div className="space-y-2">
|
||||||
typeFilter={peoplePickerTypeFilter}
|
<h4 className="mb-2 flex items-center gap-2 text-sm font-medium text-text-primary">
|
||||||
/>
|
<UserCheck className="h-4 w-4" />
|
||||||
|
{localize('com_ui_user_group_permissions')} ( {allShares.length} )
|
||||||
|
</h4>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<UnifiedPeopleSearch
|
||||||
<div className="flex items-center justify-between">
|
onAddPeople={handleAddFromSearch}
|
||||||
<div className="flex items-center gap-2">
|
placeholder={localize('com_ui_search_people_placeholder')}
|
||||||
<Shield className="h-4 w-4 text-text-secondary" />
|
typeFilter={peoplePickerTypeFilter}
|
||||||
<label className="text-sm font-medium text-text-primary">
|
excludeIds={allShares.map((s) => s.idOnTheSource)}
|
||||||
{localize('com_ui_permission_level')}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<AccessRolesPicker
|
|
||||||
resourceType={resourceType}
|
|
||||||
selectedRoleId={defaultPermissionId}
|
|
||||||
onRoleChange={setDefaultPermissionId}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Unified User/Group List */}
|
||||||
|
{(() => {
|
||||||
|
if (isLoadingPermissions) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<Skeleton className="h-[62px] w-full rounded-lg" />
|
||||||
|
<Skeleton className="h-[62px] w-full rounded-lg" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allShares.length === 0 && !hasChanges) {
|
||||||
|
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-primary" />
|
||||||
|
<p className="mt-2 text-sm text-text-primary">
|
||||||
|
{localize('com_ui_no_individual_access')}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-text-primary">
|
||||||
|
{localize('com_ui_search_above_to_add_people')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{!hasAtLeastOneOwner && hasChanges && (
|
||||||
|
<div className="rounded-lg border border-destructive/30 bg-destructive/10 p-3 text-center">
|
||||||
|
<div className="flex items-center justify-center gap-2 text-sm text-red-600 dark:text-red-400">
|
||||||
|
<UserX className="h-4 w-4" />
|
||||||
|
{localize('com_ui_at_least_one_owner_required')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<SelectedPrincipalsList
|
||||||
|
principles={allShares}
|
||||||
|
onRemoveHandler={handleRemoveShare}
|
||||||
|
resourceType={resourceType}
|
||||||
|
onRoleChange={(id, newRole) => handleRoleChange(id, newRole)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</>
|
)}
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
|
<div className="flex border-t border-border-light" />
|
||||||
|
|
||||||
|
{/* Public Access Section */}
|
||||||
<PublicSharingToggle
|
<PublicSharingToggle
|
||||||
isPublic={isPublic}
|
isPublic={isPublic}
|
||||||
publicRole={publicRole}
|
publicRole={publicRole}
|
||||||
onPublicToggle={setIsPublic}
|
onPublicToggle={handlePublicToggle}
|
||||||
onPublicRoleChange={setPublicRole}
|
onPublicRoleChange={handlePublicRoleChange}
|
||||||
resourceType={resourceType}
|
resourceType={resourceType}
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-between border-t pt-4">
|
|
||||||
|
{/* Footer Actions */}
|
||||||
|
<div className="flex justify-between pt-4">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{hasPeoplePickerAccess && (
|
|
||||||
<GenericManagePermissionsDialog
|
|
||||||
resourceDbId={resourceDbId}
|
|
||||||
resourceName={resourceName}
|
|
||||||
resourceType={resourceType}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{resourceId && resourceUrl && (
|
{resourceId && resourceUrl && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isCopying) return;
|
if (isCopying) return;
|
||||||
copyResourceUrl(setIsCopying);
|
copyResourceUrl(setIsCopying);
|
||||||
|
|
@ -244,17 +373,21 @@ export default function GenericGrantAccessDialog({
|
||||||
</Button>
|
</Button>
|
||||||
</OGDialogClose>
|
</OGDialogClose>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleGrantAccess}
|
onClick={handleSave}
|
||||||
disabled={updatePermissionsMutation.isLoading || !submitButtonActive}
|
disabled={
|
||||||
|
updatePermissionsMutation.isLoading ||
|
||||||
|
!submitButtonActive ||
|
||||||
|
(hasChanges && !hasAtLeastOneOwner)
|
||||||
|
}
|
||||||
className="min-w-[120px]"
|
className="min-w-[120px]"
|
||||||
>
|
>
|
||||||
{updatePermissionsMutation.isLoading ? (
|
{updatePermissionsMutation.isLoading ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Loader className="h-4 w-4 animate-spin" />
|
<Spinner className="h-4 w-4" />
|
||||||
{localize('com_ui_granting')}
|
{localize('com_ui_saving')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
localize('com_ui_grant_access')
|
localize('com_ui_save_changes')
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,349 +0,0 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { useGetAccessRolesQuery } from 'librechat-data-provider/react-query';
|
|
||||||
import { Settings, Users, Loader, UserCheck, Trash2, Shield } from 'lucide-react';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
OGDialog,
|
|
||||||
OGDialogTitle,
|
|
||||||
OGDialogClose,
|
|
||||||
OGDialogContent,
|
|
||||||
OGDialogTrigger,
|
|
||||||
useToastContext,
|
|
||||||
} from '@librechat/client';
|
|
||||||
import type { TPrincipal, ResourceType, AccessRoleIds } from 'librechat-data-provider';
|
|
||||||
import { useResourcePermissionState } from '~/hooks/Sharing';
|
|
||||||
import PublicSharingToggle from './PublicSharingToggle';
|
|
||||||
import { SelectedPrincipalsList } from './PeoplePicker';
|
|
||||||
import { cn, removeFocusOutlines } from '~/utils';
|
|
||||||
import { useLocalize } from '~/hooks';
|
|
||||||
|
|
||||||
export default function GenericManagePermissionsDialog({
|
|
||||||
resourceDbId,
|
|
||||||
resourceName,
|
|
||||||
resourceType,
|
|
||||||
onUpdatePermissions,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
resourceDbId: string;
|
|
||||||
resourceName?: string;
|
|
||||||
resourceType: ResourceType;
|
|
||||||
onUpdatePermissions?: (
|
|
||||||
shares: TPrincipal[],
|
|
||||||
isPublic: boolean,
|
|
||||||
publicRole?: AccessRoleIds,
|
|
||||||
) => void;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
const localize = useLocalize();
|
|
||||||
const { showToast } = useToastContext();
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
||||||
const [hasChanges, setHasChanges] = useState(false);
|
|
||||||
|
|
||||||
const {
|
|
||||||
config,
|
|
||||||
permissionsData,
|
|
||||||
isLoadingPermissions,
|
|
||||||
permissionsError,
|
|
||||||
updatePermissionsMutation,
|
|
||||||
currentShares,
|
|
||||||
currentIsPublic,
|
|
||||||
currentPublicRole,
|
|
||||||
isPublic: managedIsPublic,
|
|
||||||
setIsPublic: setManagedIsPublic,
|
|
||||||
publicRole: managedPublicRole,
|
|
||||||
setPublicRole: setManagedPublicRole,
|
|
||||||
} = useResourcePermissionState(resourceType, resourceDbId, isModalOpen);
|
|
||||||
|
|
||||||
const { data: accessRoles } = useGetAccessRolesQuery(resourceType);
|
|
||||||
|
|
||||||
const [managedShares, setManagedShares] = useState<TPrincipal[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (permissionsData && isModalOpen) {
|
|
||||||
const shares = permissionsData.principals || [];
|
|
||||||
setManagedShares(shares);
|
|
||||||
setHasChanges(false);
|
|
||||||
}
|
|
||||||
}, [permissionsData, isModalOpen]);
|
|
||||||
|
|
||||||
if (!resourceDbId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!config) {
|
|
||||||
console.error(`Unsupported resource type: ${resourceType}`);
|
|
||||||
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: AccessRoleIds) => {
|
|
||||||
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: resourceDbId,
|
|
||||||
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(currentIsPublic);
|
|
||||||
setManagedPublicRole(currentPublicRole || config?.defaultViewerRoleId || '');
|
|
||||||
setIsModalOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRevokeAll = () => {
|
|
||||||
setManagedShares([]);
|
|
||||||
setManagedIsPublic(false);
|
|
||||||
setHasChanges(true);
|
|
||||||
};
|
|
||||||
const handlePublicToggle = (isPublic: boolean) => {
|
|
||||||
setManagedIsPublic(isPublic);
|
|
||||||
setHasChanges(true);
|
|
||||||
if (!isPublic) {
|
|
||||||
setManagedPublicRole(config?.defaultViewerRoleId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const handlePublicRoleChange = (role: AccessRoleIds) => {
|
|
||||||
setManagedPublicRole(role);
|
|
||||||
setHasChanges(true);
|
|
||||||
};
|
|
||||||
const totalShares = managedShares.length + (managedIsPublic ? 1 : 0);
|
|
||||||
const originalTotalShares = currentShares.length + (currentIsPublic ? 1 : 0);
|
|
||||||
|
|
||||||
/** Check if there's at least one owner (user, group, or public with owner role) */
|
|
||||||
const hasAtLeastOneOwner =
|
|
||||||
managedShares.some((share) => share.accessRoleId === config?.defaultOwnerRoleId) ||
|
|
||||||
(managedIsPublic && managedPublicRole === config?.defaultOwnerRoleId);
|
|
||||||
|
|
||||||
let peopleLabel = localize('com_ui_people');
|
|
||||||
if (managedShares.length === 1) {
|
|
||||||
peopleLabel = localize('com_ui_person');
|
|
||||||
}
|
|
||||||
|
|
||||||
const buttonAriaLabel = config?.getManageMessage(resourceName);
|
|
||||||
const dialogTitle = config?.getManageMessage(resourceName);
|
|
||||||
|
|
||||||
let publicSuffix = '';
|
|
||||||
if (managedIsPublic) {
|
|
||||||
publicSuffix = localize('com_ui_and_public');
|
|
||||||
}
|
|
||||||
|
|
||||||
const TriggerComponent = children ? (
|
|
||||||
children
|
|
||||||
) : (
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<OGDialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
|
||||||
<OGDialogTrigger asChild>{TriggerComponent}</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}
|
|
||||||
resourceType={resourceType}
|
|
||||||
/>
|
|
||||||
</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,273 +0,0 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { ResourceType, AccessRoleIds } 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, usePeoplePickerPermissions } from '~/hooks';
|
|
||||||
import ManagePermissionsDialog from './ManagePermissionsDialog';
|
|
||||||
import PublicSharingToggle from './PublicSharingToggle';
|
|
||||||
import AccessRolesPicker from './AccessRolesPicker';
|
|
||||||
import { cn, removeFocusOutlines } from '~/utils';
|
|
||||||
import { PeoplePicker } from './PeoplePicker';
|
|
||||||
|
|
||||||
export default function GrantAccessDialog({
|
|
||||||
agentName,
|
|
||||||
onGrantAccess,
|
|
||||||
resourceType = ResourceType.AGENT,
|
|
||||||
agentDbId,
|
|
||||||
agentId,
|
|
||||||
}: {
|
|
||||||
agentDbId?: string | null;
|
|
||||||
agentId?: string | null;
|
|
||||||
agentName?: string;
|
|
||||||
onGrantAccess?: (shares: TPrincipal[], isPublic: boolean, publicRole: AccessRoleIds) => void;
|
|
||||||
resourceType?: ResourceType;
|
|
||||||
}) {
|
|
||||||
const localize = useLocalize();
|
|
||||||
const { showToast } = useToastContext();
|
|
||||||
const { hasPeoplePickerAccess, peoplePickerTypeFilter } = usePeoplePickerPermissions();
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: permissionsData,
|
|
||||||
// isLoading: isLoadingPermissions,
|
|
||||||
// error: permissionsError,
|
|
||||||
} = useGetResourcePermissionsQuery(resourceType, agentDbId!, {
|
|
||||||
enabled: !!agentDbId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const updatePermissionsMutation = useUpdateResourcePermissionsMutation();
|
|
||||||
|
|
||||||
const [newShares, setNewShares] = useState<TPrincipal[]>([]);
|
|
||||||
const [defaultPermissionId, setDefaultPermissionId] = useState<AccessRoleIds>(
|
|
||||||
AccessRoleIds.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 || AccessRoleIds.AGENT_VIEWER;
|
|
||||||
|
|
||||||
const [isPublic, setIsPublic] = useState(false);
|
|
||||||
const [publicRole, setPublicRole] = useState<AccessRoleIds>(AccessRoleIds.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(AccessRoleIds.AGENT_VIEWER);
|
|
||||||
setIsPublic(false);
|
|
||||||
setPublicRole(AccessRoleIds.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(AccessRoleIds.AGENT_VIEWER);
|
|
||||||
setIsPublic(false);
|
|
||||||
setPublicRole(AccessRoleIds.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,14 +1,14 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { AccessRoleIds, ResourceType } from 'librechat-data-provider';
|
import { AccessRoleIds, ResourceType } from 'librechat-data-provider';
|
||||||
import { Settings, Users, Loader, UserCheck, Trash2, Shield } from 'lucide-react';
|
import { Settings, Users, UserCheck, Trash2, Shield } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
useGetAccessRolesQuery,
|
|
||||||
useGetResourcePermissionsQuery,
|
useGetResourcePermissionsQuery,
|
||||||
useUpdateResourcePermissionsMutation,
|
useUpdateResourcePermissionsMutation,
|
||||||
} from 'librechat-data-provider/react-query';
|
} from 'librechat-data-provider/react-query';
|
||||||
import type { TPrincipal } from 'librechat-data-provider';
|
import type { TPrincipal } from 'librechat-data-provider';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
Spinner,
|
||||||
OGDialog,
|
OGDialog,
|
||||||
OGDialogTitle,
|
OGDialogTitle,
|
||||||
OGDialogClose,
|
OGDialogClose,
|
||||||
|
|
@ -46,10 +46,6 @@ export default function ManagePermissionsDialog({
|
||||||
} = useGetResourcePermissionsQuery(resourceType, agentDbId, {
|
} = useGetResourcePermissionsQuery(resourceType, agentDbId, {
|
||||||
enabled: !!agentDbId,
|
enabled: !!agentDbId,
|
||||||
});
|
});
|
||||||
const {
|
|
||||||
data: accessRoles,
|
|
||||||
// isLoading,
|
|
||||||
} = useGetAccessRolesQuery(resourceType);
|
|
||||||
|
|
||||||
const updatePermissionsMutation = useUpdateResourcePermissionsMutation();
|
const updatePermissionsMutation = useUpdateResourcePermissionsMutation();
|
||||||
|
|
||||||
|
|
@ -267,7 +263,7 @@ export default function ManagePermissionsDialog({
|
||||||
if (isLoadingPermissions) {
|
if (isLoadingPermissions) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center p-8">
|
<div className="flex items-center justify-center p-8">
|
||||||
<Loader className="h-6 w-6 animate-spin" />
|
<Spinner className="h-6 w-6" />
|
||||||
<span className="ml-2 text-sm text-text-secondary">
|
<span className="ml-2 text-sm text-text-secondary">
|
||||||
{localize('com_ui_loading_permissions')}
|
{localize('com_ui_loading_permissions')}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -285,7 +281,7 @@ export default function ManagePermissionsDialog({
|
||||||
<SelectedPrincipalsList
|
<SelectedPrincipalsList
|
||||||
principles={managedShares}
|
principles={managedShares}
|
||||||
onRemoveHandler={handleRemoveShare}
|
onRemoveHandler={handleRemoveShare}
|
||||||
availableRoles={accessRoles || []}
|
resourceType={resourceType}
|
||||||
onRoleChange={(id, newRole) => handleRoleChange(id, newRole)}
|
onRoleChange={(id, newRole) => handleRoleChange(id, newRole)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -302,17 +298,12 @@ export default function ManagePermissionsDialog({
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
<div>
|
<PublicSharingToggle
|
||||||
<h3 className="mb-3 text-sm font-medium text-text-primary">
|
isPublic={managedIsPublic}
|
||||||
{localize('com_ui_public_access')}
|
publicRole={managedPublicRole}
|
||||||
</h3>
|
onPublicToggle={handlePublicToggle}
|
||||||
<PublicSharingToggle
|
onPublicRoleChange={handlePublicRoleChange}
|
||||||
isPublic={managedIsPublic}
|
/>
|
||||||
publicRole={managedPublicRole}
|
|
||||||
onPublicToggle={handlePublicToggle}
|
|
||||||
onPublicRoleChange={handlePublicRoleChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 border-t pt-4">
|
<div className="flex justify-end gap-3 border-t pt-4">
|
||||||
<OGDialogClose asChild>
|
<OGDialogClose asChild>
|
||||||
|
|
@ -332,7 +323,7 @@ export default function ManagePermissionsDialog({
|
||||||
>
|
>
|
||||||
{updatePermissionsMutation.isLoading ? (
|
{updatePermissionsMutation.isLoading ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Loader className="h-4 w-4 animate-spin" />
|
<Spinner className="h-4 w-4" />
|
||||||
{localize('com_ui_saving')}
|
{localize('com_ui_saving')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,10 @@ import React, { useState, useMemo } from 'react';
|
||||||
import { PrincipalType } from 'librechat-data-provider';
|
import { PrincipalType } from 'librechat-data-provider';
|
||||||
import type { TPrincipal, PrincipalSearchParams } from 'librechat-data-provider';
|
import type { TPrincipal, PrincipalSearchParams } from 'librechat-data-provider';
|
||||||
import { useSearchPrincipalsQuery } from 'librechat-data-provider/react-query';
|
import { useSearchPrincipalsQuery } from 'librechat-data-provider/react-query';
|
||||||
import { useLocalize, usePeoplePickerPermissions } from '~/hooks';
|
|
||||||
import PeoplePickerSearchItem from './PeoplePickerSearchItem';
|
import PeoplePickerSearchItem from './PeoplePickerSearchItem';
|
||||||
import SelectedPrincipalsList from './SelectedPrincipalsList';
|
import SelectedPrincipalsList from './SelectedPrincipalsList';
|
||||||
import { SearchPicker } from './SearchPicker';
|
import { SearchPicker } from './SearchPicker';
|
||||||
|
import { useLocalize } from '~/hooks';
|
||||||
|
|
||||||
interface PeoplePickerProps {
|
interface PeoplePickerProps {
|
||||||
onSelectionChange: (principals: TPrincipal[]) => void;
|
onSelectionChange: (principals: TPrincipal[]) => void;
|
||||||
|
|
@ -21,7 +21,6 @@ export default function PeoplePicker({
|
||||||
typeFilter = null,
|
typeFilter = null,
|
||||||
}: PeoplePickerProps) {
|
}: PeoplePickerProps) {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const { canViewUsers, canViewGroups, canViewRoles } = usePeoplePickerPermissions();
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [selectedShares, setSelectedShares] = useState<TPrincipal[]>([]);
|
const [selectedShares, setSelectedShares] = useState<TPrincipal[]>([]);
|
||||||
|
|
||||||
|
|
@ -56,28 +55,6 @@ export default function PeoplePicker({
|
||||||
console.error('Principal search error:', error);
|
console.error('Principal search error:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get appropriate label based on permissions */
|
|
||||||
const getSearchLabel = () => {
|
|
||||||
const permissions = [canViewUsers, canViewGroups, canViewRoles];
|
|
||||||
const permissionCount = permissions.filter(Boolean).length;
|
|
||||||
|
|
||||||
if (permissionCount === 3) {
|
|
||||||
return localize('com_ui_search_users_groups_roles');
|
|
||||||
} else if (permissionCount === 2) {
|
|
||||||
if (canViewUsers && canViewGroups) {
|
|
||||||
return localize('com_ui_search_users_groups');
|
|
||||||
}
|
|
||||||
} else if (canViewUsers) {
|
|
||||||
return localize('com_ui_search_users');
|
|
||||||
} else if (canViewGroups) {
|
|
||||||
return localize('com_ui_search_groups');
|
|
||||||
} else if (canViewRoles) {
|
|
||||||
return localize('com_ui_search_roles');
|
|
||||||
}
|
|
||||||
|
|
||||||
return localize('com_ui_search_users_groups');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`space-y-3 ${className}`}>
|
<div className={`space-y-3 ${className}`}>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
|
@ -107,7 +84,6 @@ export default function PeoplePicker({
|
||||||
});
|
});
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
}}
|
}}
|
||||||
label={getSearchLabel()}
|
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import { Search } from 'lucide-react';
|
||||||
import debounce from 'lodash/debounce';
|
import debounce from 'lodash/debounce';
|
||||||
import { Search, X } from 'lucide-react';
|
|
||||||
import * as Ariakit from '@ariakit/react';
|
import * as Ariakit from '@ariakit/react';
|
||||||
import { Spinner, Skeleton } from '@librechat/client';
|
import { Spinner, Skeleton } from '@librechat/client';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
|
|
@ -14,7 +14,7 @@ type SearchPickerProps<TOption extends { key: string }> = {
|
||||||
onPick: (pickedOption: TOption) => void;
|
onPick: (pickedOption: TOption) => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
inputClassName?: string;
|
inputClassName?: string;
|
||||||
label: string;
|
label?: string;
|
||||||
resetValueOnHide?: boolean;
|
resetValueOnHide?: boolean;
|
||||||
isSmallScreen?: boolean;
|
isSmallScreen?: boolean;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
|
|
@ -69,29 +69,21 @@ export function SearchPicker<TOption extends { key: string; value: string }>({
|
||||||
inputRef.current.focus();
|
inputRef.current.focus();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const showClearIcon = localQuery.trim().length > 0;
|
|
||||||
const clearText = () => {
|
|
||||||
setLocalQuery('');
|
|
||||||
onQueryChange('');
|
|
||||||
debouncedOnQueryChange.cancel();
|
|
||||||
if (inputRef.current) {
|
|
||||||
inputRef.current.focus();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<Ariakit.ComboboxProvider store={combobox}>
|
<Ariakit.ComboboxProvider store={combobox}>
|
||||||
<Ariakit.ComboboxLabel className="text-token-text-primary mb-2 block font-medium">
|
<Ariakit.ComboboxLabel className="mb-2 block font-medium text-text-primary">
|
||||||
{label}
|
{label}
|
||||||
</Ariakit.ComboboxLabel>
|
</Ariakit.ComboboxLabel>
|
||||||
<div className="py-1.5">
|
<>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'group relative mt-1 flex h-10 cursor-pointer items-center gap-3 rounded-lg border-border-medium px-3 py-2 text-text-primary transition-colors duration-200 focus-within:bg-surface-hover hover:bg-surface-hover',
|
'group relative flex h-10 cursor-pointer items-center gap-2 rounded-lg border-border-medium text-text-primary transition-colors duration-200 focus-within:bg-surface-hover hover:bg-surface-hover',
|
||||||
isSmallScreen === true ? 'mb-2 h-14 rounded-2xl' : '',
|
isSmallScreen === true ? 'mb-2 h-14 rounded-2xl' : '',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Spinner className="absolute left-3 h-4 w-4 text-text-primary" />
|
<Spinner className="absolute left-3 h-4 w-4" />
|
||||||
) : (
|
) : (
|
||||||
<Search className="absolute left-3 h-4 w-4 text-text-secondary group-focus-within:text-text-primary group-hover:text-text-primary" />
|
<Search className="absolute left-3 h-4 w-4 text-text-secondary group-focus-within:text-text-primary group-hover:text-text-primary" />
|
||||||
)}
|
)}
|
||||||
|
|
@ -118,28 +110,14 @@ export function SearchPicker<TOption extends { key: string; value: string }>({
|
||||||
value={localQuery}
|
value={localQuery}
|
||||||
// autoSelect
|
// autoSelect
|
||||||
placeholder={placeholder || localize('com_ui_select_options')}
|
placeholder={placeholder || localize('com_ui_select_options')}
|
||||||
className="m-0 mr-0 w-full rounded-md border-none bg-transparent p-0 py-2 pl-9 pr-3 text-sm leading-tight text-text-primary placeholder-text-secondary placeholder-opacity-100 focus:outline-none focus-visible:outline-none group-focus-within:placeholder-text-primary group-hover:placeholder-text-primary"
|
className="h-10 w-full rounded-lg bg-transparent pl-10 text-sm leading-tight text-text-primary placeholder-text-secondary placeholder-opacity-100 focus:outline-none focus-visible:outline-none group-focus-within:placeholder-text-primary group-hover:placeholder-text-primary"
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={`${localize('com_ui_clear')} ${localize('com_ui_search')}`}
|
|
||||||
className={cn(
|
|
||||||
'absolute right-[7px] flex h-5 w-5 items-center justify-center rounded-full border-none bg-transparent p-0 transition-opacity duration-200',
|
|
||||||
showClearIcon ? 'opacity-100' : 'opacity-0',
|
|
||||||
isSmallScreen === true ? 'right-[16px]' : '',
|
|
||||||
)}
|
|
||||||
onClick={clearText}
|
|
||||||
tabIndex={showClearIcon ? 0 : -1}
|
|
||||||
disabled={!showClearIcon}
|
|
||||||
>
|
|
||||||
<X className="h-5 w-5 cursor-pointer" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
<Ariakit.ComboboxPopover
|
<Ariakit.ComboboxPopover
|
||||||
portal={false} //todo fix focus when set to true
|
portal={false} //todo fix focus when set to true
|
||||||
gutter={10}
|
gutter={8}
|
||||||
// sameWidth
|
sameWidth
|
||||||
open={
|
open={
|
||||||
isLoading ||
|
isLoading ||
|
||||||
options.length > 0 ||
|
options.length > 0 ||
|
||||||
|
|
@ -150,7 +128,7 @@ export function SearchPicker<TOption extends { key: string; value: string }>({
|
||||||
autoFocusOnShow={false}
|
autoFocusOnShow={false}
|
||||||
modal={false}
|
modal={false}
|
||||||
className={cn(
|
className={cn(
|
||||||
'animate-popover z-[9999] min-w-64 overflow-hidden rounded-xl border border-border-light bg-surface-secondary shadow-lg',
|
'animate-popover z-[9999] min-w-64 overflow-hidden rounded-2xl border border-border-light bg-surface-secondary shadow-lg',
|
||||||
'[pointer-events:auto]', // Override body's pointer-events:none when in modal
|
'[pointer-events:auto]', // Override body's pointer-events:none when in modal
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
import React, { useState, useId } from 'react';
|
import React from 'react';
|
||||||
import * as Menu from '@ariakit/react/menu';
|
import { Button, useMediaQuery } from '@librechat/client';
|
||||||
import { Button, DropdownPopup } from '@librechat/client';
|
import { Users, X, ExternalLink } from 'lucide-react';
|
||||||
import { Users, X, ExternalLink, ChevronDown } from 'lucide-react';
|
import { ResourceType } from 'librechat-data-provider';
|
||||||
import type { TPrincipal, TAccessRole, AccessRoleIds } from 'librechat-data-provider';
|
import type { TPrincipal, AccessRoleIds } from 'librechat-data-provider';
|
||||||
|
import AccessRolesPicker from '~/components/Sharing/AccessRolesPicker';
|
||||||
import PrincipalAvatar from '~/components/Sharing/PrincipalAvatar';
|
import PrincipalAvatar from '~/components/Sharing/PrincipalAvatar';
|
||||||
import { getRoleLocalizationKeys } from '~/utils';
|
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
|
|
||||||
interface SelectedPrincipalsListProps {
|
interface SelectedPrincipalsListProps {
|
||||||
principles: TPrincipal[];
|
principles: TPrincipal[];
|
||||||
onRemoveHandler: (idOnTheSource: string) => void;
|
onRemoveHandler: (idOnTheSource: string) => void;
|
||||||
onRoleChange?: (idOnTheSource: string, newRoleId: AccessRoleIds) => void;
|
onRoleChange?: (idOnTheSource: string, newRoleId: AccessRoleIds) => void;
|
||||||
availableRoles?: Omit<TAccessRole, 'resourceType'>[];
|
resourceType?: ResourceType;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -20,13 +20,16 @@ export default function SelectedPrincipalsList({
|
||||||
onRemoveHandler,
|
onRemoveHandler,
|
||||||
className = '',
|
className = '',
|
||||||
onRoleChange,
|
onRoleChange,
|
||||||
availableRoles,
|
resourceType = ResourceType.AGENT,
|
||||||
}: SelectedPrincipalsListProps) {
|
}: SelectedPrincipalsListProps) {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
|
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||||
|
|
||||||
const getPrincipalDisplayInfo = (principal: TPrincipal) => {
|
const getPrincipalDisplayInfo = (principal: TPrincipal) => {
|
||||||
const displayName = principal.name || localize('com_ui_unknown');
|
const displayName = principal.name || localize('com_ui_unknown');
|
||||||
const subtitle = principal.email || `${principal.type} (${principal.source || 'local'})`;
|
const subtitle = isMobile
|
||||||
|
? `${principal.type} (${principal.source || 'local'})`
|
||||||
|
: principal.email || `${principal.type} (${principal.source || 'local'})`;
|
||||||
|
|
||||||
return { displayName, subtitle };
|
return { displayName, subtitle };
|
||||||
};
|
};
|
||||||
|
|
@ -34,7 +37,7 @@ export default function SelectedPrincipalsList({
|
||||||
if (principles.length === 0) {
|
if (principles.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className={`space-y-3 ${className}`}>
|
<div className={`space-y-3 ${className}`}>
|
||||||
<div className="rounded-lg border border-dashed border-border py-8 text-center text-muted-foreground">
|
<div className="rounded-lg border border-dashed border-border-medium py-8 text-center text-muted-foreground">
|
||||||
<Users className="mx-auto mb-2 h-8 w-8 opacity-50" />
|
<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_all')}</p>
|
<p className="mt-1 text-xs">{localize('com_ui_search_above_to_add_all')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -50,7 +53,7 @@ export default function SelectedPrincipalsList({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={share.idOnTheSource + '-principalList'}
|
key={share.idOnTheSource + '-principalList'}
|
||||||
className="bg-surface flex items-center justify-between rounded-lg border border-border p-3"
|
className="bg-surface flex items-center justify-between rounded-2xl border border-border p-3"
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||||
<PrincipalAvatar principal={share} size="md" />
|
<PrincipalAvatar principal={share} size="md" />
|
||||||
|
|
@ -71,19 +74,19 @@ export default function SelectedPrincipalsList({
|
||||||
|
|
||||||
<div className="flex flex-shrink-0 items-center gap-2">
|
<div className="flex flex-shrink-0 items-center gap-2">
|
||||||
{!!share.accessRoleId && !!onRoleChange && (
|
{!!share.accessRoleId && !!onRoleChange && (
|
||||||
<RoleSelector
|
<AccessRolesPicker
|
||||||
currentRole={share.accessRoleId}
|
resourceType={resourceType}
|
||||||
|
selectedRoleId={share.accessRoleId}
|
||||||
onRoleChange={(newRole) => {
|
onRoleChange={(newRole) => {
|
||||||
onRoleChange?.(share.idOnTheSource!, newRole);
|
onRoleChange?.(share.idOnTheSource!, newRole);
|
||||||
}}
|
}}
|
||||||
availableRoles={availableRoles ?? []}
|
className="min-w-0"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="outline"
|
||||||
size="sm"
|
|
||||||
onClick={() => onRemoveHandler(share.idOnTheSource!)}
|
onClick={() => onRemoveHandler(share.idOnTheSource!)}
|
||||||
className="h-8 w-8 p-0 hover:bg-destructive/10 hover:text-destructive"
|
className="h-9 w-9 p-0 hover:border-destructive/10 hover:bg-destructive/10 hover:text-destructive"
|
||||||
aria-label={localize('com_ui_remove_user', { 0: displayName })}
|
aria-label={localize('com_ui_remove_user', { 0: displayName })}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
|
|
@ -96,44 +99,3 @@ export default function SelectedPrincipalsList({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RoleSelectorProps {
|
|
||||||
currentRole: AccessRoleIds;
|
|
||||||
onRoleChange: (newRole: AccessRoleIds) => 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: AccessRoleIds) => {
|
|
||||||
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]"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import type { TPrincipal, PrincipalType, PrincipalSearchParams } from 'librechat-data-provider';
|
||||||
|
import { useSearchPrincipalsQuery } from 'librechat-data-provider/react-query';
|
||||||
|
import PeoplePickerSearchItem from './PeoplePickerSearchItem';
|
||||||
|
import { SearchPicker } from './SearchPicker';
|
||||||
|
import { useLocalize } from '~/hooks';
|
||||||
|
|
||||||
|
interface UnifiedPeopleSearchProps {
|
||||||
|
onAddPeople: (principals: TPrincipal[]) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
typeFilter?: PrincipalType.USER | PrincipalType.GROUP | PrincipalType.ROLE | null;
|
||||||
|
excludeIds?: (string | undefined)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UnifiedPeopleSearch({
|
||||||
|
onAddPeople,
|
||||||
|
placeholder,
|
||||||
|
className = '',
|
||||||
|
typeFilter = null,
|
||||||
|
excludeIds = [],
|
||||||
|
}: UnifiedPeopleSearchProps) {
|
||||||
|
const localize = useLocalize();
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
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) => result.idOnTheSource && !excludeIds.includes(result.idOnTheSource),
|
||||||
|
);
|
||||||
|
}, [searchResponse?.results, excludeIds]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Principal search error:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePick = (principal: TPrincipal) => {
|
||||||
|
// Immediately add the selected person to the unified list
|
||||||
|
onAddPeople([principal]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${className}`}>
|
||||||
|
<SearchPicker<TPrincipal & { key: string; value: string }>
|
||||||
|
options={selectableResults.map((s) => ({
|
||||||
|
...s,
|
||||||
|
id: s.id ?? undefined,
|
||||||
|
key: s.idOnTheSource || 'unknown' + 'picker_key',
|
||||||
|
value: s.idOnTheSource || 'Unknown',
|
||||||
|
}))}
|
||||||
|
renderOptions={(o) => <PeoplePickerSearchItem principal={o} />}
|
||||||
|
placeholder={placeholder || localize('com_ui_search_default_placeholder')}
|
||||||
|
query={searchQuery}
|
||||||
|
onQueryChange={(query: string) => {
|
||||||
|
setSearchQuery(query);
|
||||||
|
}}
|
||||||
|
onPick={handlePick}
|
||||||
|
isLoading={isLoading}
|
||||||
|
label=""
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
export { default as PeoplePicker } from './PeoplePicker';
|
export { default as PeoplePicker } from './PeoplePicker';
|
||||||
export { default as PeoplePickerSearchItem } from './PeoplePickerSearchItem';
|
export { default as PeoplePickerSearchItem } from './PeoplePickerSearchItem';
|
||||||
export { default as SelectedPrincipalsList } from './SelectedPrincipalsList';
|
export { default as SelectedPrincipalsList } from './SelectedPrincipalsList';
|
||||||
|
export { default as UnifiedPeopleSearch } from './UnifiedPeopleSearch';
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,20 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Switch } from '@librechat/client';
|
|
||||||
import { Globe, Shield } from 'lucide-react';
|
import { Globe, Shield } from 'lucide-react';
|
||||||
import type { AccessRoleIds } from 'librechat-data-provider';
|
|
||||||
import { ResourceType } from 'librechat-data-provider';
|
import { ResourceType } from 'librechat-data-provider';
|
||||||
|
import { Switch, InfoHoverCard, ESide, Label } from '@librechat/client';
|
||||||
|
import type { AccessRoleIds } from 'librechat-data-provider';
|
||||||
import AccessRolesPicker from './AccessRolesPicker';
|
import AccessRolesPicker from './AccessRolesPicker';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
|
interface PublicSharingToggleProps {
|
||||||
|
isPublic: boolean;
|
||||||
|
publicRole?: AccessRoleIds;
|
||||||
|
onPublicToggle: (isPublic: boolean) => void;
|
||||||
|
onPublicRoleChange: (role: AccessRoleIds) => void;
|
||||||
|
resourceType?: ResourceType;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function PublicSharingToggle({
|
export default function PublicSharingToggle({
|
||||||
isPublic,
|
isPublic,
|
||||||
|
|
@ -12,51 +22,98 @@ export default function PublicSharingToggle({
|
||||||
onPublicToggle,
|
onPublicToggle,
|
||||||
onPublicRoleChange,
|
onPublicRoleChange,
|
||||||
resourceType = ResourceType.AGENT,
|
resourceType = ResourceType.AGENT,
|
||||||
}: {
|
className,
|
||||||
isPublic: boolean;
|
}: PublicSharingToggleProps) {
|
||||||
publicRole?: AccessRoleIds;
|
|
||||||
onPublicToggle: (isPublic: boolean) => void;
|
|
||||||
onPublicRoleChange: (role: AccessRoleIds) => void;
|
|
||||||
resourceType?: ResourceType;
|
|
||||||
}) {
|
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
|
|
||||||
return (
|
const handleToggle = React.useCallback(
|
||||||
<div className="space-y-4">
|
(checked: boolean) => {
|
||||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
onPublicToggle(checked);
|
||||||
<div className="flex items-start gap-3">
|
},
|
||||||
<Globe className="mt-0.5 h-5 w-5 text-blue-500" />
|
[onPublicToggle],
|
||||||
<div>
|
);
|
||||||
<h4 className="text-sm font-medium text-text-primary">
|
|
||||||
{localize('com_ui_public_access')}
|
|
||||||
</h4>
|
|
||||||
<p className="text-xs text-text-secondary">
|
|
||||||
{localize('com_ui_public_access_description')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={isPublic}
|
|
||||||
onCheckedChange={onPublicToggle}
|
|
||||||
aria-labelledby="public-access-toggle"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isPublic && (
|
return (
|
||||||
<div className="ml-8 space-y-3">
|
<div className={cn('space-y-4', className)}>
|
||||||
<div className="flex items-center gap-2">
|
{/* Main toggle section */}
|
||||||
<Shield className="h-4 w-4 text-text-secondary" />
|
<div className="group relative rounded-lg">
|
||||||
<label className="text-sm font-medium text-text-primary">
|
<div className="flex items-center justify-between">
|
||||||
{localize('com_ui_public_permission_level')}
|
<div className="flex items-center gap-3">
|
||||||
</label>
|
<div
|
||||||
|
className={cn(
|
||||||
|
'transition-colors duration-200',
|
||||||
|
isPublic ? 'text-blue-600 dark:text-blue-500' : 'text-text-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Globe className="size-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Label
|
||||||
|
htmlFor="public-access-toggle"
|
||||||
|
className="cursor-pointer text-sm font-medium text-text-primary"
|
||||||
|
>
|
||||||
|
{localize('com_ui_public_access')}
|
||||||
|
</Label>
|
||||||
|
<InfoHoverCard side={ESide.Top} text={localize('com_ui_public_access_description')} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AccessRolesPicker
|
<Switch
|
||||||
resourceType={resourceType}
|
id="public-access-toggle"
|
||||||
selectedRoleId={publicRole}
|
checked={isPublic}
|
||||||
onRoleChange={onPublicRoleChange}
|
onCheckedChange={handleToggle}
|
||||||
|
aria-label={localize('com_ui_public_access')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
|
{/* Permission level section with smooth animation */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'transition-all duration-300 ease-in-out',
|
||||||
|
isPublic ? 'max-h-32 opacity-100' : 'max-h-0 opacity-0',
|
||||||
|
)}
|
||||||
|
style={{ overflow: isPublic ? 'visible' : 'hidden' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg transition-all duration-300',
|
||||||
|
isPublic ? 'bg-surface-secondary/50 translate-y-0' : '-translate-y-2',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'transition-all duration-300',
|
||||||
|
isPublic
|
||||||
|
? 'scale-100 text-blue-600 dark:text-blue-500'
|
||||||
|
: 'scale-95 text-text-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Shield className="size-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<Label htmlFor="permission-level" className="text-sm font-medium text-text-primary">
|
||||||
|
{localize('com_ui_public_permission_level')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative z-50 transition-all duration-300',
|
||||||
|
isPublic ? 'scale-100 opacity-100' : 'scale-95 opacity-0',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<AccessRolesPicker
|
||||||
|
id="permission-level"
|
||||||
|
resourceType={resourceType}
|
||||||
|
selectedRoleId={publicRole}
|
||||||
|
onRoleChange={onPublicRoleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
export { default as AccessRolesPicker } from './AccessRolesPicker';
|
export { default as AccessRolesPicker } from './AccessRolesPicker';
|
||||||
export { default as GenericGrantAccessDialog } from './GenericGrantAccessDialog';
|
export { default as GenericGrantAccessDialog } from './GenericGrantAccessDialog';
|
||||||
export { default as GenericManagePermissionsDialog } from './GenericManagePermissionsDialog';
|
|
||||||
export { default as ManagePermissionsDialog } from './ManagePermissionsDialog';
|
export { default as ManagePermissionsDialog } from './ManagePermissionsDialog';
|
||||||
export { default as PrincipalAvatar } from './PrincipalAvatar';
|
export { default as PrincipalAvatar } from './PrincipalAvatar';
|
||||||
export { default as PublicSharingToggle } from './PublicSharingToggle';
|
export { default as PublicSharingToggle } from './PublicSharingToggle';
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,11 @@ jest.mock('@librechat/client', () => ({
|
||||||
// Mock other dependencies
|
// Mock other dependencies
|
||||||
jest.mock('librechat-data-provider/react-query', () => ({
|
jest.mock('librechat-data-provider/react-query', () => ({
|
||||||
useGetModelsQuery: () => ({ data: {} }),
|
useGetModelsQuery: () => ({ data: {} }),
|
||||||
|
useGetEffectivePermissionsQuery: () => ({
|
||||||
|
data: { permissionBits: 0xffffffff }, // All permissions
|
||||||
|
isLoading: false,
|
||||||
|
}),
|
||||||
|
hasPermissions: (_bits: number, _required: number) => true, // Always return true for tests
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('~/utils', () => ({
|
jest.mock('~/utils', () => ({
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
import { useFormContext } from 'react-hook-form';
|
import { useFormContext } from 'react-hook-form';
|
||||||
import {
|
import {
|
||||||
OGDialog,
|
|
||||||
OGDialogTrigger,
|
|
||||||
Label,
|
Label,
|
||||||
OGDialogTemplate,
|
Button,
|
||||||
|
OGDialog,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
useToastContext,
|
useToastContext,
|
||||||
|
OGDialogTrigger,
|
||||||
|
OGDialogTemplate,
|
||||||
} from '@librechat/client';
|
} from '@librechat/client';
|
||||||
import type { Agent, AgentCreateParams } from 'librechat-data-provider';
|
import type { Agent, AgentCreateParams } from 'librechat-data-provider';
|
||||||
import type { UseMutationResult } from '@tanstack/react-query';
|
import type { UseMutationResult } from '@tanstack/react-query';
|
||||||
import { cn, logger, removeFocusOutlines, getDefaultAgentFormValues } from '~/utils';
|
import { logger, getDefaultAgentFormValues } from '~/utils';
|
||||||
import { useLocalize, useSetIndexOptions } from '~/hooks';
|
import { useLocalize, useSetIndexOptions } from '~/hooks';
|
||||||
import { useDeleteAgentMutation } from '~/data-provider';
|
import { useDeleteAgentMutation } from '~/data-provider';
|
||||||
import { useChatContext } from '~/Providers';
|
import { useChatContext } from '~/Providers';
|
||||||
|
|
@ -82,18 +83,16 @@ export default function DeleteButton({
|
||||||
return (
|
return (
|
||||||
<OGDialog>
|
<OGDialog>
|
||||||
<OGDialogTrigger asChild>
|
<OGDialogTrigger asChild>
|
||||||
<button
|
<Button
|
||||||
className={cn(
|
size="sm"
|
||||||
'btn btn-neutral border-token-border-light relative h-9 rounded-lg font-medium',
|
variant="outline"
|
||||||
removeFocusOutlines,
|
|
||||||
)}
|
|
||||||
aria-label={localize('com_ui_delete') + ' ' + localize('com_ui_agent')}
|
aria-label={localize('com_ui_delete') + ' ' + localize('com_ui_agent')}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<div className="flex w-full items-center justify-center gap-2 text-red-500">
|
<div className="flex w-full items-center justify-center gap-2 text-red-500">
|
||||||
<TrashIcon />
|
<TrashIcon />
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</Button>
|
||||||
</OGDialogTrigger>
|
</OGDialogTrigger>
|
||||||
<OGDialogTemplate
|
<OGDialogTemplate
|
||||||
title={localize('com_ui_delete') + ' ' + localize('com_ui_agent')}
|
title={localize('com_ui_delete') + ' ' + localize('com_ui_agent')}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { CopyIcon } from 'lucide-react';
|
import { CopyIcon } from 'lucide-react';
|
||||||
import { useToastContext } from '@librechat/client';
|
import { useToastContext, Button } from '@librechat/client';
|
||||||
import { useDuplicateAgentMutation } from '~/data-provider';
|
import { useDuplicateAgentMutation } from '~/data-provider';
|
||||||
import { cn, removeFocusOutlines } from '~/utils';
|
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
|
|
||||||
export default function DuplicateAgent({ agent_id }: { agent_id: string }) {
|
export default function DuplicateAgent({ agent_id }: { agent_id: string }) {
|
||||||
|
|
@ -33,11 +32,9 @@ export default function DuplicateAgent({ agent_id }: { agent_id: string }) {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<Button
|
||||||
className={cn(
|
size="sm"
|
||||||
'btn btn-neutral border-token-border-light relative h-9 rounded-lg font-medium',
|
variant="outline"
|
||||||
removeFocusOutlines,
|
|
||||||
)}
|
|
||||||
aria-label={localize('com_ui_duplicate') + ' ' + localize('com_ui_agent')}
|
aria-label={localize('com_ui_duplicate') + ' ' + localize('com_ui_agent')}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleDuplicate}
|
onClick={handleDuplicate}
|
||||||
|
|
@ -45,6 +42,6 @@ export default function DuplicateAgent({ agent_id }: { agent_id: string }) {
|
||||||
<div className="flex w-full items-center justify-center gap-2 text-primary">
|
<div className="flex w-full items-center justify-center gap-2 text-primary">
|
||||||
<CopyIcon className="size-4" />
|
<CopyIcon className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
91
client/src/hooks/useInfiniteScroll.ts
Normal file
91
client/src/hooks/useInfiniteScroll.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { throttle } from 'lodash';
|
||||||
|
|
||||||
|
interface UseInfiniteScrollOptions {
|
||||||
|
hasNextPage?: boolean;
|
||||||
|
isFetchingNextPage?: boolean;
|
||||||
|
fetchNextPage: () => void;
|
||||||
|
threshold?: number; // Percentage of scroll position to trigger fetch (0-1)
|
||||||
|
throttleMs?: number; // Throttle delay in milliseconds
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for implementing infinite scroll functionality
|
||||||
|
* Detects when user scrolls near the bottom and triggers data fetching
|
||||||
|
*/
|
||||||
|
export const useInfiniteScroll = ({
|
||||||
|
hasNextPage = false,
|
||||||
|
isFetchingNextPage = false,
|
||||||
|
fetchNextPage,
|
||||||
|
threshold = 0.8, // Trigger when 80% scrolled
|
||||||
|
throttleMs = 200,
|
||||||
|
}: UseInfiniteScrollOptions) => {
|
||||||
|
const scrollElementRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
// Throttled scroll handler to prevent excessive API calls
|
||||||
|
const handleScroll = useCallback(
|
||||||
|
throttle(() => {
|
||||||
|
const element = scrollElementRef.current;
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = element;
|
||||||
|
|
||||||
|
// Calculate scroll position as percentage
|
||||||
|
const scrollPosition = (scrollTop + clientHeight) / scrollHeight;
|
||||||
|
|
||||||
|
// Check if we've scrolled past the threshold and conditions are met
|
||||||
|
const shouldFetch = scrollPosition >= threshold && hasNextPage && !isFetchingNextPage;
|
||||||
|
|
||||||
|
if (shouldFetch) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
}, throttleMs),
|
||||||
|
[hasNextPage, isFetchingNextPage, fetchNextPage, threshold, throttleMs],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set up scroll listener
|
||||||
|
useEffect(() => {
|
||||||
|
const element = scrollElementRef.current;
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
// Remove any existing listener first
|
||||||
|
element.removeEventListener('scroll', handleScroll);
|
||||||
|
|
||||||
|
// Add the new listener
|
||||||
|
element.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
element.removeEventListener('scroll', handleScroll);
|
||||||
|
// Clean up throttled function
|
||||||
|
handleScroll.cancel?.();
|
||||||
|
};
|
||||||
|
}, [handleScroll]);
|
||||||
|
|
||||||
|
// Additional effect to re-setup listeners when scroll element changes
|
||||||
|
useEffect(() => {
|
||||||
|
const element = scrollElementRef.current;
|
||||||
|
if (!element) return;
|
||||||
|
// Remove any existing listener first
|
||||||
|
element.removeEventListener('scroll', handleScroll);
|
||||||
|
|
||||||
|
// Add the new listener
|
||||||
|
element.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
return () => {
|
||||||
|
element.removeEventListener('scroll', handleScroll);
|
||||||
|
// Clean up throttled function
|
||||||
|
handleScroll.cancel?.();
|
||||||
|
};
|
||||||
|
}, [scrollElementRef.current, handleScroll]);
|
||||||
|
|
||||||
|
// Function to manually set the scroll container
|
||||||
|
const setScrollElement = useCallback((element: HTMLElement | null) => {
|
||||||
|
scrollElementRef.current = element;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
setScrollElement,
|
||||||
|
scrollElementRef,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useInfiniteScroll;
|
||||||
66
client/src/hooks/useVirtualGrid.ts
Normal file
66
client/src/hooks/useVirtualGrid.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
|
interface UseVirtualGridProps {
|
||||||
|
itemCount: number;
|
||||||
|
containerWidth: number;
|
||||||
|
itemHeight: number;
|
||||||
|
gapSize: number;
|
||||||
|
mobileColumnsCount: number;
|
||||||
|
desktopColumnsCount: number;
|
||||||
|
mobileBreakpoint: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseVirtualGridReturn {
|
||||||
|
cardsPerRow: number;
|
||||||
|
rowCount: number;
|
||||||
|
rowHeight: number;
|
||||||
|
getRowItems: (rowIndex: number, items: any[]) => any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for virtual grid calculations
|
||||||
|
* Handles responsive grid layout and item positioning for virtualized lists
|
||||||
|
*/
|
||||||
|
export const useVirtualGrid = ({
|
||||||
|
itemCount,
|
||||||
|
containerWidth,
|
||||||
|
itemHeight,
|
||||||
|
gapSize,
|
||||||
|
mobileColumnsCount,
|
||||||
|
desktopColumnsCount,
|
||||||
|
mobileBreakpoint = 768,
|
||||||
|
}: UseVirtualGridProps): UseVirtualGridReturn => {
|
||||||
|
// Calculate cards per row based on container width
|
||||||
|
const cardsPerRow = useMemo(() => {
|
||||||
|
return containerWidth >= mobileBreakpoint ? desktopColumnsCount : mobileColumnsCount;
|
||||||
|
}, [containerWidth, mobileBreakpoint, desktopColumnsCount, mobileColumnsCount]);
|
||||||
|
|
||||||
|
// Calculate total number of rows needed
|
||||||
|
const rowCount = useMemo(() => {
|
||||||
|
return Math.ceil(itemCount / cardsPerRow);
|
||||||
|
}, [itemCount, cardsPerRow]);
|
||||||
|
|
||||||
|
// Calculate row height including gap
|
||||||
|
const rowHeight = useMemo(() => {
|
||||||
|
return itemHeight + gapSize;
|
||||||
|
}, [itemHeight, gapSize]);
|
||||||
|
|
||||||
|
// Get items for a specific row
|
||||||
|
const getRowItems = useCallback(
|
||||||
|
(rowIndex: number, items: any[]) => {
|
||||||
|
const startIndex = rowIndex * cardsPerRow;
|
||||||
|
const endIndex = Math.min(startIndex + cardsPerRow, items.length);
|
||||||
|
return items.slice(startIndex, endIndex);
|
||||||
|
},
|
||||||
|
[cardsPerRow],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cardsPerRow,
|
||||||
|
rowCount,
|
||||||
|
rowHeight,
|
||||||
|
getRowItems,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useVirtualGrid;
|
||||||
|
|
@ -696,19 +696,12 @@
|
||||||
"com_ui_copy_url_to_clipboard": "Copy URL to clipboard",
|
"com_ui_copy_url_to_clipboard": "Copy URL to clipboard",
|
||||||
"com_ui_agent_url_copied": "Agent URL copied to clipboard",
|
"com_ui_agent_url_copied": "Agent URL copied to clipboard",
|
||||||
"com_ui_search_people_placeholder": "Search for people or groups by name or email",
|
"com_ui_search_people_placeholder": "Search for people or groups by name or email",
|
||||||
"com_ui_permission_level": "Permission Level",
|
|
||||||
"com_ui_grant_access": "Grant Access",
|
|
||||||
"com_ui_granting": "Granting...",
|
|
||||||
"com_ui_search_users_groups": "Search Users and Groups",
|
|
||||||
"com_ui_search_users_groups_roles": "Search Users, Groups, and Roles",
|
|
||||||
"com_ui_search_users": "Search Users",
|
|
||||||
"com_ui_search_groups": "Search Groups",
|
|
||||||
"com_ui_search_roles": "Search Roles",
|
|
||||||
"com_ui_search_default_placeholder": "Search by name or email (min 2 chars)",
|
"com_ui_search_default_placeholder": "Search by name or email (min 2 chars)",
|
||||||
"com_ui_user": "User",
|
"com_ui_user": "User",
|
||||||
"com_ui_group": "Group",
|
"com_ui_group": "Group",
|
||||||
"com_ui_role": "Role",
|
"com_ui_role": "Role",
|
||||||
"com_ui_search_above_to_add": "Search above to add users or groups",
|
"com_ui_search_above_to_add": "Search above to add users or groups",
|
||||||
|
"com_ui_search_above_to_add_people": "Search above to add people",
|
||||||
"com_ui_search_above_to_add_all": "Search above to add users, groups, or roles",
|
"com_ui_search_above_to_add_all": "Search above to add users, groups, or roles",
|
||||||
"com_ui_azure_ad": "Entra ID",
|
"com_ui_azure_ad": "Entra ID",
|
||||||
"com_ui_remove_user": "Remove {{0}}",
|
"com_ui_remove_user": "Remove {{0}}",
|
||||||
|
|
@ -1245,10 +1238,9 @@
|
||||||
"com_agents_empty_state_heading": "No agents found",
|
"com_agents_empty_state_heading": "No agents found",
|
||||||
"com_agents_search_empty_heading": "No search results",
|
"com_agents_search_empty_heading": "No search results",
|
||||||
"com_agents_no_description": "No description available",
|
"com_agents_no_description": "No description available",
|
||||||
"com_agents_none_in_category": "No agents found in this category",
|
|
||||||
"com_agents_no_results": "No agents found. Try another search term.",
|
|
||||||
"com_agents_results_for": "Results for '{{query}}'",
|
"com_agents_results_for": "Results for '{{query}}'",
|
||||||
"com_agents_marketplace_subtitle": "Discover and use powerful AI agents to enhance your workflows and productivity",
|
"com_agents_marketplace_subtitle": "Discover and use powerful AI agents to enhance your workflows and productivity",
|
||||||
|
"com_agents_no_more_results": "You've reached the end of the results",
|
||||||
"com_ui_agent_name_is_required": "Agent name is required",
|
"com_ui_agent_name_is_required": "Agent name is required",
|
||||||
"com_agents_missing_name": "Please type in a name before creating an agent."
|
"com_agents_missing_name": "Please type in a name before creating an agent."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -123,10 +123,10 @@ describe('Agent Utilities', () => {
|
||||||
} as unknown as t.Agent;
|
} as unknown as t.Agent;
|
||||||
|
|
||||||
const { rerender } = render(<div>{renderAgentAvatar(agent, { showBorder: true })}</div>);
|
const { rerender } = render(<div>{renderAgentAvatar(agent, { showBorder: true })}</div>);
|
||||||
expect(screen.getByAltText('Test Agent avatar')).toHaveClass('border-2');
|
expect(screen.getByAltText('Test Agent avatar')).toHaveClass('border-1');
|
||||||
|
|
||||||
rerender(<div>{renderAgentAvatar(agent, { showBorder: false })}</div>);
|
rerender(<div>{renderAgentAvatar(agent, { showBorder: false })}</div>);
|
||||||
expect(screen.getByAltText('Test Agent avatar')).not.toHaveClass('border-2');
|
expect(screen.getByAltText('Test Agent avatar')).not.toHaveClass('border-1');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ export const renderAgentAvatar = (
|
||||||
xl: 'h-20 w-20',
|
xl: 'h-20 w-20',
|
||||||
};
|
};
|
||||||
|
|
||||||
const borderClasses = showBorder ? 'border-2 border-white dark:border-gray-800' : '';
|
const borderClasses = showBorder ? 'border-1 border-border-medium' : '';
|
||||||
|
|
||||||
if (avatarUrl) {
|
if (avatarUrl) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -79,14 +79,11 @@ export const renderAgentAvatar = (
|
||||||
return (
|
return (
|
||||||
<div className={`relative flex items-center justify-center ${sizeClasses[size]} ${className}`}>
|
<div className={`relative flex items-center justify-center ${sizeClasses[size]} ${className}`}>
|
||||||
{/* Subtle minimalistic placeholder */}
|
{/* Subtle minimalistic placeholder */}
|
||||||
<div className="absolute inset-0 rounded-full border border-gray-300 bg-gray-200 dark:border-gray-600 dark:bg-gray-700"></div>
|
<div className="absolute inset-0 rounded-full border border-border-medium bg-surface-secondary"></div>
|
||||||
<div
|
<div
|
||||||
className={`relative flex items-center justify-center rounded-full bg-gray-300 dark:bg-gray-600 ${placeholderSizeClasses[size]}`}
|
className={`relative flex items-center justify-center rounded-full ${placeholderSizeClasses[size]}`}
|
||||||
>
|
>
|
||||||
<Bot
|
<Bot className={`text-text-primary ${iconSizeClasses[size]}`} strokeWidth={1.5} />
|
||||||
className={`text-gray-500 dark:text-gray-400 ${iconSizeClasses[size]}`}
|
|
||||||
strokeWidth={1.5}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -31,11 +31,31 @@ module.exports = {
|
||||||
from: { height: 'var(--radix-accordion-content-height)' },
|
from: { height: 'var(--radix-accordion-content-height)' },
|
||||||
to: { height: 0 },
|
to: { height: 0 },
|
||||||
},
|
},
|
||||||
|
'slide-in-right': {
|
||||||
|
'0%': { transform: 'translateX(100%)' },
|
||||||
|
'100%': { transform: 'translateX(0)' },
|
||||||
|
},
|
||||||
|
'slide-in-left': {
|
||||||
|
'0%': { transform: 'translateX(-100%)' },
|
||||||
|
'100%': { transform: 'translateX(0)' },
|
||||||
|
},
|
||||||
|
'slide-out-left': {
|
||||||
|
'0%': { transform: 'translateX(0)' },
|
||||||
|
'100%': { transform: 'translateX(-100%)' },
|
||||||
|
},
|
||||||
|
'slide-out-right': {
|
||||||
|
'0%': { transform: 'translateX(0)' },
|
||||||
|
'100%': { transform: 'translateX(100%)' },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
'fade-in': 'fadeIn 0.5s ease-out forwards',
|
'fade-in': 'fadeIn 0.5s ease-out forwards',
|
||||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||||
'accordion-up': 'accordion-up 0.2s ease-out',
|
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||||
|
'slide-in-right': 'slide-in-right 300ms cubic-bezier(0.25, 0.1, 0.25, 1)',
|
||||||
|
'slide-in-left': 'slide-in-left 300ms cubic-bezier(0.25, 0.1, 0.25, 1)',
|
||||||
|
'slide-out-left': 'slide-out-left 300ms cubic-bezier(0.25, 0.1, 0.25, 1)',
|
||||||
|
'slide-out-right': 'slide-out-right 300ms cubic-bezier(0.25, 0.1, 0.25, 1)',
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
gray: {
|
gray: {
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@
|
||||||
"../e2e/**/*",
|
"../e2e/**/*",
|
||||||
"test/setupTests.js",
|
"test/setupTests.js",
|
||||||
"env.d.ts",
|
"env.d.ts",
|
||||||
"../config/translations/**/*.ts"
|
"../config/translations/**/*.ts",
|
||||||
, "../packages/client/src/hooks/useDelayedRender.tsx" ]
|
"../packages/client/src/hooks/useDelayedRender.tsx",
|
||||||
|
"../packages/client/src/components/InfoHoverCard.tsx"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13
packages/client/src/common/enum.ts
Normal file
13
packages/client/src/common/enum.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
export enum ESide {
|
||||||
|
Top = 'top',
|
||||||
|
Right = 'right',
|
||||||
|
Bottom = 'bottom',
|
||||||
|
Left = 'left',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum NotificationSeverity {
|
||||||
|
INFO = 'info',
|
||||||
|
SUCCESS = 'success',
|
||||||
|
WARNING = 'warning',
|
||||||
|
ERROR = 'error',
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,5 @@
|
||||||
export type {
|
export * from './types';
|
||||||
TShowToast,
|
|
||||||
Option,
|
|
||||||
OptionWithIcon,
|
|
||||||
DropdownValueSetter,
|
|
||||||
MentionOption,
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
export { NotificationSeverity } from './types';
|
export * from './enum';
|
||||||
|
|
||||||
export type { MenuItemProps } from './menus';
|
export type { MenuItemProps } from './menus';
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,4 @@
|
||||||
export enum NotificationSeverity {
|
import { NotificationSeverity } from './enum';
|
||||||
INFO = 'info',
|
|
||||||
SUCCESS = 'success',
|
|
||||||
WARNING = 'warning',
|
|
||||||
ERROR = 'error',
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TShowToast = {
|
export type TShowToast = {
|
||||||
message: string;
|
message: string;
|
||||||
|
|
|
||||||
27
packages/client/src/components/InfoHoverCard.tsx
Normal file
27
packages/client/src/components/InfoHoverCard.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { CircleHelpIcon } from 'lucide-react';
|
||||||
|
import { HoverCard, HoverCardTrigger, HoverCardPortal, HoverCardContent } from './HoverCard';
|
||||||
|
import { ESide } from '~/common';
|
||||||
|
|
||||||
|
type InfoHoverCardProps = {
|
||||||
|
side?: ESide;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const InfoHoverCard = ({ side, text }: InfoHoverCardProps) => {
|
||||||
|
return (
|
||||||
|
<HoverCard openDelay={50}>
|
||||||
|
<HoverCardTrigger className="cursor-help">
|
||||||
|
<CircleHelpIcon className="h-5 w-5 text-text-tertiary" />{' '}
|
||||||
|
</HoverCardTrigger>
|
||||||
|
<HoverCardPortal>
|
||||||
|
<HoverCardContent side={side} className="z-[999] w-80">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-text-secondary">{text}</p>
|
||||||
|
</div>
|
||||||
|
</HoverCardContent>
|
||||||
|
</HoverCardPortal>
|
||||||
|
</HoverCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InfoHoverCard;
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import * as RadixToast from '@radix-ui/react-toast';
|
import * as RadixToast from '@radix-ui/react-toast';
|
||||||
import { NotificationSeverity } from '~/common/types';
|
import { NotificationSeverity } from '~/common';
|
||||||
import { useToast } from '~/hooks';
|
import { useToast } from '~/hooks';
|
||||||
|
|
||||||
export function Toast() {
|
export function Toast() {
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ export { default as MultiSelect } from './MultiSelect';
|
||||||
export { default as DropdownPopup } from './DropdownPopup';
|
export { default as DropdownPopup } from './DropdownPopup';
|
||||||
export { default as DelayedRender } from './DelayedRender';
|
export { default as DelayedRender } from './DelayedRender';
|
||||||
export { default as ThemeSelector } from './ThemeSelector';
|
export { default as ThemeSelector } from './ThemeSelector';
|
||||||
|
export { default as InfoHoverCard } from './InfoHoverCard';
|
||||||
export { default as CheckboxButton } from './CheckboxButton';
|
export { default as CheckboxButton } from './CheckboxButton';
|
||||||
export { default as DialogTemplate } from './DialogTemplate';
|
export { default as DialogTemplate } from './DialogTemplate';
|
||||||
export { default as SelectDropDown } from './SelectDropDown';
|
export { default as SelectDropDown } from './SelectDropDown';
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue