mirror of
https://github.com/johndoe6345789/postgres.git
synced 2026-04-24 13:55:00 +00:00
feat: implement Phase 1 - core infrastructure for JSON-driven architecture
Co-authored-by: johndoe6345789 <224850594+johndoe6345789@users.noreply.github.com>
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getComponentTree, getDataTypes, getFeatureById } from '@/utils/featureConfig';
|
||||
import ComponentTreeRenderer from '@/utils/ComponentTreeRenderer';
|
||||
import ComponentTreeRenderer from '@/utils/componentTreeRenderer';
|
||||
import ColumnDialog from './ColumnDialog';
|
||||
|
||||
type ColumnManagerTabProps = {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { getComponentTree, getDataTypes, getFeatureById } from '@/utils/featureConfig';
|
||||
import ComponentTreeRenderer from '@/utils/ComponentTreeRenderer';
|
||||
import ComponentTreeRenderer from '@/utils/componentTreeRenderer';
|
||||
import CreateTableDialog from './CreateTableDialog';
|
||||
import DropTableDialog from './DropTableDialog';
|
||||
|
||||
|
||||
253
src/components/atoms/index.tsx
Normal file
253
src/components/atoms/index.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Atomic Components Library
|
||||
* Minimal, reusable components with no business logic
|
||||
*/
|
||||
|
||||
import type { AlertProps, ButtonProps, CardProps, CheckboxProps, ChipProps, CircularProgressProps, IconButtonProps, PaperProps, SelectProps, TextFieldProps, TypographyProps } from '@mui/material';
|
||||
import {
|
||||
|
||||
Box,
|
||||
|
||||
CircularProgress,
|
||||
|
||||
FormControlLabel,
|
||||
|
||||
MenuItem,
|
||||
Alert as MuiAlert,
|
||||
Button as MuiButton,
|
||||
Card as MuiCard,
|
||||
Checkbox as MuiCheckbox,
|
||||
Chip as MuiChip,
|
||||
IconButton as MuiIconButton,
|
||||
Paper as MuiPaper,
|
||||
Select as MuiSelect,
|
||||
TextField as MuiTextField,
|
||||
Typography as MuiTypography,
|
||||
|
||||
} from '@mui/material';
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Atomic Button - Pure presentation, no logic
|
||||
*/
|
||||
export function Button(props: ButtonProps) {
|
||||
return <MuiButton {...props} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic TextField - Pure presentation, no logic
|
||||
*/
|
||||
export function TextField(props: TextFieldProps) {
|
||||
return <MuiTextField {...props} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic Typography - Pure presentation, no logic
|
||||
*/
|
||||
export function Typography(props: TypographyProps) {
|
||||
return <MuiTypography {...props} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic Select - Pure presentation, no logic
|
||||
*/
|
||||
export function Select(props: SelectProps & { options?: Array<{ value: string; label: string }> }) {
|
||||
const { options = [], ...selectProps } = props;
|
||||
|
||||
return (
|
||||
<MuiSelect {...selectProps}>
|
||||
{options.map(option => (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</MuiSelect>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic Checkbox - Pure presentation, no logic
|
||||
*/
|
||||
export function Checkbox(props: CheckboxProps & { label?: string }) {
|
||||
const { label, ...checkboxProps } = props;
|
||||
|
||||
if (label) {
|
||||
return (
|
||||
<FormControlLabel
|
||||
control={<MuiCheckbox {...checkboxProps} />}
|
||||
label={label}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <MuiCheckbox {...checkboxProps} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic IconButton - Pure presentation, no logic
|
||||
*/
|
||||
export function IconButton(props: IconButtonProps) {
|
||||
return <MuiIconButton {...props} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic Paper - Pure presentation, no logic
|
||||
*/
|
||||
export function Paper(props: PaperProps) {
|
||||
return <MuiPaper {...props} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic Card - Pure presentation, no logic
|
||||
*/
|
||||
export function Card(props: CardProps) {
|
||||
return <MuiCard {...props} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic Chip - Pure presentation, no logic
|
||||
*/
|
||||
export function Chip(props: ChipProps) {
|
||||
return <MuiChip {...props} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic Alert - Pure presentation, no logic
|
||||
*/
|
||||
export function Alert(props: AlertProps) {
|
||||
return <MuiAlert {...props} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic Loading Spinner - Pure presentation, no logic
|
||||
*/
|
||||
export function LoadingSpinner(props: CircularProgressProps) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3, ...props.sx }}>
|
||||
<CircularProgress {...props} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic Container - Simple Box wrapper with common styling
|
||||
*/
|
||||
export function Container({ children, ...props }: React.PropsWithChildren<{ sx?: any }>) {
|
||||
return (
|
||||
<Box sx={{ p: 3, ...props.sx }}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic Stack - Vertical or horizontal flex layout
|
||||
*/
|
||||
export function Stack({
|
||||
children,
|
||||
direction = 'column',
|
||||
spacing = 2,
|
||||
...props
|
||||
}: React.PropsWithChildren<{
|
||||
direction?: 'row' | 'column';
|
||||
spacing?: number;
|
||||
sx?: any;
|
||||
}>) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: direction,
|
||||
gap: spacing,
|
||||
...props.sx,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic Empty State - Shows when there's no data
|
||||
*/
|
||||
export function EmptyState({
|
||||
message = 'No data available',
|
||||
icon,
|
||||
action,
|
||||
}: {
|
||||
message?: string;
|
||||
icon?: React.ReactNode;
|
||||
action?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: 6,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{icon && <Box sx={{ mb: 2, opacity: 0.5 }}>{icon}</Box>}
|
||||
<Typography variant="body1" color="text.secondary" gutterBottom>
|
||||
{message}
|
||||
</Typography>
|
||||
{action && <Box sx={{ mt: 2 }}>{action}</Box>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic Error Display - Shows error messages
|
||||
*/
|
||||
export function ErrorDisplay({
|
||||
error,
|
||||
onRetry,
|
||||
}: {
|
||||
error: string | null;
|
||||
onRetry?: () => void;
|
||||
}) {
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert
|
||||
severity="error"
|
||||
action={
|
||||
onRetry
|
||||
? (
|
||||
<Button size="small" onClick={onRetry}>
|
||||
Retry
|
||||
</Button>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic Success Display - Shows success messages
|
||||
*/
|
||||
export function SuccessDisplay({
|
||||
message,
|
||||
onClose,
|
||||
}: {
|
||||
message: string | null;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert severity="success" onClose={onClose}>
|
||||
{message}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
242
src/components/molecules/DynamicForm.tsx
Normal file
242
src/components/molecules/DynamicForm.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Dynamic Form Renderer
|
||||
* Renders forms based on form schemas from features.json
|
||||
*/
|
||||
|
||||
import type { FormField } from '@/utils/featureConfig';
|
||||
import {
|
||||
Box,
|
||||
Checkbox,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
FormHelperText,
|
||||
Grid,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Select,
|
||||
TextField,
|
||||
} from '@mui/material';
|
||||
import React from 'react';
|
||||
|
||||
type DynamicFormProps = {
|
||||
fields: FormField[];
|
||||
values: Record<string, any>;
|
||||
errors: Record<string, string>;
|
||||
onChange: (fieldName: string, value: any) => void;
|
||||
onBlur?: (fieldName: string) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Render a single form field based on its type
|
||||
*/
|
||||
function renderField(
|
||||
field: FormField,
|
||||
value: any,
|
||||
error: string | undefined,
|
||||
onChange: (value: any) => void,
|
||||
onBlur?: () => void,
|
||||
disabled?: boolean,
|
||||
) {
|
||||
const commonProps = {
|
||||
fullWidth: true,
|
||||
disabled,
|
||||
error: Boolean(error),
|
||||
helperText: error,
|
||||
};
|
||||
|
||||
switch (field.type) {
|
||||
case 'text':
|
||||
case 'email':
|
||||
return (
|
||||
<TextField
|
||||
{...commonProps}
|
||||
type={field.type}
|
||||
label={field.label}
|
||||
placeholder={field.placeholder}
|
||||
value={value || ''}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
required={field.required}
|
||||
inputProps={{
|
||||
minLength: field.minLength,
|
||||
maxLength: field.maxLength,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'number':
|
||||
return (
|
||||
<TextField
|
||||
{...commonProps}
|
||||
type="number"
|
||||
label={field.label}
|
||||
placeholder={field.placeholder}
|
||||
value={value ?? ''}
|
||||
onChange={e => onChange(e.target.value ? Number(e.target.value) : null)}
|
||||
onBlur={onBlur}
|
||||
required={field.required}
|
||||
inputProps={{
|
||||
min: field.min,
|
||||
max: field.max,
|
||||
step: field.step,
|
||||
}}
|
||||
InputProps={{
|
||||
startAdornment: field.prefix,
|
||||
endAdornment: field.suffix,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'textarea':
|
||||
return (
|
||||
<TextField
|
||||
{...commonProps}
|
||||
multiline
|
||||
rows={field.rows || 4}
|
||||
label={field.label}
|
||||
placeholder={field.placeholder}
|
||||
value={value || ''}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
required={field.required}
|
||||
inputProps={{
|
||||
maxLength: field.maxLength,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<FormControl {...commonProps}>
|
||||
<InputLabel required={field.required}>{field.label}</InputLabel>
|
||||
<Select
|
||||
value={value || ''}
|
||||
label={field.label}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
>
|
||||
{field.options?.map(option => (
|
||||
<MenuItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
{error && <FormHelperText error>{error}</FormHelperText>}
|
||||
</FormControl>
|
||||
);
|
||||
|
||||
case 'checkbox':
|
||||
return (
|
||||
<FormControl error={Boolean(error)}>
|
||||
<FormControlLabel
|
||||
control={(
|
||||
<Checkbox
|
||||
checked={Boolean(value)}
|
||||
onChange={e => onChange(e.target.checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
label={field.label}
|
||||
/>
|
||||
{error && <FormHelperText error>{error}</FormHelperText>}
|
||||
</FormControl>
|
||||
);
|
||||
|
||||
case 'date':
|
||||
return (
|
||||
<TextField
|
||||
{...commonProps}
|
||||
type="date"
|
||||
label={field.label}
|
||||
value={value || ''}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
required={field.required}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'datetime':
|
||||
return (
|
||||
<TextField
|
||||
{...commonProps}
|
||||
type="datetime-local"
|
||||
label={field.label}
|
||||
value={value || ''}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
onBlur={onBlur}
|
||||
required={field.required}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
console.warn(`Unknown field type: ${field.type}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamic Form Component
|
||||
* Renders a complete form based on schema from features.json
|
||||
*/
|
||||
export function DynamicForm({
|
||||
fields,
|
||||
values,
|
||||
errors,
|
||||
onChange,
|
||||
onBlur,
|
||||
disabled,
|
||||
}: DynamicFormProps) {
|
||||
return (
|
||||
<Grid container spacing={2}>
|
||||
{fields.map((field) => {
|
||||
// Determine grid size based on field requirements
|
||||
const gridSize = field.type === 'textarea' || field.type === 'checkbox' ? 12 : 6;
|
||||
|
||||
return (
|
||||
<Grid item xs={12} sm={gridSize} key={field.name}>
|
||||
{renderField(
|
||||
field,
|
||||
values[field.name],
|
||||
errors[field.name],
|
||||
value => onChange(field.name, value),
|
||||
onBlur ? () => onBlur(field.name) : undefined,
|
||||
disabled,
|
||||
)}
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Form Section - Groups related fields with a title
|
||||
*/
|
||||
export function FormSection({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Box component="h3" sx={{ m: 0, fontSize: '1.1rem', fontWeight: 500 }}>
|
||||
{title}
|
||||
</Box>
|
||||
{description && (
|
||||
<Box component="p" sx={{ m: 0, mt: 0.5, fontSize: '0.875rem', color: 'text.secondary' }}>
|
||||
{description}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
85
src/hooks/useFeatureActions.ts
Normal file
85
src/hooks/useFeatureActions.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Hook for creating feature actions from features.json configuration
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { createResourceActions } from '@/utils/actionWiring';
|
||||
|
||||
type ActionState = {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
success: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook that creates action handlers based on API endpoints defined in features.json
|
||||
* Provides automatic loading, error, and success state management
|
||||
*/
|
||||
export function useFeatureActions(resourceName: string) {
|
||||
const [state, setState] = useState<ActionState>({
|
||||
loading: false,
|
||||
error: null,
|
||||
success: null,
|
||||
});
|
||||
|
||||
const clearMessages = useCallback(() => {
|
||||
setState(prev => ({ ...prev, error: null, success: null }));
|
||||
}, []);
|
||||
|
||||
const setLoading = useCallback((loading: boolean) => {
|
||||
setState(prev => ({ ...prev, loading }));
|
||||
}, []);
|
||||
|
||||
const setError = useCallback((error: string | null) => {
|
||||
setState(prev => ({ ...prev, error, loading: false }));
|
||||
}, []);
|
||||
|
||||
const setSuccess = useCallback((success: string | null) => {
|
||||
setState(prev => ({ ...prev, success, loading: false }));
|
||||
}, []);
|
||||
|
||||
// Create action handlers with automatic state management
|
||||
const actions = useMemo(() => {
|
||||
return createResourceActions(resourceName, {
|
||||
onSuccess: (actionName, data) => {
|
||||
setSuccess(data.message || `${actionName} completed successfully`);
|
||||
},
|
||||
onError: (actionName, error) => {
|
||||
setError(error.message || `${actionName} failed`);
|
||||
},
|
||||
});
|
||||
}, [resourceName, setSuccess, setError]);
|
||||
|
||||
// Wrap each action with loading state
|
||||
const wrappedActions = useMemo(() => {
|
||||
const wrapped: Record<string, (params?: Record<string, any>) => Promise<any>> = {};
|
||||
|
||||
Object.entries(actions).forEach(([actionName, handler]) => {
|
||||
wrapped[actionName] = async (params?: Record<string, any>) => {
|
||||
setLoading(true);
|
||||
clearMessages();
|
||||
try {
|
||||
const result = await handler(params);
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
return wrapped;
|
||||
}, [actions, setLoading, clearMessages]);
|
||||
|
||||
return {
|
||||
actions: wrappedActions,
|
||||
loading: state.loading,
|
||||
error: state.error,
|
||||
success: state.success,
|
||||
clearMessages,
|
||||
setLoading,
|
||||
setError,
|
||||
setSuccess,
|
||||
};
|
||||
}
|
||||
170
src/hooks/useFormSchema.ts
Normal file
170
src/hooks/useFormSchema.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Hook for managing forms based on form schemas from features.json
|
||||
*/
|
||||
|
||||
import type { FormField, FormSchema } from '@/utils/featureConfig';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { getFormSchema, getValidationRule } from '@/utils/featureConfig';
|
||||
|
||||
type ValidationErrors = Record<string, string>;
|
||||
|
||||
/**
|
||||
* Hook that provides form state management based on schemas from features.json
|
||||
*/
|
||||
export function useFormSchema(resourceName: string, initialData?: Record<string, any>) {
|
||||
const schema = getFormSchema(resourceName);
|
||||
|
||||
if (!schema) {
|
||||
console.warn(`No form schema found for resource: ${resourceName}`);
|
||||
}
|
||||
|
||||
const [values, setValues] = useState<Record<string, any>>(() =>
|
||||
initialData || getDefaultValues(schema),
|
||||
);
|
||||
const [errors, setErrors] = useState<ValidationErrors>({});
|
||||
const [touched, setTouched] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Get default values from schema
|
||||
function getDefaultValues(formSchema?: FormSchema): Record<string, any> {
|
||||
if (!formSchema) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const defaults: Record<string, any> = {};
|
||||
formSchema.fields.forEach((field) => {
|
||||
if (field.defaultValue !== undefined) {
|
||||
defaults[field.name] = field.defaultValue;
|
||||
}
|
||||
});
|
||||
return defaults;
|
||||
}
|
||||
|
||||
// Validate a single field
|
||||
const validateField = useCallback((field: FormField, value: any): string | null => {
|
||||
// Check required
|
||||
if (field.required && (value === undefined || value === null || value === '')) {
|
||||
return `${field.label} is required`;
|
||||
}
|
||||
|
||||
// Check type-specific validations
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
// Min/max length for text fields
|
||||
if (field.type === 'text' || field.type === 'textarea') {
|
||||
const strValue = String(value);
|
||||
if (field.minLength && strValue.length < field.minLength) {
|
||||
return `${field.label} must be at least ${field.minLength} characters`;
|
||||
}
|
||||
if (field.maxLength && strValue.length > field.maxLength) {
|
||||
return `${field.label} must be at most ${field.maxLength} characters`;
|
||||
}
|
||||
}
|
||||
|
||||
// Min/max for number fields
|
||||
if (field.type === 'number') {
|
||||
const numValue = Number(value);
|
||||
if (Number.isNaN(numValue)) {
|
||||
return `${field.label} must be a valid number`;
|
||||
}
|
||||
if (field.min !== undefined && numValue < field.min) {
|
||||
return `${field.label} must be at least ${field.min}`;
|
||||
}
|
||||
if (field.max !== undefined && numValue > field.max) {
|
||||
return `${field.label} must be at most ${field.max}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Custom validation rules from features.json
|
||||
if (field.validation) {
|
||||
const rule = getValidationRule(field.validation);
|
||||
if (rule) {
|
||||
const regex = new RegExp(rule.pattern);
|
||||
if (!regex.test(String(value))) {
|
||||
return rule.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
// Validate all fields
|
||||
const validateForm = useCallback((): boolean => {
|
||||
if (!schema) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const newErrors: ValidationErrors = {};
|
||||
let isValid = true;
|
||||
|
||||
schema.fields.forEach((field) => {
|
||||
const error = validateField(field, values[field.name]);
|
||||
if (error) {
|
||||
newErrors[field.name] = error;
|
||||
isValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
setErrors(newErrors);
|
||||
return isValid;
|
||||
}, [schema, values, validateField]);
|
||||
|
||||
// Handle field change
|
||||
const handleChange = useCallback((fieldName: string, value: any) => {
|
||||
setValues(prev => ({ ...prev, [fieldName]: value }));
|
||||
|
||||
// Clear error for this field
|
||||
setErrors((prev) => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[fieldName];
|
||||
return newErrors;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Handle field blur
|
||||
const handleBlur = useCallback((fieldName: string) => {
|
||||
setTouched(prev => ({ ...prev, [fieldName]: true }));
|
||||
|
||||
// Validate this field on blur
|
||||
if (schema) {
|
||||
const field = schema.fields.find(f => f.name === fieldName);
|
||||
if (field) {
|
||||
const error = validateField(field, values[fieldName]);
|
||||
if (error) {
|
||||
setErrors(prev => ({ ...prev, [fieldName]: error }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [schema, values, validateField]);
|
||||
|
||||
// Reset form
|
||||
const reset = useCallback((newData?: Record<string, any>) => {
|
||||
setValues(newData || getDefaultValues(schema));
|
||||
setErrors({});
|
||||
setTouched({});
|
||||
}, [schema]);
|
||||
|
||||
// Check if form is valid
|
||||
const isValid = useMemo(() => {
|
||||
return Object.keys(errors).length === 0;
|
||||
}, [errors]);
|
||||
|
||||
// Check if form has been modified
|
||||
const isDirty = useMemo(() => {
|
||||
const defaults = getDefaultValues(schema);
|
||||
return Object.keys(values).some(key => values[key] !== defaults[key]);
|
||||
}, [values, schema]);
|
||||
|
||||
return {
|
||||
schema,
|
||||
values,
|
||||
errors,
|
||||
touched,
|
||||
isValid,
|
||||
isDirty,
|
||||
handleChange,
|
||||
handleBlur,
|
||||
validateForm,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -1,417 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Typography,
|
||||
Paper,
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
Checkbox,
|
||||
FormControlLabel,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Chip,
|
||||
Tooltip,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
} from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import TableChartIcon from '@mui/icons-material/TableChart';
|
||||
import SpeedIcon from '@mui/icons-material/Speed';
|
||||
import { ComponentNode } from './featureConfig';
|
||||
|
||||
// Map of component names to actual components
|
||||
const componentMap: Record<string, React.ComponentType<any>> = {
|
||||
Box,
|
||||
Button,
|
||||
Typography,
|
||||
Paper,
|
||||
TextField,
|
||||
Select,
|
||||
MenuItem,
|
||||
Checkbox,
|
||||
FormControlLabel,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Chip,
|
||||
Tooltip,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
};
|
||||
|
||||
// Map of icon names to icon components
|
||||
const iconMap: Record<string, React.ComponentType<any>> = {
|
||||
Add: AddIcon,
|
||||
Delete: DeleteIcon,
|
||||
Edit: EditIcon,
|
||||
TableChart: TableChartIcon,
|
||||
Speed: SpeedIcon,
|
||||
};
|
||||
|
||||
type ComponentTreeRendererProps = {
|
||||
tree: ComponentNode;
|
||||
data?: Record<string, any>;
|
||||
handlers?: Record<string, (...args: any[]) => void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Safe operator functions for condition evaluation
|
||||
*/
|
||||
const SAFE_OPERATORS: Record<string, (a: any, b: any) => boolean> = {
|
||||
'===': (a, b) => a === b,
|
||||
'!==': (a, b) => a !== b,
|
||||
'==': (a, b) => a == b,
|
||||
'!=': (a, b) => a != b,
|
||||
'>': (a, b) => a > b,
|
||||
'<': (a, b) => a < b,
|
||||
'>=': (a, b) => a >= b,
|
||||
'<=': (a, b) => a <= b,
|
||||
'&&': (a, b) => a && b,
|
||||
'||': (a, b) => a || b,
|
||||
};
|
||||
|
||||
/**
|
||||
* Safely get nested property value from object using dot notation
|
||||
* Only allows alphanumeric and dots - no function calls or arbitrary code
|
||||
*/
|
||||
function safeGetProperty(obj: Record<string, any>, path: string): any {
|
||||
// Validate path contains only safe characters
|
||||
if (!/^[a-zA-Z_$][a-zA-Z0-9_$.]*$/.test(path)) {
|
||||
console.warn('Invalid property path:', path);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parts = path.split('.');
|
||||
let current = obj;
|
||||
|
||||
for (const part of parts) {
|
||||
if (current == null || typeof current !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
current = current[part];
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a condition string with the provided data context
|
||||
* Uses safe property access and whitelisted operators - NO new Function()
|
||||
*/
|
||||
function evaluateCondition(condition: string, data: Record<string, any>): boolean {
|
||||
try {
|
||||
// Simple boolean property check: "isAdmin"
|
||||
if (/^[a-zA-Z_$][a-zA-Z0-9_$.]*$/.test(condition.trim())) {
|
||||
const value = safeGetProperty(data, condition.trim());
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
// Find operator in condition
|
||||
let operator: string | null = null;
|
||||
let operatorIndex = -1;
|
||||
|
||||
// Check for operators in order of precedence
|
||||
for (const op of ['===', '!==', '==', '!=', '>=', '<=', '>', '<', '&&', '||']) {
|
||||
const idx = condition.indexOf(op);
|
||||
if (idx !== -1) {
|
||||
operator = op;
|
||||
operatorIndex = idx;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!operator || operatorIndex === -1) {
|
||||
console.warn('No valid operator found in condition:', condition);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract left and right operands
|
||||
const left = condition.slice(0, operatorIndex).trim();
|
||||
const right = condition.slice(operatorIndex + operator.length).trim();
|
||||
|
||||
// Evaluate operands
|
||||
let leftValue: any;
|
||||
let rightValue: any;
|
||||
|
||||
// Left operand - check if it's a property or literal
|
||||
if (/^[a-zA-Z_$][a-zA-Z0-9_$.]*$/.test(left)) {
|
||||
leftValue = safeGetProperty(data, left);
|
||||
} else if (left === 'true') {
|
||||
leftValue = true;
|
||||
} else if (left === 'false') {
|
||||
leftValue = false;
|
||||
} else if (left === 'null') {
|
||||
leftValue = null;
|
||||
} else if (!isNaN(Number(left))) {
|
||||
leftValue = Number(left);
|
||||
} else if ((left.startsWith('"') && left.endsWith('"')) || (left.startsWith("'") && left.endsWith("'"))) {
|
||||
leftValue = left.slice(1, -1);
|
||||
} else {
|
||||
console.warn('Invalid left operand:', left);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Right operand - same logic
|
||||
if (/^[a-zA-Z_$][a-zA-Z0-9_$.]*$/.test(right)) {
|
||||
rightValue = safeGetProperty(data, right);
|
||||
} else if (right === 'true') {
|
||||
rightValue = true;
|
||||
} else if (right === 'false') {
|
||||
rightValue = false;
|
||||
} else if (right === 'null') {
|
||||
rightValue = null;
|
||||
} else if (!isNaN(Number(right))) {
|
||||
rightValue = Number(right);
|
||||
} else if ((right.startsWith('"') && right.endsWith('"')) || (right.startsWith("'") && right.endsWith("'"))) {
|
||||
rightValue = right.slice(1, -1);
|
||||
} else {
|
||||
console.warn('Invalid right operand:', right);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Apply operator
|
||||
const operatorFunc = SAFE_OPERATORS[operator];
|
||||
if (!operatorFunc) {
|
||||
console.warn('Unknown operator:', operator);
|
||||
return false;
|
||||
}
|
||||
|
||||
return operatorFunc(leftValue, rightValue);
|
||||
} catch (error) {
|
||||
console.error('Error evaluating condition:', condition, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolate template strings like {{variable}} with actual values from data
|
||||
* Uses safe property access - NO new Function() or eval()
|
||||
*/
|
||||
function interpolateValue(value: any, data: Record<string, any>): any {
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
// Check if it's a template string
|
||||
const templateMatch = value.match(/^\{\{(.+)\}\}$/);
|
||||
if (templateMatch && templateMatch[1]) {
|
||||
const expression = templateMatch[1].trim();
|
||||
|
||||
// Support Math operations for numeric expressions
|
||||
if (/^Math\.[a-zA-Z]+\(/.test(expression)) {
|
||||
// Allow safe Math operations
|
||||
const mathOp = expression.match(/^Math\.([a-zA-Z]+)\((.+)\)$/);
|
||||
if (mathOp) {
|
||||
const [, operation, argsStr] = mathOp;
|
||||
const safeOps = ['abs', 'ceil', 'floor', 'round', 'max', 'min'];
|
||||
|
||||
if (operation && argsStr && safeOps.includes(operation)) {
|
||||
try {
|
||||
// Parse arguments safely
|
||||
const args = argsStr.split(',').map(arg => {
|
||||
const trimmed = arg.trim();
|
||||
const propValue = safeGetProperty(data, trimmed);
|
||||
return propValue !== undefined ? propValue : Number(trimmed);
|
||||
});
|
||||
|
||||
return (Math as any)[operation](...args);
|
||||
} catch (error) {
|
||||
console.error('Error evaluating Math operation:', expression, error);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ternary operator: condition ? valueIfTrue : valueIfFalse
|
||||
const ternaryMatch = expression.match(/^(.+?)\s*\?\s*(.+?)\s*:\s*(.+)$/);
|
||||
if (ternaryMatch) {
|
||||
const [, condition, trueValue, falseValue] = ternaryMatch;
|
||||
if (condition && trueValue !== undefined && falseValue !== undefined) {
|
||||
const conditionResult = evaluateCondition(condition.trim(), data);
|
||||
const targetValue = conditionResult ? trueValue.trim() : falseValue.trim();
|
||||
|
||||
// Recursively interpolate the result
|
||||
return interpolateValue(`{{${targetValue}}}`, data);
|
||||
}
|
||||
}
|
||||
|
||||
// Simple property access
|
||||
return safeGetProperty(data, expression);
|
||||
}
|
||||
|
||||
// Replace inline templates
|
||||
return value.replace(/\{\{(.+?)\}\}/g, (_, expression) => {
|
||||
const trimmed = expression.trim();
|
||||
const result = safeGetProperty(data, trimmed);
|
||||
return result !== undefined ? String(result) : '';
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolate all props in an object
|
||||
*/
|
||||
function interpolateProps(
|
||||
props: Record<string, any> | undefined,
|
||||
data: Record<string, any>,
|
||||
handlers: Record<string, (...args: any[]) => void>
|
||||
): Record<string, any> {
|
||||
if (!props) return {};
|
||||
|
||||
const interpolated: Record<string, any> = {};
|
||||
|
||||
Object.entries(props).forEach(([key, value]) => {
|
||||
if (typeof value === 'string' && handlers[value]) {
|
||||
// If the value is a handler function name, use the handler
|
||||
interpolated[key] = handlers[value];
|
||||
} else if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
// Recursively interpolate nested objects
|
||||
interpolated[key] = interpolateProps(value, data, handlers);
|
||||
} else {
|
||||
// Interpolate the value
|
||||
interpolated[key] = interpolateValue(value, data);
|
||||
}
|
||||
});
|
||||
|
||||
return interpolated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singular form of a plural word (simple implementation)
|
||||
*/
|
||||
function getSingular(plural: string): string {
|
||||
if (plural.endsWith('ies')) {
|
||||
return plural.slice(0, -3) + 'y';
|
||||
}
|
||||
if (plural.endsWith('es')) {
|
||||
return plural.slice(0, -2);
|
||||
}
|
||||
if (plural.endsWith('s')) {
|
||||
return plural.slice(0, -1);
|
||||
}
|
||||
return plural;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single component node
|
||||
*/
|
||||
function renderNode(
|
||||
node: ComponentNode,
|
||||
data: Record<string, any>,
|
||||
handlers: Record<string, (...args: any[]) => void>,
|
||||
key: string | number
|
||||
): React.ReactNode {
|
||||
// Evaluate condition
|
||||
if (node.condition && !evaluateCondition(node.condition, data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle forEach loops
|
||||
if (node.forEach) {
|
||||
const items = data[node.forEach];
|
||||
if (!Array.isArray(items)) {
|
||||
console.warn(`forEach data "${node.forEach}" is not an array`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const singularName = getSingular(node.forEach);
|
||||
return items.map((item, index) => {
|
||||
const itemData = { ...data, [singularName]: item, index };
|
||||
// Remove forEach from node to avoid infinite loop
|
||||
const nodeWithoutForEach = { ...node, forEach: undefined };
|
||||
return renderNode(nodeWithoutForEach, itemData, handlers, `${key}-${index}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Get the component
|
||||
const componentName = interpolateValue(node.component, data);
|
||||
const Component = componentMap[componentName];
|
||||
|
||||
if (!Component) {
|
||||
console.warn(`Component "${componentName}" not found in componentMap`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Interpolate props
|
||||
const props = interpolateProps(node.props, data, handlers);
|
||||
|
||||
// Handle special props
|
||||
if (props.startIcon && typeof props.startIcon === 'string') {
|
||||
const IconComponent = iconMap[props.startIcon];
|
||||
if (IconComponent) {
|
||||
props.startIcon = <IconComponent />;
|
||||
}
|
||||
}
|
||||
|
||||
if (props.endIcon && typeof props.endIcon === 'string') {
|
||||
const IconComponent = iconMap[props.endIcon];
|
||||
if (IconComponent) {
|
||||
props.endIcon = <IconComponent />;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle 'text' prop for Typography and Button
|
||||
let textContent = null;
|
||||
if (props.text !== undefined) {
|
||||
textContent = props.text;
|
||||
delete props.text;
|
||||
}
|
||||
|
||||
// Render children
|
||||
const children = node.children?.map((child, index) =>
|
||||
renderNode(child, data, handlers, `${key}-child-${index}`)
|
||||
);
|
||||
|
||||
return (
|
||||
<Component key={key} {...props}>
|
||||
{textContent}
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ComponentTreeRenderer - Renders a component tree from JSON configuration
|
||||
*/
|
||||
export default function ComponentTreeRenderer({
|
||||
tree,
|
||||
data = {},
|
||||
handlers = {},
|
||||
}: ComponentTreeRendererProps) {
|
||||
if (!tree) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{renderNode(tree, data, handlers, 'root')}</>;
|
||||
}
|
||||
120
src/utils/actionWiring.ts
Normal file
120
src/utils/actionWiring.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Action Wiring Utilities
|
||||
* Create action handlers dynamically from features.json configuration
|
||||
*/
|
||||
|
||||
import type { ApiEndpoint } from './featureConfig';
|
||||
import { getApiEndpoints } from './featureConfig';
|
||||
|
||||
/**
|
||||
* Generic action handler factory
|
||||
* Creates a function that calls an API endpoint with the specified parameters
|
||||
*/
|
||||
export function createActionHandler(
|
||||
endpoint: ApiEndpoint,
|
||||
onSuccess?: (data: any) => void,
|
||||
onError?: (error: Error) => void,
|
||||
) {
|
||||
return async (params?: Record<string, any>) => {
|
||||
try {
|
||||
const url = interpolatePath(endpoint.path, params || {});
|
||||
|
||||
const options: RequestInit = {
|
||||
method: endpoint.method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
|
||||
// Add body for POST, PUT, PATCH methods
|
||||
if (['POST', 'PUT', 'PATCH'].includes(endpoint.method) && params) {
|
||||
options.body = JSON.stringify(params);
|
||||
}
|
||||
|
||||
const response = await fetch(url, options);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || `Request failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
onSuccess?.(data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error('Unknown error');
|
||||
onError?.(err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolate path parameters like /api/users/:id
|
||||
*/
|
||||
function interpolatePath(path: string, params: Record<string, any>): string {
|
||||
return path.replace(/:([a-z_$][\w$]*)/gi, (_, paramName) => {
|
||||
const value = params[paramName];
|
||||
if (value === undefined) {
|
||||
console.warn(`Missing path parameter: ${paramName}`);
|
||||
return `:${paramName}`;
|
||||
}
|
||||
return String(value);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create action handlers for a resource from features.json
|
||||
*/
|
||||
export function createResourceActions(
|
||||
resourceName: string,
|
||||
callbacks?: {
|
||||
onSuccess?: (action: string, data: any) => void;
|
||||
onError?: (action: string, error: Error) => void;
|
||||
},
|
||||
): Record<string, (params?: Record<string, any>) => Promise<any>> {
|
||||
const endpoints = getApiEndpoints(resourceName);
|
||||
|
||||
if (!endpoints) {
|
||||
console.warn(`No API endpoints found for resource: ${resourceName}`);
|
||||
return {};
|
||||
}
|
||||
|
||||
const actions: Record<string, (params?: Record<string, any>) => Promise<any>> = {};
|
||||
|
||||
Object.entries(endpoints).forEach(([actionName, endpoint]) => {
|
||||
actions[actionName] = createActionHandler(
|
||||
endpoint,
|
||||
data => callbacks?.onSuccess?.(actionName, data),
|
||||
error => callbacks?.onError?.(actionName, error),
|
||||
);
|
||||
});
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch execute multiple actions
|
||||
*/
|
||||
export async function batchExecuteActions(
|
||||
actions: Array<{ handler: () => Promise<any>; name: string }>,
|
||||
): Promise<{ successes: any[]; errors: Array<{ name: string; error: Error }> }> {
|
||||
const results = await Promise.allSettled(
|
||||
actions.map(({ handler }) => handler()),
|
||||
);
|
||||
|
||||
const successes: any[] = [];
|
||||
const errors: Array<{ name: string; error: Error }> = [];
|
||||
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
successes.push(result.value);
|
||||
} else {
|
||||
errors.push({
|
||||
name: actions[index]?.name || `Action ${index}`,
|
||||
error: result.reason instanceof Error ? result.reason : new Error(String(result.reason)),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return { successes, errors };
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
/**
|
||||
* Component Tree Renderer
|
||||
* Unified Component Tree Renderer
|
||||
* Dynamically renders React component trees from JSON configuration
|
||||
* Merges both previous implementations for maximum compatibility
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import type { ComponentNode } from './featureConfig';
|
||||
|
||||
@@ -22,6 +25,7 @@ import {
|
||||
DialogTitle,
|
||||
Drawer,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
Grid,
|
||||
IconButton,
|
||||
InputLabel,
|
||||
@@ -43,6 +47,7 @@ import {
|
||||
Tabs,
|
||||
TextField,
|
||||
Toolbar,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Chip,
|
||||
Accordion,
|
||||
@@ -70,6 +75,7 @@ const componentRegistry: Record<string, React.ComponentType<any>> = {
|
||||
DialogTitle,
|
||||
Drawer,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
Grid,
|
||||
IconButton,
|
||||
InputLabel,
|
||||
@@ -91,6 +97,7 @@ const componentRegistry: Record<string, React.ComponentType<any>> = {
|
||||
Tabs,
|
||||
TextField,
|
||||
Toolbar,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Chip,
|
||||
Accordion,
|
||||
@@ -102,6 +109,7 @@ const componentRegistry: Record<string, React.ComponentType<any>> = {
|
||||
type RenderContext = {
|
||||
data?: Record<string, any>;
|
||||
actions?: Record<string, (...args: any[]) => any>;
|
||||
handlers?: Record<string, (...args: any[]) => any>; // Alias for backward compatibility
|
||||
state?: Record<string, any>;
|
||||
};
|
||||
|
||||
@@ -171,10 +179,10 @@ function processProps(props: Record<string, any> = {}, context: RenderContext):
|
||||
|
||||
for (const [key, value] of Object.entries(props)) {
|
||||
// Handle special props
|
||||
if (key === 'onClick' || key === 'onChange' || key === 'onClose') {
|
||||
// Map to action functions
|
||||
if (key === 'onClick' || key === 'onChange' || key === 'onClose' || key === 'onBlur' || key === 'onFocus') {
|
||||
// Map to action functions - check both actions and handlers for backward compatibility
|
||||
if (typeof value === 'string') {
|
||||
processed[key] = context.actions?.[value];
|
||||
processed[key] = context.actions?.[value] || context.handlers?.[value];
|
||||
} else {
|
||||
processed[key] = value;
|
||||
}
|
||||
@@ -290,7 +298,7 @@ export function renderComponentNode(
|
||||
}
|
||||
|
||||
/**
|
||||
* Main component tree renderer
|
||||
* Main component tree renderer (named export)
|
||||
*/
|
||||
export function ComponentTreeRenderer({
|
||||
tree,
|
||||
@@ -302,6 +310,27 @@ export function ComponentTreeRenderer({
|
||||
return renderComponentNode(tree, context, 'root');
|
||||
}
|
||||
|
||||
/**
|
||||
* Default export for backward compatibility with old imports
|
||||
*/
|
||||
export default function ComponentTreeRendererDefault({
|
||||
tree,
|
||||
data = {},
|
||||
handlers = {},
|
||||
}: {
|
||||
tree: ComponentNode;
|
||||
data?: Record<string, any>;
|
||||
handlers?: Record<string, (...args: any[]) => void>;
|
||||
}): React.ReactElement | null {
|
||||
const context: RenderContext = {
|
||||
data,
|
||||
handlers,
|
||||
actions: handlers, // Map handlers to actions for compatibility
|
||||
};
|
||||
|
||||
return renderComponentNode(tree, context, 'root');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to use component tree with state management
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user