(feat:docs):add contextMenu to actions

This commit is contained in:
ManishMadan2882
2025-03-04 18:53:23 +05:30
parent bfffd5e4b3
commit 2b7f4de832
3 changed files with 250 additions and 114 deletions

View File

@@ -1,4 +1,4 @@
import { SyntheticEvent, useRef, useEffect } from 'react';
import { SyntheticEvent, useRef, useEffect, CSSProperties } from 'react';
export interface MenuOption {
icon?: string;
@@ -27,82 +27,93 @@ export default function ContextMenu({
anchorRef,
className = '',
position = 'bottom-right',
offset = { x: 1, y: 5 },
offset = { x: 0, y: 8 },
}: ContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
useEffect(() => {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
const handleClickOutside = (event: MouseEvent) => {
if (
menuRef.current &&
!menuRef.current.contains(event.target as Node) &&
!anchorRef.current?.contains(event.target as Node)
) {
setIsOpen(false);
}
};
}, []);
const getPositionClasses = () => {
const positionMap = {
'bottom-right': 'translate-x-1 translate-y-5',
'bottom-left': '-translate-x-full translate-y-5',
'top-right': 'translate-x-1 -translate-y-full',
'top-left': '-translate-x-full -translate-y-full',
};
return positionMap[position];
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () =>
document.removeEventListener('mousedown', handleClickOutside);
}
}, [isOpen, setIsOpen]);
if (!isOpen) return null;
const getMenuPosition = () => {
const getMenuPosition = (): CSSProperties => {
if (!anchorRef.current) return {};
const rect = anchorRef.current.getBoundingClientRect();
return {
top: `${rect.top + window.scrollY + offset.y}px`,
};
};
const scrollY = window.scrollY || document.documentElement.scrollTop;
const scrollX = window.scrollX || document.documentElement.scrollLeft;
const getOptionStyles = (option: MenuOption, index: number) => {
if (option.variant === 'danger') {
return `
dark:text-red-2000 dark:hover:bg-charcoal-grey
text-rosso-corsa hover:bg-bright-gray
}`;
let top = rect.bottom + scrollY + offset.y;
let left = rect.right + scrollX + offset.x;
// Adjust position based on position prop
switch (position) {
case 'bottom-left':
left = rect.left + scrollX - offset.x;
break;
case 'top-right':
top = rect.top + scrollY - offset.y;
break;
case 'top-left':
top = rect.top + scrollY - offset.y;
left = rect.left + scrollX - offset.x;
break;
// bottom-right is default
}
return `
dark:text-bright-gray dark:hover:bg-charcoal-grey
text-eerie-black hover:bg-bright-gray
}`;
return {
position: 'fixed',
top: `${top}px`,
left: `${left}px`,
};
};
return (
<div
ref={menuRef}
className={`absolute z-30 ${getPositionClasses()} ${className}`}
style={getMenuPosition()}
className={`fixed z-50 ${className}`}
style={{ ...getMenuPosition() }}
onClick={(e) => e.stopPropagation()}
>
<div
className={`flex w-32 flex-col rounded-xl text-sm shadow-xl md:w-36 dark:bg-charleston-green-2 bg-lotion`}
className="flex w-32 flex-col rounded-xl text-sm shadow-xl md:w-36 dark:bg-charleston-green-2 bg-lotion"
style={{ minWidth: '144px' }}
>
{options.map((option, index) => (
<button
key={index}
onClick={(event: SyntheticEvent) => {
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
option.onClick(event);
setIsOpen(false);
}}
className={`${`
flex justify-start items-center gap-4 p-3
transition-colors duration-200 ease-in-out
${index === 0 ? 'rounded-t-xl' : ''}
${index === options.length - 1 ? 'rounded-b-xl' : ''}
`}${getOptionStyles(option, index)}`}
className={`
flex justify-start items-center gap-4 p-3
transition-colors duration-200 ease-in-out
${index === 0 ? 'rounded-t-xl' : ''}
${index === options.length - 1 ? 'rounded-b-xl' : ''}
${
option.variant === 'danger'
? 'dark:text-red-2000 dark:hover:bg-charcoal-grey text-rosso-corsa hover:bg-bright-gray'
: 'dark:text-bright-gray dark:hover:bg-charcoal-grey text-eerie-black hover:bg-bright-gray'
}
`}
>
{option.icon && (
<img
@@ -110,7 +121,7 @@ export default function ContextMenu({
height={option.iconHeight || 16}
src={option.icon}
alt={option.label}
className={`cursor-pointer hover:opacity-75 ${option.iconClassName}`}
className={`cursor-pointer hover:opacity-75 ${option.iconClassName || ''}`}
/>
)}
<span>{option.label}</span>

View File

@@ -6,6 +6,11 @@ type DropdownMenuProps = {
onSelect: (value: string) => void;
defaultValue?: string;
icon?: string;
isOpen?: boolean;
onOpenChange?: (isOpen: boolean) => void;
anchorRef?: React.RefObject<HTMLElement>;
className?: string;
position?: 'left' | 'right';
};
export default function DropdownMenu({
@@ -14,16 +19,22 @@ export default function DropdownMenu({
onSelect,
defaultValue = 'none',
icon,
isOpen: controlledIsOpen,
onOpenChange,
anchorRef,
className,
position = 'left',
}: DropdownMenuProps) {
const dropdownRef = React.useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = React.useState(false);
const [internalIsOpen, setInternalIsOpen] = React.useState(false);
const [selectedOption, setSelectedOption] = React.useState(
options.find((option) => option.value === defaultValue) || options[0],
);
const handleToggle = () => {
setIsOpen(!isOpen);
};
const isOpen =
controlledIsOpen !== undefined ? controlledIsOpen : internalIsOpen;
const setIsOpen = onOpenChange || setInternalIsOpen;
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
@@ -32,6 +43,7 @@ export default function DropdownMenu({
setIsOpen(false);
}
};
const handleClickOption = (optionId: number) => {
setIsOpen(false);
setSelectedOption(options[optionId]);
@@ -44,17 +56,11 @@ export default function DropdownMenu({
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
return (
<div className="static inline-block text-left" ref={dropdownRef}>
<button
onClick={handleToggle}
className="flex w-20 cursor-pointer flex-row gap-1 rounded-3xl border-purple-30/25 bg-purple-30 p-2 text-xs text-white hover:bg-[#6F3FD1] focus:outline-none"
>
{icon && <img src={icon} alt="OptionIcon" className="h-4 w-4" />}
{selectedOption.value !== 'never' ? selectedOption.label : name}
</button>
<div className={`fixed ${className || ''}`} ref={dropdownRef}>
<div
className={`absolute z-50 right-0 mt-1 w-28 transform rounded-md bg-transparent shadow-lg ring-1 ring-black ring-opacity-5 transition-all duration-200 ease-in-out ${
className={`w-28 transform rounded-md bg-white dark:bg-dark-charcoal shadow-lg ring-1 ring-black ring-opacity-5 transition-all duration-200 ease-in-out ${
isOpen
? 'scale-100 opacity-100'
: 'pointer-events-none scale-95 opacity-0'

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
@@ -9,7 +9,7 @@ import Edit from '../assets/edit.svg';
import NoFilesDarkIcon from '../assets/no-files-dark.svg';
import NoFilesIcon from '../assets/no-files.svg';
import SyncIcon from '../assets/sync.svg';
import Trash from '../assets/trash.svg';
import Trash from '../assets/red-trash.svg';
import Pagination from '../components/DocumentPagination';
import DropdownMenu from '../components/DropdownMenu';
import Input from '../components/Input';
@@ -27,6 +27,8 @@ import {
import Upload from '../upload/Upload';
import { formatDate } from '../utils/dateTimeUtils';
import { ChunkType } from './types';
import ContextMenu, { MenuOption } from '../components/ContextMenu';
import ThreeDots from '../assets/three-dots.svg';
const formatTokens = (tokens: number): string => {
const roundToTwoDecimals = (num: number): string => {
@@ -61,6 +63,51 @@ export default function Documents({
const [currentPage, setCurrentPage] = useState<number>(1);
const [rowsPerPage, setRowsPerPage] = useState<number>(10);
const [totalPages, setTotalPages] = useState<number>(1);
const [activeMenuId, setActiveMenuId] = useState<string | null>(null);
const menuRefs = useRef<{ [key: string]: React.RefObject<HTMLDivElement> }>(
{},
);
// Create or get a ref for each document wrapper div (not the td)
const getMenuRef = (docId: string) => {
if (!menuRefs.current[docId]) {
menuRefs.current[docId] = React.createRef<HTMLDivElement>();
}
return menuRefs.current[docId];
};
const handleMenuClick = (e: React.MouseEvent, docId: string) => {
e.preventDefault();
e.stopPropagation();
// Close any open menu if clicking on a different button
if (activeMenuId && activeMenuId !== docId) {
setActiveMenuId(null);
}
// Toggle the clicked menu
setActiveMenuId((prev) => (prev === docId ? null : docId));
};
// Close menu when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (activeMenuId) {
const activeRef = menuRefs.current[activeMenuId];
if (
activeRef?.current &&
!activeRef.current.contains(event.target as Node)
) {
setActiveMenuId(null);
}
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [activeMenuId]);
const currentDocuments = paginatedDocuments ?? [];
const syncOptions = [
{ label: t('settings.documents.syncFrequency.never'), value: 'never' },
@@ -69,6 +116,16 @@ export default function Documents({
{ label: t('settings.documents.syncFrequency.monthly'), value: 'monthly' },
];
const [showDocumentChunks, setShowDocumentChunks] = useState<Doc>();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [syncMenuState, setSyncMenuState] = useState<{
isOpen: boolean;
docId: string | null;
document: Doc | null;
}>({
isOpen: false,
docId: null,
document: null,
});
const refreshDocs = useCallback(
(
@@ -164,6 +221,39 @@ export default function Documents({
}
};
const getActionOptions = (index: number, document: Doc): MenuOption[] => {
const actions: MenuOption[] = [
{
icon: Trash,
label: t('convTile.delete'),
onClick: () => {
handleDeleteConfirmation(index, document);
},
iconWidth: 18,
iconHeight: 18,
variant: 'danger',
},
];
if (document.syncFrequency) {
actions.push({
icon: SyncIcon,
label: t('settings.documents.sync'),
onClick: () => {
setSyncMenuState({
isOpen: true,
docId: document.id ?? null,
document: document,
});
},
iconWidth: 14,
iconHeight: 14,
variant: 'primary',
});
}
return actions;
};
useEffect(() => {
refreshDocs(undefined, 1, rowsPerPage);
}, [searchTerm]);
@@ -269,61 +359,90 @@ export default function Documents({
</td>
</tr>
) : (
currentDocuments.map((document, index) => (
<tr
key={index}
className="group transition-colors cursor-pointer"
onClick={() => setShowDocumentChunks(document)}
>
<td
className="py-4 px-4 text-sm text-gray-700 dark:text-[#E0E0E0] w-[45%] min-w-48 max-w-0 truncate group-hover:bg-gray-50 dark:group-hover:bg-gray-800/50"
title={document.name}
currentDocuments.map((document, index) => {
const docId = document.id ? document.id.toString() : '';
return (
<tr
key={docId}
className="group transition-colors cursor-pointer"
onClick={() => setShowDocumentChunks(document)}
>
{document.name}
</td>
<td className="py-4 px-4 text-center text-sm text-gray-700 dark:text-[#E0E0E0] whitespace-nowrap w-[20%] group-hover:bg-gray-50 dark:group-hover:bg-gray-800/50">
{document.date ? formatDate(document.date) : ''}
</td>
<td className="py-4 px-4 text-center text-sm text-gray-700 dark:text-[#E0E0E0] whitespace-nowrap w-[25%] group-hover:bg-gray-50 dark:group-hover:bg-gray-800/50">
{document.tokens
? formatTokens(+document.tokens)
: ''}
</td>
<td
className="py-4 px-4 text-right w-[10%] group-hover:bg-gray-50 dark:group-hover:bg-gray-800/50"
onClick={(e) => e.stopPropagation()} // Stop event propagation for the entire actions cell
>
<div className="flex items-center justify-end gap-3">
{!document.syncFrequency && (
<div className="w-8"></div>
)}
{document.syncFrequency && (
<DropdownMenu
name={t('settings.documents.sync')}
options={syncOptions}
onSelect={(value: string) => {
handleManageSync(document, value);
}}
defaultValue={document.syncFrequency}
icon={SyncIcon}
/>
)}
<button
onClick={() => {
handleDeleteConfirmation(index, document);
}}
className="inline-flex items-center justify-center w-8 h-8 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex-shrink-0"
<td
className="py-4 px-4 text-sm text-gray-700 dark:text-[#E0E0E0] w-[45%] min-w-48 max-w-0 truncate group-hover:bg-gray-50 dark:group-hover:bg-gray-800/50"
title={document.name}
>
{document.name}
</td>
<td className="py-4 px-4 text-center text-sm text-gray-700 dark:text-[#E0E0E0] whitespace-nowrap w-[20%] group-hover:bg-gray-50 dark:group-hover:bg-gray-800/50">
{document.date ? formatDate(document.date) : ''}
</td>
<td className="py-4 px-4 text-center text-sm text-gray-700 dark:text-[#E0E0E0] whitespace-nowrap w-[25%] group-hover:bg-gray-50 dark:group-hover:bg-gray-800/50">
{document.tokens
? formatTokens(+document.tokens)
: ''}
</td>
<td
className="py-4 px-4 text-right w-[10%] group-hover:bg-gray-50 dark:group-hover:bg-gray-800/50"
onClick={(e) => e.stopPropagation()}
>
<div
ref={getMenuRef(docId)}
className="flex items-center justify-end gap-3 relative"
>
<img
src={Trash}
alt={t('convTile.delete')}
className="h-4 w-4 opacity-60 hover:opacity-100"
{document.syncFrequency && (
<DropdownMenu
name={t('settings.documents.sync')}
options={syncOptions}
onSelect={(value: string) => {
handleManageSync(document, value);
}}
defaultValue={document.syncFrequency}
icon={SyncIcon}
isOpen={
syncMenuState.docId === docId &&
syncMenuState.isOpen
}
onOpenChange={(isOpen) => {
setSyncMenuState((prev) => ({
...prev,
isOpen,
docId: isOpen ? docId : null,
document: isOpen ? document : null,
}));
}}
anchorRef={getMenuRef(docId)}
className="absolute right-12 top-0"
/>
)}
<button
onClick={(e) => handleMenuClick(e, docId)}
className="inline-flex items-center justify-center w-8 h-8 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors flex-shrink-0"
aria-label="Open menu"
data-testid={`menu-button-${docId}`}
>
<img
src={ThreeDots}
alt={t('convTile.menu')}
className="h-4 w-4 opacity-60 hover:opacity-100"
/>
</button>
<ContextMenu
isOpen={activeMenuId === docId}
setIsOpen={(isOpen) => {
setActiveMenuId(isOpen ? docId : null);
}}
options={getActionOptions(index, document)}
anchorRef={getMenuRef(docId)}
position="bottom-left"
offset={{ x: 48, y: -24 }}
className="z-50"
/>
</button>
</div>
</td>
</tr>
))
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>