This doesnt solve directly the same as your problem because it doesnt have filtering, but does handle many-to-many selects.
This is in my core_components.ex
@doc """
Renders a multi-select dropdown with checkboxes.
## Examples
<.multi_select
id="team-select"
name="ticket[team_ids]"
options={@teams}
selected={@selected_team_ids}
label="Teams"
label_field={:name}
value_field={:id}
/>
"""
attr :id, :string, required: true
attr :name, :string, required: true
attr :label, :string, default: nil
attr :options, :list, required: true
attr :selected, :list, default: []
attr :label_field, :atom, default: :name
attr :value_field, :atom, default: :id
attr :placeholder, :string, default: "Select options..."
attr :errors, :list, default: []
def multi_select(assigns) do
# Ensure selected is always a list and filter out empty strings
selected =
assigns.selected
|> List.wrap()
|> Enum.reject(&(&1 == "" || &1 == nil))
assigns = assign(assigns, :selected, selected)
~H"""
<div class="fieldset mb-2">
<label>
<span :if={@label} class="label mb-1">{@label}</span>
<div class="relative">
<%!-- Hidden inputs for form submission --%>
<input type="hidden" name={@name} value="" />
<div :for={value <- @selected} style="display: none;">
<input type="hidden" name={@name <> "[]"} value={value} />
</div>
<div class="relative">
<div
role="button"
class="select select-bordered w-full text-left cursor-pointer transition-all duration-300 ease-in-out"
phx-click={
JS.toggle(to: "##{@id}-dropdown", in: "fade-in-scale", out: "fade-out-scale")
}
>
{if length(@selected) == 0 do
@placeholder
else
"#{length(@selected)} selected"
end}
</div>
<div
class="hidden absolute z-10 bg-base-100 rounded-box shadow-lg w-full max-h-60 overflow-auto p-2 mt-1"
id={"#{@id}-dropdown"}
phx-click-away={JS.hide(to: "##{@id}-dropdown", transition: "fade-out-scale")}
>
<div
:for={option <- @options}
class="flex items-center p-2 hover:bg-base-200 cursor-pointer rounded"
phx-click="toggle_multiselect_item"
phx-value-id={@id}
phx-value-name={@name}
phx-value-value={Map.get(option, @value_field)}
>
<div class="w-5 h-5 border-2 border-base-300 rounded flex items-center justify-center mr-3">
<.icon
:if={
to_string(Map.get(option, @value_field)) in Enum.map(@selected, &to_string/1)
}
name="hero-check"
class="size-4 text-primary"
/>
</div>
<span class="select-none">{Map.get(option, @label_field)}</span>
</div>
</div>
</div>
</div>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
</div>
"""
end
and this is a helper, which helps me make it generic over any and all relationships instead of writing them one by one, the must important part is the handle_multiselect_toggle, the last func handle_form_name isn’t really needed for the multiselect.
defmodule JnbWeb.Shared.MultiSelectHelpers do
@moduledoc """
Helper functions for LiveViews that use multi-select fields.
## Usage
In your LiveView:
use JnbWeb, :live_view
import JnbWeb.Shared.MultiSelectHelpers
Then use the helper functions in your event handlers.
"""
import Phoenix.Component, only: [assign: 2, to_form: 1]
import Phoenix.LiveView, only: [put_flash: 3, push_navigate: 2]
@doc """
Safely gets selected values from a form, handling nil data gracefully.
## Parameters
- form: The form changeset
- field: The field name as an atom or string
## Example
get_selected_values(@form, :team_ids)
"""
def get_selected_values(form, field) when is_atom(field) do
get_selected_values(form, to_string(field))
end
def get_selected_values(form, field) when is_binary(field) do
params_value = form.params[field]
data_value = if form.data, do: Map.get(form.data, String.to_atom(field))
List.wrap(params_value || data_value || [])
end
@doc """
Handles the toggle_multiselect_item event for multi-select fields.
## Parameters
- socket: The LiveView socket
- field_name: The full field name (e.g., "team[ticket_ids]")
- value: The value to toggle
- resource_type: The resource type as a string (e.g., "team")
"""
def handle_multiselect_toggle(socket, field_name, value, resource_type) do
# Extract field name from "resource[field]" format
case String.split(field_name, "[", parts: 2) do
[^resource_type, field_with_bracket] ->
field = String.trim_trailing(field_with_bracket, "]")
current_form = socket.assigns.form.source
current_values =
(socket.assigns.form.params[field] ||
(current_form.data && Map.get(current_form.data, String.to_atom(field))) || [])
|> List.wrap()
|> Enum.reject(&(&1 == "" || &1 == nil))
new_values =
if value in Enum.map(current_values, &to_string/1) do
Enum.reject(current_values, &(to_string(&1) == value))
else
current_values ++ [value]
end
# Create complete params including all form fields
all_params = Map.merge(socket.assigns.form.params, %{field => new_values})
# Validate with complete params
new_form = AshPhoenix.Form.validate(current_form, all_params)
assign(socket, form: to_form(new_form))
_ ->
# Field name doesn't match expected format, return socket unchanged
socket
end
end
@doc """
Prepares parameters for forms with multi-select fields.
## Parameters
- params: The form parameters
- multi_select_fields: List of field names that are multi-selects (as atoms)
## Example
prepare_multiselect_params(params, [:ticket_ids, :user_ids])
"""
def prepare_multiselect_params(params, multi_select_fields) do
Enum.reduce(multi_select_fields, params, fn field, acc ->
field_str = to_string(field)
value =
case acc[field_str] do
"" -> []
nil -> []
ids -> List.wrap(ids)
end
Map.put(acc, field_str, value)
end)
end
@doc """
Common save handler for forms with standard success/error handling.
## Parameters
- socket: The LiveView socket
- form: The form changeset/struct
- params: The submitted parameters
- opts: Options including:
- :resource_name - Name of the resource for flash messages
- :return_path_fn - Function to generate return path
"""
def handle_form_save(socket, form, params, opts \\ []) do
resource_name = Keyword.get(opts, :resource_name, "Resource")
return_path_fn = Keyword.get(opts, :return_path_fn, fn _, _ -> "/" end)
case AshPhoenix.Form.submit(form, params: params) do
{:ok, resource} ->
# Send notification if the module defines notify_parent
if function_exported?(socket.view, :notify_parent, 1) do
apply(socket.view, :notify_parent, [{:saved, resource}])
end
socket
|> put_flash(:info, "#{resource_name} #{form.source.type}d successfully")
|> push_navigate(to: return_path_fn.(socket.assigns.return_to, resource))
{:error, form} ->
assign(socket, form: form)
end
end
end
then you would just call it like this
<.multi_select
id="team-tickets"
name="team[ticket_ids]"
options={@tickets}
selected={get_selected_values(@form, :ticket_ids)}
label="Tickets"
label_field={:title}
value_field={:id}
placeholder="Select tickets..."
/>
and then use the functions like this
def handle_event("validate", %{"team" => team_params}, socket) do
prepared_params = prepare_multiselect_params(team_params, [:ticket_ids])
{:noreply,
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, prepared_params))}
end
def handle_event("toggle_multiselect_item", %{"name" => name, "value" => value}, socket) do
{:noreply, handle_multiselect_toggle(socket, name, value, "team")}
end
def handle_event("save", %{"team" => team_params}, socket) do
prepared_params = prepare_multiselect_params(team_params, [:ticket_ids])
socket =
handle_form_save(socket, socket.assigns.form, prepared_params,
resource_name: "Team",
return_path_fn: &return_path/2
)
{:noreply, socket}
end
The downside of this approach is each toggle is a callback to the server, which in something more FE focussed you wouldnt need, after all the options you can select are already pre-rendered, but I think it might help you, also this is liveview, most of the state should live on the server, otherwise maybe liveview is a bad fit. About doing full trips to the server I wouldn’t worry too much about it, I have my app in a VPS server in Germany and I am in Central America, there’s very little latency I would say