Many to many checkbox form

ecto
phoenix_html
many_to_many

#1

I have roles and permissions schemas in many_to_many relationships:

  schema "roles" do
    field(:name, :string)

    many_to_many(:permissions, X.Accounts.Permission, join_through: "permissions_roles")
  end

  schema "permissions" do
    field(:name, :string)

    many_to_many(:roles, X.Accounts.Role, join_through: "permissions_roles")
  end

In my Roles form, I’d like to have a list of checkboxes of all of the Permissions that could be associated with a Role. Checking a checkbox would mean an entry in the permissions_roles table, which is a database-only relationship that is not represented by a named module.

I’ve considered various ways of solving this, but I feel like I’m writing a lot of code for something that should be simpler.

Is there a pattern for creating a form like this or a good example someone could recommend?


#2

I think the What’s new in Ecto 2.0 ebook covers this?

But in a nutshell, I think you need to do something like this:

permissions = Repo.all from p in Permission, where: p in ^params["permissions_ids"]
role
|> changeset(...)
|> put_assoc(:permissions, permissions)

#3

Right, that’s some of the Ecto - but I’m looking for the full idiomatic Phoenix way to handle this problem from end-to-end. It’s similar to the embedded schema problem that you can find multiple web hits for (that typically uses inputs_for in the form), but it has some uniqueness that makes it different:

  • You need to show all choices in your checkbox list, not just the embedded ones.
  • You need to show which choices have been selected in an edit.
  • You need to accept the results of the form and then probably put_assoc cleanly.

#4

Every time you will show the form, you should load all permissions as well as preload all permissions in the role you are editing right in your controller.

In your view, you will have to traverse the full list of permissions. Something like this:

<%= for permission <- @permissions do %>
  <input checked="<%= role_has_permission?(@role, permission)" name="role[permissions_ids]" type="checkbox" value="<%= @permission.id %>">
<% end %>

You should probably put the input generation function in your view but that’s the code in rough lines. Keep in mind that params["permissions_ids"] will be nil if no checkbox is checked.


#5

Hi,
I think we also need to deal with the case where the user while (creating and/or updating) selects some checkboxes and then hits submit but there are are some validation errors caused, perhaps by a field somewhere else on the form. In that case when the page is refreshed the checkboxes that the user just selected should still be checked.

I struggled with this also and the following is what I came up with. Sorry that it’s not very polished (I even have a TODO in there) and I’m sure someone will tell me that I’ve overcooked this but here goes anyway :slight_smile:
I wanted to have a legend on the left then the checkboxes with the label of each checkbox on the right. Clicking on the checkbox label also selects the checkbox - so the IDs had to match. And my example is of a User with many_to_many Roles.

In the form I have…

  <%= render SharedView, "_form_group_multi_checkbox.html", form: f, field_name: :roles, collection: @all_roles %>

And this partial looks like…

<div class="form-group row">
  <legend class="col-form-label col-sm-3"
    <%= "id= #{@form.id}_#{@field_name}_legend"%>
  >
  <%=
     opts = assigns[:opts] || []
     opts[:label_value] || humanize @field_name
   %>
  </legend>

    <div class="col-sm-6">

      <%
        selected_checkboxes = get_selected_checkboxes(@form, @field_name)
      %>

        <%= for {desc, value} <- @collection do %>
          <div class="form-check">
            <%=
              checkbox String.to_atom(@form.id), "#{@field_name}_#{value}",
              value: multi_checkbox_value(selected_checkboxes, value),
              name: "#{@form.id}[#{@field_name}][#{value}]",
              class: "form-check-input #{state_class(@form, @field_name)}"
            %>
            <%=
              # TODO look for a better way to do this
              label :blah, :blah, desc, for: input_id(@form, @field_name, value), class: "form-check-label"
            %>
          </div>
        <% end %>

      <%= get_error(@form, @field_name) %>

    </div>
</div>

Then I have an inputs_helper.ex with the functions that are called from the partial.

  # Check if a particular checkbox is selected or not.
  # If selected return true, otherwise return false.
  def multi_checkbox_value(selected_checkboxes, field_value) do
    selected_checkboxes && field_value in selected_checkboxes
  end

  # Returns an array of selected checkbox values
  # For example ["option-two", "option-four"]
  #
  # Checkboxs may be backed by
  #  - a simple list of strings.
  #  - a many_to_many association in which case
  # we need to deals with the various case where the data will come
  # from parameters or a list of structs or a list of changesets.

  def get_selected_checkboxes(form, field) do
    input_value(form, field)
    |> _get_selected_checkboxes()
  end

  # inputs is an empty list
  defp _get_selected_checkboxes([]) do
    []
  end

  # inputs is a list of strings
  defp _get_selected_checkboxes([h | _t] = inputs) when is_binary(h) do
    # return unchanged.
    inputs
  end

  # inputs is a map of params
  defp _get_selected_checkboxes(%{} = inputs) do
    Janey.Schema.CheckboxHelper.parse_checkbox_data(inputs)
  end

  # inputs is a list of changesets or a list of structs
  # not practical to match on a list of something so we do it while enumerating.
  defp _get_selected_checkboxes(inputs) do
    Enum.map(inputs, &get_input_value(&1))
    |> Enum.filter(&(&1 != nil))
  end

  # from changeset
  defp get_input_value(%Ecto.Changeset{} = %{action: action, data: data} = _input) do
    if(action == :update) do
      data.value
    else
      nil
    end
  end

  # from struct
  defp get_input_value(%_{} = input) do
    input.value
  end

Any feedback is welcome!


#6

My current application uses multi-select checkboxes all over the place.

I came up with the solution below, which works for both new and updated records, and maintains user selections in the case of errors. It was some of the first code I wrote in phoenix, and specifically using form helpers / changesets so it could do with some cleaning up and a nicer api.

<%=
  multiselect_checkboxes(
    f,
    :categories,
    Enum.map(@categories, fn c -> { c.name, c.id } end),
    selected: Enum.map(@changeset.data.categories,&(&1.id))
  )
%>

And the helper

defmodule MyApp.CheckboxHelper do
  use Phoenix.HTML

  def multiselect_checkboxes(form, field, options, opts \\ []) do
    {selected, _} = get_selected_values(form, field, opts)

    # HACK: If there is an error on the form, we inspect the posted params
    # as expected, but they may be strings and we're converting ints.
    selected_as_strings = Enum.map(selected, &"#{&1}")

    boxes =
      for {val, key} <- options, into: [] do
        content_tag(:span, class: "checkbox") do
          field_id = input_id(form, field, key)

          checkbox =
            tag(
              :input,
              name: input_name(form, field) <> "[]",
              id: field_id,
              type: "checkbox",
              value: key,
              class: "check_boxes optional",
              checked: Enum.member?(selected_as_strings, "#{key}")
            )

          content_tag(:label) do
            [checkbox, val]
          end
        end
      end

    content_tag(:div, class: "form-group check_boxes optional") do
      [label(form, field), boxes]
    end
  end

  defp get_selected_values(form, field, opts) do
    {selected, opts} = Keyword.pop(opts, :selected)
    param = field_to_string(field)

    case form do
      %{params: %{^param => sent}} ->
        {sent, opts}

      _ ->
        {selected || input_value(form, field), opts}
    end
  end

  defp field_to_string(field) when is_atom(field), do: Atom.to_string(field)
  defp field_to_string(field) when is_binary(field), do: field
end

#7

Okay, I’ve managed to put together a solution. One important change to your raw code (besides the closing “%>” tag needed is the addition of brackets to the role[permissions_ids][] name so that the returned results comes back as an array.

I think that part of my hesitation was due to not understanding the name mapping from the form to the parameters sent back to the controller.

Thanks!