Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature Request / Discussion: Slots in Layouts #2586

Closed
SteffenDE opened this issue Apr 12, 2023 · 11 comments
Closed

Feature Request / Discussion: Slots in Layouts #2586

SteffenDE opened this issue Apr 12, 2023 · 11 comments

Comments

@SteffenDE
Copy link
Collaborator

Hello there,
I want to discuss a thought that I recently had about the use of function components in layouts. As the Phoenix documentation states (https://hexdocs.pm/phoenix/components.html#layouts):

Layouts are just function components.

Currently, there are some inconsistencies and one major feature missing (in my opinion) that I think could really have some nice benefits.

Imagine that you have a layout defined that includes a heading section with some content that is conditionally rendered on the right side, like the buttons in this image:
image
These buttons could be defined in the layout (e.g. app.html.heex) like this:

<div class="...">
  <div class="...">
    <h2 class="..."><%= @page_title %></h2>
  </div>
  <div class="...">
    <button :for={button in @action_buttons} type="button" class="..." phx-click={button.action}><%= button.title %></button>
  </div>
</div>
<%= @inner_content %>

In the LiveView we then define them as an assign:

def mount(_params, _session, socket) do
  socket
  |> assign(:action_buttons, [%{title: "Edit", action: JS.patch(...)}])
  |> then(&{:noreply, &1})
end

Now, we want to modify the section right to the title to allow arbitrary content, so we adapt the code to use something like this, basically writing our own very crude slot implementation:

<div class="...">
  <div class="...">
    <h2 class="..."><%= @page_title %></h2>
  </div>
  <div class="...">
    <button :for={button in @action_buttons} type="button" class="..." phx-click={button.action}><%= button.title %></button>
    <%= if is_function(assigns[:action_slot], 1) do %>
      <%= assigns[:action_slot].(assigns) %>
    <% end %>
  </div>
</div

And in our LiveView we could do the follwing:

def mount(_params, _session, socket) do
  socket
  |> assign(:action_slot, &render_action/1)
  |> then(&{:noreply, &1})
end

defp render_action(assigns) do
  ~H"""
  <div class="...">
    This is some custom content
  </div>
  """
end

I've come across this use case of having something like "slots" in the layout more than once now and as layouts are mostly function components now, I imagined being able to do something like this instead:

defmodule MyApp.Layouts do
  use Phoenix.Component

  attr :page_title, :string, required: true

  slot :action, required: false
  slot :inner_block, required: true

  def app(assigns) do
    ~H"""
    <div class="...">
      <div class="...">
        <h2 class="..."><%= @page_title %></h2>
      </div>
      <div class="...">
        <%= render_slot(@action) %>
      </div>
    </div>

    <%= render_slot(@inner_block) %>
    """
  end
end

This is currently not a valid layout component, because layouts use @inner_content instead of the @inner_block slot, but defining slots is actually working, just without any way to use them. Wouldn't it make sense to move to the inner_block slot for layouts too?

defmodule MyApp.SomeLive do
  use MyApp, :live_view

  def render(assigns) do
    ~H"""
    <:action>This sould be rendered in the action slot of the layout</:action>

    The default content goes here.
    """
  end
end

Currently, code like this leads to the following error:

** (Phoenix.LiveView.Tokenizer.ParseError) lib/my_app_web/live/test_item_live/index.html.heex:1:1: invalid slot entry <:action>. A slot entry must be a direct child of a component
  |
1 | <:action>Foo</:action>
  | ^
    (phoenix_live_view 0.18.18) lib/phoenix_live_view/tag_engine.ex:1383: Phoenix.LiveView.TagEngine.raise_syntax_error!/3
    (phoenix_live_view 0.18.18) lib/phoenix_live_view/tag_engine.ex:532: Phoenix.LiveView.TagEngine.handle_token/2
    (elixir 1.14.4) lib/enum.ex:2468: Enum."-reduce/3-lists^foldl/2-0-"/3
    (phoenix_live_view 0.18.18) lib/phoenix_live_view/tag_engine.ex:182: Phoenix.LiveView.TagEngine.handle_body/1
    (phoenix_live_view 0.18.18) expanding macro: Phoenix.LiveView.HTMLEngine.compile/1
    /Users/steffen.deusch/my_app/lib/my_app_web/live/test_item_live/index.ex:1: (file)
    (phoenix_live_view 0.18.18) /Users/steffen.deusch/my_app/lib/my_app_web/live/test_item_live/index.ex:1: Phoenix.LiveView.Renderer.__before_compile__/1

Of course there always is the option of moving code from the layout into a component and then using this component in every LiveView, e.g.

def render(assigns) do
  ~H"""
  <.my_header page_title={@page_title}>
    <:action>
      ...
    <:action>
  </.my_header>

  ... Rest of the content ...
  """
end

but I really think that if every page ends up including something like this it's better placed in the layout instead.

So I wanted to ask here: what do you think about this? Are there any major drawbacks that I am missing? Do you have any other nice solutions for "slots in layouts"?

@josevalim
Copy link
Member

I think that ends up being too magical IMO. What I typically do is that I wrap my templates in an additional component if necessary and keep the layout simpler.

@SteffenDE
Copy link
Collaborator Author

What I do not like with that solution is the need to pass all the assigns to the layout component, e.g. if the layout uses the page_title assign and a couple more (for example set in an on_mount hook) one would have something like:

def render(assigns) do
  ~H"""
  <.my_layout page_title={@page_title} user={@current_user} ...>
    <:my_slot>...</.my_slot>
    Other content
  </.my_layout>
  """
end

as using <.my_layout {assigns}> leads to unwanted data over the wire due to the missing change tracking.

@thiagomajesk
Copy link
Contributor

thiagomajesk commented Apr 15, 2023

This is currently not a valid layout component, because layouts use @inner_content instead of the @inner_block slot

Hi @SteffenDE! This is a major drawback for one particular use case I'm having as well. You can read more about this on the forum: https://elixirforum.com/t/how-are-you-doing-nested-layouts-with-phoenix-1-7/55236/11. But the gist of the problem is what you already mentioned; the fact that layouts are not actually behaving as standard functional components.

@josevalim I think this definitely should be looked into. If layouts are meant to be called just like function components, @inner_content should at least be replaced with @inner_block in layouts so we can easily reuse layouts as components.

@josevalim
Copy link
Member

josevalim commented Apr 15, 2023

We don't want layouts to become full-blow function components because layouts are rendered before LiveView. For example, the root layouts comes into play before anything else is rendered, regardless if you have LiveView or not. The solution to your problems, both here and the forum, is to keep the layout minimal and call shared function components in your templates/layouts.

AFAIK, this is also how it is done in the JS community and also in Django. So, at the moment, we don't plan to add more magic to layouts.

@thiagomajesk
Copy link
Contributor

thiagomajesk commented Apr 15, 2023

Hi @josevalim! Thanks for the explanation...

The solution to your problems, both here and the forum, is to keep the layout minimal and call a function component in your templates.

I just one to ask for a favor then... Could you please give an example of how to achieve this? If possible even, reply on the forums so we have a documented solution. I'm asking because this sentence sounds rather abstract to me (I thought I was already doing what you are saying).


Since I can basically just call a layout like a function component, it seems a little bit counter-intuitive that what gets passed to it it's not a @inner_block like you usually expect it to be. I'm not quite sure what is the solution for this case.

Another problem is that I was under the impression that this explanation was true:

The only functional difference between layouts and function components using inner block is that layouts implicitly pass assigns around

So, imagine my surprise that while calling a layout as a component, I get an error that says that @inner_content is missing from the assigns. Another thing is that the Phoenix 1.7 release says this:

All HTML rendering is then based on function components, which can be written directly in a module, or embedded from an external file with the new embed_templates macro provided by Phoenix.Component.

I might be misreading this, but it sounds like (when you first read it) we should expect some parity for all HTML rendering inside the framework because it is all based on function components.

PS.: Sorry if this sounds like a stupid question, but if not's immediately obvious to me what should be done here 😅.

@josevalim
Copy link
Member

josevalim commented Apr 15, 2023

Instead of trying to pass slots to a layout, have shared function components that you build your layouts. Let's imagine you are building a school website. You have "students" layout and a "teachers" layout. They are similar layout with different links and buttons. Don't try to create a "generic" layout and then extend it in both "students" and "teachers". Instead, create a set of generic components and slots, such as <.layout, <:sidebar, <:header, and use it to build your layouts:

# students.html.heex
<.layout>
  <:header>Hello student <%= @student.name %></:header>
  <:body>...</:body>
</.layout>
# teachers.html.heex
<.layout>
  <:header>Hello teacher <%= @teacher.name %></:header>
  <:body>...</:body>
</.layout>

Your starting point is the function component not the layout. That's what I mean by keeping the layouts minimal. If you want to share something, use function components to share it, not the layouts themselves.

I might be misreading this, but it sounds like (when you first read it) we should expect some parity for all HTML rendering inside the framework because it is all based on function components.

Yes, they are all function components, but you don't expect all function components to receive the exact same arguments. Each receive their own and you need to adept accordingly. Like any other function.

@josevalim
Copy link
Member

Btw, what I mentioned above is the same technique @greven mentioned in the forum: https://elixirforum.com/t/how-are-you-doing-nested-layouts-with-phoenix-1-7/55236/8

The general structure is defined as a bunch of function components, such as Components.Dashboard.layout. The layouts call them with the missing pieces. If you need to reuse something, you do it by also calling Components.Dashboard.layout.

@thiagomajesk
Copy link
Contributor

thiagomajesk commented Apr 15, 2023

Hi @josevalim! Sorry, but I found your example a little confusing and I'm kind of on a dead end here... Could you help me see how that example I gave would work? The use case is fairly simple and we were already able to do that previously.

As a side note, I'm not understanding why we can't rename the assigns from layouts from @inner_content to @inner_block - it seems the problem is just that. I just tested and it seems we can call render_slot(@inner_block) inside a layout like this:

admin layout:

<main>
  <.flash_group flash={@flash} />
  <!-- Something like this would even work -->
  <!-- <%= assigns[:inner_content] || render_slot(@inner_block) %> -->
  <%= render_slot(@inner_block) %>
</main>

Settings layout (and this already works):

slot :inner_block, required: true
def settings(assigns) do
  ~H"""
   <.admin flash={@flash}>
     <header>Settings</header>    
    <%= render_slot(@inner_block) %>
   </.admin>
  """
end

What I want to achieve is that a random edit page can be rendered inside the settings layout that is rendered inside the admin layout. It all works except for the fact that the assigns for the "contents" are different when the layout is called from other places and when it's called from another function component: @inner_content vs @inner_block.

PS.: Man, I'm really sorry but this is not intuitive to me (at all) 😣

Update: I just want to make a correction from my previous statement... It seems that when I'm rendering an edit page from the controller passing this: put_layout(conn, html: {MyAppWeb.Layouts, :settings}); I get a @inner_block assign with the contents from the edit page. However, the contents passed to the .admin layout (which is a function component) only go as @inner_block when called like that. Otherwise, it goes as @inner_content which is honestly very confusing.

@josevalim
Copy link
Member

josevalim commented Apr 15, 2023

I am bowing out of this discussion. I have said above that you should not try to extend layouts. Don't make the settings layout call the admin layout. Make both settings and admin call a shared set of function components, and not each other.

I recommend continuing the discussion in the forum. @greven already posted an example there, coming from an actual application, and hopefully others can share more.

@thiagomajesk
Copy link
Contributor

No problem, that's cool... I think this issue should at least be left open and considered more carefully because the current experience is at least confusing if you are used to how things work with Phoenix pre-1.7 (I know I'm having to teach this to at least three other people besides myself).

The documentation states that "Layouts are just function components" but they are not quite the same - to be fair, the docs say that contents for the layouts will be placed inside an @inner_content assign; but when you read the section about function components you are taught how to render content inside the components differently.

And now, things look the same but behave differently - so either layouts are in fact, a "special type of function component" or they are "just function components".

I'm sure there might be some technical concerns like you said that I'm unaware of, specifically because the whole implementation is very recent; but I hope the user experience is taken into consideration for the future. Cheers!

@josevalim
Copy link
Member

josevalim commented Apr 15, 2023

The documentation states that "Layouts are just function components" but they are not quite the same

I have already addressed this. It is not because something is a function component that it means they accept slots. For example, live_img_preview is a function component, it doesn't mean you can pass a slot to it. The admin layout is a function component, it also doesn't mean you can pass a slot to it.

Since we are clearly going in circles, I will lock the thread to avoid more circling around. Especially because this is not an bug and I believe the forum will provide better support, as it is clear that my attempts at explaining here have not been enough, and others have already provided similar answers in the forum.

@phoenixframework phoenixframework locked as resolved and limited conversation to collaborators Apr 15, 2023
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants