dashboard-test-app/src/renderer/runtime/RendererRuntimeProvider.tsx

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