Skip to content

Commit 230ad74

Browse files
committed
feat: allow odyssey_select to take a form field
1 parent 8c16890 commit 230ad74

2 files changed

Lines changed: 187 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111

1212
- `odyssey_select` now accepts an optional `label` attr, wrapping the component in a fieldset with label styling consistent with other Odyssey form components.
13+
- `odyssey_select` now supports form-integrated mode when a `field` attr is provided (like `odyssey_toggle_button`). Manages a hidden input for form binding with single and multiple selection via the `multiple` attr. Standalone mode (via `on_select`) remains unchanged.
1314

1415
### Changed
1516

lib/peek_app_sdk/ui/odyssey/select.ex

Lines changed: 186 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,37 @@ defmodule PeekAppSDK.UI.Odyssey.Select do
55
Each item must have an `id` and `name` field. Color is optional and can be
66
specified via the `color_field` assign (defaults to `:color_hex`).
77
8+
Supports two modes:
9+
- **Standalone**: fires an event via `on_select` when an item is selected
10+
- **Form-integrated**: when a `field` is provided, manages a hidden input for form binding.
11+
Supports single and multiple selection via the `multiple` attr.
12+
813
## Examples
914
15+
Standalone:
16+
1017
<.odyssey_select
1118
id="activity-picker"
1219
items={@activities}
1320
on_select={:activity_selected}
1421
title="Select Activity"
1522
/>
1623
24+
Form-integrated (single select):
25+
26+
<.odyssey_select
27+
field={@form[:category_id]}
28+
items={@categories}
29+
title="Select Category"
30+
/>
31+
32+
Form-integrated (multiple select):
33+
1734
<.odyssey_select
18-
id="ticket-picker"
19-
items={@tickets}
20-
excluded_ids={@used_ids}
21-
on_select={:ticket_selected}
22-
context={@index}
23-
color_field={:colorHex}
24-
title="+ Add Another Ticket"
35+
field={@form[:tag_ids]}
36+
items={@tags}
37+
title="Select Tags"
38+
multiple={true}
2539
/>
2640
"""
2741

@@ -37,13 +51,25 @@ defmodule PeekAppSDK.UI.Odyssey.Select do
3751

3852
@impl true
3953
def update(assigns, socket) do
54+
socket = assign(socket, assigns)
55+
56+
socket =
57+
if socket.assigns[:field] do
58+
selected_ids = resolve_selected_ids(socket.assigns)
59+
assign(socket, :selected_ids, selected_ids)
60+
else
61+
assign_new(socket, :selected_ids, fn -> [] end)
62+
end
63+
4064
{:ok,
4165
socket
42-
|> assign(assigns)
4366
|> assign_new(:title, fn -> "Select Item" end)
4467
|> assign_new(:excluded_ids, fn -> [] end)
4568
|> assign_new(:color_field, fn -> :color_hex end)
46-
|> assign_new(:context, fn -> nil end)}
69+
|> assign_new(:context, fn -> nil end)
70+
|> assign_new(:field, fn -> nil end)
71+
|> assign_new(:multiple, fn -> false end)
72+
|> assign_new(:on_select, fn -> nil end)}
4773
end
4874

4975
@impl true
@@ -56,14 +82,22 @@ defmodule PeekAppSDK.UI.Odyssey.Select do
5682
phx-target={@myself}
5783
phx-hook="OdysseySelect"
5884
>
85+
<input
86+
:if={@field}
87+
type="hidden"
88+
id={"#{@id}_hidden_field"}
89+
name={@field.name}
90+
value={encode_selected(@selected_ids)}
91+
/>
92+
5993
<button
6094
type="button"
6195
class="inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm font-medium border-zinc-300 bg-white hover:bg-zinc-50 cursor-pointer"
6296
phx-click="toggle"
6397
phx-target={@myself}
6498
aria-expanded={@open}
6599
>
66-
<span>{@title}</span>
100+
<span>{button_text(@selected_ids, @items, @title, @multiple)}</span>
67101
<svg class="w-4 h-4 text-zinc-500" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
68102
<path
69103
fill-rule="evenodd"
@@ -102,7 +136,10 @@ defmodule PeekAppSDK.UI.Odyssey.Select do
102136
<button
103137
:for={item <- filtered_items(@items, @search, @excluded_ids)}
104138
type="button"
105-
class="text-left flex w-full items-center gap-2 px-3 py-2 text-sm text-zinc-700 hover:bg-zinc-50 cursor-pointer"
139+
class={[
140+
"text-left flex w-full items-center gap-2 px-3 py-2 text-sm text-zinc-700 hover:bg-zinc-50 cursor-pointer",
141+
selected?(item, @selected_ids) && "bg-zinc-50 font-medium"
142+
]}
106143
phx-click="select"
107144
phx-value-id={item.id}
108145
phx-target={@myself}
@@ -113,7 +150,19 @@ defmodule PeekAppSDK.UI.Odyssey.Select do
113150
aria-hidden="true"
114151
style={"background-color: #{get_color(item, @color_field)}"}
115152
/>
116-
<span>{item.name}</span>
153+
<span class="flex-1">{item.name}</span>
154+
<svg
155+
:if={selected?(item, @selected_ids)}
156+
class="w-4 h-4 text-blue-600"
157+
viewBox="0 0 20 20"
158+
fill="currentColor"
159+
>
160+
<path
161+
fill-rule="evenodd"
162+
d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z"
163+
clip-rule="evenodd"
164+
/>
165+
</svg>
117166
</button>
118167
</div>
119168
</div>
@@ -135,13 +184,85 @@ defmodule PeekAppSDK.UI.Odyssey.Select do
135184
end
136185

137186
def handle_event("select", %{"id" => id}, socket) do
187+
if socket.assigns.field do
188+
handle_form_select(id, socket)
189+
else
190+
handle_standalone_select(id, socket)
191+
end
192+
end
193+
194+
defp handle_standalone_select(id, socket) do
138195
%{on_select: on_select, context: context} = socket.assigns
139196
send(self(), {on_select, context, id})
140197
{:noreply, socket |> assign(:open, false) |> assign(:search, "")}
141198
end
142199

200+
defp handle_form_select(id, socket) do
201+
id_str = to_string(id)
202+
203+
selected_ids =
204+
if socket.assigns.multiple do
205+
if selected_by_id?(id_str, socket.assigns.selected_ids),
206+
do: Enum.reject(socket.assigns.selected_ids, &(to_string(&1) == id_str)),
207+
else: Enum.reverse([id_str | Enum.reverse(socket.assigns.selected_ids)])
208+
else
209+
[id_str]
210+
end
211+
212+
socket =
213+
socket
214+
|> assign(:selected_ids, selected_ids)
215+
|> push_event("trigger-input", %{field_id: "#{socket.assigns.id}_hidden_field"})
216+
217+
socket =
218+
if socket.assigns.multiple,
219+
do: socket,
220+
else: socket |> assign(:open, false) |> assign(:search, "")
221+
222+
{:noreply, socket}
223+
end
224+
143225
defp get_color(item, field), do: Map.get(item, field)
144226

227+
defp selected?(item, selected_ids),
228+
do: selected_by_id?(to_string(item.id), selected_ids)
229+
230+
defp selected_by_id?(id_str, selected_ids),
231+
do: id_str in Enum.map(selected_ids, &to_string/1)
232+
233+
defp button_text([], _items, title, _multiple), do: title
234+
defp button_text(_ids, _items, title, true), do: title
235+
236+
defp button_text([id | _], items, title, false) do
237+
case Enum.find(items, &(to_string(&1.id) == to_string(id))) do
238+
nil -> title
239+
item -> item.name
240+
end
241+
end
242+
243+
defp encode_selected([]), do: ""
244+
defp encode_selected(ids), do: Enum.join(ids, ",")
245+
246+
defp resolve_selected_ids(%{selected_ids: ids}) when is_list(ids), do: ids
247+
248+
defp resolve_selected_ids(%{field: field, multiple: true}) do
249+
case field.value do
250+
nil -> []
251+
"" -> []
252+
ids when is_list(ids) -> Enum.map(ids, &to_string/1)
253+
ids when is_binary(ids) -> String.split(ids, ",", trim: true)
254+
id -> [to_string(id)]
255+
end
256+
end
257+
258+
defp resolve_selected_ids(%{field: field}) do
259+
case field.value do
260+
nil -> []
261+
"" -> []
262+
id -> [to_string(id)]
263+
end
264+
end
265+
145266
defp filtered_items(items, search, excluded_ids) do
146267
excluded_ids = Enum.map(excluded_ids, &to_string/1)
147268

@@ -159,24 +280,75 @@ defmodule PeekAppSDK.UI.Odyssey.Select do
159280
@doc """
160281
Renders an odyssey_select dropdown component.
161282
283+
Supports two modes:
284+
- **Standalone**: fires an event via `on_select` when an item is selected
285+
- **Form-integrated**: when a `field` is provided, manages a hidden input for form binding
286+
162287
## Examples
163288
289+
Standalone:
290+
164291
<.odyssey_select
165292
id="activity-picker"
166293
items={@activities}
167294
on_select={:activity_selected}
168295
title="Select Activity"
169296
/>
297+
298+
Form-integrated (single select):
299+
300+
<.odyssey_select
301+
field={@form[:category_id]}
302+
items={@categories}
303+
title="Select Category"
304+
/>
305+
306+
Form-integrated (multiple select):
307+
308+
<.odyssey_select
309+
field={@form[:tag_ids]}
310+
items={@tags}
311+
title="Select Tags"
312+
multiple={true}
313+
/>
170314
"""
171-
attr :id, :string, required: true
315+
attr :id, :string, required: false
172316
attr :items, :list, required: true, doc: "list of items with :id and :name fields"
173-
attr :on_select, :atom, required: true, doc: "message atom sent when item is selected"
317+
attr :on_select, :atom, required: false, doc: "message atom sent when item is selected (standalone mode)"
174318
attr :title, :string, default: "Select Item", doc: "button text"
175319
attr :excluded_ids, :list, default: [], doc: "list of ids to exclude"
176320
attr :color_field, :atom, default: :color_hex, doc: "atom for the color field on items"
177321
attr :context, :any, default: nil, doc: "additional context included in the message"
178322
attr :label, :string, required: false, doc: "optional label to wrap the select in a fieldset"
323+
attr :field, Phoenix.HTML.FormField, required: false, doc: "form field for form-integrated mode"
324+
attr :multiple, :boolean, default: false, doc: "allow multiple selections (form mode only)"
325+
attr :selected_ids, :list, required: false, doc: "override for pre-selected IDs (form mode only)"
326+
327+
# Form-integrated mode
328+
def odyssey_select(%{field: _field} = assigns) do
329+
assigns =
330+
assigns
331+
|> assign_new(:id, fn %{field: field} ->
332+
"odyssey_select_#{field.form.name}_#{field.field}"
333+
end)
334+
|> assign_new(:label, fn -> nil end)
335+
|> assign(:module, __MODULE__)
336+
337+
~H"""
338+
<%= if @label do %>
339+
<fieldset class="fieldset mb-2">
340+
<div>
341+
<span class="label mb-1 block">{@label}</span>
342+
<.live_component {assigns} />
343+
</div>
344+
</fieldset>
345+
<% else %>
346+
<.live_component {assigns} />
347+
<% end %>
348+
"""
349+
end
179350

351+
# Standalone mode
180352
def odyssey_select(assigns) do
181353
assigns =
182354
assigns

0 commit comments

Comments
 (0)