frontend_nyla/src/components/UiComponents/ConnectedFilesList/ConnectedFilesList.tsx
2026-04-09 00:11:35 +02:00

379 lines
15 KiB
TypeScript

import React, { useMemo } from 'react';
import {
ViewActionButton,
DeleteActionButton,
RemoveActionButton,
EditActionButton,
CopyActionButton,
CustomActionButton
} from '../../FormGenerator/ActionButtons';
import { FaDownload, FaLink, FaPlay } from 'react-icons/fa';
import { WorkflowFile } from '../../../hooks/usePlayground';
import styles from './ConnectedFilesList.module.css';
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
import { useLanguage } from '../../../providers/language/LanguageContext';
export interface ConnectedFilesListActionButton {
type: 'edit' | 'delete' | 'download' | 'view' | 'copy' | 'connect' | 'play' | 'remove';
onAction?: (file: WorkflowFile) => Promise<void> | void;
disabled?: (file: WorkflowFile) => boolean | { disabled: boolean; message?: string };
loading?: (file: WorkflowFile) => boolean;
title?: string | ((file: WorkflowFile) => string);
className?: string;
// For download and view buttons
isProcessing?: (file: WorkflowFile) => boolean;
// Field mappings for flexible data access
idField?: string; // Field name for the unique identifier (default: 'fileId')
nameField?: string; // Field name for display name (default: 'fileName')
typeField?: string; // Field name for type/mime type (default: 'mimeType')
contentField?: string; // Field name for content (used by copy button)
statusField?: string; // Field name for status (used by connect button)
authorityField?: string; // Field name for authority (msft/google) (used by connect button)
// Operation and loading state names
operationName?: string; // Name of the operation function in hookData
refreshOperationName?: string; // Name of the refresh operation function in hookData (for connect button)
loadingStateName?: string; // Name of the loading state in hookData
// Navigation (for play button)
navigateTo?: string; // Route to navigate to when play button is clicked
// Special handling for remove button
showOnlyForPending?: boolean; // Show remove button only for pending files (default: true for 'remove' type)
}
export interface ConnectedFilesListProps {
files: WorkflowFile[];
pendingFiles?: WorkflowFile[];
// New: Configurable action buttons (takes precedence over legacy props)
actionButtons?: ConnectedFilesListActionButton[];
// Legacy props (kept for backward compatibility, used as defaults if actionButtons not provided)
onDelete?: (file: WorkflowFile) => Promise<void>;
onRemove?: (file: WorkflowFile) => Promise<void>;
onAttach?: (fileId: string) => Promise<void>; // Attach file for next message
deletingFiles?: Set<string>;
previewingFiles?: Set<string>;
removingFiles?: Set<string>;
workflowId?: string;
emptyMessage?: string;
}
export function ConnectedFilesList({
files,
pendingFiles = [],
actionButtons,
onDelete,
onRemove,
onAttach,
deletingFiles = new Set(),
previewingFiles = new Set(),
removingFiles = new Set(),
workflowId: _workflowId,
emptyMessage = 'No files connected to this workflow'
}: ConnectedFilesListProps) {
const { t } = useLanguage();
// Combine workflow files and pending files, deduplicating by fileId
const allFiles = useMemo(() => {
const fileMap = new Map<string, WorkflowFile>();
// Add workflow files first (filter out files without fileId)
files.forEach(file => {
if (file.fileId && file.fileId.trim() !== '') {
fileMap.set(file.fileId, file);
}
});
// Add pending files (may override workflow files if same fileId)
pendingFiles.forEach(file => {
if (file.fileId && file.fileId.trim() !== '') {
fileMap.set(file.fileId, file);
}
});
return Array.from(fileMap.values());
}, [files, pendingFiles]);
// Create hookData object for action buttons
const hookData = useMemo(() => ({
handleDelete: async (fileId: string) => {
const file = allFiles.find(f => f.fileId === fileId);
if (file && onDelete) {
await onDelete(file);
return true;
}
return false;
},
removeOptimistically: (_fileId: string) => {
// This will be handled by the parent component's state
},
refetch: async () => {
// Refetch handled by parent
},
deletingItems: deletingFiles,
previewingFiles: previewingFiles,
removingItems: removingFiles
}), [allFiles, onDelete, deletingFiles, previewingFiles, removingFiles]);
// Generate default action buttons from legacy props if actionButtons not provided
const defaultActionButtons = useMemo<ConnectedFilesListActionButton[]>(() => {
if (actionButtons) {
return actionButtons;
}
// Legacy behavior: create default buttons from old props
const buttons: ConnectedFilesListActionButton[] = [];
// View button (always shown)
buttons.push({
type: 'view',
onAction: async (_file: WorkflowFile) => {
// View is handled by ViewActionButton's FilePreview component
return Promise.resolve();
},
idField: 'fileId',
nameField: 'fileName',
typeField: 'mimeType'
});
// Remove button (only for pending files, if onRemove provided)
if (onRemove) {
buttons.push({
type: 'remove',
onAction: async (file: WorkflowFile) => {
await onRemove(file);
},
showOnlyForPending: true,
idField: 'fileId',
loadingStateName: 'removingItems'
});
}
// Delete button (always shown, if onDelete provided)
if (onDelete) {
buttons.push({
type: 'delete',
operationName: 'handleDelete',
loadingStateName: 'deletingItems',
idField: 'fileId'
});
}
return buttons;
}, [actionButtons, onDelete, onRemove]);
const handleView = async (_file: WorkflowFile) => {
// View is handled by ViewActionButton's FilePreview component
return Promise.resolve();
};
const handleRemove = async (file: WorkflowFile) => {
// Remove file from workflow (not delete from backend)
if (onRemove) {
await onRemove(file);
}
};
if (allFiles.length === 0) {
return (
<div className={styles.container}>
<div className={styles.emptyState}>
{emptyMessage}
</div>
</div>
);
}
return (
<div className={styles.container}>
<div className={styles.header}>
<h3 className={styles.title}>{t('connectedFilesList.connectedFiles')}</h3>
<span className={styles.count}>({allFiles.length})</span>
</div>
<div className={styles.fileList}>
{allFiles
.filter(file => file.fileId && file.fileId.trim() !== '') // Ensure fileId exists
.map((file) => {
// const isDeleting = deletingFiles.has(file.fileId!);
// const isPreviewing = previewingFiles.has(file.fileId!);
// const isRemoving = removingFiles.has(file.fileId!);
// Use fileId as key since we've filtered out files without it
const uniqueKey = file.fileId!;
// Check if file is in pending files (can be removed) or in messages (already sent)
const isPendingFile = pendingFiles.some(f => f.fileId === file.fileId);
// Handle clicking on file item to attach/detach for next message
const handleFileItemClick = async (e: React.MouseEvent) => {
// Don't trigger if clicking on action buttons - they handle their own clicks
const target = e.target as HTMLElement;
const fileActionsElement = target.closest(`.${styles.fileActions}`);
const buttonElement = target.closest('button');
if (fileActionsElement || buttonElement) {
e.stopPropagation();
return;
}
// Prevent default and stop propagation to ensure click handler fires
e.preventDefault();
e.stopPropagation();
if (onAttach && file.fileId) {
console.log('🖱️ ConnectedFilesList: Clicking file to attach/detach:', file.fileId);
await onAttach(file.fileId);
}
};
return (
<div
key={uniqueKey}
className={styles.fileItem}
onClick={handleFileItemClick}
style={{
cursor: onAttach ? 'pointer' : 'default',
userSelect: 'none' // Prevent text selection on click
}}
title={onAttach ? (isPendingFile ? t('connectedFilesList.clickToDetachFromNext') : t('connectedFilesList.clickToAttachForNext')) : undefined}
>
<div className={styles.fileInfo}>
<div className={styles.fileName} title={file.fileName}>
{file.fileName}
{onAttach && (
<span style={{ marginLeft: '0.5rem', fontSize: '0.75rem', color: '#666' }}>
{isPendingFile ? t('connectedFilesList.clickToDetach') : t('connectedFilesList.clickToAttach')}
</span>
)}
</div>
<div className={styles.fileMeta}>
<span className={styles.fileSize}>
{formatBinaryDataSizeBytes(file.fileSize)}
</span>
{file.source && (
<span className={styles.fileSource}>
{file.source === 'user_uploaded' ? t('connectedFilesList.uploaded') : t('connectedFilesList.aiCreated')}
</span>
)}
{isPendingFile && (
<span style={{ fontSize: '0.75rem', color: '#4CAF50', fontWeight: 500 }}>
Attached
</span>
)}
</div>
</div>
<div className={styles.fileActions} onClick={(e) => e.stopPropagation()}>
{defaultActionButtons.map((actionButton, actionIndex) => {
// Check if button should be shown for this file
if (actionButton.showOnlyForPending !== undefined) {
const shouldShow = actionButton.showOnlyForPending ? isPendingFile : !isPendingFile;
if (!shouldShow) {
return null;
}
}
const actionTitle = typeof actionButton.title === 'function'
? actionButton.title(file)
: actionButton.title;
const disabledResult = actionButton.disabled ? actionButton.disabled(file) : false;
const isLoading = actionButton.loading ? actionButton.loading(file) : false;
const isProcessing = actionButton.isProcessing ? actionButton.isProcessing(file) : false;
const baseProps = {
row: file,
disabled: disabledResult,
loading: isLoading,
className: actionButton.className,
title: actionTitle,
idField: actionButton.idField ?? 'fileId',
nameField: actionButton.nameField ?? 'fileName',
typeField: actionButton.typeField ?? 'mimeType',
contentField: actionButton.contentField ?? 'content',
statusField: actionButton.statusField ?? 'status',
authorityField: actionButton.authorityField ?? 'authority',
operationName: actionButton.operationName,
refreshOperationName: actionButton.refreshOperationName,
loadingStateName: actionButton.loadingStateName,
hookData: hookData
};
switch (actionButton.type) {
case 'edit':
return <EditActionButton
key={actionIndex}
{...baseProps}
onEdit={actionButton.onAction}
/>;
case 'delete':
return <DeleteActionButton
key={actionIndex}
{...baseProps}
/>;
case 'download':
return <CustomActionButton
key={actionIndex}
row={file}
id="download"
icon={<FaDownload />}
onClick={actionButton.onAction || (() => {})}
disabled={() => disabledResult}
loading={() => isProcessing}
title={actionTitle}
className={actionButton.className}
/>;
case 'view':
return <ViewActionButton
key={actionIndex}
{...baseProps}
onView={actionButton.onAction || handleView}
isViewing={isProcessing}
/>;
case 'copy':
return <CopyActionButton
key={actionIndex}
{...baseProps}
onCopy={actionButton.onAction}
isCopying={isProcessing}
contentField={actionButton.contentField}
/>;
case 'connect':
return <CustomActionButton
key={actionIndex}
row={file}
id="connect"
icon={<FaLink />}
onClick={actionButton.onAction || (() => {})}
disabled={() => disabledResult}
loading={() => isLoading}
title={actionTitle}
className={actionButton.className}
/>;
case 'play':
return <CustomActionButton
key={actionIndex}
row={file}
id="play"
icon={<FaPlay />}
onClick={actionButton.onAction || (() => {})}
disabled={() => disabledResult}
loading={() => isLoading}
title={actionTitle}
className={actionButton.className}
/>;
case 'remove':
return <RemoveActionButton
key={actionIndex}
{...baseProps}
onRemove={actionButton.onAction || handleRemove}
/>;
default:
return null;
}
})}
</div>
</div>
);
})}
</div>
</div>
);
}
export default ConnectedFilesList;