379 lines
15 KiB
TypeScript
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;
|
|
|