ohif-viewer/platform/ui-next/src/components/DataRow/DataRow.tsx

349 lines
11 KiB
TypeScript

import React, { useState, useEffect, useRef } from 'react';
import { Button } from '../../components/Button/Button';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from '../../components/DropdownMenu';
import { Icons } from '../../components/Icons/Icons';
import { Tooltip, TooltipTrigger, TooltipContent } from '../../components/Tooltip/Tooltip';
import { cn } from '../../lib/utils';
/**
* DataRow is a complex UI component that displays a selectable, interactive row with hierarchical data.
* It's designed to show a numbered item with a title, optional color indicator, and expandable details.
* The row supports various interactive features like visibility toggling, locking, and contextual actions.
*
* @component
* @example
* ```tsx
* <DataRow
* number={1}
* title="My Item"
* details={{
* primary: ["Main detail", " Sub detail"],
* secondary: []
* }}
* isVisible={true}
* isLocked={false}
* onToggleVisibility={() => {}}
* onToggleLocked={() => {}}
* onRename={() => {}}
* onDelete={() => {}}
* onColor={() => {}}
* />
* ```
*/
/**
* Props for the DataRow component
* @interface DataRowProps
* @property {number} number - The display number/index of the row
* @property {string} title - The main text label for the row
* @property {boolean} disableEditing - When true, prevents rename and delete operations
* @property {string} [colorHex] - Optional hex color code to display a color indicator
* @property {Object} [details] - Optional hierarchical details to display below the row
* @property {string[]} details.primary - Primary details shown immediately below the row
* @property {string[]} details.secondary - Secondary details (currently unused)
* @property {boolean} [isSelected] - Whether the row is currently selected
* @property {() => void} [onSelect] - Callback when the row is clicked/selected
* @property {boolean} isVisible - Controls the row's visibility state
* @property {() => void} onToggleVisibility - Callback to toggle visibility
* @property {boolean} isLocked - Controls the row's locked state
* @property {() => void} onToggleLocked - Callback to toggle locked state
* @property {() => void} onRename - Callback when rename is requested
* @property {() => void} onDelete - Callback when delete is requested
* @property {() => void} onColor - Callback when color change is requested
*/
interface DataRowProps {
number: number;
disableEditing: boolean;
description: string;
details?: { primary: string[]; secondary: string[] };
//
isSelected?: boolean;
onSelect?: (e) => void;
//
isVisible: boolean;
onToggleVisibility: (e) => void;
//
isLocked: boolean;
onToggleLocked: (e) => void;
//
title: string;
onRename: (e) => void;
//
onDelete: (e) => void;
//
colorHex?: string;
onColor: (e) => void;
className?: string;
}
export const DataRow: React.FC<DataRowProps> = ({
number,
title,
colorHex,
details,
onSelect,
isLocked,
onToggleVisibility,
onToggleLocked,
onRename,
onDelete,
onColor,
isSelected = false,
isVisible = true,
disableEditing = false,
className,
}) => {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const isTitleLong = title?.length > 25;
const rowRef = useRef<HTMLDivElement>(null);
// useEffect(() => {
// if (isSelected && rowRef.current) {
// setTimeout(() => {
// rowRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
// }, 200);
// }
// }, [isSelected]);
const handleAction = (action: string, e: React.MouseEvent) => {
e.stopPropagation();
switch (action) {
case 'Rename':
onRename(e);
break;
case 'Lock':
onToggleLocked(e);
break;
case 'Delete':
onDelete(e);
break;
case 'Color':
onColor(e);
break;
}
};
const decodeHTML = (html: string) => {
const txt = document.createElement('textarea');
txt.innerHTML = html;
return txt.value;
};
const renderDetailText = (text: string, indent: number = 0) => {
const indentation = ' '.repeat(indent);
if (text === '') {
return (
<div
key={`empty-${indent}`}
className="h-2"
></div>
);
}
const cleanText = decodeHTML(text);
return (
<div
key={cleanText}
className="whitespace-pre-wrap"
>
{indentation}
<span className="font-medium">{cleanText}</span>
</div>
);
};
const renderDetails = (details: string[]) => {
const visibleLines = details.slice(0, 4);
const hiddenLines = details.slice(4);
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="cursor-help">
<div className="flex flex-col space-y-1">
{visibleLines.map((line, lineIndex) =>
renderDetailText(line, line.startsWith(' ') ? 1 : 0)
)}
</div>
{hiddenLines.length > 0 && (
<div className="text-muted-foreground mt-1 flex items-center text-sm">
<span>...</span>
<Icons.Info className="mr-1 h-5 w-5" />
</div>
)}
</div>
</TooltipTrigger>
<TooltipContent
side="right"
align="start"
className="max-w-md"
>
<div className="text-secondary-foreground flex flex-col space-y-1 text-sm leading-normal">
{details.map((line, lineIndex) =>
renderDetailText(line, line.startsWith(' ') ? 1 : 0)
)}
</div>
</TooltipContent>
</Tooltip>
);
};
return (
<div
ref={rowRef}
className={cn('flex flex-col', !isVisible && 'opacity-60', className)}
>
<div
className={`flex items-center ${
isSelected ? 'bg-popover' : 'bg-muted'
} group relative cursor-pointer`}
onClick={onSelect}
data-cy="data-row"
>
{/* Hover Overlay */}
<div className="bg-primary/20 pointer-events-none absolute inset-0 opacity-0 transition-opacity group-hover:opacity-100"></div>
{/* Number Box */}
<div
className={`flex h-7 max-h-7 w-7 flex-shrink-0 items-center justify-center rounded-l border-r border-black text-base ${
isSelected ? 'bg-highlight text-black' : 'bg-muted text-muted-foreground'
} overflow-hidden`}
>
{number}
</div>
{/* Color Circle (Optional) */}
{colorHex && (
<div className="flex h-7 w-5 items-center justify-center">
<span
className="ml-2 h-2 w-2 rounded-full"
style={{ backgroundColor: colorHex }}
></span>
</div>
)}
{/* Label with Conditional Tooltip */}
<div className="ml-2 flex-1 overflow-hidden">
{isTitleLong ? (
<Tooltip>
<TooltipTrigger asChild>
<span
className={`cursor-default text-base ${
isSelected ? 'text-highlight' : 'text-muted-foreground'
} [overflow:hidden] [display:-webkit-box] [-webkit-line-clamp:2] [-webkit-box-orient:vertical]`}
>
{title}
</span>
</TooltipTrigger>
<TooltipContent
side="top"
align="center"
>
{title}
</TooltipContent>
</Tooltip>
) : (
<span
className={`text-base ${
isSelected ? 'text-highlight' : 'text-muted-foreground'
} [overflow:hidden] [display:-webkit-box] [-webkit-line-clamp:2] [-webkit-box-orient:vertical]`}
>
{title}
</span>
)}
</div>
{/* Actions and Visibility Toggle */}
<div className="relative ml-2 flex items-center space-x-1">
{/* Visibility Toggle Icon */}
<Button
size="icon"
variant="ghost"
className={`h-6 w-6 transition-opacity ${
isSelected || !isVisible ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
}`}
aria-label={isVisible ? 'Hide' : 'Show'}
onClick={e => {
e.stopPropagation();
onToggleVisibility(e);
}}
>
{isVisible ? <Icons.Hide className="h-6 w-6" /> : <Icons.Show className="h-6 w-6" />}
</Button>
{/* Lock Icon (if needed) */}
{isLocked && !disableEditing && <Icons.Lock className="text-muted-foreground h-6 w-6" />}
{/* Actions Dropdown Menu */}
{disableEditing && <div className="h-6 w-6"></div>}
{!disableEditing && (
<DropdownMenu onOpenChange={open => setIsDropdownOpen(open)}>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className={`h-6 w-6 transition-opacity ${
isSelected || isDropdownOpen
? 'opacity-100'
: 'opacity-0 group-hover:opacity-100'
}`}
aria-label="Actions"
onClick={e => e.stopPropagation()} // Prevent row selection on button click
>
<Icons.More className="h-6 w-6" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
// this was causing issue for auto focus on input dialog
onCloseAutoFocus={e => e.preventDefault()}
>
<>
<DropdownMenuItem onClick={e => handleAction('Rename', e)}>
<Icons.Rename className="text-foreground" />
<span className="pl-2">Rename</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={e => handleAction('Delete', e)}>
<Icons.Delete className="text-foreground" />
<span className="pl-2">Delete</span>
</DropdownMenuItem>
{onColor && (
<DropdownMenuItem onClick={e => handleAction('Color', e)}>
<Icons.ColorChange className="text-foreground" />
<span className="pl-2">Change Color</span>
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={e => handleAction('Lock', e)}>
<Icons.Lock className="text-foreground" />
<span className="pl-2">{isLocked ? 'Unlock' : 'Lock'}</span>
</DropdownMenuItem>
</>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
{/* Details Section */}
{details && (details.primary?.length > 0 || details.secondary?.length > 0) && (
<div className="ml-7 px-2 py-2">
<div className="text-secondary-foreground flex items-center gap-1 text-base leading-normal">
{details.primary?.length > 0 && renderDetails(details.primary)}
{details.secondary?.length > 0 && (
<div className="text-muted-foreground ml-auto text-sm">
{renderDetails(details.secondary)}
</div>
)}
</div>
</div>
)}
</div>
);
};
export default DataRow;