Chained selects in multi-model form child rows

Hello all,
I’m new to Phoenix / LiveView / Elixir, and I’ve been struggling for a few days to implement chained selects in a multi-model form on the child rows, i.e. one-to-many.

This tutorial and sample code was super helpful.
One to many Live View Form

In my version, the “lines” have a pair of chained selects. I have it working so that when the first select is changed on any line, all of the second selects in all of the rows change. The challenge is that there is no way to identify the row, and the index keeps changing as rows are added/deleted. It would be pretty solvable with JS and an API endpoint. I was hoping to do it with LiveView. I tried giving the rows a temp_id, and also tried making each row its own live_component. I couldn’t quite get either to work, and I’m feeling like this is a good time to take a step back and ask how a pro would go about it? It feels like a pretty common use case that probably is well solved. I’d post some code, but I’m not sure what path to even take.

In my version where I made the line a live component, it was easy to grab the first select’s value and fetch the options for the second select. I just put a “phx-changed” on the first select with a “phx-target={@myself}” and a “handle_event/3” callback in my live_component. However, changing the assigns didn’t re-render the component as I expected. The new assigns seemed to be missing on the component’s “update/2” callback just before render was called. It probably didn’t help that I gave the component a random id when each row was added (since I didn’t have one). Is there a way to send along a reference to the component that needs updating?

Anyhoo, I would really appreciate some guidance.

Thanks!

The section of that tutorial under “Deleting existing records” seems like a good place to start - inputs_for sets up an index property that is useful for distinguishing rows (even unsaved ones).

1 Like

Just to clarify, is your goal to scope it to each line i.e. selecting from the first select for a given line updates the second select for only that line? And could you add some context and ground it in a specific example?

For a static mapping such as when each line is an address with a country field as the first select and a state field as the second select, you could store that mapping as an assign such as @country_to_states.

assign(socket, :country_to_states, %{
  "USA" => ["CA", "NY", ...],
  "-- select country --" => ["-- select state --"],
  ...
})

Then you can dynamically set the options for the second select based off of the first select with something like this:

        <.inputs_for :let={af} field={@form[:addresses]}>
          <.input type="select" field={[:country]} options={["-- select country --", "USA"]} placeholder="country" />
          <.input type="select" field={[:state]} options={@country_to_states[af.params["country"]} placeholder="state" />
        </.inputs_for>

For reference, the pseudocode above is based on this: Nested inputs_for events in calendar form livecomponent with conditio… · codeanpeace/live_cal@e56742c · GitHub

Yes, exactly. Imagine we were going to add “brand” and “size” for each item in the groceries list. The first select is “Store brand”, “Frosted Flakes”, “Wheaties”. The store brand comes in “16 oz, 32 oz or 64 oz”. “Frosted Flakes” come in “22 oz, 48oz, or 64oz”. “Wheaties” comes in “12 oz, 38oz or 72oz”. I want to make the correct size choices available in the second select when the brand is chosen via the first select. However, the line before it is “Toilet Paper.” It’s sold by the number of rolls. It doesn’t make any sense to set the sizes for cereal in the second select on the line for toilet paper. Similarly, the line after is of course “beer.” It’s sold in 6 packs, 12 packs, 18 packs, or 24 packs.

Thanks for the help!

You know, that was one of the paths that I went down, but I was a bit turned around at the time. Although, I should mention that there would be an API call involved where the id for the first chosen select is used to fetch the options for the second. That’s why I was trying to get it done on the server side where I could just make the query and assign the result.

Naively (very naively) I had thought that a stateful live_component could be made to re-render itself once the event was caught and the assigns were updated. I had been thinking it would get me out of having to track the ids and out of api calls. I was just trying to get it to work with stubbed data yesterday, but it was feeling painful, so I stopped. It may be possible to build some kind of map ahead of time, but in the worst case scenario it could be a whole lot of data.

As a newbie, I wish I had a better sense of where the boundaries are between Liveview and JS. It feels like an easy problem to solve by adding an endpoint and making the call. I just don’t want to miss out on the benefits of Liveview if its just my inexperience that’s causing me to miss a more obvious solution.

This is the approach that I’m going to explore today.

Hmm, I wonder what the latency would be like – round trip via websockets to the server with another round trip via HTTP for an API call in the middle. I’d definitely suggest benchmarking if you go that route. If it’s a public API, it could make sense to make that call from the client side via a client hook.

If you need to do it from the server e.g. for auth reasons then you could adapt the approach I shared above that uses an assign to store the mapping between the selects. And if you don’t want to use an index as the key, you could use the value of select 1 instead with the corresponding options for select 2 that are returned by the API as the value. This could be nice if there are multiple lines with the same mappings e.g. when you want to add the same brand item in multiple sizes.

That could be a simpler approach than nesting multiple LiveComponents within the inputs_for which seems unnecessarily complicated for what you’re trying to accomplish. Also as a sidenote, it could also be helpful to find a typeahead dropdown that filters dynamically filters the options for reference.

I added the API call, which was very easy. However, I realized that it wasn’t really the clean or modern approach that I wanted and kept looking. As it turns out, the approach above that I linked to, “Communicating Between Live Components,” combined with adding a virtual attribute for a row ID did the trick. I literally just got it working, and I have a few other things to fix. I’ll try to pull together some code and tests shortly.

The basic solution that I came up with (not sure if it has inherent flaws, but I’m very open to feedback):

  1. I have a parent Liveview, ie “GroceriesList” which embeds_many Lines.
  2. Each Line is a stateful live_component with a “temp_id” virtual attribute. I actually called it “component_id” because I use it as the component_id for that Line component. In addition to adding in that component id on new rows, I also I added it in when persisted rows are fetched for Edit.
  3. I created another stateful live_component for the second select box, call it “SecondSelectComponent.” Its component id is just the Line component ID plus an arbitrary suffix, like “_1”.
  4. On the first select box’s phx-change event, it emits an event that Line catches with a “handle_event/3”. It now has the value of the first select box and a reference to the second select box. All it has to do is say, “SecondSelectBox.update_select_options(“component_id_1”, first_select_box_value)” per the link above. The SecondSelectBox fetches it’s options with the first_select_box_value and renders.

Like I said, I just got it working. It seems to be pretty smooth. I don’t think it’s too complicated. I’ll take the next steps and see if I can pull something together to show.

“Also as a sidenote, it could also be helpful to find a typeahead dropdown that filters dynamically filters the options for reference.” That’s on my list to try next!

Thanks for the help!

EDIT: I thought I could leave out a step, but that seems to have busted it. Not sure yet why. In my original implementation, I did a “send(self(), params)” in the Line component handle_event/3 for the Liveview template to catch via handle_info 2. I did the “SecondSelectBox.update_select_options(“component_id_1”, first_select_box_value) call there, which did what I want.