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) => void; }> = ({ value, onChange, placeholder, size, disabled, className, onKeyDown }) => { const wrapperRef = useRef(null); const textareaRef = useRef(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 (
); }; // 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) => ( {renderContent(nestedContent)} ))} ) })); return ( ); }; // 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; }> = ({ contents, renderContent, hasPermission }) => { const [visibleContents, setVisibleContents] = useState([]); 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 ( <>
{/* Left column: Chat History */} {chatHistoryContent && (
{renderContent(chatHistoryContent)}
)} {/* Right column: Messages and Input Form */}
{messagesContent && (
{renderContent(messagesContent)}
)} {inputFormContent && (
{renderContent(inputFormContent)}
)}
{/* Render any other content sections */} {otherContents.map((content, index) => ( {renderContent(content)} ))} ); } 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 ( <>
{/* Top row: Messages | Log */}
{messagesContent && (
{renderContent(messagesContent)}
)} {logContent && (
{renderContent(logContent)}
)}
{/* Bottom row: Input Form (which includes connected files) */} {inputFormContent && (
{renderContent(inputFormContent)}
)}
{/* Render any other content sections */} {otherContents.map((content, index) => ( {renderContent(content)} ))} ); } return ( <> {visibleContents.map((content, index) => ( {renderContent(content)} ))} ); }; // 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 = ({ 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(); 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; loadingBySection: Record; errorsBySection: Record; onSave?: (sectionId: string, data: any) => Promise; 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>({}); const [sectionSaveLoading, setSectionSaveLoading] = useState>({}); const [sectionSaveMessages, setSectionSaveMessages] = useState>({}); const formRefs = useRef>({}); // Initialize form data from formData when it changes useEffect(() => { const newFormData: Record = {}; 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 (
{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 ( {section.renderAlternative(formData, t, resolveLanguageText)} ); } 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 = {}; allFields.forEach(field => { const value = currentSectionFormData[field.dataKey] !== undefined ? currentSectionFormData[field.dataKey] : getNestedValue(formData, field.dataKey); if (value !== undefined) { formDataForSection[field.dataKey] = value; } }); return (
{/* Section Header */}
{section.icon && ( )}

{resolveLanguageText(section.title, t)}

{section.description && (

{resolveLanguageText(section.description, t)}

)}
{/* Loading State */} {isLoading && (
{t('common.loading')}
)} {/* Error State */} {error && !isLoading && (
{error}
)} {/* FormGeneratorForm */} {!isLoading && !error && attributes.length > 0 && ( <>
{ if (el) { const form = el.querySelector('form'); if (form) { formRefs.current[section.id] = form; } } }}> { // 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="" />
{/* Save Message */} {saveMessage && (
{saveMessage.text}
)} {/* Save Button */}
)}
); })}
); }; // 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) | 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) 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) const result = handler(file); if (result instanceof Promise) await result; } : undefined; const wrapFileRemove: ((file: WorkflowFile) => Promise) | 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) 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) 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 (

{resolveLanguageText(content.content, t)}

); case 'list': return (
{content.content && (

{resolveLanguageText(content.content, t)}

)}
    {content.items?.map((item, index) => (
  • {resolveLanguageText(item, t)}
  • ))}
); case 'code': return (
                        
                            {resolveLanguageText(content.content, t)}
                        
                    
); case 'divider': return
; case 'custom': if (content.customComponent) { const CustomComponent = content.customComponent; return ; } 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 (

Error loading data: {currentTableHookData.error}

{currentTableHookData.refetch && ( )}
); } // 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 (
{currentTableHookData.isRefetching && (
Refreshing...
)} { // 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} />
); } 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) => { 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 (
{/* Top row: Dropdown selectors and buttons */}
{showPromptSelector && (
{})} 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" />
)} {showWorkflowModeSelector && (
{})} 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" />
)}
{/* Bottom row: Input text area */}
{/* Right column: Connected Files List (spans both rows) */}
{ 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" />
{/* File Attachment Popup */} {hookData.isFileAttachmentPopupOpen && ( hookData.setIsFileAttachmentPopupOpen?.(false)} size="large" >
{/* Upload Button Section */}
{ const handler = hookData.handleFileUploadAndAttach || hookData.handleFileUpload; if (handler) { // Handler returns Promise<{ success, data }>, but UploadButton expects Promise await handler(file); } } : async () => {}} disabled={hookData.isSubmitting || false} loading={hookData.uploadingFile || false} variant="primary" size="md" multiple={true} > Upload New File
{/* Files List */}
{hookData.allUserFiles && hookData.allUserFiles.length > 0 ? (
{hookData.allUserFiles.map((file: any) => { const isAttached = hookData.pendingFiles?.some((pf: any) => pf.fileId === file.id); return (
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'; } }} >
📎
{file.file_name || file.fileName || 'Unknown File'}
{(() => { 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; })()}
{isAttached ? 'Attached' : 'Attach'}
); })}
) : (
No files uploaded yet. Click "Upload New File" to add files.
)}
)}
); } // Chatbot file upload layout (simpler than dashboard) if (hasChatbotFileUpload) { const uploadedFiles = hookData.uploadedFiles || []; return (
{/* Input and buttons row */}
{ 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
{/* Pending files display */} {uploadedFiles.length > 0 && (
{uploadedFiles.map((file: { fileId: string; fileName: string }) => (
📎 {file.fileName}
))}
)} {/* Upload error display */} {hookData.uploadError && (
{hookData.uploadError}
)}
); } // Default layout without files (backward compatible) return (
{/* Dropdown selectors row */} {(showPromptSelector || showWorkflowModeSelector) && (
{showPromptSelector && ( {})} 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 && ( {})} 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} /> )}
)} {/* Input and button row */}
); } 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 ( ); } else { return ( ); } } : undefined; return (
); 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 ; } return null; case 'log': { const logConfig = content.logConfig || {}; const dashboardTree = hookData?.dashboardTree; const onToggleOperationExpanded = hookData?.onToggleOperationExpanded; const getChildOperations = hookData?.getChildOperations; return (
); } case 'tabs': { return ( ); } 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 (
{columnsConfig.columns.map((column, colIndex) => (
{column.content.map((nestedContent, index) => ( {renderContent(nestedContent)} ))}
))}
); } 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 (

{t('chat_history.error_loading', 'Error loading workflows:')} {threadsError}

{(hookData as any)?.loadThreads && ( )}
); } return (
{ // 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 ? ( ) : undefined} />
); } 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 (
{/* Page Header */}

{resolveLanguageText(pageData.title, t)}

{pageData.subtitle && (

{resolveLanguageText(pageData.subtitle, t)}

)}
{/* Header Buttons */} {pageData.headerButtons && pageData.headerButtons.length > 0 && (
{/* Workflow Status - Left of workflow selector */} {hookData && pageData.headerButtons.some(btn => btn.id === 'workflow-selector') && (
)} {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 = () => {}; 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 ( ); } // 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 ( {resolveLanguageText(button.label, t)} ); } // 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 ( { if (hookData.refetch) { hookData.refetch(); } }} > {resolveLanguageText(button.label, t)} ); } // 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 ( { if (hookData.refetch) { hookData.refetch(); } }} > {resolveLanguageText(button.label, t)} ); } // 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 ( { // Refetch data after successful creation if (hookData.refetch) { hookData.refetch(); } }} > {resolveLanguageText(button.label, t)} ); } } // 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 ( ); })}
)}
{/* Page Content */}
); }; export default PageRenderer;