mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-21 01:36:13 +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
|
|
@ -2,10 +2,10 @@ import React, { useState, useMemo } from 'react';
|
|||
import { PrincipalType } from 'librechat-data-provider';
|
||||
import type { TPrincipal, PrincipalSearchParams } from 'librechat-data-provider';
|
||||
import { useSearchPrincipalsQuery } from 'librechat-data-provider/react-query';
|
||||
import { useLocalize, usePeoplePickerPermissions } from '~/hooks';
|
||||
import PeoplePickerSearchItem from './PeoplePickerSearchItem';
|
||||
import SelectedPrincipalsList from './SelectedPrincipalsList';
|
||||
import { SearchPicker } from './SearchPicker';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface PeoplePickerProps {
|
||||
onSelectionChange: (principals: TPrincipal[]) => void;
|
||||
|
|
@ -21,7 +21,6 @@ export default function PeoplePicker({
|
|||
typeFilter = null,
|
||||
}: PeoplePickerProps) {
|
||||
const localize = useLocalize();
|
||||
const { canViewUsers, canViewGroups, canViewRoles } = usePeoplePickerPermissions();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedShares, setSelectedShares] = useState<TPrincipal[]>([]);
|
||||
|
||||
|
|
@ -56,28 +55,6 @@ export default function PeoplePicker({
|
|||
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 (
|
||||
<div className={`space-y-3 ${className}`}>
|
||||
<div className="relative">
|
||||
|
|
@ -107,7 +84,6 @@ export default function PeoplePicker({
|
|||
});
|
||||
setSearchQuery('');
|
||||
}}
|
||||
label={getSearchLabel()}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import * as React from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { Search, X } from 'lucide-react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { Spinner, Skeleton } from '@librechat/client';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
|
@ -14,7 +14,7 @@ type SearchPickerProps<TOption extends { key: string }> = {
|
|||
onPick: (pickedOption: TOption) => void;
|
||||
placeholder?: string;
|
||||
inputClassName?: string;
|
||||
label: string;
|
||||
label?: string;
|
||||
resetValueOnHide?: boolean;
|
||||
isSmallScreen?: boolean;
|
||||
isLoading?: boolean;
|
||||
|
|
@ -69,29 +69,21 @@ export function SearchPicker<TOption extends { key: string; value: string }>({
|
|||
inputRef.current.focus();
|
||||
}
|
||||
};
|
||||
const showClearIcon = localQuery.trim().length > 0;
|
||||
const clearText = () => {
|
||||
setLocalQuery('');
|
||||
onQueryChange('');
|
||||
debouncedOnQueryChange.cancel();
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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}
|
||||
</Ariakit.ComboboxLabel>
|
||||
<div className="py-1.5">
|
||||
<>
|
||||
<div
|
||||
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' : '',
|
||||
)}
|
||||
>
|
||||
{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" />
|
||||
)}
|
||||
|
|
@ -118,28 +110,14 @@ export function SearchPicker<TOption extends { key: string; value: string }>({
|
|||
value={localQuery}
|
||||
// autoSelect
|
||||
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>
|
||||
</>
|
||||
<Ariakit.ComboboxPopover
|
||||
portal={false} //todo fix focus when set to true
|
||||
gutter={10}
|
||||
// sameWidth
|
||||
gutter={8}
|
||||
sameWidth
|
||||
open={
|
||||
isLoading ||
|
||||
options.length > 0 ||
|
||||
|
|
@ -150,7 +128,7 @@ export function SearchPicker<TOption extends { key: string; value: string }>({
|
|||
autoFocusOnShow={false}
|
||||
modal={false}
|
||||
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
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
import React, { useState, useId } from 'react';
|
||||
import * as Menu from '@ariakit/react/menu';
|
||||
import { Button, DropdownPopup } from '@librechat/client';
|
||||
import { Users, X, ExternalLink, ChevronDown } from 'lucide-react';
|
||||
import type { TPrincipal, TAccessRole, AccessRoleIds } from 'librechat-data-provider';
|
||||
import React from 'react';
|
||||
import { Button, useMediaQuery } from '@librechat/client';
|
||||
import { Users, X, ExternalLink } from 'lucide-react';
|
||||
import { ResourceType } 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 { getRoleLocalizationKeys } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface SelectedPrincipalsListProps {
|
||||
principles: TPrincipal[];
|
||||
onRemoveHandler: (idOnTheSource: string) => void;
|
||||
onRoleChange?: (idOnTheSource: string, newRoleId: AccessRoleIds) => void;
|
||||
availableRoles?: Omit<TAccessRole, 'resourceType'>[];
|
||||
resourceType?: ResourceType;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
|
|
@ -20,13 +20,16 @@ export default function SelectedPrincipalsList({
|
|||
onRemoveHandler,
|
||||
className = '',
|
||||
onRoleChange,
|
||||
availableRoles,
|
||||
resourceType = ResourceType.AGENT,
|
||||
}: SelectedPrincipalsListProps) {
|
||||
const localize = useLocalize();
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
|
||||
const getPrincipalDisplayInfo = (principal: TPrincipal) => {
|
||||
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 };
|
||||
};
|
||||
|
|
@ -34,7 +37,7 @@ export default function SelectedPrincipalsList({
|
|||
if (principles.length === 0) {
|
||||
return (
|
||||
<div className={`space-y-3 ${className}`}>
|
||||
<div className="rounded-lg border border-dashed border-border py-8 text-center text-muted-foreground">
|
||||
<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" />
|
||||
<p className="mt-1 text-xs">{localize('com_ui_search_above_to_add_all')}</p>
|
||||
</div>
|
||||
|
|
@ -50,7 +53,7 @@ export default function SelectedPrincipalsList({
|
|||
return (
|
||||
<div
|
||||
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">
|
||||
<PrincipalAvatar principal={share} size="md" />
|
||||
|
|
@ -71,19 +74,19 @@ export default function SelectedPrincipalsList({
|
|||
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
{!!share.accessRoleId && !!onRoleChange && (
|
||||
<RoleSelector
|
||||
currentRole={share.accessRoleId}
|
||||
<AccessRolesPicker
|
||||
resourceType={resourceType}
|
||||
selectedRoleId={share.accessRoleId}
|
||||
onRoleChange={(newRole) => {
|
||||
onRoleChange?.(share.idOnTheSource!, newRole);
|
||||
}}
|
||||
availableRoles={availableRoles ?? []}
|
||||
className="min-w-0"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
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 })}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
|
|
@ -96,44 +99,3 @@ export default function SelectedPrincipalsList({
|
|||
</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 PeoplePickerSearchItem } from './PeoplePickerSearchItem';
|
||||
export { default as SelectedPrincipalsList } from './SelectedPrincipalsList';
|
||||
export { default as UnifiedPeopleSearch } from './UnifiedPeopleSearch';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue