248 lines
7.5 KiB
TypeScript
248 lines
7.5 KiB
TypeScript
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>;
|
|
};
|