Skip to content

Instantly share code, notes, and snippets.

@brainlid
Last active October 31, 2024 08:44
Show Gist options
  • Save brainlid/9dcf78386e68ca03d279ae4a9c8c2373 to your computer and use it in GitHub Desktop.
Save brainlid/9dcf78386e68ca03d279ae4a9c8c2373 to your computer and use it in GitHub Desktop.
defmodule MyApp.Books.Book do
use Ecto.Schema
import Ecto.Query, warn: false
import Ecto.Changeset
import MyApp.ChangesetHelpers
schema "books" do
field :name, :string
field :genres, {:array, :string}, default: []
# ...
end
@genre_options [
{"Fantasy", "fantasy"},
{"Science Fiction", "sci-fi"},
{"Dystopian", "dystopian"},
{"Adventure", "adventure"},
{"Romance", "romance"},
{"Detective & Mystery", "mystery"},
{"Horror", "horror"},
{"Thriller", "thriller"},
{"Historical Fiction", "historical-fiction"},
{"Young Adult (YA)", "young-adult"},
{"Children's Fiction", "children-fiction"},
{"Memoir & Autobiography", "autobiography"},
{"Biography", "biography"},
{"Cooking", "cooking"},
# ...
]
@valid_genres Enum.map(@genre_options, fn({_text, val}) -> val end)
def genre_options, do: @genre_options
def changeset(book, attrs) do
book
|> cast(attrs, [:name, :genres])
|> common_validations()
end
defp common_validations(changeset) do
changeset
# ...
|> validate_required([:name])
|> clean_and_validate_array(:genres, @valid_genres)
end
end
defmodule MyApp.ChangesetHelpers do
@moduledoc """
Helper functions for working with changesets.
Includes common specialized validations for working with tags.
"""
import Ecto.Changeset
@doc """
Remove the blank value from the array.
"""
def trim_array(changeset, field, blank_value \\ "") do
update_change(changeset, field, &Enum.reject(&1, fn item -> item == blank_value end))
end
@doc """
Validate that the array of string on the changeset are all in the set of valid
values.
NOTE: Could use `Ecto.Changeset.validate_subset/4` instead, however, it won't
give as helpful errors.
"""
def validate_array(changeset, field, valid_values) when is_list(valid_values) do
validate_change(changeset, field, fn ^field, new_values ->
if Enum.all?(new_values, &(&1 in valid_values)) do
[]
else
unsupported = new_values -- valid_values
[{field, "Only the defined values are allowed. Unsupported: #{inspect(unsupported)}"}]
end
end)
end
@doc """
When working with a field that is an array of strings, this function sorts the
values in the array.
"""
def sort_array(changeset, field) do
update_change(changeset, field, &Enum.sort(&1))
end
@doc """
Clean and process the array values and validate the selected values against an
approved list.
"""
def clean_and_validate_array(changeset, field, valid_values, blank_value \\ "") do
changeset
|> trim_array(field, blank_value)
|> sort_array(field)
|> validate_array(field, valid_values)
end
end
defmodule MyAppWeb.CoreComponents do
use Phoenix.Component
# ...
def input(%{type: "checkgroup"} = assigns) do
~H"""
<div phx-feedback-for={@name} class="text-sm">
<.label for={@id}><%= @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
# ...
@doc """
Generate a checkbox group for multi-select.
"""
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[:email]"
attr :errors, :list
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
# ...
end
@ciroque
Copy link

ciroque commented Nov 1, 2023

Hi, I am getting an error protocol Enumerable not implemented for nil of type Atom in the code that determines if a given checkbox should be checked: checked={value in @value}.

I have copied the core_components.ex code exactly as found above, and my form file uses this to instantiate the input:

        <.checkgroup
          field={@form[:ordinances]}
          name="ordinances"
          label="Ordinances"
          options={@ordinances}
        />

@ordinances is defined just as the @genre_options above.

I am using:

{:phoenix, "~> 1.7.7"}
{:phoenix_live_view, "~> 0.19.0"}

If I comment out the checked={value in @value} line it works, except for checkbox selection.

I am probably missing something obvious, what might it be?

Thanks!

@ciroque
Copy link

ciroque commented Nov 1, 2023

I see, I haven't included the changeset_helpers yet

@brainlid
Copy link
Author

brainlid commented Nov 2, 2023

Glad you figured it out!

@nicemaker
Copy link

nicemaker commented Nov 8, 2023

This solution works well with a simple embedded list of strings.
But with many_to_many relations coming from database the "selected" state makes quite some difficulties.
Here is my solution for that.

Lets say a Product <> Tag relation:

  schema "products" do
    field :name, :string
    many_to_many :tags, Shopex.Products.Tag,
      join_through: "product_tag_relations", 
      on_replace: :delete
  end

In the changeset I use put_assoc to fetch existing tags via list of ids:

 def changeset(product, attrs) do
    product
    |> cast(attrs, [:name,:archived_at])
    |> validate_required([:name])
    |> put_tags(attrs)
    |> cast_assoc( :variants )
  end
  
  defp put_tags(changeset, attrs) do
    case Map.get(attrs,"tags",[]) do
      [] -> changeset
      tags ->
        found = attrs["tags"]
          |> Enum.filter( fn x -> x != "" end)
          |> Products.list_product_tags()
        put_assoc(changeset, :tags, found)
    end
  end

Little side note:
In case you want to support also the full %Tag{} data in the changeset params you can extend the put_tags function to check if the attr["tags"] is a list of strings or of %Tags{}

In the form controller I fetch the available tags for options:

@impl true
  def handle_params(%{"id" => id}, _, socket) do
    product = Products.get_product!(id)
    tags = Products.list_product_tags()
       |> Enum.map( &({&1.name,&1.id}) )

    {:noreply,
     socket
     |> assign(:page_title, page_title(socket.assigns.live_action))
     |> assign(:product,product)
     |> assign(:tags, tags)
     |> assign_form(Products.change_product(product))}
  end

This is the form snippet for my case:

<.input type="checkgroup" multiple="true" field={@form[:tags]} options={@tags} />

On first render the assigns.value in the checkgroup input is a list of %Tags{ id, name}

Thats not to difficult. You just need to map it to a list of ids and assign this list:

  def input(%{type: "checkgroup"} = assigns) do
    assigns = assign( assigns, :selected, Enum.map( assigns.value, &1(&1.id) ) )
    ...
    <input
         ...,
         checked={value in @selected}
    /> 

But this works only on the first render, because as soon as you select/unselect a checkbox the value of the field is a list of %Ecto.Changeset{}. Bummer!

The changeset has happily the selected tags with full data in the %Ecto.Changeset{ data } field.

In some cases the value field is also just a list of id strings, including the dummy value "" for an empty selection.

to deal with these cases I wrote a helper:

  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

Note that I am not pattern matching for %Tag{ id } but only in general for %{ id }.
Because the logic is not Tag specific but rather many_to_many/ has_many specific.
The component should still work if you have another input like %Options{ id } or %Categories{}

And then you just replace in the checkgroup input the id mapping with the helper:

  def input(%{type: "checkgroup"} = assigns) do
    assigns = assign( assigns, :selected, pick_selected( assigns) ) )
    ...
    <input
         ...,
         checked={value in @selected}
    /> 

@petrus-jvrensburg
Copy link

petrus-jvrensburg commented Apr 1, 2024

I replaced the line checked={value in @value} with checked={@value && value in @value} to avoid the protocol Enumerable not implemented for nil of type Atom error mentioned by @ciroque above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment