Skip to content

Commit 0adfc79

Browse files
authored
Merge pull request #53 from peek-travel/feature/product-picker
Feature/product picker
1 parent 89ee3df commit 0adfc79

9 files changed

Lines changed: 965 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [2026-03-14]
9+
10+
### Added
11+
12+
- Added `odyssey_product_picker` component — a form-integrated product selector with "All" / "Specific" toggle and per-product checkboxes. Expects products as `%{id, name, color}` atom-keyed maps. Auto-extracts `selected_ids` from the form field value (supports maps, structs, and Ecto changesets) when not explicitly provided.
13+
- Added `OdysseyProductPicker` JS hook for client-side checkbox ↔ hidden field sync.
14+
- Added `odyssey_select` component — a generic dropdown LiveComponent with search/filter.
15+
- Added product picker and select demos to the showcase app with LiveView tests.
16+
817
## [2026-03-05]
918

1019
### Changed

assets/js/odyssey.js

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,68 @@ const OdysseyHooks = {
3535
})
3636
}
3737
}
38+
},
39+
OdysseySelect: {
40+
mounted () {
41+
this.positionDropdown()
42+
},
43+
updated () {
44+
this.positionDropdown()
45+
},
46+
positionDropdown () {
47+
const dropdown = this.el.querySelector('[data-dropdown]')
48+
if (!dropdown) return
49+
50+
const button = this.el.querySelector('button')
51+
const buttonRect = button.getBoundingClientRect()
52+
const dropdownHeight = dropdown.offsetHeight
53+
const viewportHeight = window.innerHeight
54+
const spaceBelow = viewportHeight - buttonRect.bottom
55+
const spaceAbove = buttonRect.top
56+
57+
if (spaceBelow < dropdownHeight && spaceAbove > spaceBelow) {
58+
dropdown.style.bottom = '100%'
59+
dropdown.style.top = 'auto'
60+
dropdown.style.marginBottom = '0.5rem'
61+
dropdown.style.marginTop = '0'
62+
} else {
63+
dropdown.style.top = '100%'
64+
dropdown.style.bottom = 'auto'
65+
dropdown.style.marginTop = '0.5rem'
66+
dropdown.style.marginBottom = '0'
67+
}
68+
}
69+
},
70+
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+
}
38100
}
39101
}
40102

demo/lib/demo_web/live/odyssey_showcase_live.ex

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ defmodule DemoWeb.OdysseyShowcaseLive do
1717
"discount" => "",
1818
"percentage" => "",
1919
"start_date" => "",
20-
"expiration_date" => ""
20+
"expiration_date" => "",
21+
"whitelisted_products" => ""
2122
}
2223

2324
socket =
@@ -32,6 +33,7 @@ defmodule DemoWeb.OdysseyShowcaseLive do
3233
|> assign(:form_data, form_data)
3334
|> assign(:form, to_form(form_data, as: "form"))
3435
|> assign(:sample_activities, sample_activities())
36+
|> assign(:sample_products, sample_products())
3537

3638
{:ok, socket}
3739
end
@@ -558,6 +560,26 @@ defmodule DemoWeb.OdysseyShowcaseLive do
558560
559561
<.odyssey_divider />
560562
563+
<div>
564+
<h3 class="text-lg font-medium mb-4">Product Picker Component</h3>
565+
<p class="text-gray-600 mb-4">
566+
Select products with a toggle between "All" and "Specific" modes.
567+
When "Specific" is selected, checkboxes appear for each product.
568+
</p>
569+
570+
<.form for={@form} phx-change="validate" id="product-picker-form">
571+
<.odyssey_product_picker
572+
field={@form[:whitelisted_products]}
573+
products={@sample_products}
574+
all_label="All Products"
575+
specific_label="Specific Products"
576+
label="Apply to"
577+
/>
578+
</.form>
579+
</div>
580+
581+
<.odyssey_divider />
582+
561583
<div>
562584
<h3 class="text-lg font-medium mb-4">Activity Picker Component</h3>
563585
<p class="text-gray-600 mb-4">
@@ -621,6 +643,15 @@ defmodule DemoWeb.OdysseyShowcaseLive do
621643
"""
622644
end
623645

646+
defp sample_products do
647+
[
648+
%{id: "prod_1", name: "Wine Tasting", color: "#8B5CF6"},
649+
%{id: "prod_2", name: "Cooking Class", color: "#F59E0B"},
650+
%{id: "prod_3", name: "City Tour", color: "#10B981"},
651+
%{id: "prod_4", name: "Sunset Cruise", color: "#3B82F6"}
652+
]
653+
end
654+
624655
defp sample_activities do
625656
[
626657
%{
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
defmodule DemoWeb.OdysseyShowcaseLiveTest do
2+
use DemoWeb.ConnCase
3+
4+
import Phoenix.LiveViewTest
5+
6+
describe "product picker" do
7+
test "renders product picker in forms tab", %{conn: conn} do
8+
{:ok, view, _html} = live(conn, "/")
9+
10+
html =
11+
view
12+
|> element("[phx-click='change_tab'][phx-value-tab='forms']")
13+
|> render_click()
14+
15+
assert html =~ "Product Picker Component"
16+
assert html =~ "product-picker-form"
17+
assert html =~ "All Products"
18+
assert html =~ "Specific Products"
19+
end
20+
21+
test "product picker starts in 'all' mode by default", %{conn: conn} do
22+
{:ok, view, _html} = live(conn, "/")
23+
24+
view
25+
|> element("[phx-click='change_tab'][phx-value-tab='forms']")
26+
|> render_click()
27+
28+
html = render(view)
29+
30+
# In "all" mode, individual product checkboxes should not be visible
31+
refute html =~ "Wine Tasting"
32+
refute html =~ "Cooking Class"
33+
end
34+
35+
test "toggling to 'specific' shows product checkboxes", %{conn: conn} do
36+
{:ok, view, _html} = live(conn, "/")
37+
38+
view
39+
|> element("[phx-click='change_tab'][phx-value-tab='forms']")
40+
|> render_click()
41+
42+
html =
43+
view
44+
|> element("button[value='specific']")
45+
|> render_click()
46+
47+
assert html =~ "Wine Tasting"
48+
assert html =~ "Cooking Class"
49+
assert html =~ "City Tour"
50+
assert html =~ "Sunset Cruise"
51+
end
52+
53+
test "toggling back to 'all' hides product checkboxes", %{conn: conn} do
54+
{:ok, view, _html} = live(conn, "/")
55+
56+
view
57+
|> element("[phx-click='change_tab'][phx-value-tab='forms']")
58+
|> render_click()
59+
60+
# Toggle to specific first
61+
view
62+
|> element("button[value='specific']")
63+
|> render_click()
64+
65+
# Toggle back to all
66+
html =
67+
view
68+
|> element("button[value='all']")
69+
|> render_click()
70+
71+
refute html =~ "Wine Tasting"
72+
refute html =~ "Cooking Class"
73+
end
74+
75+
test "product picker renders with label", %{conn: conn} do
76+
{:ok, view, _html} = live(conn, "/")
77+
78+
html =
79+
view
80+
|> element("[phx-click='change_tab'][phx-value-tab='forms']")
81+
|> render_click()
82+
83+
assert html =~ "Apply to"
84+
end
85+
end
86+
end
87+

lib/peek_app_sdk/ui/odyssey.ex

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ 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
19+
defdelegate odyssey_select(assigns), to: PeekAppSDK.UI.Odyssey.Select
1820
end

0 commit comments

Comments
 (0)