renderer.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502
  1. import React, {
  2. type ComponentType,
  3. type ErrorInfo,
  4. type ReactNode,
  5. useCallback,
  6. useMemo,
  7. } from "react";
  8. import type {
  9. UIElement,
  10. Spec,
  11. ActionBinding,
  12. Catalog,
  13. SchemaDefinition,
  14. } from "@json-render/core";
  15. import {
  16. resolveElementProps,
  17. resolveBindings,
  18. resolveActionParam,
  19. evaluateVisibility,
  20. getByPath,
  21. type PropResolutionContext,
  22. type VisibilityContext as CoreVisibilityContext,
  23. } from "@json-render/core";
  24. import type { Components, SetState, StateModel } from "./catalog-types";
  25. import { useIsVisible, useVisibility } from "./contexts/visibility";
  26. import { useActions } from "./contexts/actions";
  27. import { useStateStore } from "./contexts/state";
  28. import { StateProvider } from "./contexts/state";
  29. import { VisibilityProvider } from "./contexts/visibility";
  30. import { ActionProvider } from "./contexts/actions";
  31. import { ValidationProvider } from "./contexts/validation";
  32. import { standardComponents } from "./components/standard";
  33. import { RepeatScopeProvider, useRepeatScope } from "./contexts/repeat-scope";
  34. // =============================================================================
  35. // Types
  36. // =============================================================================
  37. export interface ComponentRenderProps<P = Record<string, unknown>> {
  38. element: UIElement<string, P>;
  39. children?: ReactNode;
  40. emit: (event: string) => void;
  41. bindings?: Record<string, string>;
  42. loading?: boolean;
  43. }
  44. export type ComponentRenderer<P = Record<string, unknown>> = ComponentType<
  45. ComponentRenderProps<P>
  46. >;
  47. export type ComponentRegistry = Record<string, ComponentRenderer<any>>;
  48. export interface RendererProps {
  49. spec: Spec | null;
  50. registry?: ComponentRegistry;
  51. includeStandard?: boolean;
  52. loading?: boolean;
  53. fallback?: ComponentRenderer;
  54. }
  55. // =============================================================================
  56. // ElementErrorBoundary
  57. // =============================================================================
  58. interface ElementErrorBoundaryProps {
  59. elementType: string;
  60. children: ReactNode;
  61. }
  62. interface ElementErrorBoundaryState {
  63. hasError: boolean;
  64. }
  65. class ElementErrorBoundary extends React.Component<
  66. ElementErrorBoundaryProps,
  67. ElementErrorBoundaryState
  68. > {
  69. constructor(props: ElementErrorBoundaryProps) {
  70. super(props);
  71. this.state = { hasError: false };
  72. }
  73. static getDerivedStateFromError(): ElementErrorBoundaryState {
  74. return { hasError: true };
  75. }
  76. componentDidCatch(error: Error, info: ErrorInfo) {
  77. console.error(
  78. `[json-render/react-email] Rendering error in <${this.props.elementType}>:`,
  79. error,
  80. info.componentStack,
  81. );
  82. }
  83. render() {
  84. if (this.state.hasError) {
  85. return null;
  86. }
  87. return this.props.children;
  88. }
  89. }
  90. // =============================================================================
  91. // ElementRenderer
  92. // =============================================================================
  93. interface ElementRendererProps {
  94. element: UIElement;
  95. spec: Spec;
  96. registry: ComponentRegistry;
  97. loading?: boolean;
  98. fallback?: ComponentRenderer;
  99. }
  100. const ElementRenderer = React.memo(function ElementRenderer({
  101. element,
  102. spec,
  103. registry,
  104. loading,
  105. fallback,
  106. }: ElementRendererProps) {
  107. const repeatScope = useRepeatScope();
  108. const { ctx } = useVisibility();
  109. const { execute } = useActions();
  110. const fullCtx: PropResolutionContext = useMemo(
  111. () =>
  112. repeatScope
  113. ? {
  114. ...ctx,
  115. repeatItem: repeatScope.item,
  116. repeatIndex: repeatScope.index,
  117. repeatBasePath: repeatScope.basePath,
  118. }
  119. : ctx,
  120. [ctx, repeatScope],
  121. );
  122. const isVisible =
  123. element.visible === undefined
  124. ? true
  125. : evaluateVisibility(element.visible, fullCtx);
  126. const onBindings = element.on;
  127. const emit = useCallback(
  128. (eventName: string) => {
  129. const binding = onBindings?.[eventName];
  130. if (!binding) return;
  131. const actionBindings = Array.isArray(binding) ? binding : [binding];
  132. for (const b of actionBindings) {
  133. if (!b.params) {
  134. execute(b);
  135. continue;
  136. }
  137. const resolved: Record<string, unknown> = {};
  138. for (const [key, val] of Object.entries(b.params)) {
  139. resolved[key] = resolveActionParam(val, fullCtx);
  140. }
  141. execute({ ...b, params: resolved });
  142. }
  143. },
  144. [onBindings, execute, fullCtx],
  145. );
  146. if (!isVisible) {
  147. return null;
  148. }
  149. const rawProps = element.props as Record<string, unknown>;
  150. const elementBindings = resolveBindings(rawProps, fullCtx);
  151. const resolvedProps = resolveElementProps(rawProps, fullCtx);
  152. const resolvedElement =
  153. resolvedProps !== element.props
  154. ? { ...element, props: resolvedProps }
  155. : element;
  156. const Component = registry[resolvedElement.type] ?? fallback;
  157. if (!Component) {
  158. console.warn(
  159. `[json-render/react-email] No renderer for component type: ${resolvedElement.type}`,
  160. );
  161. return null;
  162. }
  163. const children = resolvedElement.repeat ? (
  164. <RepeatChildren
  165. element={resolvedElement}
  166. spec={spec}
  167. registry={registry}
  168. loading={loading}
  169. fallback={fallback}
  170. />
  171. ) : (
  172. resolvedElement.children?.map((childKey) => {
  173. const childElement = spec.elements[childKey];
  174. if (!childElement) {
  175. if (!loading) {
  176. console.warn(
  177. `[json-render/react-email] Missing element "${childKey}" referenced as child of "${resolvedElement.type}".`,
  178. );
  179. }
  180. return null;
  181. }
  182. return (
  183. <ElementRenderer
  184. key={childKey}
  185. element={childElement}
  186. spec={spec}
  187. registry={registry}
  188. loading={loading}
  189. fallback={fallback}
  190. />
  191. );
  192. })
  193. );
  194. return (
  195. <ElementErrorBoundary elementType={resolvedElement.type}>
  196. <Component
  197. element={resolvedElement}
  198. emit={emit}
  199. bindings={elementBindings}
  200. loading={loading}
  201. >
  202. {children}
  203. </Component>
  204. </ElementErrorBoundary>
  205. );
  206. });
  207. // =============================================================================
  208. // RepeatChildren
  209. // =============================================================================
  210. function RepeatChildren({
  211. element,
  212. spec,
  213. registry,
  214. loading,
  215. fallback,
  216. }: {
  217. element: UIElement;
  218. spec: Spec;
  219. registry: ComponentRegistry;
  220. loading?: boolean;
  221. fallback?: ComponentRenderer;
  222. }) {
  223. const { state } = useStateStore();
  224. const repeat = element.repeat!;
  225. const statePath = repeat.statePath;
  226. const items = (getByPath(state, statePath) as unknown[] | undefined) ?? [];
  227. return (
  228. <>
  229. {items.map((itemValue, index) => {
  230. const key =
  231. repeat.key && typeof itemValue === "object" && itemValue !== null
  232. ? String(
  233. (itemValue as Record<string, unknown>)[repeat.key] ?? index,
  234. )
  235. : String(index);
  236. return (
  237. <RepeatScopeProvider
  238. key={key}
  239. item={itemValue}
  240. index={index}
  241. basePath={`${statePath}/${index}`}
  242. >
  243. {element.children?.map((childKey) => {
  244. const childElement = spec.elements[childKey];
  245. if (!childElement) {
  246. if (!loading) {
  247. console.warn(
  248. `[json-render/react-email] Missing element "${childKey}" referenced as child of "${element.type}" (repeat).`,
  249. );
  250. }
  251. return null;
  252. }
  253. return (
  254. <ElementRenderer
  255. key={childKey}
  256. element={childElement}
  257. spec={spec}
  258. registry={registry}
  259. loading={loading}
  260. fallback={fallback}
  261. />
  262. );
  263. })}
  264. </RepeatScopeProvider>
  265. );
  266. })}
  267. </>
  268. );
  269. }
  270. // =============================================================================
  271. // Renderer
  272. // =============================================================================
  273. export function Renderer({
  274. spec,
  275. registry: customRegistry,
  276. includeStandard = true,
  277. loading,
  278. fallback,
  279. }: RendererProps) {
  280. const registry: ComponentRegistry = useMemo(
  281. () => ({
  282. ...(includeStandard ? standardComponents : {}),
  283. ...customRegistry,
  284. }),
  285. [customRegistry, includeStandard],
  286. );
  287. if (!spec || !spec.root) {
  288. return null;
  289. }
  290. const rootElement = spec.elements[spec.root];
  291. if (!rootElement) {
  292. return null;
  293. }
  294. return (
  295. <ElementRenderer
  296. element={rootElement}
  297. spec={spec}
  298. registry={registry}
  299. loading={loading}
  300. fallback={fallback}
  301. />
  302. );
  303. }
  304. // =============================================================================
  305. // JSONUIProvider
  306. // =============================================================================
  307. export interface JSONUIProviderProps {
  308. initialState?: Record<string, unknown>;
  309. handlers?: Record<
  310. string,
  311. (params: Record<string, unknown>) => Promise<unknown> | unknown
  312. >;
  313. navigate?: (path: string) => void;
  314. validationFunctions?: Record<
  315. string,
  316. (value: unknown, args?: Record<string, unknown>) => boolean
  317. >;
  318. onStateChange?: (path: string, value: unknown) => void;
  319. children: ReactNode;
  320. }
  321. export function JSONUIProvider({
  322. initialState,
  323. handlers,
  324. navigate,
  325. validationFunctions,
  326. onStateChange,
  327. children,
  328. }: JSONUIProviderProps) {
  329. return (
  330. <StateProvider initialState={initialState} onStateChange={onStateChange}>
  331. <VisibilityProvider>
  332. <ActionProvider handlers={handlers} navigate={navigate}>
  333. <ValidationProvider customFunctions={validationFunctions}>
  334. {children}
  335. </ValidationProvider>
  336. </ActionProvider>
  337. </VisibilityProvider>
  338. </StateProvider>
  339. );
  340. }
  341. // =============================================================================
  342. // defineRegistry
  343. // =============================================================================
  344. export interface DefineRegistryResult {
  345. registry: ComponentRegistry;
  346. }
  347. type DefineRegistryComponentFn = (ctx: {
  348. props: unknown;
  349. children?: React.ReactNode;
  350. emit: (event: string) => void;
  351. bindings?: Record<string, string>;
  352. loading?: boolean;
  353. }) => React.ReactNode;
  354. export function defineRegistry<C extends Catalog>(
  355. _catalog: C,
  356. options: {
  357. components?: Components<C>;
  358. },
  359. ): DefineRegistryResult {
  360. const registry: ComponentRegistry = {};
  361. if (options.components) {
  362. for (const [name, componentFn] of Object.entries(options.components)) {
  363. registry[name] = ({
  364. element,
  365. children,
  366. emit,
  367. bindings,
  368. loading,
  369. }: ComponentRenderProps) => {
  370. return (componentFn as DefineRegistryComponentFn)({
  371. props: element.props,
  372. children,
  373. emit,
  374. bindings,
  375. loading,
  376. });
  377. };
  378. }
  379. }
  380. return { registry };
  381. }
  382. // =============================================================================
  383. // createRenderer
  384. // =============================================================================
  385. export interface CreateRendererProps {
  386. spec: Spec | null;
  387. state?: Record<string, unknown>;
  388. onAction?: (actionName: string, params?: Record<string, unknown>) => void;
  389. onStateChange?: (path: string, value: unknown) => void;
  390. loading?: boolean;
  391. fallback?: ComponentRenderer;
  392. }
  393. export type ComponentMap<
  394. TComponents extends Record<string, { props: unknown }>,
  395. > = {
  396. [K in keyof TComponents]: ComponentType<
  397. ComponentRenderProps<
  398. TComponents[K]["props"] extends { _output: infer O }
  399. ? O
  400. : Record<string, unknown>
  401. >
  402. >;
  403. };
  404. export function createRenderer<
  405. TDef extends SchemaDefinition,
  406. TCatalog extends { components: Record<string, { props: unknown }> },
  407. >(
  408. catalog: Catalog<TDef, TCatalog>,
  409. components: ComponentMap<TCatalog["components"]>,
  410. ): ComponentType<CreateRendererProps> {
  411. const registry: ComponentRegistry =
  412. components as unknown as ComponentRegistry;
  413. return function CatalogRenderer({
  414. spec,
  415. state,
  416. onAction,
  417. onStateChange,
  418. loading,
  419. fallback,
  420. }: CreateRendererProps) {
  421. const actionHandlers = onAction
  422. ? new Proxy(
  423. {} as Record<
  424. string,
  425. (params: Record<string, unknown>) => void | Promise<void>
  426. >,
  427. {
  428. get: (_target, prop: string) => {
  429. return (params: Record<string, unknown>) =>
  430. onAction(prop, params);
  431. },
  432. has: () => true,
  433. },
  434. )
  435. : undefined;
  436. return (
  437. <StateProvider initialState={state} onStateChange={onStateChange}>
  438. <VisibilityProvider>
  439. <ActionProvider handlers={actionHandlers}>
  440. <ValidationProvider>
  441. <Renderer
  442. spec={spec}
  443. registry={registry}
  444. loading={loading}
  445. fallback={fallback}
  446. />
  447. </ValidationProvider>
  448. </ActionProvider>
  449. </VisibilityProvider>
  450. </StateProvider>
  451. );
  452. };
  453. }