Dynamically render Phoenix function components

Hi!

I’m trying to dynamically render some function components for my navigation menu and am looking for a way to potentially do it a little… nicer?

What I have right now is:

# in module OutlineIcons
def home(assigns) do
  assigns = assign_defaults(assigns)

  ~H"""
  <svg id={@id} class={@class} style={@style} xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
  </svg>
  """
end

# in module Page
def page(assigns) do
  navigation = [
    {"Home", Routes.home_index_path(assigns.socket, :index), &OutlineIcons.home/1}
  ]

  # add it to assigns, lots of markup...

  ~H"""
  <%= for {label, href, icon} <- @navigation do %>
    <%= live_redirect to: href, class: "text-gray-600 hover:bg-gray-50 group flex items-center px-3 py-2 text-sm font-medium rounded-md" do %>
      <%= icon.(%{__changed__: %{}, class: "text-gray-400 group-hover:text-gray-500 flex-shrink-0 -ml-1 mr-3 h-6 w-6"}) %>
      <%= label %>
    <% end %>
  <% end %>
  """
end

Now, since my icon component has an assigns map as parameter, I need to create my own one by adding %{__changed__: %{}. This works and renders just fine, but seems verbose. Is there a nicer way to do this?

Cheers!

1 Like

Call the component macro to handle it for you:

<%= component icon, foo: … %>
2 Likes

Cannot seem to figure this one out completely.

Say the names of the function components of a set of icons is passed as attributes.

        <.navigation
          links={[
            %{text: "home", href: "/", icon: "home_icon"},
            %{text: "about", href: "/about", icon: "about_icon"}
          ]}
        />

And you want to render those icons dynamically.

  # function components imported or aliased in
  import AppWeb.IconComponents
  alias AppWeb.IconComponents, as: Icons

  import Phoenix.LiveView.HTMLEngine

  attr :links, :list, default: []
  def navigation(assigns) do
    ~H"""
    <div id="navigation">
      <%= for %{href: href, text: text, icon: icon} <- @links do %>
        <a href={href}>
        <%= component icon ??? %>
        </a>
      <% end %>
    </div>
    """
  end

How do you call the component macro, then?

1 Like

After having read more about function capturing (Modules and functions - The Elixir programming language), I changed my approach. Just like OP, I now pass captured functions to my navigator component (&home_icon/1), instead of function name strings (“home_icon”).

I was sort of hoping maybe something like <.{icon} color="red"/> would work to call a function component dynamically, but the component macro gets the job done.

I think this may be the macro Jose means. So something like:

~H"""
<div id="navigation">
  <%= for %{href: href, text: text, icon: icon} <- @links do %>
    <a href={href}>
      <%= Phoenix.LiveView.HTMLEngine.component(
        icon,
        [..your attrs here...],
        {__ENV__.module, __ENV__.function, __ENV__.file, __ENV__.line}
      ) %>
    </a>
  <% end %>
</div>
"""

Where icon is a captured function still.

My response is out of date, apologies. Components are functions, so you can use apply(module, functions, [assigns]) to invoke them.

5 Likes

What’s the best way to generate the assigns, since a simple map of attrs won’t work?

And, is this still considered a good practice? Debating whether just wrapping the component I want to call dynamically (Phoenix.Component.link) in another component would be better…

Enum.into can transform a map into a keyword list like so:

%{foo: 'f00', bar: 'b@r'} |> Enum.into([])
# => [foo: 'f00', bar: 'b@r']

It doesn’t seem to accept a Keyword list either. Passing a list results in this error: ** (BadMapError) expected a map, got: [navigate: "/test"] while changing to a map gives me this: assign/3 expects a socket from Phoenix.LiveView/Phoenix.LiveComponent or an assigns map from Phoenix.Component as first argument, got: %{__given__: %{navigate: "/test"}

The second error also explicitly mentions calling a function component dynamically, but seems to assume the only valid use case would be in tests:

 If you are using HEEx, make sure you are calling a component using:
     
         <.component attribute={value} />
     
     If you are outside of HEEx and you want to test a component, use Phoenix.LiveViewTest.render_component/2:
     
         Phoenix.LiveViewTest.render_component(&component/1, attribute: "value")

Oh, maybe try something like apply(Phoenix.Component, link, [%{patch: ~p"/details"}] since apply/3 takes a list of a function’s arguments as its third parameter and Phoenix.Component.link/1 has an arity 1 with the assigns map as its only parameter.

@tfwright what are you trying to do? You’re generally not expected to create assigns out of thin air, because those wouldn’t be able to be change tracked.

1 Like

I am conditionally rendering a link in a function kind of like this

defmodule MyComponent do
  def render(assigns) do
     ~H"""
       <%= Helpers.do_something(@foo, @bar) %>
     """
  end
end

defmodule MyHelpers do
  def do_something(foo, bar) do
    if foo do
      render_link
    else
      "some text"
    end
  end
end

In the past I would have used an HTML helper function to accomplish this.

Why is do_something itself not a function component? It would receive assigns, which you can then forward to the link.

1 Like