Forms with variable number of inputs

forms
ecto
phoenix
phoenix-14

#1

Hello. I’m new into elixir/phoenix and I love it. I’m still learning and can’t figure how to solve this problem about associations in ecto (mysql).

I’m working on something like this:

Category has_many Propertyname many_to_many Property.
Product many_to_many Property

Example category: drinks->brand->[“coca cola”, “pepsi”]. To create a new product (with some specific properties) under this category, in order to generate a variable number of select inputs (one for each Propertyname with some Properties as options), I feed the form with this function:

def list_category_properties(category_id) do
propertynames = for propertyname <- Repo.preload(get_category!(category_id), :propertynames).propertynames do
%{"propertyname_field"=>"propertyname_" <> Integer.to_string(propertyname.id), "propertyname" => propertyname.name, "properties" => for property <- Repo.preload(get_propertyname!(propertyname.id), :properties).properties do
    {property.name, property.id}
end
    }
end
end

Example:
properties = [
%{
"properties" => [{"Red", 3}, {"Green", 4}],
"propertyname" => "Color",
"propertyname_field" => "propertyname_1"
}
%{
"properties" => [{"Big", 5}, {"Small", 6}],
"propertyname" => "Size",
"propertyname_field" => "propertyname_2"
}
]

For generating the inputs, in the form I use:

<%= for propertyname <- @properties do %>
    <p>
	<%= label f, propertyname["propertyname"] %>
	<%= select f, String.to_atom(propertyname["propertyname_field"]), propertyname["properties"] %>
    </p>
    <br/>
    <br/>
<% end %>

So when the form is submitted the attr’s in the changeset could be:

%{
....
"propertyname_1" => "3",
"propertyname_2" => "6",
}

I’m not sure if it’s even possible to make it work this way, but the whole thing looks quite bad. I’m reading about Ecto associations and there seems to be a better/standard approach to do things like this. But I feel like trying (without any success) to solve the problem by brute force, because I don’t really understand exactly how should I use those methods to solve this particular problem.

Thank you!


#2

I don’t think You need to do this manually, You might have a look at

https://hexdocs.pm/phoenix_html/Phoenix.HTML.Form.html#inputs_for/4


#3

I did something similar sometimes ago…
As @kokolegorille suggested I think inputs_for/4 is the way to go.

I will call what you named property_name just property, and your property by property_value.

Then we will have another schema called product_property that has_one property_value and make product has_many product_properties.

Now in a product_controller the new action could looks like this:

def new(conn, {"category_id" => cat_id}) do
    ...get the product category preloaded with [properties: :property_values]...
    length = length(category.properties)
    properties = List.duplicate(%ProductProperties{}, length)
    changeset = %Product{product_properties: properties} |> Product.changeset()
    render(conn, "new.html", changeset: changeset, properties: category.properties)
end

And the form template will look like this:

<%= form_for @changeset, @action, fn f -> %>

  <%= inputs_for f, :product_properties, fn i -> %>
    <% property = Enum.at(@properties, i.index) %>
    <%= hidden_input(i, :property_id, value: property.id) %>
    <label><%= property.name %></label>
    <%= select(i, :property_value_id, property.property_values) %>
  <% end %>

<% end %>

#4

Thank you both, inputs_for is clearly a better way. I have been playing a lot based on Kuritsu example, but still i can’t get it to work :frowning:

I’m using:

Category has_many PropertyName many_to_many PropertyValue
Product many_to_many PropertyValue

RelProduct is the many_to_many relation table between Product and PropertyValue. The problem I have is writing values here. I’ll also add a third foreign key in this table relating PropertyName’s because PropertyValues can be shared by many PropertyNames.

Product

schema "products" do
...
has_many :properties, Products.RelProduct
timestamps()
end

@doc false
def changeset(product, attrs) do
product
|> cast(attrs, [...])
|> cast_assoc(:properties, required: true)
|> validate_required([...])
end

end

RelProduct

schema "rel_products" do
field :product_id, :id
# if i use belongs_to :product_id, not even product_id is inserted here.
belongs_to :property_value_id, Products.PropertyValue 
timestamps()
end

@doc false
def changeset(rel_product, attrs) do
rel_product
|> cast(attrs, [])
|> validate_required([])
end
end

Controller

...
property_names = for propertyname <- category.property_names do
  %{
:name => propertyname.name, 
:id =>propertyname.id,
:property_values => 
  for propertyvalue <- propertyname.property_values do
    [key: propertyvalue.name, value: propertyvalue.id]
  end
  }
end
length = length(property_names)
changeset = Product.changeset(%Product{properties: List.duplicate(%Products.PropertyValue{}, length)}, %{})
    render(conn, "neww.html", .....,  changeset: changeset, property_names: property_names)

Form

<%= inputs_for f, :properties, fn i -> %>
<% propertyname = Enum.at(@property_names, i.index) %>
<%=  hidden_input(i, :property_name_id, value: propertyname.id) %>
<label><%= propertyname.id %> &nbsp; <%= propertyname.name %></label>
<%= select(i, :property_value, propertyname.property_values, class: "form-control float-right", style: "" ) %>

<%= if message = i.errors[:property_value] do %>
<span class="help-block"><%= message %></span>
<% end %>
<% end %>

The generated html is ok, but the values (other than product_id) are never stored in RelProduct. I think the problem is, the attr’s generated by the form are not in the same format expected by cast_assoc, but I don’t know why. Here is an example of the values in the changeset attr’s after posting the form:

%{
...
"properties" => %{
"0" => %{"property_name_id" => "1", "property_value" => "1"},
"1" => %{"property_name_id" => "3", "property_value" => "1"},
"2" => %{"property_name_id" => "4", "property_value" => "3"}
},
}

In the docs, the example of cast_assoc shows something slightly different:

%{"name" => "john doe", "addresses" => [
  %{"street" => "somewhere", "country" => "brazil", "id" => 1},
  %{"street" => "elsewhere", "country" => "poland"},
]}

Any idea? Thanks again.


#5

I think you don’t have to add “_id” part in the syntax of belongs_to.

Ex: belongs_to :product, Products.Product

Also in the changeset you need to cast all the fields that will be provided by the form, for example the property_value_id set with the hidden input.

Here I would use has_one :property_value instead, and as you can notice the “_id” have also to be omitted. Only when casting in the changeset or building the form field you have to use property_value_id.

For this part I don’t very well understand, but you should not format data in your controller. I think your view will be a better place to write a view_helper function that you could call from the template. Try to see Enum.map/2 for the formating.

Hum both of the data structures should work I think, if the expected has_many association is defined and casted with cast_assoc.

I hope all this will help you.

Edit: Sorry I was wrong about the has_one association I suggested. You are right, you need a belongs_to in rel_product. You just have to name the association without the “_id” part. ^^


#6

Here instead of using an association name (:product_value), since you are defining a field you have to use the full field name :product_value_id.

Instead of this you can just use the form helper error_tag(form, field) that is defined in YourAppWeb.ErrorsHelper.


#7

I was failing every time I tried to do so. In consequence I thought both property_value and property_name were going to be automatically casted as both being part of properties. But I had a combination of things raising exceptions there. Thanks to your other comments I managed to better understand and fix everything :slight_smile: I don’t remember being so stuck with a problem at least in the last ten years so… Thank you!!

Thanks.