actions.tsx 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. import React, {
  2. createContext,
  3. useContext,
  4. useState,
  5. useCallback,
  6. useMemo,
  7. type ReactNode,
  8. } from "react";
  9. import {
  10. resolveAction,
  11. executeAction,
  12. type ActionBinding,
  13. type ActionHandler,
  14. type ActionConfirm,
  15. type ResolvedAction,
  16. } from "@json-render/core";
  17. import { useStateStore } from "./state";
  18. let idCounter = 0;
  19. function generateUniqueId(): string {
  20. idCounter += 1;
  21. return `${Date.now()}-${idCounter}`;
  22. }
  23. function deepResolveValue(
  24. value: unknown,
  25. get: (path: string) => unknown,
  26. ): unknown {
  27. if (value === null || value === undefined) return value;
  28. if (value === "$id") {
  29. return generateUniqueId();
  30. }
  31. if (typeof value === "object" && !Array.isArray(value)) {
  32. const obj = value as Record<string, unknown>;
  33. const keys = Object.keys(obj);
  34. if (keys.length === 1 && typeof obj.$state === "string") {
  35. return get(obj.$state as string);
  36. }
  37. if (keys.length === 1 && "$id" in obj) {
  38. return generateUniqueId();
  39. }
  40. }
  41. if (Array.isArray(value)) {
  42. return value.map((item) => deepResolveValue(item, get));
  43. }
  44. if (typeof value === "object") {
  45. const resolved: Record<string, unknown> = {};
  46. for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
  47. resolved[key] = deepResolveValue(val, get);
  48. }
  49. return resolved;
  50. }
  51. return value;
  52. }
  53. export interface PendingConfirmation {
  54. action: ResolvedAction;
  55. handler: ActionHandler;
  56. resolve: () => void;
  57. reject: () => void;
  58. }
  59. export interface ActionContextValue {
  60. handlers: Record<string, ActionHandler>;
  61. loadingActions: Set<string>;
  62. pendingConfirmation: PendingConfirmation | null;
  63. execute: (binding: ActionBinding) => Promise<void>;
  64. confirm: () => void;
  65. cancel: () => void;
  66. registerHandler: (name: string, handler: ActionHandler) => void;
  67. }
  68. const ActionContext = createContext<ActionContextValue | null>(null);
  69. export interface ActionProviderProps {
  70. handlers?: Record<string, ActionHandler>;
  71. navigate?: (path: string) => void;
  72. children: ReactNode;
  73. }
  74. export function ActionProvider({
  75. handlers: initialHandlers = {},
  76. navigate,
  77. children,
  78. }: ActionProviderProps) {
  79. const { state, get, set } = useStateStore();
  80. const [handlers, setHandlers] =
  81. useState<Record<string, ActionHandler>>(initialHandlers);
  82. const [loadingActions, setLoadingActions] = useState<Set<string>>(new Set());
  83. const [pendingConfirmation, setPendingConfirmation] =
  84. useState<PendingConfirmation | null>(null);
  85. const registerHandler = useCallback(
  86. (name: string, handler: ActionHandler) => {
  87. setHandlers((prev) => ({ ...prev, [name]: handler }));
  88. },
  89. [],
  90. );
  91. const execute = useCallback(
  92. async (binding: ActionBinding) => {
  93. const resolved = resolveAction(binding, state);
  94. if (resolved.action === "setState" && resolved.params) {
  95. const statePath = resolved.params.statePath as string;
  96. const value = resolved.params.value;
  97. if (statePath) {
  98. set(statePath, value);
  99. }
  100. return;
  101. }
  102. if (resolved.action === "pushState" && resolved.params) {
  103. const statePath = resolved.params.statePath as string;
  104. const rawValue = resolved.params.value;
  105. if (statePath) {
  106. const resolvedValue = deepResolveValue(rawValue, get);
  107. const arr = (get(statePath) as unknown[] | undefined) ?? [];
  108. set(statePath, [...arr, resolvedValue]);
  109. const clearStatePath = resolved.params.clearStatePath as
  110. | string
  111. | undefined;
  112. if (clearStatePath) {
  113. set(clearStatePath, "");
  114. }
  115. }
  116. return;
  117. }
  118. if (resolved.action === "removeState" && resolved.params) {
  119. const statePath = resolved.params.statePath as string;
  120. const index = resolved.params.index as number;
  121. if (statePath !== undefined && index !== undefined) {
  122. const arr = (get(statePath) as unknown[] | undefined) ?? [];
  123. set(
  124. statePath,
  125. arr.filter((_, i) => i !== index),
  126. );
  127. }
  128. return;
  129. }
  130. const handler = handlers[resolved.action];
  131. if (!handler) {
  132. console.warn(`No handler registered for action: ${resolved.action}`);
  133. return;
  134. }
  135. if (resolved.confirm) {
  136. return new Promise<void>((resolve, reject) => {
  137. setPendingConfirmation({
  138. action: resolved,
  139. handler,
  140. resolve: () => {
  141. setPendingConfirmation(null);
  142. resolve();
  143. },
  144. reject: () => {
  145. setPendingConfirmation(null);
  146. reject(new Error("Action cancelled"));
  147. },
  148. });
  149. }).then(async () => {
  150. setLoadingActions((prev) => new Set(prev).add(resolved.action));
  151. try {
  152. await executeAction({
  153. action: resolved,
  154. handler,
  155. setState: set,
  156. navigate,
  157. executeAction: async (name) => {
  158. const subBinding: ActionBinding = { action: name };
  159. await execute(subBinding);
  160. },
  161. });
  162. } finally {
  163. setLoadingActions((prev) => {
  164. const next = new Set(prev);
  165. next.delete(resolved.action);
  166. return next;
  167. });
  168. }
  169. });
  170. }
  171. setLoadingActions((prev) => new Set(prev).add(resolved.action));
  172. try {
  173. await executeAction({
  174. action: resolved,
  175. handler,
  176. setState: set,
  177. navigate,
  178. executeAction: async (name) => {
  179. const subBinding: ActionBinding = { action: name };
  180. await execute(subBinding);
  181. },
  182. });
  183. } finally {
  184. setLoadingActions((prev) => {
  185. const next = new Set(prev);
  186. next.delete(resolved.action);
  187. return next;
  188. });
  189. }
  190. },
  191. [state, handlers, get, set, navigate],
  192. );
  193. const confirm = useCallback(() => {
  194. pendingConfirmation?.resolve();
  195. }, [pendingConfirmation]);
  196. const cancel = useCallback(() => {
  197. pendingConfirmation?.reject();
  198. }, [pendingConfirmation]);
  199. const value = useMemo<ActionContextValue>(
  200. () => ({
  201. handlers,
  202. loadingActions,
  203. pendingConfirmation,
  204. execute,
  205. confirm,
  206. cancel,
  207. registerHandler,
  208. }),
  209. [
  210. handlers,
  211. loadingActions,
  212. pendingConfirmation,
  213. execute,
  214. confirm,
  215. cancel,
  216. registerHandler,
  217. ],
  218. );
  219. return (
  220. <ActionContext.Provider value={value}>{children}</ActionContext.Provider>
  221. );
  222. }
  223. export function useActions(): ActionContextValue {
  224. const ctx = useContext(ActionContext);
  225. if (!ctx) {
  226. throw new Error("useActions must be used within an ActionProvider");
  227. }
  228. return ctx;
  229. }
  230. export function useAction(binding: ActionBinding): {
  231. execute: () => Promise<void>;
  232. isLoading: boolean;
  233. } {
  234. const { execute, loadingActions } = useActions();
  235. const isLoading = loadingActions.has(binding.action);
  236. const executeAction = useCallback(() => execute(binding), [execute, binding]);
  237. return { execute: executeAction, isLoading };
  238. }
  239. export interface ConfirmDialogProps {
  240. confirm: ActionConfirm;
  241. onConfirm: () => void;
  242. onCancel: () => void;
  243. }
  244. /**
  245. * No-op confirm dialog for PDF context. PDFs are non-interactive,
  246. * so confirmations are not rendered.
  247. */
  248. export function ConfirmDialog(_props: ConfirmDialogProps) {
  249. return null;
  250. }