edith003/src/common/registry/definitions/list.ts

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