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.

4 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))

I’m new to Phoenix and am trying to implement a role/permission system in my project. I have gotten pretty close to what I need by following the Phoenix Files post and the comments in this thread.

I can get the checkbox group rendered properly and check the ones I need, and also submit the form and have the data saved properly. The problem I’m running into now is that when the “edit” modal is opened up, none of the checkboxes are selected and I have to re-select every option again. The problem seems to be coming from the fact that when the modal loads, the changeset passed through has action: :replace . This is also the same action the changeset has when changing a selection. I’m guessing that it is the put_assoc function that adds the permissions (each one as a changeset itself) to the changeset that is adding the action: replace . Is there a way to change what the action is added during the put_assoc ?

My changeset looks like this, maybe something like the put_roles function is what’s missing:

  def changeset(member, attrs) do
    member
    |> cast(attrs, @required ++ [:status, :approved_at])
    |> validate_required(@required)
    |> put_roles(attrs)
    |> unique_constraint(@required,
      name: :members_unique_member_index,
      message: "is already member",
      error_key: :user
    )
    |> unique_constraint(:roles,
      name: :members_roles_pkey,
      message: "has already this role",
      error_key: :roles
    )
  end

  defp put_roles(changeset, attrs) do
    case Map.get(attrs, :roles, Map.get(attrs, "roles")) do
      nil ->
        changeset

      role_ids ->
        # this returns a list of roles from a list of role ids
        roles = Authorizations.get_roles(role_ids)  

        put_assoc(changeset, :roles, roles)
    end
  end