Skip to content

Commit e5872d4

Browse files
committed
Add odyssey_product_picker component with JS hook
A reusable product picker with all/specific toggle and checkbox selection. Includes OdysseyProductPicker JS hook for client-side form sync.
1 parent 89ee3df commit e5872d4

4 files changed

Lines changed: 365 additions & 0 deletions

File tree

assets/js/odyssey.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,37 @@ const OdysseyHooks = {
3535
})
3636
}
3737
}
38+
},
39+
OdysseyProductPicker: {
40+
mounted () {
41+
this.el.addEventListener('click', (event) => {
42+
if (event.target.classList.contains('product-picker-checkbox')) {
43+
event.stopPropagation()
44+
}
45+
})
46+
47+
this.el.addEventListener('change', (event) => {
48+
if (event.target.classList.contains('product-picker-checkbox')) {
49+
event.preventDefault()
50+
event.stopPropagation()
51+
52+
const checkboxes = this.el.querySelectorAll('.product-picker-checkbox:checked')
53+
const selectedIds = Array.from(checkboxes).map(cb => cb.dataset.productId)
54+
55+
const hiddenInput = this.el.querySelector('input[type="hidden"]')
56+
hiddenInput.value = selectedIds.join(',')
57+
hiddenInput.dispatchEvent(new Event('input', { bubbles: true }))
58+
}
59+
})
60+
61+
this.handleEvent('update-product-selection', ({ field_id, value }) => {
62+
const hiddenInput = document.getElementById(field_id)
63+
if (hiddenInput) {
64+
hiddenInput.value = value
65+
hiddenInput.dispatchEvent(new Event('input', { bubbles: true }))
66+
}
67+
})
68+
}
3869
}
3970
}
4071

lib/peek_app_sdk/ui/odyssey.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ defmodule PeekAppSDK.UI.Odyssey do
1212
defdelegate odyssey_icon(assigns), to: PeekAppSDK.UI.Odyssey.Icon
1313
defdelegate odyssey_tabs(assigns), to: PeekAppSDK.UI.Odyssey.Tabs
1414
defdelegate odyssey_toggle_button(assigns), to: PeekAppSDK.UI.Odyssey.ToggleButton
15+
defdelegate odyssey_product_picker(assigns), to: PeekAppSDK.UI.Odyssey.ProductPicker
1516
defdelegate odyssey_prefix_input(assigns), to: PeekAppSDK.UI.Odyssey.PrefixInput
1617
defdelegate odyssey_date_picker(assigns), to: PeekAppSDK.UI.Odyssey.DatePicker
1718
defdelegate odyssey_tooltip(assigns), to: PeekAppSDK.UI.Odyssey.Tooltip
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
defmodule PeekAppSDK.UI.Odyssey.ProductPicker do
2+
@moduledoc """
3+
A live component for selecting products with a toggle between "All" and "Specific".
4+
When "Specific" is selected, displays checkboxes for each product.
5+
6+
Integrates with forms via a hidden input field and a JS hook.
7+
8+
## Examples
9+
10+
<.odyssey_product_picker
11+
field={@form[:whitelisted_products]}
12+
products={@products}
13+
selected_ids={@selected_ids}
14+
/>
15+
"""
16+
17+
use Phoenix.LiveComponent
18+
use Phoenix.Component
19+
20+
import PeekAppSDK.UI.Odyssey.ToggleButton, only: [odyssey_toggle_button: 1]
21+
22+
@impl true
23+
def mount(socket) do
24+
{:ok, socket}
25+
end
26+
27+
@impl true
28+
def update(assigns, socket) do
29+
socket = assign(socket, assigns)
30+
31+
socket =
32+
socket
33+
|> assign_new(:apply_to_mode, fn -> determine_mode(socket.assigns.selected_ids) end)
34+
35+
{:ok, socket}
36+
end
37+
38+
defp determine_mode([]), do: "all"
39+
defp determine_mode(_), do: "specific"
40+
41+
@impl true
42+
def render(assigns) do
43+
~H"""
44+
<div id={@id} phx-hook="OdysseyProductPicker" data-integration="product-picker">
45+
<input
46+
type="hidden"
47+
name={@field.name}
48+
value={encode_selected_products(@selected_ids)}
49+
id={"#{@id}_hidden_field"}
50+
/>
51+
52+
<div class="space-y-4">
53+
<.odyssey_toggle_button
54+
options={[
55+
%{value: "all", label: @all_label},
56+
%{value: "specific", label: @specific_label}
57+
]}
58+
selected={@apply_to_mode}
59+
on_change="toggle_apply_to"
60+
phx-target={@myself}
61+
label={@label}
62+
disabled={@disabled}
63+
/>
64+
65+
<div
66+
:if={@apply_to_mode == "specific"}
67+
class="space-y-2 pl-4 max-h-64 overflow-y-auto"
68+
phx-update="ignore"
69+
id={"#{@id}_checkboxes"}
70+
>
71+
<div :for={product <- @products} class="flex items-center gap-3">
72+
<input
73+
type="checkbox"
74+
id={"#{@id}_product_#{product.id}"}
75+
checked={product.id in @selected_ids}
76+
data-product-id={product.id}
77+
disabled={@disabled}
78+
class="checkbox checkbox-sm product-picker-checkbox"
79+
/>
80+
<div
81+
class="w-3 h-3 rounded-sm flex-shrink-0"
82+
style={"background-color: #{product.color_hex}"}
83+
>
84+
</div>
85+
<label for={"#{@id}_product_#{product.id}"} class="text-sm text-gray-700 cursor-pointer">
86+
{product.name}
87+
</label>
88+
</div>
89+
</div>
90+
</div>
91+
</div>
92+
"""
93+
end
94+
95+
@impl true
96+
def handle_event("toggle_apply_to", %{"value" => mode}, socket) do
97+
selected_ids =
98+
case mode do
99+
"all" -> []
100+
"specific" -> socket.assigns.selected_ids
101+
end
102+
103+
socket =
104+
socket
105+
|> assign(:apply_to_mode, mode)
106+
|> assign(:selected_ids, selected_ids)
107+
|> push_event("update-product-selection", %{
108+
field_id: "#{socket.assigns.id}_hidden_field",
109+
value: encode_selected_products(selected_ids)
110+
})
111+
112+
{:noreply, socket}
113+
end
114+
115+
defp encode_selected_products([]), do: ""
116+
defp encode_selected_products(ids), do: Enum.join(ids, ",")
117+
118+
@doc """
119+
Renders a product picker component.
120+
121+
## Examples
122+
123+
<.odyssey_product_picker
124+
field={@form[:whitelisted_products]}
125+
products={@products}
126+
selected_ids={["prod_1", "prod_2"]}
127+
/>
128+
"""
129+
attr :field, :any, required: true, doc: "a Phoenix.HTML.FormField struct"
130+
attr :id, :string, doc: "component id, defaults to form_field_product_picker"
131+
attr :products, :list, required: true, doc: "list of %{id, name, color_hex} maps"
132+
attr :selected_ids, :list, default: [], doc: "list of pre-selected product IDs"
133+
attr :all_label, :string, default: "All Products", doc: "label for the 'all' toggle option"
134+
attr :specific_label, :string, default: "Specific Products", doc: "label for the 'specific' toggle option"
135+
attr :label, :string, required: false, doc: "label for the toggle button"
136+
attr :disabled, :boolean, default: false, doc: "whether the picker is disabled"
137+
138+
def odyssey_product_picker(assigns) do
139+
assigns =
140+
assigns
141+
|> assign_new(:id, fn %{field: field} ->
142+
"#{field.form.name}_#{field.field}_product_picker"
143+
end)
144+
|> assign_new(:label, fn -> nil end)
145+
|> assign(:module, __MODULE__)
146+
147+
~H"""
148+
<.live_component {assigns} />
149+
"""
150+
end
151+
end
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
defmodule PeekAppSDK.UI.Odyssey.ProductPickerTest do
2+
use ExUnit.Case, async: true
3+
4+
import Phoenix.Component
5+
import Phoenix.LiveViewTest
6+
import PeekAppSDK.UI.Odyssey
7+
8+
describe "odyssey_product_picker/1" do
9+
test "renders with products and no selection (all mode)" do
10+
form = to_form(%{"whitelisted_products" => nil}, as: :campaign)
11+
12+
products = [
13+
%{id: "p1", name: "Kayak Tour", color_hex: "#FF5733"},
14+
%{id: "p2", name: "Snorkel Trip", color_hex: "#33FF57"}
15+
]
16+
17+
html =
18+
render_component(
19+
fn assigns ->
20+
~H"""
21+
<.form for={@form}>
22+
<.odyssey_product_picker field={@form[:whitelisted_products]} products={@products} />
23+
</.form>
24+
"""
25+
end,
26+
%{form: form, products: products}
27+
)
28+
29+
assert html =~ ~r/<input[^>]*type="hidden"[^>]*name="campaign\[whitelisted_products\]"/
30+
assert html =~ "All Products"
31+
assert html =~ "Specific Products"
32+
refute html =~ "Kayak Tour"
33+
end
34+
35+
test "renders with selected_ids in specific mode" do
36+
form = to_form(%{"whitelisted_products" => nil}, as: :campaign)
37+
38+
products = [
39+
%{id: "p1", name: "Kayak Tour", color_hex: "#FF5733"},
40+
%{id: "p2", name: "Snorkel Trip", color_hex: "#33FF57"}
41+
]
42+
43+
html =
44+
render_component(
45+
fn assigns ->
46+
~H"""
47+
<.odyssey_product_picker
48+
field={@form[:whitelisted_products]}
49+
products={@products}
50+
selected_ids={["p1"]}
51+
/>
52+
"""
53+
end,
54+
%{form: form, products: products}
55+
)
56+
57+
assert html =~ "Kayak Tour"
58+
assert html =~ "Snorkel Trip"
59+
assert html =~ ~r/value="p1"/
60+
assert html =~ "#FF5733"
61+
assert html =~ "#33FF57"
62+
end
63+
64+
test "renders with custom toggle labels" do
65+
form = to_form(%{"products" => nil}, as: :test)
66+
67+
html =
68+
render_component(
69+
fn assigns ->
70+
~H"""
71+
<.odyssey_product_picker
72+
field={@form[:products]}
73+
products={[]}
74+
all_label="All Events"
75+
specific_label="Specific Events"
76+
/>
77+
"""
78+
end,
79+
%{form: form}
80+
)
81+
82+
assert html =~ "All Events"
83+
assert html =~ "Specific Events"
84+
end
85+
86+
test "renders disabled state" do
87+
form = to_form(%{"products" => nil}, as: :test)
88+
89+
products = [%{id: "p1", name: "Tour", color_hex: "#000"}]
90+
91+
html =
92+
render_component(
93+
fn assigns ->
94+
~H"""
95+
<.odyssey_product_picker
96+
field={@form[:products]}
97+
products={@products}
98+
selected_ids={["p1"]}
99+
disabled={true}
100+
/>
101+
"""
102+
end,
103+
%{form: form, products: products}
104+
)
105+
106+
assert html =~ "disabled"
107+
end
108+
109+
test "generates unique component ID based on form field" do
110+
form = to_form(%{"activity_ids" => nil}, as: :booking)
111+
112+
html =
113+
render_component(
114+
fn assigns ->
115+
~H"""
116+
<.odyssey_product_picker field={@form[:activity_ids]} products={[]} />
117+
"""
118+
end,
119+
%{form: form}
120+
)
121+
122+
assert html =~ ~r/id="booking_activity_ids_product_picker"/
123+
end
124+
125+
test "renders with custom id" do
126+
form = to_form(%{"products" => nil}, as: :test)
127+
128+
html =
129+
render_component(
130+
fn assigns ->
131+
~H"""
132+
<.odyssey_product_picker field={@form[:products]} products={[]} id="my-custom-picker" />
133+
"""
134+
end,
135+
%{form: form}
136+
)
137+
138+
assert html =~ ~r/id="my-custom-picker"/
139+
end
140+
141+
test "renders with label" do
142+
form = to_form(%{"products" => nil}, as: :test)
143+
144+
html =
145+
render_component(
146+
fn assigns ->
147+
~H"""
148+
<.odyssey_product_picker field={@form[:products]} products={[]} label="Apply Products" />
149+
"""
150+
end,
151+
%{form: form}
152+
)
153+
154+
assert html =~ "Apply Products"
155+
end
156+
157+
test "encodes multiple selected_ids as comma-separated value" do
158+
form = to_form(%{"products" => nil}, as: :test)
159+
160+
products = [
161+
%{id: "p1", name: "Tour A", color_hex: "#111"},
162+
%{id: "p2", name: "Tour B", color_hex: "#222"}
163+
]
164+
165+
html =
166+
render_component(
167+
fn assigns ->
168+
~H"""
169+
<.odyssey_product_picker
170+
field={@form[:products]}
171+
products={@products}
172+
selected_ids={["p1", "p2"]}
173+
/>
174+
"""
175+
end,
176+
%{form: form, products: products}
177+
)
178+
179+
assert html =~ ~r/value="p1,p2"/
180+
end
181+
end
182+
end

0 commit comments

Comments
 (0)