frontend_nyla/src/core/PageManager/PageRenderer.tsx
2026-01-21 10:59:34 +01:00

2366 lines
135 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;