I was creating the website of a construction company, so I had 2 schemas: properties
and amenities
. One property can have many amenities (like pool, barbecue grill, playground, etc), one amenity can belong to many properties.
Another popular example similar to this one is posts
and tags
.
So my idea was: I can create a lot of amenities, and in the property form I list these amenities in checkboxes to the user check which one the property has.
I had some bad times trying to do that, so that's why I created this post, so I may help someone...
OK, so I created this schema called properties_amenities
:
defmodule MyApp.PropertiesAmenities do
use Ecto.Schema
import Ecto.Changeset
@primary_key false
schema "properties_amenities" do
belongs_to :property, MyApp.Properties.Property
belongs_to :amenity, MyApp.Amenities.Amenity
end
def changeset(struct, params \\ %{}) do
struct
|> cast(params, [:property_id, :amenity_id])
|> validate_required([:property_id, :amenity_id])
end
end
And then I added it to the properties
schema:
schema "properties" do
# ...
many_to_many(:amenities, MyApp.Amenities.Amenity, join_through: MyApp.PropertiesAmenities)
# ...
end
And the amenities
schema:
schema "amenities" do
# ...
many_to_many(:properties, MyApp.Properties.Property, join_through: MyApp.PropertiesAmenities)
# ...
end
OK, now I created a helper to list all the amenities on the property form, so in the file lib/myapp_web/helpers/checkbox_helper.ex
:
defmodule ConstrutoraLcHiertWeb.Helpers.CheckboxHelper do
use Phoenix.HTML
@doc """
Renders multiple checkboxes.
## Example
iex> multiselect_checkboxes(
f,
:amenities,
Enum.map(@amenities, fn c -> { c.name, c.id } end),
selected: Enum.map(@changeset.data.amenities,&(&1.id))
)
<div class="checkbox">
<label>
<input name="property[amenities][]" id="property_amenities_1" type="checkbox" value="1" checked>
<input name="property[amenities][]" id="property_amenities_2" type="checkbox" value="2">
</label>
</div
"""
def multiselect_checkboxes(form, field, options, opts \\ []) do
{selected, _} = get_selected_values(form, field, opts)
selected_as_strings = Enum.map(selected, &"#{&1}")
for {value, key} <- options, into: [] do
content_tag(:label, class: "checkbox-inline") do
[
tag(:input,
name: input_name(form, field) <> "[]",
id: input_id(form, field, key),
type: "checkbox",
value: key,
checked: Enum.member?(selected_as_strings, "#{key}")
),
value
]
end
end
end
defp get_selected_values(form, field, opts) do
{selected, opts} = Keyword.pop(opts, :selected)
param = field_to_string(field)
case form do
%{params: %{^param => sent}} ->
{sent, opts}
_ ->
{selected || input_value(form, field), opts}
end
end
defp field_to_string(field) when is_atom(field), do: Atom.to_string(field)
defp field_to_string(field) when is_binary(field), do: field
end
And added it to lib/myapp_web.ex
:
def view do
quote do
# ...
import MyAppWeb.Helpers.CheckboxHelper
# ...
end
end
Now I returned the amenities
to the form of properties, so, in the properties_controller#new
I added:
def new(conn, _params) do
# ...
amenities = Amenities.list_amenities()
render("new.html", changeset: changeset, amenities: amenities)
end
And used the new function to render multi-select checkboxes in the new.html.eex
file:
<div class="form-group">
<%=
multiselect_checkboxes(
f,
:amenities,
Enum.map(@amenities, fn a -> { a.name, a.id } end),
selected: Enum.map(@changeset.data.amenities,&(&1.id))
)
%>
</div>
Now the view is rendering the checkboxes :)
OK, now we have to create a new function to create the association between properties and amenities, to do this, I created this function in the file containing all the function related to properties:
defp maybe_put_amenities(changeset, []), do: changeset
defp maybe_put_amenities(changeset, attrs) do
amenities = Amenities.get_amenities(attrs["amenities"])
Ecto.Changeset.put_assoc(changeset, :amenities, amenities)
end
And this function in the file related to amenities:
def get_amenities(nil), do: []
def get_amenities(ids) do
Repo.all(from a in MyApp.Amenities.Amenity, where: a.id in ^ids)
end
And added this new method maybe_put_amenities
in the function that creates a new property, like this:
def create_property(attrs) do
%Property{}
|> Property.changeset(attrs)
|> maybe_put_amenities(attrs)
|> Repo.insert()
end
So now, when the access the properties/new
page, it will render the form and all the amenities. When we select the amenities and click on the Submit button, it will go the properties_controller#create
and execute the code like:
Properties.create_property(params)
And it will execute our function maybe_put_amenities
that will list all the amenities using the function Amenities.get_amenities/1
and put_assoc
in every amenity found. And then save it. And it's done. It works.
But we still may have a problem when trying to submit the form but it got an error. For example, if the user selects some amenities, click on submit but it got an error, and we render again the new
page, the previously selected amenities will not be selected anymore, so to fix that, we can do this in the properties_controller.ex
:
def create(conn, %{"property" => params}) do
case Properties.create_property(params) do
{:ok, property} ->
conn
|> put_flash(:info, "Success!")
|> redirect(to: "/")
{:error, changeset} ->
data = Properties.load_amenities(changeset.data)
amenities = Amenities.list_amenities()
conn
|> put_flash(:error, "Error!"))
|> render("new.html", changeset: %{changeset | data: data}, amenities: amenities)
end
end
And create this new method in the file responsible for properties:
def load_amenities(property), do: Repo.preload(property, :amenities)
And that's all. It looks like a lot of steps but I hope I can help someone :)
Top comments (3)
Thank you! I've been looking for this, just put it into action :)
This helped a lot! Thanks!! 🚀
Thank you so much!