How are you doing nested layouts with Phoenix 1.7?

Hi! I’m trying to convert a nested layout structure from pre-1.7 to the new version with a little trouble…

Let’s imagine you have the following layout structure:

layouts
|_ root.html.heex
|_ app.html.heex
|_ admin.html.heex

As per usual, app and admin is rendered inside root…

Now, imagine I have a second layout called settings that should be nested inside admin, for instance:

admin.html.heex
|-> settings.html.heex
  |-> profile.html.heex
  |-> options.html.heex

In this case, settings should be under the admin layout and profile + options are the pages that should be rendered inside settings. Previously we used render_layout extensively, but now I’m not exactly sure what is the best approach here.

An example of what worked previously:

settings.html.heex

<%= render_layout LayoutsView, "admin.html" do %>
   <%= @inner_content %>
<% end %>

profile.html.heex

<%= render_layout LayoutsView, "settings.html" do %>
   Hello From Profile!!!
<% end %>

Is the alternative to render_layout just calling components directly now? For instance:

ProfileController.ex

put_layout(conn, html: {MyAppWeb.Layouts, :settings})

Layouts.ex

def settings(assigns) do
   # Need to pass this so admin can render stuff like
   # <.flash_group flash={@flash} />
   # But this approach seems a bit off
   extra = assigns_to_attributes(assigns)
   assigns = assign(assigns, :extra, extra)

  ~H"""
   <.admin {@extra}>
    <%= @inner_content %>
   </.admin>
  """
end
2 Likes

You can replace render_layout with function components using slots.

2 Likes

Hi @LostKobrakai!

Did you see the last example of my post? If that’s not it, how would you do it with slots?

After thinking about it for a while I’m not sure I understand… The render_layout function just lets you “wrap” a given HTML with a layout of your choice. In this case, the layout doesn’t need to know which children will eventually be rendered inside of it. Slots, on the other hand, need to be pre-defined - unless of course, you defined a generic inner_block slot where you can pass everything.

What I did already kinda works, but feels a bit hacky.

The only functional difference between layouts and function components using inner block is that layouts implicitly pass assigns around, while for function components you need to explicitly pass in all the assigns the function component needs access to.

A function components cares just as much about what markup slots pass into it as layouts do – not at all.

Not sure why any of this would feel hacky. That’s exactly how function components are meant to be used.

1 Like

Perhaps “hacky” is not the best choice of words, it’s just adaptation (like the extra steps needed to convert assigns into function component attributes instead of passing it directly).

What do you mean by “layouts implicitly pass assigns around”?

So, just to make sure, would you say that my solution is a perfectly reasonable solution for this particular use-case?

# From the controller
# (Perhaps this is what what you meant that assigns 
# are "implicitly" passed to this layout here from the conn)
put_layout(conn, html: {MyAppWeb.Layouts, :settings})
def settings(assigns) do
   # Need to pass this so admin can render stuff like
   # <.flash_group flash={@flash} />
   # But this approach seems a bit off
   extra = assigns_to_attributes(assigns)
   assigns = assign(assigns, :extra, extra)

  ~H"""
   <.admin {@extra}>
    <%= @inner_content %>
   </.admin>
  """
end

I wouldn’t call it perfect - though reasonable. I’d explicitly pass all the assigns <.admin> actually needs instead of blindly dumping everything onto the component: <.admin flash={@flash} …>…</.admin>.

2 Likes

Cool! Thanks for clearing that out for me :023:

Yeah, this is what I do.
For base layouts, for example a dashboard layout I defined it in the layouts folder, so I use it instead of app.

CleanShot 2023-04-14 at 11.21.36@2x

But then those base layouts call any nested layouts that are simply function components with slots where I explicitly pass all the assigns.

Example:

3 Likes

Hi @greven! Out of curiosity, could you share how does your Components.Dashboard.layout function looks like? It seems that you are using a different approach than just rendering the component directly like <.dashboard /> for instance.

Sure. It looks like this:

@doc false

  attr :class, :any, default: "dashboard"
  attr :breadcrumb_items, :list, default: []
  attr :rest, :global

  slot :inner_block, required: true
  slot :primary_navigation
  slot :secondary_navigation
  slot :breadcrumbs

  def layout(assigns) do
    ~H"""
    <div id="dashboard" class={@class} phx-hook="dashboard" {@rest}>
      <header class="dashboard-header">
        <div class="dashboard-brand">
          <%= svg_image("eikko_logo", height: 40, width: 120) %>
        </div>

        <nav class="dashboard-navigation">
          <button
            id="navigation__toggle"
            class="hamburger-btn"
            aria-expanded="false"
            aria-controls="navigation__actions"
            data-expand="open nav"
            data-retract="close nav"
          >
            <%= "open nav" %>
          </button>

          <div id="navigation__actions" class="dashboard-navigation__actions">
            <a class="dashboard-skip-to-main link link--enhanced sr-only sr-only--focusable" href="#main">
              Skip to main content
            </a>

            <div class="dashboard-navigation-bar">
              <div class="dashboard-navigation-bar__primary">
                <%= render_slot(@primary_navigation) %>
              </div>
              <div class="dashboard-navigation-bar__secondary">
                <%= render_slot(@secondary_navigation) %>
              </div>
            </div>
          </div>
        </nav>
      </header>

      <main class="dashboard-main">
        <.breadcrumbs items={@breadcrumb_items} />

        <div id="main" tabindex="-1">
          <%= render_slot(@inner_block) %>
        </div>
      </main>
    </div>
    """
  end

Hi again! I’m unmarking the solution from @LostKobrakai in this post because I think I found a major roadblock with this approach… Here’s the problem:

This is the admin layout:

<nav>
  # Some page links over here
</nav>
<main>
  <.flash_group flash={@flash} />
  <%= @inner_content %>
</main>

There’s a pipeline that does this for each route inside /admin:

plug(:put_layout, html: {MyAppWeb.Layouts, :admin})

The “settings” controller specifies its layout for the edit action:

put_layout(conn, html: {MyAppWeb.Layouts, :settings})

And here’s the function that defines the settings layout:

slot :inner_block, required: true
def settings(assigns) do
  ~H"""
   <.admin flash={@flash}>
     <header>
       # Tabs with links to other pages
     </header>
    <%= render_slot(@inner_block) %>
   </.admin>
  """
end

Here’s the problem… Layouts expect a @inner_content assign while function components expect a @inner_block assign. What is happening is that the contents from this call: <.admin> [...] <.admin> is not passed as @inner_content to the <.admin> layout. I think I found a similar issue here: Feature Request / Discussion: Slots in Layouts · Issue #2586 · phoenixframework/phoenix_live_view · GitHub.

Are you aware of surface-ui? Perhaps you would enjoy using this?

https://surface-ui.org/