How to make checkbox work with form bindings Phoenix LV?

Background

I have a LiveView page where I have a small form. This form is supposed ton have a group of radio buttons, and a group of checkboxes.

To achieve this, I am trying to use Form Bindings:

Code

This is what I currently have. I want to give the user the option to receive notifications about some topics.

For the radio button group, the user chooses whether to receive the notification via email or sms. These are mutually exclusive.

For the checkbox group, the user chooses the topics. These are not mutually exclusive.

This is my html.heex file:

    <.form for={@form} phx-submit="save">
       <.input id={"email"} name="notifications" type="radio" value={"email"} field={@form[:notifications]} />
       <.input id={"sms"} name="notifications" type="radio" value={"sms"} field={@form[:notifications]} />

      <.input id={"sports"} name="topics" type="checkbox" value={"sports"} field={@form[:topics]} />
      <.input id={"science"} name="topics" type="checkbox" value={"science"} field={@form[:topics]} />

      <button>Save</button>
    </.form>

And this is the corresponding LivewView:

defmodule MyApp.Settings do
  use MyApp, :live_view

  @impl true
  def mount(_params, _session, socket) do
  
    form = to_form(%{})
    updated_socket = assign(socket, form: form)

    {:ok, updated_socket}
  end

  def handle_event(event, params, socket) do
    Logger.info("Event: #{inspect(event)} ; #{inspect(params)}")
    {:noreply, socket}
  end

end

If you are a keen reader, you will see I have no label, or legend tags anywhere. This is for simplification purposes.

Problem

Even though I get the correct value for the "notifications" radio group, the problem is that my checkboxes always return true or false:

 [debug] HANDLE EVENT "save" in MyApp.Settings
  Parameters: %{"notifications" => "sms", "topics" => "true"}

This is rather confusing. I was expecting something like:

Parameters: %{"notifications" => "sms", "topics" => ["sports", "science"]}

After reading the relevant parts of the docs, I don’t think the type checkbox is considered a special type, so it is not documented (in the link above mentioned).

I also don’t quite understand why I need a schema when to_form seems to work perfectly fine with %{}.

Questions

  • How can I get a list of values instead of a boolean for checkboxes?
  • I don’t use schemas in my application. Can to_form work with a Struct?
  • What would be the benefit of passing a Struct to to_form ?

The built in <.input type="checkbox"> is for selfstanding checkboxes (boolean on the backend), not for multi value fields. Phoenix doesn’t come with helpers for a set of checkboxes.

See Making a CheckboxGroup Input · The Phoenix Files or Scratchpad | Benjamin Milde on approaches to handling such.

2 Likes

This is amazing content, I am learning a lot!

On the fly.io tutorial, I do have a few questions (I am focusing on that one now, before trying your code snippet, as I would like to further understand how this works). I understand I need to change 3 files:

  • core_components
  • respective LV file
  • respective LV heex file

So I added these changes to core_components.ex (same as Code snippets for Fly blog posts - https://fly.io/phoenix-files/making-a-checkboxgroup-input/ · GitHub):

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

  ## Examples

    <.checkgroup field={@form[:genres]} label="Genres" options={[{"Fantasy", "fantasy"}, {"Science Fiction", "sci-fi"}]} />
  """
  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 :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
  attr :rest, :global, include: ~w(disabled form readonly)
  attr :class, :string, default: nil

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

    input(new_assigns)
  end

  def input(%{type: "checkgroup"} = assigns) do
    ~H"""
    <div phx-feedback-for={@name} class="text-sm">
      <.label for={@id} required={@required}><%= @label %></.label>
      <div class="mt-1 w-full bg-white border border-gray-300 rounded-md shadow-sm pl-3 pr-10 py-2 text-left cursor-default focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
        <div class="grid grid-cols-1 gap-1 text-sm items-baseline">
          <input type="hidden" name={@name} value="" />
          <div class="flex items-center" :for={{label, value} <- @options}>
            <label
              for={"#{@name}-#{value}"} class="font-medium text-gray-700">
              <input
                type="checkbox"
                id={"#{@name}-#{value}"}
                name={@name}
                value={value}
                checked={value in @value}
                class="mr-2 h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500 transition duration-150 ease-in-out"
                {@rest}
              />
              <%= label %>
            </label>
          </div>
        </div>
      </div>
      <.error :for={msg <- @errors}><%= msg %></.error>
    </div>
    """
  end

In my .html.heex file I have a very simple form:

    <.form for={@form} phx-submit="save">

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

      <button>Save</button>
    </.form>

And in the LV .ex file:

defmodule MyApp.Settings do
  use MyApp, :live_view

  @impl true
  def mount(_params, _session, socket) do
    updated_socket = assign(socket, form:  to_form(%{}))
    {:ok, updated_socket}
  end

  def handle_event(event, params, socket) do
    Logger.info("Event: #{inspect(event)} ; #{inspect(params)}")
    {:noreply, socket}
  end

end

However, the page does not even reload, as I get the following error:

Request: GET /activate
** (exit) an exception was raised:
    ** (KeyError) key :required not found in: %{
  __changed__: nil,
  __given__: %{
    __changed__: nil,
    __given__: %{
      __changed__: nil,
      __given__: %{
        __changed__: nil,
        field: %Phoenix.HTML.FormField{
          id: "genres",
          name: "genres",
          errors: [],
          field: :genres,
          form: %Phoenix.HTML.Form{
            source: %{},
            impl: Phoenix.HTML.FormData.Map,
            id: nil,
            name: nil,
            data: %{},
            hidden: [],
            params: %{},
            errors: [],
            options: [],
            index: nil,
            action: nil
          },
          value: nil
        },
        label: "Genres",
        options: [{"Fantasy", "fantasy"}, {"Science Fiction", "sci-fi"}],
        required: false
      },
      field: %Phoenix.HTML.FormField{
        id: "genres",
        name: "genres",
        errors: [],
        field: :genres,
        form: %Phoenix.HTML.Form{
          source: %{},
          impl: Phoenix.HTML.FormData.Map,
          id: nil,
          name: nil,
          data: %{},
          hidden: [],
          params: %{},
          errors: [],
          options: [],
          index: nil,
          action: nil
        },
        value: nil
      },
      label: "Genres",
      multiple: true,
      options: [{"Fantasy", "fantasy"}, {"Science Fiction", "sci-fi"}],
      rest: %{},
      type: "checkgroup"
    },
    errors: [],
    field: nil,
    id: "genres",
    inner_block: [],
    label: "Genres",
    multiple: true,
    name: "genres[]",
    options: [{"Fantasy", "fantasy"}, {"Science Fiction", "sci-fi"}],
    prompt: nil,
    rest: %{},
    type: "checkgroup",
    value: nil
  },
  errors: [],
  field: nil,
  id: "genres",
  inner_block: [],
  label: "Genres",
  multiple: true,
  name: "genres[]",
  options: [{"Fantasy", "fantasy"}, {"Science Fiction", "sci-fi"}],
  prompt: nil,
  rest: %{},
  type: "checkgroup",
  value: nil
}

It complains about not finding the :required key. This is confusing to me as I have defined attr :required, :boolean, default: false in the .checkgroup function inside core_components.

I can’t understand why I get this issue. Is it because I am creating the wrapper incorrectly?

It is my belief (perhaps incorrect) that the code:

 <.label for={@id} required={@required}><%= @label %></.label>

Should not have the required key. The default function for .label does not mention such a key:

  @doc """
  Renders a label.
  """
  attr :for, :string, default: nil
  slot :inner_block, required: true

  def label(assigns) do
    ~H"""
    <label for={@for} class="block text-sm font-semibold leading-6 text-zinc-800">
      <%= render_slot(@inner_block) %>
    </label>
    """
  end

After reading both Articles provided by @LostKobrakai I have arrived to a code that mixes what I learned from both. Definitely recommend the reading. Here are the code pieces for your inspiration:

core_components.ex

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

  ## Examples

    <.checkgroup
      field={@form[:genres]}
      label="Genres"
      options={[%{name: "Fantasy", id: "fantasy"}, %{name: "Science Fiction", id: "sci-fi"}]}
      selected={[%{name: "Fantasy", id: "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 :options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2"
  attr :rest, :global, include: ~w(disabled form readonly)
  attr :class, :string, default: nil

  attr :selected, :any, default: [],
    doc: "the currently selected options, to know which boxes are checked"

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

    input(new_assigns)
  end



  def input(%{type: "checkgroup"} = assigns) do
    ~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="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-600" checked={opt in @selected}>
          </div>
          <div class="text-sm leading-6">
            <label  for={opt.id} class="text-base font-semibold text-gray-900"><%= opt.name %></label>
          </div>
        </div>

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

usage_live.html.heex

      <.simple_form for={@form} phx-change="change" phx-submit="execute">

        <div class="mt-4">
          <.checkgroup field={@form[:genres]} label="Genres" options={@all_genres} selected={@selected_genres} required/>
        </div>

        <div class="mt-4">
          <.button}>Execute Command</.button>
        </div>
      </.simple_form>