refactor: DataTable and ArchivedChats; fix: sorting ArchivedChats API

This commit is contained in:
Marco Beretta 2025-09-15 22:51:34 +02:00
parent 8e22e70439
commit a253aa5d22
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
5 changed files with 2296 additions and 549 deletions

View file

@ -28,7 +28,7 @@ const getConvo = async (user, conversationId) => {
return await Conversation.findOne({ user, conversationId }).lean(); return await Conversation.findOne({ user, conversationId }).lean();
} catch (error) { } catch (error) {
logger.error('[getConvo] Error getting single conversation', error); logger.error('[getConvo] Error getting single conversation', error);
return { message: 'Error getting single conversation' }; throw new Error('Error getting single conversation');
} }
}; };
@ -151,13 +151,21 @@ module.exports = {
const result = await Conversation.bulkWrite(bulkOps); const result = await Conversation.bulkWrite(bulkOps);
return result; return result;
} catch (error) { } catch (error) {
logger.error('[saveBulkConversations] Error saving conversations in bulk', error); logger.error('[bulkSaveConvos] Error saving conversations in bulk', error);
throw new Error('Failed to save conversations in bulk.'); throw new Error('Failed to save conversations in bulk.');
} }
}, },
getConvosByCursor: async ( getConvosByCursor: async (
user, user,
{ cursor, limit = 25, isArchived = false, tags, search, order = 'desc' } = {}, {
cursor,
limit = 25,
isArchived = false,
tags,
search,
sortBy = 'createdAt',
sortDirection = 'desc',
} = {},
) => { ) => {
const filters = [{ user }]; const filters = [{ user }];
if (isArchived) { if (isArchived) {
@ -184,35 +192,77 @@ module.exports = {
filters.push({ conversationId: { $in: matchingIds } }); filters.push({ conversationId: { $in: matchingIds } });
} catch (error) { } catch (error) {
logger.error('[getConvosByCursor] Error during meiliSearch', error); logger.error('[getConvosByCursor] Error during meiliSearch', error);
return { message: 'Error during meiliSearch' }; throw new Error('Error during meiliSearch');
} }
} }
const validSortFields = ['title', 'createdAt', 'updatedAt'];
if (!validSortFields.includes(sortBy)) {
throw new Error(
`Invalid sortBy field: ${sortBy}. Must be one of ${validSortFields.join(', ')}`,
);
}
const finalSortBy = sortBy;
const finalSortDirection = sortDirection === 'asc' ? 'asc' : 'desc';
let cursorFilter = null;
if (cursor) { if (cursor) {
filters.push({ updatedAt: { $lt: new Date(cursor) } }); try {
const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString());
const { primary, secondary } = decoded;
const primaryValue = finalSortBy === 'title' ? primary : new Date(primary);
const secondaryValue = new Date(secondary);
const op = finalSortDirection === 'asc' ? '$gt' : '$lt';
cursorFilter = {
$or: [
{ [finalSortBy]: { [op]: primaryValue } },
{
[finalSortBy]: primaryValue,
updatedAt: { [op]: secondaryValue },
},
],
};
} catch (err) {
logger.warn('[getConvosByCursor] Invalid cursor format, starting from beginning');
}
if (cursorFilter) {
filters.push(cursorFilter);
}
} }
const query = filters.length === 1 ? filters[0] : { $and: filters }; const query = filters.length === 1 ? filters[0] : { $and: filters };
try { try {
const sortOrder = finalSortDirection === 'asc' ? 1 : -1;
const sortObj = { [finalSortBy]: sortOrder };
if (finalSortBy !== 'updatedAt') {
sortObj.updatedAt = sortOrder;
}
const convos = await Conversation.find(query) const convos = await Conversation.find(query)
.select( .select(
'conversationId endpoint title createdAt updatedAt user model agent_id assistant_id spec iconURL', 'conversationId endpoint title createdAt updatedAt user model agent_id assistant_id spec iconURL',
) )
.sort({ updatedAt: order === 'asc' ? 1 : -1 }) .sort(sortObj)
.limit(limit + 1) .limit(limit + 1)
.lean(); .lean();
let nextCursor = null; let nextCursor = null;
if (convos.length > limit) { if (convos.length > limit) {
const lastConvo = convos.pop(); const lastConvo = convos.pop();
nextCursor = lastConvo.updatedAt.toISOString(); const primaryValue = lastConvo[finalSortBy];
const primaryStr = finalSortBy === 'title' ? primaryValue : primaryValue.toISOString();
const secondaryStr = lastConvo.updatedAt.toISOString();
const composite = { primary: primaryStr, secondary: secondaryStr };
nextCursor = Buffer.from(JSON.stringify(composite)).toString('base64');
} }
return { conversations: convos, nextCursor }; return { conversations: convos, nextCursor };
} catch (error) { } catch (error) {
logger.error('[getConvosByCursor] Error getting conversations', error); logger.error('[getConvosByCursor] Error getting conversations', error);
return { message: 'Error getting conversations' }; throw new Error('Error getting conversations');
} }
}, },
getConvosQueried: async (user, convoIds, cursor = null, limit = 25) => { getConvosQueried: async (user, convoIds, cursor = null, limit = 25) => {
@ -252,7 +302,7 @@ module.exports = {
return { conversations: limited, nextCursor, convoMap }; return { conversations: limited, nextCursor, convoMap };
} catch (error) { } catch (error) {
logger.error('[getConvosQueried] Error getting conversations', error); logger.error('[getConvosQueried] Error getting conversations', error);
return { message: 'Error fetching conversations' }; throw new Error('Error fetching conversations');
} }
}, },
getConvo, getConvo,
@ -269,7 +319,7 @@ module.exports = {
} }
} catch (error) { } catch (error) {
logger.error('[getConvoTitle] Error getting conversation title', error); logger.error('[getConvoTitle] Error getting conversation title', error);
return { message: 'Error getting conversation title' }; throw new Error('Error getting conversation title');
} }
}, },
/** /**

View file

@ -30,7 +30,8 @@ router.get('/', async (req, res) => {
const cursor = req.query.cursor; const cursor = req.query.cursor;
const isArchived = isEnabled(req.query.isArchived); const isArchived = isEnabled(req.query.isArchived);
const search = req.query.search ? decodeURIComponent(req.query.search) : undefined; const search = req.query.search ? decodeURIComponent(req.query.search) : undefined;
const order = req.query.order || 'desc'; const sortBy = req.query.sortBy || 'createdAt';
const sortDirection = req.query.sortDirection || 'desc';
let tags; let tags;
if (req.query.tags) { if (req.query.tags) {
@ -44,7 +45,8 @@ router.get('/', async (req, res) => {
isArchived, isArchived,
tags, tags,
search, search,
order, sortBy,
sortDirection,
}); });
res.status(200).json(result); res.status(200).json(result);
} catch (error) { } catch (error) {

View file

@ -12,7 +12,6 @@ import {
Label, Label,
TooltipAnchor, TooltipAnchor,
Spinner, Spinner,
DataTable,
useToastContext, useToastContext,
useMediaQuery, useMediaQuery,
} from '@librechat/client'; } from '@librechat/client';
@ -26,6 +25,7 @@ import { MinimalIcon } from '~/components/Endpoints';
import { NotificationSeverity } from '~/common'; import { NotificationSeverity } from '~/common';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import { formatDate } from '~/utils'; import { formatDate } from '~/utils';
import DataTable from './DataTable';
const DEFAULT_PARAMS = { const DEFAULT_PARAMS = {
isArchived: true, isArchived: true,
@ -44,13 +44,12 @@ const defaultSort: SortingState = [
}, },
]; ];
// Define the table column type for better type safety
// (kept from your original code)
type TableColumn<TData, TValue> = ColumnDef<TData, TValue> & { type TableColumn<TData, TValue> = ColumnDef<TData, TValue> & {
meta?: { meta?: {
size?: string | number; size?: string | number;
mobileSize?: string | number; mobileSize?: string | number;
minWidth?: string | number; minWidth?: string | number;
priority?: number;
}; };
}; };
@ -86,17 +85,16 @@ export default function ArchivedChatsTable() {
})); }));
}, []); }, []);
// Robust against stale state; keeps UI sort in sync with backend defaults
const handleSortingChange = useCallback( const handleSortingChange = useCallback(
(updater: SortingState | ((old: SortingState) => SortingState)) => { (updater: SortingState | ((old: SortingState) => SortingState)) => {
setSorting((prev) => { setSorting((prev) => {
const next = typeof updater === 'function' ? updater(prev) : updater; const next = typeof updater === 'function' ? updater(prev) : updater;
// If user clears sorting, fall back to default both in UI and query const coerced = next;
const coerced = next.length === 0 ? defaultSort : next;
const primary = coerced[0]; const primary = coerced[0];
setQueryParams((p) => { setQueryParams((p) => {
const newParams = (() => {
if (primary && isSortKey(primary.id)) { if (primary && isSortKey(primary.id)) {
return { return {
...p, ...p,
@ -104,18 +102,24 @@ export default function ArchivedChatsTable() {
sortDirection: primary.desc ? 'desc' : 'asc', sortDirection: primary.desc ? 'desc' : 'asc',
}; };
} }
// Fallback if id isn't one of the permitted keys
return { return {
...p, ...p,
sortBy: 'createdAt', sortBy: 'createdAt',
sortDirection: 'desc', sortDirection: 'desc',
}; };
})();
setTimeout(() => {
refetch();
}, 0);
return newParams;
}); });
return coerced; return coerced;
}); });
}, },
[setQueryParams, setSorting], [setQueryParams, setSorting, refetch],
); );
const handleError = useCallback( const handleError = useCallback(
@ -189,13 +193,7 @@ export default function ArchivedChatsTable() {
cell: ({ row }) => { cell: ({ row }) => {
const { conversationId, title } = row.original; const { conversationId, title } = row.original;
return ( return (
<a <div className="flex items-center gap-2">
href={`/c/${conversationId}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 truncate underline"
aria-label={localize('com_ui_open_conversation', { 0: title })}
>
<MinimalIcon <MinimalIcon
endpoint={row.original.endpoint} endpoint={row.original.endpoint}
size={28} size={28}
@ -203,13 +201,21 @@ export default function ArchivedChatsTable() {
iconClassName="size-4" iconClassName="size-4"
aria-hidden="true" aria-hidden="true"
/> />
<span>{title}</span> <a
href={`/c/${conversationId}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center truncate underline"
aria-label={localize('com_ui_open_conversation', { 0: title })}
>
{title}
</a> </a>
</div>
); );
}, },
meta: { meta: {
size: isSmallScreen ? '70%' : '50%', priority: 3,
mobileSize: '70%', minWidth: 'min-content',
}, },
enableSorting: true, enableSorting: true,
}, },
@ -220,8 +226,8 @@ export default function ArchivedChatsTable() {
), ),
cell: ({ row }) => formatDate(row.original.createdAt?.toString() ?? '', isSmallScreen), cell: ({ row }) => formatDate(row.original.createdAt?.toString() ?? '', isSmallScreen),
meta: { meta: {
size: isSmallScreen ? '30%' : '35%', priority: 2,
mobileSize: '30%', minWidth: '80px',
}, },
enableSorting: true, enableSorting: true,
}, },
@ -265,7 +271,7 @@ export default function ArchivedChatsTable() {
description={localize('com_ui_delete')} description={localize('com_ui_delete')}
render={ render={
<Button <Button
variant="ghost" variant="destructive"
className="h-8 w-8 p-0 hover:bg-surface-hover" className="h-8 w-8 p-0 hover:bg-surface-hover"
onClick={() => { onClick={() => {
setDeleteRow(row.original); setDeleteRow(row.original);
@ -281,8 +287,8 @@ export default function ArchivedChatsTable() {
); );
}, },
meta: { meta: {
size: '15%', priority: 1,
mobileSize: '25%', minWidth: '120px',
}, },
enableSorting: false, enableSorting: false,
}, },

File diff suppressed because it is too large Load diff

View file

@ -31,6 +31,7 @@ export * from './InputOTP';
export * from './MultiSearch'; export * from './MultiSearch';
export * from './Resizable'; export * from './Resizable';
export * from './Select'; export * from './Select';
export { default as DataTableErrorBoundary } from './DataTable/DataTableErrorBoundary';
export { default as Radio } from './Radio'; export { default as Radio } from './Radio';
export { default as Badge } from './Badge'; export { default as Badge } from './Badge';
export { default as Avatar } from './Avatar'; export { default as Avatar } from './Avatar';