import React from 'react'; import { ListIcon } from '../../icons/ComponentIcons'; import type { ComponentDefinition, RuntimeValues } from '../types'; import { ComponentRegistry } from '../ComponentRegistry'; import { ComponentRenderer } from '../../utils/ComponentRenderer'; import type { ComponentNode, ComponentType } from '../../types/component.types'; import { parseScopedPath } from '../../state/scopedPath'; import { RuntimeContext } from '../../runtime'; import type { RuntimeContextValue } from '../../runtime'; /** Traverse a dot-path on an object, returning the resolved value as a string */ const resolveNestedValue = (obj: any, path: string): string => { const parts = path.split('.'); let current = obj; for (const part of parts) { if (current == null || typeof current !== 'object') return ''; current = current[part]; if (current === undefined) return ''; } return current == null ? '' : String(current); }; /** * Create a RuntimeContextValue for a single list iteration. * Intercepts {{ dataSource.* }} and {{ index }} bindings before * they reach the standard interpolateText (which only knows page/global). */ const createIterationContext = ( runtime: RuntimeValues, item: any, index: number, ): RuntimeContextValue => { const iterationResolveText = (text: string): string => { // Pre-process: resolve dataSource / index bindings first const preprocessed = text.replace( /\{\{\s*([^}]+?)\s*\}\}/g, (match, rawPath) => { const trimmed = rawPath.trim(); if (trimmed === 'index') { return String(index); } if (trimmed === 'dataSource') { return item == null ? '' : String(item); } if (trimmed.startsWith('dataSource.')) { const subPath = trimmed.slice('dataSource.'.length); return resolveNestedValue(item, subPath); } // Not a dataSource binding — pass through for standard resolution return match; }, ); // Resolve remaining {{ page.* }} / {{ global.* }} bindings return runtime.resolveText(preprocessed); }; return { state: runtime.state, resolveText: iterationResolveText, executeHandlers: runtime.executeHandlers, dispatchEvent: runtime.dispatchEvent, pages: runtime.pages, navigateToPage: runtime.navigateToPage, setStateValue: runtime.setStateValue, previewMode: true, }; }; export const listDefinition: ComponentDefinition = { type: 'list', label: 'List', icon: ListIcon, category: 'container', canHaveChildren: true, // Changed: now accepts children directly for inline editing defaultStyles: { width: 'auto', display: 'flex', flexDirection: 'column', gap: '8px', }, defaultProps: {}, propertySchema: [ { key: 'dataSource', label: 'Data Source', type: 'text-binding', category: 'content', description: 'Array data source (e.g., {{ page.cart.items }} or {{ global.products.items }})', }, ], styleSchema: [ // Layout { key: 'width', label: 'Width', type: 'dimension', category: 'layout' }, { key: 'height', label: 'Height', type: 'dimension', category: 'layout' }, { key: 'minWidth', label: 'Min Width', type: 'dimension', category: 'layout' }, { key: 'maxWidth', label: 'Max Width', type: 'dimension', category: 'layout' }, { key: 'minHeight', label: 'Min Height', type: 'dimension', category: 'layout' }, { key: 'maxHeight', label: 'Max Height', type: 'dimension', category: 'layout' }, { key: 'flexDirection', label: 'Direction', type: 'select', category: 'layout', options: [ { value: 'column', label: 'Column' }, { value: 'row', label: 'Row' }, ], }, { key: 'alignItems', label: 'Align Items', type: 'select', category: 'layout', options: [ { value: 'flex-start', label: 'Start' }, { value: 'center', label: 'Center' }, { value: 'flex-end', label: 'End' }, { value: 'stretch', label: 'Stretch' }, ], }, { key: 'justifyContent', label: 'Justify Content', type: 'select', category: 'layout', options: [ { value: 'flex-start', label: 'Start' }, { value: 'center', label: 'Center' }, { value: 'flex-end', label: 'End' }, { value: 'space-between', label: 'Space Between' }, { value: 'space-around', label: 'Space Around' }, ], }, { key: 'overflow', label: 'Overflow', type: 'select', category: 'layout', options: [ { value: 'visible', label: 'Visible' }, { value: 'hidden', label: 'Hidden' }, { value: 'scroll', label: 'Scroll' }, { value: 'auto', label: 'Auto' }, ], }, // Spacing { key: 'gap', label: 'Gap', type: 'dimension', category: 'spacing' }, { key: 'padding', label: 'Padding', type: 'dimension', category: 'spacing' }, { key: 'margin', label: 'Margin', type: 'dimension', category: 'spacing' }, // Appearance { key: 'backgroundColor', label: 'Background', type: 'color', category: 'appearance' }, { key: 'opacity', label: 'Opacity', type: 'range', category: 'appearance' }, // Border { key: 'borderWidth', label: 'Border Width', type: 'dimension', category: 'appearance' }, { key: 'borderStyle', label: 'Border Style', type: 'select', category: 'appearance', options: [ { value: 'none', label: 'None' }, { value: 'solid', label: 'Solid' }, { value: 'dashed', label: 'Dashed' }, { value: 'dotted', label: 'Dotted' }, ], }, { key: 'borderColor', label: 'Border Color', type: 'color', category: 'appearance' }, { key: 'borderRadius', label: 'Border Radius', type: 'dimension', category: 'appearance' }, // Grid Item (when inside grid container) { key: 'gridColumn', label: 'Grid Column', type: 'text', category: 'grid-item' }, { key: 'gridRow', label: 'Grid Row', type: 'text', category: 'grid-item' }, { key: 'gridColumnStart', label: 'Column Start', type: 'text', category: 'grid-item' }, { key: 'gridColumnEnd', label: 'Column End', type: 'text', category: 'grid-item' }, { key: 'gridRowStart', label: 'Row Start', type: 'text', category: 'grid-item' }, { key: 'gridRowEnd', label: 'Row End', type: 'text', category: 'grid-item' }, { key: 'justifySelf', label: 'Justify Self', type: 'select', category: 'grid-item', options: [ { value: 'start', label: 'Start' }, { value: 'end', label: 'End' }, { value: 'center', label: 'Center' }, { value: 'stretch', label: 'Stretch' }, ], }, ], render: ({ component, isSelected, onSelect, previewMode, children, runtime }) => { const isEmpty = !component.children || component.children.length === 0; const dataSourceBinding = (component.props?.dataSource || '').trim(); // Helper function to extract array from data binding const getDataArray = (): { array: any[] | null; error: string | null } => { if (!dataSourceBinding || !runtime) { return { array: null, error: null }; } let parsedPath; try { parsedPath = parseScopedPath(dataSourceBinding); } catch (error) { return { array: null, error: error instanceof Error ? error.message : `Invalid binding format: "${dataSourceBinding}"`, }; } const parts = parsedPath.fullPath.split('.'); let current: any = runtime.state; for (const part of parts) { if (current == null || typeof current !== 'object') { return { array: null, error: `Path "${parsedPath.fullPath}" not found in state` }; } current = current[part]; if (current === undefined) { return { array: null, error: `Path "${parsedPath.fullPath}" not found in state` }; } } if (!Array.isArray(current)) { return { array: null, error: `Data source "${parsedPath.fullPath}" is not an array (found ${typeof current})`, }; } return { array: current, error: null }; }; // EDITOR MODE - always show 1 instance for design if (!previewMode) { return React.createElement( 'div', { style: { ...component.styles, outline: isSelected ? '2px solid rgb(255, 89, 47)' : 'none', minHeight: isEmpty ? '100px' : 'auto', position: 'relative' as const, }, onClick: (e: React.MouseEvent) => { e.stopPropagation(); onSelect(component.id); }, }, isEmpty ? React.createElement( 'div', { style: { position: 'absolute' as const, top: '50%', left: '50%', transform: 'translate(-50%, -50%)', color: '#999', fontSize: '14px', textAlign: 'center' as const, pointerEvents: 'none' as const, }, }, 'Drop components here to design list item' ) : children ); } // PREVIEW MODE - render based on data source if (isEmpty) { return React.createElement( 'div', { style: { ...component.styles, color: '#999', padding: '16px', textAlign: 'center' as const, }, }, 'No list items configured' ); } // If no data source, hide the list in preview if (!dataSourceBinding) { return null; } // Get data array const { array: dataArray, error } = getDataArray(); // Show error if data source is invalid if (error) { return React.createElement( 'div', { style: { ...component.styles, color: '#d32f2f', padding: '16px', backgroundColor: '#ffebee', borderRadius: '4px', border: '1px solid #ef5350', }, }, `Error: ${error}` ); } // If array is empty, render nothing if (!dataArray || dataArray.length === 0) { return null; } // Create a recursive renderChildren function for nested components const renderChildren = (children: ComponentNode[], parentType: ComponentType): React.ReactNode => { return children.map((child) => React.createElement( ComponentRenderer, { key: child.id, component: child, isSelected: null, onSelect: () => { }, // Items in preview are not selectable previewMode: true, parentType: parentType, renderChildren: (childChildren, childParentType) => renderChildren(childChildren, childParentType), } ) ); }; // Render N instances based on array length, each wrapped in an // iteration-scoped RuntimeContext so children can resolve {{ dataSource.* }} return React.createElement( 'div', { style: component.styles, }, dataArray.map((item, index) => { const iterationContext = createIterationContext(runtime, item, index); return React.createElement( RuntimeContext.Provider, { key: index, value: iterationContext, }, React.createElement( 'div', null, component.children!.map((child) => React.createElement( ComponentRenderer, { key: `${child.id}-${index}`, component: child, isSelected: null, onSelect: () => { }, previewMode: true, parentType: component.type, renderChildren: (childChildren, childParentType) => renderChildren(childChildren, childParentType), } ) ) ) ); }) ); }, }; // Auto-register on import ComponentRegistry.register(listDefinition);