Skip to content

Commit ed7727c

Browse files
authored
Merge pull request #1528 from gmazzamuto/select-enhancements
Add support for option groups in `Select` and `MultiSelect` fields
2 parents f39bb0c + 9982d62 commit ed7727c

6 files changed

Lines changed: 112 additions & 55 deletions

File tree

demo/lib/demo/address.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ defmodule Demo.Address do
1111
field :street, :string
1212
field :zip, :string
1313
field :city, :string
14-
field :country, Ecto.Enum, values: [:de, :at, :ch]
14+
field :country, Ecto.Enum, values: [:de, :at, :ch, :us, :ca]
1515
field :full_address, :string, virtual: true
1616

1717
timestamps()

demo/lib/demo_web/live/address_live.ex

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,10 @@ defmodule DemoWeb.AddressLive do
3636
country: %{
3737
module: Backpex.Fields.Select,
3838
label: "Country",
39-
options: [Germany: "de", Austria: "at", Switzerland: "ch"]
39+
options: %{
40+
"Europe" => [Germany: "de", Austria: "at", Switzerland: "ch"],
41+
"North America" => [USA: "us", Canada: "ca"]
42+
}
4043
}
4144
]
4245
end

demo/lib/demo_web/live/user_live.ex

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,11 @@ defmodule DemoWeb.UserLive do
197197
permissions: %{
198198
module: Backpex.Fields.MultiSelect,
199199
label: "Permissions",
200-
options: fn _assigns -> [{"Delete", "delete"}, {"Edit", "edit"}, {"Show", "show"}] end
200+
options: [
201+
{"Can access admin panel", "can_access_admin_panel"},
202+
{"Item actions", [{"Delete", "delete"}, {"Edit", "edit"}, {"Show", "show"}]},
203+
{"Other actions", [{"Can send email", "can_send_email"}]}
204+
]
201205
}
202206
]
203207
end

lib/backpex/fields/multi_select.ex

Lines changed: 72 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
defmodule Backpex.Fields.MultiSelect do
22
@config_schema [
33
options: [
4-
doc: "List of options or function that receives the assigns.",
5-
type: {:or, [{:list, :any}, {:fun, 1}]},
4+
doc: "List of possibly grouped options or function that receives the assigns.",
5+
type: {:or, [{:list, :any}, {:map, :any, :any}, {:fun, 1}]},
66
required: true
77
],
88
prompt: [
@@ -66,50 +66,64 @@ defmodule Backpex.Fields.MultiSelect do
6666
%{assigns: %{field_options: field_options} = assigns} = socket
6767

6868
options =
69-
assigns
70-
|> field_options.options.()
69+
case Map.get(field_options, :options) do
70+
options when is_function(options) -> options.(assigns)
71+
options -> options
72+
end
73+
74+
options =
75+
options
7176
|> Enum.map(fn {label, value} ->
72-
{to_string(label), to_string(value)}
77+
case value do
78+
value when is_list(value) or is_map(value) ->
79+
{to_string(label), Enum.map(value, fn {lab, val} -> {to_string(lab), to_string(val)} end)}
80+
81+
value ->
82+
{to_string(label), to_string(value)}
83+
end
7384
end)
7485

7586
assign(socket, :options, options)
7687
end
7788

89+
defp flatten_options(options) do
90+
Enum.map(options, fn {_label, value} = option ->
91+
case value do
92+
value when is_list(value) or is_map(value) -> value
93+
_value -> option
94+
end
95+
end)
96+
|> List.flatten()
97+
end
98+
7899
defp assign_selected(socket) do
79100
%{assigns: %{type: type, options: options, item: item, name: name} = assigns} = socket
80101

102+
options = flatten_options(options)
103+
81104
selected_ids =
82105
if type == :form do
83-
values =
84-
case PhoenixForm.input_value(assigns.form, name) do
85-
value when is_binary(value) -> [value]
86-
value when is_list(value) -> value
87-
_value -> []
88-
end
89-
90-
Enum.map(values, &to_string/1)
106+
case PhoenixForm.input_value(assigns.form, name) do
107+
value when is_binary(value) -> [value]
108+
value when is_list(value) -> value
109+
_value -> []
110+
end
91111
else
92-
value = Map.get(item, name)
93-
94-
if value, do: value, else: []
112+
Map.get(item, name) || []
95113
end
96114

97-
selected =
98-
Enum.reduce(options, [], fn {_label, value} = option, acc ->
99-
if value in selected_ids do
100-
[option | acc]
101-
else
102-
acc
103-
end
104-
end)
105-
|> Enum.reverse()
115+
selected_ids = Enum.map(selected_ids, &to_string/1)
116+
117+
selected = Enum.filter(options, fn {_label, value} -> value in selected_ids end)
106118

107119
assign(socket, :selected, selected)
108120
end
109121

110122
defp maybe_assign_form(%{assigns: %{type: :form} = assigns} = socket) do
111123
%{selected: selected, options: options} = assigns
112124

125+
options = flatten_options(options)
126+
113127
show_select_all = length(selected) != length(options)
114128

115129
socket
@@ -167,21 +181,20 @@ defmodule Backpex.Fields.MultiSelect do
167181

168182
@impl Phoenix.LiveComponent
169183
def handle_event("toggle-option", %{"id" => id}, socket) do
170-
%{assigns: %{selected: selected, options: options, field_options: field_options}} = socket
184+
%{assigns: %{selected: selected, options: options}} = socket
171185

172-
selected_item = Enum.find(selected, fn {_label, value} -> value == id end)
186+
options = flatten_options(options)
187+
188+
clicked_item = Enum.find(options, fn {_label, value} -> value == id end)
173189

174190
new_selected =
175-
if selected_item do
176-
Enum.reject(selected, fn {_label, value} -> value == id end)
191+
if clicked_item in selected do
192+
selected -- [clicked_item]
177193
else
178-
selected
179-
|> Enum.reverse()
180-
|> Kernel.then(&[Enum.find(options, fn {_label, value} -> value == id end) | &1])
181-
|> Enum.reverse()
194+
[clicked_item] ++ selected
182195
end
183196

184-
show_select_all = length(new_selected) != length(field_options.options.(socket.assigns))
197+
show_select_all = length(new_selected) != length(options)
185198

186199
socket
187200
|> assign(:selected, new_selected)
@@ -191,13 +204,12 @@ defmodule Backpex.Fields.MultiSelect do
191204

192205
@impl Phoenix.LiveComponent
193206
def handle_event("search", params, socket) do
194-
%{assigns: %{name: name, field_options: field_options} = assigns} = socket
207+
socket = assign_options(socket)
208+
%{assigns: %{name: name, options: options}} = socket
195209

196210
search_input = Map.get(params, "change[#{name}]_search")
197211

198-
options =
199-
field_options.options.(assigns)
200-
|> maybe_apply_search(search_input)
212+
options = apply_search(options, search_input)
201213

202214
socket
203215
|> assign(:options, options)
@@ -207,27 +219,37 @@ defmodule Backpex.Fields.MultiSelect do
207219

208220
@impl Phoenix.LiveComponent
209221
def handle_event("toggle-select-all", _params, socket) do
210-
%{assigns: %{field_options: field_options, show_select_all: show_select_all} = assigns} = socket
222+
%{assigns: %{options: options, show_select_all: show_select_all}} = socket
211223

212-
new_selected = if show_select_all, do: field_options.options.(assigns), else: []
224+
options = flatten_options(options)
225+
226+
new_selected = if show_select_all, do: options, else: []
213227

214228
socket
215229
|> assign(:selected, new_selected)
216230
|> assign(:show_select_all, not show_select_all)
217231
|> noreply()
218232
end
219233

220-
defp maybe_apply_search(options, search_input) do
221-
if String.trim(search_input) == "" do
222-
options
223-
else
224-
search_input_downcase = String.downcase(search_input)
234+
defp apply_search(options, search_input) do
235+
Enum.map(options, fn {label, value} -> filter_option(label, value, search_input) end) |> Enum.filter(& &1)
236+
end
225237

226-
Enum.filter(options, fn {label, _value} ->
227-
String.downcase(label)
228-
|> String.contains?(search_input_downcase)
229-
end)
230-
end
238+
defp filter_option(label, value, search_input) when is_list(value) or is_map(value) do
239+
search_input_downcase = String.downcase(search_input)
240+
241+
filtered =
242+
Enum.filter(value, fn {_label, _value} -> String.downcase(label) |> String.contains?(search_input_downcase) end)
243+
244+
if not Enum.empty?(filtered), do: {label, filtered}
245+
end
246+
247+
defp filter_option(label, _value, search_input) do
248+
search_input_downcase = String.downcase(search_input)
249+
250+
label
251+
|> String.downcase()
252+
|> String.contains?(search_input_downcase)
231253
end
232254

233255
defp prompt(assigns, field_options) do

lib/backpex/fields/select.ex

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
defmodule Backpex.Fields.Select do
22
@config_schema [
33
options: [
4-
doc: "List of options or function that receives the assigns.",
5-
type: {:or, [{:list, :any}, {:fun, 1}]},
4+
doc: "List of possibly grouped options or function that receives the assigns.",
5+
type: {:or, [{:list, :any}, {:map, :any, :any}, {:fun, 1}]},
66
required: true
77
],
88
prompt: [
@@ -128,6 +128,15 @@ defmodule Backpex.Fields.Select do
128128
end
129129

130130
defp get_label(value, options) do
131+
options =
132+
Enum.map(options, fn {_label, value} = option ->
133+
case value do
134+
value when is_list(value) or is_map(value) -> value
135+
_value -> option
136+
end
137+
end)
138+
|> List.flatten()
139+
131140
case Enum.find(options, fn option -> value?(option, value) end) do
132141
nil -> value
133142
{label, _value} -> label

lib/backpex/html/form.ex

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,25 @@ defmodule Backpex.HTML.Form do
435435
attr :selected, :list, required: true
436436
attr :event_target, :any, required: true
437437

438+
defp multi_select_option(%{value: value} = assigns) when is_list(value) or is_map(value) do
439+
~H"""
440+
<div class="not-first:mt-2">
441+
<span class="font-medium">{@label}</span>
442+
<div class="ml-4">
443+
<.multi_select_option
444+
:for={{lab, val} <- @value}
445+
class="mt-2"
446+
label={lab}
447+
value={val}
448+
event_target={@event_target}
449+
field={@field}
450+
selected={@selected}
451+
/>
452+
</div>
453+
</div>
454+
"""
455+
end
456+
438457
defp multi_select_option(assigns) do
439458
~H"""
440459
<label

0 commit comments

Comments
 (0)