2144 lines
122 KiB
TypeScript
2144 lines
122 KiB
TypeScript
import React, { useState, useEffect, useRef } from 'react';
|
||
import { GenericPageData, PageButton, PageContent, resolveLanguageText, SettingsFieldConfig, SettingsSectionConfig } from './pageInterface';
|
||
import { FormGenerator } from '../../components/FormGenerator';
|
||
import { FormGeneratorForm, AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
||
import { Button, UploadButton, CreateButton, TextField, Messages, ChatMessage, LogMessage, DropdownSelect, Log, WorkflowStatus } from '../../components/UiComponents';
|
||
import { FormGeneratorList, FieldConfig } from '../../components/FormGenerator';
|
||
import { LuPlus } from 'react-icons/lu';
|
||
import type { ChatbotWorkflow } from '../../api/chatbotApi';
|
||
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 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 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>
|
||
))}
|
||
</>
|
||
);
|
||
};
|
||
|
||
const PageRenderer: React.FC<PageRendererProps> = ({
|
||
pageData,
|
||
onButtonClick
|
||
}) => {
|
||
// Get translation function from language context
|
||
const { t } = useLanguage();
|
||
const { hasPermission } = usePermissions();
|
||
|
||
// Call the hook at the top level to ensure it persists across renders
|
||
// This is CRITICAL - hooks must be called in the same order on every render
|
||
const tableContent = pageData.content?.find(content => content.type === 'table');
|
||
const inputFormContent = pageData.content?.find(content => content.type === 'inputForm');
|
||
const settingsContent = pageData.content?.find(content => content.type === 'settings');
|
||
const hookFactory = tableContent?.tableConfig?.hookFactory
|
||
|| inputFormContent?.inputFormConfig?.hookFactory
|
||
|| settingsContent?.settingsConfig?.hookFactory;
|
||
|
||
// Create a stable hook instance using React.useMemo
|
||
// This ensures the same hook instance is used across re-renders
|
||
const useTableData = React.useMemo(() => {
|
||
if (hookFactory) {
|
||
return hookFactory();
|
||
}
|
||
return null;
|
||
}, [hookFactory]);
|
||
|
||
// Call the hook to get the current data
|
||
// This will be called on every render, but it's the SAME hook instance
|
||
const hookData = useTableData ? useTableData() : null;
|
||
|
||
|
||
|
||
// 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':
|
||
if (content.tableConfig && hookData) {
|
||
const { columns: configColumns, actionButtons, ...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 = hookData.loading && hookData.data.length === 0;
|
||
|
||
// Show error state if there's an error
|
||
if (hookData.error) {
|
||
return (
|
||
<div key={content.id} className={styles.tableContainer}>
|
||
<div className={styles.errorState}>
|
||
<p>Error loading data: {hookData.error}</p>
|
||
{hookData.refetch && (
|
||
<button onClick={hookData.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 = hookData.columns && hookData.columns.length > 0 ? hookData.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
|
||
let requiredPermission: 'read' | 'create' | 'update' | 'delete' | null = null;
|
||
if (action.type === 'view' || action.type === 'play') {
|
||
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,
|
||
// Navigation and behavior (for play button)
|
||
navigateTo: action.navigateTo,
|
||
mode: action.mode
|
||
};
|
||
}) || [];
|
||
|
||
// Debug logging for table rendering
|
||
if (import.meta.env.DEV) {
|
||
console.log('🔍 Rendering FormGenerator:', {
|
||
dataLength: hookData.data?.length || 0,
|
||
columnsCount: resolvedColumns?.length || 0,
|
||
loading: showLoadingSpinner,
|
||
hasError: !!hookData.error,
|
||
data: hookData.data,
|
||
willAutoDetect: !resolvedColumns
|
||
});
|
||
}
|
||
|
||
return (
|
||
<div key={content.id} className={styles.tableContainer}>
|
||
{hookData.isRefetching && (
|
||
<div className={styles.refetchingIndicator}>
|
||
Refreshing...
|
||
</div>
|
||
)}
|
||
<FormGenerator
|
||
data={hookData.data || []}
|
||
columns={resolvedColumns}
|
||
loading={showLoadingSpinner}
|
||
actionButtons={formGeneratorActions}
|
||
hookData={hookData}
|
||
onDelete={hookData.onDelete}
|
||
onDeleteMultiple={hookData.onDeleteMultiple}
|
||
{...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');
|
||
const buttonDisabled = hookData.isSubmitting || (!isRunning && !hookData.inputValue?.trim());
|
||
|
||
// 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)
|
||
const hasChatbotFileUpload = !!(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) => {
|
||
const result = 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 'chatHistory': {
|
||
const chatHistoryConfig = content.chatHistoryConfig || {};
|
||
const threads: ChatbotWorkflow[] = (hookData as any)?.threads || [];
|
||
const selectedThreadId = (hookData as any)?.selectedThreadId || null;
|
||
const selectThread = (hookData as any)?.selectThread;
|
||
const startNewChat = (hookData as any)?.startNewChat;
|
||
const threadsLoading = (hookData as any)?.threadsLoading || false;
|
||
const threadsError = (hookData as any)?.threadsError || null;
|
||
|
||
if (!selectThread) {
|
||
console.warn('ChatHistoryList requires selectThread method from hookData');
|
||
return null;
|
||
}
|
||
|
||
// Format date function for relative time display
|
||
const formatDate = (timestamp?: number): string => {
|
||
if (!timestamp) return '';
|
||
|
||
const date = new Date(timestamp);
|
||
const now = new Date();
|
||
const diffMs = now.getTime() - date.getTime();
|
||
const diffMins = Math.floor(diffMs / 60000);
|
||
const diffHours = Math.floor(diffMs / 3600000);
|
||
const diffDays = Math.floor(diffMs / 86400000);
|
||
|
||
if (diffMins < 1) return 'Just now';
|
||
if (diffMins < 60) return `${diffMins}m ago`;
|
||
if (diffHours < 24) return `${diffHours}h ago`;
|
||
if (diffDays < 7) return `${diffDays}d ago`;
|
||
|
||
// Format as date
|
||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||
};
|
||
|
||
// Get thread preview function
|
||
const getThreadPreview = (thread: ChatbotWorkflow): string => {
|
||
if (thread.name) return thread.name;
|
||
return `Chat ${thread.id.slice(0, 8)}...`;
|
||
};
|
||
|
||
// Field configuration for ChatbotWorkflow
|
||
// First field: Thread preview (uses name field but formats it)
|
||
// Second field: Date (uses lastActivity field but formats it)
|
||
// Third field: Status
|
||
const fields: FieldConfig[] = [
|
||
{
|
||
key: 'name',
|
||
label: 'Thread',
|
||
type: 'text',
|
||
formatter: (value: any, row: ChatbotWorkflow) => getThreadPreview(row)
|
||
},
|
||
{
|
||
key: 'lastActivity',
|
||
label: 'Date',
|
||
type: 'timestamp',
|
||
formatter: (value: any, row: ChatbotWorkflow) => {
|
||
const timestamp = row.lastActivity || row.startedAt;
|
||
return formatDate(timestamp);
|
||
}
|
||
},
|
||
{
|
||
key: 'status',
|
||
label: 'Status',
|
||
type: 'text',
|
||
formatter: (value: any, row: ChatbotWorkflow) => row.status || ''
|
||
}
|
||
];
|
||
|
||
// Handle error state
|
||
if (threadsError) {
|
||
return (
|
||
<div key={content.id} className={styles.chatHistorySection}>
|
||
<div className={styles.chatHistoryHeader}>
|
||
<h3 className={styles.chatHistoryTitle}>Chat History</h3>
|
||
{startNewChat && (
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
icon={LuPlus}
|
||
onClick={startNewChat}
|
||
title="New Chat"
|
||
/>
|
||
)}
|
||
</div>
|
||
<div className={styles.chatHistoryError}>
|
||
{threadsError}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const emptyMessage = chatHistoryConfig.emptyMessage
|
||
? resolveLanguageText(chatHistoryConfig.emptyMessage, t)
|
||
: 'No chat history yet. Start a conversation to see it here.';
|
||
|
||
return (
|
||
<div key={content.id} className={styles.chatHistorySection}>
|
||
<FormGeneratorList<ChatbotWorkflow>
|
||
data={threads}
|
||
fields={fields}
|
||
title="Chat History"
|
||
searchable={false}
|
||
filterable={false}
|
||
sortable={false}
|
||
pagination={false}
|
||
selectable={true}
|
||
loading={threadsLoading}
|
||
onItemClick={(row) => selectThread(row.id)}
|
||
onItemSelect={(selectedRows) => {
|
||
// Handle multiselect - select first item when multiple selected
|
||
if (selectedRows.length > 0 && selectedRows[0]) {
|
||
selectThread(selectedRows[0].id);
|
||
}
|
||
}}
|
||
onDeleteMultiple={(selectedRows) => {
|
||
// Handle bulk delete
|
||
selectedRows.forEach(row => {
|
||
// Delete logic handled by action button
|
||
});
|
||
}}
|
||
getItemDataAttributes={(row) => ({
|
||
'selected-thread-id': selectedThreadId === row.id ? 'true' : 'false',
|
||
'thread-id': row.id
|
||
})}
|
||
actionButtons={[
|
||
{
|
||
type: 'delete',
|
||
idField: 'id',
|
||
operationName: 'handleDelete',
|
||
loadingStateName: 'deletingItems',
|
||
onAction: async (row) => {
|
||
// If deleted thread was selected, start new chat
|
||
if (selectedThreadId === row.id && startNewChat) {
|
||
startNewChat();
|
||
}
|
||
}
|
||
}
|
||
]}
|
||
hookData={hookData}
|
||
className={styles.chatHistoryList}
|
||
emptyMessage={emptyMessage}
|
||
headerButton={startNewChat ? (
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
icon={LuPlus}
|
||
onClick={startNewChat}
|
||
title="New Chat"
|
||
className={styles.chatHistoryNewButton}
|
||
/>
|
||
) : 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 generateEditFieldsFromAttributes from backend (required, no fallback)
|
||
const hookDataAny = hookData as any;
|
||
|
||
if (!hookDataAny.generateEditFieldsFromAttributes || typeof hookDataAny.generateEditFieldsFromAttributes !== 'function') {
|
||
console.error('Create button requires generateEditFieldsFromAttributes function in hookData');
|
||
return null;
|
||
}
|
||
|
||
// Use dynamic fields from backend attributes
|
||
const generatedFields = hookDataAny.generateEditFieldsFromAttributes();
|
||
if (!generatedFields || generatedFields.length === 0) {
|
||
console.error('No fields generated from backend attributes');
|
||
return null;
|
||
}
|
||
|
||
// 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
|
||
}));
|
||
|
||
// Create a wrapper for onCreate that ensures attributes are loaded
|
||
const wrappedCreateOperation = async (formData: any) => {
|
||
// Ensure attributes are loaded before creating (if function exists)
|
||
if (hookDataAny.ensureAttributesLoaded && typeof hookDataAny.ensureAttributesLoaded === 'function') {
|
||
await hookDataAny.ensureAttributesLoaded();
|
||
}
|
||
return await createOperation(formData);
|
||
};
|
||
|
||
// 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}
|
||
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>
|
||
|
||
{/* Message Overlay Component */}
|
||
{hookData?.MessageOverlayComponent && <hookData.MessageOverlayComponent />}
|
||
</div>
|
||
</DragDropOverlay>
|
||
);
|
||
};
|
||
|
||
export default PageRenderer;
|
||
|