git update recorded at: 23/04/26 06:56:49
This commit is contained in:
parent
450350f5cb
commit
395881bc72
|
|
@ -0,0 +1,9 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
|
||||||
|
*.log
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"]
|
||||||
77
boltic.yaml
77
boltic.yaml
|
|
@ -1,70 +1,9 @@
|
||||||
# ============================================================================
|
app: renderer-bundle
|
||||||
# Boltic Configuration File - edith003
|
|
||||||
# ============================================================================
|
|
||||||
#
|
|
||||||
# ⚠️ 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: edith003
|
|
||||||
build:
|
|
||||||
builtin: dockerfile
|
|
||||||
ignorefile: .gitignore
|
|
||||||
language: nodejs/18
|
|
||||||
region: asia-south1
|
region: asia-south1
|
||||||
serverlessConfig:
|
|
||||||
Scaling:
|
build:
|
||||||
AutoStop: false
|
builtin: dockerfile
|
||||||
Min: 1
|
ignorefile: .gitignore
|
||||||
Max: 1
|
|
||||||
MaxIdleTime: 300
|
env:
|
||||||
Resources:
|
PORT: "8080"
|
||||||
CPU: 0.1
|
|
||||||
MemoryMB: 128
|
|
||||||
MemoryMaxMB: 128
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>renderer-bundle</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,46 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { RendererApp } from './renderer';
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const [json, setJson] = React.useState<string>('');
|
||||||
|
const [error, setError] = React.useState<string | null>(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 <div style={{ padding: 16 }}>Error: {error}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!json) {
|
||||||
|
return <div style={{ padding: 16 }}>Loading renderer JSON\u2026</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: '100vh' }}>
|
||||||
|
<RendererApp json={json} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<ActionType, ActionExecutor> = 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<EventExecutorConfig>) {
|
||||||
|
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<void> {
|
||||||
|
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<boolean> {
|
||||||
|
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<any> }
|
||||||
|
): Promise<void> => {
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface IconProps {
|
||||||
|
size?: number;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BoxIcon: React.FC<IconProps> = ({ size = 16, style }) => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: size, height: size, ...style }}>
|
||||||
|
<rect x="4" y="4" width="16" height="16" stroke="currentColor" strokeWidth="1.5" fill="none" rx="2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ColumnIcon: React.FC<IconProps> = ({ size = 16, style }) => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: size, height: size, ...style }}>
|
||||||
|
<rect x="4" y="4" width="16" height="5" stroke="currentColor" strokeWidth="1.5" fill="none" rx="1" />
|
||||||
|
<rect x="4" y="10" width="16" height="5" stroke="currentColor" strokeWidth="1.5" fill="none" rx="1" />
|
||||||
|
<rect x="4" y="16" width="16" height="4" stroke="currentColor" strokeWidth="1.5" fill="none" rx="1" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const RowIcon: React.FC<IconProps> = ({ size = 16, style }) => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: size, height: size, ...style }}>
|
||||||
|
<rect x="4" y="4" width="5" height="16" stroke="currentColor" strokeWidth="1.5" fill="none" rx="1" />
|
||||||
|
<rect x="10" y="4" width="5" height="16" stroke="currentColor" strokeWidth="1.5" fill="none" rx="1" />
|
||||||
|
<rect x="16" y="4" width="4" height="16" stroke="currentColor" strokeWidth="1.5" fill="none" rx="1" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ButtonIcon: React.FC<IconProps> = ({ size = 16, style }) => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: size, height: size, ...style }}>
|
||||||
|
<rect x="4" y="8" width="16" height="8" stroke="currentColor" strokeWidth="1.5" fill="none" rx="4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const TextIcon: React.FC<IconProps> = ({ size = 16, style }) => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: size, height: size, ...style }}>
|
||||||
|
<path d="M4 6h16M12 6v12M8 18h8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const GridIcon: React.FC<IconProps> = ({ size = 16, style }) => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: size, height: size, ...style }}>
|
||||||
|
<rect x="4" y="4" width="7" height="7" stroke="currentColor" strokeWidth="1.5" fill="none" rx="1" />
|
||||||
|
<rect x="13" y="4" width="7" height="7" stroke="currentColor" strokeWidth="1.5" fill="none" rx="1" />
|
||||||
|
<rect x="4" y="13" width="7" height="7" stroke="currentColor" strokeWidth="1.5" fill="none" rx="1" />
|
||||||
|
<rect x="13" y="13" width="7" height="7" stroke="currentColor" strokeWidth="1.5" fill="none" rx="1" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ListIcon: React.FC<IconProps> = ({ size = 16, style }) => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: size, height: size, ...style }}>
|
||||||
|
<circle cx="6" cy="6" r="1.5" fill="currentColor" />
|
||||||
|
<line x1="10" y1="6" x2="20" y2="6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
<circle cx="6" cy="12" r="1.5" fill="currentColor" />
|
||||||
|
<line x1="10" y1="12" x2="20" y2="12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
<circle cx="6" cy="18" r="1.5" fill="currentColor" />
|
||||||
|
<line x1="10" y1="18" x2="20" y2="18" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const InputIcon: React.FC<IconProps> = ({ size = 16, style }) => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: size, height: size, ...style }}>
|
||||||
|
<rect x="4" y="8" width="16" height="8" stroke="currentColor" strokeWidth="1.5" fill="none" rx="2" />
|
||||||
|
<line x1="7" y1="12" x2="7" y2="12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const MobileIcon: React.FC<IconProps> = ({ size = 16, style }) => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: size, height: size, ...style }}>
|
||||||
|
<rect x="7" y="3" width="10" height="18" stroke="currentColor" strokeWidth="1.5" fill="none" rx="2" />
|
||||||
|
<line x1="10" y1="18" x2="14" y2="18" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const TabletIcon: React.FC<IconProps> = ({ size = 16, style }) => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: size, height: size, ...style }}>
|
||||||
|
<rect x="4" y="3" width="16" height="18" stroke="currentColor" strokeWidth="1.5" fill="none" rx="2" />
|
||||||
|
<line x1="10" y1="18" x2="14" y2="18" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const DesktopIcon: React.FC<IconProps> = ({ size = 16, style }) => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: size, height: size, ...style }}>
|
||||||
|
<rect x="2" y="4" width="20" height="13" stroke="currentColor" strokeWidth="1.5" fill="none" rx="2" />
|
||||||
|
<line x1="9" y1="21" x2="15" y2="21" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
<line x1="12" y1="17" x2="12" y2="21" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const PlusIcon: React.FC<IconProps> = ({ size = 16, style }) => (
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ width: size, height: size, ...style }}>
|
||||||
|
<path d="M12 5V19M5 12H19" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
@ -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<string, ComponentDefinition> = 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<string, any> {
|
||||||
|
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<any> | 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();
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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<ComponentRenderProps> = ({ 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<HTMLButtonElement>) => {
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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<HTMLInputElement>) => {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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<string, any>;
|
||||||
|
global: Record<string, any>;
|
||||||
|
};
|
||||||
|
/** Resolve text with scoped state bindings ({{ page.* }} / {{ global.* }} syntax) */
|
||||||
|
resolveText: (text: string) => string;
|
||||||
|
/** Execute event handlers */
|
||||||
|
executeHandlers: (handlers: EventHandler[]) => Promise<void>;
|
||||||
|
/** Dispatch an event for a component */
|
||||||
|
dispatchEvent: (eventType: ComponentEventType, component: ComponentNode, handlersKey?: string) => Promise<void>;
|
||||||
|
/** 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<string, any>;
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
|
|
@ -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<string, any>;
|
||||||
|
global: Record<string, any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Resolve scoped bindings in text ({{ page.* }} / {{ global.* }} syntax) */
|
||||||
|
resolveText: (text: string) => string;
|
||||||
|
|
||||||
|
/** Execute event handlers (for button clicks, etc.) */
|
||||||
|
executeHandlers: (handlers: EventHandler[]) => Promise<void>;
|
||||||
|
|
||||||
|
/** Dispatch an event for a component */
|
||||||
|
dispatchEvent: (eventType: ComponentEventType, component: ComponentNode, handlersKey?: string) => Promise<void>;
|
||||||
|
|
||||||
|
/** 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<RuntimeContextValue | null>(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);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { RuntimeContext, useRuntime, useRuntimeOptional } from './RuntimeContext';
|
||||||
|
export type { RuntimeContextValue } from './RuntimeContext';
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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<string, any> = 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<string, any> = { ...currentState };
|
||||||
|
if (rest.length === 0) {
|
||||||
|
delete updatedObject[first];
|
||||||
|
return updatedObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedObject[first] = deleteStateAtPath(updatedObject[first], rest);
|
||||||
|
return updatedObject;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -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<string, unknown>).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');
|
||||||
|
};
|
||||||
|
|
@ -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<string, any>;
|
||||||
|
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<string, StateData>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StateData {
|
||||||
|
state: Record<string, any>;
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
@ -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<ComponentRendererProps> = ({
|
||||||
|
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<HTMLDivElement>(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 (
|
||||||
|
<div ref={domRef} style={{ display: 'contents' }}>
|
||||||
|
{renderComponent()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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<string, any> =>
|
||||||
|
value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, any>) : {};
|
||||||
|
|
||||||
|
const createStateData = (state: unknown): StateData => {
|
||||||
|
return {
|
||||||
|
state: safeObject(state),
|
||||||
|
version: STATE_VERSION,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const createPageStateData = (pageStateById: Record<string, unknown> = {}): Record<string, StateData> => {
|
||||||
|
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<string, any> = {},
|
||||||
|
pageStateById: Record<string, Record<string, any>> = {}
|
||||||
|
): 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<string, any> | null => {
|
||||||
|
if (!pagesData?.stateData?.state) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return pagesData.stateData.state;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deserializePageStateData = (pagesData: PagesData | null): Record<string, Record<string, any>> | null => {
|
||||||
|
const pageStateData = pagesData?.pageStateData;
|
||||||
|
if (!pageStateData || typeof pageStateData !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const byPageId: Record<string, Record<string, any>> = {};
|
||||||
|
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<string, any>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return byPageId;
|
||||||
|
};
|
||||||
|
|
@ -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<string, any> = {};
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -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();
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
|
|
@ -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<RendererAppProps> = ({ json, workflow, initialRoute }) => {
|
||||||
|
return (
|
||||||
|
<RendererRoutes
|
||||||
|
json={json}
|
||||||
|
workflow={workflow}
|
||||||
|
initialRoute={initialRoute ?? window.location.pathname}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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<RendererRoutesProps> = ({ 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<string>(
|
||||||
|
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 (
|
||||||
|
<div style={{ padding: 16, fontFamily: 'system-ui, sans-serif', color: '#333', whiteSpace: 'pre-wrap' }}>
|
||||||
|
{parseResult.error || 'Invalid renderer JSON.'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RendererRuntimeProvider
|
||||||
|
pagesData={parseResult.pagesData}
|
||||||
|
workflow={workflow}
|
||||||
|
currentRoute={currentRoute}
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
>
|
||||||
|
<Preview
|
||||||
|
pages={parseResult.pagesData.pages}
|
||||||
|
currentRoute={currentRoute}
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
/>
|
||||||
|
</RendererRuntimeProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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<NavigationHeaderProps> = ({ pages }) => {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
borderBottom: '1px solid #ddd',
|
||||||
|
padding: '12px 24px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '16px',
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pages.map((page) => {
|
||||||
|
const isActive = location.pathname === page.route;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={page.id}
|
||||||
|
to={page.route}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: isActive ? '#007bff' : '#333',
|
||||||
|
backgroundColor: isActive ? '#e3f2fd' : 'transparent',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontWeight: isActive ? '600' : '400',
|
||||||
|
fontSize: '14px',
|
||||||
|
border: isActive ? '1px solid #007bff' : '1px solid transparent',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{page.name}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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) => (
|
||||||
|
<PreviewComponent
|
||||||
|
key={child.id}
|
||||||
|
component={child}
|
||||||
|
parentType={currentParentType}
|
||||||
|
parentStyles={currentParentStyles ?? null}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ComponentRenderer
|
||||||
|
component={component}
|
||||||
|
isSelected={null}
|
||||||
|
onSelect={() => {}}
|
||||||
|
renderChildren={renderChildren}
|
||||||
|
previewMode={true}
|
||||||
|
parentType={parentType}
|
||||||
|
parentStyles={parentStyles}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const PageContent: React.FC<{ root: ComponentNode | null }> = ({ root }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
overflow: 'auto',
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{root ? (
|
||||||
|
<PreviewComponent component={root} />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
height: '100%',
|
||||||
|
color: '#999',
|
||||||
|
fontSize: '16px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No components to preview. Add components in the editor.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Preview: React.FC<PreviewProps> = ({ pages, currentRoute }) => {
|
||||||
|
const defaultPage = pages.find((p) => p.isDefault) || pages[0];
|
||||||
|
const activePage = pages.find((p) => p.route === currentRoute) || defaultPage;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1, overflow: 'hidden' }}>
|
||||||
|
<PageContent root={activePage?.root ?? null} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
export { RendererApp } from './RendererApp';
|
||||||
|
export { RendererRoutes } from './RendererRoutes';
|
||||||
|
export { NavigationHeader } from './components/NavigationHeader';
|
||||||
|
export { importFromJSON } from '../common/utils/serialization';
|
||||||
|
|
||||||
|
|
@ -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<string, any> =>
|
||||||
|
value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, any>) : {};
|
||||||
|
|
||||||
|
const deserializePageStateSnapshot = (pagesData: PagesData): Record<string, Record<string, any>> => {
|
||||||
|
const raw = pagesData.pageStateData;
|
||||||
|
if (!raw || typeof raw !== 'object') return {};
|
||||||
|
|
||||||
|
const byPageId: Record<string, Record<string, any>> = {};
|
||||||
|
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<RendererRuntimeProviderProps> = ({
|
||||||
|
children,
|
||||||
|
pagesData,
|
||||||
|
workflow,
|
||||||
|
currentRoute,
|
||||||
|
onNavigate,
|
||||||
|
}) => {
|
||||||
|
const pages = pagesData.pages ?? [];
|
||||||
|
|
||||||
|
const [globalState, setGlobalState] = useState<Record<string, any>>(() => safeObject(pagesData.stateData?.state));
|
||||||
|
const [pageStateById, setPageStateById] = useState<Record<string, Record<string, any>>>(() =>
|
||||||
|
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<RuntimeContextValue>(
|
||||||
|
() => ({
|
||||||
|
state: scopedState,
|
||||||
|
resolveText,
|
||||||
|
executeHandlers,
|
||||||
|
dispatchEvent,
|
||||||
|
pages,
|
||||||
|
navigateToPage,
|
||||||
|
setStateValue,
|
||||||
|
previewMode: true,
|
||||||
|
}),
|
||||||
|
[dispatchEvent, executeHandlers, navigateToPage, pages, resolveText, scopedState, setStateValue]
|
||||||
|
);
|
||||||
|
|
||||||
|
return <RuntimeContext.Provider value={contextValue}>{children}</RuntimeContext.Provider>;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { RendererRuntimeProvider } from './RendererRuntimeProvider';
|
||||||
|
|
||||||
|
|
@ -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`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user