382 lines
14 KiB
TypeScript
382 lines
14 KiB
TypeScript
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);
|