1749 lines
100 KiB
TypeScript
1749 lines
100 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 { 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 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 dashboard layout pattern: messages, log, inputForm
|
|
const hasMessages = visibleContents.some(c => c.type === 'messages');
|
|
const hasLog = visibleContents.some(c => c.type === 'log');
|
|
const hasInputForm = visibleContents.some(c => c.type === 'inputForm');
|
|
const isDashboardLayout = hasMessages && hasLog && hasInputForm;
|
|
|
|
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 }) => {
|
|
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) => {
|
|
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
|
|
disabledFn = () => action.disabled as boolean | { disabled: boolean; message?: string };
|
|
}
|
|
} 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
|
|
const hasFileManagement = !!(hookData.handleFileUpload && hookData.workflowFiles !== 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: hookData.handleFileRemove,
|
|
showOnlyForPending: true,
|
|
idField: 'fileId',
|
|
loadingStateName: 'removingItems'
|
|
},
|
|
{
|
|
type: 'delete',
|
|
operationName: 'handleDelete',
|
|
loadingStateName: 'deletingItems',
|
|
idField: 'fileId'
|
|
}
|
|
]}
|
|
onDelete={hookData.handleFileDelete}
|
|
onRemove={hookData.handleFileRemove}
|
|
onAttach={hookData.handleFileAttach} // Allow attaching files for next message
|
|
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}
|
|
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>
|
|
);
|
|
}
|
|
|
|
// 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={hookData?.handleFileDelete}
|
|
onFileRemove={hookData?.handleFileRemove}
|
|
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={hookData?.handleFileDelete}
|
|
onFileRemove={hookData?.handleFileRemove}
|
|
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={hookData?.handleFileDelete}
|
|
onFileRemove={hookData?.handleFileRemove}
|
|
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>
|
|
);
|
|
}
|
|
|
|
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, integrate them
|
|
if (hookData?.handleUpload) {
|
|
return {
|
|
...pageData.dragDropConfig,
|
|
onDrop: async (files: File[]) => {
|
|
|
|
try {
|
|
// Process each file through the hook's handleUpload function
|
|
for (const file of files) {
|
|
if (hookData.handleUpload) {
|
|
|
|
await hookData.handleUpload(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);
|
|
};
|
|
|
|
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={button.disabled}
|
|
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;
|
|
|