mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +01:00
🪟 feat: DataTable update + Various UI enhancements (#9698)
* 🎨 feat: Enhance Import Conversations UI with loading state and new localization key * fix: Correct pluralization in selected items message in translation.json * Refactor Chat Input File Table Headers to Use SortFilterHeader Component - Replaced button-based sorting headers in the Chat Input Files Table with a new SortFilterHeader component for better code organization and consistency. - Updated the header for filename, updatedAt, and bytes columns to utilize the new component. Enhance Navigation Component with Skeleton Loading States - Added Skeleton loading states to the Nav component for better user experience during data fetching. - Updated Suspense fallbacks for AgentMarketplaceButton and BookmarkNav components to display Skeletons. Refactor Avatar Component for Improved UI - Enhanced the Avatar component by adding a Label for drag-and-drop functionality. - Improved styling and structure for the file upload area. Update Shared Links Component for Better Error Handling and Sorting - Improved error handling in the Shared Links component for fetching next pages and deleting shared links. - Simplified the header rendering for sorting columns and added sorting functionality to the title and createdAt columns. Refactor Archived Chats Component - Merged ArchivedChats and ArchivedChatsTable components into a single ArchivedChats component for better maintainability. - Implemented sorting and searching functionality with debouncing for improved performance. - Enhanced the UI with better loading states and error handling. Update DataTable Component for Sorting Icons - Added sorting icons (ChevronUp, ChevronDown, ChevronsUpDown) to the DataTable headers for better visual feedback on sorting state. Localization Updates - Updated translation.json to fix missing translations and improve existing ones for better user experience. * ✨ feat: Update DataTable component to streamline props and enhance sorting icons * fix: TS issues * feat: polish and redefine DataTable + shared links and archived chats * feat: enhance DataTable with column pinning and improve sorting functionality * feat: enhance deepEqual function for array support and improve column style stability * refactor: DataTable and ArchivedChats; fix: sorting ArchivedChats API * feat(DataTable): Implement new DataTable component with hooks and optimized features - Added DataTable component with support for virtual scrolling, row selection, and customizable columns. - Introduced hooks for debouncing search input, managing row selection, and calculating column styles. - Enhanced accessibility with keyboard navigation and selection checkboxes. - Implemented skeleton loading state for better user experience during data fetching. - Added DataTableSearch component for filtering data with debounced input. - Created utility logger for improved debugging in development. - Updated translations to support new UI elements and actions. * refactor: update SharedLinks and ArchivedChats to use desktopOnly instead of hideOnMobile; remove unused DataTableColumnHeader component * fix: ensure desktopOnly columns are hidden on mobile in DataTable * refactor: reorganize imports in DataTable components and update index exports * refactor: improve styling and animations in Artifacts, ArtifactsSubMenu, and MCPSubMenu components; update border-radius in style.css * refactor(Artifacts): enhance button toggle functionality and manage expanded state with useEffect * refactor: comment out desktopOnly property in SharedLinks and ArchivedChats components; update translation.json with new keys for link actions * refactor(DataTable): streamline column visibility logic and enhance type definitions; improve cleanup timers and optimize rendering * refactor(DataTable): enhance type definitions for processed data rows and update custom actions renderer type * refactor(DataTable): optimize processed data handling and improve warning for missing IDs; streamline DataTableComponents imports * refactor(DataTable): enhance accessibility features and improve localization for selection and loading states * refactor: improve padding in dialog content and enhance row selection functionality in ArchivedChats and DataTable components * refactor(DataTable): remove unnecessary role and tabindex attributes from select all button for improved accessibility * refactor(translation): remove outdated error messages and unused UI strings for cleaner localization * refactor(DataTable): enhance virtualization and scrolling performance with dynamic overscan adjustments * refactor(DataTableErrorBoundary): enhance error handling and localization support * refactor(DataTable): improve column sizing and visibility handling; remove deprecated features * refactor: enhance UI components with improved class handling and state management * refactor(DataTable): improve column width handling and responsiveness; disable row selection * refactor(DataTable): enhance accessibility with row header support and improve column visibility handling * chore(DataTable): comments update * refactor(Table): add unwrapped prop for direct table rendering; adjust minWidth calculation for responsiveness * refactor(DataTable): simplify search handling by removing unnecessary trimming; adjust column width handling for better responsiveness * refactor(translation): remove redundant drag and drop UI text for clarity * refactor(parsers): change uiResources to a constant and streamline artifacts handling * chore: remove unused file, bump @librechat/client to 0.3.2; fix(SharedLinks): missing import; * refactor: change button variant from destructive to ghost for delete actions in SharedLinks and ArchivedChats components * refactor(DataTable): simplify aria-sort assignment for better readability * refactor(DataTable): update aria-label and ariaLabel to use indexed placeholder for localization * refactor(translation): update no data messages for consistency * Refactor code structure for improved readability and maintainability * chore: restore linting fixes * chore: restore linting fixes 2; refactor: remove unused translation keys * feat(tests): add unit tests for DataTable components and error handling - Implement tests for SelectionCheckbox and SkeletonRows components in DataTable. - Add tests for DataTableErrorBoundary to ensure proper error handling and UI rendering. - Create tests for DataTableSearch to validate search functionality and accessibility. - Update DialogTemplate tests to reflect hardcoded cancel text. - Remove redundant IntersectionObserver mock in SplitText tests. - Unmock react-i18next in Translation tests to validate actual i18n functionality. * refactor: Remove jest-environment-jsdom dependency from package.json; fix: reset package-lock * chore: revert lint fixes * chore: clean up package.json by removing unused devDependencies and redundant test scripts * chore: update package dependencies in package.json and package-lock.json - Added new devDependencies: @babel/core, @babel/preset-env, @babel/preset-react, @babel/preset-typescript, @tanstack/react-table, @tanstack/react-virtual, @testing-library/jest-dom, identity-obj-proxy, jest, jest-environment-jsdom, and lucide-react. - Updated existing devDependencies to their latest versions. - Added new module @asamuzakjp/css-color to package-lock.json with its dependencies. - Updated version of @babel/plugin-transform-destructuring and added @babel/plugin-transform-explicit-resource-management in package-lock.json. --------- Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
parent
9400148175
commit
b4b5a2cd69
49 changed files with 7578 additions and 917 deletions
|
|
@ -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');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,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) {
|
||||||
|
|
@ -45,7 +46,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) {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { memo, useState, useCallback, useMemo } from 'react';
|
import React, { memo, useState, useCallback, useMemo, useEffect } from 'react';
|
||||||
import * as Ariakit from '@ariakit/react';
|
import * as Ariakit from '@ariakit/react';
|
||||||
import { CheckboxButton } from '@librechat/client';
|
import { CheckboxButton } from '@librechat/client';
|
||||||
import { ArtifactModes } from 'librechat-data-provider';
|
import { ArtifactModes } from 'librechat-data-provider';
|
||||||
|
|
@ -18,6 +18,7 @@ function Artifacts() {
|
||||||
const { toggleState, debouncedChange, isPinned } = artifacts;
|
const { toggleState, debouncedChange, isPinned } = artifacts;
|
||||||
|
|
||||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||||
|
const [isButtonExpanded, setIsButtonExpanded] = useState(false);
|
||||||
|
|
||||||
const currentState = useMemo<ArtifactsToggleState>(() => {
|
const currentState = useMemo<ArtifactsToggleState>(() => {
|
||||||
if (typeof toggleState === 'string' && toggleState) {
|
if (typeof toggleState === 'string' && toggleState) {
|
||||||
|
|
@ -33,11 +34,26 @@ function Artifacts() {
|
||||||
const handleToggle = useCallback(() => {
|
const handleToggle = useCallback(() => {
|
||||||
if (isEnabled) {
|
if (isEnabled) {
|
||||||
debouncedChange({ value: '' });
|
debouncedChange({ value: '' });
|
||||||
|
setIsButtonExpanded(false);
|
||||||
} else {
|
} else {
|
||||||
debouncedChange({ value: ArtifactModes.DEFAULT });
|
debouncedChange({ value: ArtifactModes.DEFAULT });
|
||||||
}
|
}
|
||||||
}, [isEnabled, debouncedChange]);
|
}, [isEnabled, debouncedChange]);
|
||||||
|
|
||||||
|
const handleMenuButtonClick = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsButtonExpanded(!isButtonExpanded);
|
||||||
|
},
|
||||||
|
[isButtonExpanded],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPopoverOpen) {
|
||||||
|
setIsButtonExpanded(false);
|
||||||
|
}
|
||||||
|
}, [isPopoverOpen]);
|
||||||
|
|
||||||
const handleShadcnToggle = useCallback(() => {
|
const handleShadcnToggle = useCallback(() => {
|
||||||
if (isShadcnEnabled) {
|
if (isShadcnEnabled) {
|
||||||
debouncedChange({ value: ArtifactModes.DEFAULT });
|
debouncedChange({ value: ArtifactModes.DEFAULT });
|
||||||
|
|
@ -77,21 +93,24 @@ function Artifacts() {
|
||||||
'border-amber-600/40 bg-amber-500/10 hover:bg-amber-700/10',
|
'border-amber-600/40 bg-amber-500/10 hover:bg-amber-700/10',
|
||||||
'transition-colors',
|
'transition-colors',
|
||||||
)}
|
)}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={handleMenuButtonClick}
|
||||||
>
|
>
|
||||||
<ChevronDown className="ml-1 h-4 w-4 text-text-secondary md:ml-0" aria-hidden="true" />
|
<ChevronDown
|
||||||
|
className={cn(
|
||||||
|
'ml-1 h-4 w-4 text-text-secondary transition-transform duration-300 md:ml-0.5',
|
||||||
|
isButtonExpanded && 'rotate-180',
|
||||||
|
)}
|
||||||
|
aria-hidden="true" />
|
||||||
</Ariakit.MenuButton>
|
</Ariakit.MenuButton>
|
||||||
|
|
||||||
<Ariakit.Menu
|
<Ariakit.Menu
|
||||||
gutter={8}
|
gutter={4}
|
||||||
className={cn(
|
className={cn(
|
||||||
'animate-popover z-50 flex max-h-[300px]',
|
'animate-popover-top-left z-50 flex min-w-[250px] flex-col rounded-xl',
|
||||||
'flex-col overflow-auto overscroll-contain rounded-xl',
|
'border border-border-light bg-surface-secondary shadow-lg',
|
||||||
'bg-surface-secondary px-1.5 py-1 text-text-primary shadow-lg',
|
|
||||||
'border border-border-light',
|
|
||||||
'min-w-[250px] outline-none',
|
|
||||||
)}
|
)}
|
||||||
portal
|
portal={true}
|
||||||
|
unmountOnHide={true}
|
||||||
>
|
>
|
||||||
<div className="px-2 py-1.5">
|
<div className="px-2 py-1.5">
|
||||||
<div className="mb-2 text-xs font-medium text-text-secondary">
|
<div className="mb-2 text-xs font-medium text-text-secondary">
|
||||||
|
|
@ -106,18 +125,16 @@ function Artifacts() {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
handleShadcnToggle();
|
handleShadcnToggle();
|
||||||
}}
|
}}
|
||||||
disabled={isCustomEnabled}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'mb-1 flex items-center justify-between rounded-lg px-2 py-2',
|
'mb-1 flex items-center justify-between gap-2 rounded-lg px-2 py-2',
|
||||||
'cursor-pointer outline-none transition-colors',
|
'cursor-pointer bg-surface-secondary text-text-primary outline-none transition-colors',
|
||||||
'hover:bg-black/[0.075] dark:hover:bg-white/10',
|
'hover:bg-surface-hover data-[active-item]:bg-surface-hover',
|
||||||
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
|
isShadcnEnabled && 'bg-surface-active',
|
||||||
isCustomEnabled && 'cursor-not-allowed opacity-50',
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<span className="text-sm">{localize('com_ui_include_shadcnui' as any)}</span>
|
||||||
|
<div className="ml-auto flex items-center">
|
||||||
<Ariakit.MenuItemCheck checked={isShadcnEnabled} />
|
<Ariakit.MenuItemCheck checked={isShadcnEnabled} />
|
||||||
<span className="text-sm">{localize('com_ui_include_shadcnui' as any)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</Ariakit.MenuItem>
|
</Ariakit.MenuItem>
|
||||||
|
|
||||||
|
|
@ -130,15 +147,15 @@ function Artifacts() {
|
||||||
handleCustomToggle();
|
handleCustomToggle();
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center justify-between rounded-lg px-2 py-2',
|
'mb-1 flex items-center justify-between gap-2 rounded-lg px-2 py-2',
|
||||||
'cursor-pointer outline-none transition-colors',
|
'cursor-pointer bg-surface-secondary text-text-primary outline-none transition-colors',
|
||||||
'hover:bg-black/[0.075] dark:hover:bg-white/10',
|
'hover:bg-surface-hover data-[active-item]:bg-surface-hover',
|
||||||
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
|
isCustomEnabled && 'bg-surface-active',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<span className="text-sm">{localize('com_ui_custom_prompt_mode' as any)}</span>
|
||||||
|
<div className="ml-auto flex items-center">
|
||||||
<Ariakit.MenuItemCheck checked={isCustomEnabled} />
|
<Ariakit.MenuItemCheck checked={isCustomEnabled} />
|
||||||
<span className="text-sm">{localize('com_ui_custom_prompt_mode' as any)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</Ariakit.MenuItem>
|
</Ariakit.MenuItem>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -90,8 +90,8 @@ const ArtifactsSubMenu = React.forwardRef<HTMLDivElement, ArtifactsSubMenuProps>
|
||||||
portal={true}
|
portal={true}
|
||||||
unmountOnHide={true}
|
unmountOnHide={true}
|
||||||
className={cn(
|
className={cn(
|
||||||
'animate-popover-left z-50 ml-3 flex min-w-[250px] flex-col rounded-xl',
|
'animate-popover-left z-50 ml-3 mt-6 flex min-w-[250px] flex-col rounded-xl',
|
||||||
'border border-border-light bg-surface-secondary px-1.5 py-1 shadow-lg',
|
'border border-border-light bg-surface-secondary shadow-lg',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="px-2 py-1.5">
|
<div className="px-2 py-1.5">
|
||||||
|
|
@ -107,18 +107,16 @@ const ArtifactsSubMenu = React.forwardRef<HTMLDivElement, ArtifactsSubMenuProps>
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
handleShadcnToggle();
|
handleShadcnToggle();
|
||||||
}}
|
}}
|
||||||
disabled={isCustomEnabled}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'mb-1 flex items-center justify-between rounded-lg px-2 py-2',
|
'mb-1 flex items-center justify-between gap-2 rounded-lg px-2 py-2',
|
||||||
'cursor-pointer text-text-primary outline-none transition-colors',
|
'cursor-pointer bg-surface-secondary text-text-primary outline-none transition-colors',
|
||||||
'hover:bg-black/[0.075] dark:hover:bg-white/10',
|
'hover:bg-surface-hover data-[active-item]:bg-surface-hover',
|
||||||
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
|
isShadcnEnabled && 'bg-surface-active',
|
||||||
isCustomEnabled && 'cursor-not-allowed opacity-50',
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<span className="text-sm">{localize('com_ui_include_shadcnui' as any)}</span>
|
||||||
|
<div className="ml-auto flex items-center">
|
||||||
<Ariakit.MenuItemCheck checked={isShadcnEnabled} />
|
<Ariakit.MenuItemCheck checked={isShadcnEnabled} />
|
||||||
<span className="text-sm">{localize('com_ui_include_shadcnui' as any)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</Ariakit.MenuItem>
|
</Ariakit.MenuItem>
|
||||||
|
|
||||||
|
|
@ -131,15 +129,15 @@ const ArtifactsSubMenu = React.forwardRef<HTMLDivElement, ArtifactsSubMenuProps>
|
||||||
handleCustomToggle();
|
handleCustomToggle();
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center justify-between rounded-lg px-2 py-2',
|
'mb-1 flex items-center justify-between gap-2 rounded-lg px-2 py-2',
|
||||||
'cursor-pointer text-text-primary outline-none transition-colors',
|
'cursor-pointer bg-surface-secondary text-text-primary outline-none transition-colors',
|
||||||
'hover:bg-black/[0.075] dark:hover:bg-white/10',
|
'hover:bg-surface-hover data-[active-item]:bg-surface-hover',
|
||||||
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
|
isCustomEnabled && 'bg-surface-active',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<span className="text-sm">{localize('com_ui_custom_prompt_mode' as any)}</span>
|
||||||
|
<div className="ml-auto flex items-center">
|
||||||
<Ariakit.MenuItemCheck checked={isCustomEnabled} />
|
<Ariakit.MenuItemCheck checked={isCustomEnabled} />
|
||||||
<span className="text-sm">{localize('com_ui_custom_prompt_mode' as any)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</Ariakit.MenuItem>
|
</Ariakit.MenuItem>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -251,6 +251,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<TextareaHeader addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
|
<TextareaHeader addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
|
||||||
|
{/* WIP */}
|
||||||
<EditBadges
|
<EditBadges
|
||||||
isEditingChatBadges={isEditingBadges}
|
isEditingChatBadges={isEditingBadges}
|
||||||
handleCancelBadges={handleCancelBadges}
|
handleCancelBadges={handleCancelBadges}
|
||||||
|
|
|
||||||
|
|
@ -221,6 +221,7 @@ const AttachFileMenu = ({
|
||||||
aria-label="Attach File Options"
|
aria-label="Attach File Options"
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex size-9 items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50',
|
'flex size-9 items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50',
|
||||||
|
isPopoverActive && 'bg-surface-hover',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex w-full items-center justify-center gap-2">
|
<div className="flex w-full items-center justify-center gap-2">
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,7 @@
|
||||||
/* eslint-disable react-hooks/rules-of-hooks */
|
/* eslint-disable react-hooks/rules-of-hooks */
|
||||||
import { ArrowUpDown, Database } from 'lucide-react';
|
import { Database } from 'lucide-react';
|
||||||
import { FileSources, FileContext } from 'librechat-data-provider';
|
import { FileSources, FileContext } from 'librechat-data-provider';
|
||||||
import {
|
import { Checkbox, OpenAIMinimalIcon, AzureMinimalIcon, useMediaQuery } from '@librechat/client';
|
||||||
Button,
|
|
||||||
Checkbox,
|
|
||||||
OpenAIMinimalIcon,
|
|
||||||
AzureMinimalIcon,
|
|
||||||
useMediaQuery,
|
|
||||||
} from '@librechat/client';
|
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
import type { TFile } from 'librechat-data-provider';
|
import type { TFile } from 'librechat-data-provider';
|
||||||
import ImagePreview from '~/components/Chat/Input/Files/ImagePreview';
|
import ImagePreview from '~/components/Chat/Input/Files/ImagePreview';
|
||||||
|
|
@ -61,16 +55,7 @@ export const columns: ColumnDef<TFile>[] = [
|
||||||
accessorKey: 'filename',
|
accessorKey: 'filename',
|
||||||
header: ({ column }) => {
|
header: ({ column }) => {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
return (
|
return <SortFilterHeader column={column} title={localize('com_ui_name')} aria-hidden="true" />;
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
|
||||||
>
|
|
||||||
{localize('com_ui_name')}
|
|
||||||
<ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const file = row.original;
|
const file = row.original;
|
||||||
|
|
@ -100,16 +85,7 @@ export const columns: ColumnDef<TFile>[] = [
|
||||||
accessorKey: 'updatedAt',
|
accessorKey: 'updatedAt',
|
||||||
header: ({ column }) => {
|
header: ({ column }) => {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
return (
|
return <SortFilterHeader column={column} title={localize('com_ui_date')} aria-hidden="true" />;
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
|
||||||
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
|
|
||||||
>
|
|
||||||
{localize('com_ui_date')}
|
|
||||||
<ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||||
|
|
@ -197,16 +173,7 @@ export const columns: ColumnDef<TFile>[] = [
|
||||||
accessorKey: 'bytes',
|
accessorKey: 'bytes',
|
||||||
header: ({ column }) => {
|
header: ({ column }) => {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
return (
|
return <SortFilterHeader column={column} title={localize('com_ui_size')} aria-hidden="true" />;
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
|
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
|
||||||
>
|
|
||||||
{localize('com_ui_size')}
|
|
||||||
<ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const suffix = ' MB';
|
const suffix = ' MB';
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
|
||||||
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
|
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
|
||||||
import { useBadgeRowContext } from '~/Providers';
|
import { useBadgeRowContext } from '~/Providers';
|
||||||
import { useHasAccess } from '~/hooks';
|
import { useHasAccess } from '~/hooks';
|
||||||
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
function MCPSelectContent() {
|
function MCPSelectContent() {
|
||||||
const { conversationId, mcpServerManager } = useBadgeRowContext();
|
const { conversationId, mcpServerManager } = useBadgeRowContext();
|
||||||
|
|
@ -97,7 +98,10 @@ function MCPSelectContent() {
|
||||||
className="badge-icon min-w-fit"
|
className="badge-icon min-w-fit"
|
||||||
selectIcon={<MCPIcon className="icon-md text-text-primary" />}
|
selectIcon={<MCPIcon className="icon-md text-text-primary" />}
|
||||||
selectItemsClassName="border border-blue-600/50 bg-blue-500/10 hover:bg-blue-700/10"
|
selectItemsClassName="border border-blue-600/50 bg-blue-500/10 hover:bg-blue-700/10"
|
||||||
selectClassName="group relative inline-flex items-center justify-center md:justify-start gap-1.5 rounded-full border border-border-medium text-sm font-medium transition-all md:w-full size-9 p-2 md:p-3 bg-transparent shadow-sm hover:bg-surface-hover hover:shadow-md active:shadow-inner"
|
selectClassName={cn(
|
||||||
|
'group relative inline-flex items-center justify-center md:justify-start gap-1.5 rounded-full border border-border-medium text-sm font-medium transition-all',
|
||||||
|
'md:w-full size-9 p-2 md:p-3 bg-transparent shadow-sm hover:bg-surface-hover hover:shadow-md active:shadow-inner',
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
{configDialogProps && (
|
{configDialogProps && (
|
||||||
<MCPConfigDialog {...configDialogProps} conversationId={conversationId} />
|
<MCPConfigDialog {...configDialogProps} conversationId={conversationId} />
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,7 @@ const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
|
||||||
'w-full min-w-0 justify-between text-sm',
|
'w-full min-w-0 justify-between text-sm',
|
||||||
isServerInitializing &&
|
isServerInitializing &&
|
||||||
'opacity-50 hover:bg-transparent dark:hover:bg-transparent',
|
'opacity-50 hover:bg-transparent dark:hover:bg-transparent',
|
||||||
|
isSelected && 'bg-surface-active',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-grow items-center gap-2">
|
<div className="flex flex-grow items-center gap-2">
|
||||||
|
|
|
||||||
|
|
@ -312,10 +312,11 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
|
||||||
aria-label="Tools Options"
|
aria-label="Tools Options"
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex size-9 items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50',
|
'flex size-9 items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50',
|
||||||
|
isPopoverActive && 'bg-surface-hover',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex w-full items-center justify-center gap-2">
|
<div className="flex w-full items-center justify-center gap-2">
|
||||||
<Settings2 className="icon-md" aria-hidden="true" />
|
<Settings2 className="size-5" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
</Ariakit.MenuButton>
|
</Ariakit.MenuButton>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ function AccountSettings() {
|
||||||
<Select.Select
|
<Select.Select
|
||||||
aria-label={localize('com_nav_account_settings')}
|
aria-label={localize('com_nav_account_settings')}
|
||||||
data-testid="nav-user"
|
data-testid="nav-user"
|
||||||
className="mt-text-sm flex h-auto w-full items-center gap-2 rounded-xl p-2 text-sm transition-all duration-200 ease-in-out hover:bg-surface-hover"
|
className="mt-text-sm flex h-auto w-full items-center gap-2 rounded-xl p-2 text-sm transition-all duration-200 ease-in-out hover:bg-surface-hover aria-[expanded=true]:bg-surface-hover"
|
||||||
>
|
>
|
||||||
<div className="-ml-0.9 -mt-0.8 h-8 w-8 flex-shrink-0">
|
<div className="-ml-0.9 -mt-0.8 h-8 w-8 flex-shrink-0">
|
||||||
<div className="relative flex">
|
<div className="relative flex">
|
||||||
|
|
@ -40,11 +40,10 @@ function AccountSettings() {
|
||||||
</div>
|
</div>
|
||||||
</Select.Select>
|
</Select.Select>
|
||||||
<Select.SelectPopover
|
<Select.SelectPopover
|
||||||
className="popover-ui w-[235px]"
|
className="popover-ui w-[305px] rounded-lg md:w-[235px]"
|
||||||
style={{
|
style={{
|
||||||
transformOrigin: 'bottom',
|
transformOrigin: 'bottom',
|
||||||
marginRight: '0px',
|
translate: '0 -4px',
|
||||||
translate: '0px',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="text-token-text-secondary ml-3 mr-2 py-2 text-sm" role="note">
|
<div className="text-token-text-secondary ml-3 mr-2 py-2 text-sm" role="note">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { useCallback, useEffect, useState, useMemo, memo, lazy, Suspense, useRef } from 'react';
|
import { useCallback, useEffect, useState, useMemo, memo, lazy, Suspense, useRef } from 'react';
|
||||||
import { useRecoilValue } from 'recoil';
|
import { useRecoilValue } from 'recoil';
|
||||||
import { List } from 'react-virtualized';
|
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
import { Skeleton, useMediaQuery } from '@librechat/client';
|
import { Skeleton, useMediaQuery } from '@librechat/client';
|
||||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||||
|
|
@ -244,7 +243,7 @@ const Nav = memo(
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={<Skeleton className="mt-1 h-12 w-full rounded-xl" />}>
|
||||||
<AccountSettings />
|
<AccountSettings />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
||||||
|
|
@ -118,11 +118,16 @@ function ImportConversations() {
|
||||||
aria-labelledby="import-conversation-label"
|
aria-labelledby="import-conversation-label"
|
||||||
>
|
>
|
||||||
{isUploading ? (
|
{isUploading ? (
|
||||||
<Spinner className="mr-1 w-4" />
|
<>
|
||||||
|
<Spinner className="mr-1 w-4" />
|
||||||
|
<span>{localize('com_ui_importing')}</span>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Import className="mr-1 flex h-4 w-4 items-center stroke-1" aria-hidden="true" />
|
<>
|
||||||
|
<Import className="mr-1 flex h-4 w-4 items-center stroke-1" aria-hidden="true" />
|
||||||
|
<span>{localize('com_ui_import')}</span>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<span>{localize('com_ui_import')}</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,6 @@
|
||||||
import { useCallback, useState, useMemo, useEffect } from 'react';
|
import { useCallback, useState, useMemo, useEffect } from 'react';
|
||||||
import { Trans } from 'react-i18next';
|
|
||||||
import debounce from 'lodash/debounce';
|
|
||||||
import { useRecoilValue } from 'recoil';
|
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import {
|
import { TrashIcon, MessageSquare, ExternalLink } from 'lucide-react';
|
||||||
TrashIcon,
|
|
||||||
MessageSquare,
|
|
||||||
ArrowUpDown,
|
|
||||||
ArrowUp,
|
|
||||||
ArrowDown,
|
|
||||||
ExternalLink,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import type { SharedLinkItem, SharedLinksListParams } from 'librechat-data-provider';
|
|
||||||
import {
|
import {
|
||||||
OGDialog,
|
OGDialog,
|
||||||
useToastContext,
|
useToastContext,
|
||||||
|
|
@ -21,89 +10,162 @@ import {
|
||||||
useMediaQuery,
|
useMediaQuery,
|
||||||
OGDialogHeader,
|
OGDialogHeader,
|
||||||
OGDialogTitle,
|
OGDialogTitle,
|
||||||
|
TooltipAnchor,
|
||||||
DataTable,
|
DataTable,
|
||||||
Spinner,
|
Spinner,
|
||||||
Button,
|
Button,
|
||||||
Label,
|
Label,
|
||||||
} from '@librechat/client';
|
} from '@librechat/client';
|
||||||
|
import type { SharedLinkItem, SharedLinksListParams } from 'librechat-data-provider';
|
||||||
|
import type { ColumnDef, SortingState } from '@tanstack/react-table';
|
||||||
import { useDeleteSharedLinkMutation, useSharedLinksQuery } from '~/data-provider';
|
import { useDeleteSharedLinkMutation, useSharedLinksQuery } from '~/data-provider';
|
||||||
import { useLocalize } from '~/hooks';
|
|
||||||
import { NotificationSeverity } from '~/common';
|
import { NotificationSeverity } from '~/common';
|
||||||
import { formatDate } from '~/utils';
|
import { formatDate, cn } from '~/utils';
|
||||||
import store from '~/store';
|
import { useLocalize } from '~/hooks';
|
||||||
|
|
||||||
const PAGE_SIZE = 25;
|
|
||||||
|
|
||||||
const DEFAULT_PARAMS: SharedLinksListParams = {
|
const DEFAULT_PARAMS: SharedLinksListParams = {
|
||||||
pageSize: PAGE_SIZE,
|
pageSize: 25,
|
||||||
isPublic: true,
|
isPublic: true,
|
||||||
sortBy: 'createdAt',
|
sortBy: 'createdAt',
|
||||||
sortDirection: 'desc',
|
sortDirection: 'desc',
|
||||||
search: '',
|
search: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SortKey = 'createdAt' | 'title';
|
||||||
|
const isSortKey = (v: string): v is SortKey => v === 'createdAt' || v === 'title';
|
||||||
|
|
||||||
|
const defaultSort: SortingState = [
|
||||||
|
{
|
||||||
|
id: 'createdAt',
|
||||||
|
desc: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
type TableColumn<TData, TValue> = ColumnDef<TData, TValue> & {
|
||||||
|
meta?: {
|
||||||
|
className?: string;
|
||||||
|
desktopOnly?: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export default function SharedLinks() {
|
export default function SharedLinks() {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const { showToast } = useToastContext();
|
const { showToast } = useToastContext();
|
||||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||||
const isSearchEnabled = useRecoilValue(store.search);
|
|
||||||
const [queryParams, setQueryParams] = useState<SharedLinksListParams>(DEFAULT_PARAMS);
|
|
||||||
const [deleteRow, setDeleteRow] = useState<SharedLinkItem | null>(null);
|
|
||||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||||
|
const [deleteRow, setDeleteRow] = useState<SharedLinkItem | null>(null);
|
||||||
|
|
||||||
|
const [queryParams, setQueryParams] = useState<SharedLinksListParams>(DEFAULT_PARAMS);
|
||||||
|
const [sorting, setSorting] = useState<SortingState>(defaultSort);
|
||||||
|
const [searchValue, setSearchValue] = useState('');
|
||||||
|
|
||||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch, isLoading } =
|
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch, isLoading } =
|
||||||
useSharedLinksQuery(queryParams, {
|
useSharedLinksQuery(queryParams, {
|
||||||
enabled: isOpen,
|
enabled: isOpen,
|
||||||
staleTime: 0,
|
keepPreviousData: true,
|
||||||
cacheTime: 5 * 60 * 1000,
|
staleTime: 30 * 1000,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnMount: false,
|
refetchOnMount: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSort = useCallback((sortField: string, sortOrder: 'asc' | 'desc') => {
|
const [allKnownLinks, setAllKnownLinks] = useState<SharedLinkItem[]>([]);
|
||||||
|
|
||||||
|
const handleSearchChange = useCallback((value: string) => {
|
||||||
|
setSearchValue(value);
|
||||||
|
setAllKnownLinks([]);
|
||||||
setQueryParams((prev) => ({
|
setQueryParams((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
sortBy: sortField as 'title' | 'createdAt',
|
search: value,
|
||||||
sortDirection: sortOrder,
|
|
||||||
}));
|
}));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleFilterChange = useCallback((value: string) => {
|
const handleSortingChange = useCallback(
|
||||||
const encodedValue = encodeURIComponent(value.trim());
|
(updater: SortingState | ((old: SortingState) => SortingState)) => {
|
||||||
setQueryParams((prev) => ({
|
setSorting((prev) => {
|
||||||
...prev,
|
const next = typeof updater === 'function' ? updater(prev) : updater;
|
||||||
search: encodedValue,
|
|
||||||
}));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const debouncedFilterChange = useMemo(
|
const coerced = next;
|
||||||
() => debounce(handleFilterChange, 300),
|
const primary = coerced[0];
|
||||||
[handleFilterChange],
|
|
||||||
|
if (data?.pages) {
|
||||||
|
const currentFlattened = data.pages.flatMap((page) => page?.links?.filter(Boolean) ?? []);
|
||||||
|
setAllKnownLinks(currentFlattened);
|
||||||
|
}
|
||||||
|
|
||||||
|
setQueryParams((p) => {
|
||||||
|
let sortBy: SortKey;
|
||||||
|
let sortDirection: 'asc' | 'desc';
|
||||||
|
|
||||||
|
if (primary && isSortKey(primary.id)) {
|
||||||
|
sortBy = primary.id;
|
||||||
|
sortDirection = primary.desc ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
sortBy = 'createdAt';
|
||||||
|
sortDirection = 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
const newParams = {
|
||||||
|
...p,
|
||||||
|
sortBy,
|
||||||
|
sortDirection,
|
||||||
|
};
|
||||||
|
|
||||||
|
return newParams;
|
||||||
|
});
|
||||||
|
|
||||||
|
return coerced;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[setQueryParams, data?.pages],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
if (!data?.pages) return;
|
||||||
debouncedFilterChange.cancel();
|
|
||||||
};
|
|
||||||
}, [debouncedFilterChange]);
|
|
||||||
|
|
||||||
const allLinks = useMemo(() => {
|
const newFlattened = data.pages.flatMap((page) => page?.links?.filter(Boolean) ?? []);
|
||||||
if (!data?.pages) {
|
|
||||||
return [];
|
const toAdd = newFlattened.filter(
|
||||||
|
(link: SharedLinkItem) => !allKnownLinks.some((known) => known.shareId === link.shareId),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (toAdd.length > 0) {
|
||||||
|
setAllKnownLinks((prev) => [...prev, ...toAdd]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return data.pages.flatMap((page) => page.links.filter(Boolean));
|
|
||||||
}, [data?.pages]);
|
}, [data?.pages]);
|
||||||
|
|
||||||
|
const displayData = useMemo(() => {
|
||||||
|
const primary = sorting[0];
|
||||||
|
if (!primary || allKnownLinks.length === 0) return allKnownLinks;
|
||||||
|
|
||||||
|
return [...allKnownLinks].sort((a: SharedLinkItem, b: SharedLinkItem) => {
|
||||||
|
let compare: number;
|
||||||
|
if (primary.id === 'createdAt') {
|
||||||
|
const aDate = new Date(a.createdAt || 0);
|
||||||
|
const bDate = new Date(b.createdAt || 0);
|
||||||
|
compare = aDate.getTime() - bDate.getTime();
|
||||||
|
} else if (primary.id === 'title') {
|
||||||
|
compare = (a.title || '').localeCompare(b.title || '');
|
||||||
|
} else {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return primary.desc ? -compare : compare;
|
||||||
|
});
|
||||||
|
}, [allKnownLinks, sorting]);
|
||||||
|
|
||||||
const deleteMutation = useDeleteSharedLinkMutation({
|
const deleteMutation = useDeleteSharedLinkMutation({
|
||||||
onSuccess: async () => {
|
onSuccess: (data, variables) => {
|
||||||
|
const { shareId } = variables;
|
||||||
|
setAllKnownLinks((prev) => prev.filter((link) => link.shareId !== shareId));
|
||||||
|
showToast({
|
||||||
|
message: localize('com_ui_shared_link_delete_success'),
|
||||||
|
severity: NotificationSeverity.SUCCESS,
|
||||||
|
});
|
||||||
setIsDeleteOpen(false);
|
setIsDeleteOpen(false);
|
||||||
setDeleteRow(null);
|
refetch();
|
||||||
await refetch();
|
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: () => {
|
||||||
console.error('Delete error:', error);
|
|
||||||
showToast({
|
showToast({
|
||||||
message: localize('com_ui_share_delete_error'),
|
message: localize('com_ui_share_delete_error'),
|
||||||
severity: NotificationSeverity.ERROR,
|
severity: NotificationSeverity.ERROR,
|
||||||
|
|
@ -111,87 +173,39 @@ export default function SharedLinks() {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleDelete = useCallback(
|
|
||||||
async (selectedRows: SharedLinkItem[]) => {
|
|
||||||
const validRows = selectedRows.filter(
|
|
||||||
(row) => typeof row.shareId === 'string' && row.shareId.length > 0,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (validRows.length === 0) {
|
|
||||||
showToast({
|
|
||||||
message: localize('com_ui_no_valid_items'),
|
|
||||||
severity: NotificationSeverity.WARNING,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
for (const row of validRows) {
|
|
||||||
await deleteMutation.mutateAsync({ shareId: row.shareId });
|
|
||||||
}
|
|
||||||
|
|
||||||
showToast({
|
|
||||||
message: localize(
|
|
||||||
validRows.length === 1
|
|
||||||
? 'com_ui_shared_link_delete_success'
|
|
||||||
: 'com_ui_shared_link_bulk_delete_success',
|
|
||||||
),
|
|
||||||
severity: NotificationSeverity.SUCCESS,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to delete shared links:', error);
|
|
||||||
showToast({
|
|
||||||
message: localize('com_ui_bulk_delete_error'),
|
|
||||||
severity: NotificationSeverity.ERROR,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[deleteMutation, showToast, localize],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleFetchNextPage = useCallback(async () => {
|
const handleFetchNextPage = useCallback(async () => {
|
||||||
if (hasNextPage !== true || isFetchingNextPage) {
|
if (!hasNextPage || isFetchingNextPage) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
await fetchNextPage();
|
await fetchNextPage();
|
||||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
||||||
|
|
||||||
const confirmDelete = useCallback(() => {
|
const effectiveIsLoading = isLoading && displayData.length === 0;
|
||||||
if (deleteRow) {
|
const effectiveIsFetching = isFetchingNextPage;
|
||||||
handleDelete([deleteRow]);
|
|
||||||
}
|
|
||||||
setIsDeleteOpen(false);
|
|
||||||
}, [deleteRow, handleDelete]);
|
|
||||||
|
|
||||||
const columns = useMemo(
|
const confirmDelete = useCallback(() => {
|
||||||
|
if (!deleteRow?.shareId) {
|
||||||
|
showToast({
|
||||||
|
message: localize('com_ui_share_delete_error'),
|
||||||
|
severity: NotificationSeverity.WARNING,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deleteMutation.mutate({ shareId: deleteRow.shareId });
|
||||||
|
}, [deleteMutation, deleteRow, localize, showToast]);
|
||||||
|
|
||||||
|
const columns: TableColumn<Record<string, unknown>, unknown>[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
accessorKey: 'title',
|
accessorKey: 'title',
|
||||||
header: () => {
|
accessorFn: (row: Record<string, unknown>): unknown => {
|
||||||
const isSorted = queryParams.sortBy === 'title';
|
const link = row as SharedLinkItem;
|
||||||
const sortDirection = queryParams.sortDirection;
|
return link.title;
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
|
|
||||||
onClick={() =>
|
|
||||||
handleSort('title', isSorted && sortDirection === 'asc' ? 'desc' : 'asc')
|
|
||||||
}
|
|
||||||
aria-label={localize('com_ui_name_sort')}
|
|
||||||
>
|
|
||||||
{localize('com_ui_name')}
|
|
||||||
{isSorted && sortDirection === 'asc' && (
|
|
||||||
<ArrowUp className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
|
||||||
)}
|
|
||||||
{isSorted && sortDirection === 'desc' && (
|
|
||||||
<ArrowDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
|
||||||
)}
|
|
||||||
{!isSorted && <ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
|
header: () => (
|
||||||
|
<span className="text-xs text-text-primary sm:text-sm">{localize('com_ui_name')}</span>
|
||||||
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const { title, shareId } = row.original;
|
const link = row.original as SharedLinkItem;
|
||||||
|
const { title, shareId } = link;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -199,7 +213,7 @@ export default function SharedLinks() {
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="group flex items-center gap-1 truncate rounded-sm text-blue-600 underline decoration-1 underline-offset-2 hover:decoration-2 focus:outline-none focus:ring-2 focus:ring-ring"
|
className="group flex items-center gap-1 truncate rounded-sm text-blue-600 underline decoration-1 underline-offset-2 hover:decoration-2 focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
title={title}
|
aria-label={localize('com_ui_open_link', { 0: title })}
|
||||||
>
|
>
|
||||||
<span className="truncate">{title}</span>
|
<span className="truncate">{title}</span>
|
||||||
<ExternalLink
|
<ExternalLink
|
||||||
|
|
@ -211,141 +225,139 @@ export default function SharedLinks() {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
size: '35%',
|
className: 'min-w-[150px] flex-1',
|
||||||
mobileSize: '50%',
|
|
||||||
},
|
},
|
||||||
|
enableSorting: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'createdAt',
|
accessorKey: 'createdAt',
|
||||||
header: () => {
|
accessorFn: (row: Record<string, unknown>): unknown => {
|
||||||
const isSorted = queryParams.sortBy === 'createdAt';
|
const link = row as SharedLinkItem;
|
||||||
const sortDirection = queryParams.sortDirection;
|
return link.createdAt;
|
||||||
return (
|
},
|
||||||
<Button
|
header: () => (
|
||||||
variant="ghost"
|
<span className="text-xs text-text-primary sm:text-sm">{localize('com_ui_date')}</span>
|
||||||
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
|
),
|
||||||
onClick={() =>
|
cell: ({ row }) => {
|
||||||
handleSort('createdAt', isSorted && sortDirection === 'asc' ? 'desc' : 'asc')
|
const link = row.original as SharedLinkItem;
|
||||||
}
|
return formatDate(link.createdAt?.toString() ?? '', isSmallScreen);
|
||||||
aria-label={localize('com_ui_creation_date_sort')}
|
|
||||||
>
|
|
||||||
{localize('com_ui_date')}
|
|
||||||
{isSorted && sortDirection === 'asc' && (
|
|
||||||
<ArrowUp className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
|
||||||
)}
|
|
||||||
{isSorted && sortDirection === 'desc' && (
|
|
||||||
<ArrowDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
|
||||||
)}
|
|
||||||
{!isSorted && <ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
cell: ({ row }) => formatDate(row.original.createdAt?.toString() ?? '', isSmallScreen),
|
|
||||||
meta: {
|
meta: {
|
||||||
size: '10%',
|
className: 'w-32 sm:w-40',
|
||||||
mobileSize: '20%',
|
desktopOnly: true,
|
||||||
},
|
},
|
||||||
|
enableSorting: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'actions',
|
id: 'actions',
|
||||||
|
accessorFn: (row: Record<string, unknown>): unknown => null,
|
||||||
header: () => (
|
header: () => (
|
||||||
<Label className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm">
|
<span className="text-xs text-text-primary sm:text-sm">
|
||||||
{localize('com_assistants_actions')}
|
{localize('com_assistants_actions')}
|
||||||
</Label>
|
</span>
|
||||||
),
|
),
|
||||||
meta: {
|
cell: ({ row }) => {
|
||||||
size: '7%',
|
const link = row.original as SharedLinkItem;
|
||||||
mobileSize: '25%',
|
const { title, conversationId } = link;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TooltipAnchor
|
||||||
|
description={localize('com_ui_view_source')}
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 p-0 hover:bg-surface-hover"
|
||||||
|
onClick={() => {
|
||||||
|
window.open(`/c/${conversationId}`, '_blank');
|
||||||
|
}}
|
||||||
|
aria-label={localize('com_ui_view_source_conversation', { 0: title })}
|
||||||
|
>
|
||||||
|
<MessageSquare className="size-4" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TooltipAnchor
|
||||||
|
description={localize('com_ui_delete')}
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteRow(link);
|
||||||
|
setIsDeleteOpen(true);
|
||||||
|
}}
|
||||||
|
aria-label={localize('com_ui_delete_link_title', { 0: title })}
|
||||||
|
>
|
||||||
|
<TrashIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
cell: ({ row }) => (
|
meta: {
|
||||||
<div className="flex items-center gap-2">
|
className: 'w-24',
|
||||||
<a
|
},
|
||||||
href={`/c/${row.original.conversationId}`}
|
enableSorting: false,
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex h-8 w-8 items-center justify-center rounded-md p-0 transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
aria-label={`${localize('com_ui_view_source')} - ${row.original.title || localize('com_ui_untitled')}`}
|
|
||||||
>
|
|
||||||
<MessageSquare className="size-4" aria-hidden="true" />
|
|
||||||
</a>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="h-8 w-8 p-0 hover:bg-surface-hover"
|
|
||||||
onClick={() => {
|
|
||||||
setDeleteRow(row.original);
|
|
||||||
setIsDeleteOpen(true);
|
|
||||||
}}
|
|
||||||
aria-label={localize('com_ui_delete_shared_link', {
|
|
||||||
title: row.original.title || localize('com_ui_untitled'),
|
|
||||||
})}
|
|
||||||
aria-haspopup="dialog"
|
|
||||||
aria-controls="delete-shared-link-dialog"
|
|
||||||
>
|
|
||||||
<TrashIcon className="size-4" aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[isSmallScreen, localize, queryParams, handleSort],
|
[isSmallScreen, localize],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label id="shared-links-label">{localize('com_nav_shared_links')}</Label>
|
<Label id="shared-links-label">{localize('com_nav_shared_links')}</Label>
|
||||||
|
|
||||||
<OGDialog open={isOpen} onOpenChange={setIsOpen}>
|
<OGDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<OGDialogTrigger asChild onClick={() => setIsOpen(true)}>
|
<OGDialogTrigger asChild>
|
||||||
<Button aria-labelledby="shared-links-label" variant="outline">
|
<Button aria-labelledby="shared-links-label" variant="outline">
|
||||||
{localize('com_ui_manage')}
|
{localize('com_ui_manage')}
|
||||||
</Button>
|
</Button>
|
||||||
</OGDialogTrigger>
|
</OGDialogTrigger>
|
||||||
|
<OGDialogContent className={cn('w-11/12 max-w-6xl', isSmallScreen && 'px-1 pb-1')}>
|
||||||
<OGDialogContent
|
|
||||||
title={localize('com_nav_my_files')}
|
|
||||||
className="w-11/12 max-w-5xl bg-background text-text-primary shadow-2xl"
|
|
||||||
>
|
|
||||||
<OGDialogHeader>
|
<OGDialogHeader>
|
||||||
<OGDialogTitle>{localize('com_nav_shared_links')}</OGDialogTitle>
|
<OGDialogTitle>{localize('com_nav_shared_links')}</OGDialogTitle>
|
||||||
</OGDialogHeader>
|
</OGDialogHeader>
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={allLinks}
|
data={displayData}
|
||||||
onDelete={handleDelete}
|
isLoading={effectiveIsLoading}
|
||||||
filterColumn="title"
|
isFetching={effectiveIsFetching}
|
||||||
|
config={{
|
||||||
|
skeleton: { count: 11 },
|
||||||
|
search: {
|
||||||
|
filterColumn: 'title',
|
||||||
|
enableSearch: true,
|
||||||
|
debounce: 300,
|
||||||
|
},
|
||||||
|
selection: {
|
||||||
|
enableRowSelection: false,
|
||||||
|
showCheckboxes: false,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
filterValue={searchValue}
|
||||||
|
onFilterChange={handleSearchChange}
|
||||||
|
fetchNextPage={handleFetchNextPage}
|
||||||
hasNextPage={hasNextPage}
|
hasNextPage={hasNextPage}
|
||||||
isFetchingNextPage={isFetchingNextPage}
|
isFetchingNextPage={isFetchingNextPage}
|
||||||
fetchNextPage={handleFetchNextPage}
|
sorting={sorting}
|
||||||
showCheckboxes={false}
|
onSortingChange={handleSortingChange}
|
||||||
onFilterChange={debouncedFilterChange}
|
|
||||||
filterValue={queryParams.search}
|
|
||||||
isLoading={isLoading}
|
|
||||||
enableSearch={isSearchEnabled}
|
|
||||||
/>
|
/>
|
||||||
</OGDialogContent>
|
</OGDialogContent>
|
||||||
</OGDialog>
|
</OGDialog>
|
||||||
<OGDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
|
<OGDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
|
||||||
<OGDialogTemplate
|
<OGDialogTemplate
|
||||||
showCloseButton={false}
|
showCloseButton={false}
|
||||||
title={localize('com_ui_delete_shared_link_heading')}
|
title={localize('com_ui_delete_shared_link')}
|
||||||
className="max-w-[450px]"
|
className="w-11/12 max-w-md"
|
||||||
main={
|
main={
|
||||||
<>
|
<div className="flex w-full flex-col items-center gap-2">
|
||||||
<div
|
<div className="grid w-full items-center gap-2">
|
||||||
id="delete-shared-link-dialog"
|
<Label className="text-left text-sm font-medium">
|
||||||
className="flex w-full flex-col items-center gap-2"
|
{localize('com_ui_delete_confirm')} <strong>{deleteRow?.title}</strong>
|
||||||
>
|
</Label>
|
||||||
<div className="grid w-full items-center gap-2">
|
|
||||||
<Label htmlFor="dialog-confirm-delete" className="text-left text-sm font-medium">
|
|
||||||
<Trans
|
|
||||||
i18nKey="com_ui_delete_confirm_strong"
|
|
||||||
values={{ title: deleteRow?.title }}
|
|
||||||
components={{ strong: <strong /> }}
|
|
||||||
/>
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
}
|
}
|
||||||
selection={{
|
selection={{
|
||||||
selectHandler: confirmDelete,
|
selectHandler: confirmDelete,
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,406 @@
|
||||||
import { useState } from 'react';
|
import { useState, useCallback, useMemo } from 'react';
|
||||||
import { OGDialogTemplate, OGDialog, OGDialogTrigger, Button } from '@librechat/client';
|
import { QueryKeys } from 'librechat-data-provider';
|
||||||
import ArchivedChatsTable from './ArchivedChatsTable';
|
import { TrashIcon, ArchiveRestore } from 'lucide-react';
|
||||||
|
import { useQueryClient, InfiniteData } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
OGDialog,
|
||||||
|
OGDialogTrigger,
|
||||||
|
OGDialogTemplate,
|
||||||
|
OGDialogContent,
|
||||||
|
OGDialogHeader,
|
||||||
|
OGDialogTitle,
|
||||||
|
Label,
|
||||||
|
TooltipAnchor,
|
||||||
|
Spinner,
|
||||||
|
useToastContext,
|
||||||
|
useMediaQuery,
|
||||||
|
DataTable,
|
||||||
|
type TableColumn,
|
||||||
|
} from '@librechat/client';
|
||||||
|
import type { ConversationListParams, TConversation } from 'librechat-data-provider';
|
||||||
|
import type { SortingState } from '@tanstack/react-table';
|
||||||
|
import {
|
||||||
|
useArchiveConvoMutation,
|
||||||
|
useConversationsInfiniteQuery,
|
||||||
|
useDeleteConversationMutation,
|
||||||
|
} from '~/data-provider';
|
||||||
|
import { MinimalIcon } from '~/components/Endpoints';
|
||||||
|
import { NotificationSeverity } from '~/common';
|
||||||
|
import { formatDate, cn } from '~/utils';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
|
|
||||||
export default function ArchivedChats() {
|
const DEFAULT_PARAMS = {
|
||||||
|
isArchived: true,
|
||||||
|
sortBy: 'createdAt',
|
||||||
|
sortDirection: 'desc',
|
||||||
|
search: '',
|
||||||
|
} as const satisfies ConversationListParams;
|
||||||
|
|
||||||
|
type SortKey = 'createdAt' | 'title';
|
||||||
|
const isSortKey = (v: string): v is SortKey => v === 'createdAt' || v === 'title';
|
||||||
|
|
||||||
|
const defaultSort: SortingState = [
|
||||||
|
{
|
||||||
|
id: 'createdAt',
|
||||||
|
desc: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: remove a conversation from all infinite queries whose key starts with the provided root
|
||||||
|
*/
|
||||||
|
function removeConversationFromInfinite(
|
||||||
|
queryClient: ReturnType<typeof useQueryClient>,
|
||||||
|
rootKey: string,
|
||||||
|
conversationId: string,
|
||||||
|
) {
|
||||||
|
const queries = queryClient.getQueryCache().findAll([rootKey], { exact: false });
|
||||||
|
for (const query of queries) {
|
||||||
|
queryClient.setQueryData<
|
||||||
|
InfiniteData<{ conversations: TConversation[]; nextCursor?: string | null }>
|
||||||
|
>(query.queryKey, (old) => {
|
||||||
|
if (!old) return old;
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
pages: old.pages.map((page) => ({
|
||||||
|
...page,
|
||||||
|
conversations: page.conversations.filter((c) => c.conversationId !== conversationId),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ArchivedChatsTable() {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
|
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||||
|
const { showToast } = useToastContext();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||||
|
const [deleteRow, setDeleteRow] = useState<TConversation | null>(null);
|
||||||
|
const [unarchivingId, setUnarchivingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [queryParams, setQueryParams] = useState<ConversationListParams>(DEFAULT_PARAMS);
|
||||||
|
const [sorting, setSorting] = useState<SortingState>(defaultSort);
|
||||||
|
const [searchValue, setSearchValue] = useState('');
|
||||||
|
|
||||||
|
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } =
|
||||||
|
useConversationsInfiniteQuery(queryParams, {
|
||||||
|
enabled: isOpen,
|
||||||
|
keepPreviousData: false,
|
||||||
|
staleTime: 30 * 1000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnMount: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSearchChange = useCallback((value: string) => {
|
||||||
|
setSearchValue(value);
|
||||||
|
setQueryParams((prev) => ({
|
||||||
|
...prev,
|
||||||
|
search: value,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSortingChange = useCallback(
|
||||||
|
(updater: SortingState | ((old: SortingState) => SortingState)) => {
|
||||||
|
setSorting((prev) => {
|
||||||
|
const next = typeof updater === 'function' ? updater(prev) : updater;
|
||||||
|
const primary = next[0];
|
||||||
|
setQueryParams((p) => {
|
||||||
|
let sortBy: SortKey = 'createdAt';
|
||||||
|
let sortDirection: 'asc' | 'desc' = 'desc';
|
||||||
|
if (primary && isSortKey(primary.id)) {
|
||||||
|
sortBy = primary.id;
|
||||||
|
sortDirection = primary.desc ? 'desc' : 'asc';
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
sortBy,
|
||||||
|
sortDirection,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const flattenedConversations = useMemo(
|
||||||
|
() => data?.pages?.flatMap((page) => page?.conversations?.filter(Boolean) ?? []) ?? [],
|
||||||
|
[data?.pages],
|
||||||
|
);
|
||||||
|
|
||||||
|
const unarchiveMutation = useArchiveConvoMutation({
|
||||||
|
onSuccess: (_res, variables) => {
|
||||||
|
const { conversationId } = variables;
|
||||||
|
if (conversationId) {
|
||||||
|
removeConversationFromInfinite(
|
||||||
|
queryClient,
|
||||||
|
QueryKeys.archivedConversations,
|
||||||
|
conversationId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
queryClient.invalidateQueries([QueryKeys.allConversations]);
|
||||||
|
setUnarchivingId(null);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showToast({
|
||||||
|
message: localize('com_ui_unarchive_error'),
|
||||||
|
severity: NotificationSeverity.ERROR,
|
||||||
|
});
|
||||||
|
setUnarchivingId(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useDeleteConversationMutation({
|
||||||
|
onSuccess: (_data, variables) => {
|
||||||
|
const { conversationId } = variables;
|
||||||
|
if (conversationId) {
|
||||||
|
removeConversationFromInfinite(
|
||||||
|
queryClient,
|
||||||
|
QueryKeys.archivedConversations,
|
||||||
|
conversationId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
showToast({
|
||||||
|
message: localize('com_ui_archived_conversation_delete_success'),
|
||||||
|
severity: NotificationSeverity.SUCCESS,
|
||||||
|
});
|
||||||
|
setIsDeleteOpen(false);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showToast({
|
||||||
|
message: localize('com_ui_archive_delete_error'),
|
||||||
|
severity: NotificationSeverity.ERROR,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleFetchNextPage = useCallback(async () => {
|
||||||
|
if (!hasNextPage || isFetchingNextPage) return;
|
||||||
|
await fetchNextPage();
|
||||||
|
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
||||||
|
|
||||||
|
const effectiveIsLoading = isLoading;
|
||||||
|
const effectiveIsFetching = isFetchingNextPage;
|
||||||
|
|
||||||
|
const confirmDelete = useCallback(() => {
|
||||||
|
if (!deleteRow?.conversationId) {
|
||||||
|
showToast({
|
||||||
|
message: localize('com_ui_convo_delete_error'),
|
||||||
|
severity: NotificationSeverity.WARNING,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deleteMutation.mutate({ conversationId: deleteRow.conversationId });
|
||||||
|
}, [deleteMutation, deleteRow, localize, showToast]);
|
||||||
|
|
||||||
|
const handleUnarchive = useCallback(
|
||||||
|
(conversationId: string) => {
|
||||||
|
setUnarchivingId(conversationId);
|
||||||
|
unarchiveMutation.mutate(
|
||||||
|
{ conversationId, isArchived: false },
|
||||||
|
{ onSettled: () => setUnarchivingId(null) },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[unarchiveMutation],
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns: TableColumn<Record<string, unknown>, unknown>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'title',
|
||||||
|
accessorFn: (row: Record<string, unknown>): unknown => {
|
||||||
|
const convo = row as TConversation;
|
||||||
|
return convo.title;
|
||||||
|
},
|
||||||
|
header: () => (
|
||||||
|
<span className="text-xs text-text-primary sm:text-sm">
|
||||||
|
{localize('com_nav_archive_name')}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const convo = row.original as TConversation;
|
||||||
|
const { conversationId, title } = convo;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MinimalIcon
|
||||||
|
endpoint={convo.endpoint}
|
||||||
|
size={28}
|
||||||
|
isCreatedByUser={false}
|
||||||
|
iconClassName="size-4"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
className: 'min-w-[150px] flex-1',
|
||||||
|
isRowHeader: true,
|
||||||
|
},
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'createdAt',
|
||||||
|
accessorFn: (row: Record<string, unknown>): unknown => {
|
||||||
|
const convo = row as TConversation;
|
||||||
|
return convo.createdAt;
|
||||||
|
},
|
||||||
|
header: () => (
|
||||||
|
<span className="text-xs text-text-primary sm:text-sm">
|
||||||
|
{localize('com_nav_archive_created_at')}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const convo = row.original as TConversation;
|
||||||
|
return formatDate(convo.createdAt?.toString() ?? '', isSmallScreen);
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
className: 'w-32 sm:w-40',
|
||||||
|
desktopOnly: true,
|
||||||
|
},
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
accessorFn: () => null,
|
||||||
|
header: () => (
|
||||||
|
<span className="text-xs text-text-primary sm:text-sm">
|
||||||
|
{localize('com_assistants_actions')}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const convo = row.original as TConversation;
|
||||||
|
const { title } = convo;
|
||||||
|
const isRowUnarchiving = unarchivingId === convo.conversationId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 md:gap-2">
|
||||||
|
<TooltipAnchor
|
||||||
|
description={localize('com_ui_unarchive')}
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="h-9 w-9 p-0 hover:bg-surface-hover md:h-8 md:w-8"
|
||||||
|
onClick={() => {
|
||||||
|
const conversationId = convo.conversationId;
|
||||||
|
if (!conversationId) return;
|
||||||
|
handleUnarchive(conversationId);
|
||||||
|
}}
|
||||||
|
disabled={isRowUnarchiving}
|
||||||
|
aria-label={localize('com_ui_unarchive_conversation_title', { 0: title })}
|
||||||
|
>
|
||||||
|
{isRowUnarchiving ? <Spinner /> : <ArchiveRestore className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TooltipAnchor
|
||||||
|
description={localize('com_ui_delete')}
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="h-9 w-9 p-0 md:h-8 md:w-8"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteRow(convo);
|
||||||
|
setIsDeleteOpen(true);
|
||||||
|
}}
|
||||||
|
aria-label={localize('com_ui_delete_conversation_title', { 0: title })}
|
||||||
|
>
|
||||||
|
<TrashIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
className: 'w-24',
|
||||||
|
},
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[isSmallScreen, localize, handleUnarchive, unarchivingId],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>{localize('com_nav_archived_chats')}</div>
|
<Label htmlFor="archived-chats-button" className="text-sm font-medium">
|
||||||
|
{localize('com_nav_archived_chats')}
|
||||||
|
</Label>
|
||||||
<OGDialog open={isOpen} onOpenChange={setIsOpen}>
|
<OGDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
<OGDialogTrigger asChild>
|
<OGDialogTrigger asChild>
|
||||||
<Button variant="outline" aria-label="Archived chats">
|
<Button
|
||||||
|
id="archived-chats-button"
|
||||||
|
variant="outline"
|
||||||
|
aria-label={localize('com_ui_manage_archived_chats')}
|
||||||
|
>
|
||||||
{localize('com_ui_manage')}
|
{localize('com_ui_manage')}
|
||||||
</Button>
|
</Button>
|
||||||
</OGDialogTrigger>
|
</OGDialogTrigger>
|
||||||
|
<OGDialogContent className={cn('w-11/12 max-w-6xl', isSmallScreen && 'px-1 pb-1')}>
|
||||||
|
<OGDialogHeader>
|
||||||
|
<OGDialogTitle>{localize('com_nav_archived_chats')}</OGDialogTitle>
|
||||||
|
</OGDialogHeader>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={flattenedConversations}
|
||||||
|
isLoading={effectiveIsLoading}
|
||||||
|
isFetching={effectiveIsFetching}
|
||||||
|
config={{
|
||||||
|
skeleton: { count: 11 },
|
||||||
|
search: {
|
||||||
|
filterColumn: 'title',
|
||||||
|
enableSearch: true,
|
||||||
|
debounce: 300,
|
||||||
|
},
|
||||||
|
selection: {
|
||||||
|
enableRowSelection: false,
|
||||||
|
showCheckboxes: false,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
filterValue={searchValue}
|
||||||
|
onFilterChange={handleSearchChange}
|
||||||
|
fetchNextPage={handleFetchNextPage}
|
||||||
|
hasNextPage={hasNextPage}
|
||||||
|
isFetchingNextPage={isFetchingNextPage}
|
||||||
|
sorting={sorting}
|
||||||
|
onSortingChange={handleSortingChange}
|
||||||
|
/>
|
||||||
|
</OGDialogContent>
|
||||||
|
</OGDialog>
|
||||||
|
<OGDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
|
||||||
<OGDialogTemplate
|
<OGDialogTemplate
|
||||||
title={localize('com_nav_archived_chats')}
|
showCloseButton={false}
|
||||||
className="max-w-[1000px]"
|
title={localize('com_ui_delete_archived_chats')}
|
||||||
showCancelButton={false}
|
className="w-11/12 max-w-md"
|
||||||
main={<ArchivedChatsTable isOpen={isOpen} onOpenChange={setIsOpen} />}
|
main={
|
||||||
|
<div className="flex w-full flex-col items-center gap-2">
|
||||||
|
<div className="grid w-full items-center gap-2">
|
||||||
|
<Label className="text-left text-sm font-medium">
|
||||||
|
{localize('com_ui_delete_confirm')} <strong>{deleteRow?.title}</strong>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
selection={{
|
||||||
|
selectHandler: confirmDelete,
|
||||||
|
selectClasses: `bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white ${
|
||||||
|
deleteMutation.isLoading ? 'cursor-not-allowed opacity-80' : ''
|
||||||
|
}`,
|
||||||
|
selectText: deleteMutation.isLoading ? <Spinner /> : localize('com_ui_delete'),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</OGDialog>
|
</OGDialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,328 +0,0 @@
|
||||||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
|
||||||
import { Trans } from 'react-i18next';
|
|
||||||
import debounce from 'lodash/debounce';
|
|
||||||
import { useRecoilValue } from 'recoil';
|
|
||||||
import { TrashIcon, ArchiveRestore, ArrowUp, ArrowDown, ArrowUpDown } from 'lucide-react';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Label,
|
|
||||||
Spinner,
|
|
||||||
OGDialog,
|
|
||||||
DataTable,
|
|
||||||
TooltipAnchor,
|
|
||||||
useMediaQuery,
|
|
||||||
OGDialogTitle,
|
|
||||||
OGDialogHeader,
|
|
||||||
useToastContext,
|
|
||||||
OGDialogContent,
|
|
||||||
} from '@librechat/client';
|
|
||||||
import type { ConversationListParams, TConversation } from 'librechat-data-provider';
|
|
||||||
import {
|
|
||||||
useConversationsInfiniteQuery,
|
|
||||||
useDeleteConversationMutation,
|
|
||||||
useArchiveConvoMutation,
|
|
||||||
} from '~/data-provider';
|
|
||||||
import { MinimalIcon } from '~/components/Endpoints';
|
|
||||||
import { NotificationSeverity } from '~/common';
|
|
||||||
import { formatDate, logger } from '~/utils';
|
|
||||||
import { useLocalize } from '~/hooks';
|
|
||||||
import store from '~/store';
|
|
||||||
|
|
||||||
const DEFAULT_PARAMS: ConversationListParams = {
|
|
||||||
isArchived: true,
|
|
||||||
sortBy: 'createdAt',
|
|
||||||
sortDirection: 'desc',
|
|
||||||
search: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ArchivedChatsTable({
|
|
||||||
onOpenChange,
|
|
||||||
}: {
|
|
||||||
onOpenChange: (isOpen: boolean) => void;
|
|
||||||
}) {
|
|
||||||
const localize = useLocalize();
|
|
||||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
|
||||||
const { showToast } = useToastContext();
|
|
||||||
const searchState = useRecoilValue(store.search);
|
|
||||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
|
||||||
const [queryParams, setQueryParams] = useState<ConversationListParams>(DEFAULT_PARAMS);
|
|
||||||
const [deleteConversation, setDeleteConversation] = useState<TConversation | null>(null);
|
|
||||||
|
|
||||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, refetch, isLoading } =
|
|
||||||
useConversationsInfiniteQuery(queryParams, {
|
|
||||||
staleTime: 0,
|
|
||||||
cacheTime: 5 * 60 * 1000,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
refetchOnMount: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSort = useCallback((sortField: string, sortOrder: 'asc' | 'desc') => {
|
|
||||||
setQueryParams((prev) => ({
|
|
||||||
...prev,
|
|
||||||
sortBy: sortField as 'title' | 'createdAt',
|
|
||||||
sortDirection: sortOrder,
|
|
||||||
}));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleFilterChange = useCallback((value: string) => {
|
|
||||||
const encodedValue = encodeURIComponent(value.trim());
|
|
||||||
setQueryParams((prev) => ({
|
|
||||||
...prev,
|
|
||||||
search: encodedValue,
|
|
||||||
}));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const debouncedFilterChange = useMemo(
|
|
||||||
() => debounce(handleFilterChange, 300),
|
|
||||||
[handleFilterChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
debouncedFilterChange.cancel();
|
|
||||||
};
|
|
||||||
}, [debouncedFilterChange]);
|
|
||||||
|
|
||||||
const allConversations = useMemo(() => {
|
|
||||||
if (!data?.pages) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return data.pages.flatMap((page) => page?.conversations?.filter(Boolean) ?? []);
|
|
||||||
}, [data?.pages]);
|
|
||||||
|
|
||||||
const deleteMutation = useDeleteConversationMutation({
|
|
||||||
onSuccess: async () => {
|
|
||||||
setIsDeleteOpen(false);
|
|
||||||
await refetch();
|
|
||||||
showToast({
|
|
||||||
message: localize('com_ui_convo_delete_success'),
|
|
||||||
severity: NotificationSeverity.SUCCESS,
|
|
||||||
showIcon: true,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: (error: unknown) => {
|
|
||||||
logger.error('Error deleting archived conversation:', error);
|
|
||||||
showToast({
|
|
||||||
message: localize('com_ui_archive_delete_error') as string,
|
|
||||||
severity: NotificationSeverity.ERROR,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const unarchiveMutation = useArchiveConvoMutation({
|
|
||||||
onSuccess: async () => {
|
|
||||||
await refetch();
|
|
||||||
},
|
|
||||||
onError: (error: unknown) => {
|
|
||||||
logger.error('Error unarchiving conversation', error);
|
|
||||||
showToast({
|
|
||||||
message: localize('com_ui_unarchive_error') as string,
|
|
||||||
severity: NotificationSeverity.ERROR,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleFetchNextPage = useCallback(async () => {
|
|
||||||
if (!hasNextPage || isFetchingNextPage) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await fetchNextPage();
|
|
||||||
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
|
|
||||||
|
|
||||||
const columns = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
accessorKey: 'title',
|
|
||||||
header: () => {
|
|
||||||
const isSorted = queryParams.sortBy === 'title';
|
|
||||||
const sortDirection = queryParams.sortDirection;
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
|
|
||||||
onClick={() =>
|
|
||||||
handleSort('title', isSorted && sortDirection === 'asc' ? 'desc' : 'asc')
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{localize('com_nav_archive_name')}
|
|
||||||
{isSorted && sortDirection === 'asc' && (
|
|
||||||
<ArrowUp className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
|
||||||
)}
|
|
||||||
{isSorted && sortDirection === 'desc' && (
|
|
||||||
<ArrowDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
|
||||||
)}
|
|
||||||
{!isSorted && <ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const { conversationId, title } = row.original;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex items-center gap-2 truncate rounded-sm"
|
|
||||||
onClick={() => window.open(`/c/${conversationId}`, '_blank')}
|
|
||||||
>
|
|
||||||
<MinimalIcon
|
|
||||||
endpoint={row.original.endpoint}
|
|
||||||
size={28}
|
|
||||||
isCreatedByUser={false}
|
|
||||||
iconClassName="size-4"
|
|
||||||
/>
|
|
||||||
<span className="underline">{title}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
meta: {
|
|
||||||
size: isSmallScreen ? '70%' : '50%',
|
|
||||||
mobileSize: '70%',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'createdAt',
|
|
||||||
header: () => {
|
|
||||||
const isSorted = queryParams.sortBy === 'createdAt';
|
|
||||||
const sortDirection = queryParams.sortDirection;
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="px-2 py-0 text-xs hover:bg-surface-hover sm:px-2 sm:py-2 sm:text-sm"
|
|
||||||
onClick={() =>
|
|
||||||
handleSort('createdAt', isSorted && sortDirection === 'asc' ? 'desc' : 'asc')
|
|
||||||
}
|
|
||||||
aria-label={localize('com_nav_archive_created_at_sort')}
|
|
||||||
>
|
|
||||||
{localize('com_nav_archive_created_at')}
|
|
||||||
{isSorted && sortDirection === 'asc' && (
|
|
||||||
<ArrowUp className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
|
||||||
)}
|
|
||||||
{isSorted && sortDirection === 'desc' && (
|
|
||||||
<ArrowDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />
|
|
||||||
)}
|
|
||||||
{!isSorted && <ArrowUpDown className="ml-2 h-3 w-4 sm:h-4 sm:w-4" />}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
cell: ({ row }) => formatDate(row.original.createdAt?.toString() ?? '', isSmallScreen),
|
|
||||||
meta: {
|
|
||||||
size: isSmallScreen ? '30%' : '35%',
|
|
||||||
mobileSize: '30%',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'actions',
|
|
||||||
header: () => (
|
|
||||||
<Label className="px-2 py-0 text-xs sm:px-2 sm:py-2 sm:text-sm">
|
|
||||||
{localize('com_assistants_actions')}
|
|
||||||
</Label>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const conversation = row.original;
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<TooltipAnchor
|
|
||||||
description={localize('com_ui_unarchive')}
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="h-8 w-8 p-0 hover:bg-surface-hover"
|
|
||||||
onClick={() =>
|
|
||||||
unarchiveMutation.mutate({
|
|
||||||
conversationId: conversation.conversationId,
|
|
||||||
isArchived: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
title={localize('com_ui_unarchive')}
|
|
||||||
aria-label={localize('com_ui_unarchive')}
|
|
||||||
disabled={unarchiveMutation.isLoading}
|
|
||||||
>
|
|
||||||
{unarchiveMutation.isLoading ? (
|
|
||||||
<Spinner />
|
|
||||||
) : (
|
|
||||||
<ArchiveRestore className="size-4" aria-hidden="true" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<TooltipAnchor
|
|
||||||
description={localize('com_ui_delete')}
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="h-8 w-8 p-0 hover:bg-surface-hover"
|
|
||||||
onClick={() => {
|
|
||||||
setDeleteConversation(row.original);
|
|
||||||
setIsDeleteOpen(true);
|
|
||||||
}}
|
|
||||||
title={localize('com_ui_delete')}
|
|
||||||
aria-label={localize('com_ui_delete')}
|
|
||||||
>
|
|
||||||
<TrashIcon className="size-4" aria-hidden="true" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
meta: {
|
|
||||||
size: '15%',
|
|
||||||
mobileSize: '25%',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[handleSort, isSmallScreen, localize, queryParams, unarchiveMutation],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DataTable
|
|
||||||
columns={columns}
|
|
||||||
data={allConversations}
|
|
||||||
filterColumn="title"
|
|
||||||
onFilterChange={debouncedFilterChange}
|
|
||||||
filterValue={queryParams.search}
|
|
||||||
fetchNextPage={handleFetchNextPage}
|
|
||||||
hasNextPage={hasNextPage}
|
|
||||||
isFetchingNextPage={isFetchingNextPage}
|
|
||||||
isLoading={isLoading}
|
|
||||||
showCheckboxes={false}
|
|
||||||
enableSearch={searchState.enabled === true}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<OGDialog open={isDeleteOpen} onOpenChange={onOpenChange}>
|
|
||||||
<OGDialogContent
|
|
||||||
title={localize('com_ui_delete_confirm', {
|
|
||||||
title: deleteConversation?.title ?? localize('com_ui_untitled'),
|
|
||||||
})}
|
|
||||||
className="w-11/12 max-w-md"
|
|
||||||
>
|
|
||||||
<OGDialogHeader>
|
|
||||||
<OGDialogTitle>
|
|
||||||
<Trans
|
|
||||||
i18nKey="com_ui_delete_confirm_strong"
|
|
||||||
values={{ title: deleteConversation?.title }}
|
|
||||||
components={{ strong: <strong /> }}
|
|
||||||
/>
|
|
||||||
</OGDialogTitle>
|
|
||||||
</OGDialogHeader>
|
|
||||||
<div className="flex justify-end gap-4 pt-4">
|
|
||||||
<Button aria-label="cancel" variant="outline" onClick={() => setIsDeleteOpen(false)}>
|
|
||||||
{localize('com_ui_cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={() =>
|
|
||||||
deleteMutation.mutate({
|
|
||||||
conversationId: deleteConversation?.conversationId ?? '',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
disabled={deleteMutation.isLoading}
|
|
||||||
>
|
|
||||||
{deleteMutation.isLoading ? <Spinner /> : localize('com_ui_delete')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</OGDialogContent>
|
|
||||||
</OGDialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -25,7 +25,7 @@ export default function OAuthSuccess() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-8">
|
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-8">
|
||||||
<div className="w-full max-w-md rounded-lg bg-white p-8 text-center shadow-lg">
|
<div className="w-full max-w-md rounded-xl bg-white p-8 text-center shadow-lg">
|
||||||
<h1 className="mb-4 text-3xl font-bold text-gray-900">
|
<h1 className="mb-4 text-3xl font-bold text-gray-900">
|
||||||
{localize('com_ui_oauth_success_title') || 'Authentication Successful'}
|
{localize('com_ui_oauth_success_title') || 'Authentication Successful'}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"chat_direction_left_to_right": "Left to Right",
|
"chat_direction_left_to_right": "Chat direction set to left to right",
|
||||||
"chat_direction_right_to_left": "Right to Left",
|
"chat_direction_right_to_left": "Chat direction set to right to left",
|
||||||
"com_a11y_ai_composing": "The AI is still composing.",
|
"com_a11y_ai_composing": "The AI is still composing.",
|
||||||
"com_a11y_end": "The AI has finished their reply.",
|
"com_a11y_end": "The AI has finished their reply.",
|
||||||
"com_a11y_start": "The AI has started their reply.",
|
"com_a11y_start": "The AI has started their reply.",
|
||||||
|
|
@ -379,12 +379,12 @@
|
||||||
"com_files_filter_by": "Filter files by...",
|
"com_files_filter_by": "Filter files by...",
|
||||||
"com_files_filter_input": "Filter listed files by name...",
|
"com_files_filter_input": "Filter listed files by name...",
|
||||||
"com_files_no_results": "No results.",
|
"com_files_no_results": "No results.",
|
||||||
"com_files_number_selected": "{{0}} of {{1}} items(s) selected",
|
"com_files_number_selected": "{{0}} of {{1}} items selected",
|
||||||
"com_files_preparing_download": "Preparing download...",
|
"com_files_preparing_download": "Preparing download...",
|
||||||
"com_files_result_found": "{{count}} result found",
|
"com_files_result_found": "{{count}} result found",
|
||||||
"com_files_results_found": "{{count}} results found",
|
"com_files_results_found": "{{count}} results found",
|
||||||
"com_files_sharepoint_picker_title": "Pick Files",
|
"com_files_sharepoint_picker_title": "Pick Files",
|
||||||
"com_files_table": "something needs to go here. was empty",
|
"com_files_table": "Files Table",
|
||||||
"com_files_upload_local_machine": "From Local Computer",
|
"com_files_upload_local_machine": "From Local Computer",
|
||||||
"com_files_upload_sharepoint": "From SharePoint",
|
"com_files_upload_sharepoint": "From SharePoint",
|
||||||
"com_generated_files": "Generated files:",
|
"com_generated_files": "Generated files:",
|
||||||
|
|
@ -394,9 +394,9 @@
|
||||||
"com_nav_account_settings": "Account Settings",
|
"com_nav_account_settings": "Account Settings",
|
||||||
"com_nav_always_make_prod": "Always make new versions production",
|
"com_nav_always_make_prod": "Always make new versions production",
|
||||||
"com_nav_archive_created_at": "Date Archived",
|
"com_nav_archive_created_at": "Date Archived",
|
||||||
"com_nav_archive_created_at_sort": "Sort by Date Archived",
|
|
||||||
"com_nav_archive_name": "Name",
|
"com_nav_archive_name": "Name",
|
||||||
"com_nav_archived_chats": "Archived chats",
|
"com_nav_archived_chats": "Archived chats",
|
||||||
|
"com_ui_manage_archived_chats": "Manage archived chats",
|
||||||
"com_nav_at_command": "@-Command",
|
"com_nav_at_command": "@-Command",
|
||||||
"com_nav_at_command_description": "Toggle command \"@\" for switching endpoints, models, presets, etc.",
|
"com_nav_at_command_description": "Toggle command \"@\" for switching endpoints, models, presets, etc.",
|
||||||
"com_nav_audio_play_error": "Error playing audio: {{0}}",
|
"com_nav_audio_play_error": "Error playing audio: {{0}}",
|
||||||
|
|
@ -767,7 +767,6 @@
|
||||||
"com_ui_bookmarks_update_error": "There was an error updating the bookmark",
|
"com_ui_bookmarks_update_error": "There was an error updating the bookmark",
|
||||||
"com_ui_bookmarks_update_success": "Bookmark updated successfully",
|
"com_ui_bookmarks_update_success": "Bookmark updated successfully",
|
||||||
"com_ui_by_author": "by {{0}}",
|
"com_ui_by_author": "by {{0}}",
|
||||||
"com_ui_bulk_delete_error": "Failed to delete shared links",
|
|
||||||
"com_ui_callback_url": "Callback URL",
|
"com_ui_callback_url": "Callback URL",
|
||||||
"com_ui_cancel": "Cancel",
|
"com_ui_cancel": "Cancel",
|
||||||
"com_ui_cancelled": "Cancelled",
|
"com_ui_cancelled": "Cancelled",
|
||||||
|
|
@ -830,7 +829,6 @@
|
||||||
"com_ui_create_prompt": "Create Prompt",
|
"com_ui_create_prompt": "Create Prompt",
|
||||||
"com_ui_create_prompt_page": "New Prompt Configuration Page",
|
"com_ui_create_prompt_page": "New Prompt Configuration Page",
|
||||||
"com_ui_creating_image": "Creating image. May take a moment",
|
"com_ui_creating_image": "Creating image. May take a moment",
|
||||||
"com_ui_creation_date_sort": "Sort by Creation Date",
|
|
||||||
"com_ui_current": "Current",
|
"com_ui_current": "Current",
|
||||||
"com_ui_currently_production": "Currently in production",
|
"com_ui_currently_production": "Currently in production",
|
||||||
"com_ui_custom": "Custom",
|
"com_ui_custom": "Custom",
|
||||||
|
|
@ -875,6 +873,7 @@
|
||||||
"com_ui_delete_shared_link": "Delete Shared Link - {{title}}",
|
"com_ui_delete_shared_link": "Delete Shared Link - {{title}}",
|
||||||
"com_ui_delete_shared_link_heading": "Delete Shared Link",
|
"com_ui_delete_shared_link_heading": "Delete Shared Link",
|
||||||
"com_ui_delete_prompt_name": "Delete Prompt - {{name}}",
|
"com_ui_delete_prompt_name": "Delete Prompt - {{name}}",
|
||||||
|
"com_ui_delete_archived_chats": "Delete archived chat?",
|
||||||
"com_ui_delete_success": "Successfully deleted",
|
"com_ui_delete_success": "Successfully deleted",
|
||||||
"com_ui_delete_tool": "Delete Tool",
|
"com_ui_delete_tool": "Delete Tool",
|
||||||
"com_ui_delete_tool_confirm": "Are you sure you want to delete this tool?",
|
"com_ui_delete_tool_confirm": "Are you sure you want to delete this tool?",
|
||||||
|
|
@ -1009,6 +1008,7 @@
|
||||||
"com_ui_image_edited": "Image edited",
|
"com_ui_image_edited": "Image edited",
|
||||||
"com_ui_image_gen": "Image Gen",
|
"com_ui_image_gen": "Image Gen",
|
||||||
"com_ui_import": "Import",
|
"com_ui_import": "Import",
|
||||||
|
"com_ui_importing": "Importing",
|
||||||
"com_ui_import_conversation_error": "There was an error importing your conversations",
|
"com_ui_import_conversation_error": "There was an error importing your conversations",
|
||||||
"com_ui_import_conversation_file_type_error": "Unsupported import type",
|
"com_ui_import_conversation_file_type_error": "Unsupported import type",
|
||||||
"com_ui_import_conversation_info": "Import conversations from a JSON file",
|
"com_ui_import_conversation_info": "Import conversations from a JSON file",
|
||||||
|
|
@ -1082,7 +1082,6 @@
|
||||||
"com_ui_more_info": "More info",
|
"com_ui_more_info": "More info",
|
||||||
"com_ui_my_prompts": "My Prompts",
|
"com_ui_my_prompts": "My Prompts",
|
||||||
"com_ui_name": "Name",
|
"com_ui_name": "Name",
|
||||||
"com_ui_name_sort": "Sort by Name",
|
|
||||||
"com_ui_new": "New",
|
"com_ui_new": "New",
|
||||||
"com_ui_new_chat": "New chat",
|
"com_ui_new_chat": "New chat",
|
||||||
"com_ui_new_conversation_title": "New Conversation Title",
|
"com_ui_new_conversation_title": "New Conversation Title",
|
||||||
|
|
@ -1098,7 +1097,6 @@
|
||||||
"com_ui_no_read_access": "You don't have permission to view memories",
|
"com_ui_no_read_access": "You don't have permission to view memories",
|
||||||
"com_ui_no_results_found": "No results found",
|
"com_ui_no_results_found": "No results found",
|
||||||
"com_ui_no_terms_content": "No terms and conditions content to display",
|
"com_ui_no_terms_content": "No terms and conditions content to display",
|
||||||
"com_ui_no_valid_items": "something needs to go here. was empty",
|
|
||||||
"com_ui_none": "None",
|
"com_ui_none": "None",
|
||||||
"com_ui_not_used": "Not Used",
|
"com_ui_not_used": "Not Used",
|
||||||
"com_ui_nothing_found": "Nothing found",
|
"com_ui_nothing_found": "Nothing found",
|
||||||
|
|
@ -1263,9 +1261,12 @@
|
||||||
"com_ui_share_qr_code_description": "QR code for sharing this conversation link",
|
"com_ui_share_qr_code_description": "QR code for sharing this conversation link",
|
||||||
"com_ui_share_update_message": "Your name, custom instructions, and any messages you add after sharing stay private.",
|
"com_ui_share_update_message": "Your name, custom instructions, and any messages you add after sharing stay private.",
|
||||||
"com_ui_share_var": "Share {{0}}",
|
"com_ui_share_var": "Share {{0}}",
|
||||||
"com_ui_shared_link_bulk_delete_success": "Successfully deleted shared links",
|
|
||||||
"com_ui_shared_link_delete_success": "Successfully deleted shared link",
|
"com_ui_shared_link_delete_success": "Successfully deleted shared link",
|
||||||
|
"com_ui_archived_conversation_delete_success": "Successfully deleted archived conversation",
|
||||||
"com_ui_shared_link_not_found": "Shared link not found",
|
"com_ui_shared_link_not_found": "Shared link not found",
|
||||||
|
"com_ui_open_link": "Open Link {{0}}",
|
||||||
|
"com_ui_view_source_conversation": "View Source Conversation {{0}}",
|
||||||
|
"com_ui_delete_link_title": "Delete Shared Link {{0}}",
|
||||||
"com_ui_shared_prompts": "Shared Prompts",
|
"com_ui_shared_prompts": "Shared Prompts",
|
||||||
"com_ui_shop": "Shopping",
|
"com_ui_shop": "Shopping",
|
||||||
"com_ui_show_all": "Show All",
|
"com_ui_show_all": "Show All",
|
||||||
|
|
@ -1395,5 +1396,8 @@
|
||||||
"com_ui_zoom_in": "Zoom in",
|
"com_ui_zoom_in": "Zoom in",
|
||||||
"com_ui_zoom_level": "Zoom level",
|
"com_ui_zoom_level": "Zoom level",
|
||||||
"com_ui_zoom_out": "Zoom out",
|
"com_ui_zoom_out": "Zoom out",
|
||||||
|
"com_ui_open_conversation": "Open conversation {{0}}",
|
||||||
|
"com_ui_delete_conversation_title": "Delete conversation {{0}}",
|
||||||
|
"com_ui_unarchive_conversation_title": "Unarchive conversation {{0}}",
|
||||||
"com_user_message": "You"
|
"com_user_message": "You"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2601,7 +2601,7 @@ html {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
border-radius: 1rem;
|
border-radius: 0.7rem;
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
border-color: var(--border-light);
|
border-color: var(--border-light);
|
||||||
|
|
@ -2674,6 +2674,7 @@ html {
|
||||||
translate: 0;
|
translate: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.animate-popover-top,
|
||||||
.animate-popover {
|
.animate-popover {
|
||||||
transform-origin: top;
|
transform-origin: top;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|
@ -2682,12 +2683,13 @@ html {
|
||||||
transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
transform: scale(0.95) translateY(-0.5rem);
|
transform: scale(0.95) translateY(-0.5rem);
|
||||||
}
|
}
|
||||||
|
.animate-popover-top[data-enter],
|
||||||
.animate-popover[data-enter] {
|
.animate-popover[data-enter] {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: scale(1) translateY(0);
|
transform: scale(1) translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Left (existing) */
|
||||||
.animate-popover-left {
|
.animate-popover-left {
|
||||||
transform-origin: left;
|
transform-origin: left;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|
@ -2696,12 +2698,92 @@ html {
|
||||||
transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
transform: scale(0.95) translateX(-0.5rem);
|
transform: scale(0.95) translateX(-0.5rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
.animate-popover-left[data-enter] {
|
.animate-popover-left[data-enter] {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: scale(1) translateX(0);
|
transform: scale(1) translateX(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Right */
|
||||||
|
.animate-popover-right {
|
||||||
|
transform-origin: right;
|
||||||
|
opacity: 0;
|
||||||
|
transition:
|
||||||
|
opacity 150ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
transform: scale(0.95) translateX(0.5rem);
|
||||||
|
}
|
||||||
|
.animate-popover-right[data-enter] {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bottom */
|
||||||
|
.animate-popover-bottom {
|
||||||
|
transform-origin: bottom;
|
||||||
|
opacity: 0;
|
||||||
|
transition:
|
||||||
|
opacity 150ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
transform: scale(0.95) translateY(0.5rem);
|
||||||
|
}
|
||||||
|
.animate-popover-bottom[data-enter] {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Corners */
|
||||||
|
.animate-popover-top-left {
|
||||||
|
transform-origin: top left;
|
||||||
|
opacity: 0;
|
||||||
|
transition:
|
||||||
|
opacity 150ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
transform: scale(0.95) translate(-0.5rem, -0.5rem);
|
||||||
|
}
|
||||||
|
.animate-popover-top-left[data-enter] {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translate(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-popover-top-right {
|
||||||
|
transform-origin: top right;
|
||||||
|
opacity: 0;
|
||||||
|
transition:
|
||||||
|
opacity 150ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
transform: scale(0.95) translate(0.5rem, -0.5rem);
|
||||||
|
}
|
||||||
|
.animate-popover-top-right[data-enter] {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translate(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-popover-bottom-left {
|
||||||
|
transform-origin: bottom left;
|
||||||
|
opacity: 0;
|
||||||
|
transition:
|
||||||
|
opacity 150ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
transform: scale(0.95) translate(-0.5rem, 0.5rem);
|
||||||
|
}
|
||||||
|
.animate-popover-bottom-left[data-enter] {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translate(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-popover-bottom-right {
|
||||||
|
transform-origin: bottom right;
|
||||||
|
opacity: 0;
|
||||||
|
transition:
|
||||||
|
opacity 150ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||||
|
transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
transform: scale(0.95) translate(0.5rem, 0.5rem);
|
||||||
|
}
|
||||||
|
.animate-popover-bottom-right[data-enter] {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translate(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
/** Note: ensure KaTeX can spread across visible space */
|
/** Note: ensure KaTeX can spread across visible space */
|
||||||
.message-content pre:has(> span.katex) {
|
.message-content pre:has(> span.katex) {
|
||||||
overflow: visible !important;
|
overflow: visible !important;
|
||||||
|
|
|
||||||
2962
package-lock.json
generated
2962
package-lock.json
generated
File diff suppressed because it is too large
Load diff
24
packages/client/babel.config.js
Normal file
24
packages/client/babel.config.js
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
module.exports = {
|
||||||
|
presets: [
|
||||||
|
['@babel/preset-env', { targets: { node: 'current' } }],
|
||||||
|
['@babel/preset-react', { runtime: 'automatic' }],
|
||||||
|
'@babel/preset-typescript',
|
||||||
|
],
|
||||||
|
plugins: [
|
||||||
|
'babel-plugin-replace-ts-export-assignment',
|
||||||
|
// Transform import.meta.env for Jest (used by Vite-style code)
|
||||||
|
function () {
|
||||||
|
return {
|
||||||
|
visitor: {
|
||||||
|
MetaProperty(path) {
|
||||||
|
if (path.node.meta.name === 'import' && path.node.property.name === 'meta') {
|
||||||
|
path.replaceWithSourceString(`({
|
||||||
|
env: { MODE: 'test', DEV: true, PROD: false }
|
||||||
|
})`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
29
packages/client/jest.config.js
Normal file
29
packages/client/jest.config.js
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
export default {
|
||||||
|
collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}', '!<rootDir>/node_modules/'],
|
||||||
|
coveragePathIgnorePatterns: ['/node_modules/', '/dist/'],
|
||||||
|
coverageReporters: ['text', 'cobertura'],
|
||||||
|
testResultsProcessor: 'jest-junit',
|
||||||
|
moduleNameMapper: {
|
||||||
|
'^@src/(.*)$': '<rootDir>/src/$1',
|
||||||
|
'^~/(.*)$': '<rootDir>/src/$1',
|
||||||
|
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
|
||||||
|
},
|
||||||
|
// coverageThreshold: {
|
||||||
|
// global: {
|
||||||
|
// statements: 58,
|
||||||
|
// branches: 49,
|
||||||
|
// functions: 50,
|
||||||
|
// lines: 57,
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
restoreMocks: true,
|
||||||
|
testTimeout: 15000,
|
||||||
|
// React component testing requires jsdom environment
|
||||||
|
testEnvironment: 'jsdom',
|
||||||
|
testEnvironmentOptions: { url: 'http://localhost:3080' },
|
||||||
|
transform: {
|
||||||
|
'^.+\\.(ts|tsx|js|jsx)$': 'babel-jest',
|
||||||
|
},
|
||||||
|
transformIgnorePatterns: ['node_modules/(?!(@tanstack|lucide-react|@dicebear)/)'],
|
||||||
|
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
||||||
|
};
|
||||||
159
packages/client/jest.setup.ts
Normal file
159
packages/client/jest.setup.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
|
||||||
|
// Mock import.meta.env for Vite compatibility
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(global as any).importMetaEnv = {
|
||||||
|
MODE: 'test',
|
||||||
|
DEV: true,
|
||||||
|
PROD: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'import', {
|
||||||
|
value: {
|
||||||
|
meta: {
|
||||||
|
env: {
|
||||||
|
MODE: 'test',
|
||||||
|
DEV: true,
|
||||||
|
PROD: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock @dicebear modules (ESM-only modules)
|
||||||
|
jest.mock('@dicebear/core', () => ({
|
||||||
|
createAvatar: jest.fn(() => ({
|
||||||
|
toDataUri: jest.fn(() => 'data:image/svg+xml;base64,mock'),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@dicebear/collection', () => ({
|
||||||
|
initials: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock window.matchMedia
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: jest.fn().mockImplementation((query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: jest.fn(),
|
||||||
|
removeListener: jest.fn(),
|
||||||
|
addEventListener: jest.fn(),
|
||||||
|
removeEventListener: jest.fn(),
|
||||||
|
dispatchEvent: jest.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock ResizeObserver
|
||||||
|
class MockResizeObserver {
|
||||||
|
callback: ResizeObserverCallback;
|
||||||
|
|
||||||
|
constructor(callback: ResizeObserverCallback) {
|
||||||
|
this.callback = callback;
|
||||||
|
}
|
||||||
|
|
||||||
|
observe = jest.fn();
|
||||||
|
unobserve = jest.fn();
|
||||||
|
disconnect = jest.fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'ResizeObserver', {
|
||||||
|
writable: true,
|
||||||
|
value: MockResizeObserver,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock IntersectionObserver
|
||||||
|
class MockIntersectionObserver {
|
||||||
|
callback: IntersectionObserverCallback;
|
||||||
|
options?: IntersectionObserverInit;
|
||||||
|
|
||||||
|
constructor(callback: IntersectionObserverCallback, options?: IntersectionObserverInit) {
|
||||||
|
this.callback = callback;
|
||||||
|
this.options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
observe = jest.fn();
|
||||||
|
unobserve = jest.fn();
|
||||||
|
disconnect = jest.fn();
|
||||||
|
takeRecords = jest.fn().mockReturnValue([]);
|
||||||
|
root = null;
|
||||||
|
rootMargin = '';
|
||||||
|
thresholds = [0];
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'IntersectionObserver', {
|
||||||
|
writable: true,
|
||||||
|
value: MockIntersectionObserver,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock scrollTo
|
||||||
|
Object.defineProperty(window, 'scrollTo', {
|
||||||
|
writable: true,
|
||||||
|
value: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock requestAnimationFrame and cancelAnimationFrame
|
||||||
|
let rafId = 0;
|
||||||
|
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||||
|
writable: true,
|
||||||
|
value: jest.fn((callback: FrameRequestCallback) => {
|
||||||
|
rafId += 1;
|
||||||
|
setTimeout(() => callback(performance.now()), 0);
|
||||||
|
return rafId;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'cancelAnimationFrame', {
|
||||||
|
writable: true,
|
||||||
|
value: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock react-i18next
|
||||||
|
jest.mock('react-i18next', () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string, options?: Record<string, unknown>) => {
|
||||||
|
if (options && typeof options === 'object') {
|
||||||
|
let result = key;
|
||||||
|
Object.entries(options).forEach(([k, v]) => {
|
||||||
|
result = result.replace(`{{${k}}}`, String(v));
|
||||||
|
result = result.replace(`{${k}}`, String(v));
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
},
|
||||||
|
i18n: {
|
||||||
|
changeLanguage: jest.fn(),
|
||||||
|
language: 'en',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
Trans: ({ children }: { children: React.ReactNode }) => children,
|
||||||
|
initReactI18next: {
|
||||||
|
type: '3rdParty',
|
||||||
|
init: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock useLocalize hook
|
||||||
|
jest.mock('~/hooks', () => ({
|
||||||
|
...jest.requireActual('~/hooks'),
|
||||||
|
useLocalize: () => (key: string, options?: Record<string, unknown>) => {
|
||||||
|
if (options && typeof options === 'object') {
|
||||||
|
let result = key;
|
||||||
|
Object.entries(options).forEach(([k, v]) => {
|
||||||
|
result = result.replace(`{{${k}}}`, String(v));
|
||||||
|
result = result.replace(`{${k}}`, String(v));
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
},
|
||||||
|
useMediaQuery: jest.fn(() => false),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Clear mocks before each test
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
@ -75,19 +75,32 @@
|
||||||
"tailwind-merge": "^1.9.1"
|
"tailwind-merge": "^1.9.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.28.5",
|
||||||
|
"@babel/preset-env": "^7.28.5",
|
||||||
|
"@babel/preset-react": "^7.28.5",
|
||||||
|
"@babel/preset-typescript": "^7.28.5",
|
||||||
"@rollup/plugin-alias": "^5.1.0",
|
"@rollup/plugin-alias": "^5.1.0",
|
||||||
"@rollup/plugin-commonjs": "^29.0.0",
|
"@rollup/plugin-commonjs": "^29.0.0",
|
||||||
"@rollup/plugin-node-resolve": "^15.0.0",
|
"@rollup/plugin-node-resolve": "^15.0.0",
|
||||||
"@rollup/plugin-replace": "^5.0.5",
|
"@rollup/plugin-replace": "^5.0.5",
|
||||||
"@rollup/plugin-terser": "^0.4.4",
|
"@rollup/plugin-terser": "^0.4.4",
|
||||||
"@tanstack/react-query": "^4.28.0",
|
"@tanstack/react-query": "^4.28.0",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
|
"@tanstack/react-virtual": "^3.13.13",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^14.0.0",
|
"@testing-library/react": "^14.0.0",
|
||||||
|
"@types/jest": "^29.5.14",
|
||||||
"@types/react": "^18.2.11",
|
"@types/react": "^18.2.11",
|
||||||
"@types/react-dom": "^18.2.4",
|
"@types/react-dom": "^18.2.4",
|
||||||
|
"babel-jest": "^30.2.0",
|
||||||
"caniuse-lite": "^1.0.30001741",
|
"caniuse-lite": "^1.0.30001741",
|
||||||
"concat-with-sourcemaps": "^1.1.0",
|
"concat-with-sourcemaps": "^1.1.0",
|
||||||
"i18next": "^24.2.3",
|
"i18next": "^24.2.3",
|
||||||
|
"identity-obj-proxy": "^3.0.0",
|
||||||
|
"jest": "^30.2.0",
|
||||||
|
"jest-environment-jsdom": "^30.2.0",
|
||||||
"jotai": "^2.12.5",
|
"jotai": "^2.12.5",
|
||||||
|
"lucide-react": "^0.525.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-i18next": "^15.4.0",
|
"react-i18next": "^15.4.0",
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ const plugins = [
|
||||||
}),
|
}),
|
||||||
replace({
|
replace({
|
||||||
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'production'),
|
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'production'),
|
||||||
|
'process.env.VITE_ENABLE_LOGGER': JSON.stringify(process.env.VITE_ENABLE_LOGGER || 'false'),
|
||||||
|
'process.env.VITE_LOGGER_FILTER': JSON.stringify(process.env.VITE_LOGGER_FILTER || ''),
|
||||||
preventAssignment: true,
|
preventAssignment: true,
|
||||||
}),
|
}),
|
||||||
commonjs(),
|
commonjs(),
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ const Checkbox = React.forwardRef<React.ElementRef<typeof CheckboxPrimitive.Root
|
||||||
<CheckboxPrimitive.Root
|
<CheckboxPrimitive.Root
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
'peer h-4 w-4 shrink-0 rounded-sm border border-border-xheavy ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
470
packages/client/src/components/DataTable/DataTable.hooks.spec.ts
Normal file
470
packages/client/src/components/DataTable/DataTable.hooks.spec.ts
Normal file
|
|
@ -0,0 +1,470 @@
|
||||||
|
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||||
|
import {
|
||||||
|
useDebounced,
|
||||||
|
useOptimizedRowSelection,
|
||||||
|
useColumnStyles,
|
||||||
|
useKeyboardNavigation,
|
||||||
|
} from './DataTable.hooks';
|
||||||
|
import type { TableColumn } from './DataTable.types';
|
||||||
|
|
||||||
|
describe('DataTable Hooks', () => {
|
||||||
|
describe('useDebounced', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the initial value immediately', () => {
|
||||||
|
const { result } = renderHook(() => useDebounced('initial', 300));
|
||||||
|
expect(result.current).toBe('initial');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update value after the delay', () => {
|
||||||
|
const { result, rerender } = renderHook(({ value, delay }) => useDebounced(value, delay), {
|
||||||
|
initialProps: { value: 'initial', delay: 300 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current).toBe('initial');
|
||||||
|
|
||||||
|
rerender({ value: 'updated', delay: 300 });
|
||||||
|
|
||||||
|
// Value should still be initial before delay
|
||||||
|
expect(result.current).toBe('initial');
|
||||||
|
|
||||||
|
// Advance timer past the delay
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current).toBe('updated');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset timer on rapid changes', () => {
|
||||||
|
const { result, rerender } = renderHook(({ value, delay }) => useDebounced(value, delay), {
|
||||||
|
initialProps: { value: 'initial', delay: 300 },
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender({ value: 'change1', delay: 300 });
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender({ value: 'change2', delay: 300 });
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender({ value: 'change3', delay: 300 });
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should still be initial because timer keeps resetting
|
||||||
|
expect(result.current).toBe('initial');
|
||||||
|
|
||||||
|
// Advance past the full delay
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should now be the last value
|
||||||
|
expect(result.current).toBe('change3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle different data types', () => {
|
||||||
|
// Test with number
|
||||||
|
const { result: numberResult } = renderHook(() => useDebounced(42, 100));
|
||||||
|
expect(numberResult.current).toBe(42);
|
||||||
|
|
||||||
|
// Test with object
|
||||||
|
const obj = { foo: 'bar' };
|
||||||
|
const { result: objectResult } = renderHook(() => useDebounced(obj, 100));
|
||||||
|
expect(objectResult.current).toEqual({ foo: 'bar' });
|
||||||
|
|
||||||
|
// Test with array
|
||||||
|
const arr = [1, 2, 3];
|
||||||
|
const { result: arrayResult } = renderHook(() => useDebounced(arr, 100));
|
||||||
|
expect(arrayResult.current).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle zero delay', () => {
|
||||||
|
const { result, rerender } = renderHook(({ value, delay }) => useDebounced(value, delay), {
|
||||||
|
initialProps: { value: 'initial', delay: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender({ value: 'updated', delay: 0 });
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current).toBe('updated');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useOptimizedRowSelection', () => {
|
||||||
|
it('should initialize with empty object by default', () => {
|
||||||
|
const { result } = renderHook(() => useOptimizedRowSelection());
|
||||||
|
const [selection] = result.current;
|
||||||
|
expect(selection).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should initialize with provided selection', () => {
|
||||||
|
const initialSelection = { row1: true, row2: true };
|
||||||
|
const { result } = renderHook(() => useOptimizedRowSelection(initialSelection));
|
||||||
|
const [selection] = result.current;
|
||||||
|
expect(selection).toEqual({ row1: true, row2: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update selection state', () => {
|
||||||
|
const { result } = renderHook(() => useOptimizedRowSelection());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
const [, setSelection] = result.current;
|
||||||
|
setSelection({ row1: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const [selection] = result.current;
|
||||||
|
expect(selection).toEqual({ row1: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return tuple with selection and setter', () => {
|
||||||
|
const { result } = renderHook(() => useOptimizedRowSelection());
|
||||||
|
const [selection, setSelection] = result.current;
|
||||||
|
|
||||||
|
expect(typeof selection).toBe('object');
|
||||||
|
expect(typeof setSelection).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support functional updates', () => {
|
||||||
|
const { result } = renderHook(() => useOptimizedRowSelection({ existing: true }));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
const [, setSelection] = result.current;
|
||||||
|
setSelection((prev) => ({ ...prev, new: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const [selection] = result.current;
|
||||||
|
expect(selection).toEqual({ existing: true, new: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useColumnStyles', () => {
|
||||||
|
let mockContainerRef: React.RefObject<HTMLDivElement>;
|
||||||
|
let mockContainer: HTMLDivElement;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockContainer = document.createElement('div');
|
||||||
|
Object.defineProperty(mockContainer, 'clientWidth', {
|
||||||
|
configurable: true,
|
||||||
|
value: 1000,
|
||||||
|
});
|
||||||
|
mockContainerRef = { current: mockContainer };
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty object when container width is 0', () => {
|
||||||
|
Object.defineProperty(mockContainer, 'clientWidth', { value: 0 });
|
||||||
|
|
||||||
|
const columns: TableColumn<{ name: string }, string>[] = [
|
||||||
|
{ accessorKey: 'name', header: 'Name' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useColumnStyles(columns, false, mockContainerRef));
|
||||||
|
|
||||||
|
expect(result.current).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate fixed width columns', () => {
|
||||||
|
const columns: TableColumn<{ name: string; status: string }, string>[] = [
|
||||||
|
{ accessorKey: 'name', header: 'Name', meta: { size: 200 } },
|
||||||
|
{ accessorKey: 'status', header: 'Status', meta: { size: 100 } },
|
||||||
|
];
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useColumnStyles(columns, false, mockContainerRef));
|
||||||
|
|
||||||
|
expect(result.current.name).toBeDefined();
|
||||||
|
expect(result.current.status).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should distribute available width to flexible columns by priority', () => {
|
||||||
|
const columns: TableColumn<{ col1: string; col2: string; col3: string }, string>[] = [
|
||||||
|
{ accessorKey: 'col1', header: 'Col 1', meta: { size: 200 } }, // fixed
|
||||||
|
{ accessorKey: 'col2', header: 'Col 2', meta: { priority: 2 } }, // flexible, priority 2
|
||||||
|
{ accessorKey: 'col3', header: 'Col 3', meta: { priority: 1 } }, // flexible, priority 1
|
||||||
|
];
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useColumnStyles(columns, false, mockContainerRef));
|
||||||
|
|
||||||
|
// Available width = 1000 - 200 = 800
|
||||||
|
// Total priority = 3
|
||||||
|
// col2 should get 2/3 = ~533px
|
||||||
|
// col3 should get 1/3 = ~266px
|
||||||
|
expect(result.current.col2).toBeDefined();
|
||||||
|
expect(result.current.col3).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle mobile vs desktop sizes', () => {
|
||||||
|
const columns: TableColumn<{ name: string }, string>[] = [
|
||||||
|
{ accessorKey: 'name', header: 'Name', meta: { size: 200, mobileSize: 150 } },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Desktop
|
||||||
|
const { result: desktopResult } = renderHook(() =>
|
||||||
|
useColumnStyles(columns, false, mockContainerRef),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mobile
|
||||||
|
const { result: mobileResult } = renderHook(() =>
|
||||||
|
useColumnStyles(columns, true, mockContainerRef),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mobile should use mobileSize if defined
|
||||||
|
expect(mobileResult.current.name).toBeDefined();
|
||||||
|
expect(desktopResult.current.name).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle columns with id instead of accessorKey', () => {
|
||||||
|
const columns: TableColumn<Record<string, unknown>, unknown>[] = [
|
||||||
|
{ id: 'custom-column', header: 'Custom', meta: { size: 150 } },
|
||||||
|
];
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useColumnStyles(columns, false, mockContainerRef));
|
||||||
|
|
||||||
|
expect(result.current['custom-column']).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty object when container ref is null', () => {
|
||||||
|
const nullRef = { current: null };
|
||||||
|
const columns: TableColumn<{ name: string }, string>[] = [
|
||||||
|
{ accessorKey: 'name', header: 'Name' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useColumnStyles(columns, false, nullRef as React.RefObject<HTMLDivElement>),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useKeyboardNavigation', () => {
|
||||||
|
let mockTableRef: React.RefObject<HTMLDivElement>;
|
||||||
|
let mockTable: HTMLDivElement;
|
||||||
|
let mockOnRowSelect: jest.Mock;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockTable = document.createElement('div');
|
||||||
|
document.body.appendChild(mockTable);
|
||||||
|
mockTableRef = { current: mockTable };
|
||||||
|
mockOnRowSelect = jest.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.removeChild(mockTable);
|
||||||
|
});
|
||||||
|
|
||||||
|
const dispatchKeyEvent = (key: string, target?: HTMLElement) => {
|
||||||
|
const event = new KeyboardEvent('keydown', {
|
||||||
|
key,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
});
|
||||||
|
(target || mockTable).dispatchEvent(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should initialize with focused index of -1', () => {
|
||||||
|
const { result } = renderHook(() => useKeyboardNavigation(mockTableRef, 10, mockOnRowSelect));
|
||||||
|
|
||||||
|
expect(result.current.focusedRowIndex).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should navigate down with ArrowDown key', async () => {
|
||||||
|
const { result } = renderHook(() => useKeyboardNavigation(mockTableRef, 10, mockOnRowSelect));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setFocusedRowIndex(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
dispatchKeyEvent('ArrowDown');
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.focusedRowIndex).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should navigate up with ArrowUp key', async () => {
|
||||||
|
const { result } = renderHook(() => useKeyboardNavigation(mockTableRef, 10, mockOnRowSelect));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setFocusedRowIndex(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
dispatchKeyEvent('ArrowUp');
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.focusedRowIndex).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not go below 0 with ArrowUp', async () => {
|
||||||
|
const { result } = renderHook(() => useKeyboardNavigation(mockTableRef, 10, mockOnRowSelect));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setFocusedRowIndex(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
dispatchKeyEvent('ArrowUp');
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.focusedRowIndex).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not exceed row count with ArrowDown', async () => {
|
||||||
|
const { result } = renderHook(() => useKeyboardNavigation(mockTableRef, 5, mockOnRowSelect));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setFocusedRowIndex(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
dispatchKeyEvent('ArrowDown');
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.focusedRowIndex).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should jump to first row with Home key', async () => {
|
||||||
|
const { result } = renderHook(() => useKeyboardNavigation(mockTableRef, 10, mockOnRowSelect));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setFocusedRowIndex(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
dispatchKeyEvent('Home');
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.focusedRowIndex).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should jump to last row with End key', async () => {
|
||||||
|
const { result } = renderHook(() => useKeyboardNavigation(mockTableRef, 10, mockOnRowSelect));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setFocusedRowIndex(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
dispatchKeyEvent('End');
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.focusedRowIndex).toBe(9);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trigger onRowSelect with Enter key', async () => {
|
||||||
|
const { result } = renderHook(() => useKeyboardNavigation(mockTableRef, 10, mockOnRowSelect));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setFocusedRowIndex(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
dispatchKeyEvent('Enter');
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnRowSelect).toHaveBeenCalledWith(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trigger onRowSelect with Space key', async () => {
|
||||||
|
const { result } = renderHook(() => useKeyboardNavigation(mockTableRef, 10, mockOnRowSelect));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setFocusedRowIndex(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
dispatchKeyEvent(' ');
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnRowSelect).toHaveBeenCalledWith(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset focused index with Escape key', async () => {
|
||||||
|
const { result } = renderHook(() => useKeyboardNavigation(mockTableRef, 10, mockOnRowSelect));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setFocusedRowIndex(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
dispatchKeyEvent('Escape');
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.focusedRowIndex).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore events outside table', () => {
|
||||||
|
const { result } = renderHook(() => useKeyboardNavigation(mockTableRef, 10, mockOnRowSelect));
|
||||||
|
|
||||||
|
const outsideElement = document.createElement('div');
|
||||||
|
document.body.appendChild(outsideElement);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setFocusedRowIndex(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const event = new KeyboardEvent('keydown', {
|
||||||
|
key: 'ArrowDown',
|
||||||
|
bubbles: true,
|
||||||
|
});
|
||||||
|
outsideElement.dispatchEvent(event);
|
||||||
|
|
||||||
|
// Should not change because event target is outside table
|
||||||
|
expect(result.current.focusedRowIndex).toBe(0);
|
||||||
|
|
||||||
|
document.body.removeChild(outsideElement);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not call onRowSelect if focused index is -1', () => {
|
||||||
|
renderHook(() => useKeyboardNavigation(mockTableRef, 10, mockOnRowSelect));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
dispatchKeyEvent('Enter');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockOnRowSelect).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow manual focus index setting', () => {
|
||||||
|
const { result } = renderHook(() => useKeyboardNavigation(mockTableRef, 10, mockOnRowSelect));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setFocusedRowIndex(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.focusedRowIndex).toBe(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
135
packages/client/src/components/DataTable/DataTable.hooks.ts
Normal file
135
packages/client/src/components/DataTable/DataTable.hooks.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import type { TableColumn } from './DataTable.types';
|
||||||
|
|
||||||
|
export function useDebounced<T>(value: T, delay: number) {
|
||||||
|
const [debounced, setDebounced] = useState(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setTimeout(() => setDebounced(value), delay);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
}, [value, delay]);
|
||||||
|
|
||||||
|
return debounced;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useOptimizedRowSelection = (initialSelection: Record<string, boolean> = {}) => {
|
||||||
|
const [selection, setSelection] = useState(initialSelection);
|
||||||
|
return [selection, setSelection] as const;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useColumnStyles = <TData, TValue>(
|
||||||
|
columns: TableColumn<TData, TValue>[],
|
||||||
|
isSmallScreen: boolean,
|
||||||
|
containerRef: React.RefObject<HTMLDivElement>,
|
||||||
|
) => {
|
||||||
|
const [containerWidth, setContainerWidth] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const updateWidth = () => {
|
||||||
|
setContainerWidth(container.clientWidth);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(updateWidth);
|
||||||
|
resizeObserver.observe(container);
|
||||||
|
updateWidth();
|
||||||
|
|
||||||
|
return () => resizeObserver.disconnect();
|
||||||
|
}, [containerRef]);
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
if (containerWidth === 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles: Record<string, React.CSSProperties> = {};
|
||||||
|
let totalFixedWidth = 0;
|
||||||
|
const flexibleColumns: (TableColumn<TData, TValue> & { priority: number })[] = [];
|
||||||
|
|
||||||
|
columns.forEach((column) => {
|
||||||
|
const key = String(column.id ?? column.accessorKey ?? '');
|
||||||
|
const size = isSmallScreen ? column.meta?.mobileSize : column.meta?.size;
|
||||||
|
|
||||||
|
if (size) {
|
||||||
|
const width = parseInt(String(size), 10);
|
||||||
|
totalFixedWidth += width;
|
||||||
|
styles[key] = {
|
||||||
|
width: size,
|
||||||
|
minWidth: column.meta?.minWidth || size,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
flexibleColumns.push({ ...column, priority: column.meta?.priority ?? 1 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const availableWidth = containerWidth - totalFixedWidth;
|
||||||
|
const totalPriority = flexibleColumns.reduce((sum, col) => sum + col.priority, 0);
|
||||||
|
|
||||||
|
if (availableWidth > 0 && totalPriority > 0) {
|
||||||
|
flexibleColumns.forEach((column) => {
|
||||||
|
const key = String(column.id ?? column.accessorKey ?? '');
|
||||||
|
const proportion = column.priority / totalPriority;
|
||||||
|
const width = Math.max(Math.floor(availableWidth * proportion), 80); // min width of 80px
|
||||||
|
styles[key] = {
|
||||||
|
width: `${width}px`,
|
||||||
|
minWidth: column.meta?.minWidth ?? `${isSmallScreen ? 60 : 80}px`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return styles;
|
||||||
|
}, [columns, containerWidth, isSmallScreen]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDynamicColumnWidths = useColumnStyles;
|
||||||
|
|
||||||
|
export const useKeyboardNavigation = (
|
||||||
|
tableRef: React.RefObject<HTMLDivElement>,
|
||||||
|
rowCount: number,
|
||||||
|
onRowSelect?: (index: number) => void,
|
||||||
|
) => {
|
||||||
|
const [focusedRowIndex, setFocusedRowIndex] = useState<number>(-1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (!tableRef.current?.contains(event.target as Node)) return;
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
event.preventDefault();
|
||||||
|
setFocusedRowIndex((prev) => Math.min(prev + 1, rowCount - 1));
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
event.preventDefault();
|
||||||
|
setFocusedRowIndex((prev) => Math.max(prev - 1, 0));
|
||||||
|
break;
|
||||||
|
case 'Home':
|
||||||
|
event.preventDefault();
|
||||||
|
setFocusedRowIndex(0);
|
||||||
|
break;
|
||||||
|
case 'End':
|
||||||
|
event.preventDefault();
|
||||||
|
setFocusedRowIndex(rowCount - 1);
|
||||||
|
break;
|
||||||
|
case 'Enter':
|
||||||
|
case ' ':
|
||||||
|
if (focusedRowIndex >= 0 && onRowSelect) {
|
||||||
|
event.preventDefault();
|
||||||
|
onRowSelect(focusedRowIndex);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'Escape':
|
||||||
|
setFocusedRowIndex(-1);
|
||||||
|
(event.target as HTMLElement).blur();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [tableRef, rowCount, focusedRowIndex, onRowSelect]);
|
||||||
|
|
||||||
|
return { focusedRowIndex, setFocusedRowIndex };
|
||||||
|
};
|
||||||
973
packages/client/src/components/DataTable/DataTable.spec.tsx
Normal file
973
packages/client/src/components/DataTable/DataTable.spec.tsx
Normal file
|
|
@ -0,0 +1,973 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import { Provider as JotaiProvider } from 'jotai';
|
||||||
|
import DataTable from './DataTable';
|
||||||
|
import type { TableColumn } from './DataTable.types';
|
||||||
|
import type { SortingState } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
// Mock utilities
|
||||||
|
jest.mock('~/utils', () => ({
|
||||||
|
cn: (...classes: (string | undefined | boolean)[]) => classes.filter(Boolean).join(' '),
|
||||||
|
logger: {
|
||||||
|
log: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock hooks
|
||||||
|
jest.mock('~/hooks', () => ({
|
||||||
|
useLocalize: () => (key: string, options?: Record<string, unknown>) => {
|
||||||
|
if (options && typeof options === 'object') {
|
||||||
|
let result = key;
|
||||||
|
Object.entries(options).forEach(([k, v]) => {
|
||||||
|
result = result.replace(`{${k}}`, String(v));
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
},
|
||||||
|
useMediaQuery: jest.fn(() => false),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock svgs
|
||||||
|
jest.mock('~/svgs', () => ({
|
||||||
|
Spinner: ({ className }: { className?: string }) => (
|
||||||
|
<div data-testid="spinner" className={className} />
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock lucide-react icons
|
||||||
|
jest.mock('lucide-react', () => ({
|
||||||
|
ArrowUp: ({ className }: { className?: string }) => (
|
||||||
|
<span data-testid="arrow-up" className={className} />
|
||||||
|
),
|
||||||
|
ArrowDown: ({ className }: { className?: string }) => (
|
||||||
|
<span data-testid="arrow-down" className={className} />
|
||||||
|
),
|
||||||
|
ArrowDownUp: ({ className }: { className?: string }) => (
|
||||||
|
<span data-testid="arrow-down-up" className={className} />
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock Table components
|
||||||
|
jest.mock('../Table', () => ({
|
||||||
|
Table: ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
role,
|
||||||
|
'aria-label': ariaLabel,
|
||||||
|
'aria-rowcount': ariaRowCount,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
role?: string;
|
||||||
|
'aria-label'?: string;
|
||||||
|
'aria-rowcount'?: number;
|
||||||
|
unwrapped?: boolean;
|
||||||
|
}) => (
|
||||||
|
<table
|
||||||
|
data-testid="data-table"
|
||||||
|
className={className}
|
||||||
|
role={role}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-rowcount={ariaRowCount}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</table>
|
||||||
|
),
|
||||||
|
TableHeader: ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
||||||
|
<thead data-testid="table-header" className={className}>
|
||||||
|
{children}
|
||||||
|
</thead>
|
||||||
|
),
|
||||||
|
TableBody: ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<tbody data-testid="table-body">{children}</tbody>
|
||||||
|
),
|
||||||
|
TableHead: ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
onClick,
|
||||||
|
onKeyDown,
|
||||||
|
role,
|
||||||
|
tabIndex,
|
||||||
|
'aria-label': ariaLabel,
|
||||||
|
'aria-sort': ariaSort,
|
||||||
|
scope,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
onKeyDown?: (e: React.KeyboardEvent) => void;
|
||||||
|
role?: string;
|
||||||
|
tabIndex?: number;
|
||||||
|
'aria-label'?: string;
|
||||||
|
'aria-sort'?: 'ascending' | 'descending' | 'none';
|
||||||
|
scope?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}) => (
|
||||||
|
<th
|
||||||
|
data-testid="table-head"
|
||||||
|
className={className}
|
||||||
|
onClick={onClick}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
role={role}
|
||||||
|
tabIndex={tabIndex}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-sort={ariaSort}
|
||||||
|
scope={scope}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</th>
|
||||||
|
),
|
||||||
|
TableRow: ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
'data-state': dataState,
|
||||||
|
'data-index': dataIndex,
|
||||||
|
style,
|
||||||
|
'aria-hidden': ariaHidden,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
'data-state'?: string;
|
||||||
|
'data-index'?: number;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
'aria-hidden'?: boolean;
|
||||||
|
}) => (
|
||||||
|
<tr
|
||||||
|
data-testid="table-row"
|
||||||
|
className={className}
|
||||||
|
data-state={dataState}
|
||||||
|
data-index={dataIndex}
|
||||||
|
style={style}
|
||||||
|
aria-hidden={ariaHidden}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</tr>
|
||||||
|
),
|
||||||
|
TableCell: ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
colSpan,
|
||||||
|
style,
|
||||||
|
id,
|
||||||
|
role,
|
||||||
|
'aria-live': ariaLive,
|
||||||
|
}: {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
colSpan?: number;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
id?: string;
|
||||||
|
role?: string;
|
||||||
|
'aria-live'?: 'polite' | 'assertive' | 'off';
|
||||||
|
}) => (
|
||||||
|
<td
|
||||||
|
data-testid="table-cell"
|
||||||
|
className={className}
|
||||||
|
colSpan={colSpan}
|
||||||
|
style={style}
|
||||||
|
id={id}
|
||||||
|
role={role}
|
||||||
|
aria-live={ariaLive}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</td>
|
||||||
|
),
|
||||||
|
TableRowHeader: ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}) => (
|
||||||
|
<th data-testid="table-row-header" className={className} style={style} scope="row">
|
||||||
|
{children}
|
||||||
|
</th>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock Label component
|
||||||
|
jest.mock('../Label', () => ({
|
||||||
|
Label: ({ children, className }: { children: React.ReactNode; className?: string }) => (
|
||||||
|
<label data-testid="label" className={className}>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock DataTableSearch component
|
||||||
|
jest.mock('./DataTableSearch', () => ({
|
||||||
|
DataTableSearch: ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}) => (
|
||||||
|
<input
|
||||||
|
data-testid="search-input"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder="Search..."
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock Checkbox component
|
||||||
|
jest.mock('../Checkbox', () => ({
|
||||||
|
Checkbox: ({
|
||||||
|
checked,
|
||||||
|
onCheckedChange,
|
||||||
|
'aria-label': ariaLabel,
|
||||||
|
}: {
|
||||||
|
checked: boolean;
|
||||||
|
onCheckedChange: (value: boolean) => void;
|
||||||
|
'aria-label': string;
|
||||||
|
}) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
data-testid="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={(e) => onCheckedChange(e.target.checked)}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock Skeleton component
|
||||||
|
jest.mock('../Skeleton', () => ({
|
||||||
|
Skeleton: ({ className }: { className?: string }) => (
|
||||||
|
<div data-testid="skeleton" className={className} />
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Test data types - extends Record<string, unknown> for DataTable compatibility
|
||||||
|
interface TestData extends Record<string, unknown> {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to create test data
|
||||||
|
const createTestData = (count: number): TestData[] =>
|
||||||
|
Array.from({ length: count }, (_, i) => ({
|
||||||
|
id: `row-${i}`,
|
||||||
|
name: `Item ${i}`,
|
||||||
|
status: i % 2 === 0 ? 'active' : 'inactive',
|
||||||
|
createdAt: `2024-01-${String(i + 1).padStart(2, '0')}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Helper to create test columns
|
||||||
|
const createTestColumns = (): TableColumn<TestData, string>[] => [
|
||||||
|
{
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
cell: ({ row }) => row.original.name,
|
||||||
|
meta: { isRowHeader: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
cell: ({ row }) => row.original.status,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'createdAt',
|
||||||
|
header: 'Created At',
|
||||||
|
cell: ({ row }) => row.original.createdAt,
|
||||||
|
meta: { desktopOnly: true },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Wrapper component with providers
|
||||||
|
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<JotaiProvider>{children}</JotaiProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('DataTable', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('should render table with columns and data', () => {
|
||||||
|
const columns = createTestColumns();
|
||||||
|
const data = createTestData(5);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<DataTable columns={columns} data={data} />
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('data-table')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('table-header')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('table-body')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render column headers', () => {
|
||||||
|
const columns = createTestColumns();
|
||||||
|
const data = createTestData(5);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<DataTable columns={columns} data={data} />
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const headers = screen.getAllByTestId('table-head');
|
||||||
|
// Should have select column + 3 data columns = 4
|
||||||
|
expect(headers.length).toBeGreaterThanOrEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show skeleton rows when isLoading is true', () => {
|
||||||
|
const columns = createTestColumns();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<DataTable columns={columns} data={[]} isLoading={true} />
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const skeletons = screen.getAllByTestId('skeleton');
|
||||||
|
expect(skeletons.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show "No data" message when empty', () => {
|
||||||
|
const columns = createTestColumns();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<DataTable columns={columns} data={[]} isLoading={false} />
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('com_ui_no_data')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show "No search results" when filtered to empty', () => {
|
||||||
|
const columns = createTestColumns();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={[]}
|
||||||
|
isLoading={false}
|
||||||
|
filterValue="nonexistent"
|
||||||
|
onFilterChange={jest.fn()}
|
||||||
|
/>
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Trigger the search term state update
|
||||||
|
const searchInput = screen.getByTestId('search-input');
|
||||||
|
fireEvent.change(searchInput, { target: { value: 'nonexistent' } });
|
||||||
|
|
||||||
|
expect(screen.getByText('com_ui_no_search_results')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply custom className', () => {
|
||||||
|
const columns = createTestColumns();
|
||||||
|
const data = createTestData(5);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<DataTable columns={columns} data={data} className="custom-table-class" />
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const container = screen.getByRole('region', { name: 'com_ui_data_table' });
|
||||||
|
expect(container.className).toContain('custom-table-class');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set aria-rowcount based on data length', () => {
|
||||||
|
const columns = createTestColumns();
|
||||||
|
const data = createTestData(25);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<DataTable columns={columns} data={data} />
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const table = screen.getByTestId('data-table');
|
||||||
|
expect(table).toHaveAttribute('aria-rowcount', '25');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Search', () => {
|
||||||
|
it('should render search input when enableSearch is true', () => {
|
||||||
|
const columns = createTestColumns();
|
||||||
|
const data = createTestData(5);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
config={{ search: { enableSearch: true } }}
|
||||||
|
onFilterChange={jest.fn()}
|
||||||
|
/>
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('search-input')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not render search input when enableSearch is false', () => {
|
||||||
|
const columns = createTestColumns();
|
||||||
|
const data = createTestData(5);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<DataTable columns={columns} data={data} config={{ search: { enableSearch: false } }} />
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('search-input')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not render search when onFilterChange is not provided', () => {
|
||||||
|
const columns = createTestColumns();
|
||||||
|
const data = createTestData(5);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<DataTable columns={columns} data={data} config={{ search: { enableSearch: true } }} />
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('search-input')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should debounce search input', async () => {
|
||||||
|
const mockOnFilterChange = jest.fn();
|
||||||
|
const columns = createTestColumns();
|
||||||
|
const data = createTestData(5);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
config={{ search: { enableSearch: true, debounce: 300 } }}
|
||||||
|
onFilterChange={mockOnFilterChange}
|
||||||
|
filterValue=""
|
||||||
|
/>
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const searchInput = screen.getByTestId('search-input');
|
||||||
|
fireEvent.change(searchInput, { target: { value: 'test' } });
|
||||||
|
|
||||||
|
// Should not call immediately
|
||||||
|
expect(mockOnFilterChange).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Advance past debounce delay
|
||||||
|
jest.advanceTimersByTime(350);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnFilterChange).toHaveBeenCalledWith('test');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display filterValue in search input', () => {
|
||||||
|
const columns = createTestColumns();
|
||||||
|
const data = createTestData(5);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
filterValue="existing search"
|
||||||
|
onFilterChange={jest.fn()}
|
||||||
|
/>
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const searchInput = screen.getByTestId('search-input');
|
||||||
|
expect(searchInput).toHaveValue('existing search');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Sorting', () => {
|
||||||
|
it('should render sort icons in sortable headers', () => {
|
||||||
|
const columns: TableColumn<TestData, string>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const data = createTestData(5);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<DataTable columns={columns} data={data} />
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should show default sort icon
|
||||||
|
expect(screen.getByTestId('arrow-down-up')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onSortingChange when header is clicked', () => {
|
||||||
|
const mockOnSortingChange = jest.fn();
|
||||||
|
const columns: TableColumn<TestData, string>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const data = createTestData(5);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
sorting={[]}
|
||||||
|
onSortingChange={mockOnSortingChange}
|
||||||
|
/>
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const sortableHeader = screen.getAllByTestId('table-head')[1]; // Skip select column
|
||||||
|
fireEvent.click(sortableHeader);
|
||||||
|
|
||||||
|
expect(mockOnSortingChange).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trigger sort on Enter key', () => {
|
||||||
|
const mockOnSortingChange = jest.fn();
|
||||||
|
const columns: TableColumn<TestData, string>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const data = createTestData(5);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
sorting={[]}
|
||||||
|
onSortingChange={mockOnSortingChange}
|
||||||
|
/>
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const sortableHeader = screen.getAllByTestId('table-head')[1];
|
||||||
|
fireEvent.keyDown(sortableHeader, { key: 'Enter' });
|
||||||
|
|
||||||
|
expect(mockOnSortingChange).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trigger sort on Space key', () => {
|
||||||
|
const mockOnSortingChange = jest.fn();
|
||||||
|
const columns: TableColumn<TestData, string>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const data = createTestData(5);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
sorting={[]}
|
||||||
|
onSortingChange={mockOnSortingChange}
|
||||||
|
/>
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const sortableHeader = screen.getAllByTestId('table-head')[1];
|
||||||
|
fireEvent.keyDown(sortableHeader, { key: ' ' });
|
||||||
|
|
||||||
|
expect(mockOnSortingChange).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show ascending icon when sorted ascending', () => {
|
||||||
|
const columns: TableColumn<TestData, string>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const data = createTestData(5);
|
||||||
|
const sorting: SortingState = [{ id: 'name', desc: false }];
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<DataTable columns={columns} data={data} sorting={sorting} onSortingChange={jest.fn()} />
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('arrow-up')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show descending icon when sorted descending', () => {
|
||||||
|
const columns: TableColumn<TestData, string>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const data = createTestData(5);
|
||||||
|
const sorting: SortingState = [{ id: 'name', desc: true }];
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<DataTable columns={columns} data={data} sorting={sorting} onSortingChange={jest.fn()} />
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('arrow-down')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use internal sorting state when onSortingChange not provided', () => {
|
||||||
|
const columns: TableColumn<TestData, string>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const data = createTestData(5);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<DataTable columns={columns} data={data} />
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const sortableHeader = screen.getAllByTestId('table-head')[1];
|
||||||
|
fireEvent.click(sortableHeader);
|
||||||
|
|
||||||
|
// Should show ascending icon after click
|
||||||
|
expect(screen.getByTestId('arrow-up')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Row Selection', () => {
|
||||||
|
it('should show checkboxes when showCheckboxes is true', () => {
|
||||||
|
const columns = createTestColumns();
|
||||||
|
const data = createTestData(5);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
config={{ selection: { enableRowSelection: true, showCheckboxes: true } }}
|
||||||
|
/>
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const checkboxes = screen.getAllByTestId('checkbox');
|
||||||
|
// Should have header checkbox + one per row
|
||||||
|
expect(checkboxes.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not show checkboxes when showCheckboxes is false', () => {
|
||||||
|
const columns = createTestColumns();
|
||||||
|
const data = createTestData(5);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
config={{ selection: { enableRowSelection: true, showCheckboxes: false } }}
|
||||||
|
/>
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const checkboxes = screen.queryAllByTestId('checkbox');
|
||||||
|
expect(checkboxes).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not show checkboxes when enableRowSelection is false', () => {
|
||||||
|
const columns = createTestColumns();
|
||||||
|
const data = createTestData(5);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
config={{ selection: { enableRowSelection: false } }}
|
||||||
|
/>
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const checkboxes = screen.queryAllByTestId('checkbox');
|
||||||
|
expect(checkboxes).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Virtualization', () => {
|
||||||
|
it('should activate virtualization when data length >= minRows', () => {
|
||||||
|
const columns = createTestColumns();
|
||||||
|
const data = createTestData(100); // More than default minRows of 50
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<DataTable columns={columns} data={data} />
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Table should still render
|
||||||
|
expect(screen.getByTestId('data-table')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not use virtualization for small datasets', () => {
|
||||||
|
const columns = createTestColumns();
|
||||||
|
const data = createTestData(10); // Less than minRows
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<DataTable columns={columns} data={data} />
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// All rows should be rendered
|
||||||
|
const rows = screen.getAllByTestId('table-row');
|
||||||
|
expect(rows.length).toBeGreaterThanOrEqual(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect custom minRows config', () => {
|
||||||
|
const columns = createTestColumns();
|
||||||
|
const data = createTestData(20);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
config={{ virtualization: { minRows: 10 } }} // Lower threshold
|
||||||
|
/>
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Virtualization should be active
|
||||||
|
expect(screen.getByTestId('data-table')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Infinite Scroll', () => {
|
||||||
|
it('should show loading spinner when isFetchingNextPage is true', () => {
|
||||||
|
const columns = createTestColumns();
|
||||||
|
const data = createTestData(5);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
isFetchingNextPage={true}
|
||||||
|
hasNextPage={true}
|
||||||
|
fetchNextPage={jest.fn()}
|
||||||
|
/>
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('spinner')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not show loading spinner when not fetching', () => {
|
||||||
|
const columns = createTestColumns();
|
||||||
|
const data = createTestData(5);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data}
|
||||||
|
isFetchingNextPage={false}
|
||||||
|
hasNextPage={true}
|
||||||
|
fetchNextPage={jest.fn()}
|
||||||
|
/>
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('spinner')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Custom Actions', () => {
|
||||||
|
it('should render customActionsRenderer with selected info', () => {
|
||||||
|
const columns = createTestColumns();
|
||||||
|
const data = createTestData(5);
|
||||||
|
const mockRenderer = jest.fn().mockReturnValue(<div data-testid="custom-actions" />);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<DataTable columns={columns} data={data} customActionsRenderer={mockRenderer} />
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('custom-actions')).toBeInTheDocument();
|
||||||
|
expect(mockRenderer).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
selectedCount: 0,
|
||||||
|
selectedRows: [],
|
||||||
|
table: expect.any(Object),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass updated selection to customActionsRenderer', () => {
|
||||||
|
const columns = createTestColumns();
|
||||||
|
const data = createTestData(5);
|
||||||
|
const mockRenderer = jest.fn().mockReturnValue(<div data-testid="custom-actions" />);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<DataTable columns={columns} data={data} customActionsRenderer={mockRenderer} />
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initial call should have 0 selected
|
||||||
|
expect(mockRenderer).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
selectedCount: 0,
|
||||||
|
selectedRows: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Skeleton Loading', () => {
|
||||||
|
it('should show skeleton rows based on skeleton count config', () => {
|
||||||
|
const columns = createTestColumns();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={[]}
|
||||||
|
isLoading={true}
|
||||||
|
config={{ skeleton: { count: 5 } }}
|
||||||
|
/>
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const skeletons = screen.getAllByTestId('skeleton');
|
||||||
|
// 5 rows * number of columns
|
||||||
|
expect(skeletons.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show skeletons when isFetching but not isFetchingNextPage', () => {
|
||||||
|
const columns = createTestColumns();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={[]}
|
||||||
|
isFetching={true}
|
||||||
|
isFetchingNextPage={false}
|
||||||
|
hasNextPage={true}
|
||||||
|
fetchNextPage={jest.fn()}
|
||||||
|
/>
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const skeletons = screen.getAllByTestId('skeleton');
|
||||||
|
expect(skeletons.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Data without IDs', () => {
|
||||||
|
it('should handle data without id property using index fallback', () => {
|
||||||
|
const columns: TableColumn<{ name: string }, string>[] = [
|
||||||
|
{ accessorKey: 'name', header: 'Name' },
|
||||||
|
];
|
||||||
|
const dataWithoutIds = [{ name: 'Item 1' }, { name: 'Item 2' }];
|
||||||
|
|
||||||
|
// Should render without errors
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<DataTable columns={columns} data={dataWithoutIds as TestData[]} />
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('data-table')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Accessibility', () => {
|
||||||
|
it('should have role="region" on container', () => {
|
||||||
|
const columns = createTestColumns();
|
||||||
|
const data = createTestData(5);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<DataTable columns={columns} data={data} />
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole('region', { name: 'com_ui_data_table' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have aria-label on table', () => {
|
||||||
|
const columns = createTestColumns();
|
||||||
|
const data = createTestData(5);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<DataTable columns={columns} data={data} />
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const table = screen.getByTestId('data-table');
|
||||||
|
expect(table).toHaveAttribute('aria-label', 'com_ui_data_table');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have proper role on sortable headers', () => {
|
||||||
|
const columns: TableColumn<TestData, string>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const data = createTestData(5);
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TestWrapper>
|
||||||
|
<DataTable columns={columns} data={data} />
|
||||||
|
</TestWrapper>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const sortableHeader = screen.getAllByTestId('table-head')[1];
|
||||||
|
expect(sortableHeader).toHaveAttribute('role', 'button');
|
||||||
|
expect(sortableHeader).toHaveAttribute('tabIndex', '0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
609
packages/client/src/components/DataTable/DataTable.tsx
Normal file
609
packages/client/src/components/DataTable/DataTable.tsx
Normal file
|
|
@ -0,0 +1,609 @@
|
||||||
|
import React, { useRef, useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
|
import { ArrowUp, ArrowDown, ArrowDownUp } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
useReactTable,
|
||||||
|
getCoreRowModel,
|
||||||
|
flexRender,
|
||||||
|
type SortingState,
|
||||||
|
type VisibilityState,
|
||||||
|
type ColumnDef,
|
||||||
|
type Row,
|
||||||
|
type Table as TTable,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import type { DataTableProps, ProcessedDataRow } from './DataTable.types';
|
||||||
|
import { SelectionCheckbox, MemoizedTableRow, SkeletonRows } from './DataTableComponents';
|
||||||
|
import { Table, TableBody, TableHead, TableHeader, TableCell, TableRow } from '../Table';
|
||||||
|
import { useDebounced, useOptimizedRowSelection } from './DataTable.hooks';
|
||||||
|
import { useMediaQuery, useLocalize } from '~/hooks';
|
||||||
|
import { DataTableSearch } from './DataTableSearch';
|
||||||
|
import { cn, logger } from '~/utils';
|
||||||
|
import { Label } from '../Label';
|
||||||
|
import { Spinner } from '~/svgs';
|
||||||
|
|
||||||
|
function DataTable<TData extends Record<string, unknown>, TValue>({
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
className = '',
|
||||||
|
isLoading = false,
|
||||||
|
isFetching = false,
|
||||||
|
config,
|
||||||
|
filterValue = '',
|
||||||
|
onFilterChange,
|
||||||
|
defaultSort = [],
|
||||||
|
isFetchingNextPage = false,
|
||||||
|
hasNextPage = false,
|
||||||
|
fetchNextPage,
|
||||||
|
sorting,
|
||||||
|
onSortingChange,
|
||||||
|
customActionsRenderer,
|
||||||
|
}: DataTableProps<TData, TValue>) {
|
||||||
|
const localize = useLocalize();
|
||||||
|
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||||
|
|
||||||
|
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const scrollTimeoutRef = useRef<number | null>(null);
|
||||||
|
const scrollRAFRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
selection: { enableRowSelection = true, showCheckboxes = true } = {},
|
||||||
|
search: { enableSearch = true, debounce: debounceDelay = 300 } = {},
|
||||||
|
skeleton: { count: skeletonCount = 10 } = {},
|
||||||
|
virtualization: {
|
||||||
|
overscan = 10,
|
||||||
|
minRows = 50,
|
||||||
|
rowHeight = 56,
|
||||||
|
fastOverscanMultiplier = 4,
|
||||||
|
} = {},
|
||||||
|
} = config || {};
|
||||||
|
|
||||||
|
const virtualizationActive = data.length >= minRows;
|
||||||
|
|
||||||
|
// Dynamic overscan for fast scrolling - increases rendered rows during rapid scroll
|
||||||
|
const [dynamicOverscan, setDynamicOverscan] = useState(overscan);
|
||||||
|
const lastScrollTopRef = useRef(0);
|
||||||
|
const lastScrollTimeRef = useRef(performance.now());
|
||||||
|
const fastScrollTimeoutRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDynamicOverscan(overscan);
|
||||||
|
}, [overscan]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (fastScrollTimeoutRef.current) {
|
||||||
|
clearTimeout(fastScrollTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||||
|
const [optimizedRowSelection, setOptimizedRowSelection] = useOptimizedRowSelection();
|
||||||
|
const [searchTerm, setSearchTerm] = useState(filterValue);
|
||||||
|
const [internalSorting, setInternalSorting] = useState<SortingState>(defaultSort);
|
||||||
|
|
||||||
|
const selectedCount = Object.keys(optimizedRowSelection).length;
|
||||||
|
const isAllSelected = useMemo(
|
||||||
|
() => data.length > 0 && selectedCount === data.length,
|
||||||
|
[data.length, selectedCount],
|
||||||
|
);
|
||||||
|
const isIndeterminate = selectedCount > 0 && !isAllSelected;
|
||||||
|
|
||||||
|
const getRowId = useCallback(
|
||||||
|
(row: TData, index?: number) => String(row.id ?? `row-${index ?? 0}`),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedRows = useMemo(() => {
|
||||||
|
if (Object.keys(optimizedRowSelection).length === 0) return [];
|
||||||
|
|
||||||
|
const dataMap = new Map(data.map((item, index) => [getRowId(item, index), item]));
|
||||||
|
return Object.keys(optimizedRowSelection)
|
||||||
|
.map((id) => dataMap.get(id))
|
||||||
|
.filter(Boolean) as TData[];
|
||||||
|
}, [optimizedRowSelection, data, getRowId]);
|
||||||
|
|
||||||
|
const cleanupTimers = useCallback(() => {
|
||||||
|
if (scrollRAFRef.current) {
|
||||||
|
cancelAnimationFrame(scrollRAFRef.current);
|
||||||
|
scrollRAFRef.current = null;
|
||||||
|
}
|
||||||
|
if (scrollTimeoutRef.current) {
|
||||||
|
clearTimeout(scrollTimeoutRef.current);
|
||||||
|
scrollTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const debouncedTerm = useDebounced(searchTerm, debounceDelay);
|
||||||
|
const finalSorting = sorting ?? internalSorting;
|
||||||
|
|
||||||
|
// Mobile column visibility: columns with desktopOnly meta are hidden via CSS on mobile
|
||||||
|
// but remain in DOM for accessibility. CSS classes handle visual hiding.
|
||||||
|
const calculatedVisibility = useMemo(() => {
|
||||||
|
const newVisibility: VisibilityState = {};
|
||||||
|
|
||||||
|
columns.forEach((col) => {
|
||||||
|
const meta = (col as { meta?: { desktopOnly?: boolean } }).meta;
|
||||||
|
if (!meta?.desktopOnly) return;
|
||||||
|
|
||||||
|
const rawId =
|
||||||
|
(col as { id?: string | number; accessorKey?: string | number }).id ??
|
||||||
|
(col as { accessorKey?: string | number }).accessorKey;
|
||||||
|
|
||||||
|
if ((typeof rawId === 'string' || typeof rawId === 'number') && String(rawId).length > 0) {
|
||||||
|
newVisibility[String(rawId)] = true;
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
'DataTable: A desktopOnly column is missing id/accessorKey; cannot control header visibility automatically.',
|
||||||
|
col,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return newVisibility;
|
||||||
|
}, [isSmallScreen, columns]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setColumnVisibility((prev) => ({ ...prev, ...calculatedVisibility }));
|
||||||
|
}, [calculatedVisibility]);
|
||||||
|
|
||||||
|
// Warn about missing row IDs - only once per component lifecycle
|
||||||
|
const hasWarnedAboutMissingIds = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data.length > 0 && !hasWarnedAboutMissingIds.current) {
|
||||||
|
const missing = data.filter((item) => item.id === null || item.id === undefined);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
logger.warn(
|
||||||
|
`DataTable Warning: ${missing.length} data rows are missing a unique "id" property. Using index as a fallback. This can lead to unexpected behavior with selection and sorting.`,
|
||||||
|
{ missingCount: missing.length, sample: missing.slice(0, 3) },
|
||||||
|
);
|
||||||
|
hasWarnedAboutMissingIds.current = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const tableColumns = useMemo((): ColumnDef<TData, TValue>[] => {
|
||||||
|
if (!enableRowSelection || !showCheckboxes) {
|
||||||
|
return columns.map((col) => col as unknown as ColumnDef<TData, TValue>);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectColumn: ColumnDef<TData, TValue> = {
|
||||||
|
id: 'select',
|
||||||
|
enableResizing: false,
|
||||||
|
header: () => {
|
||||||
|
const extraCheckboxProps = (isIndeterminate ? { indeterminate: true } : {}) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex h-full items-center justify-center"
|
||||||
|
aria-label={localize('com_ui_select_all')}
|
||||||
|
>
|
||||||
|
<SelectionCheckbox
|
||||||
|
checked={isAllSelected}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (isAllSelected || !value) {
|
||||||
|
setOptimizedRowSelection({});
|
||||||
|
} else {
|
||||||
|
const allSelection = data.reduce<Record<string, boolean>>((acc, item, index) => {
|
||||||
|
acc[getRowId(item, index)] = true;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
setOptimizedRowSelection(allSelection);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
ariaLabel={localize('com_ui_select_all')}
|
||||||
|
{...extraCheckboxProps}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const rowDescription = row.original.name
|
||||||
|
? `named ${row.original.name}`
|
||||||
|
: `at position ${row.index + 1}`;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex h-full items-center justify-center"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={localize(`com_ui_select_row`, { 0: rowDescription })}
|
||||||
|
>
|
||||||
|
<SelectionCheckbox
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onChange={(value) => row.toggleSelected(value)}
|
||||||
|
ariaLabel={localize(`com_ui_select_row`, { 0: rowDescription })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
className: 'max-w-[20px] flex-1',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return [selectColumn, ...columns.map((col) => col as unknown as ColumnDef<TData, TValue>)];
|
||||||
|
}, [
|
||||||
|
columns,
|
||||||
|
enableRowSelection,
|
||||||
|
showCheckboxes,
|
||||||
|
localize,
|
||||||
|
data,
|
||||||
|
getRowId,
|
||||||
|
isAllSelected,
|
||||||
|
isIndeterminate,
|
||||||
|
setOptimizedRowSelection,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const sizedColumns = tableColumns;
|
||||||
|
|
||||||
|
const table = useReactTable<TData>({
|
||||||
|
data,
|
||||||
|
columns: sizedColumns,
|
||||||
|
getRowId: getRowId,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
enableRowSelection,
|
||||||
|
enableMultiRowSelection: true,
|
||||||
|
manualSorting: true,
|
||||||
|
manualFiltering: true,
|
||||||
|
state: {
|
||||||
|
sorting: finalSorting,
|
||||||
|
columnVisibility,
|
||||||
|
rowSelection: optimizedRowSelection,
|
||||||
|
},
|
||||||
|
onSortingChange: onSortingChange ?? setInternalSorting,
|
||||||
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
|
onRowSelectionChange: setOptimizedRowSelection,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rowVirtualizer = useVirtualizer({
|
||||||
|
enabled: virtualizationActive,
|
||||||
|
count: data.length,
|
||||||
|
getScrollElement: () => tableContainerRef.current,
|
||||||
|
getItemKey: (index) => getRowId(data[index] as TData, index),
|
||||||
|
estimateSize: useCallback(() => rowHeight, [rowHeight]),
|
||||||
|
overscan: dynamicOverscan,
|
||||||
|
});
|
||||||
|
|
||||||
|
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||||
|
const totalSize = rowVirtualizer.getTotalSize();
|
||||||
|
const paddingTop = virtualRows[0]?.start ?? 0;
|
||||||
|
const paddingBottom =
|
||||||
|
virtualRows.length > 0 ? totalSize - (virtualRows[virtualRows.length - 1]?.end ?? 0) : 0;
|
||||||
|
|
||||||
|
const { rows } = table.getRowModel();
|
||||||
|
const headerGroups = table.getHeaderGroups();
|
||||||
|
|
||||||
|
const showSkeletons = isLoading || (isFetching && !isFetchingNextPage);
|
||||||
|
const shouldShowSearch = enableSearch && onFilterChange;
|
||||||
|
|
||||||
|
// Render table body based on loading state and virtualization
|
||||||
|
let tableBodyContent: React.ReactNode;
|
||||||
|
if (showSkeletons) {
|
||||||
|
tableBodyContent = (
|
||||||
|
<SkeletonRows
|
||||||
|
count={skeletonCount}
|
||||||
|
columns={tableColumns as ColumnDef<Record<string, unknown>>[]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (virtualizationActive) {
|
||||||
|
tableBodyContent = (
|
||||||
|
<>
|
||||||
|
{paddingTop > 0 && (
|
||||||
|
<TableRow aria-hidden="true">
|
||||||
|
<TableCell
|
||||||
|
colSpan={tableColumns.length}
|
||||||
|
style={{ height: paddingTop, padding: 0, border: 0 }}
|
||||||
|
/>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{virtualRows.map((virtualRow) => {
|
||||||
|
const row = rows[virtualRow.index];
|
||||||
|
if (!row) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MemoizedTableRow
|
||||||
|
key={virtualRow.key}
|
||||||
|
row={row as unknown as Row<Record<string, unknown>>}
|
||||||
|
virtualIndex={virtualRow.index}
|
||||||
|
selected={row.getIsSelected()}
|
||||||
|
style={{ height: rowHeight }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{paddingBottom > 0 && (
|
||||||
|
<TableRow aria-hidden="true">
|
||||||
|
<TableCell
|
||||||
|
colSpan={tableColumns.length}
|
||||||
|
style={{ height: paddingBottom, padding: 0, border: 0 }}
|
||||||
|
/>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tableBodyContent = rows.map((row) => (
|
||||||
|
<MemoizedTableRow
|
||||||
|
key={getRowId(row.original as TData, row.index)}
|
||||||
|
row={row as unknown as Row<Record<string, unknown>>}
|
||||||
|
virtualIndex={row.index}
|
||||||
|
selected={row.getIsSelected()}
|
||||||
|
style={{ height: rowHeight }}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSearchTerm(filterValue);
|
||||||
|
}, [filterValue]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (debouncedTerm !== filterValue && onFilterChange) {
|
||||||
|
onFilterChange(debouncedTerm);
|
||||||
|
setOptimizedRowSelection({});
|
||||||
|
}
|
||||||
|
}, [debouncedTerm, filterValue, onFilterChange, setOptimizedRowSelection]);
|
||||||
|
|
||||||
|
// Recalculate virtual range when data or state changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!virtualizationActive) return;
|
||||||
|
rowVirtualizer.calculateRange();
|
||||||
|
}, [data.length, finalSorting, columnVisibility, virtualizationActive, rowVirtualizer]);
|
||||||
|
|
||||||
|
// Recalculate when container is resized
|
||||||
|
useEffect(() => {
|
||||||
|
if (!virtualizationActive) return;
|
||||||
|
const container = tableContainerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
const ro = new ResizeObserver(() => {
|
||||||
|
rowVirtualizer.calculateRange();
|
||||||
|
});
|
||||||
|
ro.observe(container);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [virtualizationActive, rowVirtualizer]);
|
||||||
|
|
||||||
|
const handleScroll = useMemo(() => {
|
||||||
|
let rafId: number | null = null;
|
||||||
|
let timeoutId: number | null = null;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (rafId) cancelAnimationFrame(rafId);
|
||||||
|
|
||||||
|
rafId = requestAnimationFrame(() => {
|
||||||
|
const container = tableContainerRef.current;
|
||||||
|
if (container) {
|
||||||
|
const now = performance.now();
|
||||||
|
const delta = Math.abs(container.scrollTop - lastScrollTopRef.current);
|
||||||
|
const dt = now - lastScrollTimeRef.current;
|
||||||
|
if (dt > 0) {
|
||||||
|
const velocity = delta / dt;
|
||||||
|
// Increase overscan during fast scrolling for smoother experience
|
||||||
|
if (velocity > 2 && virtualizationActive && dynamicOverscan === overscan) {
|
||||||
|
if (fastScrollTimeoutRef.current) {
|
||||||
|
window.clearTimeout(fastScrollTimeoutRef.current);
|
||||||
|
}
|
||||||
|
setDynamicOverscan(Math.min(overscan * fastOverscanMultiplier, overscan * 8));
|
||||||
|
fastScrollTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
setDynamicOverscan((current) => (current !== overscan ? overscan : current));
|
||||||
|
}, 160);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastScrollTopRef.current = container.scrollTop;
|
||||||
|
lastScrollTimeRef.current = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
// Trigger infinite scroll pagination
|
||||||
|
timeoutId = window.setTimeout(() => {
|
||||||
|
const loaderContainer = tableContainerRef.current;
|
||||||
|
if (!loaderContainer || !fetchNextPage || !hasNextPage || isFetchingNextPage) return;
|
||||||
|
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = loaderContainer;
|
||||||
|
if (scrollTop + clientHeight >= scrollHeight - 200) {
|
||||||
|
fetchNextPage().finally();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
overscan,
|
||||||
|
fastOverscanMultiplier,
|
||||||
|
virtualizationActive,
|
||||||
|
dynamicOverscan,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const scrollElement = tableContainerRef.current;
|
||||||
|
if (!scrollElement) return;
|
||||||
|
|
||||||
|
scrollElement.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
return () => {
|
||||||
|
scrollElement.removeEventListener('scroll', handleScroll);
|
||||||
|
cleanupTimers();
|
||||||
|
};
|
||||||
|
}, [handleScroll, cleanupTimers]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative flex w-full flex-col overflow-hidden rounded-lg border border-border-light bg-background',
|
||||||
|
'h-[calc(100vh-8rem)] max-h-[80vh]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
role="region"
|
||||||
|
aria-label={localize('com_ui_data_table')}
|
||||||
|
>
|
||||||
|
<div className="flex w-full shrink-0 items-center gap-2 border-b border-border-light md:gap-3">
|
||||||
|
{shouldShowSearch && <DataTableSearch value={searchTerm} onChange={setSearchTerm} />}
|
||||||
|
{customActionsRenderer &&
|
||||||
|
customActionsRenderer({
|
||||||
|
selectedCount,
|
||||||
|
selectedRows,
|
||||||
|
table: table as unknown as TTable<ProcessedDataRow<TData>>,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={tableContainerRef}
|
||||||
|
className="overflow-anchor-none relative min-h-0 flex-1 overflow-auto will-change-scroll"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
WebkitOverflowScrolling: 'touch',
|
||||||
|
overscrollBehavior: 'contain',
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
role="region"
|
||||||
|
aria-label={localize('com_ui_data_table_scroll_area')}
|
||||||
|
aria-describedby={showSkeletons ? 'loading-status' : undefined}
|
||||||
|
>
|
||||||
|
<Table
|
||||||
|
role="table"
|
||||||
|
aria-label={localize('com_ui_data_table')}
|
||||||
|
aria-rowcount={data.length}
|
||||||
|
className="table-auto"
|
||||||
|
unwrapped={true}
|
||||||
|
>
|
||||||
|
<TableHeader className="sticky top-0 z-10 bg-surface-secondary">
|
||||||
|
{headerGroups.map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
const isDesktopOnly =
|
||||||
|
(header.column.columnDef.meta as { desktopOnly?: boolean } | undefined)
|
||||||
|
?.desktopOnly ?? false;
|
||||||
|
|
||||||
|
if (!header.column.getIsVisible()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSelectHeader = header.id === 'select';
|
||||||
|
const meta = header.column.columnDef.meta as { className?: string } | undefined;
|
||||||
|
const canSort = header.column.getCanSort();
|
||||||
|
|
||||||
|
let sortAriaLabel: string | undefined;
|
||||||
|
if (canSort) {
|
||||||
|
const sortState = header.column.getIsSorted();
|
||||||
|
let sortStateLabel = 'sortable';
|
||||||
|
if (sortState === 'asc') {
|
||||||
|
sortStateLabel = 'ascending';
|
||||||
|
} else if (sortState === 'desc') {
|
||||||
|
sortStateLabel = 'descending';
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerLabel =
|
||||||
|
typeof header.column.columnDef.header === 'string'
|
||||||
|
? header.column.columnDef.header
|
||||||
|
: header.column.id;
|
||||||
|
|
||||||
|
sortAriaLabel = `${headerLabel ?? ''} column, ${sortStateLabel}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSortingKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (canSort && (e.key === 'Enter' || e.key === ' ')) {
|
||||||
|
e.preventDefault();
|
||||||
|
header.column.toggleSorting();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const metaWidth = (header.column.columnDef.meta as { width?: number } | undefined)
|
||||||
|
?.width;
|
||||||
|
const widthStyle = isSelectHeader
|
||||||
|
? { width: '32px', maxWidth: '32px', minWidth: '32px' }
|
||||||
|
: metaWidth && metaWidth >= 1 && metaWidth <= 100
|
||||||
|
? {
|
||||||
|
width: `${metaWidth}%`,
|
||||||
|
maxWidth: `${metaWidth}%`,
|
||||||
|
minWidth: `${metaWidth}%`,
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
return (
|
||||||
|
<TableHead
|
||||||
|
key={header.id}
|
||||||
|
scope="col"
|
||||||
|
className={cn(
|
||||||
|
'border-b border-border-light px-2 py-2 md:px-3 md:py-2',
|
||||||
|
isSelectHeader && 'px-0 text-center',
|
||||||
|
canSort && 'cursor-pointer hover:bg-surface-tertiary',
|
||||||
|
meta?.className,
|
||||||
|
header.column.getIsResizing() && 'bg-surface-tertiary/60',
|
||||||
|
isDesktopOnly && 'hidden md:table-cell',
|
||||||
|
)}
|
||||||
|
style={widthStyle}
|
||||||
|
onClick={header.column.getToggleSortingHandler()}
|
||||||
|
onKeyDown={handleSortingKeyDown}
|
||||||
|
role={canSort ? 'button' : undefined}
|
||||||
|
tabIndex={canSort ? 0 : undefined}
|
||||||
|
aria-label={sortAriaLabel}
|
||||||
|
aria-sort={
|
||||||
|
header.column.getIsSorted() === 'asc'
|
||||||
|
? 'ascending'
|
||||||
|
: header.column.getIsSorted() === 'desc'
|
||||||
|
? 'descending'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isSelectHeader ? (
|
||||||
|
flexRender(header.column.columnDef.header, header.getContext())
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1 md:gap-2">
|
||||||
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
{canSort && (
|
||||||
|
<span className="text-text-primary" aria-hidden="true">
|
||||||
|
{{
|
||||||
|
asc: <ArrowUp className="size-4 text-text-primary" />,
|
||||||
|
desc: <ArrowDown className="size-4 text-text-primary" />,
|
||||||
|
}[header.column.getIsSorted() as string] ?? (
|
||||||
|
<ArrowDownUp className="size-4 text-text-primary" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{tableBodyContent}
|
||||||
|
{isFetchingNextPage && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={tableColumns.length}
|
||||||
|
className="p-4 text-center"
|
||||||
|
id="loading-status"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<Spinner className="h-5 w-5" aria-hidden="true" />
|
||||||
|
<span className="sr-only">{localize('com_ui_loading_more_data')}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
{!isLoading && !showSkeletons && rows.length === 0 && (
|
||||||
|
<div
|
||||||
|
className="flex flex-col items-center justify-center py-12"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<Label className="text-center text-text-secondary">
|
||||||
|
{searchTerm ? localize('com_ui_no_search_results') : localize('com_ui_no_data')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DataTable;
|
||||||
124
packages/client/src/components/DataTable/DataTable.types.ts
Normal file
124
packages/client/src/components/DataTable/DataTable.types.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
import type { ColumnDef, SortingState, Table } from '@tanstack/react-table';
|
||||||
|
import type React from 'react';
|
||||||
|
|
||||||
|
export type ProcessedDataRow<TData> = TData & { _id: string; _index: number };
|
||||||
|
|
||||||
|
export type TableColumnDef<TData, TValue> = ColumnDef<ProcessedDataRow<TData>, TValue>;
|
||||||
|
|
||||||
|
export type TableColumn<TData, TValue> = ColumnDef<TData, TValue> & {
|
||||||
|
accessorKey?: string | number;
|
||||||
|
meta?: {
|
||||||
|
/** Column width as a percentage (1-100). Used for proportional column sizing. */
|
||||||
|
width?: number;
|
||||||
|
/** Fixed column size in pixels (e.g., '150px'). Takes precedence over width percentage. */
|
||||||
|
size?: string | number;
|
||||||
|
/** Fixed column size for mobile screens. Falls back to size if not specified. */
|
||||||
|
mobileSize?: string | number;
|
||||||
|
/** Minimum width for the column (e.g., '80px'). */
|
||||||
|
minWidth?: string | number;
|
||||||
|
/** Priority for flexible column width distribution. Higher priority = more space. Default is 1. */
|
||||||
|
priority?: number;
|
||||||
|
/** Additional CSS classes to apply to the column cells and header. */
|
||||||
|
className?: string;
|
||||||
|
/**
|
||||||
|
* When true, this column will be hidden on mobile devices (viewport < 768px).
|
||||||
|
* This is useful for hiding less critical information on smaller screens.
|
||||||
|
*
|
||||||
|
* **Usage Example:**
|
||||||
|
* ```typescript
|
||||||
|
* {
|
||||||
|
* accessorKey: 'createdAt',
|
||||||
|
* header: 'Date Created',
|
||||||
|
* cell: ({ row }) => formatDate(row.original.createdAt),
|
||||||
|
* meta: {
|
||||||
|
* desktopOnly: true, // Hide this column on mobile
|
||||||
|
* width: 20,
|
||||||
|
* className: 'min-w-[6rem]'
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* The column will be completely hidden including:
|
||||||
|
* - Header cell
|
||||||
|
* - Data cells
|
||||||
|
* - Skeleton loading cells
|
||||||
|
*/
|
||||||
|
desktopOnly?: boolean;
|
||||||
|
/**
|
||||||
|
* When true, this column's cells will use `<th scope="row">` instead of `<td>`.
|
||||||
|
* This is important for accessibility as it marks the cell as a row header,
|
||||||
|
* providing context for screen readers about what each row represents.
|
||||||
|
*
|
||||||
|
* Typically the first column (e.g., name, title) should be marked as a row header.
|
||||||
|
*
|
||||||
|
* **Usage Example:**
|
||||||
|
* ```typescript
|
||||||
|
* {
|
||||||
|
* accessorKey: 'title',
|
||||||
|
* header: 'Conversation Name',
|
||||||
|
* cell: ({ row }) => row.original.title,
|
||||||
|
* meta: {
|
||||||
|
* isRowHeader: true // Mark this column as row headers
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
isRowHeader?: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface DataTableConfig {
|
||||||
|
selection?: {
|
||||||
|
enableRowSelection?: boolean;
|
||||||
|
showCheckboxes?: boolean;
|
||||||
|
};
|
||||||
|
search?: {
|
||||||
|
enableSearch?: boolean;
|
||||||
|
debounce?: number;
|
||||||
|
filterColumn?: string;
|
||||||
|
};
|
||||||
|
skeleton?: {
|
||||||
|
count?: number;
|
||||||
|
};
|
||||||
|
virtualization?: {
|
||||||
|
overscan?: number;
|
||||||
|
minRows?: number;
|
||||||
|
rowHeight?: number;
|
||||||
|
fastOverscanMultiplier?: number;
|
||||||
|
};
|
||||||
|
pinning?: {
|
||||||
|
enableColumnPinning?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataTableProps<TData extends Record<string, unknown>, TValue> {
|
||||||
|
columns: TableColumn<TData, TValue>[];
|
||||||
|
data: TData[];
|
||||||
|
className?: string;
|
||||||
|
isLoading?: boolean;
|
||||||
|
isFetching?: boolean;
|
||||||
|
config?: DataTableConfig;
|
||||||
|
onDelete?: (selectedRows: TData[]) => Promise<void>;
|
||||||
|
filterValue?: string;
|
||||||
|
onFilterChange?: (value: string) => void;
|
||||||
|
defaultSort?: SortingState;
|
||||||
|
isFetchingNextPage?: boolean;
|
||||||
|
hasNextPage?: boolean;
|
||||||
|
fetchNextPage?: () => Promise<unknown>;
|
||||||
|
sorting?: SortingState;
|
||||||
|
onSortingChange?: (updater: SortingState | ((old: SortingState) => SortingState)) => void;
|
||||||
|
conversationIndex?: number;
|
||||||
|
customActionsRenderer?: (params: {
|
||||||
|
selectedCount: number;
|
||||||
|
selectedRows: TData[];
|
||||||
|
table: Table<ProcessedDataRow<TData>>;
|
||||||
|
}) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataTableSearchProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,362 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { SelectionCheckbox, SkeletonRows } from './DataTableComponents';
|
||||||
|
import type { TableColumn } from './DataTable.types';
|
||||||
|
|
||||||
|
// Mock the cn utility
|
||||||
|
jest.mock('~/utils', () => ({
|
||||||
|
cn: (...classes: (string | undefined | boolean)[]) => classes.filter(Boolean).join(' '),
|
||||||
|
logger: {
|
||||||
|
log: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the Checkbox component
|
||||||
|
jest.mock('../Checkbox', () => ({
|
||||||
|
Checkbox: ({
|
||||||
|
checked,
|
||||||
|
onCheckedChange,
|
||||||
|
'aria-label': ariaLabel,
|
||||||
|
}: {
|
||||||
|
checked: boolean;
|
||||||
|
onCheckedChange: (value: boolean) => void;
|
||||||
|
'aria-label': string;
|
||||||
|
}) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={(e) => onCheckedChange(e.target.checked)}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
data-testid="checkbox-input"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the Skeleton component
|
||||||
|
jest.mock('../Skeleton', () => ({
|
||||||
|
Skeleton: ({ className }: { className?: string }) => (
|
||||||
|
<div data-testid="skeleton" className={className} />
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the Table components
|
||||||
|
jest.mock('../Table', () => ({
|
||||||
|
TableCell: ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}) => (
|
||||||
|
<td data-testid="table-cell" className={className} style={style}>
|
||||||
|
{children}
|
||||||
|
</td>
|
||||||
|
),
|
||||||
|
TableRow: ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
'data-state': dataState,
|
||||||
|
'data-index': dataIndex,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
'data-state'?: string;
|
||||||
|
'data-index'?: number;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}) => (
|
||||||
|
<tr
|
||||||
|
data-testid="table-row"
|
||||||
|
className={className}
|
||||||
|
data-state={dataState}
|
||||||
|
data-index={dataIndex}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</tr>
|
||||||
|
),
|
||||||
|
TableRowHeader: ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}) => (
|
||||||
|
<th data-testid="table-row-header" className={className} style={style}>
|
||||||
|
{children}
|
||||||
|
</th>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('DataTableComponents', () => {
|
||||||
|
describe('SelectionCheckbox', () => {
|
||||||
|
it('should render checkbox with correct aria-label', () => {
|
||||||
|
render(<SelectionCheckbox checked={false} onChange={jest.fn()} ariaLabel="Select row 1" />);
|
||||||
|
|
||||||
|
const checkbox = screen.getByRole('checkbox');
|
||||||
|
expect(checkbox).toHaveAttribute('aria-label', 'Select row 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render in checked state', () => {
|
||||||
|
render(<SelectionCheckbox checked={true} onChange={jest.fn()} ariaLabel="Select row" />);
|
||||||
|
|
||||||
|
const checkbox = screen.getByRole('checkbox');
|
||||||
|
expect(checkbox).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render in unchecked state', () => {
|
||||||
|
render(<SelectionCheckbox checked={false} onChange={jest.fn()} ariaLabel="Select row" />);
|
||||||
|
|
||||||
|
const checkbox = screen.getByRole('checkbox');
|
||||||
|
expect(checkbox).not.toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onChange when clicked', () => {
|
||||||
|
const mockOnChange = jest.fn();
|
||||||
|
render(<SelectionCheckbox checked={false} onChange={mockOnChange} ariaLabel="Select row" />);
|
||||||
|
|
||||||
|
const wrapper = screen.getByRole('button');
|
||||||
|
fireEvent.click(wrapper);
|
||||||
|
|
||||||
|
expect(mockOnChange).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onChange with false when unchecking', () => {
|
||||||
|
const mockOnChange = jest.fn();
|
||||||
|
render(<SelectionCheckbox checked={true} onChange={mockOnChange} ariaLabel="Select row" />);
|
||||||
|
|
||||||
|
const wrapper = screen.getByRole('button');
|
||||||
|
fireEvent.click(wrapper);
|
||||||
|
|
||||||
|
expect(mockOnChange).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trigger onChange on Enter key', () => {
|
||||||
|
const mockOnChange = jest.fn();
|
||||||
|
render(<SelectionCheckbox checked={false} onChange={mockOnChange} ariaLabel="Select row" />);
|
||||||
|
|
||||||
|
const wrapper = screen.getByRole('button');
|
||||||
|
fireEvent.keyDown(wrapper, { key: 'Enter' });
|
||||||
|
|
||||||
|
expect(mockOnChange).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trigger onChange on Space key', () => {
|
||||||
|
const mockOnChange = jest.fn();
|
||||||
|
render(<SelectionCheckbox checked={false} onChange={mockOnChange} ariaLabel="Select row" />);
|
||||||
|
|
||||||
|
const wrapper = screen.getByRole('button');
|
||||||
|
fireEvent.keyDown(wrapper, { key: ' ' });
|
||||||
|
|
||||||
|
expect(mockOnChange).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not trigger onChange on other keys', () => {
|
||||||
|
const mockOnChange = jest.fn();
|
||||||
|
render(<SelectionCheckbox checked={false} onChange={mockOnChange} ariaLabel="Select row" />);
|
||||||
|
|
||||||
|
const wrapper = screen.getByRole('button');
|
||||||
|
fireEvent.keyDown(wrapper, { key: 'a' });
|
||||||
|
fireEvent.keyDown(wrapper, { key: 'Tab' });
|
||||||
|
fireEvent.keyDown(wrapper, { key: 'Escape' });
|
||||||
|
|
||||||
|
expect(mockOnChange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stop event propagation on click', () => {
|
||||||
|
const mockOnChange = jest.fn();
|
||||||
|
const mockParentClick = jest.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<div onClick={mockParentClick}>
|
||||||
|
<SelectionCheckbox checked={false} onChange={mockOnChange} ariaLabel="Select row" />
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const wrapper = screen.getByRole('button');
|
||||||
|
fireEvent.click(wrapper);
|
||||||
|
|
||||||
|
expect(mockOnChange).toHaveBeenCalled();
|
||||||
|
expect(mockParentClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should stop event propagation on keydown', () => {
|
||||||
|
const mockOnChange = jest.fn();
|
||||||
|
const mockParentKeyDown = jest.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<div onKeyDown={mockParentKeyDown}>
|
||||||
|
<SelectionCheckbox checked={false} onChange={mockOnChange} ariaLabel="Select row" />
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const wrapper = screen.getByRole('button');
|
||||||
|
fireEvent.keyDown(wrapper, { key: 'Enter' });
|
||||||
|
|
||||||
|
expect(mockOnChange).toHaveBeenCalled();
|
||||||
|
expect(mockParentKeyDown).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have tabIndex 0 for keyboard accessibility', () => {
|
||||||
|
render(<SelectionCheckbox checked={false} onChange={jest.fn()} ariaLabel="Select row" />);
|
||||||
|
|
||||||
|
const wrapper = screen.getByRole('button');
|
||||||
|
expect(wrapper).toHaveAttribute('tabindex', '0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SkeletonRows', () => {
|
||||||
|
const createTestColumns = () =>
|
||||||
|
[
|
||||||
|
{ accessorKey: 'name', header: 'Name' },
|
||||||
|
{ accessorKey: 'status', header: 'Status' },
|
||||||
|
] as TableColumn<Record<string, unknown>, unknown>[];
|
||||||
|
|
||||||
|
it('should render correct number of skeleton rows', () => {
|
||||||
|
const columns = createTestColumns();
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<SkeletonRows count={5} columns={columns} />
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = screen.getAllByTestId('table-row');
|
||||||
|
expect(rows).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use default count of 10 when not provided', () => {
|
||||||
|
const columns = createTestColumns();
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<SkeletonRows columns={columns} />
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = screen.getAllByTestId('table-row');
|
||||||
|
expect(rows).toHaveLength(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render skeleton for each column', () => {
|
||||||
|
const columns = createTestColumns();
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<SkeletonRows count={1} columns={columns} />
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const skeletons = screen.getAllByTestId('skeleton');
|
||||||
|
expect(skeletons).toHaveLength(2); // One per column
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply desktopOnly class to column cells', () => {
|
||||||
|
const columns = [
|
||||||
|
{ accessorKey: 'name', header: 'Name' },
|
||||||
|
{ accessorKey: 'status', header: 'Status', meta: { desktopOnly: true } },
|
||||||
|
] as TableColumn<Record<string, unknown>, unknown>[];
|
||||||
|
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<SkeletonRows count={1} columns={columns} />
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const cells = screen.getAllByTestId('table-cell');
|
||||||
|
// Second cell should have desktopOnly class
|
||||||
|
expect(cells[1]).toHaveClass('hidden');
|
||||||
|
expect(cells[1]).toHaveClass('md:table-cell');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply custom className from column meta', () => {
|
||||||
|
const columns = [
|
||||||
|
{ accessorKey: 'name', header: 'Name', meta: { className: 'custom-class' } },
|
||||||
|
] as TableColumn<Record<string, unknown>, unknown>[];
|
||||||
|
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<SkeletonRows count={1} columns={columns} />
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const cell = screen.getByTestId('table-cell');
|
||||||
|
expect(cell).toHaveClass('custom-class');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate unique keys for each row', () => {
|
||||||
|
const columns = createTestColumns();
|
||||||
|
const { container } = render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<SkeletonRows count={3} columns={columns} />
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify no duplicate keys warning (React would warn in console)
|
||||||
|
const rows = container.querySelectorAll('tr');
|
||||||
|
expect(rows).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle columns with id instead of accessorKey', () => {
|
||||||
|
const columns: TableColumn<Record<string, unknown>, unknown>[] = [
|
||||||
|
{ id: 'custom-id', header: 'Custom Column' },
|
||||||
|
];
|
||||||
|
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<SkeletonRows count={1} columns={columns} />
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const cells = screen.getAllByTestId('table-cell');
|
||||||
|
expect(cells).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle zero count', () => {
|
||||||
|
const columns = createTestColumns();
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<SkeletonRows count={0} columns={columns} />
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = screen.queryAllByTestId('table-row');
|
||||||
|
expect(rows).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty columns array', () => {
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<SkeletonRows count={3} columns={[]} />
|
||||||
|
</tbody>
|
||||||
|
</table>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = screen.getAllByTestId('table-row');
|
||||||
|
expect(rows).toHaveLength(3);
|
||||||
|
// Rows should have no cells
|
||||||
|
const cells = screen.queryAllByTestId('table-cell');
|
||||||
|
expect(cells).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
168
packages/client/src/components/DataTable/DataTableComponents.tsx
Normal file
168
packages/client/src/components/DataTable/DataTableComponents.tsx
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
import React, { memo, forwardRef } from 'react';
|
||||||
|
import { flexRender } from '@tanstack/react-table';
|
||||||
|
import type { TableColumn } from './DataTable.types';
|
||||||
|
import type { Row } from '@tanstack/react-table';
|
||||||
|
import { TableCell, TableRow, TableRowHeader } from '../Table';
|
||||||
|
import { Checkbox } from '../Checkbox';
|
||||||
|
import { Skeleton } from '../Skeleton';
|
||||||
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
|
export const SelectionCheckbox = memo(
|
||||||
|
({
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
ariaLabel,
|
||||||
|
}: {
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (value: boolean) => void;
|
||||||
|
ariaLabel: string;
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onChange(!checked);
|
||||||
|
}
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
className="flex h-full w-8 items-center justify-center"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onChange(!checked);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox checked={checked} onCheckedChange={onChange} aria-label={ariaLabel} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
SelectionCheckbox.displayName = 'SelectionCheckbox';
|
||||||
|
|
||||||
|
interface TableRowComponentProps<TData extends Record<string, unknown>> {
|
||||||
|
row: Row<TData>;
|
||||||
|
virtualIndex?: number;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
selected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ...existing code...
|
||||||
|
const TableRowComponent = <TData extends Record<string, unknown>>(
|
||||||
|
{ row, virtualIndex, style, selected }: TableRowComponentProps<TData>,
|
||||||
|
ref: React.Ref<HTMLTableRowElement>,
|
||||||
|
) => {
|
||||||
|
// Check if we're on mobile - use window.innerWidth for component-level check
|
||||||
|
const isSmallScreen = typeof window !== 'undefined' && window.innerWidth < 768;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
ref={ref}
|
||||||
|
data-state={selected ? 'selected' : undefined}
|
||||||
|
data-index={virtualIndex}
|
||||||
|
className="border-none hover:bg-surface-secondary"
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => {
|
||||||
|
const meta = cell.column.columnDef.meta as
|
||||||
|
| { className?: string; desktopOnly?: boolean; width?: number; isRowHeader?: boolean }
|
||||||
|
| undefined;
|
||||||
|
const isDesktopOnly = meta?.desktopOnly;
|
||||||
|
const isRowHeader = meta?.isRowHeader;
|
||||||
|
const percent = meta?.width;
|
||||||
|
const widthStyle =
|
||||||
|
cell.column.id === 'select'
|
||||||
|
? { width: '32px', maxWidth: '32px', minWidth: '32px' }
|
||||||
|
: percent
|
||||||
|
? {
|
||||||
|
width: `${percent}%`,
|
||||||
|
maxWidth: `${percent}%`,
|
||||||
|
minWidth: `${percent}%`, // Don't shrink on mobile
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const CellComponent = isRowHeader ? TableRowHeader : TableCell;
|
||||||
|
|
||||||
|
// For desktop-only columns on mobile, keep them in DOM but visually hidden
|
||||||
|
// This ensures screen readers can still access the content
|
||||||
|
const cellProps =
|
||||||
|
isDesktopOnly && isSmallScreen
|
||||||
|
? { 'aria-hidden': false as const } // Keep accessible to screen readers
|
||||||
|
: {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CellComponent
|
||||||
|
key={cell.id}
|
||||||
|
className={cn(
|
||||||
|
'max-w-0 truncate px-2 py-2 md:px-3 md:py-3',
|
||||||
|
cell.column.id === 'select' && 'w-8 p-1',
|
||||||
|
meta?.className,
|
||||||
|
isDesktopOnly && 'hidden md:table-cell',
|
||||||
|
)}
|
||||||
|
style={widthStyle}
|
||||||
|
{...cellProps}
|
||||||
|
>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</CellComponent>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
// ...existing code...
|
||||||
|
|
||||||
|
type ForwardTableRowComponentType = <TData extends Record<string, unknown>>(
|
||||||
|
props: TableRowComponentProps<TData> & React.RefAttributes<HTMLTableRowElement>,
|
||||||
|
) => JSX.Element;
|
||||||
|
|
||||||
|
const ForwardTableRowComponent = forwardRef(TableRowComponent) as ForwardTableRowComponentType;
|
||||||
|
|
||||||
|
interface GenericRowProps {
|
||||||
|
row: Row<Record<string, unknown>>;
|
||||||
|
virtualIndex?: number;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
selected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MemoizedTableRow = memo(
|
||||||
|
ForwardTableRowComponent as (props: GenericRowProps) => JSX.Element,
|
||||||
|
(prev: GenericRowProps, next: GenericRowProps) =>
|
||||||
|
prev.row.original === next.row.original && prev.selected === next.selected,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SkeletonRows = memo(
|
||||||
|
<TData extends Record<string, unknown>, TValue>({
|
||||||
|
count = 10,
|
||||||
|
columns,
|
||||||
|
}: {
|
||||||
|
count?: number;
|
||||||
|
columns: TableColumn<TData, TValue>[];
|
||||||
|
}) => (
|
||||||
|
<>
|
||||||
|
{Array.from({ length: count }, (_, index) => (
|
||||||
|
<TableRow key={`skeleton-${index}`} className="h-[56px] border-b border-border-light">
|
||||||
|
{columns.map((column) => {
|
||||||
|
const columnKey = String(
|
||||||
|
column.id ?? ('accessorKey' in column && column.accessorKey) ?? '',
|
||||||
|
);
|
||||||
|
const meta = column.meta as { className?: string; desktopOnly?: boolean } | undefined;
|
||||||
|
return (
|
||||||
|
<TableCell
|
||||||
|
key={columnKey}
|
||||||
|
className={cn(
|
||||||
|
'px-2 py-2 md:px-3',
|
||||||
|
meta?.className,
|
||||||
|
meta?.desktopOnly && 'hidden md:table-cell',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Skeleton className="h-6 w-full" />
|
||||||
|
</TableCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
SkeletonRows.displayName = 'SkeletonRows';
|
||||||
|
|
@ -0,0 +1,311 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { DataTableErrorBoundary } from './DataTableErrorBoundary';
|
||||||
|
|
||||||
|
// Mock the logger
|
||||||
|
jest.mock('~/utils', () => ({
|
||||||
|
logger: {
|
||||||
|
error: jest.fn(),
|
||||||
|
log: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock lucide-react
|
||||||
|
jest.mock('lucide-react', () => ({
|
||||||
|
RefreshCw: ({ className }: { className?: string }) => (
|
||||||
|
<svg data-testid="refresh-icon" className={className} />
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the Button component
|
||||||
|
jest.mock('../Button', () => ({
|
||||||
|
Button: ({
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
variant,
|
||||||
|
className,
|
||||||
|
'aria-label': ariaLabel,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
onClick?: () => void;
|
||||||
|
variant?: string;
|
||||||
|
className?: string;
|
||||||
|
'aria-label'?: string;
|
||||||
|
}) => (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
data-variant={variant}
|
||||||
|
className={className}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
data-testid="retry-button"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Component that throws an error
|
||||||
|
const ThrowingComponent = ({ shouldThrow }: { shouldThrow: boolean }) => {
|
||||||
|
if (shouldThrow) {
|
||||||
|
throw new Error('Test error message');
|
||||||
|
}
|
||||||
|
return <div data-testid="child-content">Child content</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Suppress console.error for expected errors during tests
|
||||||
|
const originalConsoleError = console.error;
|
||||||
|
beforeAll(() => {
|
||||||
|
console.error = jest.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
console.error = originalConsoleError;
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DataTableErrorBoundary', () => {
|
||||||
|
it('should render children when no error', () => {
|
||||||
|
render(
|
||||||
|
<DataTableErrorBoundary>
|
||||||
|
<ThrowingComponent shouldThrow={false} />
|
||||||
|
</DataTableErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('child-content')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Child content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should catch errors and display fallback UI', () => {
|
||||||
|
render(
|
||||||
|
<DataTableErrorBoundary>
|
||||||
|
<ThrowingComponent shouldThrow={true} />
|
||||||
|
</DataTableErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId('child-content')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show error title', () => {
|
||||||
|
render(
|
||||||
|
<DataTableErrorBoundary>
|
||||||
|
<ThrowingComponent shouldThrow={true} />
|
||||||
|
</DataTableErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The title comes from localize which returns the key
|
||||||
|
expect(screen.getByText('com_ui_table_error')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show error description', () => {
|
||||||
|
render(
|
||||||
|
<DataTableErrorBoundary>
|
||||||
|
<ThrowingComponent shouldThrow={true} />
|
||||||
|
</DataTableErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('com_ui_table_error_description')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render retry button', () => {
|
||||||
|
render(
|
||||||
|
<DataTableErrorBoundary>
|
||||||
|
<ThrowingComponent shouldThrow={true} />
|
||||||
|
</DataTableErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('retry-button')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('com_ui_retry')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset error state when retry button is clicked', () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<DataTableErrorBoundary>
|
||||||
|
<ThrowingComponent shouldThrow={true} />
|
||||||
|
</DataTableErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Error state should be shown
|
||||||
|
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// First rerender with non-throwing component (error boundary still shows fallback
|
||||||
|
// because it hasn't reset yet)
|
||||||
|
rerender(
|
||||||
|
<DataTableErrorBoundary>
|
||||||
|
<ThrowingComponent shouldThrow={false} />
|
||||||
|
</DataTableErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Still showing error (because error boundary hasn't reset)
|
||||||
|
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Click retry to reset error boundary state
|
||||||
|
fireEvent.click(screen.getByTestId('retry-button'));
|
||||||
|
|
||||||
|
// Now children should render without throwing
|
||||||
|
expect(screen.getByTestId('child-content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onError callback with error', () => {
|
||||||
|
const mockOnError = jest.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DataTableErrorBoundary onError={mockOnError}>
|
||||||
|
<ThrowingComponent shouldThrow={true} />
|
||||||
|
</DataTableErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockOnError).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockOnError).toHaveBeenCalledWith(expect.any(Error));
|
||||||
|
expect(mockOnError.mock.calls[0][0].message).toBe('Test error message');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onReset callback on retry', () => {
|
||||||
|
const mockOnReset = jest.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DataTableErrorBoundary onReset={mockOnReset}>
|
||||||
|
<ThrowingComponent shouldThrow={true} />
|
||||||
|
</DataTableErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId('retry-button'));
|
||||||
|
|
||||||
|
expect(mockOnReset).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have proper ARIA attributes on error card', () => {
|
||||||
|
render(
|
||||||
|
<DataTableErrorBoundary>
|
||||||
|
<ThrowingComponent shouldThrow={true} />
|
||||||
|
</DataTableErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const alertElement = screen.getByRole('alert');
|
||||||
|
expect(alertElement).toHaveAttribute('aria-live', 'assertive');
|
||||||
|
expect(alertElement).toHaveAttribute('aria-labelledby', 'datatable-error-title');
|
||||||
|
expect(alertElement).toHaveAttribute('aria-describedby', 'datatable-error-desc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have proper id on title element', () => {
|
||||||
|
render(
|
||||||
|
<DataTableErrorBoundary>
|
||||||
|
<ThrowingComponent shouldThrow={true} />
|
||||||
|
</DataTableErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const title = screen.getByText('com_ui_table_error');
|
||||||
|
expect(title).toHaveAttribute('id', 'datatable-error-title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have proper id on description element', () => {
|
||||||
|
render(
|
||||||
|
<DataTableErrorBoundary>
|
||||||
|
<ThrowingComponent shouldThrow={true} />
|
||||||
|
</DataTableErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const description = screen.getByText('com_ui_table_error_description');
|
||||||
|
expect(description).toHaveAttribute('id', 'datatable-error-desc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render refresh icon in error state', () => {
|
||||||
|
render(
|
||||||
|
<DataTableErrorBoundary>
|
||||||
|
<ThrowingComponent shouldThrow={true} />
|
||||||
|
</DataTableErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const refreshIcons = screen.getAllByTestId('refresh-icon');
|
||||||
|
expect(refreshIcons.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have aria-label on retry button', () => {
|
||||||
|
render(
|
||||||
|
<DataTableErrorBoundary>
|
||||||
|
<ThrowingComponent shouldThrow={true} />
|
||||||
|
</DataTableErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const retryButton = screen.getByTestId('retry-button');
|
||||||
|
expect(retryButton).toHaveAttribute('aria-label', 'Retry loading table');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with outline variant button', () => {
|
||||||
|
render(
|
||||||
|
<DataTableErrorBoundary>
|
||||||
|
<ThrowingComponent shouldThrow={true} />
|
||||||
|
</DataTableErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const retryButton = screen.getByTestId('retry-button');
|
||||||
|
expect(retryButton).toHaveAttribute('data-variant', 'outline');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple consecutive errors', () => {
|
||||||
|
const mockOnError = jest.fn();
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<DataTableErrorBoundary onError={mockOnError}>
|
||||||
|
<ThrowingComponent shouldThrow={true} />
|
||||||
|
</DataTableErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockOnError).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Reset and throw again
|
||||||
|
fireEvent.click(screen.getByTestId('retry-button'));
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<DataTableErrorBoundary onError={mockOnError}>
|
||||||
|
<ThrowingComponent shouldThrow={true} />
|
||||||
|
</DataTableErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should have caught the error again
|
||||||
|
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not call onError when no error occurs', () => {
|
||||||
|
const mockOnError = jest.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DataTableErrorBoundary onError={mockOnError}>
|
||||||
|
<ThrowingComponent shouldThrow={false} />
|
||||||
|
</DataTableErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockOnError).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle children that return null', () => {
|
||||||
|
const NullComponent = () => null;
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DataTableErrorBoundary>
|
||||||
|
<NullComponent />
|
||||||
|
</DataTableErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should not show error state
|
||||||
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle nested children', () => {
|
||||||
|
render(
|
||||||
|
<DataTableErrorBoundary>
|
||||||
|
<div>
|
||||||
|
<span>Nested content</span>
|
||||||
|
<ThrowingComponent shouldThrow={false} />
|
||||||
|
</div>
|
||||||
|
</DataTableErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText('Nested content')).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId('child-content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
import { Component, ErrorInfo, ReactNode, createRef } from 'react';
|
||||||
|
import { RefreshCw } from 'lucide-react';
|
||||||
|
import { Button } from '../Button';
|
||||||
|
import { logger } from '~/utils';
|
||||||
|
import { useLocalize } from '~/hooks';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error boundary specifically for DataTable component.
|
||||||
|
* Catches JavaScript errors in the table rendering and provides a fallback UI.
|
||||||
|
* Handles errors from virtualizer, cell renderers, fetch operations, and child components.
|
||||||
|
*/
|
||||||
|
interface DataTableErrorBoundaryState {
|
||||||
|
hasError: boolean;
|
||||||
|
error?: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DataTableErrorBoundaryProps {
|
||||||
|
children: ReactNode;
|
||||||
|
onError?: (error: Error) => void;
|
||||||
|
onReset?: () => void;
|
||||||
|
}
|
||||||
|
interface DataTableErrorBoundaryInnerProps extends DataTableErrorBoundaryProps {
|
||||||
|
localize: ReturnType<typeof useLocalize>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class DataTableErrorBoundaryInner extends Component<
|
||||||
|
DataTableErrorBoundaryInnerProps,
|
||||||
|
DataTableErrorBoundaryState
|
||||||
|
> {
|
||||||
|
private errorCardRef = createRef<HTMLDivElement>();
|
||||||
|
|
||||||
|
constructor(props: DataTableErrorBoundaryInnerProps) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): DataTableErrorBoundaryState {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
|
logger.error('DataTable Error Boundary caught an error:', error, errorInfo);
|
||||||
|
this.props.onError?.(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(
|
||||||
|
_prevProps: DataTableErrorBoundaryInnerProps,
|
||||||
|
prevState: DataTableErrorBoundaryState,
|
||||||
|
) {
|
||||||
|
if (!prevState.hasError && this.state.hasError && this.errorCardRef.current) {
|
||||||
|
this.errorCardRef.current.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the error state and attempt to re-render the children.
|
||||||
|
* This can be used to retry after a table error (e.g., network retry).
|
||||||
|
*/
|
||||||
|
private handleReset = () => {
|
||||||
|
this.setState({ hasError: false, error: undefined });
|
||||||
|
this.props.onReset?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col items-center justify-center p-8">
|
||||||
|
<div
|
||||||
|
ref={this.errorCardRef}
|
||||||
|
role="alert"
|
||||||
|
aria-live="assertive"
|
||||||
|
aria-labelledby="datatable-error-title"
|
||||||
|
aria-describedby="datatable-error-desc"
|
||||||
|
tabIndex={-1}
|
||||||
|
className="before:bg-surface-destructive/80 relative w-full max-w-md overflow-hidden rounded-lg border border-border-light bg-surface-primary-alt p-6 shadow-sm outline-none before:absolute before:left-0 before:top-0 before:h-full before:w-1 focus:ring-2 focus:ring-ring focus:ring-offset-2 dark:border-border-medium dark:bg-surface-secondary"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RefreshCw className="h-4 w-4 text-surface-destructive" />
|
||||||
|
<h3 id="datatable-error-title" className="text-sm font-medium text-text-primary">
|
||||||
|
{this.props.localize('com_ui_table_error')}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p id="datatable-error-desc" className="mt-2 text-sm text-text-secondary">
|
||||||
|
{this.props.localize('com_ui_table_error_description')}
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 flex justify-center">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={this.handleReset}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 text-sm hover:bg-surface-hover dark:hover:bg-surface-active"
|
||||||
|
aria-label="Retry loading table"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3 w-3" />
|
||||||
|
{this.props.localize('com_ui_retry')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{import.meta.env.MODE === 'development' && this.state.error && (
|
||||||
|
<details className="mt-4 max-w-md rounded-md bg-surface-secondary p-3 text-xs dark:bg-surface-tertiary">
|
||||||
|
<summary className="cursor-pointer font-medium text-text-primary">
|
||||||
|
{this.props.localize('com_ui_error_details')}
|
||||||
|
</summary>
|
||||||
|
<pre className="mt-2 whitespace-pre-wrap text-text-secondary">
|
||||||
|
{this.state.error.message}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataTableErrorBoundary(props: DataTableErrorBoundaryProps) {
|
||||||
|
const localize = useLocalize();
|
||||||
|
return <DataTableErrorBoundaryInner {...props} localize={localize} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DataTableErrorBoundary;
|
||||||
|
|
@ -0,0 +1,178 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { DataTableSearch } from './DataTableSearch';
|
||||||
|
|
||||||
|
// Mock the cn utility
|
||||||
|
jest.mock('~/utils', () => ({
|
||||||
|
cn: (...classes: (string | undefined | boolean)[]) => classes.filter(Boolean).join(' '),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the Input component
|
||||||
|
|
||||||
|
jest.mock('../Input', () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
const ReactModule = require('react');
|
||||||
|
return {
|
||||||
|
Input: ReactModule.forwardRef(function MockInput(
|
||||||
|
props: {
|
||||||
|
id?: string;
|
||||||
|
value?: string;
|
||||||
|
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
'aria-label'?: string;
|
||||||
|
'aria-describedby'?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
},
|
||||||
|
ref: React.Ref<HTMLInputElement>,
|
||||||
|
) {
|
||||||
|
return ReactModule.createElement('input', {
|
||||||
|
ref,
|
||||||
|
id: props.id,
|
||||||
|
value: props.value,
|
||||||
|
onChange: props.onChange,
|
||||||
|
disabled: props.disabled,
|
||||||
|
'aria-label': props['aria-label'],
|
||||||
|
'aria-describedby': props['aria-describedby'],
|
||||||
|
placeholder: props.placeholder,
|
||||||
|
className: props.className,
|
||||||
|
'data-testid': 'search-input',
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DataTableSearch', () => {
|
||||||
|
it('should render input with correct placeholder', () => {
|
||||||
|
render(<DataTableSearch value="" onChange={jest.fn()} placeholder="Search items..." />);
|
||||||
|
|
||||||
|
const input = screen.getByTestId('search-input');
|
||||||
|
expect(input).toHaveAttribute('placeholder', 'Search items...');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with default placeholder when not provided', () => {
|
||||||
|
render(<DataTableSearch value="" onChange={jest.fn()} />);
|
||||||
|
|
||||||
|
const input = screen.getByTestId('search-input');
|
||||||
|
// Default placeholder is from localize function which returns the key
|
||||||
|
expect(input).toHaveAttribute('placeholder', 'com_ui_search');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display the current value', () => {
|
||||||
|
render(<DataTableSearch value="test query" onChange={jest.fn()} />);
|
||||||
|
|
||||||
|
const input = screen.getByTestId('search-input');
|
||||||
|
expect(input).toHaveValue('test query');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call onChange when user types', () => {
|
||||||
|
const mockOnChange = jest.fn();
|
||||||
|
render(<DataTableSearch value="" onChange={mockOnChange} />);
|
||||||
|
|
||||||
|
const input = screen.getByTestId('search-input');
|
||||||
|
fireEvent.change(input, { target: { value: 'new search' } });
|
||||||
|
|
||||||
|
expect(mockOnChange).toHaveBeenCalledWith('new search');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have accessible label (sr-only)', () => {
|
||||||
|
render(<DataTableSearch value="" onChange={jest.fn()} />);
|
||||||
|
|
||||||
|
// The label should be present but visually hidden (sr-only class)
|
||||||
|
const label = screen.getByText('com_ui_search_table');
|
||||||
|
expect(label).toHaveClass('sr-only');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have aria-label on input', () => {
|
||||||
|
render(<DataTableSearch value="" onChange={jest.fn()} />);
|
||||||
|
|
||||||
|
const input = screen.getByTestId('search-input');
|
||||||
|
expect(input).toHaveAttribute('aria-label', 'com_ui_search_table');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have aria-describedby linking to description', () => {
|
||||||
|
render(<DataTableSearch value="" onChange={jest.fn()} />);
|
||||||
|
|
||||||
|
const input = screen.getByTestId('search-input');
|
||||||
|
expect(input).toHaveAttribute('aria-describedby', 'search-description');
|
||||||
|
|
||||||
|
// Description should be present
|
||||||
|
const description = screen.getByText('com_ui_search_table_description');
|
||||||
|
expect(description).toHaveAttribute('id', 'search-description');
|
||||||
|
expect(description).toHaveClass('sr-only');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect disabled prop', () => {
|
||||||
|
render(<DataTableSearch value="" onChange={jest.fn()} disabled={true} />);
|
||||||
|
|
||||||
|
const input = screen.getByTestId('search-input');
|
||||||
|
expect(input).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not be disabled by default', () => {
|
||||||
|
render(<DataTableSearch value="" onChange={jest.fn()} />);
|
||||||
|
|
||||||
|
const input = screen.getByTestId('search-input');
|
||||||
|
expect(input).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply custom className', () => {
|
||||||
|
render(<DataTableSearch value="" onChange={jest.fn()} className="custom-class" />);
|
||||||
|
|
||||||
|
const input = screen.getByTestId('search-input');
|
||||||
|
expect(input.className).toContain('custom-class');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply default styling classes', () => {
|
||||||
|
render(<DataTableSearch value="" onChange={jest.fn()} />);
|
||||||
|
|
||||||
|
const input = screen.getByTestId('search-input');
|
||||||
|
expect(input.className).toContain('h-10');
|
||||||
|
expect(input.className).toContain('bg-surface-secondary');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have correct id for label association', () => {
|
||||||
|
render(<DataTableSearch value="" onChange={jest.fn()} />);
|
||||||
|
|
||||||
|
const input = screen.getByTestId('search-input');
|
||||||
|
const label = screen.getByText('com_ui_search_table');
|
||||||
|
|
||||||
|
expect(input).toHaveAttribute('id', 'table-search');
|
||||||
|
expect(label).toHaveAttribute('for', 'table-search');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty string onChange', () => {
|
||||||
|
const mockOnChange = jest.fn();
|
||||||
|
render(<DataTableSearch value="existing" onChange={mockOnChange} />);
|
||||||
|
|
||||||
|
const input = screen.getByTestId('search-input');
|
||||||
|
fireEvent.change(input, { target: { value: '' } });
|
||||||
|
|
||||||
|
expect(mockOnChange).toHaveBeenCalledWith('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle special characters in search', () => {
|
||||||
|
const mockOnChange = jest.fn();
|
||||||
|
render(<DataTableSearch value="" onChange={mockOnChange} />);
|
||||||
|
|
||||||
|
const input = screen.getByTestId('search-input');
|
||||||
|
fireEvent.change(input, { target: { value: 'test@#$%^&*()' } });
|
||||||
|
|
||||||
|
expect(mockOnChange).toHaveBeenCalledWith('test@#$%^&*()');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle long text input', () => {
|
||||||
|
const mockOnChange = jest.fn();
|
||||||
|
const longText = 'a'.repeat(1000);
|
||||||
|
render(<DataTableSearch value="" onChange={mockOnChange} />);
|
||||||
|
|
||||||
|
const input = screen.getByTestId('search-input');
|
||||||
|
fireEvent.change(input, { target: { value: longText } });
|
||||||
|
|
||||||
|
expect(mockOnChange).toHaveBeenCalledWith(longText);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be memoized (displayName check)', () => {
|
||||||
|
expect(DataTableSearch.displayName).toBe('DataTableSearch');
|
||||||
|
});
|
||||||
|
});
|
||||||
37
packages/client/src/components/DataTable/DataTableSearch.tsx
Normal file
37
packages/client/src/components/DataTable/DataTableSearch.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { memo } from 'react';
|
||||||
|
import { startTransition } from 'react';
|
||||||
|
import type { DataTableSearchProps } from './DataTable.types';
|
||||||
|
import { useLocalize } from '~/hooks';
|
||||||
|
import { Input } from '../Input';
|
||||||
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
|
export const DataTableSearch = memo(
|
||||||
|
({ value, onChange, placeholder, className, disabled = false }: DataTableSearchProps) => {
|
||||||
|
const localize = useLocalize();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<label htmlFor="table-search" className="sr-only">
|
||||||
|
{localize('com_ui_search_table')}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="table-search"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
startTransition(() => onChange(e.target.value));
|
||||||
|
}}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={localize('com_ui_search_table')}
|
||||||
|
aria-describedby="search-description"
|
||||||
|
placeholder={placeholder || localize('com_ui_search')}
|
||||||
|
className={cn('h-10 rounded-b-none border-0 bg-surface-secondary md:h-12', className)}
|
||||||
|
/>
|
||||||
|
<span id="search-description" className="sr-only">
|
||||||
|
{localize('com_ui_search_table_description')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
DataTableSearch.displayName = 'DataTableSearch';
|
||||||
3
packages/client/src/components/DataTable/index.ts
Normal file
3
packages/client/src/components/DataTable/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { default as DataTable } from './DataTable';
|
||||||
|
export * from './DataTable.types';
|
||||||
|
// Removed legacy DataTableSettings exports (store/context) as column resizing & dynamic sizing were deprecated.
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
import { Column } from '@tanstack/react-table';
|
|
||||||
import { ArrowDownIcon, ArrowUpIcon, CaretSortIcon, EyeNoneIcon } from '@radix-ui/react-icons';
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from './DropdownMenu';
|
|
||||||
import { useLocalize } from '~/hooks';
|
|
||||||
import { Button } from './Button';
|
|
||||||
import { cn } from '~/utils';
|
|
||||||
|
|
||||||
interface DataTableColumnHeaderProps<TData, TValue> extends React.HTMLAttributes<HTMLDivElement> {
|
|
||||||
column: Column<TData, TValue>;
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DataTableColumnHeader<TData, TValue>({
|
|
||||||
column,
|
|
||||||
title,
|
|
||||||
className = '',
|
|
||||||
}: DataTableColumnHeaderProps<TData, TValue>) {
|
|
||||||
const localize = useLocalize();
|
|
||||||
|
|
||||||
const getSortIcon = () => {
|
|
||||||
const sortDirection = column.getIsSorted();
|
|
||||||
if (sortDirection === 'desc') {
|
|
||||||
return <ArrowDownIcon className="ml-2 h-4 w-4" />;
|
|
||||||
}
|
|
||||||
if (sortDirection === 'asc') {
|
|
||||||
return <ArrowUpIcon className="ml-2 h-4 w-4" />;
|
|
||||||
}
|
|
||||||
return <CaretSortIcon className="ml-2 h-4 w-4" />;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!column.getCanSort()) {
|
|
||||||
return <div className={cn(className)}>{title}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cn('flex items-center space-x-2', className)}>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="-ml-3 h-8 data-[state=open]:bg-accent"
|
|
||||||
aria-label={localize('com_ui_filter_by', { title })}
|
|
||||||
>
|
|
||||||
<span>{title}</span>
|
|
||||||
{getSortIcon()}
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start" className="z-[1001]">
|
|
||||||
<DropdownMenuItem onClick={() => column.toggleSorting(false)}>
|
|
||||||
<ArrowUpIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
|
|
||||||
Asc
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => column.toggleSorting(true)}>
|
|
||||||
<ArrowDownIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
|
|
||||||
Desc
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
|
|
||||||
<EyeNoneIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
|
|
||||||
Hide
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import 'test/matchMedia.mock';
|
|
||||||
import { render, fireEvent } from '@testing-library/react';
|
import { render, fireEvent } from '@testing-library/react';
|
||||||
import '@testing-library/jest-dom/extend-expect';
|
import '@testing-library/jest-dom';
|
||||||
import DialogTemplate from './DialogTemplate';
|
import DialogTemplate from './DialogTemplate';
|
||||||
import { Dialog } from '@radix-ui/react-dialog';
|
import { Dialog } from '@radix-ui/react-dialog';
|
||||||
import { Provider } from 'jotai';
|
import { Provider } from 'jotai';
|
||||||
|
|
@ -39,7 +38,8 @@ describe('DialogTemplate', () => {
|
||||||
expect(getByText('Main Content')).toBeInTheDocument();
|
expect(getByText('Main Content')).toBeInTheDocument();
|
||||||
expect(getByText('Button')).toBeInTheDocument();
|
expect(getByText('Button')).toBeInTheDocument();
|
||||||
expect(getByText('Left Button')).toBeInTheDocument();
|
expect(getByText('Left Button')).toBeInTheDocument();
|
||||||
expect(getByText('Cancel')).toBeInTheDocument();
|
// Cancel text is hardcoded in DialogTemplate as 'cancel'
|
||||||
|
expect(getByText('cancel')).toBeInTheDocument();
|
||||||
expect(getByText('Select')).toBeInTheDocument();
|
expect(getByText('Select')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -60,7 +60,7 @@ describe('DialogTemplate', () => {
|
||||||
expect(queryByText('Main Content')).not.toBeInTheDocument();
|
expect(queryByText('Main Content')).not.toBeInTheDocument();
|
||||||
expect(queryByText('Button')).not.toBeInTheDocument();
|
expect(queryByText('Button')).not.toBeInTheDocument();
|
||||||
expect(queryByText('Left Button')).not.toBeInTheDocument();
|
expect(queryByText('Left Button')).not.toBeInTheDocument();
|
||||||
expect(queryByText('Cancel')).not.toBeInTheDocument();
|
expect(queryByText('cancel')).not.toBeInTheDocument();
|
||||||
expect(queryByText('Select')).not.toBeInTheDocument();
|
expect(queryByText('Select')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useRef } from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectArrow,
|
SelectArrow,
|
||||||
|
|
@ -86,6 +86,7 @@ export default function MultiSelect<T extends string>({
|
||||||
renderItemContent,
|
renderItemContent,
|
||||||
}: MultiSelectProps<T>) {
|
}: MultiSelectProps<T>) {
|
||||||
const selectRef = useRef<HTMLButtonElement>(null);
|
const selectRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||||
|
|
||||||
const handleValueChange = (values: T[]) => {
|
const handleValueChange = (values: T[]) => {
|
||||||
setSelectedValues(values);
|
setSelectedValues(values);
|
||||||
|
|
@ -96,7 +97,12 @@ export default function MultiSelect<T extends string>({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<SelectProvider value={selectedValues} setValue={handleValueChange}>
|
<SelectProvider
|
||||||
|
value={selectedValues}
|
||||||
|
setValue={handleValueChange}
|
||||||
|
open={isPopoverOpen}
|
||||||
|
setOpen={setIsPopoverOpen}
|
||||||
|
>
|
||||||
{label && (
|
{label && (
|
||||||
<SelectLabel className={cn('mb-1 block text-sm text-text-primary', labelClassName)}>
|
<SelectLabel className={cn('mb-1 block text-sm text-text-primary', labelClassName)}>
|
||||||
{label}
|
{label}
|
||||||
|
|
@ -117,7 +123,12 @@ export default function MultiSelect<T extends string>({
|
||||||
<span className="mr-auto hidden truncate md:block">
|
<span className="mr-auto hidden truncate md:block">
|
||||||
{renderSelectedValues(selectedValues, placeholder, items)}
|
{renderSelectedValues(selectedValues, placeholder, items)}
|
||||||
</span>
|
</span>
|
||||||
<SelectArrow className="ml-1 hidden stroke-1 text-base opacity-75 md:block" />
|
<SelectArrow
|
||||||
|
className={cn(
|
||||||
|
'ml-1 hidden stroke-1 text-base opacity-75 transition-transform duration-300 md:block',
|
||||||
|
isPopoverOpen && 'rotate-180',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</Select>
|
</Select>
|
||||||
<SelectPopover
|
<SelectPopover
|
||||||
gutter={4}
|
gutter={4}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,6 @@
|
||||||
import { render } from '@testing-library/react';
|
import { render } from '@testing-library/react';
|
||||||
import SplitText from './SplitText';
|
import SplitText from './SplitText';
|
||||||
|
|
||||||
// Mock IntersectionObserver
|
|
||||||
class MockIntersectionObserver {
|
|
||||||
observe = jest.fn();
|
|
||||||
unobserve = jest.fn();
|
|
||||||
disconnect = jest.fn();
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.defineProperty(window, 'IntersectionObserver', {
|
|
||||||
writable: true,
|
|
||||||
configurable: true,
|
|
||||||
value: MockIntersectionObserver,
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('SplitText', () => {
|
describe('SplitText', () => {
|
||||||
it('renders emojis correctly', () => {
|
it('renders emojis correctly', () => {
|
||||||
const emojis = ['🚧', '❤️🔥', '💜', '🦎', '❌', '✅', '⚠️'];
|
const emojis = ['🚧', '❤️🔥', '💜', '🦎', '❌', '✅', '⚠️'];
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,22 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { cn } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
interface TableProps extends React.HTMLAttributes<HTMLTableElement> {
|
||||||
({ className, ...props }, ref) => (
|
unwrapped?: boolean;
|
||||||
<div className="relative w-full overflow-auto">
|
}
|
||||||
|
|
||||||
|
const Table = React.forwardRef<HTMLTableElement, TableProps>(
|
||||||
|
({ className, unwrapped = false, ...props }, ref) => {
|
||||||
|
const tableElement = (
|
||||||
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
|
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
|
||||||
</div>
|
);
|
||||||
),
|
|
||||||
|
if (unwrapped) {
|
||||||
|
return tableElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="relative w-full overflow-auto">{tableElement}</div>;
|
||||||
|
},
|
||||||
);
|
);
|
||||||
Table.displayName = 'Table';
|
Table.displayName = 'Table';
|
||||||
|
|
||||||
|
|
@ -79,6 +89,22 @@ const TableCell = React.forwardRef<
|
||||||
));
|
));
|
||||||
TableCell.displayName = 'TableCell';
|
TableCell.displayName = 'TableCell';
|
||||||
|
|
||||||
|
const TableRowHeader = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<th
|
||||||
|
ref={ref}
|
||||||
|
scope="row"
|
||||||
|
className={cn(
|
||||||
|
'p-4 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TableRowHeader.displayName = 'TableRowHeader';
|
||||||
|
|
||||||
const TableCaption = React.forwardRef<
|
const TableCaption = React.forwardRef<
|
||||||
HTMLTableCaptionElement,
|
HTMLTableCaptionElement,
|
||||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||||
|
|
@ -87,4 +113,14 @@ const TableCaption = React.forwardRef<
|
||||||
));
|
));
|
||||||
TableCaption.displayName = 'TableCaption';
|
TableCaption.displayName = 'TableCaption';
|
||||||
|
|
||||||
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableRowHeader,
|
||||||
|
TableCaption,
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ export * from './AlertDialog';
|
||||||
export * from './Breadcrumb';
|
export * from './Breadcrumb';
|
||||||
export * from './Button';
|
export * from './Button';
|
||||||
export * from './Checkbox';
|
export * from './Checkbox';
|
||||||
export * from './DataTableColumnHeader';
|
|
||||||
export * from './Dialog';
|
export * from './Dialog';
|
||||||
export * from './DropdownMenu';
|
export * from './DropdownMenu';
|
||||||
export * from './HoverCard';
|
export * from './HoverCard';
|
||||||
|
|
@ -31,13 +30,13 @@ export * from './InputOTP';
|
||||||
export * from './MultiSearch';
|
export * from './MultiSearch';
|
||||||
export * from './Resizable';
|
export * from './Resizable';
|
||||||
export * from './Select';
|
export * from './Select';
|
||||||
|
export * from './DataTable/index';
|
||||||
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';
|
||||||
export { default as Combobox } from './Combobox';
|
export { default as Combobox } from './Combobox';
|
||||||
export { default as Dropdown } from './Dropdown';
|
export { default as Dropdown } from './Dropdown';
|
||||||
export { default as SplitText } from './SplitText';
|
export { default as SplitText } from './SplitText';
|
||||||
export { default as DataTable } from './DataTable';
|
|
||||||
export { default as FormInput } from './FormInput';
|
export { default as FormInput } from './FormInput';
|
||||||
export { default as PixelCard } from './PixelCard';
|
export { default as PixelCard } from './PixelCard';
|
||||||
export { default as FileUpload } from './FileUpload';
|
export { default as FileUpload } from './FileUpload';
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
// Unmock react-i18next for this test file since we're testing actual i18n functionality
|
||||||
|
jest.unmock('react-i18next');
|
||||||
|
|
||||||
import i18n from './i18n';
|
import i18n from './i18n';
|
||||||
import English from './en/translation.json';
|
import English from './en/translation.json';
|
||||||
import French from './fr/translation.json';
|
import French from './fr/translation.json';
|
||||||
|
|
@ -13,23 +16,23 @@ describe('i18next translation tests', () => {
|
||||||
|
|
||||||
it('should return the correct translation for a valid key in English', () => {
|
it('should return the correct translation for a valid key in English', () => {
|
||||||
i18n.changeLanguage('en');
|
i18n.changeLanguage('en');
|
||||||
expect(i18n.t('com_ui_examples')).toBe(English.com_ui_examples);
|
expect(i18n.t('com_ui_cancel')).toBe(English.com_ui_cancel);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return the correct translation for a valid key in French', () => {
|
it('should return the correct translation for a valid key in French', () => {
|
||||||
i18n.changeLanguage('fr');
|
i18n.changeLanguage('fr');
|
||||||
expect(i18n.t('com_ui_examples')).toBe(French.com_ui_examples);
|
expect(i18n.t('com_ui_cancel')).toBe(French.com_ui_cancel);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return the correct translation for a valid key in Spanish', () => {
|
it('should return the correct translation for a valid key in Spanish', () => {
|
||||||
i18n.changeLanguage('es');
|
i18n.changeLanguage('es');
|
||||||
expect(i18n.t('com_ui_examples')).toBe(Spanish.com_ui_examples);
|
expect(i18n.t('com_ui_cancel')).toBe(Spanish.com_ui_cancel);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fallback to English for an invalid language code', () => {
|
it('should fallback to English for an invalid language code', () => {
|
||||||
// When an invalid language is provided, i18next should fallback to English
|
// When an invalid language is provided, i18next should fallback to English
|
||||||
i18n.changeLanguage('invalid-code');
|
i18n.changeLanguage('invalid-code');
|
||||||
expect(i18n.t('com_ui_examples')).toBe(English.com_ui_examples);
|
expect(i18n.t('com_ui_cancel')).toBe(English.com_ui_cancel);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return the key itself for an invalid key', () => {
|
it('should return the key itself for an invalid key', () => {
|
||||||
|
|
@ -39,9 +42,8 @@ describe('i18next translation tests', () => {
|
||||||
|
|
||||||
it('should correctly format placeholders in the translation', () => {
|
it('should correctly format placeholders in the translation', () => {
|
||||||
i18n.changeLanguage('en');
|
i18n.changeLanguage('en');
|
||||||
expect(i18n.t('com_endpoint_default_with_num', { 0: 'John' })).toBe('default: John');
|
// The translation uses {count} syntax (not standard i18next {{count}})
|
||||||
|
// Verify i18next returns the template string with the placeholder
|
||||||
i18n.changeLanguage('fr');
|
expect(i18n.t('com_ui_selected_count', { count: 5 })).toBe('{count} selected');
|
||||||
expect(i18n.t('com_endpoint_default_with_num', { 0: 'Marie' })).toBe('par défaut : Marie');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,29 @@
|
||||||
"com_ui_delete_selected_items": "Delete selected items",
|
"com_ui_delete_selected_items": "Delete selected items",
|
||||||
"com_ui_filter_by": "Filter by {{title}}",
|
"com_ui_filter_by": "Filter by {{title}}",
|
||||||
"com_ui_cancel_dialog": "Cancel dialog",
|
"com_ui_cancel_dialog": "Cancel dialog",
|
||||||
|
"com_ui_no_results_found": "No results found",
|
||||||
|
"com_ui_select_all": "Select All",
|
||||||
|
"com_ui_no_selection": "No selection",
|
||||||
|
"com_ui_confirm_bulk_delete": "Are you sure you want to delete the selected items? This action cannot be undone.",
|
||||||
|
"com_ui_delete_success": "Items deleted successfully",
|
||||||
|
"com_ui_retry": "Retry",
|
||||||
|
"com_ui_selected_count": "{count} selected",
|
||||||
|
"com_ui_data_table": "Data Table",
|
||||||
|
"com_ui_no_data": "No data",
|
||||||
|
"com_ui_delete_selected": "Delete Selected",
|
||||||
|
"com_ui_search": "Search...",
|
||||||
|
"com_ui_search_table": "Search table",
|
||||||
|
"com_ui_search_table_description": "Type to filter results",
|
||||||
|
"com_ui_data_table_scroll_area": "Scrollable data table area",
|
||||||
|
"com_ui_select_row": "Select row {{0}}",
|
||||||
|
"com_ui_loading_more_data": "Loading more data...",
|
||||||
|
"com_ui_no_search_results": "No search results found",
|
||||||
|
"com_ui_table_error": "Table Error",
|
||||||
|
"com_ui_table_error_description": "Table failed to load. Please refresh or try again.",
|
||||||
|
"com_ui_error_details": "Error Details (Dev)",
|
||||||
|
"com_ui_enabled": "Enabled",
|
||||||
|
"com_ui_disabled": "Disabled",
|
||||||
"com_ui_toggle_theme": "Toggle theme",
|
"com_ui_toggle_theme": "Toggle theme",
|
||||||
"com_ui_dark_theme_enabled": "Dark theme enabled",
|
"com_ui_dark_theme_enabled": "Dark theme enabled",
|
||||||
"com_ui_light_theme_enabled": "Light theme enabled",
|
"com_ui_light_theme_enabled": "Light theme enabled"
|
||||||
"com_ui_search": "Search..."
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
export * from './utils';
|
export * from './utils';
|
||||||
export * from './theme';
|
export * from './theme';
|
||||||
|
export { default as logger } from './logger';
|
||||||
|
|
|
||||||
49
packages/client/src/utils/logger.ts
Normal file
49
packages/client/src/utils/logger.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
const isDevelopment = process.env.NODE_ENV === 'development';
|
||||||
|
const isLoggerEnabled = process.env.VITE_ENABLE_LOGGER === 'true';
|
||||||
|
const loggerFilter = process.env.VITE_LOGGER_FILTER || '';
|
||||||
|
|
||||||
|
type LogFunction = (...args: unknown[]) => void;
|
||||||
|
|
||||||
|
const createLogFunction = (
|
||||||
|
consoleMethod: LogFunction,
|
||||||
|
type?: 'log' | 'warn' | 'error' | 'info' | 'debug' | 'dir',
|
||||||
|
): LogFunction => {
|
||||||
|
return (...args: unknown[]) => {
|
||||||
|
if (isDevelopment || isLoggerEnabled) {
|
||||||
|
const tag = typeof args[0] === 'string' ? args[0] : '';
|
||||||
|
if (shouldLog(tag)) {
|
||||||
|
if (tag && typeof args[1] === 'string' && type === 'error') {
|
||||||
|
consoleMethod(`[${tag}] ${args[1]}`, ...args.slice(2));
|
||||||
|
} else if (tag && args.length > 1) {
|
||||||
|
consoleMethod(`[${tag}]`, ...args.slice(1));
|
||||||
|
} else {
|
||||||
|
consoleMethod(...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const logger = {
|
||||||
|
log: createLogFunction(console.log, 'log'),
|
||||||
|
dir: createLogFunction(console.dir, 'dir'),
|
||||||
|
warn: createLogFunction(console.warn, 'warn'),
|
||||||
|
info: createLogFunction(console.info, 'info'),
|
||||||
|
error: createLogFunction(console.error, 'error'),
|
||||||
|
debug: createLogFunction(console.debug, 'debug'),
|
||||||
|
};
|
||||||
|
|
||||||
|
function shouldLog(tag: string): boolean {
|
||||||
|
if (!loggerFilter) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
/* If no tag is provided, always log */
|
||||||
|
if (!tag) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return loggerFilter
|
||||||
|
.split(',')
|
||||||
|
.some((filter) => tag.toLowerCase().includes(filter.trim().toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default logger;
|
||||||
20
packages/client/tsconfig.test.json
Normal file
20
packages/client/tsconfig.test.json
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": ".",
|
||||||
|
"noEmit": true,
|
||||||
|
"declaration": false,
|
||||||
|
"declarationMap": false,
|
||||||
|
"declarationDir": null,
|
||||||
|
"outDir": null,
|
||||||
|
"types": ["jest", "node"]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.spec.ts",
|
||||||
|
"src/**/*.spec.tsx",
|
||||||
|
"src/**/*.test.ts",
|
||||||
|
"src/**/*.test.tsx",
|
||||||
|
"jest.setup.ts"
|
||||||
|
],
|
||||||
|
"exclude": ["dist", "node_modules"]
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue