-
-
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.
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:@ordinances
is defined just as the@genre_options
above.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!