mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-18 01:10:14 +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
|
|
@ -1,8 +1,11 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
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 {
|
||||
Label,
|
||||
Button,
|
||||
Spinner,
|
||||
Skeleton,
|
||||
OGDialog,
|
||||
OGDialogTitle,
|
||||
OGDialogClose,
|
||||
|
|
@ -17,11 +20,10 @@ import {
|
|||
useCopyToClipboard,
|
||||
useLocalize,
|
||||
} from '~/hooks';
|
||||
import GenericManagePermissionsDialog from './GenericManagePermissionsDialog';
|
||||
import UnifiedPeopleSearch from './PeoplePicker/UnifiedPeopleSearch';
|
||||
import PublicSharingToggle from './PublicSharingToggle';
|
||||
import AccessRolesPicker from './AccessRolesPicker';
|
||||
import { cn, removeFocusOutlines } from '~/utils';
|
||||
import { PeoplePicker } from './PeoplePicker';
|
||||
import { SelectedPrincipalsList } from './PeoplePicker';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function GenericGrantAccessDialog({
|
||||
resourceName,
|
||||
|
|
@ -49,6 +51,9 @@ export default function GenericGrantAccessDialog({
|
|||
const { hasPeoplePickerAccess, peoplePickerTypeFilter } = usePeoplePickerPermissions();
|
||||
const {
|
||||
config,
|
||||
permissionsData,
|
||||
isLoadingPermissions,
|
||||
permissionsError,
|
||||
updatePermissionsMutation,
|
||||
currentShares,
|
||||
currentIsPublic,
|
||||
|
|
@ -59,11 +64,22 @@ export default function GenericGrantAccessDialog({
|
|||
setPublicRole,
|
||||
} = 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>(
|
||||
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 copyResourceUrl = useCopyToClipboard({ text: resourceUrl });
|
||||
|
||||
|
|
@ -76,21 +92,88 @@ export default function GenericGrantAccessDialog({
|
|||
return null;
|
||||
}
|
||||
|
||||
const handleGrantAccess = async () => {
|
||||
try {
|
||||
const sharesToAdd = newShares.map((share) => ({
|
||||
...share,
|
||||
accessRoleId: defaultPermissionId,
|
||||
}));
|
||||
// Handler for adding users from search (immediate add to unified list)
|
||||
const handleAddFromSearch = (newShares: TPrincipal[]) => {
|
||||
const sharesToAdd = newShares.filter(
|
||||
(newShare) =>
|
||||
!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({
|
||||
resourceType,
|
||||
resourceId: resourceDbId,
|
||||
data: {
|
||||
updated: sharesToAdd,
|
||||
removed: [],
|
||||
updated,
|
||||
removed,
|
||||
public: isPublic,
|
||||
publicAccessRoleId: isPublic ? publicRole : undefined,
|
||||
},
|
||||
|
|
@ -101,65 +184,74 @@ export default function GenericGrantAccessDialog({
|
|||
}
|
||||
|
||||
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',
|
||||
});
|
||||
|
||||
setNewShares([]);
|
||||
setDefaultPermissionId(config?.defaultViewerRoleId);
|
||||
setIsPublic(false);
|
||||
setPublicRole(config?.defaultViewerRoleId);
|
||||
setIsModalOpen(false);
|
||||
setHasChanges(false);
|
||||
} catch (error) {
|
||||
console.error('Error granting access:', error);
|
||||
console.error('Error updating permissions:', error);
|
||||
showToast({
|
||||
message: 'Failed to grant access. Please try again.',
|
||||
message: localize('com_ui_permissions_failed_update'),
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setNewShares([]);
|
||||
// Reset to original state
|
||||
const shares = permissionsData?.principals || [];
|
||||
setAllShares(shares.map((share) => ({ ...share, isExisting: true })));
|
||||
setDefaultPermissionId(config?.defaultViewerRoleId);
|
||||
setIsPublic(false);
|
||||
setPublicRole(config?.defaultViewerRoleId);
|
||||
setIsPublic(currentIsPublic);
|
||||
setPublicRole(currentPublicRole || config?.defaultViewerRoleId || '');
|
||||
setHasChanges(false);
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
// Validation and calculated values
|
||||
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 ? (
|
||||
children
|
||||
) : (
|
||||
<button
|
||||
className={cn(
|
||||
'btn btn-neutral border-token-border-light relative h-9 rounded-lg font-medium',
|
||||
removeFocusOutlines,
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
aria-label={localize('com_ui_share_var', {
|
||||
0: config?.getShareMessage(resourceName),
|
||||
})}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2 text-blue-500">
|
||||
<Share2Icon className="icon-md h-4 w-4" />
|
||||
<div className="flex min-w-[32px] items-center justify-center gap-2 text-blue-500">
|
||||
<span className="flex h-6 w-6 items-center justify-center">
|
||||
<Share2Icon className="icon-md h-4 w-4" />
|
||||
</span>
|
||||
{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>
|
||||
<Label className="text-sm font-medium text-text-secondary">{totalCurrentShares}</Label>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<OGDialog open={isModalOpen} onOpenChange={setIsModalOpen} modal>
|
||||
<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">
|
||||
|
|
@ -171,51 +263,88 @@ export default function GenericGrantAccessDialog({
|
|||
</OGDialogTitle>
|
||||
|
||||
<div className="space-y-6 p-2">
|
||||
{hasPeoplePickerAccess && (
|
||||
<>
|
||||
<PeoplePicker
|
||||
onSelectionChange={setNewShares}
|
||||
placeholder={localize('com_ui_search_people_placeholder')}
|
||||
typeFilter={peoplePickerTypeFilter}
|
||||
/>
|
||||
{/* Unified Search and Management Section */}
|
||||
<div className="space-y-4">
|
||||
{/* Search Bar with Default Permission Setting */}
|
||||
{hasPeoplePickerAccess && (
|
||||
<div className="space-y-2">
|
||||
<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">
|
||||
<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}
|
||||
<UnifiedPeopleSearch
|
||||
onAddPeople={handleAddFromSearch}
|
||||
placeholder={localize('com_ui_search_people_placeholder')}
|
||||
typeFilter={peoplePickerTypeFilter}
|
||||
excludeIds={allShares.map((s) => s.idOnTheSource)}
|
||||
/>
|
||||
|
||||
{/* 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 className="flex border-t border-border-light" />
|
||||
|
||||
{/* Public Access Section */}
|
||||
<PublicSharingToggle
|
||||
isPublic={isPublic}
|
||||
publicRole={publicRole}
|
||||
onPublicToggle={setIsPublic}
|
||||
onPublicRoleChange={setPublicRole}
|
||||
onPublicToggle={handlePublicToggle}
|
||||
onPublicRoleChange={handlePublicRoleChange}
|
||||
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">
|
||||
{hasPeoplePickerAccess && (
|
||||
<GenericManagePermissionsDialog
|
||||
resourceDbId={resourceDbId}
|
||||
resourceName={resourceName}
|
||||
resourceType={resourceType}
|
||||
/>
|
||||
)}
|
||||
{resourceId && resourceUrl && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (isCopying) return;
|
||||
copyResourceUrl(setIsCopying);
|
||||
|
|
@ -244,17 +373,21 @@ export default function GenericGrantAccessDialog({
|
|||
</Button>
|
||||
</OGDialogClose>
|
||||
<Button
|
||||
onClick={handleGrantAccess}
|
||||
disabled={updatePermissionsMutation.isLoading || !submitButtonActive}
|
||||
onClick={handleSave}
|
||||
disabled={
|
||||
updatePermissionsMutation.isLoading ||
|
||||
!submitButtonActive ||
|
||||
(hasChanges && !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_granting')}
|
||||
<Spinner className="h-4 w-4" />
|
||||
{localize('com_ui_saving')}
|
||||
</div>
|
||||
) : (
|
||||
localize('com_ui_grant_access')
|
||||
localize('com_ui_save_changes')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue