lib/chore_runner_ui/chore_live.ex

defmodule ChoreRunnerUI.ChoreLive do
  @moduledoc """
  A LiveView used in conjunction with ChoreRunner's regular functionality.
  Features logging and data value display of all running chores locally and on all connected nodes.
  Allows the running of chores (with autogenerated forms based on chore inputs), as well as
  stopping running chores.

  ## Usage
  Make sure that `Chore.Supervisor` is added in your application
  ```
  children = [
    {Phoenix.PubSub, name: MyApp.PubSub},
    {ChoreRunner, pubsub: MyApp.PubSub}
  ]
  ```
  A pubsub MUST BE RUNNING and configured for both the Chore supervisor and the Chore LiveView for the Chore UI to function.

  Make the Chore LiveView accessible in your Phoenix web app, either in a template or in the router.
  ### Template
  `<%= live_render(@conn, ChoreRunnerUI.ChoreLive, session: %{"otp_app" => :my_app, "chore_root" => MyApp.Chores, "pubsub" => MyApp.PubSub}) %>`

  ### Router
  ```
  @chore_session %{
    "otp_app" => :my_app,
    "chore_root" => MyApp.Chores,
    "pubsub" => MyApp.PubSub
  }
  scope "/" do
    pipe_through :browser

    live_session :chores, session: @chore_session do
      live "/", ChoreRunnerUI.ChoreLive
    end
  end
  ```

  The `"chore_root"` key in the session should be the module root that all of your chore modules use.
  For example: if your root is `MyApp.Chores` your chore modules should be named like `MyApp.Chores.MyChore`

  Now you can visit the speficied url and start running chores!
  """
  use ChoreRunnerUI, :live
  alias ChoreRunnerUI.ChoreView
  require Logger

  def mount(params, session, socket) do
    subscribe_to_pubsub(session)
    chores = list_chores(session)

    {selected_chore_name, selected_chore} =
      case Map.to_list(chores) do
        [{selected_chore_name, selected_chore} | _] -> {selected_chore_name, selected_chore}
        _ -> {nil, nil}
      end

    socket =
      assign(socket,
        chores: chores,
        form_chores: Map.keys(chores),
        running_chores: ChoreRunner.list_running_chores(),
        params: params,
        session: session,
        inputs: [],
        file_inputs: [],
        currently_selected_chore: selected_chore,
        form_selected_chore: selected_chore_name,
        chore_errors: %{},
        is_chore_valid: true,
        selected_chore: nil
      )
      |> set_inputs(selected_chore && selected_chore.inputs())

    {:ok, socket}
  end

  def render(assigns) do
    ChoreView.render("index.html", assigns)
  end

  def handle_event(
        "form_changed",
        %{"run_chore" => %{"chore" => chore_name} = attrs},
        %{assigns: %{chores: chores, currently_selected_chore: currently_selected_chore}} = socket
      ) do
    selected_chore = chores[chore_name]

    if(currently_selected_chore == selected_chore) do
      chore_attrs = Map.get(attrs, "chore_attrs", %{})

      errors =
        case selected_chore.validate_input(chore_attrs) do
          {:ok, _} -> []
          {:error, errors} -> errors
        end

      {:noreply, assign_errors(socket, errors)}
    else
      socket =
        socket
        |> assign(
          currently_selected_chore: selected_chore,
          form_selected_chore: chore_name,
          chore_errors: %{},
          is_chore_valid: true
        )
        |> set_inputs(selected_chore.inputs())

      {:noreply, socket}
    end
  end

  def handle_event("run_chore", %{"run_chore" => %{"chore" => chore_name} = attrs}, socket) do
    file_attrs =
      Enum.map(socket.assigns.file_inputs, fn file_input ->
        uploaded_file =
          consume_uploaded_entries(socket, file_input, fn %{path: path}, _entry ->
            tmp_dir = System.tmp_dir!()
            File.mkdir(Path.join(tmp_dir, "chore_files"))
            dest = Path.join([System.tmp_dir!(), "chore_files", Path.basename(path)])
            File.cp!(path, dest)
            dest
          end)
          |> List.first()

        {file_input, uploaded_file}
      end)
      |> Enum.into(%{})

    chore = socket.assigns.chores[chore_name]
    chore_attrs = Map.get(attrs, "chore_attrs", %{}) |> Map.merge(file_attrs)

    errors =
      case ChoreRunner.run_chore(chore, chore_attrs) do
        {:ok, _} -> []
        {:error, errors} -> errors
      end

    {:noreply, assign_errors(socket, errors)}
  end

  def handle_event("stop_chore", _, %{assigns: %{running_chores: []}} = socket),
    do: {:noreply, socket}

  def handle_event("stop_chore", %{"id" => id}, %{assigns: %{running_chores: chores}} = socket) do
    chores
    |> Enum.find(&(&1.id == id))
    |> ChoreRunner.stop_chore()

    {:noreply, socket}
  end

  def handle_event("dismiss_chore", %{"id" => id}, socket) do
    {:noreply, remove_running_chore(socket, id)}
  end

  def handle_event("select_chore", %{"chore" => id}, socket) do
    selected_chore = Enum.find(socket.assigns.running_chores, &(&1.id == id))
    {:noreply, assign(socket, :selected_chore, selected_chore)}
  end

  def handle_event("deselect_chore", _, socket) do
    {:noreply, assign(socket, :selected_chore, nil)}
  end

  def handle_event(event, _attrs, socket) do
    Logger.debug("Unhandled event #{inspect(event)} in ChoreRunnerUI.ChoreLive")
    {:noreply, socket}
  end

  def handle_info({:chore_started, chore}, socket) do
    {:noreply,
     assign(
       socket,
       :running_chores,
       [chore | socket.assigns.running_chores]
     )}
  end

  def handle_info({:chore_update, chore}, socket) do
    socket =
      if socket.assigns.selected_chore && socket.assigns.selected_chore.id == chore.id do
        assign(
          socket,
          :selected_chore,
          %{
            socket.assigns.selected_chore
            | logs: chore.logs ++ socket.assigns.selected_chore.logs
          }
        )
      else
        socket
      end

    {:noreply,
     assign(
       socket,
       :running_chores,
       update_running_chore(socket.assigns.running_chores, chore)
     )}
  end

  def handle_info({final_message, chore}, socket)
      when final_message in [:chore_finished, :chore_failed] do
    {:noreply,
     assign(
       socket,
       :running_chores,
       update_running_chore(socket.assigns.running_chores, chore)
     )}
  end

  def handle_info(unhandled, socket) do
    Logger.debug("Unhandled message #{inspect(unhandled)} sent to ChoreRunnerUI.ChoreLive")
    {:noreply, socket}
  end

  defp subscribe_to_pubsub(%{"pubsub" => pubsub}) do
    Phoenix.PubSub.subscribe(pubsub, ChoreRunner.chore_pubsub_topic(:all))
  end

  defp subscribe_to_pubsub(_), do: :noop

  defp list_chores(%{"otp_app" => app, "chore_root" => root}) do
    split_root = Module.split(root) |> Enum.reverse()

    {:ok, modules} = :application.get_key(app, :modules)

    modules
    |> Enum.map(fn module ->
      module
      |> Module.split()
      |> Enum.reverse()
      |> case do
        [trimmed_module | ^split_root] ->
          {trimmed_module, module}

        _ ->
          nil
      end
    end)
    |> Enum.reject(&is_nil/1)
    |> Enum.into(%{})
  end

  defp list_chores(_), do: %{}

  defp update_running_chore(running_chores, %{id: id} = chore) do
    Enum.map(running_chores, fn
      %{id: ^id, logs: logs, finished_at: nil} ->
        %{chore | logs: chore.logs ++ logs, task: chore.task}

      chore ->
        chore
    end)
  end

  defp remove_running_chore(%{assigns: %{running_chores: running_chores}} = socket, id) do
    assign(socket, :running_chores, Enum.reject(running_chores, &(&1.id == id)))
  end

  defp set_inputs(socket, nil), do: socket

  defp set_inputs(socket, inputs) do
    socket
    |> disable_previous_file_inputs()
    |> assign(inputs: inputs)
    |> assign(
      :file_inputs,
      inputs |> Enum.filter(&(elem(&1, 0) == :file)) |> Enum.map(&elem(&1, 1))
    )
    |> enable_file_inputs()
  end

  defp disable_previous_file_inputs(%{assigns: %{inputs: nil}} = socket), do: socket

  defp disable_previous_file_inputs(%{assigns: %{inputs: inputs}} = socket) do
    inputs
    |> Enum.filter(fn {type, _key, _opts} -> type == :file end)
    |> Enum.reduce(socket, fn {:file, key, _opts}, socket ->
      disallow_upload(socket, key)
    end)
  end

  defp enable_file_inputs(%{assigns: %{inputs: nil}} = socket), do: socket

  defp enable_file_inputs(%{assigns: %{inputs: inputs}} = socket) do
    inputs
    |> Enum.filter(fn {type, _key, _opts} -> type == :file end)
    |> Enum.reduce(socket, fn {:file, key, _opts}, socket ->
      allow_upload(socket, key, accept: :any, max_entries: 1)
    end)
  end

  defp assign_errors(socket, []) do
    assign(socket, chore_errors: %{}, is_chore_valid: true)
  end

  defp assign_errors(socket, errors) do
    assign(socket, chore_errors: Enum.into(errors, %{}), is_chore_valid: false)
  end
end