actions.ts 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  1. import { z } from "zod";
  2. import type { DynamicValue, StateModel } from "./types";
  3. import { DynamicValueSchema, resolveDynamicValue } from "./types";
  4. /**
  5. * Confirmation dialog configuration
  6. */
  7. export interface ActionConfirm {
  8. title: string;
  9. message: string;
  10. confirmLabel?: string;
  11. cancelLabel?: string;
  12. variant?: "default" | "danger";
  13. }
  14. /**
  15. * Action success handler
  16. */
  17. export type ActionOnSuccess =
  18. | { navigate: string }
  19. | { set: Record<string, unknown> }
  20. | { action: string };
  21. /**
  22. * Action error handler
  23. */
  24. export type ActionOnError =
  25. | { set: Record<string, unknown> }
  26. | { action: string };
  27. /**
  28. * Action binding — maps an event to an action invocation.
  29. *
  30. * Used inside the `on` field of a UIElement:
  31. * ```json
  32. * { "on": { "press": { "action": "setState", "params": { "statePath": "/x", "value": 1 } } } }
  33. * ```
  34. */
  35. export interface ActionBinding {
  36. /** Action name (must be in catalog) */
  37. action: string;
  38. /** Parameters to pass to the action handler */
  39. params?: Record<string, DynamicValue>;
  40. /** Confirmation dialog before execution */
  41. confirm?: ActionConfirm;
  42. /** Handler after successful execution */
  43. onSuccess?: ActionOnSuccess;
  44. /** Handler after failed execution */
  45. onError?: ActionOnError;
  46. /** Whether to prevent default browser behavior (e.g. navigation on links) */
  47. preventDefault?: boolean;
  48. }
  49. /**
  50. * @deprecated Use ActionBinding instead
  51. */
  52. export type Action = ActionBinding;
  53. /**
  54. * Schema for action confirmation
  55. */
  56. export const ActionConfirmSchema = z.object({
  57. title: z.string(),
  58. message: z.string(),
  59. confirmLabel: z.string().optional(),
  60. cancelLabel: z.string().optional(),
  61. variant: z.enum(["default", "danger"]).optional(),
  62. });
  63. /**
  64. * Schema for success handlers
  65. */
  66. export const ActionOnSuccessSchema = z.union([
  67. z.object({ navigate: z.string() }),
  68. z.object({ set: z.record(z.string(), z.unknown()) }),
  69. z.object({ action: z.string() }),
  70. ]);
  71. /**
  72. * Schema for error handlers
  73. */
  74. export const ActionOnErrorSchema = z.union([
  75. z.object({ set: z.record(z.string(), z.unknown()) }),
  76. z.object({ action: z.string() }),
  77. ]);
  78. /**
  79. * Full action binding schema
  80. */
  81. export const ActionBindingSchema = z.object({
  82. action: z.string(),
  83. params: z.record(z.string(), DynamicValueSchema).optional(),
  84. confirm: ActionConfirmSchema.optional(),
  85. onSuccess: ActionOnSuccessSchema.optional(),
  86. onError: ActionOnErrorSchema.optional(),
  87. preventDefault: z.boolean().optional(),
  88. });
  89. /**
  90. * @deprecated Use ActionBindingSchema instead
  91. */
  92. export const ActionSchema = ActionBindingSchema;
  93. /**
  94. * Action handler function signature
  95. */
  96. export type ActionHandler<
  97. TParams = Record<string, unknown>,
  98. TResult = unknown,
  99. > = (params: TParams) => Promise<TResult> | TResult;
  100. /**
  101. * Action definition in catalog
  102. */
  103. export interface ActionDefinition<TParams = Record<string, unknown>> {
  104. /** Zod schema for params validation */
  105. params?: z.ZodType<TParams>;
  106. /** Description for AI */
  107. description?: string;
  108. }
  109. /**
  110. * Resolved action with all dynamic values resolved
  111. */
  112. export interface ResolvedAction {
  113. action: string;
  114. params: Record<string, unknown>;
  115. confirm?: ActionConfirm;
  116. onSuccess?: ActionOnSuccess;
  117. onError?: ActionOnError;
  118. }
  119. /**
  120. * Resolve all dynamic values in an action binding
  121. */
  122. export function resolveAction(
  123. binding: ActionBinding,
  124. stateModel: StateModel,
  125. ): ResolvedAction {
  126. const resolvedParams: Record<string, unknown> = {};
  127. if (binding.params) {
  128. for (const [key, value] of Object.entries(binding.params)) {
  129. resolvedParams[key] = resolveDynamicValue(value, stateModel);
  130. }
  131. }
  132. // Interpolate confirmation message if present
  133. let confirm = binding.confirm;
  134. if (confirm) {
  135. confirm = {
  136. ...confirm,
  137. message: interpolateString(confirm.message, stateModel),
  138. title: interpolateString(confirm.title, stateModel),
  139. };
  140. }
  141. return {
  142. action: binding.action,
  143. params: resolvedParams,
  144. confirm,
  145. onSuccess: binding.onSuccess,
  146. onError: binding.onError,
  147. };
  148. }
  149. /**
  150. * Interpolate ${path} expressions in a string
  151. */
  152. export function interpolateString(
  153. template: string,
  154. stateModel: StateModel,
  155. ): string {
  156. return template.replace(/\$\{([^}]+)\}/g, (_, path) => {
  157. const value = resolveDynamicValue({ $state: path }, stateModel);
  158. return String(value ?? "");
  159. });
  160. }
  161. /**
  162. * Context for action execution
  163. */
  164. export interface ActionExecutionContext {
  165. /** The resolved action */
  166. action: ResolvedAction;
  167. /** The action handler from the host */
  168. handler: ActionHandler;
  169. /** Function to update state model */
  170. setState: (path: string, value: unknown) => void;
  171. /** Function to navigate */
  172. navigate?: (path: string) => void;
  173. /** Function to execute another action */
  174. executeAction?: (name: string) => Promise<void>;
  175. }
  176. /**
  177. * Execute an action with all callbacks
  178. */
  179. export async function executeAction(
  180. ctx: ActionExecutionContext,
  181. ): Promise<void> {
  182. const { action, handler, setState, navigate, executeAction } = ctx;
  183. try {
  184. await handler(action.params);
  185. // Handle success
  186. if (action.onSuccess) {
  187. if ("navigate" in action.onSuccess && navigate) {
  188. navigate(action.onSuccess.navigate);
  189. } else if ("set" in action.onSuccess) {
  190. for (const [path, value] of Object.entries(action.onSuccess.set)) {
  191. setState(path, value);
  192. }
  193. } else if ("action" in action.onSuccess && executeAction) {
  194. await executeAction(action.onSuccess.action);
  195. }
  196. }
  197. } catch (error) {
  198. // Handle error
  199. if (action.onError) {
  200. if ("set" in action.onError) {
  201. for (const [path, value] of Object.entries(action.onError.set)) {
  202. // Replace $error.message with actual error
  203. const resolvedValue =
  204. typeof value === "string" && value === "$error.message"
  205. ? (error as Error).message
  206. : value;
  207. setState(path, resolvedValue);
  208. }
  209. } else if ("action" in action.onError && executeAction) {
  210. await executeAction(action.onError.action);
  211. }
  212. } else {
  213. throw error;
  214. }
  215. }
  216. }
  217. /**
  218. * Helper to create action bindings
  219. */
  220. export const actionBinding = {
  221. /** Create a simple action binding */
  222. simple: (
  223. actionName: string,
  224. params?: Record<string, DynamicValue>,
  225. ): ActionBinding => ({
  226. action: actionName,
  227. params,
  228. }),
  229. /** Create an action binding with confirmation */
  230. withConfirm: (
  231. actionName: string,
  232. confirm: ActionConfirm,
  233. params?: Record<string, DynamicValue>,
  234. ): ActionBinding => ({
  235. action: actionName,
  236. params,
  237. confirm,
  238. }),
  239. /** Create an action binding with success handler */
  240. withSuccess: (
  241. actionName: string,
  242. onSuccess: ActionOnSuccess,
  243. params?: Record<string, DynamicValue>,
  244. ): ActionBinding => ({
  245. action: actionName,
  246. params,
  247. onSuccess,
  248. }),
  249. };
  250. /**
  251. * @deprecated Use actionBinding instead
  252. */
  253. export const action = actionBinding;