Search code examples
elixirphoenix-live-view

Assigns is not assigning attribute in custom core component


Background

I have a custom component in my LiveView, which is basically a group of checkboxes. I want to add a new attribute to my custom component, however no matter what I do, I always get nil when accessing said attribute.

Code

core_components.ex

  @doc """
  Generate a checkbox group for multi-select.

  ## Examples

    <.checkgroup
      field={@form[:genres]}
      label="Genres"
      options={[{"Fantasy", "fantasy"}, {"Science Fiction", "sci-fi"}]}
      selected={[{"Fantasy", "fantasy"}]}
    />

  """
  attr :id, :any
  attr :name, :any
  attr :label, :string, default: nil
  attr :field, Phoenix.HTML.FormField, doc: "a form field struct retrieved from the form, for example: @form[:genres]"
  attr :errors, :list
  attr :required, :boolean, default: false
  attr :rest, :global, include: ~w(form readonly)
  attr :class, :string, default: nil
  attr :options, :list, default: [], doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
  attr :selected, :list, default: [], doc: "the currently selected options, to know which boxes are checked"

  attr :meow, :any, default: "meow", doc: "a very cool new attribute!"

  def checkgroup(assigns) do
    new_assigns =
      assigns
      |> assign(:multiple, true)
      |> assign(:type, "checkgroup")

    input(new_assigns)
  end


  def input(%{type: "checkgroup"} = assigns) do
    ~H"""
    <p> <%= "Cat says: #{@meow}" %></p> 

    <div class="mt-2">
      <%= for opt <- @options do %>
        <div class="relative flex gap-x-3">
          <div class="flex h-6 items-center">
            <input id={opt.id} name={@name} type="checkbox" value={opt.id} checked={opt in @selected} disabled={false} />
          </div>
          <div class="text-sm leading-6">
            <label  for={opt.id} ><%= opt.name %></label>
          </div>
        </div>

      <% end %>
    </div>
    """
  end

Here I am using a customer component to render a stylish checkbox group. It works fine, except for the :meow attribute, which somehow is always nil when inside the input function.

Error

Using this code as a sample:

<.checkgroup field={@form[:my_form]} label="SUper question!" options={@sounds} selected={@selected_sounds} meow={[1, 2]} required />

I get the following error upon rendering this code:

** (exit) an exception was raised:
    ** (KeyError) key :meow not found in: %{
  __changed__: nil,
  __given__: %{
    __changed__: nil,
    __given__: %{
      __changed__: nil,
      __given__: %{
        __changed__: nil,
        field: %Phoenix.HTML.FormField{
          id: "my_form",
          name: "my_form",
          errors: [],
          field: :my_form,
          form: %Phoenix.HTML.Form{
            source: %{"some_command" => []},
            impl: Phoenix.HTML.FormData.Map,
            id: nil,
            name: nil,
            data: %{},
            hidden: [],
            params: %{"some_command" => []},
            errors: [],
            options: [],
            index: nil,
            action: nil
          },
          value: nil
        },
        label: "SUper question!",
        meow: [1, 2],
        options: [1, 2, 3, 4],
        # .... more things follow

As you can see, meow: [1, 2], is clearly present. Yet, @meow returns nil and using assigns.meow outright crashes the application.

What am I doing wrong here and how can I fix it?


Solution

  • After a lot of digging around, i realized that all of the input functions incore_components.ex modify assigns. The best example of this is this intermediary function, called before any input component functions:

      def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
        assigns
        |> assign(field: nil, id: assigns.id || field.id)
        |> assign(:errors, Enum.map(field.errors, &translate_error(&1)))
        |> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
        |> assign_new(:value, fn -> field.value end)
        |> input()
      end
    

    This means I do not fully control what is going on. I decided to move away from using/modifying the input functions and created my own component from scratch:

    @doc """
      Generate a checkbox group for multi-select.
    
      ## Examples
    
        <.checkgroup
          field={@form[:genres]}
          label="Genres"
          options={[{"Fantasy", "fantasy"}, {"Science Fiction", "sci-fi"}]}
          selected={[{"Fantasy", "fantasy"}]}
        />
    
      """
      attr :name, :any
    
      attr :field, Phoenix.HTML.FormField,
        doc: "a form field struct retrieved from the form, for example: @form[:genres]"
    
      attr :required, :boolean, default: false
    
      attr :options, :list,
        default: [],
        doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
    
      attr :disabled, :list, default: [], doc: "the list of options that are disabled"
    
      attr :selected, :list,
        default: [],
        doc: "the currently selected options, to know which boxes are checked"
    
      def checkgroup(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
        assigns =
          assigns
          |> assign(:name, "#{field.name}[]")
    
        ~H"""
        <div class="mt-2">
          <%= for opt <- @options do %>
            <div class="relative flex gap-x-3">
              <div class="flex h-6 items-center">
                <input
                  id={opt.id}
                  name={@name}
                  type="checkbox"
                  value={opt.id}
                  class={
                    if opt in @disabled do
                      "h-4 w-4 rounded border-gray-300 text-gray-300 focus:ring-indigo-600"
                    else
                      "h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600"
                    end
                  }
                  checked={opt in @selected}
                  disabled={opt in @disabled}
                />
              </div>
              <div class="text-sm leading-6">
                <label
                  for={opt.id}
                  class={
                    if opt in @disabled do
                      "text-base font-semibold text-gray-300"
                    else
                      "text-base font-semibold text-gray-900"
                    end
                  }
                >
                  <%= opt.name %>
                </label>
              </div>
            </div>
          <% end %>
        </div>
        """
      end
    

    which works as expected and all the assigns not have the expected values.