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?

1 Like

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