feat: Refactor FilterPrompts and List components for improved search handling and sorting functionality

This commit is contained in:
Marco Beretta 2025-06-02 23:32:09 +02:00
parent 3e698338aa
commit 84f62eb70c
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
3 changed files with 192 additions and 175 deletions

View file

@ -1,5 +1,5 @@
import { ListFilter, User, Share2 } from 'lucide-react'; import { ListFilter, User, Share2 } from 'lucide-react';
import React, { useState, useCallback, useMemo, useEffect } from 'react'; import React, { useState, useCallback, useMemo } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil'; import { useRecoilValue, useSetRecoilState } from 'recoil';
import { SystemCategories } from 'librechat-data-provider'; import { SystemCategories } from 'librechat-data-provider';
import { usePromptGroupsNav, useLocalize, useCategories } from '~/hooks'; import { usePromptGroupsNav, useLocalize, useCategories } from '~/hooks';
@ -19,7 +19,6 @@ export default function FilterPrompts({
const setCategory = useSetRecoilState(store.promptsCategory); const setCategory = useSetRecoilState(store.promptsCategory);
const categoryFilter = useRecoilValue(store.promptsCategory); const categoryFilter = useRecoilValue(store.promptsCategory);
const { categories } = useCategories('h-4 w-4'); const { categories } = useCategories('h-4 w-4');
const [isSearching, setIsSearching] = useState(false);
const filterOptions = useMemo(() => { const filterOptions = useMemo(() => {
const baseOptions: Option[] = [ const baseOptions: Option[] = [
@ -41,36 +40,27 @@ export default function FilterPrompts({
{ divider: true, value: null }, { divider: true, value: null },
]; ];
const categoryOptions = categories const categoryOptions = categories?.length
? [...categories] ? [...categories]
: [ : [{ value: SystemCategories.NO_CATEGORY, label: localize('com_ui_no_category') }];
{
value: SystemCategories.NO_CATEGORY,
label: localize('com_ui_no_category'),
},
];
return [...baseOptions, ...categoryOptions]; return [...baseOptions, ...categoryOptions];
}, [categories, localize]); }, [categories, localize]);
const onSelect = useCallback( const onSelect = useCallback(
(value: string) => { (value: string) => {
if (value === SystemCategories.ALL) { setCategory(value === SystemCategories.ALL ? '' : value);
setCategory('');
} else {
setCategory(value);
}
}, },
[setCategory], [setCategory],
); );
useEffect(() => { const handleSearchChange = useCallback(
setIsSearching(true); (e: React.ChangeEvent<HTMLInputElement>) => {
const timeout = setTimeout(() => { setDisplayName(e.target.value);
setIsSearching(false); setName(e.target.value);
}, 500); },
return () => clearTimeout(timeout); [setName],
}, [displayName]); );
return ( return (
<div className={cn('flex w-full gap-2 text-text-primary', className)}> <div className={cn('flex w-full gap-2 text-text-primary', className)}>
@ -86,11 +76,7 @@ export default function FilterPrompts({
/> />
<AnimatedSearchInput <AnimatedSearchInput
value={displayName} value={displayName}
onChange={(e) => { onChange={handleSearchChange}
setDisplayName(e.target.value);
setName(e.target.value);
}}
isSearching={isSearching}
placeholder={localize('com_ui_filter_prompts_name')} placeholder={localize('com_ui_filter_prompts_name')}
/> />
</div> </div>

View file

@ -1,65 +1,105 @@
import type { TPromptGroup, TStartupConfig } from 'librechat-data-provider'; import type { TPromptGroup, TStartupConfig } from 'librechat-data-provider';
import { RankablePromptList, RankingProvider } from '~/components/Prompts/Groups/RankingComponent'; import {
RankablePromptList,
SortedPromptList,
RankingProvider,
} from '~/components/Prompts/Groups/RankingComponent';
import DashGroupItem from '~/components/Prompts/Groups/DashGroupItem'; import DashGroupItem from '~/components/Prompts/Groups/DashGroupItem';
import ChatGroupItem from '~/components/Prompts/Groups/ChatGroupItem'; import ChatGroupItem from '~/components/Prompts/Groups/ChatGroupItem';
import { useGetStartupConfig } from '~/data-provider'; import { useGetStartupConfig } from '~/data-provider';
import { Button, Skeleton } from '~/components/ui'; import { Skeleton } from '~/components/ui';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import { useMemo } from 'react';
interface ListProps {
groups?: TPromptGroup[];
isChatRoute: boolean;
isLoading: boolean;
enableRanking?: boolean;
}
export default function List({ export default function List({
groups = [], groups = [],
isChatRoute, isChatRoute,
isLoading, isLoading,
enableRanking = true, enableRanking = true,
}: { }: ListProps) {
groups?: TPromptGroup[];
isChatRoute: boolean;
isLoading: boolean;
enableRanking?: boolean;
}) {
const localize = useLocalize(); const localize = useLocalize();
const { data: startupConfig = {} as Partial<TStartupConfig> } = useGetStartupConfig(); const { data: startupConfig = {} as Partial<TStartupConfig> } = useGetStartupConfig();
const { instanceProjectId } = startupConfig; const { instanceProjectId } = startupConfig;
const renderGroupItem = (group: TPromptGroup) => { const renderGroupItem = useMemo(
() => (group: TPromptGroup) => {
const Component = isChatRoute ? ChatGroupItem : DashGroupItem;
return <Component key={group._id} group={group} instanceProjectId={instanceProjectId} />;
},
[isChatRoute, instanceProjectId],
);
const emptyMessage = localize('com_ui_nothing_found');
if (isLoading) {
return (
<RankingProvider>
<div className="flex h-full flex-col">
<div className="flex-grow overflow-y-auto">
<div className="overflow-y-auto overflow-x-hidden">
{isChatRoute ? (
<Skeleton className="my-2 flex h-[84px] w-full rounded-2xl border-0 px-3 pb-4 pt-3" />
) : (
Array.from({ length: 10 }).map((_, index) => (
<Skeleton
key={index}
className="w-100 mx-2 my-2 flex h-14 rounded-lg border-0 p-4"
/>
))
)}
</div>
</div>
</div>
</RankingProvider>
);
}
if (groups.length === 0) {
return (
<RankingProvider>
<div className="flex h-full flex-col">
<div className="flex-grow overflow-y-auto">
<div className="overflow-y-auto overflow-x-hidden">
{isChatRoute ? (
<div className="my-2 flex h-[84px] w-full items-center justify-center rounded-2xl border border-border-light bg-transparent px-3 pb-4 pt-3 text-text-primary">
{emptyMessage}
</div>
) : (
<div className="my-12 flex w-full items-center justify-center text-lg font-semibold text-text-primary">
{emptyMessage}
</div>
)}
</div>
</div>
</div>
</RankingProvider>
);
}
const shouldUseRanking = !isChatRoute && enableRanking;
const renderContent = () => {
if (isChatRoute) { if (isChatRoute) {
return <ChatGroupItem key={group._id} group={group} instanceProjectId={instanceProjectId} />; return <SortedPromptList groups={groups} renderItem={renderGroupItem} />;
} }
return <DashGroupItem key={group._id} group={group} instanceProjectId={instanceProjectId} />; if (shouldUseRanking) {
return <RankablePromptList groups={groups} renderItem={renderGroupItem} />;
}
return groups.map((group) => renderGroupItem(group));
}; };
return ( return (
<RankingProvider> <RankingProvider>
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="flex-grow overflow-y-auto"> <div className="flex-grow overflow-y-auto">
<div className="overflow-y-auto overflow-x-hidden"> <div className="overflow-y-auto overflow-x-hidden">{renderContent()}</div>
{isLoading && isChatRoute && (
<Skeleton className="my-2 flex h-[84px] w-full rounded-2xl border-0 px-3 pb-4 pt-3" />
)}
{isLoading &&
!isChatRoute &&
Array.from({ length: 10 }).map((_, index: number) => (
<Skeleton
key={index}
className="w-100 mx-2 my-2 flex h-14 rounded-lg border-0 p-4"
/>
))}
{!isLoading && groups.length === 0 && isChatRoute && (
<div className="my-2 flex h-[84px] w-full items-center justify-center rounded-2xl border border-border-light bg-transparent px-3 pb-4 pt-3 text-text-primary">
{localize('com_ui_nothing_found')}
</div>
)}
{!isLoading && groups.length === 0 && !isChatRoute && (
<div className="my-12 flex w-full items-center justify-center text-lg font-semibold text-text-primary">
{localize('com_ui_nothing_found')}
</div>
)}
{!isLoading && groups.length > 0 && !isChatRoute && enableRanking ? (
<RankablePromptList groups={groups} renderItem={renderGroupItem} />
) : (
!isLoading && groups.map((group) => renderGroupItem(group))
)}
</div>
</div> </div>
</div> </div>
</RankingProvider> </RankingProvider>

View file

@ -1,9 +1,10 @@
import React, { useCallback, useEffect, useState, useRef, ReactNode } from 'react'; import React, { useCallback, useEffect, useState, useRef, ReactNode } from 'react';
import { DndProvider, useDrag, useDrop, useDragLayer } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { GripVertical } from 'lucide-react'; import { GripVertical } from 'lucide-react';
import { useUpdatePromptRankings, useGetUserPromptPreferences } from '~/data-provider'; import { useDrag, useDrop, useDragLayer } from 'react-dnd';
import type { TPromptGroup } from 'librechat-data-provider'; import type { TPromptGroup } from 'librechat-data-provider';
import { useUpdatePromptRankings, useGetUserPromptPreferences } from '~/data-provider';
import CategoryIcon from './CategoryIcon';
import { Label } from '~/components';
import { cn } from '~/utils'; import { cn } from '~/utils';
const ITEM_TYPE = 'PROMPT_GROUP'; const ITEM_TYPE = 'PROMPT_GROUP';
@ -20,8 +21,31 @@ interface DragItem {
index: number; index: number;
id: string; id: string;
type: string; type: string;
group: TPromptGroup;
} }
const sortGroups = (groups: TPromptGroup[], rankings: any[], favorites: string[]) => {
const rankingMap = new Map(rankings.map((ranking) => [ranking.promptGroupId, ranking.order]));
return [...groups].sort((a, b) => {
const aId = a._id ?? '';
const bId = b._id ?? '';
const aIsFavorite = favorites.includes(aId);
const bIsFavorite = favorites.includes(bId);
if (aIsFavorite && !bIsFavorite) return -1;
if (!aIsFavorite && bIsFavorite) return 1;
const aRank = rankingMap.get(aId);
const bRank = rankingMap.get(bId);
if (aRank !== undefined && bRank !== undefined) return aRank - bRank;
if (aRank !== undefined) return -1;
if (bRank !== undefined) return 1;
return a.name.localeCompare(b.name);
});
};
function DraggablePromptItem({ function DraggablePromptItem({
group, group,
index, index,
@ -30,46 +54,33 @@ function DraggablePromptItem({
children, children,
}: DraggablePromptItemProps) { }: DraggablePromptItemProps) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const [isOver, setIsOver] = useState(false);
const [{ isDragging }, drag, preview] = useDrag({ const [{ isDragging }, drag, preview] = useDrag({
type: ITEM_TYPE, type: ITEM_TYPE,
item: () => ({ type: ITEM_TYPE, index, id: group._id }), item: { type: ITEM_TYPE, index, id: group._id, group },
collect: (monitor) => ({ collect: (monitor) => ({ isDragging: monitor.isDragging() }),
isDragging: monitor.isDragging(),
}),
}); });
const [{ isOverCurrent }, drop] = useDrop<DragItem, void, { isOverCurrent: boolean }>({ const [{ isOver }, drop] = useDrop<DragItem, void, { isOver: boolean }>({
accept: ITEM_TYPE, accept: ITEM_TYPE,
hover: (item, monitor) => { hover: (item, monitor) => {
if (!ref.current) return; if (!ref.current || item.index === index) return;
const dragIndex = item.index;
const hoverIndex = index;
if (dragIndex === hoverIndex) return;
const hoverBoundingRect = ref.current.getBoundingClientRect(); const hoverBoundingRect = ref.current.getBoundingClientRect();
const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; const hoverMiddleY = hoverBoundingRect.height / 2;
const clientOffset = monitor.getClientOffset(); const clientOffset = monitor.getClientOffset();
if (!clientOffset) return; if (!clientOffset) return;
const hoverClientY = clientOffset.y - hoverBoundingRect.top; const hoverClientY = clientOffset.y - hoverBoundingRect.top;
if (item.index < index && hoverClientY < hoverMiddleY * 0.8) return;
if (item.index > index && hoverClientY > hoverMiddleY * 1.2) return;
if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY * 0.8) return; moveItem(item.index, index);
if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY * 1.2) return; item.index = index;
moveItem(dragIndex, hoverIndex);
item.index = hoverIndex;
}, },
collect: (monitor) => ({ collect: (monitor) => ({ isOver: monitor.isOver({ shallow: true }) }),
isOverCurrent: monitor.isOver({ shallow: true }),
}),
}); });
useEffect(() => {
setIsOver(isOverCurrent);
}, [isOverCurrent]);
drag(drop(ref)); drag(drop(ref));
useEffect(() => { useEffect(() => {
@ -85,15 +96,11 @@ function DraggablePromptItem({
isAnyDragging && !isDragging && 'transition-transform', isAnyDragging && !isDragging && 'transition-transform',
isOver && !isDragging && 'scale-[1.02]', isOver && !isDragging && 'scale-[1.02]',
)} )}
style={{ style={{ cursor: isDragging ? 'grabbing' : 'grab' }}
transform: isDragging ? 'scale(1.05)' : 'scale(1)',
cursor: isDragging ? 'grabbing' : 'grab',
}}
> >
<div <div
className={cn( className={cn(
'absolute left-2 top-1/2 z-10 -translate-y-1/2 transition-all duration-200', 'absolute left-2 top-1/2 z-10 -translate-y-1/2 opacity-0 group-hover:opacity-100',
'opacity-0 group-hover:opacity-100',
isDragging && 'opacity-100', isDragging && 'opacity-100',
)} )}
> >
@ -105,35 +112,14 @@ function DraggablePromptItem({
} }
function CustomDragLayer() { function CustomDragLayer() {
const { item, itemType, currentOffset, isDragging } = useDragLayer((monitor) => ({ const { itemType, item, currentOffset, isDragging } = useDragLayer((monitor) => ({
item: monitor.getItem(),
itemType: monitor.getItemType(), itemType: monitor.getItemType(),
item: monitor.getItem() as DragItem,
currentOffset: monitor.getSourceClientOffset(), currentOffset: monitor.getSourceClientOffset(),
isDragging: monitor.isDragging(), isDragging: monitor.isDragging(),
})); }));
if (!isDragging || !currentOffset || itemType !== ITEM_TYPE) { if (!isDragging || !currentOffset || itemType !== ITEM_TYPE || !item?.group) return null;
return null;
}
const renderPreview = () => {
if (item && typeof item.id === 'string') {
return (
<div
style={{
backgroundColor: 'rgba(230, 245, 255, 0.9)',
padding: '10px',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
display: 'inline-block',
}}
>
{`Moving: ${item.id}`}
</div>
);
}
return <div>Dragging...</div>;
};
return ( return (
<div <div
@ -152,12 +138,56 @@ function CustomDragLayer() {
transform: `translate(${currentOffset.x}px, ${currentOffset.y}px)`, transform: `translate(${currentOffset.x}px, ${currentOffset.y}px)`,
}} }}
> >
{renderPreview()} <div className="mx-2 my-2 flex h-[60px] w-[430px] min-w-[300px] cursor-pointer rounded-lg border border-border-light bg-surface-primary p-3 opacity-90 shadow-lg">
<div className="flex items-center gap-2 truncate pr-2">
<CategoryIcon
category={item.group.category ?? ''}
className="icon-lg"
aria-hidden="true"
/>
<Label className="text-md cursor-pointer truncate font-semibold text-text-primary">
{item.group.name}
</Label>
</div>
</div>
</div> </div>
</div> </div>
); );
} }
function SortedPromptList({
groups,
renderItem,
}: {
groups: TPromptGroup[];
renderItem: (group: TPromptGroup) => ReactNode;
}) {
const { data: preferences } = useGetUserPromptPreferences();
const [sortedGroups, setSortedGroups] = useState<TPromptGroup[]>([]);
useEffect(() => {
if (!groups?.length) {
setSortedGroups([]);
return;
}
const rankings = preferences?.rankings || [];
const favorites = preferences?.favorites || [];
setSortedGroups(sortGroups(groups, rankings, favorites));
}, [groups, preferences]);
return (
<div className="space-y-2">
{sortedGroups.map((group) => (
<div key={group._id} className="transition-all duration-300 ease-in-out">
{renderItem(group)}
</div>
))}
</div>
);
}
interface RankablePromptListProps { interface RankablePromptListProps {
groups: TPromptGroup[]; groups: TPromptGroup[];
renderItem: (group: TPromptGroup) => ReactNode; renderItem: (group: TPromptGroup) => ReactNode;
@ -167,7 +197,6 @@ interface RankablePromptListProps {
function RankablePromptList({ groups, renderItem, onRankingChange }: RankablePromptListProps) { function RankablePromptList({ groups, renderItem, onRankingChange }: RankablePromptListProps) {
const { data: preferences } = useGetUserPromptPreferences(); const { data: preferences } = useGetUserPromptPreferences();
const updateRankings = useUpdatePromptRankings(); const updateRankings = useUpdatePromptRankings();
const [sortedGroups, setSortedGroups] = useState<TPromptGroup[]>([]); const [sortedGroups, setSortedGroups] = useState<TPromptGroup[]>([]);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null); const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
@ -177,54 +206,29 @@ function RankablePromptList({ groups, renderItem, onRankingChange }: RankablePro
setSortedGroups([]); setSortedGroups([]);
return; return;
} }
const rankings = preferences?.rankings || []; const rankings = preferences?.rankings || [];
const favorites = preferences?.favorites || []; const favorites = preferences?.favorites || [];
const rankingMap = new Map(rankings.map((ranking) => [ranking.promptGroupId, ranking.order])); setSortedGroups(sortGroups(groups, rankings, favorites));
const sorted = [...groups].sort((a, b) => {
const aId = a._id ?? '';
const bId = b._id ?? '';
const aIsFavorite = favorites.includes(aId);
const bIsFavorite = favorites.includes(bId);
if (aIsFavorite && !bIsFavorite) return -1;
if (!aIsFavorite && bIsFavorite) return 1;
const aRank = rankingMap.get(aId);
const bRank = rankingMap.get(bId);
if (aRank !== undefined && bRank !== undefined) {
return aRank - bRank;
}
if (aRank !== undefined) return -1;
if (bRank !== undefined) return 1;
return a.name.localeCompare(b.name);
});
setSortedGroups(sorted);
}, [groups, preferences]); }, [groups, preferences]);
const moveItem = useCallback( const moveItem = useCallback(
(dragIndex: number, hoverIndex: number) => { (dragIndex: number, hoverIndex: number) => {
if (dragIndex === hoverIndex) return; if (dragIndex === hoverIndex) return;
setSortedGroups((prevGroups) => { setSortedGroups((prevGroups) => {
const newGroups = [...prevGroups]; const newGroups = [...prevGroups];
const draggedItem = newGroups[dragIndex]; const [draggedItem] = newGroups.splice(dragIndex, 1);
newGroups.splice(dragIndex, 1);
newGroups.splice(hoverIndex, 0, draggedItem); newGroups.splice(hoverIndex, 0, draggedItem);
return newGroups; return newGroups;
}); });
if (saveTimeoutRef.current) { if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
clearTimeout(saveTimeoutRef.current);
}
saveTimeoutRef.current = setTimeout(() => { saveTimeoutRef.current = setTimeout(() => {
setSortedGroups((currentGroups) => { setSortedGroups((currentGroups) => {
const newRankings = currentGroups const newRankings = currentGroups
.map((group, index) => .map((group, index) => (group._id ? { promptGroupId: group._id, order: index } : null))
typeof group._id === 'string' ? { promptGroupId: group._id, order: index } : null,
)
.filter( .filter(
(ranking): ranking is { promptGroupId: string; order: number } => ranking !== null, (ranking): ranking is { promptGroupId: string; order: number } => ranking !== null,
); );
@ -232,12 +236,8 @@ function RankablePromptList({ groups, renderItem, onRankingChange }: RankablePro
if (newRankings.length > 0) { if (newRankings.length > 0) {
updateRankings updateRankings
.mutateAsync({ rankings: newRankings }) .mutateAsync({ rankings: newRankings })
.then(() => { .then(() => onRankingChange?.(newRankings.map((r) => r.promptGroupId)))
onRankingChange?.(newRankings.map((r) => r.promptGroupId)); .catch(console.error);
})
.catch((error) => {
console.error('Failed to update rankings:', error);
});
} }
return currentGroups; return currentGroups;
}); });
@ -246,14 +246,6 @@ function RankablePromptList({ groups, renderItem, onRankingChange }: RankablePro
[updateRankings, onRankingChange], [updateRankings, onRankingChange],
); );
useEffect(() => {
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
};
}, []);
useEffect(() => { useEffect(() => {
const handleDragStart = () => setIsDragging(true); const handleDragStart = () => setIsDragging(true);
const handleDragEnd = () => setIsDragging(false); const handleDragEnd = () => setIsDragging(false);
@ -264,6 +256,7 @@ function RankablePromptList({ groups, renderItem, onRankingChange }: RankablePro
return () => { return () => {
document.removeEventListener('dragstart', handleDragStart); document.removeEventListener('dragstart', handleDragStart);
document.removeEventListener('dragend', handleDragEnd); document.removeEventListener('dragend', handleDragEnd);
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
}; };
}, []); }, []);
@ -273,9 +266,7 @@ function RankablePromptList({ groups, renderItem, onRankingChange }: RankablePro
<div <div
key={group._id || index} key={group._id || index}
className="transition-all duration-300 ease-in-out" className="transition-all duration-300 ease-in-out"
style={{ style={{ transform: `translateY(${isDragging ? '2px' : '0'})` }}
transform: `translateY(${isDragging ? '2px' : '0'})`,
}}
> >
<DraggablePromptItem <DraggablePromptItem
group={group} group={group}
@ -293,11 +284,11 @@ function RankablePromptList({ groups, renderItem, onRankingChange }: RankablePro
function RankingProvider({ children }: { children: ReactNode }) { function RankingProvider({ children }: { children: ReactNode }) {
return ( return (
<DndProvider backend={HTML5Backend}> <div>
<CustomDragLayer /> <CustomDragLayer />
{children} {children}
</DndProvider> </div>
); );
} }
export { RankablePromptList, RankingProvider }; export { RankablePromptList, SortedPromptList, RankingProvider };