diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e686f85 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +node_modules +dist + +.git +.github + +*.log + +.DS_Store diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..19305c1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +node_modules +dist + +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +.DS_Store + +node_modules/.tmp +*.tsbuildinfo diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..385e966 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# syntax=docker/dockerfile:1 + +FROM node:lts-alpine AS build +WORKDIR /app + +COPY package.json ./ +RUN npm install --no-audit --no-fund --include=dev + +COPY . . +RUN npm run build + +FROM node:lts-alpine AS runner +WORKDIR /app + +ENV NODE_ENV=production +ENV PORT=8080 + +RUN npm install -g serve@14 --no-audit --no-fund + +COPY --from=build /app/dist ./dist + +EXPOSE 8080 +CMD ["sh", "-c", "serve -s dist -l $PORT"] diff --git a/boltic.yaml b/boltic.yaml index e888272..f704e2d 100644 --- a/boltic.yaml +++ b/boltic.yaml @@ -1,70 +1,9 @@ -# ============================================================================ -# Boltic Configuration File - edith002 -# ============================================================================ -# -# ⚠️ WARNING: Changes to this file will directly impact your deployed application! -# - Env changes will update environment variables -# - Scaling changes will affect auto-scaling behavior -# - PortMap changes will modify exposed ports -# -# This file is synced with the Boltic UI. You can edit settings here or in the UI. -# -# ---------------------------------------------------------------------------- -# SERVERLESS CONFIG GUIDE -# ---------------------------------------------------------------------------- -# -# Environment Variables (Env): -# serverlessConfig: -# Env: -# MY_VAR: "my_value" -# DATABASE_URL: "postgres://..." -# API_KEY: "secret-key" -# -# Port Mapping (PortMap): -# serverlessConfig: -# PortMap: -# - Name: "testhttp" -# Port: 8080 -# Protocol: "http" -# - Name: "test https" -# Port: 9090 -# Protocol: "https" -# -# Scaling Configuration: -# serverlessConfig: -# Scaling: -# AutoStop: true # Auto-stop when idle -# Min: 1 # Minimum instances -# Max: 5 # Maximum instances -# MaxIdleTime: 300 # Seconds before auto-stop -# -# Resources: -# serverlessConfig: -# Resources: -# CPU: 0.5 # CPU cores (0.1 to 4) -# MemoryMB: 256 # Memory in MB -# MemoryMaxMB: 512 # Max memory in MB -# -# Timeout: -# serverlessConfig: -# Timeout: 120 # Request timeout in seconds -# -# For more info: https://docs.boltic.io/compute/serverless/application-config -# ============================================================================ - -app: edith002 -build: - builtin: dockerfile - ignorefile: .gitignore -language: nodejs/18 +app: renderer-bundle region: asia-south1 -serverlessConfig: - Scaling: - AutoStop: false - Min: 1 - Max: 1 - MaxIdleTime: 300 - Resources: - CPU: 0.1 - MemoryMB: 128 - MemoryMaxMB: 128 + +build: + builtin: dockerfile + ignorefile: .gitignore + +env: + PORT: "8080" diff --git a/index.html b/index.html new file mode 100644 index 0000000..3e1fe4a --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + renderer-bundle + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..0e56b63 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "renderer-bundle", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "node ./node_modules/typescript/bin/tsc -b && node ./node_modules/vite/bin/vite.js build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-router-dom": "^7.11.0", + "uuid": "^13.0.0" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "typescript": "~5.9.3", + "vite": "^7.2.4" + } +} diff --git a/public/pagesData.json b/public/pagesData.json new file mode 100644 index 0000000..3d8afd8 --- /dev/null +++ b/public/pagesData.json @@ -0,0 +1 @@ +{"pages": [{"id": "pf-page-home", "name": "Home", "root": {"id": "pf-root", "type": "column", "styles": {"width": "100%", "display": "flex", "minHeight": "100vh", "flexDirection": "column", "backgroundColor": "#0f0f0f"}, "children": [{"id": "pf-hero", "type": "column", "styles": {"gap": "20px", "width": "100%", "display": "flex", "padding": "120px 80px", "flexDirection": "column", "justifyContent": "center", "backgroundColor": "#0f0f0f"}, "children": [{"id": "pf-hero-greeting", "type": "text", "props": {"text": "Hello, I am", "elementType": "span"}, "styles": {"color": "#FF592F", "fontSize": "14px", "fontWeight": "600", "letterSpacing": "3px", "textTransform": "uppercase"}, "layerName": "Greeting"}, {"id": "pf-hero-name", "type": "text", "props": {"text": "John Doe", "elementType": "h1"}, "styles": {"color": "#ffffff", "fontSize": "68px", "fontWeight": "800", "lineHeight": "1.05", "letterSpacing": "-2px"}, "layerName": "Name"}, {"id": "pf-hero-role", "type": "text", "props": {"text": "Full Stack Developer", "elementType": "h2"}, "styles": {"color": "#94a3b8", "fontSize": "28px", "fontWeight": "400"}, "layerName": "Role"}, {"id": "pf-hero-bio", "type": "text", "props": {"text": "I design and build digital experiences that are fast, accessible, and beautiful. Focused on React, Node.js, and everything in between.", "elementType": "p"}, "styles": {"color": "#64748b", "fontSize": "16px", "maxWidth": "520px", "lineHeight": "1.8"}, "layerName": "Short Bio"}, {"id": "pf-hero-actions", "type": "row", "styles": {"gap": "16px", "display": "flex", "marginTop": "8px", "alignItems": "center", "flexDirection": "row"}, "children": [{"id": "pf-cta-work", "type": "button", "props": {"text": "View My Work"}, "styles": {"color": "#ffffff", "border": "none", "cursor": "pointer", "padding": "14px 32px", "fontSize": "15px", "fontWeight": "600", "borderRadius": "8px", "backgroundColor": "#FF592F"}, "layerName": "View Work"}, {"id": "pf-cta-contact", "type": "button", "props": {"text": "Contact Me"}, "styles": {"color": "#ffffff", "border": "1px solid rgba(255,255,255,0.2)", "cursor": "pointer", "padding": "14px 32px", "fontSize": "15px", "fontWeight": "600", "borderRadius": "8px", "backgroundColor": "transparent"}, "layerName": "Contact"}], "layerName": "Hero Actions"}], "layerName": "Hero"}, {"id": "pf-about", "type": "row", "styles": {"gap": "64px", "width": "100%", "display": "flex", "padding": "80px", "alignItems": "flex-start", "flexDirection": "row", "backgroundColor": "#ffffff"}, "children": [{"id": "pf-about-left", "type": "column", "styles": {"gap": "16px", "flex": "1", "display": "flex", "flexDirection": "column"}, "children": [{"id": "pf-about-label", "type": "text", "props": {"text": "About Me", "elementType": "span"}, "styles": {"color": "#FF592F", "fontSize": "12px", "fontWeight": "700", "letterSpacing": "3px", "textTransform": "uppercase"}, "layerName": "About Label"}, {"id": "pf-about-title", "type": "text", "props": {"text": "Passionate about crafting great software", "elementType": "h2"}, "styles": {"color": "#1a1a2e", "fontSize": "36px", "fontWeight": "700", "lineHeight": "1.25"}, "layerName": "About Title"}], "layerName": "About Left"}, {"id": "pf-about-right", "type": "column", "styles": {"gap": "20px", "flex": "1", "display": "flex", "flexDirection": "column", "justifyContent": "center"}, "children": [{"id": "pf-about-text", "type": "text", "props": {"text": "I'm a software developer with 5+ years of experience building scalable web applications. I care deeply about clean code, great UX, and developer experience.", "elementType": "p"}, "styles": {"color": "#6b7280", "fontSize": "16px", "lineHeight": "1.8"}, "layerName": "About Text"}, {"id": "pf-about-skills", "type": "row", "styles": {"gap": "8px", "display": "flex", "flexWrap": "wrap", "flexDirection": "row"}, "children": [{"id": "pf-skill-react", "type": "text", "props": {"text": "React", "elementType": "span"}, "styles": {"color": "#1a1a2e", "padding": "6px 14px", "fontSize": "13px", "fontWeight": "600", "borderRadius": "999px", "backgroundColor": "#f1f5f9"}, "layerName": "React"}, {"id": "pf-skill-typescript", "type": "text", "props": {"text": "TypeScript", "elementType": "span"}, "styles": {"color": "#1a1a2e", "padding": "6px 14px", "fontSize": "13px", "fontWeight": "600", "borderRadius": "999px", "backgroundColor": "#f1f5f9"}, "layerName": "TypeScript"}, {"id": "pf-skill-node.js", "type": "text", "props": {"text": "Node.js", "elementType": "span"}, "styles": {"color": "#1a1a2e", "padding": "6px 14px", "fontSize": "13px", "fontWeight": "600", "borderRadius": "999px", "backgroundColor": "#f1f5f9"}, "layerName": "Node.js"}, {"id": "pf-skill-postgresql", "type": "text", "props": {"text": "PostgreSQL", "elementType": "span"}, "styles": {"color": "#1a1a2e", "padding": "6px 14px", "fontSize": "13px", "fontWeight": "600", "borderRadius": "999px", "backgroundColor": "#f1f5f9"}, "layerName": "PostgreSQL"}, {"id": "pf-skill-docker", "type": "text", "props": {"text": "Docker", "elementType": "span"}, "styles": {"color": "#1a1a2e", "padding": "6px 14px", "fontSize": "13px", "fontWeight": "600", "borderRadius": "999px", "backgroundColor": "#f1f5f9"}, "layerName": "Docker"}], "layerName": "Skills"}], "layerName": "About Right"}], "layerName": "About"}, {"id": "pf-projects", "type": "column", "styles": {"gap": "16px", "width": "100%", "display": "flex", "padding": "80px", "alignItems": "center", "flexDirection": "column", "backgroundColor": "#f8f9fa"}, "children": [{"id": "pf-projects-label", "type": "text", "props": {"text": "My Work", "elementType": "span"}, "styles": {"color": "#FF592F", "fontSize": "12px", "fontWeight": "700", "letterSpacing": "3px", "textTransform": "uppercase"}, "layerName": "Projects Label"}, {"id": "pf-projects-title", "type": "text", "props": {"text": "Selected Projects", "elementType": "h2"}, "styles": {"color": "#1a1a2e", "fontSize": "36px", "textAlign": "center", "fontWeight": "700"}, "layerName": "Projects Title"}, {"id": "pf-projects-grid", "type": "grid", "styles": {"gap": "24px", "width": "100%", "display": "grid", "maxWidth": "960px", "marginTop": "32px", "gridTemplateColumns": "repeat(2, 1fr)"}, "children": [{"id": "pf-proj-1", "type": "column", "styles": {"gap": "12px", "display": "flex", "padding": "32px", "boxShadow": "0 2px 12px rgba(0,0,0,0.06)", "borderRadius": "12px", "flexDirection": "column", "backgroundColor": "#ffffff"}, "children": [{"id": "pf-proj-1-title", "type": "text", "props": {"text": "E-Commerce Platform", "elementType": "h3"}, "styles": {"color": "#1a1a2e", "fontSize": "20px", "fontWeight": "700"}, "layerName": "Title"}, {"id": "pf-proj-1-desc", "type": "text", "props": {"text": "A full-featured e-commerce platform with real-time inventory, payments, and analytics dashboard.", "elementType": "p"}, "styles": {"color": "#6b7280", "fontSize": "14px", "lineHeight": "1.6"}, "layerName": "Desc"}, {"id": "pf-proj-1-tags", "type": "row", "styles": {"gap": "8px", "display": "flex", "flexWrap": "wrap", "flexDirection": "row"}, "children": [{"id": "pf-proj-1-tag-React", "type": "text", "props": {"text": "React", "elementType": "span"}, "styles": {"color": "#64748b", "padding": "4px 10px", "fontSize": "12px", "fontWeight": "500", "borderRadius": "4px", "backgroundColor": "#f1f5f9"}}, {"id": "pf-proj-1-tag-Node.js", "type": "text", "props": {"text": "Node.js", "elementType": "span"}, "styles": {"color": "#64748b", "padding": "4px 10px", "fontSize": "12px", "fontWeight": "500", "borderRadius": "4px", "backgroundColor": "#f1f5f9"}}, {"id": "pf-proj-1-tag-Stripe", "type": "text", "props": {"text": "Stripe", "elementType": "span"}, "styles": {"color": "#64748b", "padding": "4px 10px", "fontSize": "12px", "fontWeight": "500", "borderRadius": "4px", "backgroundColor": "#f1f5f9"}}], "layerName": "Tags"}, {"id": "pf-proj-1-btn", "type": "button", "props": {"text": "View Project →"}, "styles": {"color": "#FF592F", "width": "fit-content", "border": "1px solid #FF592F", "cursor": "pointer", "padding": "8px 20px", "fontSize": "14px", "marginTop": "4px", "fontWeight": "600", "borderRadius": "6px", "backgroundColor": "transparent"}, "layerName": "View Project"}], "layerName": "Project 1"}, {"id": "pf-proj-2", "type": "column", "styles": {"gap": "12px", "display": "flex", "padding": "32px", "boxShadow": "0 2px 12px rgba(0,0,0,0.06)", "borderRadius": "12px", "flexDirection": "column", "backgroundColor": "#ffffff"}, "children": [{"id": "pf-proj-2-title", "type": "text", "props": {"text": "AI Chat Assistant", "elementType": "h3"}, "styles": {"color": "#1a1a2e", "fontSize": "20px", "fontWeight": "700"}, "layerName": "Title"}, {"id": "pf-proj-2-desc", "type": "text", "props": {"text": "A conversational AI assistant with document understanding, custom personas, and multi-channel deployment.", "elementType": "p"}, "styles": {"color": "#6b7280", "fontSize": "14px", "lineHeight": "1.6"}, "layerName": "Desc"}, {"id": "pf-proj-2-tags", "type": "row", "styles": {"gap": "8px", "display": "flex", "flexWrap": "wrap", "flexDirection": "row"}, "children": [{"id": "pf-proj-2-tag-Python", "type": "text", "props": {"text": "Python", "elementType": "span"}, "styles": {"color": "#64748b", "padding": "4px 10px", "fontSize": "12px", "fontWeight": "500", "borderRadius": "4px", "backgroundColor": "#f1f5f9"}}, {"id": "pf-proj-2-tag-OpenAI", "type": "text", "props": {"text": "OpenAI", "elementType": "span"}, "styles": {"color": "#64748b", "padding": "4px 10px", "fontSize": "12px", "fontWeight": "500", "borderRadius": "4px", "backgroundColor": "#f1f5f9"}}, {"id": "pf-proj-2-tag-FastAPI", "type": "text", "props": {"text": "FastAPI", "elementType": "span"}, "styles": {"color": "#64748b", "padding": "4px 10px", "fontSize": "12px", "fontWeight": "500", "borderRadius": "4px", "backgroundColor": "#f1f5f9"}}], "layerName": "Tags"}, {"id": "pf-proj-2-btn", "type": "button", "props": {"text": "View Project →"}, "styles": {"color": "#FF592F", "width": "fit-content", "border": "1px solid #FF592F", "cursor": "pointer", "padding": "8px 20px", "fontSize": "14px", "marginTop": "4px", "fontWeight": "600", "borderRadius": "6px", "backgroundColor": "transparent"}, "layerName": "View Project"}], "layerName": "Project 2"}, {"id": "pf-proj-3", "type": "column", "styles": {"gap": "12px", "display": "flex", "padding": "32px", "boxShadow": "0 2px 12px rgba(0,0,0,0.06)", "borderRadius": "12px", "flexDirection": "column", "backgroundColor": "#ffffff"}, "children": [{"id": "pf-proj-3-title", "type": "text", "props": {"text": "Analytics Dashboard", "elementType": "h3"}, "styles": {"color": "#1a1a2e", "fontSize": "20px", "fontWeight": "700"}, "layerName": "Title"}, {"id": "pf-proj-3-desc", "type": "text", "props": {"text": "Real-time analytics with customizable widgets, team collaboration, and automated reports.", "elementType": "p"}, "styles": {"color": "#6b7280", "fontSize": "14px", "lineHeight": "1.6"}, "layerName": "Desc"}, {"id": "pf-proj-3-tags", "type": "row", "styles": {"gap": "8px", "display": "flex", "flexWrap": "wrap", "flexDirection": "row"}, "children": [{"id": "pf-proj-3-tag-React", "type": "text", "props": {"text": "React", "elementType": "span"}, "styles": {"color": "#64748b", "padding": "4px 10px", "fontSize": "12px", "fontWeight": "500", "borderRadius": "4px", "backgroundColor": "#f1f5f9"}}, {"id": "pf-proj-3-tag-D3.js", "type": "text", "props": {"text": "D3.js", "elementType": "span"}, "styles": {"color": "#64748b", "padding": "4px 10px", "fontSize": "12px", "fontWeight": "500", "borderRadius": "4px", "backgroundColor": "#f1f5f9"}}, {"id": "pf-proj-3-tag-PostgreSQL", "type": "text", "props": {"text": "PostgreSQL", "elementType": "span"}, "styles": {"color": "#64748b", "padding": "4px 10px", "fontSize": "12px", "fontWeight": "500", "borderRadius": "4px", "backgroundColor": "#f1f5f9"}}], "layerName": "Tags"}, {"id": "pf-proj-3-btn", "type": "button", "props": {"text": "View Project →"}, "styles": {"color": "#FF592F", "width": "fit-content", "border": "1px solid #FF592F", "cursor": "pointer", "padding": "8px 20px", "fontSize": "14px", "marginTop": "4px", "fontWeight": "600", "borderRadius": "6px", "backgroundColor": "transparent"}, "layerName": "View Project"}], "layerName": "Project 3"}, {"id": "pf-proj-4", "type": "column", "styles": {"gap": "12px", "display": "flex", "padding": "32px", "boxShadow": "0 2px 12px rgba(0,0,0,0.06)", "borderRadius": "12px", "flexDirection": "column", "backgroundColor": "#ffffff"}, "children": [{"id": "pf-proj-4-title", "type": "text", "props": {"text": "DevOps Pipeline Tool", "elementType": "h3"}, "styles": {"color": "#1a1a2e", "fontSize": "20px", "fontWeight": "700"}, "layerName": "Title"}, {"id": "pf-proj-4-desc", "type": "text", "props": {"text": "A CI/CD pipeline tool with visual workflow builder, deployment tracking, and Slack notifications.", "elementType": "p"}, "styles": {"color": "#6b7280", "fontSize": "14px", "lineHeight": "1.6"}, "layerName": "Desc"}, {"id": "pf-proj-4-tags", "type": "row", "styles": {"gap": "8px", "display": "flex", "flexWrap": "wrap", "flexDirection": "row"}, "children": [{"id": "pf-proj-4-tag-Go", "type": "text", "props": {"text": "Go", "elementType": "span"}, "styles": {"color": "#64748b", "padding": "4px 10px", "fontSize": "12px", "fontWeight": "500", "borderRadius": "4px", "backgroundColor": "#f1f5f9"}}, {"id": "pf-proj-4-tag-Docker", "type": "text", "props": {"text": "Docker", "elementType": "span"}, "styles": {"color": "#64748b", "padding": "4px 10px", "fontSize": "12px", "fontWeight": "500", "borderRadius": "4px", "backgroundColor": "#f1f5f9"}}, {"id": "pf-proj-4-tag-Kubernetes", "type": "text", "props": {"text": "Kubernetes", "elementType": "span"}, "styles": {"color": "#64748b", "padding": "4px 10px", "fontSize": "12px", "fontWeight": "500", "borderRadius": "4px", "backgroundColor": "#f1f5f9"}}], "layerName": "Tags"}, {"id": "pf-proj-4-btn", "type": "button", "props": {"text": "View Project →"}, "styles": {"color": "#FF592F", "width": "fit-content", "border": "1px solid #FF592F", "cursor": "pointer", "padding": "8px 20px", "fontSize": "14px", "marginTop": "4px", "fontWeight": "600", "borderRadius": "6px", "backgroundColor": "transparent"}, "layerName": "View Project"}], "layerName": "Project 4"}], "layerName": "Projects Grid"}], "layerName": "Projects"}, {"id": "pf-contact", "type": "column", "styles": {"gap": "24px", "width": "100%", "display": "flex", "padding": "80px", "alignItems": "center", "flexDirection": "column", "backgroundColor": "#1a1a2e"}, "children": [{"id": "pf-contact-title", "type": "text", "props": {"text": "Let's Work Together", "elementType": "h2"}, "styles": {"color": "#ffffff", "fontSize": "36px", "textAlign": "center", "fontWeight": "700"}, "layerName": "Contact Title"}, {"id": "pf-contact-subtitle", "type": "text", "props": {"text": "Have a project in mind? I'd love to hear about it.", "elementType": "p"}, "styles": {"color": "#94a3b8", "fontSize": "16px", "textAlign": "center"}, "layerName": "Contact Subtitle"}, {"id": "pf-contact-form", "type": "row", "styles": {"gap": "12px", "display": "flex", "marginTop": "8px", "alignItems": "center", "flexDirection": "row"}, "children": [{"id": "pf-contact-email", "type": "input", "props": {"type": "email", "placeholder": "your@email.com"}, "styles": {"color": "#ffffff", "width": "320px", "border": "1px solid rgba(255,255,255,0.15)", "padding": "12px 20px", "fontSize": "15px", "borderRadius": "8px", "backgroundColor": "rgba(255,255,255,0.05)"}, "layerName": "Email Input"}, {"id": "pf-contact-send", "type": "button", "props": {"text": "Send Message"}, "styles": {"color": "#ffffff", "border": "none", "cursor": "pointer", "padding": "12px 28px", "fontSize": "15px", "fontWeight": "600", "borderRadius": "8px", "backgroundColor": "#FF592F"}, "layerName": "Send Button"}], "layerName": "Contact Form"}], "layerName": "Contact"}], "layerName": "Page"}, "route": "/", "isDefault": true}], "stateData": {"state": {}, "version": 1}, "currentPageId": "pf-page-home", "pageStateData": {}} \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..b84bd2e --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { RendererApp } from './renderer'; + +export function App() { + const [json, setJson] = React.useState(''); + const [error, setError] = React.useState(null); + + React.useEffect(() => { + fetch('/pagesData.json') + .then((response) => { + if (!response.ok) { + throw new Error(`Failed to load pagesData.json (${response.status})`); + } + return response.text(); + }) + .then((rawJson) => { + let normalized = rawJson; + try { + const parsed = JSON.parse(rawJson) as { state?: unknown }; + if (parsed?.state && typeof parsed.state === 'object' && !Array.isArray(parsed.state)) { + normalized = JSON.stringify(parsed.state); + } + } catch { + normalized = rawJson; + } + setJson(normalized); + }) + .catch((fetchError) => { + setError(fetchError instanceof Error ? fetchError.message : String(fetchError)); + }); + }, []); + + if (error) { + return
Error: {error}
; + } + + if (!json) { + return
Loading renderer JSON\u2026
; + } + + return ( +
+ +
+ ); +} diff --git a/src/common/events/EventExecutor.ts b/src/common/events/EventExecutor.ts new file mode 100644 index 0000000..4a888a4 --- /dev/null +++ b/src/common/events/EventExecutor.ts @@ -0,0 +1,424 @@ +import type { EventHandler, Page } from '../types/component.types'; +import { evaluateExpression } from '../utils/expressionEvaluator'; +import { resolveRequestBody } from '../utils/stateBinding'; +import { normalizeWorkflowResultKey, suggestWorkflowResultKeyFromName } from '../utils/workflowResultKey'; +import { parseScopedPath } from '../state/scopedPath'; + +/** + * Event types that components can dispatch + */ +export type EventType = 'click' | 'change' | 'submit' | 'focus' | 'blur'; + +/** + * Action types that event handlers can execute + */ +export type ActionType = 'setState' | 'navigateToPage' | 'workflow'; + +/** + * Context required for executing actions + */ +export interface ActionContext { + /** Set a state value by path */ + setStateValue: (path: string, value: any) => boolean; + /** Set a state value by raw key */ + setStateValueRaw: (key: string, value: any) => boolean; + /** Set a state value directly without type auto-detection (for expressions) */ + setStateValueDirect: (path: string, value: any) => boolean; + /** Navigate to a page by ID */ + navigateToPage: (pageId: string) => void; + /** All pages (for validation) */ + pages: Page[]; + /** Scoped runtime state (for expression evaluation) */ + state: any; +} + +/** + * Configuration for workflow execution + */ +export interface WorkflowConfig { + /** URL to call for workflow execution */ + executeUrl: string; + /** Request body builder */ + buildRequestBody?: (workflowName: string) => any; +} + +/** + * Action executor function type + */ +export type ActionExecutor = ( + handler: EventHandler, + context: ActionContext, + config: EventExecutorConfig +) => Promise; + +/** + * Configuration for the EventExecutor + */ +export interface EventExecutorConfig { + workflow?: WorkflowConfig; +} + +/** + * Validates a single event handler structure + */ +const isValidHandler = (handler: any): handler is EventHandler => { + if (!handler || typeof handler !== 'object') return false; + if (!handler.id || typeof handler.id !== 'string') return false; + if (!handler.action || !['setState', 'navigateToPage', 'workflow'].includes(handler.action)) return false; + + if (handler.action === 'setState') { + return !!(handler.statePath && handler.value !== undefined); + } else if (handler.action === 'navigateToPage') { + return !!handler.pageId; + } else if (handler.action === 'workflow') { + return !!handler.workflowName; + } + + return false; +}; + +/** + * Validates and filters event handlers array + */ +export const validateHandlers = (handlers: any[]): EventHandler[] => { + if (!Array.isArray(handlers)) return []; + return handlers.filter(isValidHandler); +}; + +// ============= Action Executors ============= + +/** + * Execute setState action + */ +const executeSetState: ActionExecutor = async (handler, context) => { + if (!handler.statePath || typeof handler.statePath !== 'string') { + throw new Error(`Invalid state path for handler ${handler.id}`); + } + let parsedStatePath; + try { + parsedStatePath = parseScopedPath(handler.statePath); + } catch (error) { + throw new Error( + `Invalid scoped state path for handler ${handler.id}: ${error instanceof Error ? error.message : String(error)}` + ); + } + + // Evaluate expression if value is a string (JavaScript expression) + let evaluatedValue = handler.value; + if (typeof handler.value === 'string' && handler.value.trim().length > 0) { + try { + evaluatedValue = evaluateExpression(handler.value, { state: context.state }); + } catch (error: any) { + throw new Error(`Expression evaluation failed for handler ${handler.id}: ${error?.message || String(error)}`); + } + } + + // Use setStateValueDirect to preserve expression result type + // This prevents "1" from being auto-converted to 1, which would break string methods like .slice() + const success = context.setStateValueDirect(parsedStatePath.fullPath, evaluatedValue); + if (!success) { + throw new Error(`Failed to set state value for handler ${handler.id}`); + } +}; + +/** + * Execute navigateToPage action + */ +const executeNavigateToPage: ActionExecutor = async (handler, context) => { + if (!handler.pageId) { + throw new Error(`Missing pageId for handler ${handler.id}`); + } + + const pageExists = context.pages.some((p) => p.id === handler.pageId); + if (!pageExists) { + throw new Error(`Page ${handler.pageId} does not exist for handler ${handler.id}`); + } + + context.navigateToPage(handler.pageId); +}; + +/** + * Execute workflow action + */ +const executeWorkflow: ActionExecutor = async (handler, context, config) => { + if (!handler.workflowName || typeof handler.workflowName !== 'string') { + throw new Error(`Missing workflow name for handler ${handler.id}`); + } + + // Use handler's workflowUrl if available, otherwise fall back to config + const workflowUrl = handler.workflowUrl || config.workflow?.executeUrl; + + if (!workflowUrl) { + throw new Error( + `Workflow handler ${handler.id} requires a workflow URL. Provide handler.workflowUrl or configure workflow.executeUrl.` + ); + } + + // Resolve request body with state bindings + let requestBody: any = {}; + + if (handler.requestBody) { + // Use handler's custom request body and resolve {{ }} bindings + try { + requestBody = resolveRequestBody(handler.requestBody, context.state); + } catch (error) { + throw new Error(`Failed to resolve request body for handler ${handler.id}: ${error instanceof Error ? error.message : String(error)}`); + } + } else { + const buildRequestBody = config.workflow?.buildRequestBody; + if (buildRequestBody) { + // Fallback to config's buildRequestBody (for backward compatibility) + requestBody = buildRequestBody(handler.workflowName); + } + } + + const response = await fetch(workflowUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + throw new Error(`Workflow execution failed: ${response.status}`); + } + + const responseText = await response.text(); + let workflowResult: any = null; + + if (responseText) { + try { + workflowResult = JSON.parse(responseText); + } catch { + workflowResult = responseText; + } + } + + let normalizedResultKey; + try { + const resultKeyInput = handler.workflowResultKey?.trim() + ? handler.workflowResultKey + : suggestWorkflowResultKeyFromName(handler.workflowName); + normalizedResultKey = normalizeWorkflowResultKey(resultKeyInput); + } catch (error) { + throw new Error( + `Invalid workflow result key for handler ${handler.id}: ${error instanceof Error ? error.message : String(error)}` + ); + } + + const success = context.setStateValueDirect(normalizedResultKey.fullPath, workflowResult); + if (!success) { + throw new Error(`Failed to save workflow result for handler ${handler.id}`); + } +}; + +// ============= Action Registry ============= + +/** + * Registry of action executors + */ +const actionExecutors: Map = new Map([ + ['setState', executeSetState], + ['navigateToPage', executeNavigateToPage], + ['workflow', executeWorkflow], +]); + +/** + * Register a custom action executor + */ +export const registerActionExecutor = (action: string, executor: ActionExecutor): void => { + actionExecutors.set(action as ActionType, executor); +}; + +/** + * Get an action executor by action type + */ +export const getActionExecutor = (action: ActionType): ActionExecutor | undefined => { + return actionExecutors.get(action); +}; + +// ============= EventExecutor Class ============= + +/** + * EventExecutor - Centralized service for executing event handlers + */ +export class EventExecutor { + private config: EventExecutorConfig; + private context: ActionContext | null = null; + + constructor(config?: Partial) { + this.config = { + workflow: config?.workflow, + }; + } + + /** + * Set the action context (called when context changes) + */ + setContext(context: ActionContext): void { + this.context = context; + } + + /** + * Evaluate a handler's condition (if present) + * @param handler - Event handler with optional condition + * @returns true if condition passes or no condition exists, false otherwise + * @throws Error if condition evaluation fails with error message + */ + private evaluateCondition(handler: EventHandler): boolean { + // If no condition specified, always execute (backward compatibility) + if (!handler.condition || handler.condition.trim() === '') { + return true; + } + + if (!this.context) { + return false; + } + + try { + // Evaluate the condition expression + const result = evaluateExpression(handler.condition, { state: this.context.state }); + + // If condition is falsy, check if we should show an error + if (!result) { + if (handler.conditionErrorMessage) { + // Throw error with custom message - will be caught by executeWithErrorHandling + throw new Error(handler.conditionErrorMessage); + } + // No error message - silently skip this handler + return false; + } + + return true; + } catch (error: any) { + // If there's a custom error message from condition failure, use it + if (handler.conditionErrorMessage && error.message === handler.conditionErrorMessage) { + throw error; // Re-throw to preserve custom message + } + // Otherwise, it's an expression evaluation error + throw new Error(`Condition evaluation failed for handler ${handler.id}: ${error?.message || String(error)}`); + } + } + + /** + * Execute event handlers sequentially + * @param handlers - Array of event handlers to execute + * @throws Error if execution fails + */ + async execute(handlers: EventHandler[]): Promise { + if (!this.context) { + throw new Error('EventExecutor context not set. Call setContext() first.'); + } + + const validHandlers = validateHandlers(handlers); + if (validHandlers.length === 0) return; + + for (const handler of validHandlers) { + // Evaluate condition before executing handler + const shouldExecute = this.evaluateCondition(handler); + + // Skip this handler if condition failed (without error message) + if (!shouldExecute) { + console.log(`Skipping handler ${handler.id} - condition not met`); + continue; + } + + const executor = actionExecutors.get(handler.action); + if (!executor) { + throw new Error(`Unknown action type: ${handler.action}`); + } + + await executor(handler, this.context, this.config); + } + } + + /** + * Execute handlers with error handling (shows alert on failure) + */ + async executeWithErrorHandling(handlers: EventHandler[]): Promise { + try { + await this.execute(handlers); + return true; + } catch (error) { + console.error('Event execution failed:', error); + const message = error instanceof Error && error.message + ? error.message + : 'Event handler execution failed. Please check your workflow or state configuration.'; + alert(message); + return false; + } + } +} + +// ============= Singleton Instance ============= + +/** + * Global EventExecutor instance + */ +export const eventExecutor = new EventExecutor(); + +// ============= Legacy API (backward compatibility) ============= + +/** + * Legacy function for backward compatibility + * @deprecated Use eventExecutor.execute() instead + */ +export const executeEventHandlers = async ( + handlers: EventHandler[], + context: ActionContext & { runWorkflow?: (workflowName: string) => Promise } +): Promise => { + const validHandlers = validateHandlers(handlers); + if (validHandlers.length === 0) return; + + for (const handler of validHandlers) { + if (handler.action === 'setState') { + if (!handler.statePath || typeof handler.statePath !== 'string') { + throw new Error(`Invalid state path for handler ${handler.id}`); + } + let parsedStatePath; + try { + parsedStatePath = parseScopedPath(handler.statePath); + } catch (error) { + throw new Error( + `Invalid scoped state path for handler ${handler.id}: ${error instanceof Error ? error.message : String(error)}` + ); + } + + const success = context.setStateValue(parsedStatePath.fullPath, handler.value); + if (!success) { + throw new Error(`Failed to set state value for handler ${handler.id}`); + } + } else if (handler.action === 'navigateToPage') { + if (!handler.pageId) { + throw new Error(`Missing pageId for handler ${handler.id}`); + } + const pageExists = context.pages.some((p) => p.id === handler.pageId); + if (!pageExists) { + throw new Error(`Page ${handler.pageId} does not exist for handler ${handler.id}`); + } + context.navigateToPage(handler.pageId); + } else if (handler.action === 'workflow') { + if (!handler.workflowName || typeof handler.workflowName !== 'string') { + throw new Error(`Missing workflow name for handler ${handler.id}`); + } + if (context.runWorkflow) { + const workflowResult = await context.runWorkflow(handler.workflowName); + let normalizedResultKey; + try { + const resultKeyInput = handler.workflowResultKey?.trim() + ? handler.workflowResultKey + : suggestWorkflowResultKeyFromName(handler.workflowName); + normalizedResultKey = normalizeWorkflowResultKey(resultKeyInput); + } catch (error) { + throw new Error( + `Invalid workflow result key for handler ${handler.id}: ${error instanceof Error ? error.message : String(error)}` + ); + } + + const success = context.setStateValueDirect(normalizedResultKey.fullPath, workflowResult); + if (!success) { + throw new Error(`Failed to save workflow result for handler ${handler.id}`); + } + } + } + } +}; diff --git a/src/common/events/index.ts b/src/common/events/index.ts new file mode 100644 index 0000000..6af615e --- /dev/null +++ b/src/common/events/index.ts @@ -0,0 +1,36 @@ +/** + * Events Module + * + * Centralized event system for component event handling. + * + * Usage: + * 1. Import the eventExecutor singleton: + * import { eventExecutor } from './events'; + * + * 2. Set context when runtime initializes: + * eventExecutor.setContext({ setStateValue, navigateToPage, pages }); + * + * 3. Execute handlers: + * await eventExecutor.execute(handlers); + * + * 4. Register custom action executors: + * registerActionExecutor('customAction', async (handler, context) => { ... }); + */ + +export { + EventExecutor, + eventExecutor, + registerActionExecutor, + getActionExecutor, + validateHandlers, + executeEventHandlers, +} from './EventExecutor'; + +export type { + EventType, + ActionType, + ActionContext, + ActionExecutor, + WorkflowConfig, + EventExecutorConfig, +} from './EventExecutor'; diff --git a/src/common/icons/ComponentIcons.tsx b/src/common/icons/ComponentIcons.tsx new file mode 100644 index 0000000..c8369f3 --- /dev/null +++ b/src/common/icons/ComponentIcons.tsx @@ -0,0 +1,96 @@ +import React from 'react'; + +interface IconProps { + size?: number; + className?: string; + style?: React.CSSProperties; +} + +export const BoxIcon: React.FC = ({ size = 16, style }) => ( + + + +); + +export const ColumnIcon: React.FC = ({ size = 16, style }) => ( + + + + + +); + +export const RowIcon: React.FC = ({ size = 16, style }) => ( + + + + + +); + +export const ButtonIcon: React.FC = ({ size = 16, style }) => ( + + + +); + +export const TextIcon: React.FC = ({ size = 16, style }) => ( + + + +); + +export const GridIcon: React.FC = ({ size = 16, style }) => ( + + + + + + +); + +export const ListIcon: React.FC = ({ size = 16, style }) => ( + + + + + + + + +); + +export const InputIcon: React.FC = ({ size = 16, style }) => ( + + + + +); + +export const MobileIcon: React.FC = ({ size = 16, style }) => ( + + + + +); + +export const TabletIcon: React.FC = ({ size = 16, style }) => ( + + + + +); + +export const DesktopIcon: React.FC = ({ size = 16, style }) => ( + + + + + +); + +export const PlusIcon: React.FC = ({ size = 16, style }) => ( + + + +); diff --git a/src/common/registry/ComponentRegistry.ts b/src/common/registry/ComponentRegistry.ts new file mode 100644 index 0000000..3ffd669 --- /dev/null +++ b/src/common/registry/ComponentRegistry.ts @@ -0,0 +1,113 @@ +import type { CSSProperties } from 'react'; +import type { ComponentDefinition } from './types'; + +/** + * Centralized registry for all component definitions. + * Singleton pattern ensures a single source of truth. + */ +class ComponentRegistryClass { + private definitions: Map = new Map(); + + /** + * Register a component definition + * @param definition The component definition to register + * @throws Error if component type is already registered + */ + register(definition: ComponentDefinition): void { + if (this.definitions.has(definition.type)) { + console.warn(`Component type "${definition.type}" is already registered. Overwriting.`); + } + this.definitions.set(definition.type, definition); + } + + /** + * Get a component definition by type + * @param type The component type to look up + * @returns The component definition or undefined if not found + */ + get(type: string): ComponentDefinition | undefined { + return this.definitions.get(type); + } + + /** + * Check if a component type is registered + * @param type The component type to check + */ + has(type: string): boolean { + return this.definitions.has(type); + } + + /** + * Get all registered component definitions + * @returns Array of all component definitions + */ + getAll(): ComponentDefinition[] { + return Array.from(this.definitions.values()); + } + + /** + * Get component definitions filtered by category + * @param category 'container' or 'leaf' + */ + getByCategory(category: 'container' | 'leaf'): ComponentDefinition[] { + return this.getAll().filter((def) => def.category === category); + } + + /** + * Get default styles for a component type + * @param type The component type + * @returns Default styles or empty object if not found + */ + getDefaultStyles(type: string): CSSProperties { + const definition = this.get(type); + return definition?.defaultStyles ?? {}; + } + + /** + * Get default props for a component type + * @param type The component type + * @returns Default props or empty object if not found + */ + getDefaultProps(type: string): Record { + const definition = this.get(type); + return definition?.defaultProps ?? {}; + } + + /** + * Check if a component type can have children + * @param type The component type + * @returns true if component can have children, false otherwise + */ + canHaveChildren(type: string): boolean { + const definition = this.get(type); + return definition?.canHaveChildren ?? false; + } + + /** + * Get icon component for a component type + * @param type The component type + * @returns Icon component or undefined if not found + */ + getIcon(type: string): React.ComponentType | undefined { + const definition = this.get(type); + return definition?.icon; + } + + /** + * Get all registered component types + * @returns Array of component type strings + */ + getTypes(): string[] { + return Array.from(this.definitions.keys()); + } + + /** + * Clear all registered components (useful for testing) + */ + clear(): void { + this.definitions.clear(); + } +} + +// Export singleton instance +export const ComponentRegistry = new ComponentRegistryClass(); diff --git a/src/common/registry/definitions/box.ts b/src/common/registry/definitions/box.ts new file mode 100644 index 0000000..ad76653 --- /dev/null +++ b/src/common/registry/definitions/box.ts @@ -0,0 +1,172 @@ +import React from 'react'; +import { BoxIcon } from '../../icons/ComponentIcons'; +import type { ComponentDefinition } from '../types'; + +export const boxDefinition: ComponentDefinition = { + type: 'box', + label: 'Box', + icon: BoxIcon, + category: 'container', + canHaveChildren: true, + + defaultStyles: { + width: 'auto', + display: 'block', + }, + + defaultProps: {}, + + propertySchema: [], + + 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: 'display', + label: 'Display', + type: 'select', + category: 'layout', + options: [ + { value: 'block', label: 'Block' }, + { value: 'flex', label: 'Flex' }, + { value: 'grid', label: 'Grid' }, + { value: 'inline', label: 'Inline' }, + { value: 'inline-block', label: 'Inline Block' }, + { value: 'none', label: 'None' }, + ], + }, + { + key: 'alignSelf', + label: 'Self Align', + type: 'select', + category: 'layout', + options: [ + { value: 'auto', label: 'Auto' }, + { value: 'flex-start', label: 'Start' }, + { value: 'center', label: 'Center' }, + { value: 'flex-end', label: 'End' }, + { value: 'stretch', label: 'Stretch' }, + { value: 'baseline', label: 'Baseline' }, + ], + }, + { + 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' }, + ], + }, + // Positioning + { + key: 'position', + label: 'Position', + type: 'select', + category: 'layout', + options: [ + { value: 'static', label: 'Static' }, + { value: 'relative', label: 'Relative' }, + { value: 'absolute', label: 'Absolute' }, + { value: 'fixed', label: 'Fixed' }, + { value: 'sticky', label: 'Sticky' }, + ], + }, + { key: 'top', label: 'Top', type: 'dimension', category: 'layout' }, + { key: 'right', label: 'Right', type: 'dimension', category: 'layout' }, + { key: 'bottom', label: 'Bottom', type: 'dimension', category: 'layout' }, + { key: 'left', label: 'Left', type: 'dimension', category: 'layout' }, + { key: 'zIndex', label: 'Z-Index', type: 'number', category: 'layout' }, + // 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' }, + { value: 'double', label: 'Double' }, + ], + }, + { key: 'borderColor', label: 'Border Color', type: 'color', category: 'appearance' }, + { key: 'borderRadius', label: 'Border Radius', type: 'dimension', category: 'appearance' }, + { key: 'boxShadow', label: 'Box Shadow', type: 'boxShadow', 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 }) => { + const isEmpty = !component.children || component.children.length === 0; + + return React.createElement( + 'div', + { + style: { + ...component.styles, + outline: isSelected && !previewMode ? '2px solid rgb(255, 89, 47)' : 'none', + minHeight: isEmpty && !previewMode ? '60px' : 'auto', + position: 'relative' as const, + }, + onClick: previewMode + ? undefined + : (e: React.MouseEvent) => { + e.stopPropagation(); + onSelect(component.id); + }, + }, + children, + isEmpty && !previewMode + ? React.createElement( + 'div', + { + style: { + position: 'absolute' as const, + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + color: '#999', + fontSize: '14px', + pointerEvents: 'none' as const, + }, + }, + 'Drop components here' + ) + : null + ); + }, +}; diff --git a/src/common/registry/definitions/button.ts b/src/common/registry/definitions/button.ts new file mode 100644 index 0000000..f99e747 --- /dev/null +++ b/src/common/registry/definitions/button.ts @@ -0,0 +1,263 @@ +import React from 'react'; +import { ButtonIcon } from '../../icons/ComponentIcons'; +import type { ComponentDefinition, ComponentRenderProps } from '../types'; + +const ensureSpinnerStyles = () => { + if (typeof document === 'undefined') return; + if (document.getElementById('button-spinner-keyframes')) return; + + const style = document.createElement('style'); + style.id = 'button-spinner-keyframes'; + style.textContent = ` + @keyframes button-spin { + to { transform: rotate(360deg); } + } + `; + document.head.appendChild(style); +}; + +const ButtonComponent: React.FC = ({ component, isSelected, onSelect, previewMode, runtime }) => { + const rawTextContent = component.props?.text || 'Button'; + const textContent = runtime.resolveText(rawTextContent); + const [isLoading, setIsLoading] = React.useState(false); + + React.useEffect(() => { + ensureSpinnerStyles(); + }, []); + + const handleClick = async (e: React.MouseEvent) => { + e.stopPropagation(); + + if (previewMode) { + if (isLoading) return; + setIsLoading(true); + try { + await runtime.dispatchEvent('click', component, 'eventHandlers'); + } catch (error) { + console.error('Event execution failed:', error); + } finally { + setIsLoading(false); + } + return; + } + + onSelect(component.id); + }; + + const spinner = React.createElement('span', { + 'aria-hidden': true, + style: { + display: 'inline-block', + width: '12px', + height: '12px', + border: '2px solid currentColor', + borderTopColor: 'transparent', + borderRadius: '50%', + animation: 'button-spin 0.6s linear infinite', + marginRight: '6px', + }, + }); + + const loadingContent = React.createElement( + 'span', + { style: { display: 'inline-flex', alignItems: 'center' } }, + spinner, + textContent + ); + + return React.createElement( + 'button', + { + style: { + ...component.styles, + outline: isSelected && !previewMode ? '2px solid rgb(255, 89, 47)' : 'none', + opacity: isLoading ? 0.7 : component.styles.opacity, + cursor: isLoading ? 'not-allowed' : (component.styles.cursor || 'pointer'), + }, + onClick: handleClick, + disabled: isLoading, + 'aria-busy': isLoading, + }, + isLoading ? loadingContent : textContent + ); +}; + +/** + * Button component - fully decoupled from stores + * Uses runtime.resolveText() for text bindings + * Uses runtime.dispatchEvent() for event handling + */ +export const buttonDefinition: ComponentDefinition = { + type: 'button', + label: 'Button', + icon: ButtonIcon, + category: 'leaf', + canHaveChildren: false, + + defaultStyles: { + padding: '8px 16px', + backgroundColor: 'rgb(255, 89, 47)', + color: '#fff', + border: 'none', + borderRadius: '4px', + cursor: 'pointer', + width: 'auto', + alignSelf: 'auto', + }, + + defaultProps: { + text: 'Button', + eventHandlers: [], + }, + + propertySchema: [ + { key: 'text', label: 'Text', type: 'text-binding', category: 'content' }, + { key: 'eventHandlers', label: 'Event Handlers', type: 'custom', customEditor: 'eventHandlers', category: 'events' }, + ], + + 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: 'alignSelf', + label: 'Self Align', + type: 'select', + category: 'layout', + options: [ + { value: 'auto', label: 'Auto' }, + { value: 'flex-start', label: 'Start' }, + { value: 'center', label: 'Center' }, + { value: 'flex-end', label: 'End' }, + { value: 'stretch', label: 'Stretch' }, + { value: 'baseline', label: 'Baseline' }, + ], + }, + // Positioning + { + key: 'position', + label: 'Position', + type: 'select', + category: 'layout', + options: [ + { value: 'static', label: 'Static' }, + { value: 'relative', label: 'Relative' }, + { value: 'absolute', label: 'Absolute' }, + { value: 'fixed', label: 'Fixed' }, + { value: 'sticky', label: 'Sticky' }, + ], + }, + { key: 'top', label: 'Top', type: 'dimension', category: 'layout' }, + { key: 'right', label: 'Right', type: 'dimension', category: 'layout' }, + { key: 'bottom', label: 'Bottom', type: 'dimension', category: 'layout' }, + { key: 'left', label: 'Left', type: 'dimension', category: 'layout' }, + { key: 'zIndex', label: 'Z-Index', type: 'number', category: 'layout' }, + // 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: 'color', label: 'Text Color', type: 'color', category: 'appearance' }, + { key: 'opacity', label: 'Opacity', type: 'range', category: 'appearance' }, + // Typography + { key: 'fontSize', label: 'Font Size', type: 'dimension', category: 'appearance' }, + { + key: 'fontFamily', + label: 'Font Family', + type: 'select', + category: 'appearance', + options: [ + { value: 'inherit', label: 'Inherit' }, + { value: 'Arial, sans-serif', label: 'Arial' }, + { value: 'Helvetica, sans-serif', label: 'Helvetica' }, + { value: 'Georgia, serif', label: 'Georgia' }, + { value: '"Times New Roman", serif', label: 'Times New Roman' }, + { value: '"Courier New", monospace', label: 'Courier New' }, + { value: 'Verdana, sans-serif', label: 'Verdana' }, + { value: 'system-ui, sans-serif', label: 'System UI' }, + ], + }, + { + key: 'fontWeight', + label: 'Font Weight', + type: 'select', + category: 'appearance', + options: [ + { value: 'normal', label: 'Normal' }, + { value: 'bold', label: 'Bold' }, + { value: '100', label: '100 (Thin)' }, + { value: '200', label: '200' }, + { value: '300', label: '300 (Light)' }, + { value: '400', label: '400 (Normal)' }, + { value: '500', label: '500 (Medium)' }, + { value: '600', label: '600 (Semibold)' }, + { value: '700', label: '700 (Bold)' }, + { value: '800', label: '800' }, + { value: '900', label: '900 (Black)' }, + ], + }, + { + key: 'textTransform', + label: 'Text Transform', + type: 'select', + category: 'appearance', + options: [ + { value: 'none', label: 'None' }, + { value: 'uppercase', label: 'Uppercase' }, + { value: 'lowercase', label: 'Lowercase' }, + { value: 'capitalize', label: 'Capitalize' }, + ], + }, + // 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' }, + { value: 'double', label: 'Double' }, + ], + }, + { key: 'borderColor', label: 'Border Color', type: 'color', category: 'appearance' }, + { key: 'borderRadius', label: 'Border Radius', type: 'dimension', category: 'appearance' }, + { key: 'boxShadow', label: 'Box Shadow', type: 'boxShadow', 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' }, + ], + }, + ], + + // Event schema: what events this component supports + eventSchema: [ + { + type: 'click', + label: 'On Click', + description: 'Triggered when the button is clicked', + handlersKey: 'eventHandlers', + }, + ], + + render: (props: ComponentRenderProps) => React.createElement(ButtonComponent, props), +}; diff --git a/src/common/registry/definitions/column.ts b/src/common/registry/definitions/column.ts new file mode 100644 index 0000000..b7c3d68 --- /dev/null +++ b/src/common/registry/definitions/column.ts @@ -0,0 +1,185 @@ +import React from 'react'; +import { ColumnIcon } from '../../icons/ComponentIcons'; +import type { ComponentDefinition } from '../types'; + +export const columnDefinition: ComponentDefinition = { + type: 'column', + label: 'Column', + icon: ColumnIcon, + category: 'container', + canHaveChildren: true, + + defaultStyles: { + width: 'auto', + display: 'flex', + flexDirection: 'column', + gap: '8px', + }, + + defaultProps: {}, + + propertySchema: [], + + 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: 'justifyContent', + label: 'Vertical Align', + 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' }, + { value: 'space-evenly', label: 'Space Evenly' }, + ], + }, + { + key: 'alignItems', + label: 'Horizontal Align', + type: 'select', + category: 'layout', + options: [ + { value: 'flex-start', label: 'Start' }, + { value: 'center', label: 'Center' }, + { value: 'flex-end', label: 'End' }, + { value: 'stretch', label: 'Stretch' }, + { value: 'baseline', label: 'Baseline' }, + ], + }, + { + key: 'flexWrap', + label: 'Flex Wrap', + type: 'select', + category: 'layout', + options: [ + { value: 'nowrap', label: 'No Wrap' }, + { value: 'wrap', label: 'Wrap' }, + { value: 'wrap-reverse', label: 'Wrap Reverse' }, + ], + }, + { + 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' }, + ], + }, + // Positioning + { + key: 'position', + label: 'Position', + type: 'select', + category: 'layout', + options: [ + { value: 'static', label: 'Static' }, + { value: 'relative', label: 'Relative' }, + { value: 'absolute', label: 'Absolute' }, + { value: 'fixed', label: 'Fixed' }, + { value: 'sticky', label: 'Sticky' }, + ], + }, + { key: 'top', label: 'Top', type: 'dimension', category: 'layout' }, + { key: 'right', label: 'Right', type: 'dimension', category: 'layout' }, + { key: 'bottom', label: 'Bottom', type: 'dimension', category: 'layout' }, + { key: 'left', label: 'Left', type: 'dimension', category: 'layout' }, + { key: 'zIndex', label: 'Z-Index', type: 'number', category: 'layout' }, + // 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' }, + { value: 'double', label: 'Double' }, + ], + }, + { key: 'borderColor', label: 'Border Color', type: 'color', category: 'appearance' }, + { key: 'borderRadius', label: 'Border Radius', type: 'dimension', category: 'appearance' }, + { key: 'boxShadow', label: 'Box Shadow', type: 'boxShadow', 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 }) => { + const isEmpty = !component.children || component.children.length === 0; + + return React.createElement( + 'div', + { + style: { + ...component.styles, + outline: isSelected && !previewMode ? '2px solid rgb(255, 89, 47)' : 'none', + minHeight: isEmpty && !previewMode ? '60px' : 'auto', + position: 'relative' as const, + }, + onClick: previewMode + ? undefined + : (e: React.MouseEvent) => { + e.stopPropagation(); + onSelect(component.id); + }, + }, + children, + isEmpty && !previewMode + ? React.createElement( + 'div', + { + style: { + position: 'absolute' as const, + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + color: '#999', + fontSize: '14px', + pointerEvents: 'none' as const, + }, + }, + 'Drop components here' + ) + : null + ); + }, +}; diff --git a/src/common/registry/definitions/grid.ts b/src/common/registry/definitions/grid.ts new file mode 100644 index 0000000..6a56e0c --- /dev/null +++ b/src/common/registry/definitions/grid.ts @@ -0,0 +1,208 @@ +import React from 'react'; +import { GridIcon } from '../../icons/ComponentIcons'; +import type { ComponentDefinition } from '../types'; +import { ComponentRegistry } from '../ComponentRegistry'; + +export const gridDefinition: ComponentDefinition = { + type: 'grid', + label: 'Grid', + icon: GridIcon, + category: 'container', + canHaveChildren: true, + + defaultStyles: { + display: 'grid', + gridTemplateColumns: 'repeat(3, 1fr)', // 3 equal columns by default + gridTemplateRows: 'auto', // Auto rows + gap: '8px', + width: 'auto', + }, + + defaultProps: {}, + + propertySchema: [], + + styleSchema: [ + // Grid Structure + { key: 'gridTemplateColumns', label: 'Grid Columns', type: 'text', category: 'grid' }, + { key: 'gridTemplateRows', label: 'Grid Rows', type: 'text', category: 'grid' }, + { key: 'gridAutoRows', label: 'Auto Rows', type: 'dimension', category: 'grid' }, + { key: 'gridAutoColumns', label: 'Auto Columns', type: 'dimension', category: 'grid' }, + { + key: 'gridAutoFlow', + label: 'Auto Flow', + type: 'select', + category: 'grid', + options: [ + { value: 'row', label: 'Row' }, + { value: 'column', label: 'Column' }, + { value: 'dense', label: 'Dense' }, + { value: 'row dense', label: 'Row Dense' }, + { value: 'column dense', label: 'Column Dense' }, + ], + }, + // Gap/Spacing + { key: 'gap', label: 'Gap', type: 'dimension', category: 'spacing' }, + { key: 'rowGap', label: 'Row Gap', type: 'dimension', category: 'spacing' }, + { key: 'columnGap', label: 'Column Gap', type: 'dimension', category: 'spacing' }, + { key: 'padding', label: 'Padding', type: 'dimension', category: 'spacing' }, + { key: 'margin', label: 'Margin', type: 'dimension', category: 'spacing' }, + // Grid Container Alignment + { + key: 'justifyItems', + label: 'Justify Items', + type: 'select', + category: 'alignment', + options: [ + { value: 'start', label: 'Start' }, + { value: 'end', label: 'End' }, + { value: 'center', label: 'Center' }, + { value: 'stretch', label: 'Stretch' }, + ], + }, + { + key: 'alignItems', + label: 'Align Items', + type: 'select', + category: 'alignment', + options: [ + { value: 'start', label: 'Start' }, + { value: 'end', label: 'End' }, + { value: 'center', label: 'Center' }, + { value: 'stretch', label: 'Stretch' }, + ], + }, + { + key: 'justifyContent', + label: 'Justify Content', + type: 'select', + category: 'alignment', + options: [ + { value: 'start', label: 'Start' }, + { value: 'end', label: 'End' }, + { value: 'center', label: 'Center' }, + { value: 'stretch', label: 'Stretch' }, + { value: 'space-between', label: 'Space Between' }, + { value: 'space-around', label: 'Space Around' }, + { value: 'space-evenly', label: 'Space Evenly' }, + ], + }, + { + key: 'alignContent', + label: 'Align Content', + type: 'select', + category: 'alignment', + options: [ + { value: 'start', label: 'Start' }, + { value: 'end', label: 'End' }, + { value: 'center', label: 'Center' }, + { value: 'stretch', label: 'Stretch' }, + { value: 'space-between', label: 'Space Between' }, + { value: 'space-around', label: 'Space Around' }, + { value: 'space-evenly', label: 'Space Evenly' }, + ], + }, + // 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: 'overflow', + label: 'Overflow', + type: 'select', + category: 'layout', + options: [ + { value: 'visible', label: 'Visible' }, + { value: 'hidden', label: 'Hidden' }, + { value: 'scroll', label: 'Scroll' }, + { value: 'auto', label: 'Auto' }, + ], + }, + // Positioning + { + key: 'position', + label: 'Position', + type: 'select', + category: 'layout', + options: [ + { value: 'static', label: 'Static' }, + { value: 'relative', label: 'Relative' }, + { value: 'absolute', label: 'Absolute' }, + { value: 'fixed', label: 'Fixed' }, + { value: 'sticky', label: 'Sticky' }, + ], + }, + { key: 'top', label: 'Top', type: 'dimension', category: 'layout' }, + { key: 'right', label: 'Right', type: 'dimension', category: 'layout' }, + { key: 'bottom', label: 'Bottom', type: 'dimension', category: 'layout' }, + { key: 'left', label: 'Left', type: 'dimension', category: 'layout' }, + { key: 'zIndex', label: 'Z-Index', type: 'number', category: 'layout' }, + // 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' }, + { value: 'double', label: 'Double' }, + ], + }, + { key: 'borderColor', label: 'Border Color', type: 'color', category: 'appearance' }, + { key: 'borderRadius', label: 'Border Radius', type: 'dimension', category: 'appearance' }, + { key: 'boxShadow', label: 'Box Shadow', type: 'boxShadow', category: 'appearance' }, + ], + + render: ({ component, isSelected, onSelect, previewMode, children }) => { + const isEmpty = !component.children || component.children.length === 0; + + return React.createElement( + 'div', + { + style: { + ...component.styles, + outline: isSelected && !previewMode ? '2px solid rgb(255, 89, 47)' : 'none', + minHeight: isEmpty && !previewMode ? '120px' : 'auto', + position: 'relative' as const, + }, + onClick: previewMode + ? undefined + : (e: React.MouseEvent) => { + e.stopPropagation(); + onSelect(component.id); + }, + }, + children, + isEmpty && !previewMode + ? React.createElement( + 'div', + { + style: { + position: 'absolute' as const, + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + color: '#999', + fontSize: '14px', + pointerEvents: 'none' as const, + }, + }, + 'Drop components here' + ) + : null + ); + }, +}; + +// Auto-register on import +ComponentRegistry.register(gridDefinition); diff --git a/src/common/registry/definitions/index.ts b/src/common/registry/definitions/index.ts new file mode 100644 index 0000000..5e74a49 --- /dev/null +++ b/src/common/registry/definitions/index.ts @@ -0,0 +1,42 @@ +/** + * Component Definitions Index + * + * This file auto-registers all component definitions with the ComponentRegistry. + * Import this file in the app entry point to ensure all components are available. + */ + +import { ComponentRegistry } from '../ComponentRegistry'; + +// Container components +import { rowDefinition } from './row'; +import { columnDefinition } from './column'; +import { boxDefinition } from './box'; +import { gridDefinition } from './grid'; + +// Leaf components +import { buttonDefinition } from './button'; +import { inputDefinition } from './input'; +import { textDefinition } from './text'; +import { listDefinition } from './list'; + +// Register all components +ComponentRegistry.register(rowDefinition); +ComponentRegistry.register(columnDefinition); +ComponentRegistry.register(boxDefinition); +ComponentRegistry.register(gridDefinition); +ComponentRegistry.register(buttonDefinition); +ComponentRegistry.register(inputDefinition); +ComponentRegistry.register(textDefinition); +ComponentRegistry.register(listDefinition); + +// Export definitions for direct access if needed +export { + rowDefinition, + columnDefinition, + boxDefinition, + gridDefinition, + buttonDefinition, + inputDefinition, + textDefinition, + listDefinition, +}; diff --git a/src/common/registry/definitions/input.ts b/src/common/registry/definitions/input.ts new file mode 100644 index 0000000..d94737f --- /dev/null +++ b/src/common/registry/definitions/input.ts @@ -0,0 +1,181 @@ +import React from 'react'; +import { InputIcon } from '../../icons/ComponentIcons'; +import type { ComponentDefinition } from '../types'; +import { parseScopedPath } from '../../state/scopedPath'; + +export const inputDefinition: ComponentDefinition = { + type: 'input', + label: 'Input', + icon: InputIcon, + category: 'leaf', + canHaveChildren: false, + + defaultStyles: { + padding: '8px', + border: '1px solid #ccc', + borderRadius: '4px', + width: 'auto', + }, + + defaultProps: { + placeholder: 'Enter text...', + type: 'text', + value: '', + onChangeStatePath: '', + }, + + propertySchema: [ + { key: 'value', label: 'Value', type: 'text-binding', category: 'content' }, + { key: 'onChangeStatePath', label: 'On Change State Path', type: 'text-binding', category: 'content' }, + { key: 'placeholder', label: 'Placeholder', type: 'text', category: 'content' }, + { + key: 'type', + label: 'Input Type', + type: 'select', + category: 'content', + options: [ + { value: 'text', label: 'Text' }, + { value: 'email', label: 'Email' }, + { value: 'password', label: 'Password' }, + { value: 'number', label: 'Number' }, + { value: 'tel', label: 'Tel' }, + { value: 'url', label: 'URL' }, + ], + }, + ], + + 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: 'alignSelf', + label: 'Self Align', + type: 'select', + category: 'layout', + options: [ + { value: 'auto', label: 'Auto' }, + { value: 'flex-start', label: 'Start' }, + { value: 'center', label: 'Center' }, + { value: 'flex-end', label: 'End' }, + { value: 'stretch', label: 'Stretch' }, + { value: 'baseline', label: 'Baseline' }, + ], + }, + // Positioning + { + key: 'position', + label: 'Position', + type: 'select', + category: 'layout', + options: [ + { value: 'static', label: 'Static' }, + { value: 'relative', label: 'Relative' }, + { value: 'absolute', label: 'Absolute' }, + { value: 'fixed', label: 'Fixed' }, + { value: 'sticky', label: 'Sticky' }, + ], + }, + { key: 'top', label: 'Top', type: 'dimension', category: 'layout' }, + { key: 'right', label: 'Right', type: 'dimension', category: 'layout' }, + { key: 'bottom', label: 'Bottom', type: 'dimension', category: 'layout' }, + { key: 'left', label: 'Left', type: 'dimension', category: 'layout' }, + { key: 'zIndex', label: 'Z-Index', type: 'number', category: 'layout' }, + // Spacing + { key: 'padding', label: 'Padding', type: 'dimension', category: 'spacing' }, + { key: 'margin', label: 'Margin', type: 'dimension', category: 'spacing' }, + // Appearance + { key: 'color', label: 'Text Color', type: 'color', category: 'appearance' }, + { key: 'backgroundColor', label: 'Background', type: 'color', category: 'appearance' }, + { key: 'opacity', label: 'Opacity', type: 'range', category: 'appearance' }, + // Typography + { key: 'fontSize', label: 'Font Size', type: 'dimension', category: 'appearance' }, + { + key: 'fontFamily', + label: 'Font Family', + type: 'select', + category: 'appearance', + options: [ + { value: 'inherit', label: 'Inherit' }, + { value: 'Arial, sans-serif', label: 'Arial' }, + { value: 'Helvetica, sans-serif', label: 'Helvetica' }, + { value: 'Georgia, serif', label: 'Georgia' }, + { value: '"Times New Roman", serif', label: 'Times New Roman' }, + { value: '"Courier New", monospace', label: 'Courier New' }, + { value: 'Verdana, sans-serif', label: 'Verdana' }, + { value: 'system-ui, sans-serif', label: 'System UI' }, + ], + }, + // 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' }, + { value: 'double', label: 'Double' }, + ], + }, + { key: 'borderColor', label: 'Border Color', type: 'color', category: 'appearance' }, + { key: 'borderRadius', label: 'Border Radius', type: 'dimension', category: 'appearance' }, + { key: 'boxShadow', label: 'Box Shadow', type: 'boxShadow', 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, runtime }) => { + // Resolve value binding + const valueBinding = component.props?.value || ''; + const displayValue = runtime.resolveText(valueBinding); + + // Handle onChange + const handleChange = (e: React.ChangeEvent) => { + if (previewMode && component.props?.onChangeStatePath) { + const parsedPath = parseScopedPath(component.props.onChangeStatePath); + runtime.setStateValue(parsedPath.fullPath, e.target.value); + } + }; + + return React.createElement('input', { + type: component.props?.type || 'text', + placeholder: component.props?.placeholder || 'Enter text...', + value: displayValue || '', + onChange: handleChange, + style: { + ...component.styles, + outline: isSelected && !previewMode ? '2px solid rgb(255, 89, 47)' : 'none', + }, + onClick: previewMode + ? undefined + : (e: React.MouseEvent) => { + e.stopPropagation(); + onSelect(component.id); + }, + readOnly: !previewMode, + }); + }, +}; diff --git a/src/common/registry/definitions/list.ts b/src/common/registry/definitions/list.ts new file mode 100644 index 0000000..f84763f --- /dev/null +++ b/src/common/registry/definitions/list.ts @@ -0,0 +1,381 @@ +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); diff --git a/src/common/registry/definitions/row.ts b/src/common/registry/definitions/row.ts new file mode 100644 index 0000000..f2bf339 --- /dev/null +++ b/src/common/registry/definitions/row.ts @@ -0,0 +1,185 @@ +import React from 'react'; +import { RowIcon } from '../../icons/ComponentIcons'; +import type { ComponentDefinition } from '../types'; + +export const rowDefinition: ComponentDefinition = { + type: 'row', + label: 'Row', + icon: RowIcon, + category: 'container', + canHaveChildren: true, + + defaultStyles: { + width: 'auto', + display: 'flex', + flexDirection: 'row', + gap: '8px', + }, + + defaultProps: {}, + + propertySchema: [], + + 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: 'justifyContent', + label: 'Horizontal Align', + 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' }, + { value: 'space-evenly', label: 'Space Evenly' }, + ], + }, + { + key: 'alignItems', + label: 'Vertical Align', + type: 'select', + category: 'layout', + options: [ + { value: 'flex-start', label: 'Start' }, + { value: 'center', label: 'Center' }, + { value: 'flex-end', label: 'End' }, + { value: 'stretch', label: 'Stretch' }, + { value: 'baseline', label: 'Baseline' }, + ], + }, + { + key: 'flexWrap', + label: 'Flex Wrap', + type: 'select', + category: 'layout', + options: [ + { value: 'nowrap', label: 'No Wrap' }, + { value: 'wrap', label: 'Wrap' }, + { value: 'wrap-reverse', label: 'Wrap Reverse' }, + ], + }, + { + 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' }, + ], + }, + // Positioning + { + key: 'position', + label: 'Position', + type: 'select', + category: 'layout', + options: [ + { value: 'static', label: 'Static' }, + { value: 'relative', label: 'Relative' }, + { value: 'absolute', label: 'Absolute' }, + { value: 'fixed', label: 'Fixed' }, + { value: 'sticky', label: 'Sticky' }, + ], + }, + { key: 'top', label: 'Top', type: 'dimension', category: 'layout' }, + { key: 'right', label: 'Right', type: 'dimension', category: 'layout' }, + { key: 'bottom', label: 'Bottom', type: 'dimension', category: 'layout' }, + { key: 'left', label: 'Left', type: 'dimension', category: 'layout' }, + { key: 'zIndex', label: 'Z-Index', type: 'number', category: 'layout' }, + // 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' }, + { value: 'double', label: 'Double' }, + ], + }, + { key: 'borderColor', label: 'Border Color', type: 'color', category: 'appearance' }, + { key: 'borderRadius', label: 'Border Radius', type: 'dimension', category: 'appearance' }, + { key: 'boxShadow', label: 'Box Shadow', type: 'boxShadow', 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 }) => { + const isEmpty = !component.children || component.children.length === 0; + + return React.createElement( + 'div', + { + style: { + ...component.styles, + outline: isSelected && !previewMode ? '2px solid rgb(255, 89, 47)' : 'none', + minHeight: isEmpty && !previewMode ? '60px' : 'auto', + position: 'relative' as const, + }, + onClick: previewMode + ? undefined + : (e: React.MouseEvent) => { + e.stopPropagation(); + onSelect(component.id); + }, + }, + children, + isEmpty && !previewMode + ? React.createElement( + 'div', + { + style: { + position: 'absolute' as const, + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + color: '#999', + fontSize: '14px', + pointerEvents: 'none' as const, + }, + }, + 'Drop components here' + ) + : null + ); + }, +}; diff --git a/src/common/registry/definitions/text.ts b/src/common/registry/definitions/text.ts new file mode 100644 index 0000000..8284b93 --- /dev/null +++ b/src/common/registry/definitions/text.ts @@ -0,0 +1,231 @@ +import React from 'react'; +import { TextIcon } from '../../icons/ComponentIcons'; +import type { ComponentDefinition, ComponentRenderProps } from '../types'; + +/** + * Text component - now decoupled from stores + * Uses runtime.resolveText() instead of direct store access + */ +export const textDefinition: ComponentDefinition = { + type: 'text', + label: 'Text', + icon: TextIcon, + category: 'leaf', + canHaveChildren: false, + + defaultStyles: { + width: 'auto', + alignSelf: 'auto', + }, + + defaultProps: { + text: 'Text', + elementType: 'p', + }, + + propertySchema: [ + { key: 'text', label: 'Text', type: 'text-binding', category: 'content' }, + { + key: 'elementType', + label: 'Element Type', + type: 'select', + category: 'content', + options: [ + { value: 'p', label: 'Paragraph (p)' }, + { value: 'span', label: 'Span' }, + { value: 'div', label: 'Div' }, + { value: 'h1', label: 'Heading 1 (h1)' }, + { value: 'h2', label: 'Heading 2 (h2)' }, + { value: 'h3', label: 'Heading 3 (h3)' }, + { value: 'h4', label: 'Heading 4 (h4)' }, + { value: 'h5', label: 'Heading 5 (h5)' }, + { value: 'h6', label: 'Heading 6 (h6)' }, + ], + }, + ], + + 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: 'alignSelf', + label: 'Self Align', + type: 'select', + category: 'layout', + options: [ + { value: 'auto', label: 'Auto' }, + { value: 'flex-start', label: 'Start' }, + { value: 'center', label: 'Center' }, + { value: 'flex-end', label: 'End' }, + { value: 'stretch', label: 'Stretch' }, + { value: 'baseline', label: 'Baseline' }, + ], + }, + // Positioning + { + key: 'position', + label: 'Position', + type: 'select', + category: 'layout', + options: [ + { value: 'static', label: 'Static' }, + { value: 'relative', label: 'Relative' }, + { value: 'absolute', label: 'Absolute' }, + { value: 'fixed', label: 'Fixed' }, + { value: 'sticky', label: 'Sticky' }, + ], + }, + { key: 'top', label: 'Top', type: 'dimension', category: 'layout' }, + { key: 'right', label: 'Right', type: 'dimension', category: 'layout' }, + { key: 'bottom', label: 'Bottom', type: 'dimension', category: 'layout' }, + { key: 'left', label: 'Left', type: 'dimension', category: 'layout' }, + { key: 'zIndex', label: 'Z-Index', type: 'number', category: 'layout' }, + // Spacing + { key: 'padding', label: 'Padding', type: 'dimension', category: 'spacing' }, + { key: 'margin', label: 'Margin', type: 'dimension', category: 'spacing' }, + // Appearance + { key: 'color', label: 'Text Color', type: 'color', category: 'appearance' }, + { key: 'backgroundColor', label: 'Background', type: 'color', category: 'appearance' }, + { key: 'opacity', label: 'Opacity', type: 'range', category: 'appearance' }, + // Typography + { key: 'fontSize', label: 'Font Size', type: 'dimension', category: 'appearance' }, + { + key: 'fontFamily', + label: 'Font Family', + type: 'select', + category: 'appearance', + options: [ + { value: 'inherit', label: 'Inherit' }, + { value: 'Arial, sans-serif', label: 'Arial' }, + { value: 'Helvetica, sans-serif', label: 'Helvetica' }, + { value: 'Georgia, serif', label: 'Georgia' }, + { value: '"Times New Roman", serif', label: 'Times New Roman' }, + { value: '"Courier New", monospace', label: 'Courier New' }, + { value: 'Verdana, sans-serif', label: 'Verdana' }, + { value: 'system-ui, sans-serif', label: 'System UI' }, + ], + }, + { + key: 'fontWeight', + label: 'Font Weight', + type: 'select', + category: 'appearance', + options: [ + { value: 'normal', label: 'Normal' }, + { value: 'bold', label: 'Bold' }, + { value: '100', label: '100 (Thin)' }, + { value: '200', label: '200' }, + { value: '300', label: '300 (Light)' }, + { value: '400', label: '400 (Normal)' }, + { value: '500', label: '500 (Medium)' }, + { value: '600', label: '600 (Semibold)' }, + { value: '700', label: '700 (Bold)' }, + { value: '800', label: '800' }, + { value: '900', label: '900 (Black)' }, + ], + }, + { key: 'lineHeight', label: 'Line Height', type: 'dimension', category: 'appearance' }, + { key: 'letterSpacing', label: 'Letter Spacing', type: 'dimension', category: 'appearance' }, + { + key: 'textAlign', + label: 'Text Align', + type: 'select', + category: 'appearance', + options: [ + { value: 'left', label: 'Left' }, + { value: 'center', label: 'Center' }, + { value: 'right', label: 'Right' }, + { value: 'justify', label: 'Justify' }, + { value: 'start', label: 'Start' }, + { value: 'end', label: 'End' }, + ], + }, + { + key: 'textDecoration', + label: 'Text Decoration', + type: 'select', + category: 'appearance', + options: [ + { value: 'none', label: 'None' }, + { value: 'underline', label: 'Underline' }, + { value: 'line-through', label: 'Line Through' }, + { value: 'overline', label: 'Overline' }, + ], + }, + { + key: 'textTransform', + label: 'Text Transform', + type: 'select', + category: 'appearance', + options: [ + { value: 'none', label: 'None' }, + { value: 'uppercase', label: 'Uppercase' }, + { value: 'lowercase', label: 'Lowercase' }, + { value: 'capitalize', label: 'Capitalize' }, + ], + }, + // 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' }, + { value: 'double', label: 'Double' }, + ], + }, + { key: 'borderColor', label: 'Border Color', type: 'color', category: 'appearance' }, + { key: 'borderRadius', label: 'Border Radius', type: 'dimension', category: 'appearance' }, + { key: 'boxShadow', label: 'Box Shadow', type: 'boxShadow', 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, runtime }: ComponentRenderProps) => { + const elementType = component.props?.elementType || 'p'; + const rawTextContent = component.props?.text || 'Text'; + + // Use runtime.resolveText instead of directly accessing stores + const textContent = runtime.resolveText(rawTextContent); + + const commonProps = { + style: { + ...component.styles, + outline: isSelected && !previewMode ? '2px solid rgb(255, 89, 47)' : 'none', + }, + onClick: previewMode + ? undefined + : (e: React.MouseEvent) => { + e.stopPropagation(); + onSelect(component.id); + }, + }; + + return React.createElement(elementType, commonProps, textContent); + }, +}; diff --git a/src/common/registry/index.ts b/src/common/registry/index.ts new file mode 100644 index 0000000..b5442db --- /dev/null +++ b/src/common/registry/index.ts @@ -0,0 +1,36 @@ +/** + * Component Registry + * + * Centralized registry for all component definitions. + * + * Usage: + * 1. Import the registry to access component definitions: + * import { ComponentRegistry } from './registry'; + * + * 2. Import with definitions auto-registration (do this once in app entry): + * import './registry/definitions'; + * + * 3. Get a component definition: + * const buttonDef = ComponentRegistry.get('button'); + * + * 4. Get all components for component library: + * const allComponents = ComponentRegistry.getAll(); + * + * 5. Get defaults when creating components: + * const styles = ComponentRegistry.getDefaultStyles('button'); + * const props = ComponentRegistry.getDefaultProps('button'); + */ + +export { ComponentRegistry } from './ComponentRegistry'; +export type { + ComponentDefinition, + ComponentRenderProps, + EditorRenderProps, + PropertySchema, + StyleSchema, + PropertyEditorProps, + StyleEditorProps, + RuntimeValues, + ComponentEventType, + EventSchema, +} from './types'; diff --git a/src/common/registry/types.ts b/src/common/registry/types.ts new file mode 100644 index 0000000..7282df5 --- /dev/null +++ b/src/common/registry/types.ts @@ -0,0 +1,173 @@ +import type { CSSProperties, ReactNode } from 'react'; +import type { ComponentNode, Page, EventHandler } from '../types/component.types'; + +/** + * Supported event types for components + */ +export type ComponentEventType = 'click' | 'change' | 'submit' | 'focus' | 'blur'; + +/** + * Event schema defining what events a component supports + */ +export interface EventSchema { + /** Event type (e.g., 'click', 'change') */ + type: ComponentEventType; + /** Display label for the event */ + label: string; + /** Description of when this event fires */ + description?: string; + /** Property key where handlers are stored (defaults to 'eventHandlers') */ + handlersKey?: string; +} + +/** + * Runtime values available to components during rendering + */ +export interface RuntimeValues { + /** Current scoped runtime state */ + state: { + page: Record; + global: Record; + }; + /** Resolve text with scoped state bindings ({{ page.* }} / {{ global.* }} syntax) */ + resolveText: (text: string) => string; + /** Execute event handlers */ + executeHandlers: (handlers: EventHandler[]) => Promise; + /** Dispatch an event for a component */ + dispatchEvent: (eventType: ComponentEventType, component: ComponentNode, handlersKey?: string) => Promise; + /** All pages (for navigation) */ + pages: Page[]; + /** Navigate to a page by ID */ + navigateToPage: (pageId: string) => void; + /** Update state value at a given path */ + setStateValue: (path: string, value: any) => void; +} + +/** + * Schema definition for a component property (used in Properties Panel) + */ +export interface PropertySchema { + /** Property key in component.props */ + key: string; + /** Display label in properties panel */ + label: string; + /** Editor type to use */ + type: 'text' | 'text-binding' | 'number' | 'color' | 'select' | 'boolean' | 'custom'; + /** Default value */ + default?: any; + /** Options for select type */ + options?: { value: string; label: string }[]; + /** Category for grouping properties */ + category?: 'content' | 'layout' | 'style' | 'events'; + /** For custom type: the custom editor key */ + customEditor?: string; + /** Description/hint text */ + description?: string; +} + +/** + * Schema definition for component styles + */ +export interface StyleSchema { + /** CSS property key */ + key: string; + /** Display label */ + label: string; + /** Editor type */ + type: 'text' | 'number' | 'color' | 'select' | 'dimension' | 'boxShadow' | 'range'; + /** Default value */ + default?: any; + /** Options for select type */ + options?: { value: string; label: string }[]; + /** Category for grouping */ + category?: 'layout' | 'spacing' | 'appearance' | 'grid' | 'grid-item' | 'alignment'; +} + +/** + * Props passed to component render functions + */ +export interface ComponentRenderProps { + component: ComponentNode; + isSelected: boolean; + onSelect: (id: string) => void; + previewMode: boolean; + children?: ReactNode; + + /** Runtime values from context (decouples components from stores) */ + runtime: RuntimeValues; +} + +/** + * Props specific to editor render (extends ComponentRenderProps) + */ +export interface EditorRenderProps extends ComponentRenderProps { + // Editor-specific props can be added here +} + +/** + * Props passed to property editors + */ +export interface PropertyEditorProps { + value: any; + onChange: (value: any, isIntermediate?: boolean) => void; + schema: PropertySchema; + component: ComponentNode; + pages?: Page[]; +} + +/** + * Props passed to style editors + */ +export interface StyleEditorProps { + value: any; + onChange: (value: any, isIntermediate?: boolean) => void; + schema: StyleSchema; + component: ComponentNode; +} + +/** + * Complete component definition for the registry + */ +export interface ComponentDefinition { + /** Unique type identifier (e.g., 'button', 'row') */ + type: string; + + /** Display label in component library */ + label: string; + + /** Optional icon component for visual identification in outline */ + icon?: React.ComponentType<{ size?: number; className?: string; style?: React.CSSProperties }>; + + /** Category: container (can have children) or leaf (cannot) */ + category: 'container' | 'leaf'; + + /** Whether this component can accept child components */ + canHaveChildren: boolean; + + /** Default CSS styles applied when component is created */ + defaultStyles: CSSProperties; + + /** Default props applied when component is created */ + defaultProps: Record; + + /** Schema for component-specific properties */ + propertySchema: PropertySchema[]; + + /** Schema for style properties */ + styleSchema: StyleSchema[]; + + /** Schema for events the component supports */ + eventSchema?: EventSchema[]; + + /** + * Render function for the component + * Receives standardized props, returns React node + */ + render: (props: ComponentRenderProps) => ReactNode; + + /** + * Optional: Editor-specific render override + * If not provided, uses render() with previewMode=false + */ + editorRender?: (props: EditorRenderProps) => ReactNode; +} diff --git a/src/common/runtime/RuntimeContext.tsx b/src/common/runtime/RuntimeContext.tsx new file mode 100644 index 0000000..3aca26c --- /dev/null +++ b/src/common/runtime/RuntimeContext.tsx @@ -0,0 +1,58 @@ +import { createContext, useContext } from 'react'; +import type { EventHandler, Page, ComponentNode } from '../types/component.types'; +import type { ComponentEventType } from '../registry/types'; + +/** + * Runtime context value interface + * Components receive these values instead of accessing stores directly. + */ +export interface RuntimeContextValue { + /** Current scoped runtime state: only `page` and `global` are available. */ + state: { + page: Record; + global: Record; + }; + + /** Resolve scoped bindings in text ({{ page.* }} / {{ global.* }} syntax) */ + resolveText: (text: string) => string; + + /** Execute event handlers (for button clicks, etc.) */ + executeHandlers: (handlers: EventHandler[]) => Promise; + + /** Dispatch an event for a component */ + dispatchEvent: (eventType: ComponentEventType, component: ComponentNode, handlersKey?: string) => Promise; + + /** All pages (for navigation) */ + pages: Page[]; + + /** Navigate to a page by ID */ + navigateToPage: (pageId: string) => void; + + /** Update state value at a given path */ + setStateValue: (path: string, value: any) => void; + + /** Whether we're in preview/runtime mode */ + previewMode: boolean; +} + +export const RuntimeContext = createContext(null); + +/** + * Hook to access runtime context + * Throws if used outside a provider. + */ +export const useRuntime = (): RuntimeContextValue => { + const context = useContext(RuntimeContext); + if (!context) { + throw new Error('useRuntime must be used within a RuntimeContext provider'); + } + return context; +}; + +/** + * Hook to access runtime context (returns null if outside provider) + * Useful for components that may render outside runtime context. + */ +export const useRuntimeOptional = (): RuntimeContextValue | null => { + return useContext(RuntimeContext); +}; diff --git a/src/common/runtime/index.ts b/src/common/runtime/index.ts new file mode 100644 index 0000000..57dde7f --- /dev/null +++ b/src/common/runtime/index.ts @@ -0,0 +1,3 @@ +export { RuntimeContext, useRuntime, useRuntimeOptional } from './RuntimeContext'; +export type { RuntimeContextValue } from './RuntimeContext'; + diff --git a/src/common/state/scopedPath.ts b/src/common/state/scopedPath.ts new file mode 100644 index 0000000..a59bf0d --- /dev/null +++ b/src/common/state/scopedPath.ts @@ -0,0 +1,88 @@ +export type ScopedScope = 'page' | 'global'; + +export type ScopedStatePath = `page.${string}` | `global.${string}`; + +export interface ParsedScopedPath { + scope: ScopedScope; + relativePath: string; + fullPath: ScopedStatePath; +} + +const BINDING_PATTERN = /^\{\{\s*([^{}]+?)\s*\}\}$/; +const SEGMENT_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; +const INDEX_SEGMENT_PATTERN = /^\d+$/; + +const normalizeInput = (rawPath: unknown): string => { + if (typeof rawPath !== 'string') { + throw new Error('State path must be a string'); + } + + const input = rawPath.trim(); + if (!input) { + throw new Error('State path is required'); + } + + if (input.includes('{{') || input.includes('}}')) { + const bindingMatch = input.match(BINDING_PATTERN); + if (!bindingMatch) { + throw new Error('State path binding must use valid {{ ... }} format'); + } + const boundValue = bindingMatch[1].trim(); + if (!boundValue) { + throw new Error('State path binding cannot be empty'); + } + return boundValue; + } + + return input; +}; + +const validateRelativePath = (relativePath: string): void => { + if (!relativePath) { + throw new Error('State path must include a property after scope'); + } + if (relativePath.startsWith('.') || relativePath.endsWith('.') || relativePath.includes('..')) { + throw new Error('State path must not start/end with dots or contain consecutive dots'); + } + + const segments = relativePath.split('.'); + for (const segment of segments) { + if (!SEGMENT_PATTERN.test(segment) && !INDEX_SEGMENT_PATTERN.test(segment)) { + throw new Error( + 'State path segments must be alphanumeric/underscore and start with a letter or underscore (indexes can be numeric)' + ); + } + } +}; + +export const parseScopedPath = (rawPath: unknown): ParsedScopedPath => { + const normalized = normalizeInput(rawPath); + let scope: ScopedScope; + + if (normalized.startsWith('page.')) { + scope = 'page'; + } else if (normalized.startsWith('global.')) { + scope = 'global'; + } else { + throw new Error('State path must start with "page." or "global."'); + } + + const relativePath = normalized.slice(`${scope}.`.length); + validateRelativePath(relativePath); + const fullPath = `${scope}.${relativePath}` as ScopedStatePath; + + return { + scope, + relativePath, + fullPath, + }; +}; + +export const isScopedPath = (rawPath: unknown): rawPath is ScopedStatePath => { + try { + parseScopedPath(rawPath); + return true; + } catch { + return false; + } +}; diff --git a/src/common/state/statePathUtils.ts b/src/common/state/statePathUtils.ts new file mode 100644 index 0000000..cdb4e0d --- /dev/null +++ b/src/common/state/statePathUtils.ts @@ -0,0 +1,91 @@ +const isIndexSegment = (segment: string | undefined): boolean => !!segment && /^\d+$/.test(segment); + +const ensureContainerForPath = (nextSegment: string | undefined) => + nextSegment && isIndexSegment(nextSegment) ? [] : {}; + +export const updateStateAtPath = (currentState: any, pathParts: string[], newValue: any): any => { + if (pathParts.length === 0) { + return newValue; + } + + const [first, ...rest] = pathParts; + const nextContainer = ensureContainerForPath(rest[0]); + + if (Array.isArray(currentState)) { + const updatedArray = [...currentState]; + if (isIndexSegment(first)) { + const index = Number(first); + const currentValue = updatedArray[index]; + if (rest.length === 0) { + updatedArray[index] = newValue; + } else { + const child = currentValue ?? nextContainer; + updatedArray[index] = updateStateAtPath(child, rest, newValue); + } + } else { + const currentValue = (updatedArray as any)[first]; + if (rest.length === 0) { + (updatedArray as any)[first] = newValue; + } else { + const child = currentValue ?? nextContainer; + (updatedArray as any)[first] = updateStateAtPath(child, rest, newValue); + } + } + return updatedArray; + } + + const baseObject = currentState && typeof currentState === 'object' ? currentState : {}; + const updatedObject: Record = Array.isArray(baseObject) + ? [...baseObject] + : { ...baseObject }; + + if (rest.length === 0) { + updatedObject[first] = newValue; + return updatedObject; + } + + const currentValue = updatedObject[first]; + const child = currentValue ?? nextContainer; + updatedObject[first] = updateStateAtPath(child, rest, newValue); + return updatedObject; +}; + +export const deleteStateAtPath = (currentState: any, pathParts: string[]): any => { + if (!currentState || pathParts.length === 0) { + return currentState; + } + + const [first, ...rest] = pathParts; + + if (Array.isArray(currentState)) { + const updatedArray = [...currentState]; + if (isIndexSegment(first)) { + const index = Number(first); + if (rest.length === 0) { + updatedArray.splice(index, 1); + return updatedArray; + } + updatedArray[index] = deleteStateAtPath(updatedArray[index], rest); + return updatedArray; + } + + (updatedArray as any)[first] = rest.length === 0 + ? undefined + : deleteStateAtPath((updatedArray as any)[first], rest); + return updatedArray; + } + + if (typeof currentState !== 'object') { + return currentState; + } + + const updatedObject: Record = { ...currentState }; + if (rest.length === 0) { + delete updatedObject[first]; + return updatedObject; + } + + updatedObject[first] = deleteStateAtPath(updatedObject[first], rest); + return updatedObject; +}; + diff --git a/src/common/state/validateScopedBindings.ts b/src/common/state/validateScopedBindings.ts new file mode 100644 index 0000000..0598dea --- /dev/null +++ b/src/common/state/validateScopedBindings.ts @@ -0,0 +1,266 @@ +import type { ComponentNode, EventHandler, PagesData } from '../types/component.types'; +import { parseScopedPath } from './scopedPath'; +import { normalizeWorkflowResultKey } from '../utils/workflowResultKey'; + +export const MAX_VIOLATIONS_TO_SHOW = 20; + +export interface ScopedBindingViolation { + pageId: string; + pageName: string; + componentId: string; + fieldPath: string; + value: string; + reason: string; +} + +const BINDING_PATTERN = /\{\{\s*([^}]+?)\s*\}\}/g; + +/** Returns true if the expression is a valid list-iteration binding (dataSource, dataSource.*, index) */ +const isIterationBinding = (expression: string): boolean => + expression === 'index' || expression === 'dataSource' || expression.startsWith('dataSource.'); + +const addViolation = ( + violations: ScopedBindingViolation[], + pageId: string, + pageName: string, + componentId: string, + fieldPath: string, + value: string, + reason: string +) => { + violations.push({ + pageId, + pageName, + componentId, + fieldPath, + value, + reason, + }); +}; + +const validateBindingTokens = ( + text: string, + violations: ScopedBindingViolation[], + pageId: string, + pageName: string, + componentId: string, + fieldPath: string, + insideList: boolean = false +) => { + const matches = text.matchAll(BINDING_PATTERN); + for (const match of matches) { + const expression = (match[1] || '').trim(); + if (!expression) { + addViolation(violations, pageId, pageName, componentId, fieldPath, match[0], 'Empty binding expression'); + continue; + } + // Allow dataSource.* and index bindings inside list components + if (insideList && isIterationBinding(expression)) { + continue; + } + try { + parseScopedPath(expression); + } catch (error) { + addViolation( + violations, + pageId, + pageName, + componentId, + fieldPath, + match[0], + error instanceof Error ? error.message : 'Invalid scoped binding' + ); + } + } +}; + +const scanValueForBindings = ( + value: unknown, + violations: ScopedBindingViolation[], + pageId: string, + pageName: string, + componentId: string, + fieldPath: string, + insideList: boolean = false +) => { + if (typeof value === 'string') { + validateBindingTokens(value, violations, pageId, pageName, componentId, fieldPath, insideList); + return; + } + + if (Array.isArray(value)) { + value.forEach((item, index) => { + scanValueForBindings(item, violations, pageId, pageName, componentId, `${fieldPath}[${index}]`, insideList); + }); + return; + } + + if (value && typeof value === 'object') { + Object.entries(value as Record).forEach(([key, item]) => { + scanValueForBindings(item, violations, pageId, pageName, componentId, `${fieldPath}.${key}`, insideList); + }); + } +}; + +const validateHandler = ( + handler: EventHandler, + violations: ScopedBindingViolation[], + pageId: string, + pageName: string, + componentId: string, + handlerPath: string, + insideList: boolean = false +) => { + if (handler.action === 'setState') { + if (!handler.statePath || typeof handler.statePath !== 'string') { + addViolation( + violations, + pageId, + pageName, + componentId, + `${handlerPath}.statePath`, + String(handler.statePath ?? ''), + 'Missing statePath for setState handler' + ); + } else { + try { + parseScopedPath(handler.statePath); + } catch (error) { + addViolation( + violations, + pageId, + pageName, + componentId, + `${handlerPath}.statePath`, + handler.statePath, + error instanceof Error ? error.message : 'Invalid scoped state path' + ); + } + } + } + + if (handler.action === 'workflow' && typeof handler.workflowResultKey === 'string' && handler.workflowResultKey.trim()) { + try { + const normalized = normalizeWorkflowResultKey(handler.workflowResultKey); + if (!normalized.fullPath.startsWith('global.workflows.')) { + addViolation( + violations, + pageId, + pageName, + componentId, + `${handlerPath}.workflowResultKey`, + handler.workflowResultKey, + 'Workflow result must resolve under global.workflows.*' + ); + } + } catch (error) { + addViolation( + violations, + pageId, + pageName, + componentId, + `${handlerPath}.workflowResultKey`, + handler.workflowResultKey, + error instanceof Error ? error.message : 'Invalid workflow result key' + ); + } + } + + if (typeof handler.value === 'string') { + validateBindingTokens(handler.value, violations, pageId, pageName, componentId, `${handlerPath}.value`, insideList); + } + if (typeof handler.condition === 'string') { + validateBindingTokens(handler.condition, violations, pageId, pageName, componentId, `${handlerPath}.condition`, insideList); + } + if (typeof handler.requestBody === 'string') { + validateBindingTokens(handler.requestBody, violations, pageId, pageName, componentId, `${handlerPath}.requestBody`, insideList); + } +}; + +const scanComponent = ( + component: ComponentNode, + violations: ScopedBindingViolation[], + pageId: string, + pageName: string, + pathPrefix: string, + insideList: boolean = false +) => { + const componentPath = `${pathPrefix}.component(${component.id})`; + const props = component.props ?? {}; + // Children of a list component are inside a list iteration scope + const childInsideList = insideList || component.type === 'list'; + + Object.entries(props).forEach(([key, value]) => { + const propPath = `${componentPath}.props.${key}`; + + if (key === 'onChangeStatePath' && typeof value === 'string' && value.trim()) { + try { + parseScopedPath(value); + } catch (error) { + addViolation( + violations, + pageId, + pageName, + component.id, + propPath, + value, + error instanceof Error ? error.message : 'Invalid scoped state path' + ); + } + } + + if (key.endsWith('Handlers') && Array.isArray(value)) { + value.forEach((handler, index) => { + if (!handler || typeof handler !== 'object') return; + validateHandler( + handler as EventHandler, + violations, + pageId, + pageName, + component.id, + `${propPath}[${index}]`, + insideList + ); + }); + } + + scanValueForBindings(value, violations, pageId, pageName, component.id, propPath, insideList); + }); + + (component.children ?? []).forEach((child, index) => { + scanComponent(child, violations, pageId, pageName, `${componentPath}.children[${index}]`, childInsideList); + }); +}; + +export const validatePagesDataScopedBindings = (pagesData: PagesData): ScopedBindingViolation[] => { + const violations: ScopedBindingViolation[] = []; + if (!pagesData || !Array.isArray(pagesData.pages)) { + return violations; + } + + pagesData.pages.forEach((page, pageIndex) => { + if (!page?.root) return; + scanComponent(page.root, violations, page.id, page.name, `pages[${pageIndex}]`); + }); + + return violations; +}; + +export const formatScopedBindingViolations = ( + violations: ScopedBindingViolation[], + limit: number = MAX_VIOLATIONS_TO_SHOW +): string => { + if (!violations.length) return ''; + + const visible = violations.slice(0, limit); + const lines = visible.map((violation, index) => { + return `${index + 1}. [${violation.pageName}] component=${violation.componentId} field=${violation.fieldPath} value="${violation.value}" reason=${violation.reason}`; + }); + + const remaining = violations.length - visible.length; + if (remaining > 0) { + lines.push(`...and ${remaining} more violation(s).`); + } + + return lines.join('\n'); +}; diff --git a/src/common/types/component.types.ts b/src/common/types/component.types.ts new file mode 100644 index 0000000..c2ae17e --- /dev/null +++ b/src/common/types/component.types.ts @@ -0,0 +1,54 @@ +import type { CSSProperties } from 'react'; +import type { ScopedStatePath } from '../state/scopedPath'; + +export type ComponentType = 'row' | 'column' | 'box' | 'button' | 'input' | 'text' | 'list' | 'grid'; + +export type Viewport = 'mobile' | 'tablet' | 'desktop'; + +export interface ComponentNode { + id: string; + type: ComponentType; + styles: CSSProperties; + props?: Record; + children?: ComponentNode[]; + layerName?: string; +} + +export interface Page { + id: string; + name: string; + route: string; + root: ComponentNode | null; + isDefault: boolean; +} + +export interface PagesData { + pages: Page[]; + currentPageId: string | null; + stateData?: StateData; + pageStateData?: Record; +} + +export interface StateData { + state: Record; + version: number; +} + +export interface EventHandler { + id: string; // UUID for each handler + action: 'setState' | 'navigateToPage' | 'workflow'; + // Conditional execution: + condition?: string; // JavaScript expression that must evaluate to true for handler to execute (e.g., "page.user.mobileNumber && page.user.mobileNumber.length >= 10") + conditionErrorMessage?: string; // Error message to show if condition fails (optional) + // For setState: + statePath?: ScopedStatePath; // e.g., "page.user.customerName" or "global.user.customerName" + value?: any; // JavaScript expression as string (e.g., "page.user.age + 1", "global.user.name.toUpperCase()") + // For navigateToPage: + pageId?: string; // Target page ID + // For workflow: + workflowId?: string; // Workflow ID from API + workflowName?: string; // Selected workflow name + workflowUrl?: string; // Workflow execution URL + requestBody?: string; // JSON string for workflow request body (supports {{ page.* }} / {{ global.* }} bindings) + workflowResultKey?: string; // Relative key under global.workflows.* to store workflow results +} diff --git a/src/common/utils/ComponentRenderer.tsx b/src/common/utils/ComponentRenderer.tsx new file mode 100644 index 0000000..c2ad14d --- /dev/null +++ b/src/common/utils/ComponentRenderer.tsx @@ -0,0 +1,122 @@ +import React, { type CSSProperties } from "react"; +import type { ComponentNode, ComponentType } from '../types/component.types'; +import { ComponentRegistry } from '../registry'; +import type { RuntimeValues } from '../registry/types'; +import { normalizeComponentStyles } from './styleNormalization'; +import { useRuntimeOptional } from '../runtime'; + +interface ComponentRendererProps { + component: ComponentNode; + isSelected: string | null; + lastModifiedId?: string | null; + onSelect: (id: string) => void; + renderChildren?: (children: ComponentNode[], parentType: ComponentType, parentStyles?: CSSProperties) => React.ReactNode; + previewMode?: boolean; + parentType?: ComponentType | null; + parentStyles?: CSSProperties | null; +} + +// Default no-op runtime values for when context is not available +const defaultRuntime: RuntimeValues = { + state: { page: {}, global: {} }, + resolveText: (text: string) => text, + executeHandlers: async () => { }, + dispatchEvent: async () => { }, + pages: [], + navigateToPage: () => { }, + setStateValue: () => { }, +}; + +export const ComponentRenderer: React.FC = ({ + component, + isSelected, + lastModifiedId, + onSelect, + renderChildren, + previewMode = false, + parentType = null, + parentStyles = null, +}) => { + const isSelectedComponent = isSelected === component.id; + const isModified = lastModifiedId === component.id; + const domRef = React.useRef(null); + + // Get runtime context (may be null if not wrapped in provider) + const runtimeContext = useRuntimeOptional(); + + // Scroll into view and pulse when modified and selected (e.g. undo/redo) + React.useEffect(() => { + if (isSelectedComponent && isModified && !previewMode) { + // Use requestAnimationFrame to ensure the DOM is ready (especially for restored components) + requestAnimationFrame(() => { + if (domRef.current) { + domRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + domRef.current.classList.remove('pulse-highlight'); + // Trigger reflow to restart animation + void domRef.current.offsetWidth; + domRef.current.classList.add('pulse-highlight'); + } + }); + } + }, [isSelectedComponent, isModified, previewMode]); + + // Normalize styles to prevent flexbox stretching, passing parent styles so the + // normalizer can skip injection when the parent has explicit alignment intent. + const normalizedStyles = normalizeComponentStyles(component.type, component.styles, parentType, parentStyles); + const normalizedComponent = { ...component, styles: normalizedStyles }; + + // Helper to render children with parent context. + // We pass normalizedStyles so children can see this component's effective alignment. + const renderChildrenWithContext = (children: ComponentNode[]) => { + if (!renderChildren) return null; + return renderChildren(children, component.type, normalizedStyles); + }; + + // Build runtime values from context or defaults + const runtime: RuntimeValues = runtimeContext + ? { + state: runtimeContext.state, + resolveText: runtimeContext.resolveText, + executeHandlers: runtimeContext.executeHandlers, + dispatchEvent: runtimeContext.dispatchEvent, + pages: runtimeContext.pages, + navigateToPage: runtimeContext.navigateToPage, + setStateValue: runtimeContext.setStateValue, + } + : defaultRuntime; + + const renderComponent = () => { + // Get component definition from registry + const definition = ComponentRegistry.get(component.type); + + if (!definition) { + console.warn(`Unknown component type: ${component.type}`); + return null; + } + + // Prepare children for containers + const childrenContent = definition.canHaveChildren && renderChildren && component.children + ? renderChildrenWithContext(component.children) + : undefined; + + // Call the render function from the definition with runtime values + return definition.render({ + component: normalizedComponent, + isSelected: isSelectedComponent, + onSelect, + previewMode, + children: childrenContent, + runtime, + }); + }; + + if (previewMode) { + return <>{renderComponent()}; + } + + return ( +
+ {renderComponent()} +
+ ); +}; diff --git a/src/common/utils/expressionEvaluator.ts b/src/common/utils/expressionEvaluator.ts new file mode 100644 index 0000000..c865fd6 --- /dev/null +++ b/src/common/utils/expressionEvaluator.ts @@ -0,0 +1,155 @@ +/** + * Safe JavaScript expression evaluator with state context + */ + +export interface EvaluationContext { + state: any; +} + +/** + * Create a deep proxy that returns safe defaults for undefined properties + * + * This prevents "undefined" from appearing in expressions when properties don't exist. + * Instead of `undefined + "1"` becoming `"undefined1"`, it becomes `"" + "1"` = `"1"`. + * + * @param state - The state object to wrap + * @returns A proxied state object that returns "" for undefined properties + * + * @example + * const safeState = createSafeStateProxy({ page: { user: { name: "Jon" } }, global: {} }); + * safeState.page.user.mobileNumber // Returns "" instead of undefined + * safeState.page.user.name // Returns "Jon" (existing values unchanged) + */ +const createSafeStateProxy = (state: any): any => { + // Handle null/undefined at the root + if (state === null || state === undefined) { + return ""; + } + + // Don't proxy primitives or special objects + if (typeof state !== 'object' || state instanceof Date || state instanceof RegExp) { + return state; + } + + // Handle arrays specially + if (Array.isArray(state)) { + return new Proxy(state, { + get(target, prop) { + // Array methods and properties + if (prop === 'length' || typeof target[prop as any] === 'function') { + return target[prop as any]; + } + + const value = target[prop as any]; + + // If index exists, return proxied value + if (value !== undefined) { + if (typeof value === 'object' && value !== null && !(value instanceof Date)) { + return createSafeStateProxy(value); + } + return value; + } + + // Out of bounds index - return empty string + return ""; + } + }); + } + + // Proxy objects + return new Proxy(state, { + get(target, prop) { + const value = target[prop]; + + // If property exists, return it (wrapped in proxy if it's an object) + if (value !== undefined) { + // Recursively wrap objects/arrays in proxy + if (typeof value === 'object' && value !== null && !(value instanceof Date) && !(value instanceof RegExp)) { + return createSafeStateProxy(value); + } + return value; + } + + // Property is undefined - return empty string as safe default + // This prevents "undefined" from appearing in string concatenations + return ""; + } + }); +}; + +/** + * Evaluate a JavaScript expression with state context + * + * @param expression - JavaScript expression as string (e.g., "page.user.age + 1") + * @param context - Evaluation context with state object + * @returns Evaluated result + * @throws Error if evaluation fails + * + * @example + * evaluateExpression("page.user.name", { state: { page: { user: { name: "Jon" } }, global: {} } }) + * // Returns: "Jon" + * + * evaluateExpression("global.counter + 1", { state: { page: {}, global: { counter: 25 } } }) + * // Returns: 26 + * + * evaluateExpression("state.page.user.name.toUpperCase()", { state: { page: { user: { name: "Jon" } }, global: {} } }) + * // Returns: "JON" + */ +export const evaluateExpression = ( + expression: string, + context: EvaluationContext +): any => { + // Validate input + if (!expression || typeof expression !== 'string') { + throw new Error('Expression must be a non-empty string'); + } + + const trimmedExpression = expression.trim(); + if (!trimmedExpression) { + throw new Error('Expression cannot be empty'); + } + + try { + // Wrap state and aliases in safe proxies that return defaults for undefined properties + // This prevents "undefined" from appearing in expressions + // Example: state.page.user.mobileNumber returns "" instead of undefined + const safeState = createSafeStateProxy(context.state); + const safePage = createSafeStateProxy(context?.state?.page ?? {}); + const safeGlobal = createSafeStateProxy(context?.state?.global ?? {}); + + // Create a function with scoped aliases in scope + // This is safe because: + // 1. This is a builder tool (not end-user facing) + // 2. Expressions are written by trusted builder users + // 3. Expressions run in isolated preview context + const func = new Function('state', 'page', 'global', `return (${trimmedExpression});`); + return func(safeState, safePage, safeGlobal); + } catch (error: any) { + throw new Error(`Expression evaluation failed: ${error?.message || String(error)}`); + } +}; + +/** + * Validate if an expression is syntactically correct (without executing it) + * + * @param expression - JavaScript expression to validate + * @returns true if valid, false otherwise + */ +export const validateExpression = (expression: string): boolean => { + if (!expression || typeof expression !== 'string') { + return false; + } + + const trimmedExpression = expression.trim(); + if (!trimmedExpression) { + return false; + } + + try { + // Try to create the function without executing it + new Function('state', 'page', 'global', `return (${trimmedExpression});`); + return true; + } catch { + return false; + } +}; diff --git a/src/common/utils/serialization.ts b/src/common/utils/serialization.ts new file mode 100644 index 0000000..1ee2f0f --- /dev/null +++ b/src/common/utils/serialization.ts @@ -0,0 +1,92 @@ +import type { ComponentNode, PagesData, StateData } from '../types/component.types'; +import { v4 as uuidv4 } from 'uuid'; + +const STATE_VERSION = 1; +const PAGE_STATE_VERSION = 1; + +const safeObject = (value: unknown): Record => + value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {}; + +const createStateData = (state: unknown): StateData => { + return { + state: safeObject(state), + version: STATE_VERSION, + }; +}; + +const createPageStateData = (pageStateById: Record = {}): Record => { + const entries = Object.entries(pageStateById).map(([pageId, state]) => { + return [pageId, { state: safeObject(state), version: PAGE_STATE_VERSION } satisfies StateData] as const; + }); + return Object.fromEntries(entries); +}; + +export const exportToJSON = ( + pagesData: PagesData, + globalState: Record = {}, + pageStateById: Record> = {} +): string => { + const payload: PagesData = { + ...pagesData, + stateData: createStateData(globalState), + pageStateData: createPageStateData(pageStateById), + }; + return JSON.stringify(payload, null, 2); +}; + +export const importFromJSON = (json: string): PagesData | null => { + try { + const parsed = JSON.parse(json); + + // Check if it's the new PagesData format + if (parsed.pages && Array.isArray(parsed.pages)) { + return parsed as PagesData; + } + + // Backward compatibility: if it's just a ComponentNode, convert to PagesData + if (parsed.id && parsed.type) { + const defaultPage = { + id: uuidv4(), + name: 'Home', + route: '/', + root: parsed as ComponentNode, + isDefault: true, + }; + return { + pages: [defaultPage], + currentPageId: defaultPage.id, + }; + } + + return null; + } catch (error) { + console.error('Failed to parse JSON:', error); + return null; + } +}; + +export const deserializeStateData = (pagesData: PagesData | null): Record | null => { + if (!pagesData?.stateData?.state) { + return null; + } + + return pagesData.stateData.state; +}; + +export const deserializePageStateData = (pagesData: PagesData | null): Record> | null => { + const pageStateData = pagesData?.pageStateData; + if (!pageStateData || typeof pageStateData !== 'object') { + return null; + } + + const byPageId: Record> = {}; + for (const [pageId, stateData] of Object.entries(pageStateData)) { + if (!stateData || typeof stateData !== 'object') continue; + const state = (stateData as any).state; + if (state && typeof state === 'object') { + byPageId[pageId] = state as Record; + } + } + + return byPageId; +}; diff --git a/src/common/utils/stateBinding.ts b/src/common/utils/stateBinding.ts new file mode 100644 index 0000000..391be91 --- /dev/null +++ b/src/common/utils/stateBinding.ts @@ -0,0 +1,220 @@ +import { parseScopedPath } from '../state/scopedPath'; + +/** + * Parse all state bindings from text + * Supports flexible whitespace: both {{ property }} and {{property}} formats + */ +export const parseStateBindings = (text: string): string[] => { + // Match {{ property }} or {{property}} patterns + const regex = /\{\{\s*([^}]+?)\s*\}\}/g; + const matches: string[] = []; + let match; + + while ((match = regex.exec(text)) !== null) { + const property = match[1].trim(); + if (property) { + matches.push(property); + } + } + + return matches; +}; + +/** + * Resolve nested property path from state object + * Returns empty string if property doesn't exist + */ +export const resolveStateValue = (state: any, path: string): any => { + if (!path || !state) { + return ''; + } + + const parts = path.split('.'); + let current: any = state; + + for (const part of parts) { + if (current == null || typeof current !== 'object') { + return ''; + } + current = current[part]; + if (current === undefined) { + return ''; + } + } + + if (current && typeof current === 'object' && typeof current.__ref === 'string') { + return resolveStateValue(state, current.__ref); + } + + // Convert to string for display, but preserve null/undefined as empty string + if (current == null) { + return ''; + } + + return String(current); +}; + +/** + * Interpolate text by replacing all {{ property }} bindings with actual values + * Strict mode: all bindings must be scoped (`page.*` or `global.*`) + */ +export const interpolateText = ( + text: string, + state: any, + options?: { strict?: boolean } +): string => { + if (!text) { + return text || ''; + } + + const strict = options?.strict ?? true; + + // Match {{ property }} or {{property}} patterns and replace + return text.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (match, propertyPath) => { + const trimmedPath = propertyPath.trim(); + if (!trimmedPath) { + if (strict) { + throw new Error('Empty state binding is not allowed'); + } + return match; + } + + let parsedPath; + try { + parsedPath = parseScopedPath(trimmedPath); + } catch (error) { + if (!strict) { + return match; + } + const message = error instanceof Error ? error.message : 'Invalid binding path'; + throw new Error(`Invalid state binding "${trimmedPath}": ${message}`); + } + + return resolveStateValue(state, parsedPath.fullPath); + }); +}; + +/** + * Get keys of an object + * Returns empty array for non-objects, arrays, null, undefined + */ +export const getObjectKeys = (obj: any): string[] => { + if (!obj || typeof obj !== 'object' || Array.isArray(obj)) { + return []; + } + return Object.keys(obj); +}; + +/** + * Navigate to a nested path in an object + * Returns the value at the path, or undefined if not found + */ +export const navigatePath = (obj: any, path: string): any => { + if (!path || !obj) { + return obj; + } + + const parts = path.split('.'); + let current = obj; + + for (const part of parts) { + if (current == null || typeof current !== 'object') { + return undefined; + } + current = current[part]; + if (current === undefined) { + return undefined; + } + } + + return current; +}; + +/** + * Get suggestions for autocomplete based on current path + * Uses a scoped state object (`page` + `global`) + * + * @param unifiedState - Object containing available scoped bindings + * @param currentPath - Current path being typed (e.g., "page.user.cart") + * @returns Array of property names for the next level + */ +export const getStateSuggestions = ( + unifiedState: any, + currentPath?: string +): string[] => { + if (!unifiedState || typeof unifiedState !== 'object') { + return []; + } + + // If no path, return top-level keys + if (!currentPath) { + return getObjectKeys(unifiedState); + } + + // Navigate to the path and return keys of that object + const target = navigatePath(unifiedState, currentPath); + return getObjectKeys(target); +}; + +/** + * Recursively resolve {{ }} bindings in an object + * Walks through all values and resolves bindings using interpolateText + * + * @param obj - Object to resolve (can be nested objects/arrays) + * @param state - Global state for resolving bindings + * @returns Resolved object with all bindings replaced + */ +const resolveObjectBindings = (obj: any, state: any): any => { + // Base case: primitive values + if (obj === null || obj === undefined) { + return obj; + } + + // If it's a string, resolve any bindings + if (typeof obj === 'string') { + return interpolateText(obj, state); + } + + // If it's an array, recursively resolve each element + if (Array.isArray(obj)) { + return obj.map((item) => resolveObjectBindings(item, state)); + } + + // If it's an object, recursively resolve each property + if (typeof obj === 'object') { + const resolved: Record = {}; + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + resolved[key] = resolveObjectBindings(obj[key], state); + } + } + return resolved; + } + + // Other types (number, boolean, etc.) - return as is + return obj; +}; + +/** + * Parse JSON request body and resolve all {{ page.* }} / {{ global.* }} bindings + * + * @param requestBodyJson - JSON string containing request body (may have {{ }} bindings) + * @param state - Scoped runtime state object + * @returns Resolved request body object + * @throws Error if JSON is invalid + */ +export const resolveRequestBody = (requestBodyJson: string, state: any): any => { + // Empty or whitespace-only strings return empty object + if (!requestBodyJson || !requestBodyJson.trim()) { + return {}; + } + + let parsed: any; + try { + parsed = JSON.parse(requestBodyJson); + } catch (error) { + throw new Error(`Failed to parse request body JSON: ${error instanceof Error ? error.message : String(error)}`); + } + + return resolveObjectBindings(parsed, state); +}; diff --git a/src/common/utils/styleNormalization.ts b/src/common/utils/styleNormalization.ts new file mode 100644 index 0000000..2df2b9e --- /dev/null +++ b/src/common/utils/styleNormalization.ts @@ -0,0 +1,41 @@ +import type { CSSProperties } from 'react'; +import type { ComponentType } from '../types/component.types'; + +/** + * Normalizes component styles to prevent unwanted flexbox stretching. + * For components without explicit width in flex containers, applies + * alignSelf: 'flex-start' to prevent stretching — BUT only when the parent + * has not already set a non-stretch alignment (e.g. alignItems: 'center'). + * When the parent explicitly aligns its children, we trust that and leave + * alignSelf untouched so the parent's intent is preserved in both editor and preview. + */ +export const normalizeComponentStyles = ( + componentType: ComponentType, + styles: CSSProperties, + parentType?: ComponentType | null, + parentStyles?: CSSProperties | null, +): CSSProperties => { + // Check if parent is a flex container + const isInFlexContainer = parentType === 'row' || parentType === 'column'; + + if (isInFlexContainer) { + const hasExplicitWidth = styles.width !== undefined && styles.width !== null; + const hasExplicitAlignSelf = styles.alignSelf !== undefined && styles.alignSelf !== null; + + // Only inject alignSelf: flex-start when the parent hasn't explicitly overridden + // the default 'stretch' behaviour. If the parent sets alignItems to 'center', + // 'flex-end', 'flex-start', etc., we leave children alone so that intent is honoured. + const parentAlignItems = parentStyles?.alignItems; + const parentIsStretching = + !parentAlignItems || parentAlignItems === 'stretch' || parentAlignItems === 'normal'; + + if (!hasExplicitWidth && !hasExplicitAlignSelf && componentType !== 'input' && parentIsStretching) { + return { + ...styles, + alignSelf: 'flex-start', + }; + } + } + + return styles; +}; diff --git a/src/common/utils/workflowResultKey.ts b/src/common/utils/workflowResultKey.ts new file mode 100644 index 0000000..f1fc1a4 --- /dev/null +++ b/src/common/utils/workflowResultKey.ts @@ -0,0 +1,116 @@ +export interface NormalizedWorkflowResultKey { + relativeKey: string; + fullPath: string; +} + +const SEGMENT_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; + +const ensureValidKey = (key: string): void => { + if (!key) { + throw new Error('Result key is required'); + } + if (key.startsWith('state.') || key.startsWith('workflows.') || key.startsWith('global.workflows.')) { + throw new Error('Result key must be relative (do not include state./workflows./global.workflows. prefixes)'); + } + if (key.startsWith('.') || key.endsWith('.') || key.includes('..')) { + throw new Error('Result key must not start or end with a dot, or contain consecutive dots'); + } + + const segments = key.split('.'); + for (const segment of segments) { + if (!SEGMENT_PATTERN.test(segment)) { + throw new Error('Each segment must start with a letter or underscore and contain only letters, numbers, or underscores'); + } + } +}; + +export const normalizeWorkflowResultKey = (input: string): NormalizedWorkflowResultKey => { + if (typeof input !== 'string') { + throw new Error('Result key must be a string'); + } + + const key = input.trim(); + ensureValidKey(key); + + return { + relativeKey: key, + fullPath: `global.workflows.${key}`, + }; +}; + +const tokenize = (value: string): string[] => + value + .split(/[^a-zA-Z0-9]+/) + .map((token) => token.trim()) + .filter(Boolean); + +const toLowerCamel = (value: string): string => { + const tokens = tokenize(value); + if (tokens.length === 0) { + return ''; + } + + const [first, ...rest] = tokens; + const firstLower = first.toLowerCase(); + const restCamel = rest.map((token) => token.charAt(0).toUpperCase() + token.slice(1).toLowerCase()); + return [firstLower, ...restCamel].join(''); +}; + +const stableHash = (input: string): number => { + let hash = 0; + for (let i = 0; i < input.length; i += 1) { + hash = (hash * 31 + input.charCodeAt(i)) >>> 0; + } + return hash; +}; + +const buildFallbackKey = (seed: string): string => { + const suffix = stableHash(seed || 'workflow').toString(36).slice(0, 6) || '0'; + const candidate = `workflow.result.r${suffix}`; + try { + normalizeWorkflowResultKey(candidate); + return candidate; + } catch { + return 'workflow.result.r0'; + } +}; + +export const suggestWorkflowResultKeyFromName = (workflowName: string): string => { + const trimmed = typeof workflowName === 'string' ? workflowName.trim() : ''; + if (!trimmed) { + return buildFallbackKey('workflow'); + } + + const parts = trimmed + .split(':') + .map((part) => part.trim()) + .filter(Boolean); + + const namespaceParts = parts.length > 1 ? parts.slice(0, -1) : []; + const actionPart = parts.length > 0 ? parts[parts.length - 1] : trimmed; + + const segments = [ + ...namespaceParts.map((part) => toLowerCamel(part)).filter(Boolean), + toLowerCamel(actionPart), + ].filter(Boolean); + + const candidate = segments.join('.'); + if (candidate) { + try { + normalizeWorkflowResultKey(candidate); + return candidate; + } catch { + // fall through to fallback + } + } + + return buildFallbackKey(trimmed); +}; + +export const getWorkflowResultKeyPreview = (input: string): string => { + if (typeof input !== 'string') { + return ''; + } + + return input.trim(); +}; diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..3c840bd --- /dev/null +++ b/src/index.css @@ -0,0 +1,14 @@ +:root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; +} + +html, +body, +#root { + height: 100%; + margin: 0; +} + +* { + box-sizing: border-box; +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..8d4ac42 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import './index.css'; +import { App } from './App'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +); diff --git a/src/renderer/RendererApp.tsx b/src/renderer/RendererApp.tsx new file mode 100644 index 0000000..9be1c1c --- /dev/null +++ b/src/renderer/RendererApp.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { RendererRoutes } from './RendererRoutes'; + +// Ensure component definitions are registered once per bundle load. +import '../common/registry/definitions'; + +export interface RendererAppProps { + json: string; + workflow?: { executeUrl: string }; + initialRoute?: string; +} + +export const RendererApp: React.FC = ({ json, workflow, initialRoute }) => { + return ( + + ); +}; diff --git a/src/renderer/RendererRoutes.tsx b/src/renderer/RendererRoutes.tsx new file mode 100644 index 0000000..20a30ef --- /dev/null +++ b/src/renderer/RendererRoutes.tsx @@ -0,0 +1,94 @@ +import React, { useMemo, useState, useCallback, useEffect } from 'react'; +import type { PagesData } from '../common/types/component.types'; +import { importFromJSON } from '../common/utils/serialization'; +import { formatScopedBindingViolations, validatePagesDataScopedBindings } from '../common/state/validateScopedBindings'; +import { RendererRuntimeProvider } from './runtime'; +import { Preview } from './components/Preview'; + +// Ensure component definitions are registered once per bundle load. +import '../common/registry/definitions'; + +export interface RendererRoutesProps { + json: string; + workflow?: { executeUrl: string }; + initialRoute?: string; +} + +export const RendererRoutes: React.FC = ({ json, workflow, initialRoute }) => { + const parseResult = useMemo<{ pagesData: PagesData | null; error: string | null }>(() => { + const parsed = importFromJSON(json); + if (!parsed || !Array.isArray(parsed.pages)) { + return { pagesData: null, error: 'Invalid renderer JSON.' }; + } + + const violations = validatePagesDataScopedBindings(parsed); + if (violations.length > 0) { + return { + pagesData: null, + error: `Invalid scoped bindings detected:\n${formatScopedBindingViolations(violations)}`, + }; + } + + return { pagesData: parsed, error: null }; + }, [json]); + + const defaultRoute = useMemo(() => { + if (!parseResult.pagesData) return initialRoute || '/'; + const defaultPage = parseResult.pagesData.pages.find((p) => p.isDefault) || parseResult.pagesData.pages[0]; + return initialRoute || defaultPage?.route || '/'; + }, [parseResult.pagesData, initialRoute]); + + const getHashRoute = (): string | null => { + const hash = window.location.hash; + return hash ? hash.slice(1) : null; + }; + + const [currentRoute, setCurrentRoute] = useState( + getHashRoute() || defaultRoute + ); + + // Set initial hash on mount + useEffect(() => { + if (!window.location.hash) { + window.location.hash = defaultRoute; + } + }, []); + + const handleNavigate = useCallback((route: string) => { + window.location.hash = route; + setCurrentRoute(route); + }, []); + + // Sync state with browser back/forward + useEffect(() => { + const onHashChange = () => { + const route = getHashRoute(); + if (route) setCurrentRoute(route); + }; + window.addEventListener('hashchange', onHashChange); + return () => window.removeEventListener('hashchange', onHashChange); + }, []); + + if (!parseResult.pagesData || !Array.isArray(parseResult.pagesData.pages)) { + return ( +
+ {parseResult.error || 'Invalid renderer JSON.'} +
+ ); + } + + return ( + + + + ); +}; diff --git a/src/renderer/components/NavigationHeader.tsx b/src/renderer/components/NavigationHeader.tsx new file mode 100644 index 0000000..1b67976 --- /dev/null +++ b/src/renderer/components/NavigationHeader.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { Link, useLocation } from 'react-router-dom'; +import type { Page } from '../../common/types/component.types'; + +interface NavigationHeaderProps { + pages: Page[]; +} + +export const NavigationHeader: React.FC = ({ pages }) => { + const location = useLocation(); + + return ( + + ); +}; diff --git a/src/renderer/components/Preview.tsx b/src/renderer/components/Preview.tsx new file mode 100644 index 0000000..be6616f --- /dev/null +++ b/src/renderer/components/Preview.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import type { CSSProperties } from 'react'; +import type { ComponentNode, ComponentType, Page } from '../../common/types/component.types'; +import { ComponentRenderer } from '../../common/utils/ComponentRenderer'; + +interface PreviewProps { + pages: Page[]; + currentRoute: string; + onNavigate: (route: string) => void; +} + +const PreviewComponent: React.FC<{ + component: ComponentNode; + parentType?: ComponentType | null; + parentStyles?: CSSProperties | null; +}> = ({ component, parentType = null, parentStyles = null }) => { + const renderChildren = ( + children: ComponentNode[], + currentParentType: ComponentType, + currentParentStyles?: CSSProperties, + ) => { + return children.map((child) => ( + + )); + }; + + return ( + {}} + renderChildren={renderChildren} + previewMode={true} + parentType={parentType} + parentStyles={parentStyles} + /> + ); +}; + +const PageContent: React.FC<{ root: ComponentNode | null }> = ({ root }) => { + return ( +
+ {root ? ( + + ) : ( +
+ No components to preview. Add components in the editor. +
+ )} +
+ ); +}; + +export const Preview: React.FC = ({ pages, currentRoute }) => { + const defaultPage = pages.find((p) => p.isDefault) || pages[0]; + const activePage = pages.find((p) => p.route === currentRoute) || defaultPage; + + return ( +
+
+ +
+
+ ); +}; diff --git a/src/renderer/index.ts b/src/renderer/index.ts new file mode 100644 index 0000000..2e25ee2 --- /dev/null +++ b/src/renderer/index.ts @@ -0,0 +1,5 @@ +export { RendererApp } from './RendererApp'; +export { RendererRoutes } from './RendererRoutes'; +export { NavigationHeader } from './components/NavigationHeader'; +export { importFromJSON } from '../common/utils/serialization'; + diff --git a/src/renderer/runtime/RendererRuntimeProvider.tsx b/src/renderer/runtime/RendererRuntimeProvider.tsx new file mode 100644 index 0000000..13effae --- /dev/null +++ b/src/renderer/runtime/RendererRuntimeProvider.tsx @@ -0,0 +1,247 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import type { EventHandler, PagesData, ComponentNode } from '../../common/types/component.types'; +import type { ComponentEventType } from '../../common/registry/types'; +import { interpolateText } from '../../common/utils/stateBinding'; +import { updateStateAtPath } from '../../common/state/statePathUtils'; +import { parseScopedPath } from '../../common/state/scopedPath'; +import { EventExecutor } from '../../common/events'; +import { RuntimeContext } from '../../common/runtime'; +import type { RuntimeContextValue } from '../../common/runtime'; + +const isPathValidForNested = (path: string): boolean => { + if (!path || typeof path !== 'string') return false; + const trimmed = path.trim(); + if (!trimmed || trimmed.startsWith('.') || trimmed.endsWith('.') || trimmed.includes('..')) return false; + const pathPattern = /^[a-zA-Z0-9_. -]+$/; + return pathPattern.test(trimmed); +}; + +const safeObject = (value: unknown): Record => + value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {}; + +const deserializePageStateSnapshot = (pagesData: PagesData): Record> => { + const raw = pagesData.pageStateData; + if (!raw || typeof raw !== 'object') return {}; + + const byPageId: Record> = {}; + for (const [pageId, stateData] of Object.entries(raw)) { + const state = (stateData as any)?.state; + byPageId[pageId] = safeObject(state); + } + return byPageId; +}; + +export interface RendererRuntimeProviderProps { + children: React.ReactNode; + pagesData: PagesData; + workflow?: { executeUrl: string }; + currentRoute: string; + onNavigate: (route: string) => void; +} + +export const RendererRuntimeProvider: React.FC = ({ + children, + pagesData, + workflow, + currentRoute, + onNavigate, +}) => { + const pages = pagesData.pages ?? []; + + const [globalState, setGlobalState] = useState>(() => safeObject(pagesData.stateData?.state)); + const [pageStateById, setPageStateById] = useState>>(() => + deserializePageStateSnapshot(pagesData) + ); + + const currentPageId = useMemo(() => { + const matched = pages.find((p) => p.route === currentRoute); + if (matched) return matched.id; + const fallback = pages.find((p) => p.isDefault) || pages[0]; + return fallback?.id ?? null; + }, [currentRoute, pages]); + + const currentPageState = useMemo(() => { + if (!currentPageId) return {}; + return pageStateById[currentPageId] ?? {}; + }, [currentPageId, pageStateById]); + + const scopedState = useMemo(() => { + return { + page: currentPageState, + global: globalState, + }; + }, [globalState, currentPageState]); + + const setScopedStateValueDirect = useCallback( + (path: string, value: any): boolean => { + let parsedPath; + try { + parsedPath = parseScopedPath(path); + } catch (error) { + console.error(error); + return false; + } + + if (!isPathValidForNested(parsedPath.relativePath)) return false; + const parts = parsedPath.relativePath.split('.'); + + if (parsedPath.scope === 'page') { + if (!currentPageId) return false; + setPageStateById((prev) => { + const current = prev[currentPageId] ?? {}; + const next = updateStateAtPath(current, parts, value); + return { ...prev, [currentPageId]: next }; + }); + return true; + } + + setGlobalState((prev) => updateStateAtPath(prev, parts, value)); + return true; + }, + [currentPageId] + ); + + const setScopedStateValue = useCallback( + (path: string, value: any): boolean => { + let parsedPath; + try { + parsedPath = parseScopedPath(path); + } catch (error) { + console.error(error); + return false; + } + + if (!isPathValidForNested(parsedPath.relativePath)) return false; + + let processedValue: any = value; + if (typeof value === 'string') { + const trimmedValue = value.trim(); + if (trimmedValue.toLowerCase() === 'true') { + processedValue = true; + } else if (trimmedValue.toLowerCase() === 'false') { + processedValue = false; + } else if (trimmedValue === 'null') { + processedValue = null; + } else { + const numberMatch = trimmedValue.match(/^-?\d+\.?\d*$/); + if (numberMatch) { + processedValue = Number(trimmedValue); + if (isNaN(processedValue)) { + processedValue = value; + } + } else { + processedValue = value; + } + } + } + + return setScopedStateValueDirect(parsedPath.fullPath, processedValue); + }, + [setScopedStateValueDirect] + ); + + const setScopedStateValueRaw = useCallback( + (key: string, value: any): boolean => { + let parsedPath; + try { + parsedPath = parseScopedPath(key); + } catch (error) { + console.error(error); + return false; + } + + if (!parsedPath.relativePath) return false; + + if (parsedPath.scope === 'page') { + if (!currentPageId) return false; + setPageStateById((prev) => { + const current = prev[currentPageId] ?? {}; + return { ...prev, [currentPageId]: { ...current, [parsedPath.relativePath]: value } }; + }); + return true; + } + + setGlobalState((prev) => ({ ...prev, [parsedPath.relativePath]: value })); + return true; + }, + [currentPageId] + ); + + const navigateToPage = useCallback( + (pageId: string) => { + const page = pages.find((p) => p.id === pageId); + if (page) { + onNavigate(page.route); + } + }, + [onNavigate, pages] + ); + + const eventExecutor = useMemo(() => new EventExecutor({ workflow }), [workflow?.executeUrl]); + + useEffect(() => { + eventExecutor.setContext({ + setStateValue: setScopedStateValue, + setStateValueRaw: setScopedStateValueRaw, + setStateValueDirect: setScopedStateValueDirect, + navigateToPage, + pages, + state: scopedState, + }); + }, [ + eventExecutor, + navigateToPage, + pages, + scopedState, + setScopedStateValue, + setScopedStateValueDirect, + setScopedStateValueRaw, + ]); + + const resolveText = useCallback( + (text: string): string => { + return interpolateText(text, scopedState); + }, + [scopedState] + ); + + const executeHandlers = useCallback( + async (handlers: EventHandler[]) => { + if (handlers.length === 0) return; + await eventExecutor.executeWithErrorHandling(handlers); + }, + [eventExecutor] + ); + + const dispatchEvent = useCallback( + async (_eventType: ComponentEventType, component: ComponentNode, handlersKey = 'eventHandlers') => { + const handlers = (component.props?.[handlersKey] as EventHandler[]) || []; + if (handlers.length === 0) return; + await eventExecutor.executeWithErrorHandling(handlers); + }, + [eventExecutor] + ); + + const setStateValue = useCallback( + (path: string, value: any) => { + setScopedStateValueDirect(path, value); + }, + [setScopedStateValueDirect] + ); + + const contextValue = useMemo( + () => ({ + state: scopedState, + resolveText, + executeHandlers, + dispatchEvent, + pages, + navigateToPage, + setStateValue, + previewMode: true, + }), + [dispatchEvent, executeHandlers, navigateToPage, pages, resolveText, scopedState, setStateValue] + ); + + return {children}; +}; diff --git a/src/renderer/runtime/index.ts b/src/renderer/runtime/index.ts new file mode 100644 index 0000000..a02b55f --- /dev/null +++ b/src/renderer/runtime/index.ts @@ -0,0 +1,2 @@ +export { RendererRuntimeProvider } from './RendererRuntimeProvider'; + diff --git a/src/renderer/utils/navigation.ts b/src/renderer/utils/navigation.ts new file mode 100644 index 0000000..a6b60b5 --- /dev/null +++ b/src/renderer/utils/navigation.ts @@ -0,0 +1,20 @@ +import type { Page } from '../../common/types/component.types'; +import type { NavigateFunction } from 'react-router-dom'; + +/** + * Creates a navigation handler function for event handlers + * Finds page by ID and navigates to its route + */ +export const createNavigateHandler = ( + pages: Page[], + navigate: NavigateFunction +): ((pageId: string) => void) => { + return (pageId: string) => { + const page = pages.find((p) => p.id === pageId); + if (page) { + navigate(page.route); + } else { + console.warn(`[Navigation] Page with ID ${pageId} not found`); + } + }; +}; diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..8f204d2 --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "module": "ESNext", + "types": [ + "vite/client" + ], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": [ + "src" + ], + "exclude": [ + "**/*.test.ts", + "**/*.spec.ts" + ] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ea9d0cd --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,11 @@ +{ + "files": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.node.json" + } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..195c696 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": [ + "ES2023" + ], + "module": "ESNext", + "types": [ + "node" + ], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": [ + "vite.config.ts" + ] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..0466183 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], +});