Checkbox with many-to-many relationship

I’m trying to implement a form to edit permissions for roles, using checkboxes to grant permissions, with LiveView 0.19. I have a Role and a Permission schema with a many_to_many relationship like so:

defmodule MyApp.Authorizations.Role do
  schema "roles" do
    field(:description, :string, null: true)
    field(:name, :string)

    many_to_many(:permissions, Permission, join_through: "roles_permissions", on_replace: :delete)
    timestamps()
  end

  def changeset(role, attrs) do
    role
    |> cast(attrs, [:name, :description])
    |> put_assoc(:permissions, parse_permissions(attrs))
    |> validate_required([:name])
    |> unique_constraint(:name)
  end

  defp parse_permissions(%{"permissions" => permissions_ids}) do
    permissions_ids
    |> Enum.reject(& &1 == "")
    |> Enum.map(&Authorizations.get_permission!/1)
  end
  defp parse_permissions(_), do: []
end

defmodule MyApp.Authorizations.Permission do
  schema "permissions" do
    field(:resource, EctoAtom)
    field(:action, EctoAtom)

    many_to_many(:roles, Role, join_through: "roles_permissions", on_replace: :delete)
  end
end

I pretty much copy-pasted the checkgroup input from the Fly.io article and use it in my form like this:

<.checkgroup field={f[:permissions]} options={perms} />

perms being a list of {"permission name", "permission id"} tuples. The issue I have now is that, when I edit a role and check a permission, the checkboxes values are nested changesets because of the put_assoc:

value: [
  #Ecto.Changeset<action: :update, changes: %{}, errors: [],
   data: #MyApp.Authorizations.Permission<>, valid?: true>
]

How can I make this work correctly?

2 Likes

Hmm, the example in the article is for an array of strings field. So since you’re using a many-to-many association, you’d probably want to use inputs_for/4.

inputs_for(assigns)

Renders nested form inputs for associations or embeds.

Examples

<.form
  :let={f}
  phx-change="change_name"
>
  <.inputs_for :let={f_nested} field={f[:nested]}>
    <.input type="text" field={f_nested[:name]} />
  </.inputs_for>
</.form>

It still doesn’t work, but for different reasons.

I added a RolePermission schema and updated the join_through parameter of the other schemas to use it.

defmodule MyApp.Authorizations.RolePermission do
  @primary_key false
  @foreign_key_type :binary_id
  schema "roles_permissions" do
    belongs_to(:permission, Permission, primary_key: true)
    belongs_to(:role, Role, primary_key: true)
  end

  @required [:permission_id, :role_id]
  @doc false
  def changeset(role_perm, attrs) do
    cast(role_perm, attrs, @required)
  end
end

And my form now looks like this:

<.inputs_for :let={fp} field={f[:permissions]}>
  <.checkgroup field={fp[:permission]} options={perms} />
</.inputs_for>

This code gives me a strange error coming from the inputs_for: construction of binary failed: segment 1 of type 'binary': expected a binary but got: nil.

I found a solution. First, I added a new virtual field in the Role schema, like so:

    field(:permission_list, {:array, :string}, virtual: true, default: [])

This field will be used as the array we need for the checkboxes to work. Then, I changed the changeset to use this virtual field:

  def changeset(role, attrs) do
    role
    |> cast(attrs, [:name, :description, :organization_id])
    |> validate_required([:name, :organization_id])
    |> put_assoc(:permissions, fetch_permissions(attrs))
    |> unique_constraint(:name)
  end

  defp fetch_permissions(%{permission_list: ids}),
    do: fetch_permissions(%{"permission_list" => ids})

  defp fetch_permissions(%{"permission_list" => permission_ids}) do
    permission_ids
    |> Enum.reject(&(&1 == ""))
    |> Authorizations.get_permissions()
  end

  defp fetch_permissions(_), do: []

Finally, since the permission_list field is virtual, we have to fill it when we get the role from the database. To do so, I implemented:

def get_role!(id) do
    Role
    |> Repo.get!(id)
    |> Repo.preload(:permissions)
    |> with_permission_list()
end

def with_permission_list(role) do
    permissions =
      role.permissions
      |> Enum.map(& &1.id)

    %Role{role | permission_list: permissions}
end

I’m not too happy with the fact I have to add some stuff in the schema only for the UI, but it works well anyway, that’s the important part.

2 Likes

Hey @loics2 ,
Thanks for posting your solution as well.
I found a way to do it without a virtual field and additional decoration of the ecto data.

The error you described pops up as you described when you click a select. This is because in that event the “value” assign is suddenly a list of Ecto.Changeset. This changeset contains the updated tags in a data field, so you can use these to figure out the selection.

And in some update cycles the field value is not even a changeset, but only a list of strings (the selected ids).

So I wrote a helper function to pick the right values:

  defp pick_selected( assigns ) do
     assigns.value
      |> Enum.map(fn x->
           case x do
             %Ecto.Changeset{ action: :update, data: data } ->
               data.id
             %Ecto.Changeset{} -> nil
             %{id: id} ->
               id
             x when is_binary(x) -> if x == "", do: nil, else: x
             _ -> nil
           end
         end)
      |> Enum.filter( &!is_nil(&1))
  end

And this is how you use it, first line in described checkgroup:

   def input(%{type: "checkgroup"} = assigns) do
    assigns = assign( assigns, :selected, pick_selected(assigns) )
    
    ~H"""
    <div phx-feedback-for={@name} class="text-sm">
      <.label for={@id}><%= @label %></.label>
      <div class="mt-1 w-full bg-white 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 @selected}
                class="mr-2 h-4 w-4  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

3 Likes

Awesome, thanks for sharing this approach! This is exactly what I needed.

Here’s my simplified version of pick_selected/1

  defp pick_selected(assigns) do
    assigns.value
    |> Enum.map(fn val ->
      case val do
        %Ecto.Changeset{action: :update, data: data} ->
          data.id

        %{id: id} ->
          id

        value when is_binary(value) and value != "" ->
          value

        _ ->
          nil
      end
    end)
    |> Enum.reject(&is_nil(&1))