How do I bypass a controller action for intercooler.js?

tl;dr below that asks the question

intercooler.js lets one replace or append parts of a page with HTML fragments that come from the back end.

Right now, I create a whole new request that returns the full page and target the specific fragment that needs to be updated.

I’d rather return only the fragment that needs to be updated. I already have my templates set up in fragments.

For example, here is a rough sketch of what I have for an recurrence rule picker:

When a different freq is picked, we need to render a slightly different form #{frequency_selection}.html. (i.e. daily.html, weekly.html, monthly.html, or yearly.html)

<div class="form-group row" id="<%= input_id(@form, :rrule) %>">
  <%= label(@form, :rrule, "Interval", class: "col-md-1 col-form-label text-right") %>
  <%# a nasty little hack to add params to fields that don't exist in the changeset %>
  <% virtual_form = virtual_form(@form, :rrule) %>
  <%# we get the frequency_selection POSTed from the conn or "monthly" if nil %>
  <% frequency_selection = get_current_frequency_selection(@conn) || "monthly" %>
  <%= select(virtual_form,
      :freq,
      [{"Daily", "daily"},
      {"Weekly", "weekly"},
      {"Monthly", "monthly"},
      {"Yearly", "yearly"}],
      selected: frequency_selection,
      class: "col form-control",
      style: "max-width: 7em;",
      "ic-post-to": Phoenix.Controller.current_path(@conn),
      "ic-select-from-response": "#frequency_selection",
      "ic-target": "#frequency_selection") %>

  <div class="col form-group row" id="frequency_selection">
    <%= render("#{frequency_selection}.html", form: virtual_form, on_selection: "day") %>
  </div>
</div>

Right now, I just re-render this whole template. Really, I only need to render the #{frequency_selection}.html and leave everything else out.

When I change the frequency_selection, Intercooler will POST with params like this:

%{
  "_csrf_token" => "JywYcQgZajkEVQo5BwsoDipdKAMnJgAAUa36FSSuTbDZFjZyM0CkIQ==",
  "_method" => "POST",
  "_utf8" => "✓",
  "ic-current-url" => "/invoices/new",
  "ic-element-id" => "invoice_specification_rrule_freq",
  "ic-element-name" => "invoice_specification[rrule][freq]",
  "ic-id" => "1",
  "ic-request" => "true",
  "ic-select-from-response" => "#frequency_selection",
  "ic-target-id" => "frequency_selection",
  "ic-trigger-id" => "invoice_specification_rrule_freq",
  "ic-trigger-name" => "invoice_specification[rrule][freq]",
  "invoice_specification" => %{
    "account_id" => "",
    "rrule" => %{"freq" => "yearly"},
    "terms" => %{"late_fee" => "", "net" => ""}
  }
}

At first, I tried using something that @OvermindDL1 created with a slight modification:

defmodule Intercooler.Plug do
 import Plug.Conn
 import Phoenix.Controller
 require Logger

 def init(options), do: options

 def call(conn, _options) do
   case conn.params["ic-request"] do
     "true" ->
       Logger.debug(fn -> "start of intercooler request" end)

       view = view_module(conn)
       target = Intercooler.get_target_id(conn)

       conn
       |> put_layout({BillingWeb.LayoutView, "intercooler.html"})
       |> put_layout_formats(["html"])
       |> put_format("html")
       |> render(view, target, conn: conn)
     _ ->
       conn
   end
 end
end

I plug this in the :browser pipeline in my router. What I want is to be able to request the same URL (Phoenix.Controller.current_path(@conn)) so that the View module associated with that controller will be called. BUT I want to bypass the controller actions and just go straight to rendering from the View module.

In the View module, I would have something like this:

def render("frequency_selection", assigns) do
  assigns = do_something_with_assigns(assigns)
  selection = get_selection(assigns)
  render("#{selection}.html", assigns)
end

When plugged in the browser pipeline, I get something like:

** (exit) an exception was raised:
    ** (KeyError) key :phoenix_view not found in: %{BillingWeb.Router => {[], %{}}, :phoenix_endpoint => BillingWeb.Endpoint, :phoenix_flash => %{}, :phoenix_format => "html", :phoenix_pipelines => [:browser], :phoenix_router => BillingWeb.Router, :plug_session => %{"_csrf_token" => "rM+GNJ9LP7NcAarwgmkhnw=="}, :plug_session_fetch => :done}

Understandable - I assume the controller is what’s responsible for selecting the View module.

OK, let me try in the actual controller:

** (RuntimeError) cannot render template "frequency_selection" without format. Use an atom if the template format is meant to be set dynamically based on the request format

I think I’m getting the hang of this. I am using the wrong render function. Here’s the last attempt I’ve made:

In the controller, I override action/2:

def action(conn, params) do
  case conn.params["ic-request"] do
    "true" -> Intercooler.render(conn, params)
    _ -> apply(__MODULE__, action_name(conn), [conn, params])
  end
end

And have this render/2 function in the Intercooler module:

def render(conn, _params) do
  view_module = Phoenix.Controller.view_module(conn)
  target = get_target_id(conn)
  {:safe, data} = Phoenix.View.render_to_iodata(view_module, target, conn: conn)

  Phoenix.Controller.html(conn, data)
end

It works, but it feels like I’m fighting Phoenix to get this to happen.

tl;dr

Is there a better way to:

  • Make a request on the same path (no special intercooler controller or path, just the same path as where the original fragment was rendered)
  • Identify a request with %{"ic-request" => "true"}
  • Route it to the appropriate Controller
  • So that I can render the fragment from the View associated with that controller

?

Why not just call through the controller anyway?

It is reasons like this that I think a library built on Phoenix to do a component-based page rendering would be far better than whole-page controllers. ^.^

I want to avoid adding any additional logic because it will be the same for each controller. I just need a way to find out which View to use so that the requested widget can be rendered.

I was able to come up with a solution that works for me. I don’t know if it’s the right way to do it, but it does allow me to render each fragment on demand.

First, I had to define a custom controller macro, overriding action/2:

controller.ex

defmodule BillingWeb.Controller do
  @moduledoc false
  defmacro __using__(opts) do
    quote bind_quoted: [opts: opts] do
      import Phoenix.Controller

      import Plug.Conn

      use Phoenix.Controller.Pipeline, opts

      plug :put_new_layout, {Phoenix.Controller.__layout__(__MODULE__, opts), :app}
      plug :put_new_view, Phoenix.Controller.__view__(__MODULE__)

      def action(conn, _) do
        if Intercooler.is_ic_request(conn) do
          Intercooler.handle_request(conn)
        else
          apply(__MODULE__, action_name(conn), [conn, conn.params])
        end
      end
    end
  end
end

Then I modified the API entry point to use this new controller:

  def controller do
    quote do
      use BillingWeb.Controller, namespace: BillingWeb
      import Plug.Conn
      import BillingWeb.Router.Helpers
      import BillingWeb.Gettext
    end
  end

I think I might be able to implement it as a plug now so that I can still use action/2 in the controller.

If I end up only needing one controller, I’ll just return to the Phoenix defaults with this logic in the controller itself.

Can you point to another library that does this? I want to see how they implement it.

There are a couple decent ones in Java, Erlang has one in the form of nitrogen of a sort, Wt in C++, etc… If you make a component based page controller/view in Phoenix that’d be awesome though. :slight_smile:

I’d imagine it first as a collection step to state ‘what’ information needs to be acquire for all of them, then a step to get that information efficiently, then dispatching that information to the component dispatcher, which then renders their proper parts based on that information. :slight_smile: