2366 lines
135 KiB
TypeScript
2366 lines
135 KiB
TypeScript
import React, { useState, useEffect, useRef } from 'react';
|
||
import { GenericPageData, PageButton, PageContent, resolveLanguageText, SettingsFieldConfig, SettingsSectionConfig, GenericDataHook } from './pageInterface';
|
||
import { FormGenerator, FormGeneratorList } from '../../components/FormGenerator';
|
||
import { FormGeneratorForm, AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
||
import { Button, UploadButton, CreateButton, TextField, Messages, ChatMessage, LogMessage, DropdownSelect, Log, WorkflowStatus, Tabs } from '../../components/UiComponents';
|
||
import { Popup } from '../../components/UiComponents/Popup';
|
||
import { ConnectedFilesList } from '../../components/UiComponents/ConnectedFilesList';
|
||
import type { DropdownSelectItem } from '../../components/UiComponents/DropdownSelect';
|
||
import { DragDropOverlay } from '../../components/UiComponents/DragDropOverlay';
|
||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||
import { usePermissions } from '../../hooks/usePermissions';
|
||
import { FiPaperclip } from 'react-icons/fi';
|
||
import { IoMdAdd } from 'react-icons/io';
|
||
import type { WorkflowFile } from '../../hooks/playground/useDashboardInputForm';
|
||
import styles from '../../styles/pages.module.css';
|
||
|
||
interface PageRendererProps {
|
||
pageData: GenericPageData;
|
||
onButtonClick?: (buttonId: string, button: PageButton) => void;
|
||
}
|
||
|
||
// Component wrapper to fix TextField height and prevent auto-grow
|
||
const FixedHeightTextField: React.FC<{
|
||
value: string;
|
||
onChange?: (value: string) => void;
|
||
placeholder?: string;
|
||
size?: 'sm' | 'md' | 'lg';
|
||
disabled?: boolean;
|
||
className?: string;
|
||
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||
}> = ({ value, onChange, placeholder, size, disabled, className, onKeyDown }) => {
|
||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||
|
||
useEffect(() => {
|
||
// Override the TextField's auto-grow behavior by finding the textarea and setting fixed height
|
||
const updateTextareaHeight = () => {
|
||
if (wrapperRef.current) {
|
||
// Find the textarea element
|
||
const textarea = wrapperRef.current.querySelector('textarea') as HTMLTextAreaElement;
|
||
if (textarea) {
|
||
textareaRef.current = textarea;
|
||
|
||
// Get the input container
|
||
const inputContainer = wrapperRef.current.querySelector('.inputContainer') as HTMLElement;
|
||
|
||
if (inputContainer) {
|
||
// Get the computed height of the input container
|
||
const containerRect = inputContainer.getBoundingClientRect();
|
||
const containerStyle = window.getComputedStyle(inputContainer);
|
||
const paddingTop = parseFloat(containerStyle.paddingTop) || 0;
|
||
const paddingBottom = parseFloat(containerStyle.paddingBottom) || 0;
|
||
|
||
// Get textarea border width (top + bottom)
|
||
const textareaStyle = window.getComputedStyle(textarea);
|
||
const borderTop = parseFloat(textareaStyle.borderTopWidth) || 0;
|
||
const borderBottom = parseFloat(textareaStyle.borderBottomWidth) || 0;
|
||
const borderHeight = borderTop + borderBottom;
|
||
|
||
// Calculate available height for textarea (subtract border to prevent clipping)
|
||
const availableHeight = containerRect.height - paddingTop - paddingBottom - borderHeight;
|
||
|
||
// Force the height - this will override TextField's auto-grow
|
||
if (availableHeight > 0) {
|
||
textarea.style.setProperty('height', `${availableHeight}px`, 'important');
|
||
textarea.style.setProperty('min-height', `${availableHeight}px`, 'important');
|
||
textarea.style.setProperty('max-height', `${availableHeight}px`, 'important');
|
||
textarea.style.setProperty('overflow-y', 'auto', 'important');
|
||
textarea.style.setProperty('resize', 'none', 'important');
|
||
textarea.style.setProperty('box-sizing', 'border-box', 'important');
|
||
textarea.style.setProperty('border-radius', '25px', 'important');
|
||
// Don't set box-shadow here - it should only appear on focus via CSS
|
||
}
|
||
} else {
|
||
// Fallback: use wrapper height
|
||
const wrapperHeight = wrapperRef.current.offsetHeight;
|
||
if (wrapperHeight > 0) {
|
||
textarea.style.setProperty('height', `${wrapperHeight}px`, 'important');
|
||
textarea.style.setProperty('min-height', `${wrapperHeight}px`, 'important');
|
||
textarea.style.setProperty('max-height', `${wrapperHeight}px`, 'important');
|
||
textarea.style.setProperty('overflow-y', 'auto', 'important');
|
||
textarea.style.setProperty('resize', 'none', 'important');
|
||
}
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
// Initial update after a short delay to ensure DOM is ready
|
||
const timeoutId = setTimeout(() => {
|
||
updateTextareaHeight();
|
||
}, 10);
|
||
|
||
// Use ResizeObserver to watch for container size changes
|
||
let resizeObserver: ResizeObserver | null = null;
|
||
if (wrapperRef.current && window.ResizeObserver) {
|
||
resizeObserver = new ResizeObserver(() => {
|
||
updateTextareaHeight();
|
||
});
|
||
resizeObserver.observe(wrapperRef.current);
|
||
}
|
||
|
||
// Use MutationObserver to catch when TextField's useEffect runs and override it
|
||
let mutationObserver: MutationObserver | null = null;
|
||
if (wrapperRef.current) {
|
||
mutationObserver = new MutationObserver(() => {
|
||
updateTextareaHeight();
|
||
});
|
||
|
||
mutationObserver.observe(wrapperRef.current, {
|
||
attributes: true,
|
||
childList: true,
|
||
subtree: true,
|
||
attributeFilter: ['style', 'class']
|
||
});
|
||
}
|
||
|
||
// Use an interval as a fallback to continuously override auto-grow
|
||
const interval = setInterval(updateTextareaHeight, 100);
|
||
|
||
// Also listen for resize events
|
||
window.addEventListener('resize', updateTextareaHeight);
|
||
|
||
return () => {
|
||
clearTimeout(timeoutId);
|
||
if (resizeObserver) {
|
||
resizeObserver.disconnect();
|
||
}
|
||
if (mutationObserver) {
|
||
mutationObserver.disconnect();
|
||
}
|
||
clearInterval(interval);
|
||
window.removeEventListener('resize', updateTextareaHeight);
|
||
};
|
||
}, [value]);
|
||
|
||
return (
|
||
<div ref={wrapperRef} style={{
|
||
flex: 1,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
minHeight: 0,
|
||
overflow: 'visible',
|
||
height: '100%'
|
||
}}>
|
||
<TextField
|
||
value={value}
|
||
onChange={onChange}
|
||
placeholder={placeholder}
|
||
size={size}
|
||
disabled={disabled}
|
||
className={className}
|
||
{...({ onKeyDown } as any)}
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// Component for rendering tabs from page content
|
||
const TabsRenderer: React.FC<{
|
||
content: PageContent;
|
||
renderContent: (content: PageContent, key?: string | number) => React.ReactNode;
|
||
t: (key: string, fallback?: string) => string;
|
||
}> = ({ content, renderContent, t }) => {
|
||
const tabsConfig = content.tabsConfig;
|
||
if (!tabsConfig || !tabsConfig.tabs || tabsConfig.tabs.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
// Convert PageContent tabs to Tabs component format
|
||
const tabs = tabsConfig.tabs.map(tab => ({
|
||
id: tab.id,
|
||
label: resolveLanguageText(tab.label, t),
|
||
content: (
|
||
<>
|
||
{tab.content.map((nestedContent, index) => (
|
||
<React.Fragment key={nestedContent.id || `${tab.id}-${index}`}>
|
||
{renderContent(nestedContent)}
|
||
</React.Fragment>
|
||
))}
|
||
</>
|
||
)
|
||
}));
|
||
|
||
return (
|
||
<Tabs
|
||
tabs={tabs}
|
||
defaultTabId={tabsConfig.defaultTabId}
|
||
/>
|
||
);
|
||
};
|
||
|
||
// Component to handle async permission checks for content
|
||
const ContentRenderer: React.FC<{
|
||
contents: PageContent[];
|
||
renderContent: (content: PageContent) => React.ReactNode;
|
||
hasPermission: (context: 'DATA' | 'UI' | 'RESOURCE', item: string, operation?: 'read' | 'create' | 'update' | 'delete') => Promise<boolean>;
|
||
}> = ({ contents, renderContent, hasPermission }) => {
|
||
const [visibleContents, setVisibleContents] = useState<PageContent[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
|
||
useEffect(() => {
|
||
const checkPermissions = async () => {
|
||
setLoading(true);
|
||
const visible: PageContent[] = [];
|
||
|
||
for (const content of contents) {
|
||
const contentId = content.id || `content-${content.type}`;
|
||
let shouldRender = true;
|
||
|
||
// Check RBAC permissions for content
|
||
try {
|
||
const hasRBACAccess = await hasPermission('UI', contentId, 'read');
|
||
if (!hasRBACAccess) {
|
||
shouldRender = false;
|
||
}
|
||
} catch (error) {
|
||
console.error(`Error checking RBAC access for content ${contentId}:`, error);
|
||
shouldRender = false;
|
||
}
|
||
|
||
if (shouldRender) {
|
||
visible.push(content);
|
||
}
|
||
}
|
||
|
||
setVisibleContents(visible);
|
||
setLoading(false);
|
||
};
|
||
|
||
checkPermissions();
|
||
}, [contents, hasPermission]);
|
||
|
||
if (loading) {
|
||
return null; // Or return a loading indicator if desired
|
||
}
|
||
|
||
// Check if this is a chatbot layout pattern: chatHistory, messages, inputForm
|
||
const hasChatHistory = visibleContents.some(c => c.type === 'chatHistory');
|
||
const hasMessages = visibleContents.some(c => c.type === 'messages');
|
||
const hasInputForm = visibleContents.some(c => c.type === 'inputForm');
|
||
const isChatbotLayout = hasChatHistory && hasMessages && hasInputForm;
|
||
|
||
// Check if this is a dashboard layout pattern: messages, log, inputForm
|
||
const hasLog = visibleContents.some(c => c.type === 'log');
|
||
const isDashboardLayout = hasMessages && hasLog && hasInputForm && !isChatbotLayout;
|
||
|
||
if (isChatbotLayout) {
|
||
// Render chatbot two-column layout
|
||
const chatHistoryContent = visibleContents.find(c => c.type === 'chatHistory');
|
||
const messagesContent = visibleContents.find(c => c.type === 'messages');
|
||
const inputFormContent = visibleContents.find(c => c.type === 'inputForm');
|
||
const otherContents = visibleContents.filter(c =>
|
||
c.type !== 'chatHistory' && c.type !== 'messages' && c.type !== 'inputForm'
|
||
);
|
||
|
||
return (
|
||
<>
|
||
<div className={styles.chatbotTwoColumnLayout}>
|
||
{/* Left column: Chat History */}
|
||
{chatHistoryContent && (
|
||
<div className={styles.chatbotHistoryColumn}>
|
||
{renderContent(chatHistoryContent)}
|
||
</div>
|
||
)}
|
||
{/* Right column: Messages and Input Form */}
|
||
<div className={styles.chatbotChatColumn}>
|
||
{messagesContent && (
|
||
<div className={styles.chatbotMessagesCell}>
|
||
{renderContent(messagesContent)}
|
||
</div>
|
||
)}
|
||
{inputFormContent && (
|
||
<div className={styles.chatbotInputCell}>
|
||
{renderContent(inputFormContent)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{/* Render any other content sections */}
|
||
{otherContents.map((content, index) => (
|
||
<React.Fragment key={content.id || `content-${content.type}-${index}`}>
|
||
{renderContent(content)}
|
||
</React.Fragment>
|
||
))}
|
||
</>
|
||
);
|
||
}
|
||
|
||
if (isDashboardLayout) {
|
||
// Render dashboard grid layout
|
||
const messagesContent = visibleContents.find(c => c.type === 'messages');
|
||
const logContent = visibleContents.find(c => c.type === 'log');
|
||
const inputFormContent = visibleContents.find(c => c.type === 'inputForm');
|
||
const otherContents = visibleContents.filter(c =>
|
||
c.type !== 'messages' && c.type !== 'log' && c.type !== 'inputForm'
|
||
);
|
||
|
||
return (
|
||
<>
|
||
<div className={styles.dashboardGridLayout}>
|
||
{/* Top row: Messages | Log */}
|
||
<div className={styles.dashboardTopRow}>
|
||
{messagesContent && (
|
||
<div className={styles.dashboardMessagesCell}>
|
||
{renderContent(messagesContent)}
|
||
</div>
|
||
)}
|
||
{logContent && (
|
||
<div className={styles.dashboardLogCell}>
|
||
{renderContent(logContent)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{/* Bottom row: Input Form (which includes connected files) */}
|
||
{inputFormContent && (
|
||
<div className={styles.dashboardBottomRow}>
|
||
{renderContent(inputFormContent)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{/* Render any other content sections */}
|
||
{otherContents.map((content, index) => (
|
||
<React.Fragment key={content.id || `content-${content.type}-${index}`}>
|
||
{renderContent(content)}
|
||
</React.Fragment>
|
||
))}
|
||
</>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
{visibleContents.map((content, index) => (
|
||
<React.Fragment key={content.id || `content-${content.type}-${index}`}>
|
||
{renderContent(content)}
|
||
</React.Fragment>
|
||
))}
|
||
</>
|
||
);
|
||
};
|
||
|
||
// Helper function to recursively find all table content sections
|
||
const findAllTableContents = (contents: PageContent[]): PageContent[] => {
|
||
const tableContents: PageContent[] = [];
|
||
|
||
const traverse = (contentList: PageContent[]) => {
|
||
for (const content of contentList) {
|
||
if (content.type === 'table') {
|
||
tableContents.push(content);
|
||
}
|
||
// Check nested content in tabs
|
||
if (content.type === 'tabs' && content.tabsConfig) {
|
||
for (const tab of content.tabsConfig.tabs) {
|
||
traverse(tab.content);
|
||
}
|
||
}
|
||
// Check nested content in columns
|
||
if (content.type === 'columns' && content.columnsConfig) {
|
||
for (const column of content.columnsConfig.columns) {
|
||
traverse(column.content);
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
traverse(contents);
|
||
return tableContents;
|
||
};
|
||
|
||
const PageRenderer: React.FC<PageRendererProps> = ({
|
||
pageData,
|
||
onButtonClick
|
||
}) => {
|
||
// Get translation function from language context
|
||
const { t } = useLanguage();
|
||
const { hasPermission } = usePermissions();
|
||
|
||
// Find all table content sections (including nested ones)
|
||
const allTableContents = React.useMemo(() =>
|
||
findAllTableContents(pageData.content || []),
|
||
[pageData.content]
|
||
);
|
||
|
||
// Create hook instances for all table contents - MUST be at top level
|
||
// We need to call hookFactory() at top level, but the actual hook() calls happen below
|
||
const tableHookFactories = React.useMemo(() => {
|
||
return allTableContents.map((tableContent, index) => {
|
||
const hookFactory = tableContent.tableConfig?.hookFactory;
|
||
if (hookFactory) {
|
||
const key = tableContent.id || `table-${index}`;
|
||
return { key, hookFactory };
|
||
}
|
||
return null;
|
||
}).filter((item): item is { key: string; hookFactory: () => () => GenericDataHook } => item !== null);
|
||
}, [allTableContents]);
|
||
|
||
// Call all hook factories at top level to create hook instances
|
||
// This must happen unconditionally and in the same order every render
|
||
const tableHookInstances = tableHookFactories.map(({ key, hookFactory }) => ({
|
||
key,
|
||
hook: hookFactory() // This creates the hook function, doesn't call it yet
|
||
}));
|
||
|
||
// Call all hooks at top level - MUST be unconditional
|
||
// All hooks are called in the same order every render
|
||
const tableHookDataArray = tableHookInstances.map(({ key, hook }) => ({
|
||
key,
|
||
data: hook() // This is the actual hook call - must be at top level
|
||
}));
|
||
|
||
// Convert to Map for easy lookup
|
||
const tableHookData = React.useMemo(() => {
|
||
const dataMap = new Map<string, GenericDataHook | null>();
|
||
tableHookDataArray.forEach(({ key, data }) => {
|
||
dataMap.set(key, data);
|
||
});
|
||
return dataMap;
|
||
}, [tableHookDataArray]);
|
||
|
||
// Also check for top-level inputForm and settings (for backward compatibility)
|
||
const inputFormContent = pageData.content?.find(content => content.type === 'inputForm');
|
||
const settingsContent = pageData.content?.find(content => content.type === 'settings');
|
||
const hookFactory = inputFormContent?.inputFormConfig?.hookFactory
|
||
|| settingsContent?.settingsConfig?.hookFactory;
|
||
|
||
// Create hook instance at top level
|
||
const useTableData = hookFactory ? hookFactory() : null;
|
||
|
||
// Call the hook to get the current data (for backward compatibility)
|
||
// If no inputForm/settings hook, try to use the first table hook for header buttons
|
||
let hookData = useTableData ? useTableData() : null;
|
||
if (!hookData && tableHookData.size > 0) {
|
||
// Use the first table hook data for header buttons
|
||
const firstTableHook = Array.from(tableHookData.values())[0];
|
||
hookData = firstTableHook;
|
||
}
|
||
|
||
|
||
|
||
// Handle button clicks
|
||
const handleButtonClick = async (button: PageButton) => {
|
||
try {
|
||
// Check RBAC permissions
|
||
// Determine operation based on button type/action
|
||
let operation: 'read' | 'create' | 'update' | 'delete' | undefined = undefined;
|
||
if (button.id.includes('delete') || button.id.includes('remove')) {
|
||
operation = 'delete';
|
||
} else if (button.id.includes('create') || button.id.includes('add') || button.id.includes('new')) {
|
||
operation = 'create';
|
||
} else if (button.id.includes('edit') || button.id.includes('update') || button.id.includes('save')) {
|
||
operation = 'update';
|
||
} else {
|
||
operation = 'read';
|
||
}
|
||
|
||
try {
|
||
const hasRBACAccess = await hasPermission('UI', button.id, operation);
|
||
if (!hasRBACAccess) {
|
||
return;
|
||
}
|
||
} catch (error) {
|
||
console.error(`Error checking RBAC access for button ${button.id}:`, error);
|
||
return;
|
||
}
|
||
|
||
// Call the button's onClick handler with hook data
|
||
if (button.onClick) {
|
||
await button.onClick(hookData);
|
||
}
|
||
|
||
// Call the parent handler
|
||
if (onButtonClick) {
|
||
onButtonClick(button.id, button);
|
||
}
|
||
} catch (error) {
|
||
console.error(`Error handling button click for ${button.id}:`, error);
|
||
}
|
||
};
|
||
|
||
// Helper function to get nested value using dot notation (generic utility)
|
||
const getNestedValue = (obj: any, path: string): any => {
|
||
return path.split('.').reduce((current, key) => current?.[key], obj);
|
||
};
|
||
|
||
// Helper function to set nested value using dot notation (generic utility)
|
||
const setNestedValue = (obj: any, path: string, value: any): any => {
|
||
const keys = path.split('.');
|
||
const result = { ...obj };
|
||
let current = result;
|
||
for (let i = 0; i < keys.length - 1; i++) {
|
||
const key = keys[i];
|
||
if (!(key in current)) {
|
||
current[key] = {};
|
||
}
|
||
current[key] = { ...current[key] };
|
||
current = current[key];
|
||
}
|
||
current[keys[keys.length - 1]] = value;
|
||
return result;
|
||
};
|
||
|
||
// Generic form section renderer - reusable for any form-based content
|
||
const FormSectionRenderer: React.FC<{
|
||
sections: SettingsSectionConfig[];
|
||
formData: any;
|
||
fieldsBySection: Record<string, SettingsFieldConfig[]>;
|
||
loadingBySection: Record<string, boolean>;
|
||
errorsBySection: Record<string, string | null>;
|
||
onSave?: (sectionId: string, data: any) => Promise<void>;
|
||
getNestedValue: (obj: any, path: string) => any;
|
||
setNestedValue: (obj: any, path: string, value: any) => any;
|
||
}> = ({ sections, formData, fieldsBySection, loadingBySection, errorsBySection, onSave, getNestedValue, setNestedValue: _setNestedValue }) => {
|
||
const [sectionFormData, setSectionFormData] = useState<Record<string, any>>({});
|
||
const [sectionSaveLoading, setSectionSaveLoading] = useState<Record<string, boolean>>({});
|
||
const [sectionSaveMessages, setSectionSaveMessages] = useState<Record<string, { type: 'success' | 'error', text: string } | null>>({});
|
||
const formRefs = useRef<Record<string, HTMLFormElement | null>>({});
|
||
|
||
// Initialize form data from formData when it changes
|
||
useEffect(() => {
|
||
const newFormData: Record<string, any> = {};
|
||
sections.forEach(section => {
|
||
const allFields = [
|
||
...(section.staticFields || []),
|
||
...(fieldsBySection[section.sectionId] || [])
|
||
];
|
||
|
||
if (allFields.length === 0) return;
|
||
|
||
const sectionData: any = { ...(sectionFormData[section.id] || {}) };
|
||
allFields.forEach(field => {
|
||
const value = getNestedValue(formData, field.dataKey);
|
||
if (value !== undefined && sectionData[field.dataKey] !== value) {
|
||
sectionData[field.dataKey] = value;
|
||
}
|
||
});
|
||
newFormData[section.id] = sectionData;
|
||
});
|
||
|
||
const hasChanges = Object.keys(newFormData).some(sectionId => {
|
||
const newData = newFormData[sectionId];
|
||
const oldData = sectionFormData[sectionId] || {};
|
||
return JSON.stringify(newData) !== JSON.stringify(oldData);
|
||
});
|
||
|
||
if (hasChanges) {
|
||
setSectionFormData(prev => ({ ...prev, ...newFormData }));
|
||
}
|
||
}, [formData, sections.length, JSON.stringify(fieldsBySection)]);
|
||
|
||
// Helper function to convert SettingsFieldConfig to AttributeDefinition
|
||
const convertFieldToAttribute = (field: SettingsFieldConfig): AttributeDefinition => {
|
||
// Determine the type based on field.type and inputType
|
||
let attributeType: AttributeDefinition['type'] = 'text';
|
||
if (field.type === 'select') {
|
||
attributeType = 'select';
|
||
} else if (field.type === 'toggle') {
|
||
attributeType = 'boolean';
|
||
} else if (field.type === 'text' && field.inputType) {
|
||
// Map inputType to attribute type
|
||
if (field.inputType === 'email') {
|
||
attributeType = 'email';
|
||
} else if (field.inputType === 'tel') {
|
||
attributeType = 'text'; // tel is not a separate type in AttributeDefinition
|
||
} else {
|
||
attributeType = 'text';
|
||
}
|
||
}
|
||
|
||
// Convert options format if present
|
||
let options: AttributeDefinition['options'] = undefined;
|
||
if (field.options && field.options.length > 0) {
|
||
options = field.options.map(opt => ({
|
||
value: opt.value,
|
||
label: typeof opt.label === 'string' ? opt.label : resolveLanguageText(opt.label, t)
|
||
}));
|
||
}
|
||
|
||
// Determine if field is disabled/readonly
|
||
const isDisabled = typeof field.disabled === 'function'
|
||
? field.disabled(formData)
|
||
: field.disabled || false;
|
||
|
||
return {
|
||
name: field.dataKey,
|
||
type: attributeType,
|
||
label: typeof field.label === 'string' ? field.label : resolveLanguageText(field.label, t),
|
||
description: field.description ? (typeof field.description === 'string' ? field.description : resolveLanguageText(field.description, t)) : undefined,
|
||
required: field.required || false,
|
||
readonly: isDisabled,
|
||
editable: !isDisabled,
|
||
placeholder: field.placeholder ? (typeof field.placeholder === 'string' ? field.placeholder : resolveLanguageText(field.placeholder, t)) : undefined,
|
||
options: options
|
||
};
|
||
};
|
||
|
||
const handleSectionSave = async (section: typeof sections[0], formDataToSave?: any) => {
|
||
// If formDataToSave is provided (from FormGeneratorForm onSubmit), use it
|
||
// Otherwise, try to get it from the form ref or use current sectionFormData
|
||
const dataToSave = formDataToSave || sectionFormData[section.id] || {};
|
||
|
||
setSectionSaveLoading(prev => ({ ...prev, [section.id]: true }));
|
||
setSectionSaveMessages(prev => ({ ...prev, [section.id]: null }));
|
||
|
||
try {
|
||
if (onSave) {
|
||
await onSave(section.id, dataToSave);
|
||
} else if (section.onSave) {
|
||
await section.onSave(section.id, dataToSave);
|
||
}
|
||
|
||
setSectionSaveMessages(prev => ({
|
||
...prev,
|
||
[section.id]: {
|
||
type: 'success',
|
||
text: t('settings.save_success') || 'Settings saved successfully'
|
||
}
|
||
}));
|
||
|
||
setTimeout(() => {
|
||
setSectionSaveMessages(prev => ({ ...prev, [section.id]: null }));
|
||
}, 3000);
|
||
} catch (error: any) {
|
||
setSectionSaveMessages(prev => ({
|
||
...prev,
|
||
[section.id]: {
|
||
type: 'error',
|
||
text: error.message || t('settings.save_error') || 'Failed to save settings'
|
||
}
|
||
}));
|
||
} finally {
|
||
setSectionSaveLoading(prev => ({ ...prev, [section.id]: false }));
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
|
||
{sections.map(section => {
|
||
// Check if section has conditional rendering logic
|
||
if (section.renderCondition) {
|
||
const shouldRender = section.renderCondition(formData);
|
||
if (!shouldRender) {
|
||
// Render alternative content if provided
|
||
if (section.renderAlternative) {
|
||
return (
|
||
<React.Fragment key={section.id}>
|
||
{section.renderAlternative(formData, t, resolveLanguageText)}
|
||
</React.Fragment>
|
||
);
|
||
}
|
||
return null;
|
||
}
|
||
}
|
||
|
||
const allFields = [
|
||
...(section.staticFields || []),
|
||
...(fieldsBySection[section.sectionId] || [])
|
||
];
|
||
const isLoading = loadingBySection[section.sectionId] || false;
|
||
const error = errorsBySection[section.sectionId];
|
||
const saveLoading = sectionSaveLoading[section.id] || false;
|
||
const saveMessage = sectionSaveMessages[section.id];
|
||
|
||
// Convert fields to AttributeDefinition format
|
||
const attributes: AttributeDefinition[] = allFields.map(convertFieldToAttribute);
|
||
|
||
// Get current form data for this section
|
||
const currentSectionFormData = sectionFormData[section.id] || {};
|
||
// Prepare section form data (flatten nested structure for FormGeneratorForm)
|
||
const formDataForSection: Record<string, any> = {};
|
||
allFields.forEach(field => {
|
||
const value = currentSectionFormData[field.dataKey] !== undefined
|
||
? currentSectionFormData[field.dataKey]
|
||
: getNestedValue(formData, field.dataKey);
|
||
if (value !== undefined) {
|
||
formDataForSection[field.dataKey] = value;
|
||
}
|
||
});
|
||
|
||
return (
|
||
<div
|
||
key={section.id}
|
||
id={section.id}
|
||
style={{
|
||
padding: '20px',
|
||
background: 'var(--color-bg)',
|
||
borderRadius: '25px',
|
||
border: '1px solid var(--color-primary)',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: '20px'
|
||
}}
|
||
>
|
||
{/* Section Header */}
|
||
<div>
|
||
{section.icon && (
|
||
<section.icon style={{
|
||
fontSize: '1.5rem',
|
||
color: 'var(--color-primary)',
|
||
marginBottom: '8px'
|
||
}} />
|
||
)}
|
||
<h3 style={{
|
||
fontSize: '1rem',
|
||
fontWeight: 500,
|
||
color: 'var(--color-text)',
|
||
marginBottom: '4px'
|
||
}}>
|
||
{resolveLanguageText(section.title, t)}
|
||
</h3>
|
||
{section.description && (
|
||
<p style={{
|
||
fontSize: '0.875rem',
|
||
color: 'var(--color-primary)',
|
||
marginTop: '4px'
|
||
}}>
|
||
{resolveLanguageText(section.description, t)}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Loading State */}
|
||
{isLoading && (
|
||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||
{t('common.loading')}
|
||
</div>
|
||
)}
|
||
|
||
{/* Error State */}
|
||
{error && !isLoading && (
|
||
<div style={{
|
||
padding: '12px 16px',
|
||
borderRadius: '12px',
|
||
backgroundColor: '#fce4ec',
|
||
color: '#c2185b',
|
||
border: '1px solid #f48fb1'
|
||
}}>
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{/* FormGeneratorForm */}
|
||
{!isLoading && !error && attributes.length > 0 && (
|
||
<>
|
||
<div ref={(el) => {
|
||
if (el) {
|
||
const form = el.querySelector('form');
|
||
if (form) {
|
||
formRefs.current[section.id] = form;
|
||
}
|
||
}
|
||
}}>
|
||
<FormGeneratorForm
|
||
attributes={attributes}
|
||
data={formDataForSection}
|
||
mode="edit"
|
||
onSubmit={async (formDataToSave) => {
|
||
// Update local section form data
|
||
setSectionFormData(prev => ({
|
||
...prev,
|
||
[section.id]: { ...prev[section.id], ...formDataToSave }
|
||
}));
|
||
// Trigger save with form data
|
||
await handleSectionSave(section, formDataToSave);
|
||
}}
|
||
showButtons={false}
|
||
className=""
|
||
/>
|
||
</div>
|
||
|
||
{/* Save Message */}
|
||
{saveMessage && (
|
||
<div style={{
|
||
padding: '12px 16px',
|
||
borderRadius: '12px',
|
||
fontSize: '0.875rem',
|
||
fontWeight: 500,
|
||
backgroundColor: saveMessage.type === 'success' ? '#e8f5e8' : '#fce4ec',
|
||
color: saveMessage.type === 'success' ? '#2e7d32' : '#c2185b',
|
||
border: `1px solid ${saveMessage.type === 'success' ? '#81c784' : '#f48fb1'}`
|
||
}}>
|
||
{saveMessage.text}
|
||
</div>
|
||
)}
|
||
|
||
{/* Save Button */}
|
||
<div style={{ display: 'flex', justifyContent: 'flex-end', paddingTop: '10px' }}>
|
||
<Button
|
||
variant={section.saveButtonVariant || 'primary'}
|
||
size={section.saveButtonSize || 'md'}
|
||
onClick={() => {
|
||
// Trigger form submission to get current form data and validation
|
||
const form = formRefs.current[section.id];
|
||
if (form && form.requestSubmit) {
|
||
// requestSubmit triggers validation and onSubmit
|
||
form.requestSubmit();
|
||
} else if (form) {
|
||
// Fallback for browsers that don't support requestSubmit
|
||
const submitButton = form.querySelector('button[type="submit"]') as HTMLButtonElement;
|
||
if (submitButton) {
|
||
submitButton.click();
|
||
} else {
|
||
// Last resort: save with current sectionFormData
|
||
handleSectionSave(section);
|
||
}
|
||
} else {
|
||
// Fallback: save with current sectionFormData
|
||
handleSectionSave(section);
|
||
}
|
||
}}
|
||
loading={saveLoading}
|
||
disabled={saveLoading}
|
||
>
|
||
{resolveLanguageText(section.saveButtonLabel || 'settings.save', t)}
|
||
</Button>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// Render content based on type
|
||
const renderContent = (content: PageContent) => {
|
||
// Wrapper functions to convert fileId-based handlers to WorkflowFile-based handlers
|
||
// These are defined at the top level of renderContent so they're accessible in all content cases
|
||
const wrapFileDelete: ((file: WorkflowFile) => Promise<void>) | undefined = hookData?.handleFileDelete ? async (file: WorkflowFile) => {
|
||
if (!hookData?.handleFileDelete || !file) return;
|
||
const handler = hookData.handleFileDelete as any;
|
||
// Check if handler expects fileId (string) or file (WorkflowFile)
|
||
if (file?.fileId && typeof file.fileId === 'string') {
|
||
// Try fileId signature first (handler might be (fileId: string, ...) => Promise<boolean>)
|
||
try {
|
||
const result = handler(file.fileId);
|
||
if (result instanceof Promise) await result;
|
||
return;
|
||
} catch {
|
||
// Fall through to file signature
|
||
}
|
||
}
|
||
// Try file signature (handler might be (file: WorkflowFile) => Promise<void>)
|
||
const result = handler(file);
|
||
if (result instanceof Promise) await result;
|
||
} : undefined;
|
||
|
||
const wrapFileRemove: ((file: WorkflowFile) => Promise<void>) | undefined = hookData?.handleFileRemove ? async (file: WorkflowFile) => {
|
||
if (!hookData?.handleFileRemove || !file) return;
|
||
const handler = hookData.handleFileRemove as any;
|
||
// Check if handler expects fileId (string) or file (WorkflowFile)
|
||
if (file?.fileId && typeof file.fileId === 'string') {
|
||
// Try fileId signature first (handler might be (fileId: string) => void | Promise<void>)
|
||
try {
|
||
const result = handler(file.fileId);
|
||
if (result instanceof Promise) await result;
|
||
return;
|
||
} catch {
|
||
// Fall through to file signature
|
||
}
|
||
}
|
||
// Try file signature (handler might be (file: WorkflowFile) => Promise<void>)
|
||
const result = handler(file);
|
||
if (result instanceof Promise) await result;
|
||
} : undefined;
|
||
|
||
switch (content.type) {
|
||
case 'heading':
|
||
const HeadingTag = `h${content.level || 2}` as keyof React.JSX.IntrinsicElements;
|
||
return React.createElement(
|
||
HeadingTag,
|
||
{ key: content.id, className: styles.contentHeading },
|
||
resolveLanguageText(content.content, t)
|
||
);
|
||
|
||
case 'paragraph':
|
||
return (
|
||
<p key={content.id} className={styles.contentParagraph}>
|
||
{resolveLanguageText(content.content, t)}
|
||
</p>
|
||
);
|
||
|
||
case 'list':
|
||
return (
|
||
<div key={content.id} className={styles.listContainer}>
|
||
{content.content && (
|
||
<p className={styles.listTitle}>{resolveLanguageText(content.content, t)}</p>
|
||
)}
|
||
<ul className={styles.list}>
|
||
{content.items?.map((item, index) => (
|
||
<li key={index} className={styles.listItem}>
|
||
{resolveLanguageText(item, t)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
);
|
||
|
||
case 'code':
|
||
return (
|
||
<pre key={content.id} className={styles.codeBlock}>
|
||
<code className={content.language ? `language-${content.language}` : ''}>
|
||
{resolveLanguageText(content.content, t)}
|
||
</code>
|
||
</pre>
|
||
);
|
||
|
||
case 'divider':
|
||
return <hr key={content.id} className={styles.contentDivider} />;
|
||
|
||
case 'custom':
|
||
if (content.customComponent) {
|
||
const CustomComponent = content.customComponent;
|
||
return <CustomComponent key={content.id} />;
|
||
}
|
||
return null;
|
||
|
||
case 'table':
|
||
// Get hookData for this specific table (nested tables use their own hooks)
|
||
const currentTableHookData = content.id && tableHookData.has(content.id)
|
||
? tableHookData.get(content.id)!
|
||
: hookData; // Fallback to top-level hookData for backward compatibility
|
||
|
||
if (content.tableConfig && currentTableHookData) {
|
||
|
||
const { columns: configColumns, actionButtons, customActions, emptyMessage, ...tableProps } = content.tableConfig;
|
||
|
||
// Only show loading spinner on initial load (when there's no data yet)
|
||
// During refetch, keep the existing data visible
|
||
const showLoadingSpinner = currentTableHookData.loading && currentTableHookData.data.length === 0;
|
||
|
||
// Show error state if there's an error
|
||
if (currentTableHookData.error) {
|
||
return (
|
||
<div key={content.id} className={styles.tableContainer}>
|
||
<div className={styles.errorState}>
|
||
<p>Error loading data: {currentTableHookData.error}</p>
|
||
{currentTableHookData.refetch && (
|
||
<button onClick={() => currentTableHookData.refetch?.()} className={styles.retryButton}>
|
||
Retry
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Use columns from hook data if available, otherwise use config columns
|
||
// CRITICAL: Preserve columns even when data is empty (e.g., after filtering)
|
||
// Columns from attributes should persist regardless of data state
|
||
const hookColumns = currentTableHookData.columns && currentTableHookData.columns.length > 0 ? currentTableHookData.columns : undefined;
|
||
const configCols = configColumns && configColumns.length > 0 ? configColumns : undefined;
|
||
// Prioritize hookColumns (from attributes) over configColumns to ensure persistence
|
||
const columns = hookColumns || configCols;
|
||
|
||
// CRITICAL: Resolve LanguageText objects in column labels
|
||
// Only map if columns exist, otherwise FormGenerator will auto-detect
|
||
const resolvedColumns = columns ? columns.map(col => ({
|
||
...col,
|
||
label: resolveLanguageText(col.label, t)
|
||
})) : undefined;
|
||
|
||
// Convert action buttons to FormGenerator format
|
||
// Filter out buttons that should be hidden based on RBAC permissions
|
||
const formGeneratorActions = actionButtons?.filter(action => {
|
||
// Check if button should be hidden based on permissions
|
||
const permissions = (hookData as any)?.permissions;
|
||
if (!permissions) {
|
||
// If no permissions loaded yet, show button (will be filtered later)
|
||
return true;
|
||
}
|
||
|
||
// Determine which permission to check based on button type
|
||
// Only standard action types: edit, delete, view, copy
|
||
let requiredPermission: 'read' | 'create' | 'update' | 'delete' | null = null;
|
||
if (action.type === 'view') {
|
||
requiredPermission = 'read';
|
||
} else if (action.type === 'edit') {
|
||
requiredPermission = 'update';
|
||
} else if (action.type === 'copy') {
|
||
// Copy creates a new item, so it requires 'create' permission
|
||
requiredPermission = 'create';
|
||
} else if (action.type === 'delete') {
|
||
requiredPermission = 'delete';
|
||
}
|
||
|
||
// If no specific permission required, show button
|
||
if (!requiredPermission) {
|
||
return true;
|
||
}
|
||
|
||
// Check if user has the required permission (not 'n')
|
||
const hasPermission = permissions[requiredPermission] !== 'n' && permissions.view;
|
||
|
||
// Log permission check for debugging
|
||
if (import.meta.env.DEV) {
|
||
console.log(`🔐 Permission check for ${action.type} button:`, {
|
||
requiredPermission,
|
||
hasPermission,
|
||
permissionValue: permissions[requiredPermission],
|
||
view: permissions.view
|
||
});
|
||
}
|
||
|
||
return hasPermission;
|
||
}).map(action => {
|
||
// Wrap disabled function to handle both row-based and hookData-based disabled functions
|
||
let disabledFn: ((row: any) => boolean | { disabled: boolean; message?: string });
|
||
if (action.disabled) {
|
||
if (typeof action.disabled === 'function') {
|
||
// Try to call with hookData first (for permission-based checks)
|
||
// If that works, use the result for all rows
|
||
// Otherwise, fall back to calling with row
|
||
try {
|
||
// Check if function signature suggests it takes hookData
|
||
// We'll try calling it with hookData - if it's designed for that, it will work
|
||
const testCall = (action.disabled as any)(hookData);
|
||
if (testCall !== undefined && (typeof testCall === 'boolean' || (typeof testCall === 'object' && 'disabled' in testCall))) {
|
||
// Function accepts hookData - use result for all rows
|
||
disabledFn = () => testCall;
|
||
} else {
|
||
// Function doesn't work with hookData, use row-based approach
|
||
disabledFn = action.disabled as (row: any) => boolean | { disabled: boolean; message?: string };
|
||
}
|
||
} catch {
|
||
// Function doesn't accept hookData, use row-based approach
|
||
disabledFn = action.disabled as (row: any) => boolean | { disabled: boolean; message?: string };
|
||
}
|
||
} else {
|
||
// Non-function disabled value
|
||
const disabledValue = action.disabled;
|
||
if (typeof disabledValue === 'boolean') {
|
||
disabledFn = () => disabledValue;
|
||
} else if (disabledValue && typeof disabledValue === 'object' && 'disabled' in disabledValue) {
|
||
disabledFn = () => disabledValue as { disabled: boolean; message?: string };
|
||
} else {
|
||
disabledFn = () => false;
|
||
}
|
||
}
|
||
} else {
|
||
disabledFn = () => false;
|
||
}
|
||
|
||
return {
|
||
type: action.type,
|
||
onAction: action.onAction,
|
||
// CRITICAL: Resolve LanguageText objects in action titles
|
||
title: resolveLanguageText(action.title, t),
|
||
isProcessing: action.loading || (() => false),
|
||
disabled: disabledFn,
|
||
// Preserve field mappings and operation names
|
||
idField: action.idField,
|
||
nameField: action.nameField,
|
||
typeField: action.typeField,
|
||
contentField: action.contentField,
|
||
operationName: action.operationName,
|
||
loadingStateName: action.loadingStateName,
|
||
fetchItemFunctionName: action.fetchItemFunctionName
|
||
};
|
||
}) || [];
|
||
|
||
// Debug logging for table rendering
|
||
if (import.meta.env.DEV) {
|
||
console.log('🔍 Rendering FormGenerator:', {
|
||
dataLength: currentTableHookData.data?.length || 0,
|
||
columnsCount: resolvedColumns?.length || 0,
|
||
loading: showLoadingSpinner,
|
||
hasError: !!currentTableHookData.error,
|
||
data: currentTableHookData.data,
|
||
willAutoDetect: !resolvedColumns
|
||
});
|
||
}
|
||
|
||
return (
|
||
<div key={content.id} className={styles.tableContainer}>
|
||
{currentTableHookData.isRefetching && (
|
||
<div className={styles.refetchingIndicator}>
|
||
Refreshing...
|
||
</div>
|
||
)}
|
||
<FormGenerator
|
||
data={currentTableHookData.data || []}
|
||
columns={resolvedColumns}
|
||
loading={showLoadingSpinner}
|
||
actionButtons={formGeneratorActions}
|
||
customActions={customActions?.map(action => {
|
||
// Resolve LanguageText in title to string
|
||
let resolvedTitle: string | ((row: any) => string) | undefined = undefined;
|
||
if (typeof action.title === 'function') {
|
||
resolvedTitle = action.title;
|
||
} else if (typeof action.title === 'string') {
|
||
resolvedTitle = resolveLanguageText(action.title, t);
|
||
} else if (action.title && typeof action.title === 'object') {
|
||
resolvedTitle = resolveLanguageText(action.title as any, t);
|
||
}
|
||
return {
|
||
...action,
|
||
title: resolvedTitle
|
||
};
|
||
})}
|
||
hookData={currentTableHookData}
|
||
onDelete={currentTableHookData.onDelete}
|
||
onDeleteMultiple={currentTableHookData.onDeleteMultiple}
|
||
emptyMessage={emptyMessage}
|
||
{...tableProps}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
return null;
|
||
|
||
case 'inputForm':
|
||
if (content.inputFormConfig && hookData) {
|
||
const config = content.inputFormConfig;
|
||
const isRunning = hookData.isRunning || false;
|
||
|
||
// Determine button props based on workflow state
|
||
const buttonLabel = isRunning
|
||
? (config.stopButtonLabel || config.buttonLabel)
|
||
: config.buttonLabel;
|
||
const buttonIcon = isRunning
|
||
? (config.stopButtonIcon || config.buttonIcon)
|
||
: config.buttonIcon;
|
||
const buttonVariant = isRunning
|
||
? (config.stopButtonVariant || config.buttonVariant || 'primary')
|
||
: (config.buttonVariant || 'primary');
|
||
// Button disabled logic:
|
||
// - Always enabled when running (to allow stopping), unless submitting
|
||
// - When not running, disabled if submitting or input is empty
|
||
const buttonDisabled = isRunning
|
||
? hookData.isSubmitting // When running, only disable if submitting
|
||
: (hookData.isSubmitting || !hookData.inputValue?.trim()); // When not running, disable if submitting or input empty
|
||
|
||
// Handle Enter key press
|
||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||
if (e.key === 'Enter' && !e.shiftKey && hookData.handleSubmit && !hookData.isSubmitting) {
|
||
e.preventDefault();
|
||
hookData.handleSubmit();
|
||
}
|
||
};
|
||
|
||
// Check if we have file management (dashboard style with workflowFiles)
|
||
const hasFileManagement = !!(hookData.handleFileUpload && hookData.workflowFiles !== undefined);
|
||
|
||
// Check if we have chatbot file upload (simpler style with uploadedFiles)
|
||
// Also check if file upload is enabled in config (default: true)
|
||
const showFileUpload = config.showFileUpload !== false; // Default to true if not specified
|
||
const hasChatbotFileUpload = showFileUpload && !!(hookData.handleFileUpload && hookData.uploadedFiles !== undefined);
|
||
|
||
// Check RBAC permissions for prompt selector and workflow mode selector
|
||
// Show prompt selector if user has permission to view/read prompts (even if no prompts exist yet)
|
||
const showPromptSelector = hookData.promptPermission &&
|
||
hookData.promptPermission.view !== false &&
|
||
hookData.promptPermission.read !== 'n';
|
||
const showWorkflowModeSelector = hookData.workflowModeItems !== undefined;
|
||
|
||
// Calculate number of visible elements for equal width distribution
|
||
const visibleElementsCount =
|
||
(showPromptSelector ? 1 : 0) +
|
||
(showWorkflowModeSelector ? 1 : 0) +
|
||
2; // Attach Files and Send buttons are always visible
|
||
|
||
// Grid layout for pages with file management
|
||
if (hasFileManagement) {
|
||
return (
|
||
<div key={content.id} className={styles.dashboardInputGrid}>
|
||
{/* Top row: Dropdown selectors and buttons */}
|
||
<div
|
||
className={styles.equalWidthButtonsRow}
|
||
style={{ '--grid-columns': visibleElementsCount } as React.CSSProperties}
|
||
>
|
||
{showPromptSelector && (
|
||
<div className={styles.equalWidthButtonWrapper}>
|
||
<DropdownSelect
|
||
items={hookData.promptItems || []}
|
||
selectedItemId={hookData.selectedPromptId || null}
|
||
onSelect={hookData.onPromptSelect || (() => {})}
|
||
placeholder={t('dashboard.prompt.select', 'Select a prompt')}
|
||
emptyMessage={t('dashboard.prompt.empty', 'No prompts available')}
|
||
headerText={t('dashboard.prompt.header', 'Select Prompt')}
|
||
variant="secondary"
|
||
size={config.buttonSize || 'md'}
|
||
disabled={hookData.isSubmitting || hookData.promptsLoading || false}
|
||
loading={hookData.promptsLoading || false}
|
||
minWidth="0"
|
||
/>
|
||
</div>
|
||
)}
|
||
{showWorkflowModeSelector && (
|
||
<div className={styles.equalWidthButtonWrapper}>
|
||
<DropdownSelect
|
||
items={hookData.workflowModeItems || []}
|
||
selectedItemId={hookData.selectedWorkflowMode || null}
|
||
onSelect={hookData.onWorkflowModeSelect || (() => {})}
|
||
placeholder={t('dashboard.workflow.mode.select', 'Select workflow mode')}
|
||
emptyMessage={t('dashboard.workflow.mode.empty', 'No modes available')}
|
||
headerText={t('dashboard.workflow.mode.header', 'Workflow Mode')}
|
||
variant="secondary"
|
||
size={config.buttonSize || 'md'}
|
||
disabled={hookData.isSubmitting || false}
|
||
showClearButton={true}
|
||
minWidth="0"
|
||
/>
|
||
</div>
|
||
)}
|
||
<div className={`${styles.equalWidthButtonWrapper} ${styles.fullWidthButton}`}>
|
||
<Button
|
||
onClick={() => hookData.setIsFileAttachmentPopupOpen?.(true)}
|
||
disabled={hookData.isSubmitting || false}
|
||
variant="secondary"
|
||
size={config.buttonSize || 'md'}
|
||
icon={FiPaperclip}
|
||
>
|
||
Attach Files
|
||
</Button>
|
||
</div>
|
||
<div className={`${styles.equalWidthButtonWrapper} ${styles.fullWidthButton}`}>
|
||
<Button
|
||
onClick={() => hookData.handleSubmit?.()}
|
||
loading={hookData.isSubmitting || false}
|
||
disabled={buttonDisabled}
|
||
variant={buttonVariant}
|
||
size={config.buttonSize || 'md'}
|
||
icon={buttonIcon}
|
||
>
|
||
{resolveLanguageText(buttonLabel, t)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Bottom row: Input text area */}
|
||
<div className={styles.inputTextAreaContainer}>
|
||
<div className={styles.inputTextAreaWrapper}>
|
||
<FixedHeightTextField
|
||
value={hookData.inputValue || ''}
|
||
onChange={hookData.onInputChange}
|
||
placeholder={resolveLanguageText(config.placeholder, t)}
|
||
size={config.textFieldSize || 'md'}
|
||
disabled={hookData.isSubmitting || false}
|
||
className={styles.textAreaFixed}
|
||
onKeyDown={handleKeyDown}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Right column: Connected Files List (spans both rows) */}
|
||
<div className={styles.connectedFilesContainer}>
|
||
<div className={styles.connectedFilesScrollable}>
|
||
<ConnectedFilesList
|
||
files={hookData.workflowFiles || []}
|
||
pendingFiles={hookData.pendingFiles || []}
|
||
actionButtons={[
|
||
{
|
||
type: 'view',
|
||
idField: 'fileId',
|
||
nameField: 'fileName',
|
||
typeField: 'mimeType'
|
||
},
|
||
{
|
||
type: 'remove',
|
||
onAction: wrapFileRemove,
|
||
showOnlyForPending: true,
|
||
idField: 'fileId',
|
||
loadingStateName: 'removingItems'
|
||
},
|
||
{
|
||
type: 'delete',
|
||
operationName: 'handleDelete',
|
||
loadingStateName: 'deletingItems',
|
||
idField: 'fileId'
|
||
}
|
||
]}
|
||
onDelete={wrapFileDelete}
|
||
onRemove={wrapFileRemove}
|
||
onAttach={hookData.handleFileAttach ? async (fileId: string) => {
|
||
const result = hookData.handleFileAttach!(fileId);
|
||
if (result instanceof Promise) await result;
|
||
} : undefined}
|
||
deletingFiles={hookData.deletingFiles || new Set()}
|
||
previewingFiles={hookData.previewingFiles || new Set()}
|
||
removingFiles={new Set()} // Can be tracked if needed
|
||
workflowId={hookData.workflowId}
|
||
emptyMessage="No files connected to this workflow"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* File Attachment Popup */}
|
||
{hookData.isFileAttachmentPopupOpen && (
|
||
<Popup
|
||
isOpen={hookData.isFileAttachmentPopupOpen || false}
|
||
title="Attach Files"
|
||
onClose={() => hookData.setIsFileAttachmentPopupOpen?.(false)}
|
||
size="large"
|
||
>
|
||
<div style={{
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: '1rem',
|
||
minHeight: '400px',
|
||
maxHeight: '600px'
|
||
}}>
|
||
{/* Upload Button Section */}
|
||
<div style={{
|
||
padding: '1rem',
|
||
borderBottom: '1px solid #e0e0e0',
|
||
display: 'flex',
|
||
justifyContent: 'flex-end'
|
||
}}>
|
||
<UploadButton
|
||
onUpload={hookData.handleFileUploadAndAttach || hookData.handleFileUpload ? async (file: File) => {
|
||
const handler = hookData.handleFileUploadAndAttach || hookData.handleFileUpload;
|
||
if (handler) {
|
||
// Handler returns Promise<{ success, data }>, but UploadButton expects Promise<void>
|
||
await handler(file);
|
||
}
|
||
} : async () => {}}
|
||
disabled={hookData.isSubmitting || false}
|
||
loading={hookData.uploadingFile || false}
|
||
variant="primary"
|
||
size="md"
|
||
multiple={true}
|
||
>
|
||
Upload New File
|
||
</UploadButton>
|
||
</div>
|
||
|
||
{/* Files List */}
|
||
<div style={{
|
||
flex: 1,
|
||
overflowY: 'auto',
|
||
padding: '0.5rem'
|
||
}}>
|
||
{hookData.allUserFiles && hookData.allUserFiles.length > 0 ? (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||
{hookData.allUserFiles.map((file: any) => {
|
||
const isAttached = hookData.pendingFiles?.some((pf: any) => pf.fileId === file.id);
|
||
return (
|
||
<div
|
||
key={file.id}
|
||
onClick={() => hookData.handleFileAttach?.(file.id)}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
padding: '0.75rem',
|
||
border: `1px solid ${isAttached ? '#4CAF50' : '#e0e0e0'}`,
|
||
borderRadius: '4px',
|
||
cursor: 'pointer',
|
||
backgroundColor: isAttached ? '#f1f8f4' : 'transparent',
|
||
transition: 'all 0.2s'
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
if (!isAttached) {
|
||
e.currentTarget.style.backgroundColor = '#f5f5f5';
|
||
}
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
if (!isAttached) {
|
||
e.currentTarget.style.backgroundColor = 'transparent';
|
||
}
|
||
}}
|
||
>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flex: 1 }}>
|
||
<span style={{ fontSize: '1.2rem' }}>📎</span>
|
||
<div style={{ flex: 1 }}>
|
||
<div style={{ fontWeight: 500, fontSize: '0.9rem' }}>
|
||
{file.file_name || file.fileName || 'Unknown File'}
|
||
</div>
|
||
<div style={{ fontSize: '0.75rem', color: '#666', marginTop: '0.25rem' }}>
|
||
{(() => {
|
||
const size = file.size;
|
||
const mimeType = file.mime_type || file.mimeType || 'application/octet-stream';
|
||
let sizeStr = '';
|
||
if (size) {
|
||
if (size < 1024) {
|
||
sizeStr = `${size} B`;
|
||
} else if (size < 1024 * 1024) {
|
||
sizeStr = `${(size / 1024).toFixed(1)} KB`;
|
||
} else if (size < 1024 * 1024 * 1024) {
|
||
sizeStr = `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||
} else {
|
||
sizeStr = `${(size / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||
}
|
||
}
|
||
return sizeStr ? `${sizeStr} • ${mimeType}` : mimeType;
|
||
})()}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div style={{
|
||
padding: '0.25rem 0.75rem',
|
||
borderRadius: '4px',
|
||
fontSize: '0.8rem',
|
||
fontWeight: 500,
|
||
backgroundColor: isAttached ? '#4CAF50' : '#e0e0e0',
|
||
color: isAttached ? 'white' : '#666'
|
||
}}>
|
||
{isAttached ? 'Attached' : 'Attach'}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<div style={{
|
||
textAlign: 'center',
|
||
padding: '2rem',
|
||
color: '#999'
|
||
}}>
|
||
No files uploaded yet. Click "Upload New File" to add files.
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</Popup>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Chatbot file upload layout (simpler than dashboard)
|
||
if (hasChatbotFileUpload) {
|
||
const uploadedFiles = hookData.uploadedFiles || [];
|
||
return (
|
||
<div key={content.id} style={{
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: '0.75rem',
|
||
margin: '1.5rem 0',
|
||
width: '100%',
|
||
flexShrink: 0
|
||
}}>
|
||
{/* Input and buttons row */}
|
||
<div style={{
|
||
display: 'flex',
|
||
gap: '8px',
|
||
alignItems: 'flex-start',
|
||
width: '100%'
|
||
}}>
|
||
<div style={{ flex: 1 }}>
|
||
<TextField
|
||
value={hookData.inputValue || ''}
|
||
onChange={hookData.onInputChange}
|
||
placeholder={resolveLanguageText(config.placeholder, t)}
|
||
size={config.textFieldSize || 'md'}
|
||
disabled={hookData.isSubmitting || false}
|
||
{...({ onKeyDown: handleKeyDown } as any)}
|
||
/>
|
||
</div>
|
||
<div style={{ flexShrink: 0, display: 'flex', gap: '8px' }}>
|
||
<UploadButton
|
||
onUpload={hookData.handleFileUpload ? async (file: File) => {
|
||
await hookData.handleFileUpload!(file);
|
||
// Error handling is done in the hook
|
||
} : async () => {}}
|
||
disabled={hookData.isSubmitting || false}
|
||
loading={hookData.uploadingFile || false}
|
||
variant="secondary"
|
||
size={config.buttonSize || 'md'}
|
||
multiple={true}
|
||
accept="*/*"
|
||
>
|
||
Upload
|
||
</UploadButton>
|
||
<Button
|
||
onClick={() => hookData.handleSubmit?.()}
|
||
loading={hookData.isSubmitting || false}
|
||
disabled={buttonDisabled}
|
||
variant={buttonVariant}
|
||
size={config.buttonSize || 'md'}
|
||
icon={buttonIcon}
|
||
>
|
||
{resolveLanguageText(buttonLabel, t)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Pending files display */}
|
||
{uploadedFiles.length > 0 && (
|
||
<div style={{
|
||
display: 'flex',
|
||
flexWrap: 'wrap',
|
||
gap: '6px',
|
||
padding: '8px',
|
||
backgroundColor: '#f5f5f5',
|
||
borderRadius: '4px',
|
||
fontSize: '0.875rem'
|
||
}}>
|
||
{uploadedFiles.map((file: { fileId: string; fileName: string }) => (
|
||
<div
|
||
key={file.fileId}
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '6px',
|
||
padding: '4px 8px',
|
||
backgroundColor: 'white',
|
||
border: '1px solid #e0e0e0',
|
||
borderRadius: '4px',
|
||
fontSize: '0.8rem'
|
||
}}
|
||
>
|
||
<span>📎</span>
|
||
<span style={{ maxWidth: '150px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||
{file.fileName}
|
||
</span>
|
||
<button
|
||
onClick={() => hookData.handleFileRemove?.(file.fileId)}
|
||
disabled={hookData.isSubmitting || false}
|
||
style={{
|
||
background: 'none',
|
||
border: 'none',
|
||
cursor: hookData.isSubmitting ? 'not-allowed' : 'pointer',
|
||
padding: '0',
|
||
marginLeft: '4px',
|
||
fontSize: '1rem',
|
||
lineHeight: '1',
|
||
opacity: hookData.isSubmitting ? 0.5 : 1
|
||
}}
|
||
title="Remove file"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Upload error display */}
|
||
{hookData.uploadError && (
|
||
<div style={{
|
||
padding: '8px 12px',
|
||
backgroundColor: '#ffebee',
|
||
color: '#c62828',
|
||
borderRadius: '4px',
|
||
fontSize: '0.875rem'
|
||
}}>
|
||
{hookData.uploadError}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// Default layout without files (backward compatible)
|
||
return (
|
||
<div key={content.id} style={{
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: '1rem',
|
||
margin: '1.5rem 0',
|
||
width: '100%',
|
||
flexShrink: 0
|
||
}}>
|
||
{/* Dropdown selectors row */}
|
||
{(showPromptSelector || showWorkflowModeSelector) && (
|
||
<div style={{
|
||
display: 'flex',
|
||
gap: '12px',
|
||
alignItems: 'flex-start',
|
||
flexWrap: 'wrap'
|
||
}}>
|
||
{showPromptSelector && (
|
||
<DropdownSelect
|
||
items={hookData.promptItems || []}
|
||
selectedItemId={hookData.selectedPromptId || null}
|
||
onSelect={hookData.onPromptSelect || (() => {})}
|
||
placeholder={t('dashboard.prompt.select', 'Select a prompt')}
|
||
emptyMessage={t('dashboard.prompt.empty', 'No prompts available')}
|
||
headerText={t('dashboard.prompt.header', 'Select Prompt')}
|
||
variant="secondary"
|
||
size={config.buttonSize || 'md'}
|
||
disabled={hookData.isSubmitting || hookData.promptsLoading || false}
|
||
loading={hookData.promptsLoading || false}
|
||
minWidth="200px"
|
||
/>
|
||
)}
|
||
{showWorkflowModeSelector && (
|
||
<DropdownSelect
|
||
items={hookData.workflowModeItems || []}
|
||
selectedItemId={hookData.selectedWorkflowMode || null}
|
||
onSelect={hookData.onWorkflowModeSelect || (() => {})}
|
||
placeholder={t('dashboard.workflow.mode.select', 'Select workflow mode')}
|
||
emptyMessage={t('dashboard.workflow.mode.empty', 'No modes available')}
|
||
headerText={t('dashboard.workflow.mode.header', 'Workflow Mode')}
|
||
variant="secondary"
|
||
size={config.buttonSize || 'md'}
|
||
disabled={hookData.isSubmitting || false}
|
||
minWidth="180px"
|
||
showClearButton={true}
|
||
/>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Input and button row */}
|
||
<div style={{
|
||
display: 'flex',
|
||
gap: '12px',
|
||
alignItems: 'flex-start',
|
||
width: '100%'
|
||
}}>
|
||
<div style={{ flex: 1 }}>
|
||
<TextField
|
||
value={hookData.inputValue || ''}
|
||
onChange={hookData.onInputChange}
|
||
placeholder={resolveLanguageText(config.placeholder, t)}
|
||
size={config.textFieldSize || 'md'}
|
||
disabled={hookData.isSubmitting || false}
|
||
{...({ onKeyDown: handleKeyDown } as any)}
|
||
/>
|
||
</div>
|
||
<div style={{ flexShrink: 0 }}>
|
||
<Button
|
||
onClick={() => hookData.handleSubmit?.()}
|
||
loading={hookData.isSubmitting || false}
|
||
disabled={buttonDisabled}
|
||
variant={buttonVariant}
|
||
size={config.buttonSize || 'md'}
|
||
icon={buttonIcon}
|
||
>
|
||
{resolveLanguageText(buttonLabel, t)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
return null;
|
||
|
||
case 'messages':
|
||
const config = content.messagesConfig || {};
|
||
const dataSource = config.dataSource || 'messages';
|
||
|
||
const hookMessages = Array.isArray(hookData?.messages) ? hookData.messages : [];
|
||
const hookLogs = Array.isArray(hookData?.logs) ? hookData.logs : [];
|
||
|
||
// Determine which data to render based on dataSource
|
||
let messagesToRender: any[] = [];
|
||
let variantToUse = config.variant || 'chat';
|
||
|
||
if (dataSource === 'logs') {
|
||
messagesToRender = hookLogs;
|
||
// Logs should use log variant by default
|
||
variantToUse = config.variant || 'log';
|
||
} else if (dataSource === 'both') {
|
||
// Merge messages and logs, with logs using log variant
|
||
// We'll render them separately in sequence
|
||
const combined = [
|
||
...hookMessages.map((msg: any) => ({ ...msg, _renderVariant: config.variant || 'chat' })),
|
||
...hookLogs.map((log: any) => ({ ...log, _renderVariant: 'log' }))
|
||
].sort((a, b) => {
|
||
// Sort by timestamp/sequence to interleave properly
|
||
const aTime = a.publishedAt || a.timestamp || 0;
|
||
const bTime = b.publishedAt || b.timestamp || 0;
|
||
return aTime - bTime;
|
||
});
|
||
messagesToRender = combined;
|
||
// When both, we'll use a custom renderer
|
||
} else {
|
||
// Default: use messages
|
||
messagesToRender = hookMessages;
|
||
}
|
||
|
||
// Custom renderer for when dataSource is 'both' to handle different variants
|
||
const renderMessage = dataSource === 'both'
|
||
? (message: any, index: number) => {
|
||
const variant = message._renderVariant || 'chat';
|
||
const cleanMessage = { ...message };
|
||
delete cleanMessage._renderVariant;
|
||
|
||
if (variant === 'log') {
|
||
return (
|
||
<LogMessage
|
||
key={message.id || index}
|
||
message={cleanMessage}
|
||
showDocuments={config.showDocuments !== false}
|
||
showMetadata={config.showMetadata !== false}
|
||
showProgress={config.showProgress !== false}
|
||
onFileDelete={wrapFileDelete}
|
||
onFileRemove={wrapFileRemove}
|
||
deletingFiles={hookData?.deletingFiles}
|
||
previewingFiles={hookData?.previewingFiles}
|
||
removingFiles={hookData?.removingFiles}
|
||
workflowId={hookData?.workflowId}
|
||
/>
|
||
);
|
||
} else {
|
||
return (
|
||
<ChatMessage
|
||
key={message.id || index}
|
||
message={cleanMessage}
|
||
showDocuments={config.showDocuments !== false}
|
||
onFileDelete={wrapFileDelete}
|
||
onFileRemove={wrapFileRemove}
|
||
deletingFiles={hookData?.deletingFiles}
|
||
previewingFiles={hookData?.previewingFiles}
|
||
removingFiles={hookData?.removingFiles}
|
||
workflowId={hookData?.workflowId}
|
||
/>
|
||
);
|
||
}
|
||
}
|
||
: undefined;
|
||
|
||
return (
|
||
<div key={content.id} className={styles.messagesSection}>
|
||
<Messages
|
||
messages={messagesToRender}
|
||
variant={variantToUse}
|
||
renderMessage={renderMessage}
|
||
showDocuments={config.showDocuments !== false}
|
||
showMetadata={config.showMetadata !== false}
|
||
showProgress={config.showProgress !== false}
|
||
emptyMessage={config.emptyMessage ? resolveLanguageText(config.emptyMessage, t) : undefined}
|
||
onFileDelete={wrapFileDelete}
|
||
onFileRemove={wrapFileRemove}
|
||
deletingFiles={hookData?.deletingFiles}
|
||
previewingFiles={hookData?.previewingFiles}
|
||
removingFiles={hookData?.removingFiles}
|
||
workflowId={hookData?.workflowId}
|
||
/>
|
||
</div>
|
||
);
|
||
|
||
case 'settings':
|
||
if (content.settingsConfig && hookData) {
|
||
const config = content.settingsConfig;
|
||
const sections = config.sections;
|
||
const formData = (hookData as any).settingsData || {};
|
||
const fieldsBySection = (hookData as any).settingsFields || {};
|
||
const loadingBySection = (hookData as any).settingsLoading || {};
|
||
const errorsBySection = (hookData as any).settingsErrors || {};
|
||
const saveSectionHandler = (hookData as any).saveSection;
|
||
|
||
// Use a generic form renderer component that can be reused
|
||
return <FormSectionRenderer
|
||
key={content.id}
|
||
sections={sections}
|
||
formData={formData}
|
||
fieldsBySection={fieldsBySection}
|
||
loadingBySection={loadingBySection}
|
||
errorsBySection={errorsBySection}
|
||
onSave={saveSectionHandler}
|
||
getNestedValue={getNestedValue}
|
||
setNestedValue={setNestedValue}
|
||
/>;
|
||
}
|
||
return null;
|
||
|
||
case 'log': {
|
||
const logConfig = content.logConfig || {};
|
||
const dashboardTree = hookData?.dashboardTree;
|
||
const onToggleOperationExpanded = hookData?.onToggleOperationExpanded;
|
||
const getChildOperations = hookData?.getChildOperations;
|
||
return (
|
||
<div key={content.id} className={styles.logSection}>
|
||
<Log
|
||
emptyMessage={logConfig.emptyMessage ? resolveLanguageText(logConfig.emptyMessage, t) : undefined}
|
||
dashboardTree={dashboardTree}
|
||
onToggleOperationExpanded={onToggleOperationExpanded}
|
||
getChildOperations={getChildOperations}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
case 'tabs': {
|
||
return (
|
||
<TabsRenderer
|
||
key={content.id}
|
||
content={content}
|
||
renderContent={renderContent}
|
||
t={t}
|
||
/>
|
||
);
|
||
}
|
||
|
||
case 'columns': {
|
||
const columnsConfig = content.columnsConfig;
|
||
if (!columnsConfig || !columnsConfig.columns || columnsConfig.columns.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
// Build grid template columns
|
||
const gridTemplateColumns = columnsConfig.columns
|
||
.map(col => col.width || '1fr')
|
||
.join(' ');
|
||
const gap = columnsConfig.gap || '1rem';
|
||
|
||
return (
|
||
<div
|
||
key={content.id}
|
||
className={styles.columnsContainer}
|
||
style={{
|
||
display: 'grid',
|
||
gridTemplateColumns,
|
||
gap
|
||
}}
|
||
>
|
||
{columnsConfig.columns.map((column, colIndex) => (
|
||
<div key={column.id} className={styles.column}>
|
||
{column.content.map((nestedContent, index) => (
|
||
<React.Fragment key={nestedContent.id || `${colIndex}-${index}`}>
|
||
{renderContent(nestedContent)}
|
||
</React.Fragment>
|
||
))}
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
case 'chatHistory': {
|
||
const config = content.chatHistoryConfig || {};
|
||
const threads = (hookData as any)?.threads || [];
|
||
const selectedThreadId = (hookData as any)?.selectedThreadId || null;
|
||
const threadsLoading = (hookData as any)?.threadsLoading || false;
|
||
const threadsError = (hookData as any)?.threadsError || null;
|
||
const selectThread = (hookData as any)?.selectThread;
|
||
const handleDelete = (hookData as any)?.handleDelete;
|
||
const deletingItems = (hookData as any)?.deletingItems || new Set();
|
||
const startNewChat = (hookData as any)?.startNewChat;
|
||
|
||
// Get thread preview text for display
|
||
const getThreadPreview = (thread: any): string => {
|
||
if (thread.name) return thread.name;
|
||
if (thread.firstMessage) return thread.firstMessage.substring(0, 50) + (thread.firstMessage.length > 50 ? '...' : '');
|
||
return t('chat_history.no_message_content', 'No message content available');
|
||
};
|
||
|
||
// Prepare data for FormGeneratorList - add a display name field
|
||
const threadsWithDisplayName = threads.map((thread: any) => ({
|
||
...thread,
|
||
displayName: getThreadPreview(thread)
|
||
}));
|
||
|
||
// Define fields for FormGeneratorList - only show creation time
|
||
const fields = [
|
||
{
|
||
key: 'displayName',
|
||
label: '', // No label needed, will be shown as first field
|
||
type: 'string' as const
|
||
},
|
||
{
|
||
key: 'startedAt',
|
||
label: '', // No label needed, will be shown as metadata
|
||
type: 'date' as const
|
||
}
|
||
];
|
||
|
||
// Ensure hookData has required properties for DeleteActionButton
|
||
const enhancedHookData = {
|
||
...hookData,
|
||
refetch: (hookData as any)?.refetch || (hookData as any)?.loadThreads || (() => {}),
|
||
handleDelete: handleDelete || (() => Promise.resolve(false)),
|
||
removeOptimistically: (hookData as any)?.removeOptimistically || (hookData as any)?.removeThreadOptimistically,
|
||
deletingItems: deletingItems
|
||
};
|
||
|
||
// Configure action buttons - delete button (always show)
|
||
const actionButtons = [
|
||
{
|
||
type: 'delete' as const,
|
||
disabled: (row: any) => !handleDelete || deletingItems.has(row.id),
|
||
loading: (row: any) => deletingItems.has(row.id),
|
||
title: t('chat_history.delete_tooltip', 'Delete workflow'),
|
||
operationName: 'handleDelete',
|
||
loadingStateName: 'deletingItems'
|
||
}
|
||
];
|
||
|
||
// Handle item click to select thread (checkbox and delete button stop propagation)
|
||
const handleItemClick = (row: any) => {
|
||
if (!deletingItems.has(row.id) && selectThread) {
|
||
selectThread(row.id);
|
||
}
|
||
};
|
||
|
||
// Get data attributes for styling selected items
|
||
const getItemDataAttributes = (row: any) => {
|
||
return {
|
||
'selected-thread-id': row.id === selectedThreadId ? 'true' : 'false'
|
||
};
|
||
};
|
||
|
||
// Show error state if there's an error
|
||
if (threadsError) {
|
||
return (
|
||
<div key={content.id} className={styles.chatHistorySection}>
|
||
<div className={styles.chatHistoryError}>
|
||
<p>{t('chat_history.error_loading', 'Error loading workflows:')} {threadsError}</p>
|
||
{(hookData as any)?.loadThreads && (
|
||
<button
|
||
onClick={() => (hookData as any).loadThreads()}
|
||
className={styles.retryButton}
|
||
>
|
||
{t('chat_history.try_again', 'Try Again')}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div key={content.id} className={styles.chatHistorySection}>
|
||
<FormGeneratorList
|
||
data={threadsWithDisplayName}
|
||
fields={fields}
|
||
title={t('chat_history.title', 'Chat History')}
|
||
loading={threadsLoading}
|
||
emptyMessage={config.emptyMessage
|
||
? resolveLanguageText(config.emptyMessage, t)
|
||
: t('chat_history.empty_state', 'No workflows available')}
|
||
onItemClick={handleItemClick}
|
||
actionButtons={actionButtons}
|
||
onDelete={handleDelete}
|
||
onDeleteMultiple={handleDelete ? async (rowsToDelete: any[]) => {
|
||
// Handle multiple delete
|
||
for (const rowToDelete of rowsToDelete) {
|
||
await handleDelete(rowToDelete.id);
|
||
}
|
||
} : undefined}
|
||
hookData={enhancedHookData}
|
||
getItemDataAttributes={getItemDataAttributes}
|
||
searchable={false}
|
||
filterable={false}
|
||
sortable={false}
|
||
pagination={false}
|
||
selectable={true}
|
||
className={styles.chatHistoryList}
|
||
headerButton={startNewChat ? (
|
||
<Button
|
||
onClick={() => startNewChat()}
|
||
variant="primary"
|
||
size="sm"
|
||
icon={IoMdAdd}
|
||
className={styles.chatHistoryNewChatButton}
|
||
>
|
||
{t('chat_history.new_chat', 'New Chat')}
|
||
</Button>
|
||
) : undefined}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
default:
|
||
return null;
|
||
}
|
||
};
|
||
|
||
// Create enhanced drag drop config with hook data integration
|
||
const getDragDropConfig = () => {
|
||
if (!pageData.dragDropConfig) {
|
||
return { enabled: false, onDrop: () => {} };
|
||
}
|
||
|
||
|
||
// If the page has drag drop config and hook data with handleUpload or handleFileUpload, integrate them
|
||
const uploadHandler = hookData?.handleUpload || hookData?.handleFileUpload;
|
||
if (uploadHandler) {
|
||
return {
|
||
...pageData.dragDropConfig,
|
||
onDrop: async (files: File[]) => {
|
||
try {
|
||
// Process each file through the hook's upload function
|
||
for (const file of files) {
|
||
if (uploadHandler) {
|
||
await uploadHandler(file);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Error uploading dropped files:', error);
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
|
||
// Fallback to the original config
|
||
return pageData.dragDropConfig;
|
||
};
|
||
|
||
return (
|
||
<DragDropOverlay config={getDragDropConfig()}>
|
||
<div className={styles.pageContainer}>
|
||
<div className={styles.pageCard}>
|
||
{/* Page Header */}
|
||
<div className={styles.pageHeader}>
|
||
<div>
|
||
<h1 className={styles.pageTitle}>{resolveLanguageText(pageData.title, t)}</h1>
|
||
{pageData.subtitle && (
|
||
<p className={styles.pageSubtitle}>{resolveLanguageText(pageData.subtitle, t)}</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Header Buttons */}
|
||
{pageData.headerButtons && pageData.headerButtons.length > 0 && (
|
||
<div className={styles.headerButtons}>
|
||
{/* Workflow Status - Left of workflow selector */}
|
||
{hookData && pageData.headerButtons.some(btn => btn.id === 'workflow-selector') && (
|
||
<div className={styles.workflowStatusHeader}>
|
||
<WorkflowStatus
|
||
logs={Array.isArray(hookData?.logs) ? hookData.logs : []}
|
||
workflowStatus={hookData?.workflowStatus}
|
||
currentRound={hookData?.currentRound || hookData?.workflowData?.currentRound}
|
||
isRunning={hookData?.isRunning || false}
|
||
latestStats={hookData?.latestStats || null}
|
||
/>
|
||
</div>
|
||
)}
|
||
{pageData.headerButtons.filter((button) => {
|
||
// Filter header buttons based on RBAC permissions
|
||
// Check DATA permissions for create buttons
|
||
if (button.id.includes('create') || button.id.includes('add') || button.id.includes('new')) {
|
||
const permissions = (hookData as any)?.permissions;
|
||
if (!permissions) {
|
||
// If permissions not loaded yet, show button (will be filtered when permissions load)
|
||
return true;
|
||
}
|
||
|
||
const hasCreate = permissions.create !== 'n' && permissions.view;
|
||
|
||
// Log permission check for debugging
|
||
if (import.meta.env.DEV) {
|
||
console.log(`🔐 Header button permission check (${button.id}):`, {
|
||
operation: 'create',
|
||
hasPermission: hasCreate,
|
||
permissionValue: permissions.create,
|
||
view: permissions.view,
|
||
fullPermissions: permissions
|
||
});
|
||
}
|
||
|
||
return hasCreate;
|
||
}
|
||
|
||
// For other buttons, show them (they may have their own permission checks)
|
||
return true;
|
||
}).map((button) => {
|
||
// Check if this is a dropdown button
|
||
if (button.dropdownConfig) {
|
||
const dropdownConfig = button.dropdownConfig;
|
||
|
||
// Get dropdown data from hookData if dataSource is specified
|
||
let items: DropdownSelectItem[] = [];
|
||
let selectedItemId: string | number | null = null;
|
||
let onSelectHandler: (item: DropdownSelectItem | null) => void | Promise<void> = () => {};
|
||
|
||
if (dropdownConfig.dataSource && hookData) {
|
||
// Get items from hookData
|
||
if (dropdownConfig.dataSource.itemsProperty) {
|
||
const hookItems = (hookData as any)[dropdownConfig.dataSource.itemsProperty];
|
||
if (Array.isArray(hookItems)) {
|
||
items = hookItems.map((item: any) => ({
|
||
id: item.id,
|
||
label: typeof item.label === 'string' ? item.label : resolveLanguageText(item.label, t),
|
||
value: item.value || item,
|
||
metadata: item.metadata
|
||
}));
|
||
}
|
||
}
|
||
|
||
// Get selectedItemId from hookData
|
||
if (dropdownConfig.dataSource.selectedIdProperty) {
|
||
selectedItemId = (hookData as any)[dropdownConfig.dataSource.selectedIdProperty] || null;
|
||
}
|
||
|
||
// Get onSelect handler from hookData
|
||
if (dropdownConfig.dataSource.onSelectMethod) {
|
||
const hookOnSelect = (hookData as any)[dropdownConfig.dataSource.onSelectMethod!];
|
||
if (typeof hookOnSelect === 'function') {
|
||
onSelectHandler = hookOnSelect;
|
||
}
|
||
}
|
||
} else {
|
||
// Use dropdownConfig directly
|
||
items = dropdownConfig.items.map(item => ({
|
||
id: item.id,
|
||
label: typeof item.label === 'string' ? item.label : resolveLanguageText(item.label, t),
|
||
value: item.value,
|
||
metadata: item.metadata
|
||
}));
|
||
selectedItemId = dropdownConfig.selectedItemId ?? null;
|
||
onSelectHandler = (item: DropdownSelectItem | null) => {
|
||
dropdownConfig.onSelect(item, hookData);
|
||
};
|
||
}
|
||
|
||
// Check if loading state is available from hookData
|
||
// Use generic loading property or check for specific loading property from dropdownConfig
|
||
const isLoading = dropdownConfig.dataSource?.loadingProperty
|
||
? (hookData as any)?.[dropdownConfig.dataSource.loadingProperty] || false
|
||
: hookData?.loading || false;
|
||
|
||
return (
|
||
<DropdownSelect
|
||
key={button.id}
|
||
items={items}
|
||
selectedItemId={selectedItemId}
|
||
onSelect={onSelectHandler}
|
||
placeholder={resolveLanguageText(dropdownConfig.placeholder || button.label, t)}
|
||
emptyMessage={resolveLanguageText(dropdownConfig.emptyMessage || 'No items available', t)}
|
||
headerText={dropdownConfig.headerText ? resolveLanguageText(dropdownConfig.headerText, t) : undefined}
|
||
variant={button.variant || 'primary'}
|
||
size={button.size || 'md'}
|
||
icon={button.icon}
|
||
disabled={button.disabled || isLoading}
|
||
loading={isLoading}
|
||
/>
|
||
);
|
||
}
|
||
|
||
// Check if this is an upload button (has handleUpload in hookData)
|
||
const handleUpload = (hookData as any)?.handleUpload;
|
||
if (import.meta.env.DEV && button.id === 'upload-file') {
|
||
console.log('🔍 Upload button check:', {
|
||
buttonId: button.id,
|
||
hasHandleUpload: !!handleUpload,
|
||
hookDataKeys: hookData ? Object.keys(hookData) : 'no hookData',
|
||
handleUploadType: typeof handleUpload
|
||
});
|
||
}
|
||
if (handleUpload && button.id === 'upload-file') {
|
||
// Evaluate disabled function if it's a function
|
||
let isDisabled = false;
|
||
if (button.disabled !== undefined) {
|
||
if (typeof button.disabled === 'function') {
|
||
try {
|
||
const disabledResult = (button.disabled as any)(hookData);
|
||
if (typeof disabledResult === 'object' && disabledResult !== null && 'disabled' in disabledResult) {
|
||
isDisabled = disabledResult.disabled;
|
||
} else if (typeof disabledResult === 'boolean') {
|
||
isDisabled = disabledResult;
|
||
}
|
||
} catch (error) {
|
||
console.error(`Error evaluating disabled function for button ${button.id}:`, error);
|
||
isDisabled = false;
|
||
}
|
||
} else if (typeof button.disabled === 'boolean') {
|
||
isDisabled = button.disabled;
|
||
}
|
||
}
|
||
|
||
return (
|
||
<UploadButton
|
||
key={button.id}
|
||
onUpload={handleUpload}
|
||
accept="*/*"
|
||
multiple={false}
|
||
variant={button.variant || 'primary'}
|
||
size={button.size || 'md'}
|
||
icon={button.icon}
|
||
disabled={isDisabled}
|
||
>
|
||
{resolveLanguageText(button.label, t)}
|
||
</UploadButton>
|
||
);
|
||
}
|
||
|
||
// Check if this button has a formConfig (create button)
|
||
if (button.formConfig && hookData) {
|
||
const createOperation = button.formConfig.createOperationName
|
||
? (hookData as any)[button.formConfig.createOperationName]
|
||
: null;
|
||
|
||
if (createOperation) {
|
||
// Use generateCreateFieldsFromAttributes from backend if available, otherwise fall back to generateEditFieldsFromAttributes
|
||
const hookDataAny = hookData as any;
|
||
|
||
// Prefer generateCreateFieldsFromAttributes for create forms
|
||
const generateFieldsFunction = hookDataAny.generateCreateFieldsFromAttributes || hookDataAny.generateEditFieldsFromAttributes;
|
||
|
||
if (!generateFieldsFunction || typeof generateFieldsFunction !== 'function') {
|
||
console.error('Create button requires generateCreateFieldsFromAttributes or generateEditFieldsFromAttributes function in hookData');
|
||
return null;
|
||
}
|
||
|
||
// Create a wrapper for onCreate that ensures attributes are loaded (define before use)
|
||
const wrappedCreateOperation = async (formData: any) => {
|
||
// Debug: Log form data being submitted
|
||
console.warn('🔧 wrappedCreateOperation - formData:', formData);
|
||
|
||
// Ensure attributes are loaded before creating (if function exists)
|
||
if (hookDataAny.ensureAttributesLoaded && typeof hookDataAny.ensureAttributesLoaded === 'function') {
|
||
await hookDataAny.ensureAttributesLoaded();
|
||
}
|
||
return await createOperation(formData);
|
||
};
|
||
|
||
// Prefer custom formConfig.fields if defined, otherwise use dynamic fields from backend attributes
|
||
const customFields = button.formConfig.fields;
|
||
const generatedFields = customFields && customFields.length > 0
|
||
? customFields
|
||
: generateFieldsFunction();
|
||
|
||
// Debug: Log which function is used and what fields are generated
|
||
console.log('🔧 CreateButton fields generation:', {
|
||
hasCustomFields: !!customFields && customFields.length > 0,
|
||
hasCreateFields: !!hookDataAny.generateCreateFieldsFromAttributes,
|
||
hasEditFields: !!hookDataAny.generateEditFieldsFromAttributes,
|
||
generatedFieldsCount: generatedFields?.length || 0,
|
||
generatedFieldKeys: generatedFields?.map((f: any) => f.key) || []
|
||
});
|
||
|
||
// Check if attributes are still loading
|
||
const attributes = hookDataAny.attributes;
|
||
const isLoadingAttributes = hookDataAny.loading || (attributes === undefined);
|
||
|
||
// If attributes are loading, show button but disable it
|
||
// If attributes loaded but empty, still show button (might be a backend issue)
|
||
// Only hide if we're sure attributes won't load (attributes is null/empty and not loading)
|
||
if (!generatedFields || generatedFields.length === 0) {
|
||
// If attributes are still loading, show button disabled
|
||
if (isLoadingAttributes) {
|
||
return (
|
||
<CreateButton
|
||
key={button.id}
|
||
onCreate={wrappedCreateOperation}
|
||
fields={[]}
|
||
popupTitle={resolveLanguageText(button.formConfig.popupTitle || 'Create New Item', t)}
|
||
popupSize={button.formConfig.popupSize || 'medium'}
|
||
variant={button.variant || 'primary'}
|
||
size={button.size || 'md'}
|
||
icon={button.icon}
|
||
disabled={true}
|
||
multiStep={button.formConfig.multiStep || false}
|
||
onSuccess={() => {
|
||
if (hookData.refetch) {
|
||
hookData.refetch();
|
||
}
|
||
}}
|
||
>
|
||
{resolveLanguageText(button.label, t)}
|
||
</CreateButton>
|
||
);
|
||
}
|
||
|
||
// Attributes loaded but no fields - log warning but still show button disabled
|
||
console.warn('No fields generated from backend attributes. Button will be disabled.');
|
||
return (
|
||
<CreateButton
|
||
key={button.id}
|
||
onCreate={wrappedCreateOperation}
|
||
fields={[]}
|
||
popupTitle={resolveLanguageText(button.formConfig.popupTitle || 'Create New Item', t)}
|
||
popupSize={button.formConfig.popupSize || 'medium'}
|
||
variant={button.variant || 'primary'}
|
||
size={button.size || 'md'}
|
||
icon={button.icon}
|
||
disabled={true}
|
||
multiStep={button.formConfig.multiStep || false}
|
||
onSuccess={() => {
|
||
if (hookData.refetch) {
|
||
hookData.refetch();
|
||
}
|
||
}}
|
||
>
|
||
{resolveLanguageText(button.label, t)}
|
||
</CreateButton>
|
||
);
|
||
}
|
||
|
||
// Resolve language text for generated fields
|
||
const fieldsToUse = generatedFields.map((field: any) => ({
|
||
...field,
|
||
label: resolveLanguageText(field.label, t),
|
||
placeholder: field.placeholder ? resolveLanguageText(field.placeholder, t) : undefined
|
||
}));
|
||
|
||
// Evaluate disabled property if it's a function
|
||
const isDisabled = typeof button.disabled === 'function'
|
||
? button.disabled(hookData)
|
||
: button.disabled ?? false;
|
||
const disabledValue = typeof isDisabled === 'object' && isDisabled !== null && 'disabled' in isDisabled
|
||
? isDisabled.disabled
|
||
: Boolean(isDisabled);
|
||
|
||
return (
|
||
<CreateButton
|
||
key={button.id}
|
||
onCreate={wrappedCreateOperation}
|
||
fields={fieldsToUse}
|
||
popupTitle={resolveLanguageText(button.formConfig.popupTitle || 'Create New Item', t)}
|
||
popupSize={button.formConfig.popupSize || 'medium'}
|
||
variant={button.variant || 'primary'}
|
||
size={button.size || 'md'}
|
||
icon={button.icon}
|
||
disabled={disabledValue}
|
||
multiStep={button.formConfig.multiStep || false}
|
||
onSuccess={() => {
|
||
// Refetch data after successful creation
|
||
if (hookData.refetch) {
|
||
hookData.refetch();
|
||
}
|
||
}}
|
||
>
|
||
{resolveLanguageText(button.label, t)}
|
||
</CreateButton>
|
||
);
|
||
}
|
||
}
|
||
|
||
// Regular button
|
||
// Evaluate disabled function if it's a function
|
||
let isDisabled = false;
|
||
if (button.disabled !== undefined) {
|
||
if (typeof button.disabled === 'function') {
|
||
try {
|
||
const disabledResult = (button.disabled as any)(hookData);
|
||
if (typeof disabledResult === 'object' && disabledResult !== null && 'disabled' in disabledResult) {
|
||
isDisabled = disabledResult.disabled;
|
||
} else if (typeof disabledResult === 'boolean') {
|
||
isDisabled = disabledResult;
|
||
}
|
||
} catch (error) {
|
||
console.error(`Error evaluating disabled function for button ${button.id}:`, error);
|
||
isDisabled = false;
|
||
}
|
||
} else if (typeof button.disabled === 'boolean') {
|
||
isDisabled = button.disabled;
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Button
|
||
key={button.id}
|
||
variant={button.variant || 'primary'}
|
||
size={button.size || 'md'}
|
||
icon={button.icon}
|
||
disabled={isDisabled}
|
||
onClick={() => handleButtonClick(button)}
|
||
>
|
||
{resolveLanguageText(button.label, t)}
|
||
</Button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className={styles.horizontalDivider}></div>
|
||
|
||
{/* Page Content */}
|
||
<div className={styles.contentArea}>
|
||
<div className={styles.scrollableContent}>
|
||
<ContentRenderer
|
||
contents={pageData.content || []}
|
||
renderContent={renderContent}
|
||
hasPermission={hasPermission}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</DragDropOverlay>
|
||
);
|
||
};
|
||
|
||
export default PageRenderer;
|
||
|