mirror of
https://github.com/plausible/analytics.git
synced 2024-09-11 18:07:33 +03:00
Allow arbitrary suggestion modules in Live ComboBox component (#3154)
* Move ComboBox under Live.Components namespace * Make suggestions module injectable through component API * Reorganize tests * Test ComboBox in isolation * Allow external suggestion limit option * Funnels editor: bugfix propagating suggestions over limit * Update docs & typespecs
This commit is contained in:
parent
34fbc3d5bc
commit
bf84c043ce
@ -1,4 +1,4 @@
|
||||
defmodule PlausibleWeb.Live.FunnelSettings.ComboBox do
|
||||
defmodule PlausibleWeb.Live.Components.ComboBox do
|
||||
@moduledoc """
|
||||
Phoenix LiveComponent for a combobox UI element with search and selection
|
||||
functionality.
|
||||
@ -13,18 +13,28 @@ defmodule PlausibleWeb.Live.FunnelSettings.ComboBox do
|
||||
by default but can be customized. When a user types into the input
|
||||
field, the component searches the available options and provides
|
||||
suggestions based on the input.
|
||||
|
||||
Any module exposing suggest/2 function can be supplied via `suggest_mod`
|
||||
attribute - see the provided `ComboBox.StaticSearch`.
|
||||
"""
|
||||
use Phoenix.LiveComponent
|
||||
alias Phoenix.LiveView.JS
|
||||
|
||||
@max_options_displayed 15
|
||||
@default_suggestions_limit 15
|
||||
|
||||
def update(assigns, socket) do
|
||||
assigns =
|
||||
if assigns[:suggestions] do
|
||||
Map.put(assigns, :suggestions, Enum.take(assigns.suggestions, suggestions_limit(assigns)))
|
||||
else
|
||||
assigns
|
||||
end
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign_new(:suggestions, fn ->
|
||||
Enum.take(assigns.options, @max_options_displayed)
|
||||
Enum.take(assigns.options, suggestions_limit(assigns))
|
||||
end)
|
||||
|
||||
{:ok, socket}
|
||||
@ -36,6 +46,8 @@ defmodule PlausibleWeb.Live.FunnelSettings.ComboBox do
|
||||
attr(:submit_name, :string, required: true)
|
||||
attr(:display_value, :string, default: "")
|
||||
attr(:submit_value, :string, default: "")
|
||||
attr(:suggest_mod, :atom, required: true)
|
||||
attr(:suggestions_limit, :integer)
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
@ -79,7 +91,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.ComboBox do
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<.dropdown ref={@id} options={@options} suggestions={@suggestions} target={@myself} />
|
||||
<.dropdown ref={@id} suggest_mod={@suggest_mod} suggestions={@suggestions} target={@myself} />
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
@ -108,8 +120,8 @@ defmodule PlausibleWeb.Live.FunnelSettings.ComboBox do
|
||||
end
|
||||
|
||||
attr(:ref, :string, required: true)
|
||||
attr(:options, :list, default: [])
|
||||
attr(:suggestions, :list, default: [])
|
||||
attr(:suggest_mod, :atom, required: true)
|
||||
attr(:target, :any)
|
||||
|
||||
def dropdown(assigns) do
|
||||
@ -154,7 +166,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.ComboBox do
|
||||
attr(:idx, :integer, required: true)
|
||||
|
||||
def option(assigns) do
|
||||
assigns = assign(assigns, :max_options_displayed, @max_options_displayed)
|
||||
assigns = assign(assigns, :suggestions_limit, suggestions_limit(assigns))
|
||||
|
||||
~H"""
|
||||
<li
|
||||
@ -175,7 +187,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.ComboBox do
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li :if={@idx == @max_options_displayed - 1} class="text-xs text-gray-500 relative py-2 px-3">
|
||||
<li :if={@idx == @suggestions_limit - 1} class="text-xs text-gray-500 relative py-2 px-3">
|
||||
Max results reached. Refine your search by typing in goal name.
|
||||
</li>
|
||||
"""
|
||||
@ -197,42 +209,26 @@ defmodule PlausibleWeb.Live.FunnelSettings.ComboBox do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("search", %{"_target" => [target]} = params, socket) do
|
||||
def handle_event(
|
||||
"search",
|
||||
%{"_target" => [target]} = params,
|
||||
%{assigns: %{suggest_mod: suggest_mod, options: options}} = socket
|
||||
) do
|
||||
input = params[target]
|
||||
input_len = input |> String.trim() |> String.length()
|
||||
|
||||
if input_len > 0 do
|
||||
suggestions = suggest(input, socket.assigns.options)
|
||||
suggestions =
|
||||
input
|
||||
|> suggest_mod.suggest(options)
|
||||
|> Enum.take(suggestions_limit(socket.assigns))
|
||||
|
||||
{:noreply, assign(socket, %{suggestions: suggestions})}
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def suggest(input, options) do
|
||||
input_len = String.length(input)
|
||||
|
||||
options
|
||||
|> Enum.reject(fn {_, value} ->
|
||||
input_len > String.length(to_string(value))
|
||||
end)
|
||||
|> Enum.sort_by(
|
||||
fn {_, value} ->
|
||||
if to_string(value) == input do
|
||||
3
|
||||
else
|
||||
value = to_string(value)
|
||||
input = String.downcase(input)
|
||||
value = String.downcase(value)
|
||||
weight = if String.contains?(value, input), do: 1, else: 0
|
||||
weight + String.jaro_distance(value, input)
|
||||
end
|
||||
end,
|
||||
:desc
|
||||
)
|
||||
|> Enum.take(@max_options_displayed)
|
||||
end
|
||||
|
||||
defp do_select(socket, submit_value, display_value) do
|
||||
id = socket.assigns.id
|
||||
|
||||
@ -254,4 +250,8 @@ defmodule PlausibleWeb.Live.FunnelSettings.ComboBox do
|
||||
|
||||
socket
|
||||
end
|
||||
|
||||
defp suggestions_limit(assigns) do
|
||||
Map.get(assigns, :suggestions_limit, @default_suggestions_limit)
|
||||
end
|
||||
end
|
35
lib/plausible_web/live/components/combo_box/static_search.ex
Normal file
35
lib/plausible_web/live/components/combo_box/static_search.ex
Normal file
@ -0,0 +1,35 @@
|
||||
defmodule PlausibleWeb.Live.Components.ComboBox.StaticSearch do
|
||||
@moduledoc """
|
||||
Default suggestion engine for the `ComboBox` component.
|
||||
|
||||
Assumes, the user have already queried the database and the data set is
|
||||
small enough to be kept in state and filtered based on external input.
|
||||
|
||||
Favours exact matches. Skips entries shorter than input.
|
||||
Allows fuzzy matching based on Jaro Distance.
|
||||
"""
|
||||
|
||||
@spec suggest(String.t(), [{any(), any()}]) :: [{any(), any()}]
|
||||
def suggest(input, options) do
|
||||
input_len = String.length(input)
|
||||
|
||||
options
|
||||
|> Enum.reject(fn {_, value} ->
|
||||
input_len > String.length(to_string(value))
|
||||
end)
|
||||
|> Enum.sort_by(
|
||||
fn {_, value} ->
|
||||
if to_string(value) == input do
|
||||
3
|
||||
else
|
||||
value = to_string(value)
|
||||
input = String.downcase(input)
|
||||
value = String.downcase(value)
|
||||
weight = if String.contains?(value, input), do: 1, else: 0
|
||||
weight + String.jaro_distance(value, input)
|
||||
end
|
||||
end,
|
||||
:desc
|
||||
)
|
||||
end
|
||||
end
|
@ -68,7 +68,8 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
|
||||
<div class="w-2/5 flex-1">
|
||||
<.live_component
|
||||
submit_name="funnel[steps][][goal_id]"
|
||||
module={PlausibleWeb.Live.FunnelSettings.ComboBox}
|
||||
module={PlausibleWeb.Live.Components.ComboBox}
|
||||
suggest_mod={PlausibleWeb.Live.Components.ComboBox.StaticSearch}
|
||||
id={"step-#{step_idx}"}
|
||||
options={reject_alrady_selected("step-#{step_idx}", @goals, @selections_made)}
|
||||
/>
|
||||
@ -401,7 +402,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
|
||||
|
||||
result = Enum.reject(goals, fn {goal_id, _} -> goal_id in selection_ids end)
|
||||
|
||||
send_update(PlausibleWeb.Live.FunnelSettings.ComboBox, id: combo_box, suggestions: result)
|
||||
send_update(PlausibleWeb.Live.Components.ComboBox, id: combo_box, suggestions: result)
|
||||
result
|
||||
end
|
||||
end
|
||||
|
@ -0,0 +1,38 @@
|
||||
defmodule PlausibleWeb.Live.Components.ComboBox.StaticSearchTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias PlausibleWeb.Live.Components.ComboBox.StaticSearch
|
||||
|
||||
describe "autosuggest algorithm" do
|
||||
test "favours exact match" do
|
||||
options = fake_options(["yellow", "hello", "cruel hello world"])
|
||||
|
||||
assert [{_, "hello"}, {_, "cruel hello world"}, {_, "yellow"}] =
|
||||
StaticSearch.suggest("hello", options)
|
||||
end
|
||||
|
||||
test "skips entries shorter than input" do
|
||||
options = fake_options(["yellow", "hello", "cruel hello world"])
|
||||
|
||||
assert [{_, "cruel hello world"}] = StaticSearch.suggest("cruel hello", options)
|
||||
end
|
||||
|
||||
test "favours similiarity" do
|
||||
options = fake_options(["melon", "hello", "yellow"])
|
||||
assert [{_, "hello"}, {_, "yellow"}, {_, "melon"}] = StaticSearch.suggest("hell", options)
|
||||
end
|
||||
|
||||
test "allows fuzzy matching" do
|
||||
options = fake_options(["/url/0xC0FFEE", "/url/0xDEADBEEF", "/url/other"])
|
||||
|
||||
assert [{_, "/url/0xC0FFEE"}, {_, "/url/0xDEADBEEF"}, {_, "/url/other"}] =
|
||||
StaticSearch.suggest("0x FF", options)
|
||||
end
|
||||
end
|
||||
|
||||
defp fake_options(option_names) do
|
||||
option_names
|
||||
|> Enum.shuffle()
|
||||
|> Enum.with_index(fn element, index -> {index, element} end)
|
||||
end
|
||||
end
|
199
test/plausible_web/live/components/combo_box_test.exs
Normal file
199
test/plausible_web/live/components/combo_box_test.exs
Normal file
@ -0,0 +1,199 @@
|
||||
defmodule PlausibleWeb.Live.Components.ComboBoxTest do
|
||||
use PlausibleWeb.ConnCase, async: true
|
||||
import Phoenix.LiveViewTest
|
||||
import Plausible.Test.Support.HTML
|
||||
|
||||
alias PlausibleWeb.Live.Components.ComboBox
|
||||
|
||||
@ul "ul#dropdown-test-component[x-show=isOpen][x-ref=suggestions]"
|
||||
|
||||
describe "static rendering" do
|
||||
test "renders suggestions" do
|
||||
assert doc = render_sample_component(new_options(10))
|
||||
|
||||
assert element_exists?(
|
||||
doc,
|
||||
~s/input#test-component[name="display-test-component"][phx-change="search"]/
|
||||
)
|
||||
|
||||
assert element_exists?(doc, @ul)
|
||||
|
||||
for i <- 1..10 do
|
||||
assert element_exists?(doc, suggestion_li(i))
|
||||
end
|
||||
end
|
||||
|
||||
test "renders up to 15 suggestions by default" do
|
||||
assert doc = render_sample_component(new_options(20))
|
||||
|
||||
assert element_exists?(doc, suggestion_li(14))
|
||||
assert element_exists?(doc, suggestion_li(15))
|
||||
|
||||
refute element_exists?(doc, suggestion_li(16))
|
||||
refute element_exists?(doc, suggestion_li(17))
|
||||
|
||||
assert Floki.text(doc) =~ "Max results reached"
|
||||
end
|
||||
|
||||
test "renders up to n suggestions if provided" do
|
||||
assert doc = render_sample_component(new_options(20), suggestions_limit: 10)
|
||||
|
||||
assert element_exists?(doc, suggestion_li(9))
|
||||
assert element_exists?(doc, suggestion_li(10))
|
||||
|
||||
refute element_exists?(doc, suggestion_li(11))
|
||||
refute element_exists?(doc, suggestion_li(12))
|
||||
end
|
||||
|
||||
test "Alpine.js: renders attrs focusing suggestion elements" do
|
||||
assert doc = render_sample_component(new_options(10))
|
||||
li1 = doc |> find(suggestion_li(1)) |> List.first()
|
||||
li2 = doc |> find(suggestion_li(2)) |> List.first()
|
||||
|
||||
assert text_of_attr(li1, "@mouseenter") == "setFocus(0)"
|
||||
assert text_of_attr(li2, "@mouseenter") == "setFocus(1)"
|
||||
|
||||
assert text_of_attr(li1, "x-bind:class") =~ "focus === 0"
|
||||
assert text_of_attr(li2, "x-bind:class") =~ "focus === 1"
|
||||
end
|
||||
|
||||
test "Alpine.js: component refers to window.suggestionsDropdown" do
|
||||
assert new_options(2)
|
||||
|> render_sample_component()
|
||||
|> find("div#input-picker-main-test-component")
|
||||
|> text_of_attr("x-data") =~ "window.suggestionsDropdown('test-component')"
|
||||
end
|
||||
|
||||
test "Alpine.js: component sets up keyboard navigation" do
|
||||
main =
|
||||
new_options(2)
|
||||
|> render_sample_component()
|
||||
|> find("div#input-picker-main-test-component")
|
||||
|
||||
assert text_of_attr(main, "x-on:keydown.arrow-up") == "focusPrev"
|
||||
assert text_of_attr(main, "x-on:keydown.arrow-down") == "focusNext"
|
||||
assert text_of_attr(main, "x-on:keydown.enter") == "select()"
|
||||
end
|
||||
|
||||
test "Alpine.js: component sets up close on click-away" do
|
||||
assert new_options(2)
|
||||
|> render_sample_component()
|
||||
|> find("div#input-picker-main-test-component div div")
|
||||
|> text_of_attr("@click.away") == "close"
|
||||
end
|
||||
|
||||
test "Alpine.js: component sets up open on focusing the display input" do
|
||||
assert new_options(2)
|
||||
|> render_sample_component()
|
||||
|> find("input#test-component")
|
||||
|> text_of_attr("x-on:focus") == "open"
|
||||
end
|
||||
|
||||
test "Alpine.js: dropdown is annotated and shows when isOpen is true" do
|
||||
dropdown =
|
||||
new_options(2)
|
||||
|> render_sample_component()
|
||||
|> find("#dropdown-test-component")
|
||||
|
||||
assert text_of_attr(dropdown, "x-show") == "isOpen"
|
||||
assert text_of_attr(dropdown, "x-ref") == "suggestions"
|
||||
end
|
||||
|
||||
test "Dropdown shows a notice when no suggestions exist" do
|
||||
doc = render_sample_component([])
|
||||
|
||||
assert text_of_element(doc, "#dropdown-test-component") ==
|
||||
"No matches found. Try searching for something different."
|
||||
end
|
||||
end
|
||||
|
||||
describe "integration" do
|
||||
defmodule SampleView do
|
||||
use Phoenix.LiveView
|
||||
|
||||
defmodule SampleSuggest do
|
||||
def suggest("Echo me", options) do
|
||||
[{length(options), "Echo me"}]
|
||||
end
|
||||
|
||||
def suggest("all", options) do
|
||||
options
|
||||
end
|
||||
end
|
||||
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<.live_component
|
||||
submit_name="some_submit_name"
|
||||
module={PlausibleWeb.Live.Components.ComboBox}
|
||||
suggest_mod={__MODULE__.SampleSuggest}
|
||||
id="test-component"
|
||||
options={for i <- 1..20, do: {i, "Option #{i}"}}
|
||||
suggestions_limit={7}
|
||||
/>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
||||
test "uses the suggestions module", %{conn: conn} do
|
||||
{:ok, lv, _html} = live_isolated(conn, SampleView, session: %{})
|
||||
doc = type_into_combo(lv, "test-component", "Echo me")
|
||||
assert text_of_element(doc, "#dropdown-test-component-option-0") == "Echo me"
|
||||
end
|
||||
|
||||
test "stores selected value", %{conn: conn} do
|
||||
{:ok, lv, _html} = live_isolated(conn, SampleView, session: %{})
|
||||
type_into_combo(lv, "test-component", "Echo me")
|
||||
|
||||
doc =
|
||||
lv
|
||||
|> element("li#dropdown-test-component-option-0 a")
|
||||
|> render_click()
|
||||
|
||||
assert element_exists?(doc, "input[type=hidden][name=some_submit_name][value=20]")
|
||||
end
|
||||
|
||||
test "limits the suggestions", %{conn: conn} do
|
||||
{:ok, lv, _html} = live_isolated(conn, SampleView, session: %{})
|
||||
doc = type_into_combo(lv, "test-component", "all")
|
||||
|
||||
assert element_exists?(doc, suggestion_li(6))
|
||||
assert element_exists?(doc, suggestion_li(7))
|
||||
|
||||
refute element_exists?(doc, suggestion_li(8))
|
||||
refute element_exists?(doc, suggestion_li(9))
|
||||
end
|
||||
end
|
||||
|
||||
defp render_sample_component(options, extra_opts \\ []) do
|
||||
render_component(
|
||||
ComboBox,
|
||||
Keyword.merge(
|
||||
[
|
||||
options: options,
|
||||
submit_name: "test-submit-name",
|
||||
id: "test-component",
|
||||
suggest_mod: ComboBox.StaticSearch
|
||||
],
|
||||
extra_opts
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
defp new_options(n) do
|
||||
Enum.map(1..n, &{&1, "TestOption #{&1}"})
|
||||
end
|
||||
|
||||
defp suggestion_li(idx) do
|
||||
~s/#{@ul} li#dropdown-test-component-option-#{idx - 1}/
|
||||
end
|
||||
|
||||
defp type_into_combo(lv, id, text) do
|
||||
lv
|
||||
|> element("input##{id}")
|
||||
|> render_change(%{
|
||||
"_target" => ["display-#{id}"],
|
||||
"display-#{id}" => "#{text}"
|
||||
})
|
||||
end
|
||||
end
|
130
test/plausible_web/live/funnel_settings/form_test.exs
Normal file
130
test/plausible_web/live/funnel_settings/form_test.exs
Normal file
@ -0,0 +1,130 @@
|
||||
defmodule PlausibleWeb.Live.FunnelSettings.FormTest do
|
||||
use PlausibleWeb.ConnCase, async: true
|
||||
import Phoenix.LiveViewTest
|
||||
import Plausible.Test.Support.HTML
|
||||
|
||||
describe "integration - live rendering" do
|
||||
setup [:create_user, :log_in, :create_site]
|
||||
|
||||
test "search reacts to the input, the user types in", %{conn: conn, site: site} do
|
||||
setup_goals(site, ["Hello World", "Plausible", "Another World"])
|
||||
lv = get_liveview(conn, site)
|
||||
|
||||
doc = type_into_combo(lv, 1, "hello")
|
||||
|
||||
assert text_of_element(doc, "#dropdown-step-1-option-0") == "Hello World"
|
||||
|
||||
doc = type_into_combo(lv, 1, "plausible")
|
||||
|
||||
assert text_of_element(doc, "#dropdown-step-1-option-0") == "Plausible"
|
||||
end
|
||||
|
||||
test "selecting an option prefills input values", %{conn: conn, site: site} do
|
||||
{:ok, [_, _, g3]} = setup_goals(site, ["Hello World", "Plausible", "Another World"])
|
||||
lv = get_liveview(conn, site)
|
||||
|
||||
doc = type_into_combo(lv, 1, "another")
|
||||
|
||||
refute element_exists?(doc, ~s/input[type="hidden"][value="#{g3.id}"]/)
|
||||
refute element_exists?(doc, ~s/input[type="text"][value="Another World"]/)
|
||||
|
||||
lv
|
||||
|> element("li#dropdown-step-1-option-0 a")
|
||||
|> render_click()
|
||||
|
||||
assert lv
|
||||
|> element("#submit-step-1")
|
||||
|> render()
|
||||
|> element_exists?(~s/input[type="hidden"][value="#{g3.id}"]/)
|
||||
|
||||
assert lv
|
||||
|> element("#step-1")
|
||||
|> render()
|
||||
|> element_exists?(~s/input[type="text"][value="Another World"]/)
|
||||
end
|
||||
|
||||
test "selecting one option reduces suggestions in the other", %{conn: conn, site: site} do
|
||||
setup_goals(site, ["Hello World", "Plausible", "Another World"])
|
||||
lv = get_liveview(conn, site)
|
||||
|
||||
type_into_combo(lv, 1, "another")
|
||||
|
||||
lv
|
||||
|> element("li#dropdown-step-1-option-0 a")
|
||||
|> render_click()
|
||||
|
||||
doc = type_into_combo(lv, 2, "another")
|
||||
|
||||
refute text_of_element(doc, "ul#dropdown-step-1 li") =~ "Another World"
|
||||
|
||||
assert text_of_element(doc, "ul#dropdown-step-2 li") =~ "Hello World"
|
||||
assert text_of_element(doc, "ul#dropdown-step-2 li") =~ "Plausible"
|
||||
refute text_of_element(doc, "ul#dropdown-step-2 li") =~ "Another World"
|
||||
end
|
||||
|
||||
test "suggestions are limited on change", %{conn: conn, site: site} do
|
||||
setup_goals(site, for(i <- 1..20, do: "Goal #{i}"))
|
||||
lv = get_liveview(conn, site)
|
||||
|
||||
doc =
|
||||
lv
|
||||
|> element("li#dropdown-step-1-option-0 a")
|
||||
|> render_click()
|
||||
|
||||
assert element_exists?(doc, ~s/#li#dropdown-step-1-option-14/)
|
||||
refute element_exists?(doc, ~s/#li#dropdown-step-1-option-15/)
|
||||
end
|
||||
|
||||
test "removing one option alters suggestions for other", %{conn: conn, site: site} do
|
||||
setup_goals(site, ["Hello World", "Plausible", "Another World"])
|
||||
|
||||
lv = get_liveview(conn, site)
|
||||
|
||||
lv |> element(~s/a[phx-click="add-step"]/) |> render_click()
|
||||
|
||||
type_into_combo(lv, 2, "hello")
|
||||
|
||||
lv
|
||||
|> element("li#dropdown-step-2-option-0 a")
|
||||
|> render_click()
|
||||
|
||||
doc = type_into_combo(lv, 1, "hello")
|
||||
|
||||
refute text_of_element(doc, "ul#dropdown-step-1 li") =~ "Hello World"
|
||||
|
||||
lv |> element(~s/#remove-step-2/) |> render_click()
|
||||
|
||||
doc = type_into_combo(lv, 1, "hello")
|
||||
|
||||
assert text_of_element(doc, "ul#dropdown-step-1 li") =~ "Hello World"
|
||||
end
|
||||
end
|
||||
|
||||
defp get_liveview(conn, site) do
|
||||
conn = assign(conn, :live_module, PlausibleWeb.Live.FunnelSettings)
|
||||
{:ok, lv, _html} = live(conn, "/#{site.domain}/settings/funnels")
|
||||
lv |> element(~s/button[phx-click="add-funnel"]/) |> render_click()
|
||||
assert form_view = find_live_child(lv, "funnels-form")
|
||||
form_view |> element("form") |> render_change(%{funnel: %{name: "My test funnel"}})
|
||||
form_view
|
||||
end
|
||||
|
||||
defp setup_goals(site, goal_names) when is_list(goal_names) do
|
||||
goals =
|
||||
Enum.map(goal_names, fn goal_name ->
|
||||
{:ok, g} = Plausible.Goals.create(site, %{"event_name" => goal_name})
|
||||
g
|
||||
end)
|
||||
|
||||
{:ok, goals}
|
||||
end
|
||||
|
||||
defp type_into_combo(lv, idx, text) do
|
||||
lv
|
||||
|> element("input#step-#{idx}")
|
||||
|> render_change(%{
|
||||
"_target" => ["display-step-#{idx}"],
|
||||
"display-step-#{idx}" => "#{text}"
|
||||
})
|
||||
end
|
||||
end
|
@ -1,270 +0,0 @@
|
||||
defmodule PlausibleWeb.Live.FunnelSettings.ComboBoxTest do
|
||||
use PlausibleWeb.ConnCase, async: true
|
||||
import Phoenix.LiveViewTest
|
||||
import Plausible.Test.Support.HTML
|
||||
|
||||
alias PlausibleWeb.Live.FunnelSettings.ComboBox
|
||||
|
||||
@ul "ul#dropdown-test-component[x-show=isOpen][x-ref=suggestions]"
|
||||
|
||||
defp suggestion_li(idx) do
|
||||
~s/#{@ul} li#dropdown-test-component-option-#{idx - 1}/
|
||||
end
|
||||
|
||||
describe "static rendering" do
|
||||
test "renders suggestions" do
|
||||
assert doc = render_sample_component(new_options(10))
|
||||
|
||||
assert element_exists?(
|
||||
doc,
|
||||
~s/input#test-component[name="display-test-component"][phx-change="search"]/
|
||||
)
|
||||
|
||||
assert element_exists?(doc, @ul)
|
||||
|
||||
for i <- 1..10 do
|
||||
assert element_exists?(doc, suggestion_li(i))
|
||||
end
|
||||
end
|
||||
|
||||
test "renders up to 15 suggestions by default" do
|
||||
assert doc = render_sample_component(new_options(20))
|
||||
|
||||
assert element_exists?(doc, suggestion_li(14))
|
||||
assert element_exists?(doc, suggestion_li(15))
|
||||
|
||||
refute element_exists?(doc, suggestion_li(16))
|
||||
refute element_exists?(doc, suggestion_li(17))
|
||||
|
||||
assert Floki.text(doc) =~ "Max results reached"
|
||||
end
|
||||
|
||||
test "Alpine.js: renders attrs focusing suggestion elements" do
|
||||
assert doc = render_sample_component(new_options(10))
|
||||
li1 = doc |> find(suggestion_li(1)) |> List.first()
|
||||
li2 = doc |> find(suggestion_li(2)) |> List.first()
|
||||
|
||||
assert text_of_attr(li1, "@mouseenter") == "setFocus(0)"
|
||||
assert text_of_attr(li2, "@mouseenter") == "setFocus(1)"
|
||||
|
||||
assert text_of_attr(li1, "x-bind:class") =~ "focus === 0"
|
||||
assert text_of_attr(li2, "x-bind:class") =~ "focus === 1"
|
||||
end
|
||||
|
||||
test "Alpine.js: component refers to window.suggestionsDropdown" do
|
||||
assert new_options(2)
|
||||
|> render_sample_component()
|
||||
|> find("div#input-picker-main-test-component")
|
||||
|> text_of_attr("x-data") =~ "window.suggestionsDropdown('test-component')"
|
||||
end
|
||||
|
||||
test "Alpine.js: component sets up keyboard navigation" do
|
||||
main =
|
||||
new_options(2)
|
||||
|> render_sample_component()
|
||||
|> find("div#input-picker-main-test-component")
|
||||
|
||||
assert text_of_attr(main, "x-on:keydown.arrow-up") == "focusPrev"
|
||||
assert text_of_attr(main, "x-on:keydown.arrow-down") == "focusNext"
|
||||
assert text_of_attr(main, "x-on:keydown.enter") == "select()"
|
||||
end
|
||||
|
||||
test "Alpine.js: component sets up close on click-away" do
|
||||
assert new_options(2)
|
||||
|> render_sample_component()
|
||||
|> find("div#input-picker-main-test-component div div")
|
||||
|> text_of_attr("@click.away") == "close"
|
||||
end
|
||||
|
||||
test "Alpine.js: component sets up open on focusing the display input" do
|
||||
assert new_options(2)
|
||||
|> render_sample_component()
|
||||
|> find("input#test-component")
|
||||
|> text_of_attr("x-on:focus") == "open"
|
||||
end
|
||||
|
||||
test "Alpine.js: dropdown is annotated and shows when isOpen is true" do
|
||||
dropdown =
|
||||
new_options(2)
|
||||
|> render_sample_component()
|
||||
|> find("#dropdown-test-component")
|
||||
|
||||
assert text_of_attr(dropdown, "x-show") == "isOpen"
|
||||
assert text_of_attr(dropdown, "x-ref") == "suggestions"
|
||||
end
|
||||
|
||||
test "Dropdown shows a notice when no suggestions exist" do
|
||||
doc = render_sample_component([])
|
||||
|
||||
assert text_of_element(doc, "#dropdown-test-component") ==
|
||||
"No matches found. Try searching for something different."
|
||||
end
|
||||
end
|
||||
|
||||
describe "autosuggest algorithm" do
|
||||
test "favours exact match" do
|
||||
options = fake_options(["yellow", "hello", "cruel hello world"])
|
||||
|
||||
assert [{_, "hello"}, {_, "cruel hello world"}, {_, "yellow"}] =
|
||||
ComboBox.suggest("hello", options)
|
||||
end
|
||||
|
||||
test "skips entries shorter than input" do
|
||||
options = fake_options(["yellow", "hello", "cruel hello world"])
|
||||
|
||||
assert [{_, "cruel hello world"}] = ComboBox.suggest("cruel hello", options)
|
||||
end
|
||||
|
||||
test "favours similiarity" do
|
||||
options = fake_options(["melon", "hello", "yellow"])
|
||||
assert [{_, "hello"}, {_, "yellow"}, {_, "melon"}] = ComboBox.suggest("hell", options)
|
||||
end
|
||||
|
||||
test "allows fuzzy matching" do
|
||||
options = fake_options(["/url/0xC0FFEE", "/url/0xDEADBEEF", "/url/other"])
|
||||
|
||||
assert [{_, "/url/0xC0FFEE"}, {_, "/url/0xDEADBEEF"}, {_, "/url/other"}] =
|
||||
ComboBox.suggest("0x FF", options)
|
||||
end
|
||||
|
||||
test "suggests up to 15 entries" do
|
||||
options =
|
||||
1..20
|
||||
|> Enum.map(&"Option #{&1}")
|
||||
|> fake_options()
|
||||
|
||||
suggestions = ComboBox.suggest("Option", options)
|
||||
assert Enum.count(suggestions) == 15
|
||||
end
|
||||
end
|
||||
|
||||
describe "integration - live rendering" do
|
||||
setup [:create_user, :log_in, :create_site]
|
||||
|
||||
test "search reacts to the input, the user types in", %{conn: conn, site: site} do
|
||||
setup_goals(site, ["Hello World", "Plausible", "Another World"])
|
||||
lv = get_liveview(conn, site)
|
||||
|
||||
doc = type_into_combo(lv, 1, "hello")
|
||||
|
||||
assert text_of_element(doc, "#dropdown-step-1-option-0") == "Hello World"
|
||||
|
||||
doc = type_into_combo(lv, 1, "plausible")
|
||||
|
||||
assert text_of_element(doc, "#dropdown-step-1-option-0") == "Plausible"
|
||||
end
|
||||
|
||||
test "selecting an option prefills input values", %{conn: conn, site: site} do
|
||||
{:ok, [_, _, g3]} = setup_goals(site, ["Hello World", "Plausible", "Another World"])
|
||||
lv = get_liveview(conn, site)
|
||||
|
||||
doc = type_into_combo(lv, 1, "another")
|
||||
|
||||
refute element_exists?(doc, ~s/input[type="hidden"][value="#{g3.id}"]/)
|
||||
refute element_exists?(doc, ~s/input[type="text"][value="Another World"]/)
|
||||
|
||||
lv
|
||||
|> element("li#dropdown-step-1-option-0 a")
|
||||
|> render_click()
|
||||
|
||||
assert lv
|
||||
|> element("#submit-step-1")
|
||||
|> render()
|
||||
|> element_exists?(~s/input[type="hidden"][value="#{g3.id}"]/)
|
||||
|
||||
assert lv
|
||||
|> element("#step-1")
|
||||
|> render()
|
||||
|> element_exists?(~s/input[type="text"][value="Another World"]/)
|
||||
end
|
||||
|
||||
test "selecting one option reduces suggestions in the other", %{conn: conn, site: site} do
|
||||
setup_goals(site, ["Hello World", "Plausible", "Another World"])
|
||||
lv = get_liveview(conn, site)
|
||||
|
||||
type_into_combo(lv, 1, "another")
|
||||
|
||||
lv
|
||||
|> element("li#dropdown-step-1-option-0 a")
|
||||
|> render_click()
|
||||
|
||||
doc = type_into_combo(lv, 2, "another")
|
||||
|
||||
refute text_of_element(doc, "ul#dropdown-step-1 li") =~ "Another World"
|
||||
|
||||
assert text_of_element(doc, "ul#dropdown-step-2 li") =~ "Hello World"
|
||||
assert text_of_element(doc, "ul#dropdown-step-2 li") =~ "Plausible"
|
||||
refute text_of_element(doc, "ul#dropdown-step-2 li") =~ "Another World"
|
||||
end
|
||||
|
||||
test "removing one option alters suggestions for other", %{conn: conn, site: site} do
|
||||
setup_goals(site, ["Hello World", "Plausible", "Another World"])
|
||||
|
||||
lv = get_liveview(conn, site)
|
||||
|
||||
lv |> element(~s/a[phx-click="add-step"]/) |> render_click()
|
||||
|
||||
type_into_combo(lv, 2, "hello")
|
||||
|
||||
lv
|
||||
|> element("li#dropdown-step-2-option-0 a")
|
||||
|> render_click()
|
||||
|
||||
doc = type_into_combo(lv, 1, "hello")
|
||||
|
||||
refute text_of_element(doc, "ul#dropdown-step-1 li") =~ "Hello World"
|
||||
|
||||
lv |> element(~s/#remove-step-2/) |> render_click()
|
||||
|
||||
doc = type_into_combo(lv, 1, "hello")
|
||||
|
||||
assert text_of_element(doc, "ul#dropdown-step-1 li") =~ "Hello World"
|
||||
end
|
||||
end
|
||||
|
||||
defp render_sample_component(options) do
|
||||
render_component(ComboBox,
|
||||
options: options,
|
||||
submit_name: "test-submit-name",
|
||||
id: "test-component"
|
||||
)
|
||||
end
|
||||
|
||||
defp new_options(n) do
|
||||
Enum.map(1..n, &{&1, "TestOption #{&1}"})
|
||||
end
|
||||
|
||||
defp get_liveview(conn, site) do
|
||||
conn = assign(conn, :live_module, PlausibleWeb.Live.FunnelSettings)
|
||||
{:ok, lv, _html} = live(conn, "/#{site.domain}/settings/funnels")
|
||||
lv |> element(~s/button[phx-click="add-funnel"]/) |> render_click()
|
||||
assert form_view = find_live_child(lv, "funnels-form")
|
||||
form_view |> element("form") |> render_change(%{funnel: %{name: "My test funnel"}})
|
||||
form_view
|
||||
end
|
||||
|
||||
defp setup_goals(site, goal_names) when is_list(goal_names) do
|
||||
goals =
|
||||
Enum.map(goal_names, fn goal_name ->
|
||||
{:ok, g} = Plausible.Goals.create(site, %{"event_name" => goal_name})
|
||||
g
|
||||
end)
|
||||
|
||||
{:ok, goals}
|
||||
end
|
||||
|
||||
defp fake_options(option_names) do
|
||||
option_names
|
||||
|> Enum.shuffle()
|
||||
|> Enum.with_index(fn element, index -> {index, element} end)
|
||||
end
|
||||
|
||||
defp type_into_combo(lv, idx, text) do
|
||||
lv
|
||||
|> element("input#step-#{idx}")
|
||||
|> render_change(%{
|
||||
"_target" => ["display-step-#{idx}"],
|
||||
"display-step-#{idx}" => "#{text}"
|
||||
})
|
||||
end
|
||||
end
|
Loading…
Reference in New Issue
Block a user