|
| 1 | +defmodule Beacon.Actions.Interpreter do |
| 2 | + @moduledoc false |
| 3 | + |
| 4 | + import Phoenix.Component, only: [assign: 3], warn: false |
| 5 | + import Phoenix.LiveView, only: [push_navigate: 2, push_patch: 2, redirect: 2, push_event: 3, put_flash: 3], warn: false |
| 6 | + |
| 7 | + alias Beacon.GraphQL.Client |
| 8 | + |
| 9 | + require Logger |
| 10 | + |
| 11 | + @max_steps 100 |
| 12 | + |
| 13 | + @doc """ |
| 14 | + Execute an action document against a LiveView socket. |
| 15 | +
|
| 16 | + Returns `{:noreply, socket}`. |
| 17 | + """ |
| 18 | + @spec execute(map(), map(), Phoenix.LiveView.Socket.t()) :: |
| 19 | + {:noreply, Phoenix.LiveView.Socket.t()} |
| 20 | + def execute(%{"version" => 1, "steps" => steps}, event_params, socket) when is_list(steps) do |
| 21 | + ctx = %{ |
| 22 | + socket: socket, |
| 23 | + event_params: event_params, |
| 24 | + state: %{}, |
| 25 | + step_count: 0, |
| 26 | + fetch_results: %{}, |
| 27 | + site: socket.assigns.beacon.site |
| 28 | + } |
| 29 | + |
| 30 | + ctx = execute_steps(steps, ctx) |
| 31 | + {:noreply, ctx.socket} |
| 32 | + end |
| 33 | + |
| 34 | + def execute(%{"steps" => steps}, event_params, socket) when is_list(steps) do |
| 35 | + # Accept documents without explicit version (default to v1) |
| 36 | + execute(%{"version" => 1, "steps" => steps}, event_params, socket) |
| 37 | + end |
| 38 | + |
| 39 | + def execute(invalid, _event_params, _socket) do |
| 40 | + raise Beacon.Web.ServerError, |
| 41 | + "invalid action document: expected %{\"version\" => 1, \"steps\" => [...]}, got: #{inspect(invalid)}" |
| 42 | + end |
| 43 | + |
| 44 | + defp execute_steps(steps, ctx) do |
| 45 | + Enum.reduce_while(steps, ctx, fn step, ctx -> |
| 46 | + if ctx.step_count >= @max_steps do |
| 47 | + Logger.error("[Beacon.Actions] Step limit (#{@max_steps}) exceeded, aborting") |
| 48 | + {:halt, ctx} |
| 49 | + else |
| 50 | + ctx = %{ctx | step_count: ctx.step_count + 1} |
| 51 | + {:cont, execute_step(step, ctx)} |
| 52 | + end |
| 53 | + end) |
| 54 | + end |
| 55 | + |
| 56 | + # -- Navigation -- |
| 57 | + |
| 58 | + defp execute_step(%{"action" => "navigate", "to" => to}, ctx) do |
| 59 | + %{ctx | socket: push_navigate(ctx.socket, to: resolve_value(to, ctx))} |
| 60 | + end |
| 61 | + |
| 62 | + defp execute_step(%{"action" => "patch", "to" => to}, ctx) do |
| 63 | + %{ctx | socket: push_patch(ctx.socket, to: resolve_value(to, ctx))} |
| 64 | + end |
| 65 | + |
| 66 | + defp execute_step(%{"action" => "redirect", "to" => to}, ctx) do |
| 67 | + %{ctx | socket: redirect(ctx.socket, to: resolve_value(to, ctx))} |
| 68 | + end |
| 69 | + |
| 70 | + defp execute_step(%{"action" => "open_url", "url" => url}, ctx) do |
| 71 | + %{ctx | socket: push_event(ctx.socket, "beacon:open_url", %{url: resolve_value(url, ctx)})} |
| 72 | + end |
| 73 | + |
| 74 | + defp execute_step(%{"action" => "scroll_to", "target" => target}, ctx) do |
| 75 | + %{ctx | socket: push_event(ctx.socket, "beacon:scroll_to", %{target: target})} |
| 76 | + end |
| 77 | + |
| 78 | + defp execute_step(%{"action" => "dismiss"}, ctx) do |
| 79 | + %{ctx | socket: push_event(ctx.socket, "beacon:dismiss", %{})} |
| 80 | + end |
| 81 | + |
| 82 | + # -- Data (GraphQL) -- |
| 83 | + |
| 84 | + defp execute_step(%{"action" => action, "endpoint" => endpoint} = step, ctx) |
| 85 | + when action in ["submit", "fetch"] do |
| 86 | + query = step["query"] || step["operation"] |
| 87 | + variables = resolve_variables(step["variables"] || %{}, ctx) |
| 88 | + result_key = step["result"] |
| 89 | + |
| 90 | + case Client.execute(ctx.site, endpoint, query, variables) do |
| 91 | + {:ok, data} -> |
| 92 | + ctx = if result_key, do: put_in(ctx, [:fetch_results, result_key], data), else: ctx |
| 93 | + execute_branch(step["on_success"], ctx) |
| 94 | + |
| 95 | + {:partial, data, _errors} -> |
| 96 | + ctx = if result_key, do: put_in(ctx, [:fetch_results, result_key], data), else: ctx |
| 97 | + execute_branch(step["on_success"], ctx) |
| 98 | + |
| 99 | + {:error, reason} -> |
| 100 | + Logger.warning("[Beacon.Actions] #{action} failed: #{inspect(reason)}") |
| 101 | + ctx = %{ctx | state: Map.put(ctx.state, "error", %{"message" => inspect(reason)})} |
| 102 | + execute_branch(step["on_error"], ctx) |
| 103 | + end |
| 104 | + end |
| 105 | + |
| 106 | + # -- State -- |
| 107 | + |
| 108 | + defp execute_step(%{"action" => "set_state", "key" => key, "value" => value}, ctx) do |
| 109 | + resolved = resolve_value(value, ctx) |
| 110 | + assign_key = String.to_atom(key) |
| 111 | + ctx = %{ctx | state: Map.put(ctx.state, key, resolved)} |
| 112 | + %{ctx | socket: assign(ctx.socket, assign_key, resolved)} |
| 113 | + end |
| 114 | + |
| 115 | + defp execute_step(%{"action" => "toggle_state", "key" => key}, ctx) do |
| 116 | + current = Map.get(ctx.state, key, false) |
| 117 | + new_val = !current |
| 118 | + assign_key = String.to_atom(key) |
| 119 | + ctx = %{ctx | state: Map.put(ctx.state, key, new_val)} |
| 120 | + %{ctx | socket: assign(ctx.socket, assign_key, new_val)} |
| 121 | + end |
| 122 | + |
| 123 | + # -- DOM -- |
| 124 | + |
| 125 | + defp execute_step(%{"action" => "show"} = step, ctx) do |
| 126 | + js_cmd = Phoenix.LiveView.JS.show(target_opts(step)) |
| 127 | + %{ctx | socket: push_event(ctx.socket, "beacon:js", %{ops: js_cmd.ops})} |
| 128 | + end |
| 129 | + |
| 130 | + defp execute_step(%{"action" => "hide"} = step, ctx) do |
| 131 | + js_cmd = Phoenix.LiveView.JS.hide(target_opts(step)) |
| 132 | + %{ctx | socket: push_event(ctx.socket, "beacon:js", %{ops: js_cmd.ops})} |
| 133 | + end |
| 134 | + |
| 135 | + defp execute_step(%{"action" => "toggle"} = step, ctx) do |
| 136 | + js_cmd = Phoenix.LiveView.JS.toggle(target_opts(step)) |
| 137 | + %{ctx | socket: push_event(ctx.socket, "beacon:js", %{ops: js_cmd.ops})} |
| 138 | + end |
| 139 | + |
| 140 | + defp execute_step(%{"action" => "add_class", "target" => target, "class" => class}, ctx) do |
| 141 | + js_cmd = Phoenix.LiveView.JS.add_class(class, to: target) |
| 142 | + %{ctx | socket: push_event(ctx.socket, "beacon:js", %{ops: js_cmd.ops})} |
| 143 | + end |
| 144 | + |
| 145 | + defp execute_step(%{"action" => "remove_class", "target" => target, "class" => class}, ctx) do |
| 146 | + js_cmd = Phoenix.LiveView.JS.remove_class(class, to: target) |
| 147 | + %{ctx | socket: push_event(ctx.socket, "beacon:js", %{ops: js_cmd.ops})} |
| 148 | + end |
| 149 | + |
| 150 | + defp execute_step(%{"action" => "toggle_class", "target" => target, "class" => class}, ctx) do |
| 151 | + js_cmd = Phoenix.LiveView.JS.toggle_class(class, to: target) |
| 152 | + %{ctx | socket: push_event(ctx.socket, "beacon:js", %{ops: js_cmd.ops})} |
| 153 | + end |
| 154 | + |
| 155 | + defp execute_step(%{"action" => "set_attribute", "target" => target, "attr" => attr, "value" => value}, ctx) do |
| 156 | + js_cmd = Phoenix.LiveView.JS.set_attribute({attr, resolve_value(value, ctx)}, to: target) |
| 157 | + %{ctx | socket: push_event(ctx.socket, "beacon:js", %{ops: js_cmd.ops})} |
| 158 | + end |
| 159 | + |
| 160 | + defp execute_step(%{"action" => "remove_attribute", "target" => target, "attr" => attr}, ctx) do |
| 161 | + js_cmd = Phoenix.LiveView.JS.remove_attribute(attr, to: target) |
| 162 | + %{ctx | socket: push_event(ctx.socket, "beacon:js", %{ops: js_cmd.ops})} |
| 163 | + end |
| 164 | + |
| 165 | + defp execute_step(%{"action" => "transition", "target" => target, "class" => class} = step, ctx) do |
| 166 | + time = step["time"] || 200 |
| 167 | + js_cmd = Phoenix.LiveView.JS.transition(class, to: target, time: time) |
| 168 | + %{ctx | socket: push_event(ctx.socket, "beacon:js", %{ops: js_cmd.ops})} |
| 169 | + end |
| 170 | + |
| 171 | + defp execute_step(%{"action" => "focus", "target" => target}, ctx) do |
| 172 | + js_cmd = Phoenix.LiveView.JS.focus(to: target) |
| 173 | + %{ctx | socket: push_event(ctx.socket, "beacon:js", %{ops: js_cmd.ops})} |
| 174 | + end |
| 175 | + |
| 176 | + # -- Feedback -- |
| 177 | + |
| 178 | + defp execute_step(%{"action" => "flash", "kind" => kind, "message" => message}, ctx) do |
| 179 | + %{ctx | socket: put_flash(ctx.socket, String.to_atom(kind), resolve_value(message, ctx))} |
| 180 | + end |
| 181 | + |
| 182 | + defp execute_step(%{"action" => "dispatch_event", "event" => event} = step, ctx) do |
| 183 | + detail = step["detail"] || %{} |
| 184 | + %{ctx | socket: push_event(ctx.socket, "beacon:dispatch", %{event: event, detail: detail})} |
| 185 | + end |
| 186 | + |
| 187 | + defp execute_step(%{"action" => "push_event", "event" => event} = step, ctx) do |
| 188 | + payload = resolve_variables(step["payload"] || %{}, ctx) |
| 189 | + %{ctx | socket: push_event(ctx.socket, event, payload)} |
| 190 | + end |
| 191 | + |
| 192 | + defp execute_step(%{"action" => "track", "event" => event} = step, ctx) do |
| 193 | + properties = step["properties"] || %{} |
| 194 | + %{ctx | socket: push_event(ctx.socket, "beacon:track", %{event: event, properties: properties})} |
| 195 | + end |
| 196 | + |
| 197 | + # -- Forms -- |
| 198 | + |
| 199 | + defp execute_step(%{"action" => "validate", "form" => _form} = step, ctx) do |
| 200 | + # Validation is primarily client-side. Push the rules to the client. |
| 201 | + rules = step["rules"] || %{} |
| 202 | + %{ctx | socket: push_event(ctx.socket, "beacon:validate", %{form: step["form"], rules: rules})} |
| 203 | + end |
| 204 | + |
| 205 | + # -- Control flow -- |
| 206 | + |
| 207 | + defp execute_step(%{"action" => "conditional", "test" => test} = step, ctx) do |
| 208 | + if evaluate_test(test, ctx) do |
| 209 | + execute_branch(step["then"], ctx) |
| 210 | + else |
| 211 | + execute_branch(step["else"], ctx) |
| 212 | + end |
| 213 | + end |
| 214 | + |
| 215 | + defp execute_step(%{"action" => "sequence", "steps" => steps}, ctx) do |
| 216 | + execute_steps(steps, ctx) |
| 217 | + end |
| 218 | + |
| 219 | + # -- Escape hatch -- |
| 220 | + |
| 221 | + defp execute_step(%{"action" => "custom", "handler" => handler_name} = step, ctx) do |
| 222 | + params = Map.merge(ctx.event_params, step["params"] || %{}) |
| 223 | + |
| 224 | + # Only dispatch to :elixir format handlers to prevent infinite recursion |
| 225 | + table = :beacon_runtime_poc |
| 226 | + key = {ctx.site, :site_handler, :event, handler_name} |
| 227 | + |
| 228 | + case :ets.lookup(table, key) do |
| 229 | + [{_, {:elixir, _} = tagged}] -> |
| 230 | + case Beacon.RuntimeRenderer.dispatch_tagged_handler(tagged, params, ctx.socket) do |
| 231 | + {:noreply, socket} -> %{ctx | socket: socket} |
| 232 | + _ -> ctx |
| 233 | + end |
| 234 | + |
| 235 | + [{_, {:actions, _}}] -> |
| 236 | + Logger.warning("[Beacon.Actions] Custom handler '#{handler_name}' is itself an actions handler — skipping to prevent recursion") |
| 237 | + ctx |
| 238 | + |
| 239 | + _ -> |
| 240 | + Logger.warning("[Beacon.Actions] Custom handler '#{handler_name}' not found") |
| 241 | + ctx |
| 242 | + end |
| 243 | + end |
| 244 | + |
| 245 | + # -- Unknown action (no-op with warning) -- |
| 246 | + |
| 247 | + defp execute_step(%{"action" => action}, ctx) do |
| 248 | + Logger.warning("[Beacon.Actions] Unknown action type: #{action}") |
| 249 | + ctx |
| 250 | + end |
| 251 | + |
| 252 | + # -- Helpers -- |
| 253 | + |
| 254 | + defp execute_branch(nil, ctx), do: ctx |
| 255 | + defp execute_branch(steps, ctx) when is_list(steps), do: execute_steps(steps, ctx) |
| 256 | + |
| 257 | + defp target_opts(%{"target" => target}), do: [to: target] |
| 258 | + defp target_opts(_), do: [] |
| 259 | + |
| 260 | + defp resolve_value(value, _ctx) when is_number(value) or is_boolean(value), do: value |
| 261 | + defp resolve_value(nil, _ctx), do: nil |
| 262 | + |
| 263 | + defp resolve_value(value, ctx) when is_binary(value) do |
| 264 | + cond do |
| 265 | + String.starts_with?(value, "$event_params.") -> |
| 266 | + path = String.trim_leading(value, "$event_params.") |> String.split(".") |
| 267 | + get_nested(ctx.event_params, path) |
| 268 | + |
| 269 | + String.starts_with?(value, "$state.") -> |
| 270 | + path = String.trim_leading(value, "$state.") |> String.split(".") |
| 271 | + get_nested(ctx.state, path) |
| 272 | + |
| 273 | + String.starts_with?(value, "$result.") -> |
| 274 | + path = String.trim_leading(value, "$result.") |> String.split(".") |
| 275 | + get_nested(ctx.fetch_results, path) |
| 276 | + |
| 277 | + String.starts_with?(value, "$error.") -> |
| 278 | + path = String.trim_leading(value, "$error.") |> String.split(".") |
| 279 | + get_nested(ctx.state, ["error" | path]) |
| 280 | + |
| 281 | + true -> |
| 282 | + value |
| 283 | + end |
| 284 | + end |
| 285 | + |
| 286 | + defp resolve_value(value, _ctx) when is_map(value), do: value |
| 287 | + defp resolve_value(value, _ctx) when is_list(value), do: value |
| 288 | + |
| 289 | + defp resolve_variables(variables, ctx) when is_map(variables) do |
| 290 | + Map.new(variables, fn |
| 291 | + {key, %{"source" => "event_param", "key" => param_key}} -> |
| 292 | + {key, get_nested(ctx.event_params, String.split(param_key, "."))} |
| 293 | + |
| 294 | + {key, %{"source" => "state", "key" => state_key}} -> |
| 295 | + {key, Map.get(ctx.state, state_key)} |
| 296 | + |
| 297 | + {key, %{"source" => "literal", "value" => value}} -> |
| 298 | + {key, value} |
| 299 | + |
| 300 | + {key, %{"source" => "result", "from" => from, "path" => path}} -> |
| 301 | + {key, get_nested(ctx.fetch_results, [from | String.split(path, ".")])} |
| 302 | + |
| 303 | + {key, value} -> |
| 304 | + {key, resolve_value(value, ctx)} |
| 305 | + end) |
| 306 | + end |
| 307 | + |
| 308 | + defp evaluate_test(%{"path" => path, "op" => op, "value" => expected}, ctx) do |
| 309 | + actual = resolve_value("$" <> path, ctx) |
| 310 | + |
| 311 | + case op do |
| 312 | + "eq" -> actual == expected |
| 313 | + "neq" -> actual != expected |
| 314 | + "gt" -> is_number(actual) and actual > expected |
| 315 | + "lt" -> is_number(actual) and actual < expected |
| 316 | + "gte" -> is_number(actual) and actual >= expected |
| 317 | + "lte" -> is_number(actual) and actual <= expected |
| 318 | + "contains" -> is_binary(actual) and String.contains?(actual, expected) |
| 319 | + "exists" -> actual != nil |
| 320 | + "not_exists" -> actual == nil |
| 321 | + _ -> false |
| 322 | + end |
| 323 | + end |
| 324 | + |
| 325 | + defp evaluate_test(%{"field" => field, "op" => op, "value" => expected}, ctx) do |
| 326 | + evaluate_test(%{"path" => field, "op" => op, "value" => expected}, ctx) |
| 327 | + end |
| 328 | + |
| 329 | + defp evaluate_test(_, _ctx), do: false |
| 330 | + |
| 331 | + defp get_nested(nil, _), do: nil |
| 332 | + defp get_nested(value, []), do: value |
| 333 | + defp get_nested(value, [key | rest]) when is_map(value) do |
| 334 | + get_nested(Map.get(value, key) || Map.get(value, String.to_atom(key)), rest) |
| 335 | + end |
| 336 | + defp get_nested(_, _), do: nil |
| 337 | +end |
0 commit comments