From 1ed571860f5d9cdada9e20e781b40a8c2ccf8726 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 17:33:13 +0000 Subject: [PATCH] feat: implement Phase 1 - core infrastructure for JSON-driven architecture Co-authored-by: johndoe6345789 <224850594+johndoe6345789@users.noreply.github.com> --- src/components/admin/ColumnManagerTab.tsx | 2 +- src/components/admin/TableManagerTab.tsx | 2 +- src/components/atoms/index.tsx | 253 +++++++++++++ src/components/molecules/DynamicForm.tsx | 242 +++++++++++++ src/hooks/useFeatureActions.ts | 85 +++++ src/hooks/useFormSchema.ts | 170 +++++++++ src/utils/ComponentTreeRenderer.tsx | 417 ---------------------- src/utils/actionWiring.ts | 120 +++++++ src/utils/componentTreeRenderer.tsx | 39 +- 9 files changed, 906 insertions(+), 424 deletions(-) create mode 100644 src/components/atoms/index.tsx create mode 100644 src/components/molecules/DynamicForm.tsx create mode 100644 src/hooks/useFeatureActions.ts create mode 100644 src/hooks/useFormSchema.ts delete mode 100644 src/utils/ComponentTreeRenderer.tsx create mode 100644 src/utils/actionWiring.ts diff --git a/src/components/admin/ColumnManagerTab.tsx b/src/components/admin/ColumnManagerTab.tsx index 34612d1..bc2014a 100644 --- a/src/components/admin/ColumnManagerTab.tsx +++ b/src/components/admin/ColumnManagerTab.tsx @@ -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 = { diff --git a/src/components/admin/TableManagerTab.tsx b/src/components/admin/TableManagerTab.tsx index 2266c83..3be86e6 100644 --- a/src/components/admin/TableManagerTab.tsx +++ b/src/components/admin/TableManagerTab.tsx @@ -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'; diff --git a/src/components/atoms/index.tsx b/src/components/atoms/index.tsx new file mode 100644 index 0000000..3d690f6 --- /dev/null +++ b/src/components/atoms/index.tsx @@ -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 ; +} + +/** + * Atomic TextField - Pure presentation, no logic + */ +export function TextField(props: TextFieldProps) { + return ; +} + +/** + * Atomic Typography - Pure presentation, no logic + */ +export function Typography(props: TypographyProps) { + return ; +} + +/** + * Atomic Select - Pure presentation, no logic + */ +export function Select(props: SelectProps & { options?: Array<{ value: string; label: string }> }) { + const { options = [], ...selectProps } = props; + + return ( + + {options.map(option => ( + + {option.label} + + ))} + + ); +} + +/** + * Atomic Checkbox - Pure presentation, no logic + */ +export function Checkbox(props: CheckboxProps & { label?: string }) { + const { label, ...checkboxProps } = props; + + if (label) { + return ( + } + label={label} + /> + ); + } + + return ; +} + +/** + * Atomic IconButton - Pure presentation, no logic + */ +export function IconButton(props: IconButtonProps) { + return ; +} + +/** + * Atomic Paper - Pure presentation, no logic + */ +export function Paper(props: PaperProps) { + return ; +} + +/** + * Atomic Card - Pure presentation, no logic + */ +export function Card(props: CardProps) { + return ; +} + +/** + * Atomic Chip - Pure presentation, no logic + */ +export function Chip(props: ChipProps) { + return ; +} + +/** + * Atomic Alert - Pure presentation, no logic + */ +export function Alert(props: AlertProps) { + return ; +} + +/** + * Atomic Loading Spinner - Pure presentation, no logic + */ +export function LoadingSpinner(props: CircularProgressProps) { + return ( + + + + ); +} + +/** + * Atomic Container - Simple Box wrapper with common styling + */ +export function Container({ children, ...props }: React.PropsWithChildren<{ sx?: any }>) { + return ( + + {children} + + ); +} + +/** + * 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 ( + + {children} + + ); +} + +/** + * 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 ( + + {icon && {icon}} + + {message} + + {action && {action}} + + ); +} + +/** + * Atomic Error Display - Shows error messages + */ +export function ErrorDisplay({ + error, + onRetry, +}: { + error: string | null; + onRetry?: () => void; +}) { + if (!error) { + return null; + } + + return ( + + Retry + + ) + : undefined + } + > + {error} + + ); +} + +/** + * Atomic Success Display - Shows success messages + */ +export function SuccessDisplay({ + message, + onClose, +}: { + message: string | null; + onClose?: () => void; +}) { + if (!message) { + return null; + } + + return ( + + {message} + + ); +} diff --git a/src/components/molecules/DynamicForm.tsx b/src/components/molecules/DynamicForm.tsx new file mode 100644 index 0000000..5dff821 --- /dev/null +++ b/src/components/molecules/DynamicForm.tsx @@ -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; + errors: Record; + 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 ( + onChange(e.target.value)} + onBlur={onBlur} + required={field.required} + inputProps={{ + minLength: field.minLength, + maxLength: field.maxLength, + }} + /> + ); + + case 'number': + return ( + 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 ( + onChange(e.target.value)} + onBlur={onBlur} + required={field.required} + inputProps={{ + maxLength: field.maxLength, + }} + /> + ); + + case 'select': + return ( + + {field.label} + + {error && {error}} + + ); + + case 'checkbox': + return ( + + onChange(e.target.checked)} + disabled={disabled} + /> + )} + label={field.label} + /> + {error && {error}} + + ); + + case 'date': + return ( + onChange(e.target.value)} + onBlur={onBlur} + required={field.required} + InputLabelProps={{ shrink: true }} + /> + ); + + case 'datetime': + return ( + 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 ( + + {fields.map((field) => { + // Determine grid size based on field requirements + const gridSize = field.type === 'textarea' || field.type === 'checkbox' ? 12 : 6; + + return ( + + {renderField( + field, + values[field.name], + errors[field.name], + value => onChange(field.name, value), + onBlur ? () => onBlur(field.name) : undefined, + disabled, + )} + + ); + })} + + ); +} + +/** + * Form Section - Groups related fields with a title + */ +export function FormSection({ + title, + description, + children, +}: { + title: string; + description?: string; + children: React.ReactNode; +}) { + return ( + + + + {title} + + {description && ( + + {description} + + )} + + {children} + + ); +} diff --git a/src/hooks/useFeatureActions.ts b/src/hooks/useFeatureActions.ts new file mode 100644 index 0000000..05886d8 --- /dev/null +++ b/src/hooks/useFeatureActions.ts @@ -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({ + 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) => Promise> = {}; + + Object.entries(actions).forEach(([actionName, handler]) => { + wrapped[actionName] = async (params?: Record) => { + 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, + }; +} diff --git a/src/hooks/useFormSchema.ts b/src/hooks/useFormSchema.ts new file mode 100644 index 0000000..b8c8208 --- /dev/null +++ b/src/hooks/useFormSchema.ts @@ -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; + +/** + * Hook that provides form state management based on schemas from features.json + */ +export function useFormSchema(resourceName: string, initialData?: Record) { + const schema = getFormSchema(resourceName); + + if (!schema) { + console.warn(`No form schema found for resource: ${resourceName}`); + } + + const [values, setValues] = useState>(() => + initialData || getDefaultValues(schema), + ); + const [errors, setErrors] = useState({}); + const [touched, setTouched] = useState>({}); + + // Get default values from schema + function getDefaultValues(formSchema?: FormSchema): Record { + if (!formSchema) { + return {}; + } + + const defaults: Record = {}; + 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) => { + 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, + }; +} diff --git a/src/utils/ComponentTreeRenderer.tsx b/src/utils/ComponentTreeRenderer.tsx deleted file mode 100644 index f072376..0000000 --- a/src/utils/ComponentTreeRenderer.tsx +++ /dev/null @@ -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> = { - 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> = { - Add: AddIcon, - Delete: DeleteIcon, - Edit: EditIcon, - TableChart: TableChartIcon, - Speed: SpeedIcon, -}; - -type ComponentTreeRendererProps = { - tree: ComponentNode; - data?: Record; - handlers?: Record void>; -}; - -/** - * Safe operator functions for condition evaluation - */ -const SAFE_OPERATORS: Record 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, 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): 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): 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 | undefined, - data: Record, - handlers: Record void> -): Record { - if (!props) return {}; - - const interpolated: Record = {}; - - 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, - handlers: Record 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 = ; - } - } - - if (props.endIcon && typeof props.endIcon === 'string') { - const IconComponent = iconMap[props.endIcon]; - if (IconComponent) { - props.endIcon = ; - } - } - - // 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 ( - - {textContent} - {children} - - ); -} - -/** - * 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')}; -} diff --git a/src/utils/actionWiring.ts b/src/utils/actionWiring.ts new file mode 100644 index 0000000..76defb7 --- /dev/null +++ b/src/utils/actionWiring.ts @@ -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) => { + 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 { + 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) => Promise> { + const endpoints = getApiEndpoints(resourceName); + + if (!endpoints) { + console.warn(`No API endpoints found for resource: ${resourceName}`); + return {}; + } + + const actions: Record) => Promise> = {}; + + 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; 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 }; +} diff --git a/src/utils/componentTreeRenderer.tsx b/src/utils/componentTreeRenderer.tsx index c2cacd4..b0d27dd 100644 --- a/src/utils/componentTreeRenderer.tsx +++ b/src/utils/componentTreeRenderer.tsx @@ -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> = { DialogTitle, Drawer, FormControl, + FormControlLabel, Grid, IconButton, InputLabel, @@ -91,6 +97,7 @@ const componentRegistry: Record> = { Tabs, TextField, Toolbar, + Tooltip, Typography, Chip, Accordion, @@ -102,6 +109,7 @@ const componentRegistry: Record> = { type RenderContext = { data?: Record; actions?: Record any>; + handlers?: Record any>; // Alias for backward compatibility state?: Record; }; @@ -171,10 +179,10 @@ function processProps(props: Record = {}, 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; + handlers?: Record 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 */