How to write put_layout plug in Phoenix 1.7?

Hello,

I have a plug which adds a layout to some controller functions.

With Phoenix 1.6, I wrote it like this and it worked fine.
plug :put_layout "account.html" when action in [:edit, :update]

Phoenix 1.7 introduced a new way of writing it, it should be written something like this:
plug :put_layout, html: {TestApp.LayoutView, :account}

but now I can’t use when clause.

Can anyone suggest a proper way of writing this plug?

plug :put_layout, [html: {TestApp.LayoutView, :account}] when ...

We will write some docs to make it clearer. Thank you!

6 Likes

You can still use it, but the keyword list won’t be the last parameter and therefore you need to provide square brackets.
plug :put_layout, [html: {TestApp.LayoutView, :account}] when action in […]

3 Likes

Thanks for replying to quickly…

Yes, this helps with errors I was getting but for some reason, the layout is not being applied.

plug :put_layout, [html: {TestApp.LayoutView, :account}] when action in [:edit, :update]
plug :put_layout, [html: {**TestAppWeb**.LayoutView, :account}] when action in [:edit, :update]
both doesn’t work. I also tried in another controller where I do something similar and the same happens there too.

layout file is called account.html.heex and it’s placed inside layout folder.

Is there maybe some other step that I have to do here?

What do you mean it does not work? It is not rendered? It shows an exception? Can you please provide more information?

Yes, it’s not being rendered.

1.6 code which works

plug :put_layout, "account.html" when action in [:edit, :update]

1.7 code which doesn’t work

plug :put_layout, [html: {TestAppWeb.LayoutView, :account}] when action in [:edit, :update]

There are no errors, but the layout is not being rendered. I also tried to create a new random layout file instead of account.html.heex and it’s also not being rendered.

Ok. Keep in mind that the old put_layout has higher priority than html: :account. So if anywhere else you set :put_layout, "something", that will win over the html one. Make sure you are consistently using the new keyword syntax and you should be good!

I replaced the code in all places where I’m using different layouts and it still doesn’t render them. Does it have anything to do with the fact that I’m using regular views and templates and not LiveView? And I just want to mention that the old code works and layouts are rendered correctly, it’s just the ElixirLS dialyzer showing me errors.

No, that shouldn’t matter. There is something else going on here. Try calling IO.inspect layout(conn, :html) in the controller action and see what it returns.

It returns false.

Here’s my code

plug :put_layout, [html: {TestAppWeb.LayoutView, :account}] when action in [:edit, :update]

def edit(conn, _params, _current_user) do
    IO.inspect layout(conn, :html)
    render(conn, "edit.html", page_title: "Settings")
end

When I added IO.inspect, function gets underlined and it shows a warning.

def edit(conn, _params, _current_user) do

when hovering it shows the following warning:

Function edit/3 has no local return

and

IO.inspect layout(conn, :html)

when hovering it shows the following warning:

The call 'Elixir.Phoenix.Controller':layout
         (_conn@1 :: any(),
          'html') breaks the contract 
          ('Elixir.Plug.Conn':t(), binary() | 'nil') ->
             {atom(), 'Elixir.String':t() | atom()} | 'false'

So something somewhere is setting put_layout(false) (or doing similar in a pipeline).

I hear what you’re saying but I don’t know where to look anymore… I was adding those layouts in several places, I replaced the code in all of them, I was adding them only using this plug and it happens for every template, the moment I update the controller, that layout is not being rendered anymore.

Here’s one of the controllers, most of the code has been generated by phx.gen.auth.

defmodule TestAppWeb.UserSettingsController do
  use TestAppWeb, :controller

  alias TestApp.Accounts
  alias TestAppWeb.UserAuth

  plug :assign_email_and_password_changesets
  plug :put_layout, [html: {TestAppWeb.LayoutView, :account}] when action in [:edit, :update]
  # plug :put_layout, "account.html" when action in [:edit, :update]

  def action(conn, _) do
    args = [conn, conn.params, conn.assigns.current_user]
    apply(__MODULE__, action_name(conn), args)
  end

  def edit(conn, _params, _current_user) do
    IO.inspect layout(conn, :html)
    render(conn, "edit.html", page_title: "Settings")
  end

  def update(conn, %{"action" => "update_email"} = params, current_user) do
    %{"current_password" => password, "user" => user_params} = params

    case Accounts.apply_user_email(current_user, password, user_params) do
      {:ok, applied_user} ->
        Accounts.deliver_update_email_instructions(
          applied_user,
          current_user.email,
          &Routes.user_settings_url(conn, :confirm_email, &1)
        )

        conn
        |> put_flash(
          :info,
          "A link to confirm your email change has been sent to the new address."
        )
        |> redirect(to: Routes.user_settings_path(conn, :edit))

      {:error, changeset} ->
        render(conn, "edit.html", page_title: "Settings", email_changeset: changeset)
    end
  end

  def update(conn, %{"action" => "update_password"} = params, _current_user) do
    %{"current_password" => password, "user" => user_params} = params
    user = conn.assigns.current_user

    case Accounts.update_user_password(user, password, user_params) do
      {:ok, user} ->
        conn
        |> put_flash(:info, "Password updated successfully.")
        |> put_session(:user_return_to, Routes.user_settings_path(conn, :edit))
        |> UserAuth.log_in_user(user)

      {:error, changeset} ->
        render(conn, "edit.html", password_changeset: changeset)
    end
  end

  def confirm_email(conn, %{"token" => token}, _current_user) do
    case Accounts.update_user_email(conn.assigns.current_user, token) do
      :ok ->
        conn
        |> put_flash(:info, "Email changed successfully.")
        |> redirect(to: Routes.user_settings_path(conn, :edit))

      :error ->
        conn
        |> put_flash(:error, "Email change link is invalid or it has expired.")
        |> redirect(to: Routes.user_settings_path(conn, :edit))
    end
  end

  defp assign_email_and_password_changesets(conn, _opts) do
    user = conn.assigns.current_user

    conn
    |> assign(:email_changeset, Accounts.change_user_email(user))
    |> assign(:password_changeset, Accounts.change_user_password(user))
  end
end

From my router

pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, {TestAppWeb.LayoutView, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
    plug :fetch_current_user
  end

Writing the edit function like this also doesn’t work

def edit(conn, _params, _current_user) do
    # IO.inspect layout(conn, :html)
    conn
    |> put_layout(html: {TestAppWeb.LayoutView, :account})
    |> render("edit.html", page_title: "Settings")
  end

What about your my_app_web.ex file? Maybe something in there?

I don’t think I ever even touched that file except when upgrading to 1.7

defmodule TestAppWeb do
  @moduledoc """
  The entrypoint for defining your web interface, such
  as controllers, views, channels and so on.

  This can be used in your application as:

      use TestAppWeb, :controller
      use TestAppWeb, :view

  The definitions below will be executed for every view,
  controller, 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.
  """

  def static_paths, do: ~w(assets fonts images favicon robots.txt)

  def controller do
    quote do
      use Phoenix.Controller, namespace: TestAppWeb

      import Plug.Conn
      import TestAppWeb.Gettext
      alias TestAppWeb.Router.Helpers, as: Routes

      unquote(verified_routes())
    end
  end

  def view do
    quote do
      use Phoenix.View,
        root: "lib/TestApp_web/templates",
        namespace: TestAppWeb

      # Import convenience functions from controllers
      import Phoenix.Controller,
        only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1]

      # Include shared imports and aliases for views
      unquote(view_helpers())
    end
  end

  def verified_routes do
   quote do
      use Phoenix.VerifiedRoutes,
        endpoint: TestAppWeb.Endpoint,
        router: TestAppWeb.Router,
        statics: TestAppWeb.static_paths()
    end
  end

  def live_view do
    quote do
      use Phoenix.LiveView,
        layout: {TestAppWeb.LayoutView, "live.html"}

      unquote(view_helpers())
    end
  end

  def live_component do
    quote do
      use Phoenix.LiveComponent

      unquote(view_helpers())
    end
  end

  def component do
    quote do
      use Phoenix.Component

      unquote(view_helpers())
    end
  end

  def router do
    quote do
      use Phoenix.Router

      import Plug.Conn
      import Phoenix.Controller
      import Phoenix.LiveView.Router
    end
  end

  def channel do
    quote do
      use Phoenix.Channel
      import TestAppWeb.Gettext
    end
  end

  defp view_helpers do
    quote do
      # Use all HTML functionality (forms, tags, etc)
      use Phoenix.HTML

      # Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc)
      import Phoenix.Component

      # Import basic rendering functionality (render, render_layout, etc)
      import Phoenix.View

      import TestAppWeb.ErrorHelpers
      import TestAppWeb.Gettext
      alias TestAppWeb.Router.Helpers, as: Routes

      unquote(verified_routes())
    end
  end

  @doc """
  When used, dispatch to the appropriate controller/view/etc.
  """
  defmacro __using__(which) when is_atom(which) do
    apply(__MODULE__, which, [])
  end
end

Could it have to do with the shift from use MyAppWeb, :view to use MyAppWeb, :html and the new function embed_templates?

# in v1.6
defmodule YourAppWeb do
  # ...

  def view do
    quote do
      use Phoenix.View, root: "lib/your_app_web/templates", namespace: YourAppWeb
      ...
    end
  end
  ...
end

defmodule YourAppWeb.UserView do
  use YourAppWeb, :view
end

In Phoenix.LiveView, Phoenix.View was replaced by Phoenix.Component. With Phoenix v1.7+ we can also use Phoenix.Component to render traditional templates as functional components, using the embed_templates function.

For example, in Phoenix v1.7+, the YourAppWeb.UserView above would be written as:

defmodule YourAppWeb.UserHTML do
  use YourAppWeb, :html

  embed_templates "users/*"
end


Feature: To embed templates from disk
Phoenix v1.6: use Phoenix.View
Phoenix v1.7: use Phoenix.Component (+ embed_templates)

source: Phoenix.View docs

Thanks for checking it out… I checked the docs and updated the code but as I changed it, something else broke and when I updated that piece of code again something new broke so eventually I gave up. The thing is that I don’t use LiveView but regular views, my plan is to move to LiveView eventually but that requires some time so for now I went back to the old code. Phoenix 1.7 is backward compatible so ElixirLS is giving me errors but that it, the code works just fine.

We found a potential cause for this. If you are using plug :put_layout, html: ..., you need to makes sure that you declare the default layout and its format in your use Phoenix.Controller, layouts: [html: {MyDefaultLayout, :app}].

We have introduced a warning message on a soon to be released new patch version of Phoenix.

Here is the issue: Phoenix 1.7 Controller.put_layout @spec does not allow 1.6 usage · Issue #5320 · phoenixframework/phoenix · GitHub

Here is the commit: Add warning on mixed layout usage, closes #5320 · phoenixframework/phoenix@b2f110f · GitHub

3 Likes

I just tried it and it works. Thanks a lot for taking the time to solve it and getting back to me.

Isn’t this a backward incompatible change? Should we have these between minor versions?

How is adding a warning backward incompatible?

1 Like