Funnel Settings UI tweaks (to match Custom Props/Goals) (#3323)

* Add hint to creatable ComboBoxes without suggestions available

* Load external resources once in funnel settings

* Load external resources once in goal settings

* Make Custom Props Settings UI match Goal Settings

* Remove unnecessary goals query

This should be done only once in the live view

* Remove funnels feature flag

* fixup

* Make the modal scrollable

* By default, focus first suggestion for creatables

* Update StaticSearch

So it's capable of casting custom data structures
into weighted items. Missing tests added.

* Add Search + modal to funnel settings

* Add sample props to seeds

* Load all suggestions asynchronously, unless `Mix.env == :test`

* ComboBox: Fix inconsistent suggestions

We require "Create ..." element to be only focused
when there are no suggestions available.
This causes some issues, depending on the state,
the least focusable index might be either 0 ("Create...")
or 1. This patch addresses all the quirks with focus.

* Fix ComboBox max results message

So that AlpineJS doesn't think it's a focusable
option.

* Keep the state up to date when changing props

* Add hint to creatable ComboBoxes without suggestions available

* Load external resources once in funnel settings

* Load external resources once in goal settings

* Make Custom Props Settings UI match Goal Settings

* Remove unnecessary goals query

This should be done only once in the live view

* Remove funnels feature flag

* fixup

* Make the modal scrollable

* By default, focus first suggestion for creatables

* Add sample props to seeds

* Load all suggestions asynchronously, unless `Mix.env == :test`

* ComboBox: Fix inconsistent suggestions

We require "Create ..." element to be only focused
when there are no suggestions available.
This causes some issues, depending on the state,
the least focusable index might be either 0 ("Create...")
or 1. This patch addresses all the quirks with focus.

* Fix ComboBox max results message

So that AlpineJS doesn't think it's a focusable
option.

* Keep the state up to date when changing props

* Fixup site_id

* Fix typo

* fixup
This commit is contained in:
hq1 2023-09-13 15:07:04 +02:00 committed by GitHub
parent 0822bc61df
commit 43be271836
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 271 additions and 158 deletions

View File

@ -19,10 +19,10 @@ defmodule PlausibleWeb.Live.Components.ComboBox.StaticSearch do
choices
|> Enum.map(fn
{_, value} = choice ->
{choice, weight(value, input)}
{choice, weight(value, input, opts)}
value ->
{value, weight(value, input)}
{value, weight(value, input, opts)}
end)
|> Enum.reject(fn {_choice, weight} -> weight < weight_threshold end)
|> Enum.sort_by(fn {_choice, weight} -> weight end, :desc)
@ -32,8 +32,9 @@ defmodule PlausibleWeb.Live.Components.ComboBox.StaticSearch do
end
end
defp weight(value, input) do
value = to_string(value)
defp weight(value, input, opts) do
to_str = Keyword.get(opts, :to_string, &to_string/1)
value = to_str.(value)
case {value, input} do
{value, input} when value == input ->

View File

@ -19,7 +19,7 @@ defmodule PlausibleWeb.Live.FunnelSettings do
|> assign_new(:site, fn ->
Sites.get_for_user!(user_id, domain, [:owner, :admin, :super_admin])
end)
|> assign_new(:funnels, fn %{site: site} ->
|> assign_new(:all_funnels, fn %{site: %{id: ^site_id} = site} ->
Funnels.list(site)
end)
|> assign_new(:goal_count, fn %{site: site} ->
@ -28,9 +28,10 @@ defmodule PlausibleWeb.Live.FunnelSettings do
{:ok,
assign(socket,
site_id: site_id,
domain: domain,
displayed_funnels: socket.assigns.all_funnels,
add_funnel?: false,
filter_text: "",
current_user_id: user_id
)}
end
@ -56,9 +57,9 @@ defmodule PlausibleWeb.Live.FunnelSettings do
<.live_component
module={PlausibleWeb.Live.FunnelSettings.List}
id="funnels-list"
funnels={@funnels}
funnels={@displayed_funnels}
filter_text={@filter_text}
/>
<button type="button" class="button mt-6" phx-click="add-funnel">+ Add Funnel</button>
</div>
<div :if={@goal_count < Funnel.min_steps()}>
@ -75,12 +76,23 @@ defmodule PlausibleWeb.Live.FunnelSettings do
"""
end
def handle_event("add-funnel", _value, socket) do
{:noreply, assign(socket, add_funnel?: true)}
def handle_event("reset-filter-text", _params, socket) do
{:noreply, assign(socket, filter_text: "", displayed_funnels: socket.assigns.all_funnels)}
end
def handle_event("cancel-add-funnel", _value, socket) do
{:noreply, assign(socket, add_funnel?: false)}
def handle_event("filter", %{"filter-text" => filter_text}, socket) do
new_list =
PlausibleWeb.Live.Components.ComboBox.StaticSearch.suggest(
filter_text,
socket.assigns.all_funnels,
to_string: & &1.name
)
{:noreply, assign(socket, displayed_funnels: new_list, filter_text: filter_text)}
end
def handle_event("add-funnel", _value, socket) do
{:noreply, assign(socket, add_funnel?: true)}
end
def handle_event("delete-funnel", %{"funnel-id" => id}, socket) do
@ -91,13 +103,28 @@ defmodule PlausibleWeb.Live.FunnelSettings do
:ok = Funnels.delete(site, id)
socket = put_flash(socket, :success, "Funnel deleted successfully")
Process.send_after(self(), :clear_flash, 5000)
{:noreply, assign(socket, funnels: Enum.reject(socket.assigns.funnels, &(&1.id == id)))}
{:noreply,
assign(socket,
all_funnels: Enum.reject(socket.assigns.all_funnels, &(&1.id == id)),
displayed_funnels: Enum.reject(socket.assigns.displayed_funnels, &(&1.id == id))
)}
end
def handle_info({:funnel_saved, funnel}, socket) do
socket = put_flash(socket, :success, "Funnel saved successfully")
Process.send_after(self(), :clear_flash, 5000)
{:noreply, assign(socket, add_funnel?: false, funnels: [funnel | socket.assigns.funnels])}
{:noreply,
assign(socket,
add_funnel?: false,
all_funnels: [funnel | socket.assigns.all_funnels],
displayed_funnels: [funnel | socket.assigns.displayed_funnels]
)}
end
def handle_info(:cancel_add_funnel, socket) do
{:noreply, assign(socket, add_funnel?: false)}
end
def handle_info(:clear_flash, socket) do

View File

@ -6,9 +6,9 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
"""
use Phoenix.LiveView
use Phoenix.HTML
use Plausible.Funnel
import PlausibleWeb.Live.Components.Form
alias Plausible.{Sites, Goals}
def mount(_params, %{"current_user_id" => user_id, "domain" => domain}, socket) do
@ -39,45 +39,64 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
def render(assigns) do
~H"""
<div id="funnel-form" class="grid grid-cols-2 gap-6 mt-6">
<div class="col-span-2 sm:col-span-2">
<div
class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity z-50"
phx-window-keydown="cancel-add-funnel"
phx-key="Escape"
>
</div>
<div class="fixed inset-0 flex items-center justify-center mt-16 z-50 overlofw-y-auto overflow-x-hidden">
<div class="w-2/5 h-full">
<div id="funnel-form">
<.form
:let={f}
for={@form}
phx-change="validate"
phx-submit="save"
phx-target="#funnel-form"
phx-click-away="cancel-add-funnel"
onkeydown="return event.key != 'Enter';"
class="bg-white dark:bg-gray-800 shadow-md rounded px-8 pt-6 pb-8 mb-4 mt-8"
>
<label
for={f[:name].name}
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
<h2 class="text-xl font-black dark:text-gray-100 mb-6">Add Funnel</h2>
<label for={f[:name].name} class="block mb-3 font-medium dark:text-gray-100">
Funnel Name
</label>
<.input field={f[:name]} />
<div id="steps-builder">
<label class="mt-6 block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<.input
field={f[:name]}
phx-debounce={200}
autocomplete="off"
placeholder="e.g. From Blog to Purchase"
autofocus
class="w-full focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-900 dark:text-gray-300 block w-7/12 rounded-md sm:text-sm border-gray-300 dark:border-gray-500 w-full p-2 mt-2"
/>
<div id="steps-builder" class="mt-6">
<label class="font-medium dark:text-gray-100">
Funnel Steps
</label>
<div :for={step_idx <- @step_ids} class="flex mb-3">
<div :for={step_idx <- @step_ids} class="flex mb-3 mt-3">
<div class="w-2/5 flex-1">
<.live_component
submit_name="funnel[steps][][goal_id]"
module={PlausibleWeb.Live.Components.ComboBox}
suggest_fun={&PlausibleWeb.Live.Components.ComboBox.StaticSearch.suggest/2}
id={"step-#{step_idx}"}
options={reject_alrady_selected("step-#{step_idx}", @goals, @selections_made)}
options={reject_already_selected("step-#{step_idx}", @goals, @selections_made)}
/>
</div>
<div class="w-min inline-flex items-center align-middle">
<.remove_step_button :if={length(@step_ids) > Funnel.min_steps()} step_idx={step_idx} />
<.remove_step_button
:if={length(@step_ids) > Funnel.min_steps()}
step_idx={step_idx}
/>
</div>
<div class="w-2/5 inline-flex items-center ml-2 mb-3 text-gray-500 dark:text-gray-400">
<div class="w-4/12 align-middle ml-4 text-gray-500 dark:text-gray-400">
<.evaluation
:if={@evaluation_result}
result={@evaluation_result}
@ -109,40 +128,11 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
<% else %>
<.submit_button />
<% end %>
<.cancel_button />
</div>
</div>
</.form>
</div>
</div>
"""
end
attr(:field, Phoenix.HTML.FormField)
def input(assigns) do
~H"""
<div phx-feedback-for={@field.name}>
<input
autocomplete="off"
autofocus
type="text"
id={@field.id}
name={@field.name}
value={@field.value}
phx-debounce="300"
class="focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-900 dark:text-gray-300 block w-7/12 rounded-md sm:text-sm border-gray-300 dark:border-gray-500"
/>
<.error :for={{msg, _} <- @field.errors}>Funnel Name <%= msg %></.error>
</div>
"""
end
def error(assigns) do
~H"""
<div class="mt-2 text-sm text-red-600">
<%= render_slot(@inner_block) %>
</div>
"""
end
@ -151,7 +141,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
def remove_step_button(assigns) do
~H"""
<div class="inline-flex items-center ml-2 mb-4 text-red-600">
<div class="inline-flex items-center ml-2 mb-4 text-red-500">
<svg
id={"remove-step-#{@step_idx}"}
class="feather feather-sm cursor-pointer"
@ -185,7 +175,9 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
def submit_button(assigns) do
~H"""
<button id="save" type="submit" class="button mt-6">Save</button>
<button id="save" type="submit" class="button text-base font-bold w-full">
Add Funnel
</button>
"""
end
@ -194,23 +186,9 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
<button
type="none"
id="save"
class="inline-block mt-6 px-4 py-2 border border-gray-300 dark:border-gray-500 text-sm leading-5 font-medium rounded-md text-gray-300 bg-white dark:bg-gray-800 hover:text-gray-500 dark:hover:text-gray-400 focus:outline-none focus:border-blue-300 focus:ring active:text-gray-800 active:bg-gray-50 transition ease-in-out duration-150 cursor-not-allowed"
class="w-full text-base font-bold py-2 border border-gray-300 dark:border-gray-500 rounded-md text-gray-300 bg-white dark:bg-gray-800 hover:text-gray-500 dark:hover:text-gray-400 focus:outline-none focus:border-blue-300 focus:ring active:text-gray-800 active:bg-gray-50 transition ease-in-out duration-150 cursor-not-allowed"
>
Save
</button>
"""
end
def cancel_button(assigns) do
~H"""
<button
type="button"
id="cancel"
class="inline-block mt-4 ml-2 px-4 py-2 border border-gray-300 dark:border-gray-500 text-sm leading-5 font-medium rounded-md text-red-700 bg-white dark:bg-gray-800 hover:text-red-500 dark:hover:text-red-400 focus:outline-none focus:border-blue-300 focus:ring active:text-red-800 active:bg-gray-50 transition ease-in-out duration-150 "
phx-click="cancel-add-funnel"
phx-target="#funnel-settings-main"
>
Cancel
Add Funnel
</button>
"""
end
@ -220,7 +198,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
def evaluation(assigns) do
~H"""
<span class="text-xs" id={"step-eval-#{@at}"}>
<span class="text-sm" id={"step-eval-#{@at}"}>
<% step = Enum.at(@result.steps, @at) %>
<span :if={step && @at == 0}>
<span
@ -288,6 +266,11 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
end
end
def handle_event("cancel-add-funnel", _value, socket) do
send(socket.parent_pid, :cancel_add_funnel)
{:noreply, socket}
end
def handle_info({:selection_made, %{submit_value: goal_id, by: combo_box}}, socket) do
selections_made = store_selection(socket.assigns, combo_box, goal_id)
@ -353,7 +336,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
steps
)
query = Plausible.Stats.Query.from(site, %{"period" => "all"})
query = Plausible.Stats.Query.from(site, %{"period" => "month"})
{:ok, {definition, query}}
end
@ -392,7 +375,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
Map.delete(selections_made, step_input_id)
end
defp reject_alrady_selected(combo_box, goals, selections_made) do
defp reject_already_selected(combo_box, goals, selections_made) do
selection_ids =
Enum.map(selections_made, fn
{_, %{id: goal_id}} -> goal_id

View File

@ -13,14 +13,44 @@ defmodule PlausibleWeb.Live.FunnelSettings.List do
def render(assigns) do
~H"""
<div>
<div class="border-t border-gray-200 pt-4 sm:flex sm:items-center sm:justify-between">
<form id="filter-form" phx-change="filter">
<div class="text-gray-800 text-sm inline-flex items-center">
<div class="relative rounded-md shadow-sm flex">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<Heroicons.magnifying_glass class="feather mr-1 dark:text-gray-300" />
</div>
<input
type="text"
name="filter-text"
id="filter-text"
class="pl-8 shadow-sm dark:bg-gray-900 dark:text-gray-300 focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 dark:border-gray-500 rounded-md dark:bg-gray-800"
placeholder="Search Funnels"
value={@filter_text}
/>
</div>
<Heroicons.backspace
:if={String.trim(@filter_text) != ""}
class="feather ml-2 cursor-pointer hover:text-red-500 dark:text-gray-300 dark:hover:text-red-500"
phx-click="reset-filter-text"
id="reset-filter"
/>
</div>
</form>
<div class="mt-4 flex sm:ml-4 sm:mt-0">
<button type="button" phx-click="add-funnel" class="button">
+ Add Funnel
</button>
</div>
</div>
<%= if Enum.count(@funnels) > 0 do %>
<div class="mt-4">
<%= for funnel <- @funnels do %>
<div class="border-b border-gray-300 dark:border-gray-500 py-3 flex justify-between">
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
<%= funnel.name %>
<br />
<span class="text-sm text-gray-400 font-normal">
<span class="text-sm text-gray-400 font-normal block mt-1">
<%= funnel.steps_count %>-step funnel
</span>
</span>
@ -52,7 +82,21 @@ defmodule PlausibleWeb.Live.FunnelSettings.List do
<% end %>
</div>
<% else %>
<div class="mt-4 dark:text-gray-100">No funnels configured for this site yet</div>
<p class="text-sm text-gray-800 dark:text-gray-200 mt-12 mb-8 text-center">
<span :if={String.trim(@filter_text) != ""}>
No funnels found for this site. Please refine or
<a
class="text-indigo-500 cursor-pointer underline"
phx-click="reset-filter-text"
id="reset-filter-hint"
>
reset your search.
</a>
</span>
<span :if={String.trim(@filter_text) == "" && Enum.empty?(@funnels)}>
No funnels configured for this site.
</span>
</p>
<% end %>
</div>
"""

View File

@ -149,7 +149,7 @@ defmodule PlausibleWeb.Live.PropsSettings do
%{assigns: %{site: site}} = socket
)
when is_binary(prop) do
allowed_event_props = [prop | site.allowed_event_props]
allowed_event_props = [prop | site.allowed_event_props || []]
socket =
socket

View File

@ -31,6 +31,17 @@ defmodule PlausibleWeb.Live.Components.ComboBox.StaticSearchTest do
options = fake_options(["OS", "Version", "Logged In"])
assert [] = StaticSearch.suggest("cow", options)
end
test "uses custom to_string/1 function" do
options = fake_options([%{key: "OS"}, %{key: "Version"}, %{key: "Logged In"}])
assert [{_, %{key: "Version"}}] = StaticSearch.suggest("vers", options, to_string: & &1.key)
end
test "accepts custom jaro distance threshold" do
options = fake_options(["Joanna", "Joel", "John"])
assert [_, _, _] = StaticSearch.suggest("Joa", options, weight_threshold: 0.5)
assert [{_, "Joanna"}] = StaticSearch.suggest("Joa", options, weight_threshold: 0.9)
end
end
defp fake_options(option_names) do

View File

@ -17,6 +17,14 @@ defmodule PlausibleWeb.Live.FunnelSettingsTest do
assert element_exists?(resp, "a[href=\"https://plausible.io/docs/funnel-analysis\"]")
end
test "search funnels input is rendered", %{conn: conn, site: site} do
setup_goals(site)
conn = get(conn, "/#{site.domain}/settings/funnels")
resp = html_response(conn, 200)
assert element_exists?(resp, ~s/input[type="text"]#filter-text/)
assert element_exists?(resp, ~s/form[phx-change="filter"]#filter-form/)
end
test "lists funnels with delete actions", %{conn: conn, site: site} do
{:ok, [f1_id, f2_id]} = setup_funnels(site)
conn = get(conn, "/#{site.domain}/settings/funnels")
@ -41,7 +49,7 @@ defmodule PlausibleWeb.Live.FunnelSettingsTest do
assert element_exists?(resp, ~S/button[phx-click="add-funnel"]/)
end
test "if not enough goals are present, a hint to create goals is rendered", %{
test "if not enough goals are present, renders a hint to create goals + no search", %{
conn: conn,
site: site
} do
@ -53,12 +61,59 @@ defmodule PlausibleWeb.Live.FunnelSettingsTest do
add_goals_path = Routes.site_path(conn, :settings_goals, site.domain)
assert element_exists?(doc, ~s/a[href="#{add_goals_path}"]/)
refute element_exists?(doc, ~s/input[type="text"]#filter-text/)
refute element_exists?(doc, ~s/form[phx-change="filter"]#filter-form/)
end
end
describe "FunnelSettings live view" do
setup [:create_user, :log_in, :create_site]
test "allows list filtering / search", %{conn: conn, site: site} do
{:ok, _} = setup_funnels(site, ["Funnel One", "Search Me"])
{lv, html} = get_liveview(conn, site, with_html?: true)
assert html =~ "Funnel One"
assert html =~ "Search Me"
html = type_into_search(lv, "search")
refute html =~ "Funnel One"
assert html =~ "Search Me"
end
test "allows resetting filter text via backspace icon", %{conn: conn, site: site} do
{:ok, _} = setup_funnels(site, ["Funnel One", "Another"])
{lv, html} = get_liveview(conn, site, with_html?: true)
refute element_exists?(html, ~s/svg[phx-click="reset-filter-text"]#reset-filter/)
html = type_into_search(lv, "one")
refute html =~ "Another"
assert element_exists?(html, ~s/svg[phx-click="reset-filter-text"]#reset-filter/)
html = lv |> element(~s/svg#reset-filter/) |> render_click()
assert html =~ "Funnel One"
assert html =~ "Another"
end
test "allows resetting filter text via no match link", %{conn: conn, site: site} do
{:ok, _} = setup_funnels(site)
lv = get_liveview(conn, site)
html = type_into_search(lv, "Definitely this is not going to render any matches")
assert html =~ "No funnels found for this site. Please refine or"
assert html =~ "reset your search"
assert element_exists?(html, ~s/a[phx-click="reset-filter-text"]#reset-filter-hint/)
html = lv |> element(~s/a#reset-filter-hint/) |> render_click()
refute html =~ "No funnels found for this site. Please refine or"
end
test "allows to delete funnels", %{conn: conn, site: site} do
{:ok, [f1_id, _f2_id]} = setup_funnels(site)
@ -83,7 +138,11 @@ defmodule PlausibleWeb.Live.FunnelSettingsTest do
lv = get_liveview(conn, site)
doc = render_click(lv, "add-funnel")
assert element_exists?(doc, ~s/form[phx-change="validate"][phx-submit="save"]/)
assert element_exists?(
doc,
~s/form[phx-change="validate"][phx-submit="save"][phx-click-away="cancel-add-funnel"]/
)
assert element_exists?(doc, ~s/form input[type="text"][name="funnel[name]"]/)
assert element_exists?(
@ -222,43 +281,22 @@ defmodule PlausibleWeb.Live.FunnelSettingsTest do
doc = lv |> element("#funnel-eval") |> render()
assert text_of_element(doc, ~s/#funnel-eval/) =~ "Last month conversion rate: 0%"
end
test "cancel buttons renders the funnel list", %{
conn: conn,
site: site
} do
setup_goals(site)
lv = get_liveview(conn, site)
doc = lv |> element(~s/button[phx-click="add-funnel"]/) |> render_click()
cancel_button = ~s/button#cancel[phx-click="cancel-add-funnel"]/
assert element_exists?(doc, cancel_button)
doc =
lv
|> element(cancel_button)
|> render_click()
assert doc =~ "No funnels configured for this site yet"
assert element_exists?(doc, ~S/button[phx-click="add-funnel"]/)
end
end
defp setup_funnels(site) do
defp setup_funnels(site, names \\ []) do
{:ok, [g1, g2]} = setup_goals(site)
{:ok, f1} =
Plausible.Funnels.create(
site,
"From blog to signup",
Enum.at(names, 0) || "From blog to signup",
[%{"goal_id" => g1.id}, %{"goal_id" => g2.id}]
)
{:ok, f2} =
Plausible.Funnels.create(
site,
"From signup to blog",
Enum.at(names, 1) || "From signup to blog",
[%{"goal_id" => g2.id}, %{"goal_id" => g1.id}]
)
@ -281,4 +319,13 @@ defmodule PlausibleWeb.Live.FunnelSettingsTest do
lv
end
end
defp type_into_search(lv, text) do
lv
|> element("form#filter-form")
|> render_change(%{
"_target" => ["filter-text"],
"filter-text" => "#{text}"
})
end
end