Working with forms

I’ve been using phoenix for side projects last year or two, mostly small CRUD apps. I find when I try to do things in forms outside of regular data entry I always seem to stumble along. A big one recently is settings fields based on the values of other fields. Or responding to a field value and conditionally rendering something else. What are the recommended ways of working with forms like this? Do i use hooks and set it all up client side (to me this seems easiest since I’m more familiar with JS, but I don’t really like it)? Do I run a function in the validation event? If so, do i operate on the params, or on the changeset, or on the form itself? What if i need to do a database call for one of the operations?

I guess as an example, I have a work order, has many work items, each work item has a quantity and a unit price. I want to calculate the total price of each item based on the quantity and unit price set for each, as well as the total work order price. And then lets also add tax based on the selected customer.

What I’ve done is:

defmodule ....WorkItem.Item do
 def changeset(item, attrs) do
    item
    |> cast(attrs, [
     ...
    ])
    ...
    |> calculate_total_price()
  end

defp calculate_total_price(changeset) do
    quantity = get_field(changeset, :quantity, 0)
    unit_price = get_field(changeset, :unit_price, 0)

    total =
      if quantity && unit_price do
        Decimal.mult(Decimal.new(quantity), Decimal.new(unit_price))
      else
        Decimal.new("0")
      end

    put_change(changeset, :total_price, total)
  end
end
defmodule ....WorkOrders.WorkOrder do
def changeset(work_order, attrs) do
    work_order
    |> cast(attrs, [
     ...
    ])
    ...
    |> Calculations.calculate_totals()
  end
end
defmodule HighImpactSigns.WorkOrders.Calculations do
  import Ecto.Changeset

  @gst_rate Decimal.new("0.05")
  @pst_rate Decimal.new("0.07")
  @combined_rate Decimal.new("0.12")

  def calculate_totals(changeset) do
    items =
      case get_change(changeset, :items) do
        nil -> get_field(changeset, :items) || []
        items -> filter_deleted_items(items)
      end

    sub_total =
      Enum.reduce(items, Decimal.new("0.00"), fn item, acc ->
        item_total =
          case item do
            %Ecto.Changeset{} -> get_field(item, :total_price) || Decimal.new("0")
            %WorkItem.Item{} -> item.total_price || Decimal.new("0")
          end

        Decimal.add(acc, item_total)
      end)
      |> Decimal.round(2)

    tax_type = get_field(changeset, :tax_type, :None) || :None

    tax_total =
      case tax_type do
        :None -> Decimal.new("0.00")
        :GST -> Decimal.mult(sub_total, @gst_rate)
        :PST -> Decimal.mult(sub_total, @pst_rate)
        :"GST/PST" -> Decimal.mult(sub_total, @combined_rate)
        :Exempt -> Decimal.new("0.00")
      end
      |> Decimal.round(2)

    total_price = Decimal.add(sub_total, tax_total) |> Decimal.round(2)

    put_change(changeset, :sub_total, sub_total)
    |> put_change(:tax_total, tax_total)
    |> put_change(:total_price, total_price)
  end

  defp filter_deleted_items(items) do
    Enum.reject(items, fn
      %Ecto.Changeset{action: action} when action in [:replace, :delete] -> true
      _ -> false
    end)
  end
end

I’ve moved setting the tax type based on the customer somewhere else so i wasn’t spamming the database.

In my work order form (I have a custom select element that emits this event when selecting a customer):

@impl true
  def handle_event("selection_change", %{"selection" => selection}, socket) do
    customer = CustomerInformation.get_customer!(selection)
    form = socket.assigns.form

    changeset =
      form.source
      |> Ecto.Changeset.put_change(:tax_type, customer.tax_type)
      |> Ecto.Changeset.put_change(:customer_id, selection)
      |> WorkOrders.Calculations.calculate_totals()

    {:noreply, socket |> assign_form(changeset)}
  end

I have to add another feature, where I’m calculating the price based on the square footage of the item and a price per square footage set on the type of item it is. This would mean conditionally rendering some inputs based on if i am calculating based on the square footage, and then also modifying values in the form again.

i found myself doing things like

item[:substrate_id].value

Which just seems wrong. And then maybe reaching for input_value/2?

Does anyone have any tips on a pattern i can reach for or some documentation i can look at to kind of solidify the “good” ways of doing this sort of stuff?

I’m trying to figure out some of these patterns myself. Would it be possible to make a UI specific embedded schema in this case? And by you found yourself doing things like item[:substrate_id].value, do you mean you’re extracting the id from the form to do that conditional/dynamic rendering from the inputs?

Sorry, I meant that I was struggling to find a good way to use the current value of that field. Felt weird to be accessing the value directly off the form field like that. But I don’t know if form[:field].value is the recommended way.

I guess I’m just trying things, and there seems to be ways to do it, but I end up with either really messy/hacky looking solutions or ones where reactivity is breaking.

After my latest attempt, I can change these values but the changes all get lost when I modify another field as the params in the form are not updated. So should I be doing my operations on the params instead of the changeset/form?

I’ll admit that I read this previously and wasn’t exactly sure what you were asking and overwhelmed by your example :sweat_smile:

If it’s just whether or not it’s ok to read the value from the form field the answer is yes, absolutely. The form struct represents the current state of your form so it’s there if you need programatic access to it on the server. This is how the input component gets its data.

Hmm, any chance you added radio buttons to toggle between pricing formulas? Those symptoms you’re describing reminds me of this head scratcher from a while back.

1 Like

Apologies, maybe I’m over complicating it. It’s two parts.

  1. What’s a good way to react to form values in a form for reactive rendering (form[:field].value vs input_value/2 vs something else)

  2. What’s a good way to modify values of a form based on other values in a form? Do I use the “validate” event? Do I modify the form/changeset/params? Is it recommended to do it with hooks instead?

That big blob of text above kinda just boils down to this.

Hey! Ya, part of the overwhelmed feeling was more that I couldn’t find anything wrong with what you are doing and I think I initially was reading on my phone then forgot about it. Anyway, what you’re doing looks fine to me, at least it looks like code I write.

I use form[:field].value if I want to have the UI react in specific ways, like showing and hiding certain fields, for example. Otherwise when it comes to populating read only fields based on other changes then 99% of the time you’d want to do it in the changeset. I don’t usually recommend manipulating params. There are some cases where it’s ok but the whole point of Changeset.cast is to typecast untrusted data into a known shape.

One (unrelated) thing you could improve is this part:

sub_total =
  Enum.reduce(items, Decimal.new("0.00"), fn item, acc ->
    item_total =
      case item do
        %Ecto.Changeset{} -> get_field(item, :total_price) || Decimal.new("0")
        %WorkItem.Item{} -> item.total_price || Decimal.new("0")
      end

You have some redactions but I’m assuming you have a cast_assoc(:items) in there? If so then you could do something like this (off the top of my head and totally untested):

sub_total =
  changeset
  |> get_assoc(:items)
  |> Enum.map(&get_field(&1, :total_price, Decimal.new("0")))
  |> Enum.reduce(Decimal.new("0"), &Decimal.add/2)
1 Like