ui-nyla/src/core/PageManager/PageRenderer.tsx

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;