Phx 1.7 component layouts: pattern for non-trivial site layouts with slots?

I’m migrating a small/medium sized application to use the new phx 1.7 view-less component paradigm and I’ve run into a few issues.

Our layout is non-trivial in that each page can potentially have a sub-navigation menu, header and search bar.

We previously accomplished this by allowing each controller action to pass a specific view as part of assigns, then the templates would check if that view was set and render it. Something like this:

in the controller:

render(conn, "show.html",
       class: class,
       content_header_view: {AppWeb.ClassView, "_header.html"}
     )

And in our “admin” layout:

<%= if content_header_view = assigns[:content_header_view] do %>
         <% {module, template} = content_header_view %>
         <section>
           <%= render(module, template, assigns) %>
         </section>

This worked well-enough and allowed for the flexibility we desired at a per-action level.

With the move to components, I thought this would be a great use case for slots but I’m now a bit baffled at what the “right” way to do this is.

Here’s what I tried first with the move to phx 1.7:

I moved the old “admin.html.heex” layout to /components/layouts/ and in my layouts file called embed_templates. Then I created a bodyless function as described by the docs in order to define my slots, like so:

defmodule AppWeb.Layouts do
  @moduledoc """
  The module for handling layouts
  """
  use AppWeb, :html

  embed_templates "layouts/*"

  slot :search_form_content,
    doc: "A special slot for a search form that lives at the top of the page",
    required: false

  slot :header_content,
    doc: "A slot for content that is fixed at the top of the page",
    required: false

  slot :secondary_nav_content, doc: "A slot for a secondary nav", required: false

  def admin(assigns)
end

I set the controller to use the admin layout and then, in my index.html.heex file, I added this at the top:

<:secondary_nav_content>
  Test Nav Content!
</:secondary_nav_content>
Existing content here was here

When I refreshed the page, I got this error: invalid slot entry <:secondary_nav_content>. A slot entry must be a direct child of a component

… which, after the fact, made sense to me since I didn’t wrap that slot in a component. But my naive expectation was that I could call into the admin layout’s slot from within the template that’s being rendered.

I then tried:

<.admin>
<:secondary_nav_content>
  Test Nav Content!
</:secondary_nav_content>
Existing content here was here
<.admin/>

But that gave me an error about not being able to find the admin component. And even if it had worked, I believe it would have rendered the admin layout twice, once inside the other since we’re using put_layout to set the admin layout.

Here’s where I ended up:

I ended up making a layout file for each major “resource” in my site and then I called into the admin component directly in that file.

E.g.: /components/layouts/student.html.heex now has this content in it:

<.admin {assigns}>
  <:search_form_content>
    <AppWeb.StudentHTML.search_form {assigns} />
  </:search_form_content>
  <:header_content :if={assigns[:student]}>
    <AppWeb.StudentHTML.header {assigns} />
  </:header_content>
  <:secondary_nav_content :if={assigns[:student]}>
    <AppWeb.StudentHTML.subnav {assigns} />
  </:secondary_nav_content>
</.admin>

Which is slightly different from another section like /components/layouts/class.html.heex which has only one slot filled out:

<.admin {assigns}>
  <:header_content :if={assigns[:class]}>
    <AppWeb.ClassHTML.header {assigns} />
  </:header_content>
</.admin>

And the controllers now specifically put_layout on each action:

  def show(conn, %{"id" => id}) do
    course = LegacyData.get_course_with_requisites!(id)

    conn
    |> put_layout({AppWeb.Layouts, :course})
    |> render(:show,
      page_title: course.course_name,
      course: course
    )
  end

In a sense, this is what I hoped for but for some reason it feels a bit icky and I’m not sure why.

My questions are: is this sane? Is it the intended approach? Is there a better way to do this?

Can anyone point to another project that handles non-trivial layouts like this?

I suppose my issue with this approach is: It’s odd that you can define slots on the admin view that cannot be used unless you compose a new view on top of it. This solution “works” in that I get the ability to define slots on a case by case basis but it requires that I define a new layout that uses the admin layout as a component internally.

This is not a pattern I’ve seen elsewhere so I’m curious what others think.

2 Likes

Your <.admin> component - is it the same component in each of these places, and you’re just instantiating it differently in each of your contexts (for each of your resources), or are you having to create a variant of the <.admin> component for each context?

I’d say yes. Unless there are numerous (as in more than 10/20…) variations I think it makes sense to statically define the different variants in use (vs. the likely many more possible with the <.admin> component). Previously you did so in the controller, which was both ad hoc and non strictly defined. Also your controller essentially “defined” the layout, which is traditionally a view layer responsibility.

2 Likes

The admin component is the same in all contexts. It just has specific slots that can be filled, or not and the layout reacts accordingly.

Yeah, makes sense. Thanks!