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:
copilot-swe-agent[bot]
2026-01-08 17:33:13 +00:00
parent 5925f81233
commit 1ed571860f
9 changed files with 906 additions and 424 deletions

View File

@@ -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 = {

View File

@@ -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';

View 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>
);
}

View 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>
);
}

View 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
View 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,
};
}

View File

@@ -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
View 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 };
}

View File

@@ -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
*/