From 366b11b51ad7db7b90aaf22a5758cc1d3d30ed88 Mon Sep 17 00:00:00 2001 From: Giacomo Mazzamuto Date: Sun, 14 Sep 2025 20:39:22 +0200 Subject: [PATCH 1/4] Fields.MultiSelect: simplify filtering of selected options One can simply call `Enum.filter`, instead of a more complicated `Enum.reduce` followed by `Enum.reverse`. --- lib/backpex/fields/multi_select.ex | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/lib/backpex/fields/multi_select.ex b/lib/backpex/fields/multi_select.ex index 441fbf2c8..1c8fc898d 100644 --- a/lib/backpex/fields/multi_select.ex +++ b/lib/backpex/fields/multi_select.ex @@ -94,15 +94,7 @@ defmodule Backpex.Fields.MultiSelect do if value, do: value, else: [] end - selected = - Enum.reduce(options, [], fn {_label, value} = option, acc -> - if value in selected_ids do - [option | acc] - else - acc - end - end) - |> Enum.reverse() + selected = Enum.filter(options, fn {_label, value} -> value in selected_ids end) assign(socket, :selected, selected) end From 76083ed504a5473e7aeee460224af41c5f134824 Mon Sep 17 00:00:00 2001 From: Giacomo Mazzamuto Date: Sun, 14 Sep 2025 23:06:21 +0200 Subject: [PATCH 2/4] Fields.MultiSelect: always apply to_string/1 Conversion to string was missing in the else case (the option value could be an atom, therefore it must be converted to a string for proper filtering). --- lib/backpex/fields/multi_select.ex | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/lib/backpex/fields/multi_select.ex b/lib/backpex/fields/multi_select.ex index 1c8fc898d..6d4a68f76 100644 --- a/lib/backpex/fields/multi_select.ex +++ b/lib/backpex/fields/multi_select.ex @@ -80,20 +80,17 @@ defmodule Backpex.Fields.MultiSelect do selected_ids = if type == :form do - values = - case PhoenixForm.input_value(assigns.form, name) do - value when is_binary(value) -> [value] - value when is_list(value) -> value - _value -> [] - end - - Enum.map(values, &to_string/1) + case PhoenixForm.input_value(assigns.form, name) do + value when is_binary(value) -> [value] + value when is_list(value) -> value + _value -> [] + end else - value = Map.get(item, name) - - if value, do: value, else: [] + Map.get(item, name, []) || [] end + selected_ids = Enum.map(selected_ids, &to_string/1) + selected = Enum.filter(options, fn {_label, value} -> value in selected_ids end) assign(socket, :selected, selected) From 6496a029dc4a93d7d53833a5827ed22c3a449673 Mon Sep 17 00:00:00 2001 From: Giacomo Mazzamuto Date: Sun, 14 Sep 2025 23:44:39 +0200 Subject: [PATCH 3/4] Fields.Select: add support for option groups See Phoenix.HTML.Form.options_for_select/2 --- demo/lib/demo/address.ex | 2 +- demo/lib/demo_web/live/address_live.ex | 7 +++++-- lib/backpex/fields/select.ex | 13 +++++++++++-- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/demo/lib/demo/address.ex b/demo/lib/demo/address.ex index 1cc12d5dc..b07250278 100644 --- a/demo/lib/demo/address.ex +++ b/demo/lib/demo/address.ex @@ -11,7 +11,7 @@ defmodule Demo.Address do field :street, :string field :zip, :string field :city, :string - field :country, Ecto.Enum, values: [:de, :at, :ch] + field :country, Ecto.Enum, values: [:de, :at, :ch, :us, :ca] field :full_address, :string, virtual: true timestamps() diff --git a/demo/lib/demo_web/live/address_live.ex b/demo/lib/demo_web/live/address_live.ex index d83248a2a..dff473694 100644 --- a/demo/lib/demo_web/live/address_live.ex +++ b/demo/lib/demo_web/live/address_live.ex @@ -36,8 +36,11 @@ defmodule DemoWeb.AddressLive do country: %{ module: Backpex.Fields.Select, label: "Country", - options: [Germany: "de", Austria: "at", Switzerland: "ch"] - } + options: %{ + "Europe" => [Germany: "de", Austria: "at", Switzerland: "ch"], + "North America" => [USA: "us", Canada: "ca"] + } + }, ] end end diff --git a/lib/backpex/fields/select.ex b/lib/backpex/fields/select.ex index 6366bb712..3782db934 100644 --- a/lib/backpex/fields/select.ex +++ b/lib/backpex/fields/select.ex @@ -1,8 +1,8 @@ defmodule Backpex.Fields.Select do @config_schema [ options: [ - doc: "List of options or function that receives the assigns.", - type: {:or, [{:list, :any}, {:fun, 1}]}, + doc: "List of possibly grouped options or function that receives the assigns.", + type: {:or, [{:list, :any}, {:map, :any, :any}, {:fun, 1}]}, required: true ], prompt: [ @@ -127,6 +127,15 @@ defmodule Backpex.Fields.Select do end defp get_label(value, options) do + options = + Enum.map(options, fn {_label, value} = option -> + case value do + value when is_list(value) or is_map(value) -> value + _ -> option + end + end) + |> List.flatten() + case Enum.find(options, fn option -> value?(option, value) end) do nil -> value {label, _value} -> label From 4da3a2d82b2f19c9529c6e5a7f10f5c33dcdb25f Mon Sep 17 00:00:00 2001 From: Giacomo Mazzamuto Date: Wed, 17 Sep 2025 19:02:21 +0200 Subject: [PATCH 4/4] Fields.MultiSelect: add support for option groups Additionally, this fixes the `:options` field option not working when passed a list rather than a function. --- demo/lib/demo_web/live/user_live.ex | 6 ++- lib/backpex/fields/multi_select.ex | 80 ++++++++++++++++++++--------- lib/backpex/html/form.ex | 72 +++++++++++++++++++------- 3 files changed, 116 insertions(+), 42 deletions(-) diff --git a/demo/lib/demo_web/live/user_live.ex b/demo/lib/demo_web/live/user_live.ex index 463de73d1..4a0c1e905 100644 --- a/demo/lib/demo_web/live/user_live.ex +++ b/demo/lib/demo_web/live/user_live.ex @@ -196,7 +196,11 @@ defmodule DemoWeb.UserLive do permissions: %{ module: Backpex.Fields.MultiSelect, label: "Permissions", - options: fn _assigns -> [{"Delete", "delete"}, {"Edit", "edit"}, {"Show", "show"}] end + options: [ + {"Foo", "foo"}, + {"Item actions", [{"Delete", "delete"}, {"Edit", "edit"}, {"Show", "show"}]}, + {"Other actions", [{"Can send email", "can_send_email"}]} + ] } ] end diff --git a/lib/backpex/fields/multi_select.ex b/lib/backpex/fields/multi_select.ex index 6d4a68f76..19939073c 100644 --- a/lib/backpex/fields/multi_select.ex +++ b/lib/backpex/fields/multi_select.ex @@ -1,8 +1,8 @@ defmodule Backpex.Fields.MultiSelect do @config_schema [ options: [ - doc: "List of options or function that receives the assigns.", - type: {:or, [{:list, :any}, {:fun, 1}]}, + doc: "List of possibly grouped options or function that receives the assigns.", + type: {:or, [{:list, :any}, {:map, :any, :any}, {:fun, 1}]}, required: true ], prompt: [ @@ -66,18 +66,41 @@ defmodule Backpex.Fields.MultiSelect do %{assigns: %{field_options: field_options} = assigns} = socket options = - assigns - |> field_options.options.() + case Map.get(field_options, :options) do + options when is_function(options) -> options.(assigns) + options -> options + end + + options = + options |> Enum.map(fn {label, value} -> - {to_string(label), to_string(value)} + case value do + value when is_list(value) or is_map(value) -> + {to_string(label), Enum.map(value, fn {lab, val} -> {to_string(lab), to_string(val)} end)} + + _ -> + {to_string(label), to_string(value)} + end end) assign(socket, :options, options) end + defp flatten_options(options) do + Enum.map(options, fn {_label, value} = option -> + case value do + value when is_list(value) or is_map(value) -> value + _ -> option + end + end) + |> List.flatten() + end + defp assign_selected(socket) do %{assigns: %{type: type, options: options, item: item, name: name} = assigns} = socket + options = flatten_options(options) + selected_ids = if type == :form do case PhoenixForm.input_value(assigns.form, name) do @@ -99,6 +122,8 @@ defmodule Backpex.Fields.MultiSelect do defp maybe_assign_form(%{assigns: %{type: :form} = assigns} = socket) do %{selected: selected, options: options} = assigns + options = flatten_options(options) + show_select_all = length(selected) != length(options) socket @@ -156,21 +181,20 @@ defmodule Backpex.Fields.MultiSelect do @impl Phoenix.LiveComponent def handle_event("toggle-option", %{"id" => id}, socket) do - %{assigns: %{selected: selected, options: options, field_options: field_options}} = socket + %{assigns: %{selected: selected, options: options}} = socket - selected_item = Enum.find(selected, fn {_label, value} -> value == id end) + options = flatten_options(options) + + clicked_item = Enum.find(options, fn {_label, value} -> value == id end) new_selected = - if selected_item do - Enum.reject(selected, fn {_label, value} -> value == id end) + if clicked_item in selected do + selected -- [clicked_item] else - selected - |> Enum.reverse() - |> Kernel.then(&[Enum.find(options, fn {_label, value} -> value == id end) | &1]) - |> Enum.reverse() + selected ++ [clicked_item] end - show_select_all = length(new_selected) != length(field_options.options.(socket.assigns)) + show_select_all = length(new_selected) != length(options) socket |> assign(:selected, new_selected) @@ -180,13 +204,12 @@ defmodule Backpex.Fields.MultiSelect do @impl Phoenix.LiveComponent def handle_event("search", params, socket) do - %{assigns: %{name: name, field_options: field_options} = assigns} = socket + socket = assign_options(socket) + %{assigns: %{name: name, options: options} = assigns} = socket search_input = Map.get(params, "change[#{name}]_search") - options = - field_options.options.(assigns) - |> maybe_apply_search(search_input) + options = maybe_apply_search(options, search_input) socket |> assign(:options, options) @@ -196,9 +219,11 @@ defmodule Backpex.Fields.MultiSelect do @impl Phoenix.LiveComponent def handle_event("toggle-select-all", _params, socket) do - %{assigns: %{field_options: field_options, show_select_all: show_select_all} = assigns} = socket + %{assigns: %{options: options, show_select_all: show_select_all}} = socket - new_selected = if show_select_all, do: field_options.options.(assigns), else: [] + options = flatten_options(options) + + new_selected = if show_select_all, do: options, else: [] socket |> assign(:selected, new_selected) @@ -212,10 +237,19 @@ defmodule Backpex.Fields.MultiSelect do else search_input_downcase = String.downcase(search_input) - Enum.filter(options, fn {label, _value} -> - String.downcase(label) - |> String.contains?(search_input_downcase) + keep? = fn label -> String.downcase(label) |> String.contains?(search_input_downcase) end + + Enum.map(options, fn {label, value} -> + case value do + value when is_list(value) or is_map(value) -> + filtered = Enum.filter(value, fn {_lab, val} -> keep?.(val) end) + if not Enum.empty?(filtered), do: {label, filtered} + + _ -> + if keep?.(label), do: {label, value} + end end) + |> Enum.filter(& &1) end end diff --git a/lib/backpex/html/form.ex b/lib/backpex/html/form.ex index fa07e377e..93a293977 100644 --- a/lib/backpex/html/form.ex +++ b/lib/backpex/html/form.ex @@ -282,25 +282,14 @@ defmodule Backpex.HTML.Form do
- + label={label} + value={value} + event_target={@event_target} + field={@field} + selected={@selected} + />