Last active
December 7, 2024 12:56
-
-
Save brainlid/9dcf78386e68ca03d279ae4a9c8c2373 to your computer and use it in GitHub Desktop.
Code snippets for Fly blog posts - https://fly.io/phoenix-files/making-a-checkboxgroup-input/
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 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
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
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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:
In the changeset I use
put_assoc
to fetch existing tags via list of ids: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 theattr["tags"]
is a list of strings or of%Tags{}
In the form controller I fetch the available tags for options:
This is the form snippet for my case:
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:
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:
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: