-
-
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 |
I see, I haven't included the changeset_helpers yet
Glad you figured it out!
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}
/>
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.
In some cases the
valuefield 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
A bit shorter
defp pick_selected_checkgroup(assigns) do
assigns.value
|> Enum.map(fn
%Ecto.Changeset{action: :update, data: data} -> data.id
%Ecto.Changeset{} -> nil
%{id: id} -> id
x when is_binary(x) and x != "" -> x
_ -> nil
end)
|> Enum.reject(&is_nil/1)
end
Hi, I am getting an error
protocol Enumerable not implemented for nil of type Atomin the code that determines if a given checkbox should be checked:checked={value in @value}.I have copied the
core_components.excode exactly as found above, and my form file uses this to instantiate the input:@ordinancesis defined just as the@genre_optionsabove.I am using:
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!