Skip to content

Commit 789d1a0

Browse files
committed
feat: redesign product picker with search bar and server-side selection
Replace the phx-update="ignore" / native-checkbox approach with a server-driven model matching odyssey_select: selected_ids and search are managed in component assigns, product rows are <button> elements, and the parent form is notified via the existing trigger-input event. Visual changes: bordered card container, search input with icon, selected-count pill badge, blue checkmark for selected rows, hover state on unselected rows. No native checkboxes. Simplify OdysseyProductPicker JS hook to an empty mounted() — all interaction is now handled by LiveView event handlers.
1 parent 371fa5f commit 789d1a0

3 files changed

Lines changed: 221 additions & 69 deletions

File tree

assets/js/odyssey.js

Lines changed: 1 addition & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -68,35 +68,7 @@ const OdysseyHooks = {
6868
}
6969
},
7070
OdysseyProductPicker: {
71-
mounted () {
72-
this.el.addEventListener('click', (event) => {
73-
if (event.target.classList.contains('product-picker-checkbox')) {
74-
event.stopPropagation()
75-
}
76-
})
77-
78-
this.el.addEventListener('change', (event) => {
79-
if (event.target.classList.contains('product-picker-checkbox')) {
80-
event.preventDefault()
81-
event.stopPropagation()
82-
83-
const checkboxes = this.el.querySelectorAll('.product-picker-checkbox:checked')
84-
const selectedIds = Array.from(checkboxes).map(cb => cb.dataset.productId)
85-
86-
const hiddenInput = this.el.querySelector('input[type="hidden"]')
87-
hiddenInput.value = selectedIds.join(',')
88-
hiddenInput.dispatchEvent(new Event('input', { bubbles: true }))
89-
}
90-
})
91-
92-
this.handleEvent('update-product-selection', ({ field_id, value }) => {
93-
const hiddenInput = document.getElementById(field_id)
94-
if (hiddenInput) {
95-
hiddenInput.value = value
96-
hiddenInput.dispatchEvent(new Event('input', { bubbles: true }))
97-
}
98-
})
99-
}
71+
mounted () {}
10072
}
10173
}
10274

lib/peek_app_sdk/ui/odyssey/product_picker.ex

Lines changed: 131 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
defmodule PeekAppSDK.UI.Odyssey.ProductPicker do
22
@moduledoc """
33
A live component for selecting products with a toggle between "All" and "Specific".
4-
When "Specific" is selected, displays checkboxes for each product.
54
6-
Integrates with forms via a hidden input field and a JS hook.
5+
When "Specific" is selected, displays a searchable list where clicking a row
6+
toggles selection. Selected state is managed server-side (no JS checkbox hacks).
7+
8+
Integrates with forms via a hidden input field. The parent form is notified of
9+
changes via the global `trigger-input` phx event (wired up by `addOdysseyGlobalEvents`).
710
811
Products must conform to `%{id: string, name: string, color: string}` (atom keys).
912
Callers are responsible for mapping their data to this shape before passing it in.
@@ -27,16 +30,22 @@ defmodule PeekAppSDK.UI.Odyssey.ProductPicker do
2730

2831
@impl true
2932
def mount(socket) do
30-
{:ok, socket}
33+
{:ok,
34+
socket
35+
|> assign(:search, "")
36+
|> assign(:filtered_products, [])}
3137
end
3238

3339
@impl true
3440
def update(assigns, socket) do
3541
selected_ids = resolve_selected_ids(assigns)
36-
socket = assign(socket, Map.put(assigns, :selected_ids, selected_ids))
42+
search = Map.get(socket.assigns, :search, "")
3743

3844
socket =
3945
socket
46+
|> assign(assigns)
47+
|> assign(:selected_ids, selected_ids)
48+
|> assign(:filtered_products, filter_products(assigns[:products] || [], search))
4049
|> assign_new(:apply_to_mode, fn -> determine_mode(selected_ids) end)
4150

4251
{:ok, socket}
@@ -71,18 +80,25 @@ defmodule PeekAppSDK.UI.Odyssey.ProductPicker do
7180
defp determine_mode([]), do: "all"
7281
defp determine_mode(_), do: "specific"
7382

83+
defp filter_products(products, ""), do: products
84+
85+
defp filter_products(products, search) do
86+
query = String.downcase(search)
87+
Enum.filter(products, &String.contains?(String.downcase(&1.name), query))
88+
end
89+
7490
@impl true
7591
def render(assigns) do
7692
~H"""
77-
<div id={@id} phx-hook="OdysseyProductPicker" data-integration="product-picker">
93+
<div id={@id} data-integration="product-picker">
7894
<input
7995
type="hidden"
8096
name={@field.name}
8197
value={encode_selected_products(@selected_ids)}
8298
id={"#{@id}_hidden_field"}
8399
/>
84100
85-
<div class="space-y-4">
101+
<div class="space-y-3">
86102
<.odyssey_toggle_button
87103
options={[
88104
%{value: "all", label: @all_label},
@@ -95,29 +111,79 @@ defmodule PeekAppSDK.UI.Odyssey.ProductPicker do
95111
disabled={@disabled}
96112
/>
97113
98-
<div
99-
:if={@apply_to_mode == "specific"}
100-
class="space-y-2 pl-4 max-h-64 overflow-y-auto"
101-
phx-update="ignore"
102-
id={"#{@id}_checkboxes"}
103-
>
104-
<div :for={product <- @products} class="flex items-center gap-3">
105-
<input
106-
type="checkbox"
107-
id={"#{@id}_product_#{product.id}"}
108-
checked={product.id in @selected_ids}
109-
data-product-id={product.id}
110-
disabled={@disabled}
111-
class="checkbox checkbox-sm product-picker-checkbox"
112-
/>
113-
<div
114-
class="w-3 h-3 rounded-sm flex-shrink-0"
115-
style={"background-color: #{product.color}"}
116-
>
114+
<div :if={@apply_to_mode == "specific"} class="rounded-xl border border-zinc-200 bg-white shadow-sm overflow-hidden">
115+
<div class="px-3 pt-3 pb-2 border-b border-zinc-100">
116+
<div class="flex items-center gap-2 rounded-md border border-zinc-300 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">
117+
<svg
118+
class="w-4 h-4 opacity-70 shrink-0"
119+
viewBox="0 0 20 20"
120+
fill="currentColor"
121+
aria-hidden="true"
122+
>
123+
<path
124+
fill-rule="evenodd"
125+
d="M12.9 14.32a8 8 0 1 1 1.414-1.414l3.39 3.39a1 1 0 0 1-1.414 1.414l-3.39-3.39ZM14 8a6 6 0 1 0-12 0 6 6 0 0 0 12 0Z"
126+
clip-rule="evenodd"
127+
/>
128+
</svg>
129+
<input
130+
type="text"
131+
class="flex-1 bg-transparent outline-none text-sm"
132+
placeholder={@search_placeholder}
133+
value={@search}
134+
phx-keyup="search"
135+
phx-target={@myself}
136+
disabled={@disabled}
137+
/>
138+
<span
139+
:if={length(@selected_ids) > 0}
140+
class="text-xs font-medium text-blue-600 bg-blue-50 px-2 py-0.5 rounded-full shrink-0"
141+
>
142+
{length(@selected_ids)} selected
143+
</span>
117144
</div>
118-
<label for={"#{@id}_product_#{product.id}"} class="text-sm text-gray-700 cursor-pointer">
119-
{product.name}
120-
</label>
145+
</div>
146+
147+
<div class="max-h-64 overflow-y-auto py-1">
148+
<button
149+
:for={product <- @filtered_products}
150+
type="button"
151+
class={[
152+
"flex w-full items-center gap-3 px-4 py-2.5 text-sm text-left transition-colors",
153+
product.id in @selected_ids && "bg-blue-50",
154+
product.id not in @selected_ids && "hover:bg-zinc-50",
155+
@disabled && "pointer-events-none opacity-60"
156+
]}
157+
phx-click="toggle_product"
158+
phx-value-id={product.id}
159+
phx-target={@myself}
160+
>
161+
<div
162+
class="w-3 h-3 rounded-sm shrink-0"
163+
style={"background-color: #{product.color}"}
164+
/>
165+
<span class="flex-1 text-zinc-700">{product.name}</span>
166+
<svg
167+
:if={product.id in @selected_ids}
168+
class="w-4 h-4 text-blue-600 shrink-0"
169+
viewBox="0 0 20 20"
170+
fill="currentColor"
171+
aria-hidden="true"
172+
>
173+
<path
174+
fill-rule="evenodd"
175+
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"
176+
clip-rule="evenodd"
177+
/>
178+
</svg>
179+
</button>
180+
181+
<p
182+
:if={@filtered_products == [] && @search != ""}
183+
class="px-4 py-6 text-sm text-zinc-400 text-center"
184+
>
185+
No products match your search.
186+
</p>
121187
</div>
122188
</div>
123189
</div>
@@ -126,21 +192,47 @@ defmodule PeekAppSDK.UI.Odyssey.ProductPicker do
126192
end
127193

128194
@impl true
129-
def handle_event("toggle_apply_to", %{"value" => mode}, socket) do
195+
def handle_event("toggle_apply_to", %{"value" => "all"}, socket) do
196+
socket =
197+
socket
198+
|> assign(:apply_to_mode, "all")
199+
|> assign(:selected_ids, [])
200+
|> assign(:search, "")
201+
|> assign(:filtered_products, socket.assigns.products)
202+
|> push_event("trigger-input", %{field_id: "#{socket.assigns.id}_hidden_field"})
203+
204+
{:noreply, socket}
205+
end
206+
207+
def handle_event("toggle_apply_to", %{"value" => "specific"}, socket) do
208+
socket =
209+
socket
210+
|> assign(:apply_to_mode, "specific")
211+
|> assign(:search, "")
212+
|> assign(:filtered_products, socket.assigns.products)
213+
214+
{:noreply, socket}
215+
end
216+
217+
def handle_event("toggle_product", %{"id" => id}, socket) do
130218
selected_ids =
131-
case mode do
132-
"all" -> []
133-
"specific" -> socket.assigns.selected_ids
134-
end
219+
if id in socket.assigns.selected_ids,
220+
do: Enum.reject(socket.assigns.selected_ids, &(&1 == id)),
221+
else: [id | socket.assigns.selected_ids] |> Enum.reverse()
135222

136223
socket =
137224
socket
138-
|> assign(:apply_to_mode, mode)
139225
|> assign(:selected_ids, selected_ids)
140-
|> push_event("update-product-selection", %{
141-
field_id: "#{socket.assigns.id}_hidden_field",
142-
value: encode_selected_products(selected_ids)
143-
})
226+
|> push_event("trigger-input", %{field_id: "#{socket.assigns.id}_hidden_field"})
227+
228+
{:noreply, socket}
229+
end
230+
231+
def handle_event("search", %{"value" => value}, socket) do
232+
socket =
233+
socket
234+
|> assign(:search, value)
235+
|> assign(:filtered_products, filter_products(socket.assigns.products, value))
144236

145237
{:noreply, socket}
146238
end
@@ -169,12 +261,12 @@ defmodule PeekAppSDK.UI.Odyssey.ProductPicker do
169261
"""
170262
attr :field, :any, required: true, doc: "a Phoenix.HTML.FormField struct"
171263
attr :id, :string, doc: "component id, defaults to form_field_product_picker"
172-
173264
attr :products, :list, required: true, doc: "list of %{id: string, name: string, color: string} maps"
174265
attr :selected_ids, :list, doc: "list of pre-selected product IDs (auto-extracted from field value when omitted)"
175266
attr :all_label, :string, default: "All Products", doc: "label for the 'all' toggle option"
176267
attr :specific_label, :string, default: "Specific Products", doc: "label for the 'specific' toggle option"
177268
attr :label, :string, required: false, doc: "label for the toggle button"
269+
attr :search_placeholder, :string, default: "Search...", doc: "placeholder text for the search input"
178270
attr :disabled, :boolean, default: false, doc: "whether the picker is disabled"
179271

180272
def odyssey_product_picker(assigns) do

test/peek_app_sdk/ui/odyssey/product_picker_test.exs

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ defmodule PeekAppSDK.UI.Odyssey.ProductPickerTest do
3232
refute html =~ "Kayak Tour"
3333
end
3434

35-
test "renders with selected_ids in specific mode" do
35+
test "renders product list with checkmarks when in specific mode" do
3636
form = to_form(%{"whitelisted_products" => nil}, as: :campaign)
3737

3838
products = [
@@ -61,6 +61,94 @@ defmodule PeekAppSDK.UI.Odyssey.ProductPickerTest do
6161
assert html =~ "#33FF57"
6262
end
6363

64+
test "shows selected count badge when items are selected" do
65+
form = to_form(%{"whitelisted_products" => nil}, as: :campaign)
66+
67+
products = [
68+
%{id: "p1", name: "Tour A", color: "#111"},
69+
%{id: "p2", name: "Tour B", color: "#222"}
70+
]
71+
72+
html =
73+
render_component(
74+
fn assigns ->
75+
~H"""
76+
<.odyssey_product_picker
77+
field={@form[:whitelisted_products]}
78+
products={@products}
79+
selected_ids={["p1", "p2"]}
80+
/>
81+
"""
82+
end,
83+
%{form: form, products: products}
84+
)
85+
86+
assert html =~ "2 selected"
87+
end
88+
89+
test "does not show selected count badge when no items are selected" do
90+
form = to_form(%{"whitelisted_products" => nil}, as: :campaign)
91+
92+
products = [%{id: "p1", name: "Tour A", color: "#111"}]
93+
94+
html =
95+
render_component(
96+
fn assigns ->
97+
~H"""
98+
<.odyssey_product_picker
99+
field={@form[:whitelisted_products]}
100+
products={@products}
101+
selected_ids={[]}
102+
/>
103+
"""
104+
end,
105+
%{form: form, products: products}
106+
)
107+
108+
refute html =~ "selected"
109+
end
110+
111+
test "renders search input when in specific mode" do
112+
form = to_form(%{"whitelisted_products" => nil}, as: :campaign)
113+
114+
html =
115+
render_component(
116+
fn assigns ->
117+
~H"""
118+
<.odyssey_product_picker
119+
field={@form[:whitelisted_products]}
120+
products={[]}
121+
selected_ids={["p1"]}
122+
/>
123+
"""
124+
end,
125+
%{form: form}
126+
)
127+
128+
assert html =~ ~r/placeholder="Search\.\.\."/
129+
end
130+
131+
test "renders search input with custom placeholder" do
132+
form = to_form(%{"whitelisted_products" => nil}, as: :campaign)
133+
134+
html =
135+
render_component(
136+
fn assigns ->
137+
~H"""
138+
<.odyssey_product_picker
139+
field={@form[:whitelisted_products]}
140+
products={[]}
141+
selected_ids={["p1"]}
142+
search_placeholder="Find a product..."
143+
/>
144+
"""
145+
end,
146+
%{form: form}
147+
)
148+
149+
assert html =~ ~r/placeholder="Find a product\.\.\."/
150+
end
151+
64152
test "renders with custom toggle labels" do
65153
form = to_form(%{"products" => nil}, as: :test)
66154

0 commit comments

Comments
 (0)