home overview tags

Herman verschooten

These things I recently learned, that may be useful to myself and others in the future.

Moving away from Phoenix views

Today I decided to upgrade one of my projects away from Phoenix views.

These are the changes I made:

Update config/config.exs and add new modules

-  render_errors: [view: CraftsThingsWeb.ErrorView, accepts: ~w(html json), layout: false],
+  render_errors: [
+    formats: [html: CraftsThingsWeb.ErrorHTML, json: CraftsThingsWeb.ErrorJSON],
+    layout: false
+  ],

new file mode 100644
index 0000000..9ecd8d8
--- /dev/null
+++ b/lib/crafts_things_web/components/core_components.ex
@@ -0,0 +1,4 @@
+defmodule CraftsThingsWeb.CoreComponents do
+ use Phoenix.Component
+
+end

new file mode 100644
index 0000000..3ed2dcd
--- /dev/null
+++ b/lib/crafts_things_web/controllers/error_html.ex
@@ -0,0 +1,19 @@
+defmodule CraftsThingsWeb.ErrorHTML do
+  use CraftsThingsWeb, :html
+
+  # If you want to customize your error pages,
+  # uncomment the embed_templates/1 call below
+  # and add pages to the error directory:
+  #
+  #   * lib/phx_web/controllers/error_html/404.html.heex
+  #   * lib/phx_web/controllers/error_html/500.html.heex
+  #
+  # embed_templates "error_html/*"
+
+  # The default is to render a plain text page based on
+  # the template name. For example, "404.html" becomes
+  # "Not Found".
+  def render(template, _assigns) do
+    Phoenix.Controller.status_message_from_template(template)
+  end
+end

new file mode 100644
index 0000000..61cc4d2
--- /dev/null
+++ b/lib/crafts_things_web/controllers/error_json.ex
@@ -0,0 +1,15 @@
+defmodule CraftsThingsWeb.ErrorJSON do
+  # If you want to customize a particular status code,
+  # you may add your own clauses, such as:
+  #
+  # def render("500.json", _assigns) do
+  #   %{errors: %{detail: "Internal Server Error"}}
+  # end
+
+  # By default, Phoenix returns the status message from
+  # the template name. For example, "404.json" becomes
+  # "Not Found".
+  def render(template, _assigns) do
+    %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
+  end
+end

new file mode 100644
index 0000000..d528c2e
--- /dev/null
+++ b/test/crafts_things_web/controllers/error_html_test.exs
@@ -0,0 +1,14 @@
+defmodule CraftsThingsWeb.ErrorHTMLTest do
+  use CraftsThingsWeb.ConnCase, async: true
+
+  # Bring render_to_string/4 for testing custom views
+  import Phoenix.Template
+
+  test "renders 404.html" do
+    assert render_to_string(CraftsThingsWeb.ErrorHTML, "404", "html", []) == "Not Found"
+  end
+
+  test "renders 500.html" do
+    assert render_to_string(CraftsThingsWeb.ErrorHTML, "500", "html", []) == "Internal Server Error"
+  end
+end

new file mode 100644
index 0000000..25c6d9d
--- /dev/null
+++ b/test/crafts_things_web/controllers/error_json_test.exs
@@ -0,0 +1,12 @@
+defmodule CraftsThingsWeb.ErrorJSONTest do
+  use CraftsThingsWeb.ConnCase, async: true
+
+  test "renders 404" do
+    assert CraftsThingsWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}}
+  end
+
+  test "renders 500" do
+    assert CraftsThingsWeb.ErrorJSON.render("500.json", %{}) ==
+             %{errors: %{detail: "Internal Server Error"}}
+  end
+end

I opted to add an empty CoreComponents for now, instead of copying the proposed one from a newly generated project, so I can see what I need in there.
I then copied my layouts to a new layouts_html folder inside the components folder, making sure to replace Routes.static_path with the correct ~p-paths.
For now, I added a root.html.heex with just <%= @inner_content %>, because my other layouts are complete HTML-pages.
Next I added the line plug :put_root_layout, {CraftsThingsWeb.Layouts, :root} in my router.ex, just above the plug :put_secure_browser_headers.

Update config/dev.exs

-      ~r"lib/crafts_things_web/(live|views)/.*(ex)$",
+      ~r"lib/crafts_things_web/(controllers|live|components)/.*(ex)$",

Update lib/crafts_things/crafts_things_web.ex

   This can be used in your application as:

       use CraftsThingsWeb, :controller
-      use CraftsThingsWeb, :view
+      use CraftsThingsWeb, :html

-  The definitions below will be executed for every view,
-  controller, etc, so keep them short and clean, focused
+  The definitions below will be executed for every controller,
+  component, etc, so keep them short and clean, focused
   on imports, uses and aliases.

   Do NOT define functions inside the quoted expressions
-  below. Instead, define any helper function in modules
-  and import those modules here.
+  below. Instead, define additional modules and import
+  those modules here.
   """

+  def static_paths(), do: ~w(assets css fonts images js favicon.ico robots.txt)
+
   def controller do
+    quote do
+      use Phoenix.Controller,
+        formats: [:html, :json],
+        layouts: [html: CraftsThingsWeb.Layouts]
+
+      import Plug.Conn
+      import CraftsThingsWeb.Gettext
+
+      unquote(verified_routes())
+    end
+  end
+
+  def old_controller do
     quote do
       use Phoenix.Controller, namespace: CraftsThingsWeb
@@ -58,6 +60,45 @@ defmodule CraftsThingsWeb do
     end
   end

+  def html do
+    quote do
+      use Phoenix.Component
+
+      # Import convenience functions from controllers
+      import Phoenix.Controller,
+        only: [get_csrf_token: 0, view_module: 1, view_template: 1]
+
+      # Include general helpers for rendering HTML
+      unquote(html_helpers())
+    end
+  end
+
+  defp html_helpers do
+    quote do
+      # HTML escaping functionality
+      import Phoenix.HTML
+      # Core UI components and translation
+      import CraftsThingsWeb.CoreComponents
+      import CraftsThingsWeb.Gettext
+
+      # Shortcut for generating JS commands
+      alias Phoenix.LiveView.JS
+
+      # Routes generation with the ~p sigil
+      unquote(verified_routes())
+    end
+  end
+
+  def verified_routes do
+    quote do
+      use Phoenix.VerifiedRoutes,
+        endpoint: CraftsThingsWeb.Endpoint,
+        router: CraftsThingsWeb.Router,
+        statics: CraftsThingsWeb.static_paths()
+    end
+  end
+

I renamed the existing controller method to old_controller and changed it in all existing controllers.
This prepares the way for switching over to the new style.

Converting the first controller

Let's see how far we get, change the :old_controller do :controller and refresh the page.
This will ofcourse gives an error as we have not yet defined a _html module. So let's create that now.

defmodule CraftsThingsWeb.PageHTML do
  use CraftsThingsWeb,: html

  embed_templates "page_html/*.html"
end

and move over the html-files from the templates folder to a folder called page_html underneath controllers, in
the mean time renaming .html.eex to .html.heex to enjoy the new html-checking, ...

Hmm, that is no fun!

I have a category menu that is renderen in each file on the top of the page, using render/2 which no longer exists.
So we need to convert this _category.html.heex into a component, not that difficult. Create a category.ex underneath components.

def CraftsThingsWeb.Category do
  use Phoenix.Component

  def categories(assigns) do
  ~H"""
  past the html here
  """
  end

  copy any function needed in the above html from `page_view`.
end

Now change the render into <CraftsThingsWeb.Category.categories categories={@categories} conn={@conn} />

I need to pass in @conn, because I need to access the sessions, and I have not yet found how to do it without.

Next up, then 'link/2' function does not exist anymore, at least it is not availabel from Phoenix.Component, so replace them all with the new .link-component.
Then img_tag/2, I decided to replace them with regular HTML img tags.

While doing this I also needed to replace all references to Routes.page_path/2 to the new verified routes sigil_p syntax.

A form_for, what should I do with this one. Well I have no changeset and I am not using the f variable, so let's replace it with a regular HTML form,
and do not forget to add a hidden input with the csrf value.

Those were all the changes I needed to make.
That was not so bad.

I went and deleted the page_view module, but that was too soon, it is referenced from another page... so undo the delete, and time to commit.

Formatting

Oh before I forget, since we are using heex-files now, we need to make some changes to the .formatter.exs file.

-  import_deps: [:ecto, :phoenix],
-  inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"],
+  import_deps: [:ecto, :ecto_sql, :phoenix],
+  plugins: [Phoenix.LiveView.HTMLFormatter],
+  inputs: ["*.{heex,ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{heex,ex,exs}"],

I must say that I like the changes I made so far, I prefer the new location of the HTML-files,
and love getting away from the Phoenix.HTML.Tags functions and use the more HTML-like components, or just plain old HTML.

Mini-components?

Another thing I learned was that in pageHTML you cannot only use functions but also component-like functions.

  def prefix(assigns) do
    ~H"""
    <%= if @product.category.menu == "S" do %>
      <%= render_slot(@inner_block) %> <%= @product.category.name %>
    <% else %>
      <%= @product.category.name %>
    <% end %>
    """
  end

and then just use it like

<.prefix product={@product}>
    <span class="text-gray-500">stempels</span>
</.prefix>

Now let's continue with the other controllers and see what they have in store for us...