From 7d61cb65a17608dfeda81cd3c0cf8923b0d1eab7 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 15 Oct 2026 13:56:47 +0200 Subject: [PATCH 01/36] Implement `suggest` and `step_to_goal` in `Stats.Funnel` --- extra/lib/plausible/stats/funnel.ex | 54 +++++++++++++++++++++++ extra/lib/plausible/stats/journey/step.ex | 6 ++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/extra/lib/plausible/stats/funnel.ex b/extra/lib/plausible/stats/funnel.ex index ba18393754eb..75858a64dd4d 100644 --- a/extra/lib/plausible/stats/funnel.ex +++ b/extra/lib/plausible/stats/funnel.ex @@ -15,8 +15,62 @@ defmodule Plausible.Stats.Funnel do alias Plausible.ClickhouseRepo alias Plausible.Stats.{Base, Comparisons, DateTimeRange, Query} + alias Plausible.Stats.Exploration alias Plausible.Stats.Goal.Revenue + @spec suggest(Plausible.Site.t(), Plausible.Stats.Query.t(), Keyword.t()) :: + {[Plausible.Goal.t()], [Plausible.Goal.t()]} + def suggest(site, query, opts \\ []) do + steps = Keyword.get(opts, :steps, []) + goals = Keyword.get(opts, :goals, []) + input = Keyword.get(opts, :input, "") + + journey = Enum.map(steps, &Exploration.Journey.Step.from/1) + + goals_map = + Map.new(goals, fn g -> {{g.event_name || "pageview", g.page_path || ""}, g} end) + + {:ok, suggestions} = + site + |> Exploration.next_steps(query, journey, + search_term: input, + include_wildcard?: false + ) + + {suggestions, unmatched_goals} = + suggestions + |> Enum.reject(&(&1.step.name == Exploration.Journey.Step.journey_end_event())) + |> Enum.reduce({[], goals_map}, fn suggestion, {suggestions, goals_map} -> + case step_to_goal(Map.fetch!(suggestion, :step), goals_map) do + {goal, :static, key} -> + {[goal | suggestions], Map.delete(goals_map, key)} + + {goal, :dynamic} -> + {[goal | suggestions], goals_map} + end + end) + + {Enum.reverse(suggestions), Map.values(unmatched_goals)} + end + + def step_to_goal(step, goals_map) do + goal = goals_map[{step.name, step.pathname}] + + if not is_nil(goal) and step.is_goal do + {goal, :static, {step.name, step.pathname}} + else + goal = + %Plausible.Goal{} + |> Plausible.Goal.changeset(%{ + event_name: if(step.name != "pageview", do: step.name), + page_path: step.pathname + }) + |> Ecto.Changeset.apply_changes() + + {goal, :dynamic} + end + end + @spec funnel(Plausible.Site.t(), Plausible.Stats.Query.t(), Funnel.t() | pos_integer()) :: {:ok, map()} | {:error, :funnel_not_found} def funnel(site, query, funnel_id) when is_integer(funnel_id) do diff --git a/extra/lib/plausible/stats/journey/step.ex b/extra/lib/plausible/stats/journey/step.ex index 0b205ea401dd..a0bae45c4e47 100644 --- a/extra/lib/plausible/stats/journey/step.ex +++ b/extra/lib/plausible/stats/journey/step.ex @@ -21,7 +21,11 @@ defmodule Plausible.Stats.Exploration.Journey.Step do @spec journey_end_label() :: String.t() def journey_end_label, do: @journey_end_label - @spec from(map()) :: t() + @spec from(map() | Plausible.Goal.t()) :: t() + def from(%Plausible.Goal{} = goal) do + new(goal.event_name || "pageview", goal.page_path || "", false, 0, true) + end + def from(step) do new(step.name, step.pathname, step.includes_subpaths, step.subpaths_count, step.is_goal) end From d57790811048eea583e4456c269b6a740ee55a06 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 15 Oct 2026 14:01:25 +0200 Subject: [PATCH 02/36] Add cursor icon component Co-authored-by: Sanne de Vries --- lib/plausible_web/components/icons.ex | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/lib/plausible_web/components/icons.ex b/lib/plausible_web/components/icons.ex index 551b7855ec5c..c726499c8189 100644 --- a/lib/plausible_web/components/icons.ex +++ b/lib/plausible_web/components/icons.ex @@ -255,4 +255,20 @@ defmodule PlausibleWeb.Components.Icons do """ end + + attr :class, :any, default: [] + + def cursor_icon(assigns) do + ~H""" + + + + """ + end end From 300bf71f1a5e40f4376c9f6f9888c4af79b5d9e7 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 15 Oct 2026 14:04:04 +0200 Subject: [PATCH 03/36] Implement wrapper for `Prima.Combobox` Co-authored-by: Sanne de Vries --- assets/js/liveview/live_socket.js | 12 +- .../components/combo_box/static_search.ex | 5 +- .../live/components/prima_combobox.ex | 173 ++++++++++++++++++ 3 files changed, 187 insertions(+), 3 deletions(-) create mode 100644 lib/plausible_web/live/components/prima_combobox.ex diff --git a/assets/js/liveview/live_socket.js b/assets/js/liveview/live_socket.js index ba097a872c0e..f5f973b971ba 100644 --- a/assets/js/liveview/live_socket.js +++ b/assets/js/liveview/live_socket.js @@ -7,7 +7,7 @@ import 'phoenix_html' import { Socket } from 'phoenix' import { LiveSocket } from 'phoenix_live_view' -import { Modal, Dropdown, Listbox } from 'prima' +import { Modal, Dropdown, Listbox, Combobox } from 'prima' import topbar from 'topbar' /* eslint-enable import/no-unresolved */ @@ -20,7 +20,15 @@ import MemberRows from './member-rows' let csrfToken = document.querySelector("meta[name='csrf-token']") let websocketUrl = document.querySelector("meta[name='websocket-url']") if (csrfToken && websocketUrl) { - let Hooks = { Modal, Dropdown, Listbox, Confirm, CopySnippet, MemberRows } + let Hooks = { + Modal, + Dropdown, + Listbox, + Confirm, + CopySnippet, + MemberRows, + Combobox + } Hooks.VerificationLifecycle = { mounted() { diff --git a/lib/plausible_web/live/components/combo_box/static_search.ex b/lib/plausible_web/live/components/combo_box/static_search.ex index 1a68f3e251eb..e13d97926110 100644 --- a/lib/plausible_web/live/components/combo_box/static_search.ex +++ b/lib/plausible_web/live/components/combo_box/static_search.ex @@ -9,7 +9,7 @@ defmodule PlausibleWeb.Live.Components.ComboBox.StaticSearch do Allows fuzzy matching based on Jaro Distance. """ - @spec suggest(String.t(), [{any(), any()}]) :: [{any(), any()}] + @spec suggest(String.t(), [item]) :: item when item: {any(), any()} | {any(), any(), list()} def suggest(input, choices, opts \\ []) do input = String.trim(input) @@ -21,6 +21,9 @@ defmodule PlausibleWeb.Live.Components.ComboBox.StaticSearch do {_, value} = choice -> {choice, weight(value, input, opts)} + {_, value, _} = choice -> + {choice, weight(value, input, opts)} + value -> {value, weight(value, input, opts)} end) diff --git a/lib/plausible_web/live/components/prima_combobox.ex b/lib/plausible_web/live/components/prima_combobox.ex new file mode 100644 index 000000000000..6df684281a96 --- /dev/null +++ b/lib/plausible_web/live/components/prima_combobox.ex @@ -0,0 +1,173 @@ +defmodule PlausibleWeb.Live.Components.PrimaCombobox do + @moduledoc false + use PlausibleWeb, :live_component + + import Prima.Combobox + + def mount(socket) do + socket = + socket + |> stream(:suggestions, socket.assigns[:options] || []) + + {:ok, socket} + end + + def update(assigns, socket) do + selected = + case assigns[:selected] do + {value, display_name} -> %{value: value, display: display_name} + _ -> socket.assigns[:selected] + end + + socket = + socket + |> assign(assigns) + |> assign(:selected, selected) + |> assign_new(:suggestions, fn -> [] end) + |> assign_new(:last_query, fn -> "" end) + + suggest_trigger = assigns[:suggest_trigger] && hash(assigns[:suggest_trigger]) + + socket = + if suggest_trigger && suggest_trigger != socket.assigns[:suggest_trigger] do + suggestions = handle_suggest(socket, socket.assigns.last_query) + assign(socket, :suggestions, suggestions) + else + socket + end + + {:ok, assign(socket, :suggest_trigger, suggest_trigger)} + end + + attr(:id, :string, required: true) + attr(:selected, :any) + attr(:placeholder, :string, default: "Select option or search by typing") + attr(:class, :string, default: "") + attr(:input_class, :string, default: "") + attr(:dropdown_class, :string, default: "") + attr(:submit_name, :string, required: true) + attr(:options, :list, default: []) + + def render(assigns) do + ~H""" +
+ <.combobox :let={input_value} name={@submit_name} id={@id} class={@class} selections={@selected}> + <.combobox_input + value={input_value} + class={ + Enum.join( + [ + "peer input-base input-md w-full pr-10", + @input_class + ], + " " + ) + } + on_search="async_combobox_search" + phx-target={@myself} + placeholder={@placeholder} + /> + + <.spinner class="absolute inset-y-3 right-3.5 invisible phx-hook-loading:peer-focus:visible" /> + + <.combobox_options + id={"input-picker-dropdown-#{@id}"} + phx-update="replace" + offset={4} + class={ + Enum.join( + [ + "relative z-50 max-h-60 w-full overflow-y-auto overflow-x-hidden p-1 rounded-md shadow-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-800 focus:outline-hidden", + @dropdown_class + ], + " " + ) + } + > +
+ <%= for {idx, {value, display_name, opts}} <- @suggestions do %> +
+ +
+ {opts[:title]} +
+ + <.combobox_option + id={"input-picker-dropdown-#{@id}-option-#{idx}"} + class="px-4 py-2.5 leading-5 rounded-md text-sm text-gray-800 dark:text-gray-200 hover:text-gray-900 dark:hover:text-gray-100 cursor-pointer select-none data-focus:bg-gray-100 data-focus:text-gray-900 dark:data-focus:bg-gray-700 dark:data-focus:text-gray-100 data-selected:bg-gray-100 data-selected:text-gray-900 dark:data-selected:bg-gray-700 dark:data-selected:text-gray-100" + value={value} + display={display_name} + > + + <.icon :if={opts[:icon]} name={opts[:icon]} /> + {display_name} + + + <% end %> + +
+ No matches found. Try searching for something different. +
+
+ + +
+ """ + end + + @icons %{ + cursor: &PlausibleWeb.Components.Icons.cursor_icon/1, + eye: &Heroicons.eye/1 + } + + defp icon(assigns) do + {name, assigns} = Map.pop(assigns, :name) + + assigns = assign(assigns, :class, "size-4 shrink-0") + + if icon_component = @icons[name] do + icon_component.(assigns) + else + ~H"" + end + end + + def handle_event("async_combobox_search", %{"query" => query}, socket) do + suggestions = handle_suggest(socket, query) + + {:noreply, assign(socket, suggestions: suggestions, last_query: query)} + end + + defp handle_suggest(socket, query) do + options = socket.assigns[:options] || [] + + if suggest_fun = socket.assigns[:suggest_fun] do + suggest_fun.(query, options) + else + Enum.filter(options, fn option -> + String.contains?(String.downcase(option), String.downcase(query)) + end) + end + |> Enum.map(&{hash(&1), &1}) + end + + defp hash(value) do + value + |> :erlang.term_to_binary() + |> then(&:crypto.hash(:md5, &1)) + |> Base.encode16() + |> String.downcase() + end +end From f840b944b4b9a68a6e0c5669daf85c8e5b7139e3 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 15 Oct 2026 14:04:59 +0200 Subject: [PATCH 04/36] Add `display_name` virtual field to `DynamicStep` embed --- extra/lib/plausible/funnel/dynamic_step.ex | 4 +++- lib/plausible/goal.ex | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/extra/lib/plausible/funnel/dynamic_step.ex b/extra/lib/plausible/funnel/dynamic_step.ex index 7dfd61d8cdad..0793f0de407f 100644 --- a/extra/lib/plausible/funnel/dynamic_step.ex +++ b/extra/lib/plausible/funnel/dynamic_step.ex @@ -12,6 +12,7 @@ defmodule Plausible.Funnel.DynamicStep do @fields [ :event_name, :page_path, + :display_name, :scroll_threshold, :custom_props, :currency @@ -22,6 +23,7 @@ defmodule Plausible.Funnel.DynamicStep do field :step_order, :integer field :event_name, :string field :page_path, :string + field :display_name, :string, virtual: true field :scroll_threshold, :integer, default: -1 field :currency, Ecto.Enum, values: Money.Currency.known_current_currencies() @@ -44,7 +46,7 @@ defmodule Plausible.Funnel.DynamicStep do def as_goal(step) do %Plausible.Goal{ id: 0, - display_name: display_name(step), + display_name: step.display_name || display_name(step), event_name: step.event_name, page_path: step.page_path, scroll_threshold: step.scroll_threshold, diff --git a/lib/plausible/goal.ex b/lib/plausible/goal.ex index 202b6bf851ca..5c67503045b7 100644 --- a/lib/plausible/goal.ex +++ b/lib/plausible/goal.ex @@ -75,7 +75,6 @@ defmodule Plausible.Goal do goal |> cast(attrs, @fields) |> base_changeset() - |> maybe_put_display_name() |> unique_constraint(:display_name, name: :goals_display_name_unique) |> unique_constraint(:event_name, name: :goals_event_config_unique) |> unique_constraint([:page_path, :scroll_threshold], @@ -102,6 +101,7 @@ defmodule Plausible.Goal do message: "Should be -1 (missing) or in range [0, 100]" ) |> maybe_drop_currency() + |> maybe_put_display_name() end @spec display_name(t()) :: String.t() From 435c64a9b16db9310a708adec4fa598651d8da5c Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 15 Oct 2026 14:06:42 +0200 Subject: [PATCH 05/36] Switch `FunnelSettings.Form` to prima combobox and enable dynamic funnels Co-authored-by: Sanne de Vries --- .../live/funnel_settings/form.ex | 322 +++++++++++------- 1 file changed, 190 insertions(+), 132 deletions(-) diff --git a/extra/lib/plausible_web/live/funnel_settings/form.ex b/extra/lib/plausible_web/live/funnel_settings/form.ex index faa6c1d8953c..879decb3840d 100644 --- a/extra/lib/plausible_web/live/funnel_settings/form.ex +++ b/extra/lib/plausible_web/live/funnel_settings/form.ex @@ -1,15 +1,14 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do @moduledoc """ - Phoenix LiveComponent that renders a form used for setting up funnels. - Makes use of dynamically placed `PlausibleWeb.Live.FunnelSettings.ComboBox` components - to allow building searchable funnel definitions out of list of goals available. + Phoenix LiveComponent that renders a form used for setting up dynamic funnels. """ use PlausibleWeb, :live_view use Plausible.Funnel import PlausibleWeb.Live.Components.Form - alias Plausible.{Goals, Funnels} + + alias Plausible.Funnels alias Plausible.Stats.QueryBuilder def mount(_params, %{"domain" => domain} = session, socket) do @@ -23,32 +22,19 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do ] ) - # We'll have the options trimmed to only the data we care about, to keep - # it minimal at the socket assigns, yet, we want to retain specific %Goal{} - # fields, so that `String.Chars` protocol and `Funnels.ephemeral_definition/3` - # are applicable downstream. goals = site - |> Goals.for_site() + |> Plausible.Goals.for_site() |> Enum.map(fn goal -> - {goal.id, - struct!( - Plausible.Goal, - Map.take(goal, [ - :id, - :display_name, - :event_name, - :page_path, - :currency, - :scroll_threshold, - :custom_props - ]) - )} + struct!( + Plausible.Goal, + Map.take(goal, [:id, :display_name, :event_name, :page_path, :currency]) + ) end) socket = socket - |> assign(goals: goals, site: site, evaluation_result: nil) + |> assign(site: site, goals: goals, evaluation_result: nil) |> prepare_socket(site, session["funnel_id"]) {:ok, socket} @@ -152,17 +138,14 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
<.live_component - selected={find_preselected(@funnel, @funnel_modified?, step_idx)} - submit_name={"funnel[steps][#{step_idx}][goal_id]"} - module={PlausibleWeb.Live.Components.ComboBox} - suggest_fun={&PlausibleWeb.Live.Components.ComboBox.StaticSearch.suggest/2} - on_selection_made={ - fn value, by_id -> - send(self(), {:selection_made, %{submit_value: value, by: by_id}}) - end - } id={"step-#{step_idx}"} - options={reject_already_selected(@goals, @selections_made)} + module={PlausibleWeb.Live.Components.PrimaCombobox} + submit_name={"funnel[steps][#{step_idx}][step_data]"} + selected={selected_option(@steps, @funnel_modified?, step_idx)} + suggest_trigger={@steps} + suggest_fun={ + fn input, _choices -> suggest(input, @site, @goals, @steps, step_idx) end + } />
@@ -183,10 +166,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
- <.add_step_button :if={ - length(@step_ids) < Funnel.max_steps() and - map_size(@selections_made) < length(@goals) - } /> + <.add_step_button :if={length(@step_ids) < Funnel.max_steps()} />

<%= if @evaluation_result do %> Last month conversion rate: <%= List.last(@evaluation_result.steps).conversion_rate %>% @@ -197,10 +177,10 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do <.button id="save" type="submit" - class="w-full mt-6" + class="w-full" disabled={ - has_steps_errors?(f) or map_size(@selections_made) < Funnel.min_steps() or - length(@step_ids) > map_size(@selections_made) + has_steps_errors?(f) or map_size(@steps) < Funnel.min_steps() or + length(@step_ids) > map_size(@steps) } > {if @funnel, do: "Update", else: "Add"} funnel @@ -288,12 +268,11 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do def handle_event("remove-step", %{"step-idx" => idx}, socket) do idx = String.to_integer(idx) step_ids = List.delete(socket.assigns.step_ids, idx) - selections_made = drop_selection(socket.assigns.selections_made, idx) + steps = drop_step(socket.assigns.steps, idx) send(self(), :evaluate_funnel) - {:noreply, - assign(socket, step_ids: step_ids, selections_made: selections_made, funnel_modified?: true)} + {:noreply, assign(socket, step_ids: step_ids, steps: steps, funnel_modified?: true)} end def handle_event("switch-type", %{"type" => funnel_type_str}, socket) do @@ -306,24 +285,26 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do def handle_event("validate", %{"funnel" => params}, socket) do funnel_type = socket.assigns.funnel_type - steps_from_assigns = - socket.assigns.step_ids - |> Enum.reduce([], fn step_id, acc -> - goal = Map.get(socket.assigns.selections_made, "step-#{step_id}") - if goal, do: [%{"goal_id" => goal.id} | acc], else: acc - end) - |> Enum.reverse() + steps_with_idx = + params + |> Map.get("steps", []) + |> Enum.sort_by(&elem(&1, 0)) + |> Enum.map(fn {idx, payload} -> {idx, JSON.decode!(payload["step_data"])} end) changeset = socket.assigns.site |> Funnels.create_changeset( params["name"], - steps_from_assigns, + Enum.map(steps_with_idx, &elem(&1, 1)), funnel_type: funnel_type ) |> Map.put(:action, :validate) - {:noreply, assign(socket, form: to_form(changeset))} + steps = store_steps(socket.assigns, steps_with_idx) + + send(self(), :evaluate_funnel) + + {:noreply, assign(socket, form: to_form(changeset), steps: steps)} end def handle_event( @@ -337,7 +318,8 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do } } = socket ) do - steps = Enum.map(params["steps"], fn {_idx, payload} -> payload end) + steps = + Enum.map(params["steps"], fn {_idx, payload} -> JSON.decode!(payload["step_data"]) end) save_fn = case funnel do @@ -374,23 +356,154 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do {: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) + def handle_info(:evaluate_funnel, socket) do + {:noreply, evaluate_funnel(socket)} + end - send(self(), :evaluate_funnel) + defp suggest(input, site, goals, steps, step_idx) do + steps = + steps + |> Enum.sort_by(&elem(&1, 0)) + |> Enum.take_while(fn {idx, _} -> idx != "step-#{step_idx}" end) + |> Enum.map(&elem(&1, 1)) - {:noreply, - assign(socket, - selections_made: selections_made - )} + query = + QueryBuilder.build!(site, + metrics: [:pageviews], + input_date_range: {:last_n_days, 30} + ) + + {suggestions, unmatched_goals} = + Plausible.Stats.Funnel.suggest(site, query, steps: steps, goals: goals, input: input) + + (suggestions ++ unmatched_goals) + |> exclude_existing_steps(steps) + |> Enum.map( + &{to_step_data(&1), to_string(&1), + [type: if(&1.event_name, do: :custom_event, else: :pageview)]} + ) + |> set_icons() + |> then(&PlausibleWeb.Live.Components.ComboBox.StaticSearch.suggest(input, &1)) + |> segment() end - def handle_info(:evaluate_funnel, socket) do - {:noreply, evaluate_funnel(socket)} + defp set_icons(suggestions) do + Enum.map(suggestions, fn {value, display_name, opts} -> + icon = + case opts[:type] do + :custom_event -> :cursor + :pageview -> :eye + end + + {value, display_name, Keyword.put(opts, :icon, icon)} + end) + end + + defp segment(suggestions) do + suggestions + |> Enum.group_by(fn {_, _, opts} -> opts[:type] end) + |> Enum.sort_by(fn {k, _} -> + case k do + :custom_event -> 0 + :pageview -> 1 + end + end) + |> Enum.reduce([], fn {_, segment}, acc -> + [{value, display_name, opts} | rest] = segment + + separator? = acc != [] + + title = + case opts[:type] do + :custom_event -> "Custom events" + :pageview -> "Pageviews" + end + + opts = + opts + |> Keyword.put(:title, title) + |> Keyword.put(:separator?, separator?) + + acc ++ [{value, display_name, opts} | rest] + end) + end + + defp exclude_existing_steps(goals, steps) do + existing_steps = MapSet.new(steps, &{&1.event_name, &1.page_path}) + Enum.reject(goals, &MapSet.member?(existing_steps, {&1.event_name, &1.page_path})) + end + + defp has_steps_errors?(f) do + not f.source.valid? + end + + defp store_steps(assigns, steps_with_idx) do + Enum.reduce(steps_with_idx, assigns.steps, fn {idx, step_data}, steps -> + Map.put(steps, "step-#{idx}", to_goal(step_data)) + end) + end + + defp drop_step(steps, idx) do + step_input_id = "step-#{idx}" + Map.delete(steps, step_input_id) + end + + defp selected_option(steps, false, idx) do + if goal = steps["step-#{idx}"] do + {to_step_data(goal), to_string(goal)} + end + end + + defp selected_option(_, _, _), do: nil + + defp to_goal(%{"goal_id" => goal_id} = step_data) do + step_data + |> Map.delete("goal_id") + |> to_goal() + |> Map.put(:id, goal_id) + end + + defp to_goal(step_data) do + step_data + |> Funnel.DynamicStep.changeset() + |> Ecto.Changeset.apply_changes() + |> Funnel.DynamicStep.as_goal() + end + + defp to_step_data(%Plausible.Goal{} = goal) do + data = %{ + event_name: goal.event_name, + page_path: goal.page_path, + display_name: goal.display_name, + scroll_threshold: goal.scroll_threshold, + currency: goal.currency + } + + data = + if is_integer(goal.id) and goal.id > 0 do + Map.put(data, :goal_id, goal.id) + else + data + end + + JSON.encode!(data) end - defp evaluate_funnel(%{assigns: %{selections_made: selections_made}} = socket) - when map_size(selections_made) < Funnel.min_steps() do + defp find_sequence_break(input) do + input + |> Enum.sort() + |> Enum.with_index(1) + |> Enum.reduce_while(nil, fn {x, order}, _ -> + if x != order do + {:halt, order} + else + {:cont, order + 1} + end + end) + end + + defp evaluate_funnel(%{assigns: %{steps: steps}} = socket) + when map_size(steps) < Funnel.min_steps() do socket end @@ -398,13 +511,13 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do %{ assigns: %{ site: site, - selections_made: selections_made, + steps: steps, funnel_type: funnel_type } } = socket ) do with {:ok, {definition, query}} <- - build_ephemeral_funnel(site, selections_made, funnel_type: funnel_type), + build_ephemeral_funnel(site, steps, funnel_type: funnel_type), {:ok, funnel} <- Plausible.Stats.funnel(site, query, definition) do assign(socket, evaluation_result: funnel) else @@ -413,9 +526,9 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do end end - defp build_ephemeral_funnel(site, selections_made, opts) do + defp build_ephemeral_funnel(site, steps, opts) do steps = - selections_made + steps |> Enum.sort_by(&elem(&1, 0)) |> Enum.map(fn {_, goal} -> %{ @@ -423,10 +536,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do "goal" => %{ "id" => goal.id, "event_name" => goal.event_name, - "page_path" => goal.page_path, - "scroll_threshold" => goal.scroll_threshold, - "currency" => goal.currency, - "custom_props" => goal.custom_props + "page_path" => goal.page_path } } end) @@ -448,70 +558,18 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do {:ok, {definition, query}} end - defp find_sequence_break(input) do - input - |> Enum.sort() - |> Enum.with_index(1) - |> Enum.reduce_while(nil, fn {x, order}, _ -> - if x != order do - {:halt, order} - else - {:cont, order + 1} - end - end) - end - - defp has_steps_errors?(f) do - not f.source.valid? - end - - defp get_goal(assigns, id) do - assigns - |> Map.fetch!(:goals) - |> Enum.find_value(fn - {goal_id, goal} when goal_id == id -> goal - _ -> nil - end) - end - - defp store_selection(assigns, combo_box, goal_id) do - Map.put(assigns.selections_made, combo_box, get_goal(assigns, goal_id)) - end - - defp drop_selection(selections_made, step_idx) do - step_input_id = "step-#{step_idx}" - Map.delete(selections_made, step_input_id) - end - - defp reject_already_selected(goals, selections_made) do - selection_ids = - Enum.map(selections_made, fn - {_, %{id: goal_id}} -> goal_id - end) - - Enum.reject(goals, fn {goal_id, _} -> goal_id in selection_ids end) - end - - defp find_preselected(%Funnel{} = funnel, false, idx) do - if goal = Enum.at(Funnel.goals(funnel), idx - 1) do - {goal.id, to_string(goal)} - end - end - - defp find_preselected(_, _, _), do: nil - defp prepare_socket(socket, site, funnel_id) when is_integer(funnel_id) do funnel = Funnels.get(site.id, funnel_id) - goals = Funnel.goals(funnel) + steps = Funnel.goals(funnel) form = funnel - |> Funnels.edit_changeset(funnel.name, goals) + |> Funnels.edit_changeset(funnel.name, steps) |> to_form() - selections_made = - Enum.reduce(Enum.with_index(goals, 1), %{}, fn {goal, idx}, acc -> - Map.put(acc, "step-#{idx}", goal) + steps = + Enum.reduce(Enum.with_index(steps, 1), %{}, fn {step, idx}, acc -> + Map.put(acc, "step-#{idx}", step) end) socket = @@ -521,8 +579,8 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do funnel: funnel, funnel_type: funnel.funnel_type, funnel_modified?: false, - selections_made: selections_made, - step_ids: Enum.to_list(1..Enum.count(goals)) + steps: steps, + step_ids: Enum.to_list(1..Enum.count(steps)) ) evaluate_funnel(socket) @@ -537,7 +595,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do funnel: nil, funnel_type: :sequential, funnel_modified?: false, - selections_made: Map.new(), + steps: Map.new(), step_ids: Enum.to_list(1..Funnel.min_steps()) ) end From 5ce34115603876c5c0d2dedf85b31439fe3f1721 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 15 Oct 2026 14:06:54 +0200 Subject: [PATCH 06/36] Update tests --- .../live/funnel_settings/form_test.exs | 10 +++----- .../live/funnel_settings_test.exs | 25 ++++++++++--------- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/test/plausible_web/live/funnel_settings/form_test.exs b/test/plausible_web/live/funnel_settings/form_test.exs index 38709ec4d45a..aa8e06cb3874 100644 --- a/test/plausible_web/live/funnel_settings/form_test.exs +++ b/test/plausible_web/live/funnel_settings/form_test.exs @@ -33,11 +33,6 @@ defmodule PlausibleWeb.Live.FunnelSettings.FormTest do |> element("li#dropdown-step-1-option-1 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() @@ -56,8 +51,11 @@ defmodule PlausibleWeb.Live.FunnelSettings.FormTest do doc = type_into_combo(lv, 2, "another") - refute text_of_element(doc, "ul#dropdown-step-1 li") =~ "Another World" refute text_of_element(doc, "ul#dropdown-step-2 li") =~ "Another World" + + doc = type_into_combo(lv, 1, "a") + + refute text_of_element(doc, "ul#dropdown-step-1 li") =~ "Another World" end test "removing one option alters suggestions for other", %{conn: conn, site: site} do diff --git a/test/plausible_web/live/funnel_settings_test.exs b/test/plausible_web/live/funnel_settings_test.exs index ee70571723ce..bfdea34e88e3 100644 --- a/test/plausible_web/live/funnel_settings_test.exs +++ b/test/plausible_web/live/funnel_settings_test.exs @@ -188,12 +188,12 @@ defmodule PlausibleWeb.Live.FunnelSettingsTest do assert element_exists?( doc, - ~s/input[type="hidden"][name="funnel[steps][1][goal_id]"]#submit-step-1/ + ~s/input[type="hidden"][name="funnel[steps][1][step_data]"]#submit-step-1/ ) step_setup_controls = [ - ~s/input[type="hidden"][name="funnel[steps][1][goal_id]"]#submit-step-1/, - ~s/input[type="hidden"][name="funnel[steps][2][goal_id]"]#submit-step-2/, + ~s/input[type="hidden"][name="funnel[steps][1][step_data]"]#submit-step-1/, + ~s/input[type="hidden"][name="funnel[steps][2][step_data]"]#submit-step-2/, ~s/input[type="text"][name="display-step-1"]#step-1/, ~s/input[type="text"][name="display-step-2"]#step-2/, ~s/a[phx-click="add-step"]/ @@ -217,7 +217,7 @@ defmodule PlausibleWeb.Live.FunnelSettingsTest do assert element_exists?( doc, - ~s/input[type="hidden"][name="funnel[steps][3][goal_id]"]#submit-step-3/ + ~s/input[type="hidden"][name="funnel[steps][3][step_data]"]#submit-step-3/ ) assert element_exists?(doc, ~s/input[type="text"][name="display-step-1"]#step-1/) @@ -296,7 +296,7 @@ defmodule PlausibleWeb.Live.FunnelSettingsTest do conn: conn, site: site } do - setup_goals(site) + {:ok, [g1, g2]} = setup_goals(site) lv = get_liveview(conn, site) lv |> element(~s/button[phx-click="add-funnel"]/) |> render_click() @@ -307,7 +307,7 @@ defmodule PlausibleWeb.Live.FunnelSettingsTest do |> render_click() lv - |> element("li#dropdown-step-2-option-1 a") + |> element("li#dropdown-step-2-option-2 a") |> render_click() lv @@ -316,8 +316,8 @@ defmodule PlausibleWeb.Live.FunnelSettingsTest do funnel: %{ name: "My test funnel", steps: [ - %{goal_id: 1}, - %{goal_id: 2} + %{step_data: JSON.encode!(%{goal_id: g1.id})}, + %{step_data: JSON.encode!(%{goal_id: g2.id})} ] } }) @@ -623,8 +623,9 @@ defmodule PlausibleWeb.Live.FunnelSettingsTest do conn: conn, site: site } do - {:ok, [f1_id, _]} = setup_funnels(site) + {:ok, [f1_id, _]} = setup_funnels(site, ["Existing funnel"]) {:ok, %{id: goal_id}} = Plausible.Goals.create(site, %{"page_path" => "/"}) + %{steps: [%{id: step1_goal_id}, _]} = Plausible.Funnels.get(site, "Existing funnel") lv = get_liveview(conn, site) @@ -635,7 +636,7 @@ defmodule PlausibleWeb.Live.FunnelSettingsTest do assert lv = find_live_child(lv, "funnels-form") lv - |> element("li#dropdown-step-2-option-1 a") + |> element("li#dropdown-step-2-option-2 a") |> render_click() lv @@ -644,8 +645,8 @@ defmodule PlausibleWeb.Live.FunnelSettingsTest do funnel: %{ name: "Updated funnel", steps: [ - %{goal_id: 1}, - %{goal_id: goal_id} + %{step_data: JSON.encode!(%{goal_id: step1_goal_id})}, + %{step_data: JSON.encode!(%{goal_id: goal_id})} ] } }) From 2bb066ad03ab135361dd61c25a76380d96352ccc Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 15 Oct 2026 15:06:20 +0200 Subject: [PATCH 07/36] Extract `Stats.Funnel` tests to a separate test module --- test/plausible/funnels_test.exs | 461 ---------------------- test/plausible/stats/funnel_test.exs | 563 +++++++++++++++++++++++++++ 2 files changed, 563 insertions(+), 461 deletions(-) create mode 100644 test/plausible/stats/funnel_test.exs diff --git a/test/plausible/funnels_test.exs b/test/plausible/funnels_test.exs index 20f11ec26965..515e27a95fa4 100644 --- a/test/plausible/funnels_test.exs +++ b/test/plausible/funnels_test.exs @@ -6,8 +6,6 @@ defmodule Plausible.FunnelsTest do alias Plausible.Funnel alias Plausible.Funnels alias Plausible.Goals - alias Plausible.Stats - alias Plausible.Stats.QueryBuilder setup do site = new_site() @@ -330,464 +328,5 @@ defmodule Plausible.FunnelsTest do funnels_list end end - - describe "Plausible.Stats.Funnel" do - test "funnels can be evaluated per site within a time range against an ephemeral definition", - %{ - site: site, - goals: [g1, g2, g3 | _] - } do - funnel_definition = - Funnels.ephemeral_definition( - site, - "From blog to signup and purchase", - [ - %{ - "goal_id" => "#{g1.id}", - "goal" => %{ - "id" => "#{g1.id}", - "event_name" => g1.event_name, - "page_path" => g1.page_path - } - }, - %{ - "goal_id" => g2.id, - "goal" => %{ - "id" => g2.id, - "event_name" => g2.event_name, - "page_path" => g2.page_path - } - }, - %{ - "goal_id" => g3.id, - "goal" => %{ - "id" => g3.id, - "event_name" => g3.event_name, - "page_path" => g3.page_path - } - } - ] - ) - - populate_stats(site, [ - build(:pageview, pathname: "/irrelevant/page/not/in/funnel", user_id: 999), - build(:pageview, pathname: "/go/to/blog/foo", user_id: 123), - build(:event, name: "Signup", user_id: 123), - build(:pageview, pathname: "/checkout", user_id: 123), - build(:pageview, pathname: "/go/to/blog/bar", user_id: 666), - build(:event, name: "Signup", user_id: 666) - ]) - - query = QueryBuilder.build!(site, input_date_range: :all) - - funnel_data = Stats.funnel(site, query, funnel_definition) - - assert {:ok, - %{ - all_visitors: 3, - entering_visitors: 2, - entering_visitors_percentage: "66.67", - never_entering_visitors: 1, - never_entering_visitors_percentage: "33.33", - steps: [ - %{ - label: "Visit /go/to/blog/**", - visitors: 2, - conversion_rate: "100", - conversion_rate_step: "0", - dropoff: 0, - dropoff_percentage: "0" - }, - %{ - label: "Signup", - visitors: 2, - conversion_rate: "100", - conversion_rate_step: "100", - dropoff: 0, - dropoff_percentage: "0" - }, - %{ - label: "Visit /checkout", - visitors: 1, - conversion_rate: "50", - conversion_rate_step: "50", - dropoff: 1, - dropoff_percentage: "50" - } - ] - }} = funnel_data - end - - test "ephemeral definition creation crashes when passed data are invalid", %{site: site} do - assert_raise MatchError, fn -> - Funnels.ephemeral_definition(site, "Empty", []) - end - end - - test "mixed funnels can be evaluated per site within a time range against an ephemeral definition", - %{ - site: site, - goals: [g1, g2, g3 | _] - } do - funnel_definition = - Funnels.ephemeral_definition( - site, - "From blog to signup and purchase", - [ - %{ - "goal_id" => "#{g1.id}", - "goal" => %{ - "id" => "#{g1.id}", - "event_name" => g1.event_name, - "page_path" => g1.page_path - } - }, - %{"event_name" => g2.event_name}, - %{"page_path" => g3.page_path} - ] - ) - - populate_stats(site, [ - build(:pageview, pathname: "/irrelevant/page/not/in/funnel", user_id: 999), - build(:pageview, pathname: "/go/to/blog/foo", user_id: 123), - build(:event, name: "Signup", user_id: 123), - build(:pageview, pathname: "/checkout", user_id: 123), - build(:pageview, pathname: "/go/to/blog/bar", user_id: 666), - build(:event, name: "Signup", user_id: 666) - ]) - - query = QueryBuilder.build!(site, input_date_range: :all) - - funnel_data = Stats.funnel(site, query, funnel_definition) - - assert {:ok, - %{ - all_visitors: 3, - entering_visitors: 2, - entering_visitors_percentage: "66.67", - never_entering_visitors: 1, - never_entering_visitors_percentage: "33.33", - steps: [ - %{ - label: "Visit /go/to/blog/**", - visitors: 2, - conversion_rate: "100", - conversion_rate_step: "0", - dropoff: 0, - dropoff_percentage: "0" - }, - %{ - label: "Signup", - visitors: 2, - conversion_rate: "100", - conversion_rate_step: "100", - dropoff: 0, - dropoff_percentage: "0" - }, - %{ - label: "Visit /checkout", - visitors: 1, - conversion_rate: "50", - conversion_rate_step: "50", - dropoff: 1, - dropoff_percentage: "50" - } - ] - }} = funnel_data - end - - test "funnels can be evaluated per site within a time range", %{ - site: site, - steps: [g1, g2, g3 | _] - } do - {:ok, funnel} = - Funnels.create( - site, - "From blog to signup and purchase", - [g1, g2, g3] - ) - - populate_stats(site, [ - build(:pageview, pathname: "/go/to/blog/foo", user_id: 123), - build(:event, name: "Signup", user_id: 123), - build(:pageview, pathname: "/checkout", user_id: 123), - build(:pageview, pathname: "/go/to/blog/bar", user_id: 666), - build(:event, name: "Signup", user_id: 666) - ]) - - query = QueryBuilder.build!(site, input_date_range: :all) - - funnel_data = Stats.funnel(site, query, funnel.id) - - assert {:ok, - %{ - all_visitors: 2, - entering_visitors: 2, - entering_visitors_percentage: "100", - never_entering_visitors: 0, - never_entering_visitors_percentage: "0", - steps: [ - %{ - label: "Visit /go/to/blog/**", - visitors: 2, - conversion_rate: "100", - conversion_rate_step: "0", - dropoff: 0 - }, - %{ - label: "Signup", - visitors: 2, - conversion_rate: "100", - conversion_rate_step: "100", - dropoff: 0, - dropoff_percentage: "0" - }, - %{ - label: "Visit /checkout", - visitors: 1, - conversion_rate: "50", - conversion_rate_step: "50", - dropoff: 1, - dropoff_percentage: "50" - } - ] - }} = funnel_data - end - - test "strict-order funnels stop at intervention events", %{ - site: site, - steps: [g1, g2, g3 | _] - } do - {:ok, non_strict_funnel} = - Funnels.create( - site, - "From blog to signup and purchase", - [g1, g2, g3] - ) - - {:ok, strict_funnel} = - Funnels.create( - site, - "Strict from blog to signup and purchase", - [g1, g2, g3], - funnel_type: :strict - ) - - populate_stats(site, [ - build(:pageview, - pathname: "/go/to/blog/foo", - user_id: 123, - timestamp: ~N[2021-01-01 00:00:00] - ), - build(:pageview, - pathname: "/some/other/page", - user_id: 123, - timestamp: ~N[2021-01-01 00:00:01] - ), - build(:event, - name: "Signup", - user_id: 123, - timestamp: ~N[2021-01-01 00:00:02] - ), - build(:pageview, - pathname: "/checkout", - user_id: 123, - timestamp: ~N[2021-01-01 00:00:03] - ) - ]) - - query = QueryBuilder.build!(site, input_date_range: :all) - - {:ok, non_strict_funnel_data} = Stats.funnel(site, query, non_strict_funnel.id) - {:ok, strict_funnel_data} = Stats.funnel(site, query, strict_funnel.id) - - assert Enum.at(non_strict_funnel_data.steps, 2).visitors == 1 - assert Enum.at(strict_funnel_data.steps, 2).visitors == 0 - - assert non_strict_funnel_data.funnel_type == :sequential - assert strict_funnel_data.funnel_type == :strict - end - - test "flexible funnels count everyone who reaches first and last step in the last step", %{ - site: site, - steps: [g1, g2, g3 | _] - } do - {:ok, non_flexible_funnel} = - Funnels.create( - site, - "From blog to signup and purchase", - [g1, g2, g3] - ) - - {:ok, flexible_funnel} = - Funnels.create( - site, - "Flexible from blog to signup and purchase", - [g1, g2, g3], - funnel_type: :flexible - ) - - populate_stats(site, [ - build(:pageview, - pathname: "/go/to/blog/foo", - user_id: 123, - timestamp: ~N[2021-01-01 00:00:00] - ), - build(:pageview, - pathname: "/checkout", - user_id: 123, - timestamp: ~N[2021-01-01 00:00:03] - ) - ]) - - query = QueryBuilder.build!(site, input_date_range: :all) - - {:ok, non_flexible_funnel_data} = Stats.funnel(site, query, non_flexible_funnel.id) - {:ok, flexible_funnel_data} = Stats.funnel(site, query, flexible_funnel.id) - - assert Enum.at(non_flexible_funnel_data.steps, 0).visitors == 1 - assert Enum.at(non_flexible_funnel_data.steps, 1).visitors == 0 - assert Enum.at(non_flexible_funnel_data.steps, 2).visitors == 0 - assert Enum.at(flexible_funnel_data.steps, 0).visitors == 1 - assert Enum.at(flexible_funnel_data.steps, 1).visitors == 0 - assert Enum.at(flexible_funnel_data.steps, 2).visitors == 1 - - assert non_flexible_funnel_data.funnel_type == :sequential - assert flexible_funnel_data.funnel_type == :flexible - end - - test "flexible mixed funnels count everyone who reaches first and last step in the last step", - %{ - site: site, - steps: [g1 | _] - } do - s2 = %{"event_name" => "Signup"} - s3 = %{"page_path" => "/checkout"} - - {:ok, non_flexible_funnel} = - Funnels.create( - site, - "From blog to signup and purchase", - [g1, s2, s3] - ) - - {:ok, flexible_funnel} = - Funnels.create( - site, - "Flexible from blog to signup and purchase", - [g1, s2, s3], - funnel_type: :flexible - ) - - populate_stats(site, [ - build(:pageview, - pathname: "/go/to/blog/foo", - user_id: 123, - timestamp: ~N[2021-01-01 00:00:00] - ), - build(:pageview, - pathname: "/checkout", - user_id: 123, - timestamp: ~N[2021-01-01 00:00:03] - ) - ]) - - query = QueryBuilder.build!(site, input_date_range: :all) - - {:ok, non_flexible_funnel_data} = Stats.funnel(site, query, non_flexible_funnel.id) - {:ok, flexible_funnel_data} = Stats.funnel(site, query, flexible_funnel.id) - - assert Enum.at(non_flexible_funnel_data.steps, 0).visitors == 1 - assert Enum.at(non_flexible_funnel_data.steps, 1).visitors == 0 - assert Enum.at(non_flexible_funnel_data.steps, 2).visitors == 0 - assert Enum.at(flexible_funnel_data.steps, 0).visitors == 1 - assert Enum.at(flexible_funnel_data.steps, 1).visitors == 0 - assert Enum.at(flexible_funnel_data.steps, 2).visitors == 1 - - assert non_flexible_funnel_data.funnel_type == :sequential - assert flexible_funnel_data.funnel_type == :flexible - end - - test "funnels can be evaluated even where there are no visits yet", %{ - site: site, - steps: [g1, g2, g3 | _] - } do - {:ok, funnel} = - Funnels.create( - site, - "From blog to signup and purchase", - [g1, g2, g3] - ) - - query = QueryBuilder.build!(site, input_date_range: :all) - - funnel_data = Stats.funnel(site, query, funnel.id) - - assert {:ok, - %{ - all_visitors: 0, - entering_visitors: 0, - entering_visitors_percentage: "0", - never_entering_visitors: 0, - never_entering_visitors_percentage: "0", - steps: [ - %{ - label: "Visit /go/to/blog/**", - visitors: 0, - conversion_rate: "0", - conversion_rate_step: "0", - dropoff: 0, - dropoff_percentage: "0" - }, - %{ - label: "Signup", - visitors: 0, - conversion_rate: "0", - conversion_rate_step: "0", - dropoff: 0, - dropoff_percentage: "0" - }, - %{ - label: "Visit /checkout", - visitors: 0, - conversion_rate: "0", - conversion_rate_step: "0", - dropoff: 0, - dropoff_percentage: "0" - } - ] - }} = funnel_data - end - - @tag :slow - test "sampling", %{site: site, steps: [g1, g2, g3 | _]} do - {:ok, funnel} = - Funnels.create( - site, - "From blog to signup and purchase", - [g1, g2, g3] - ) - - stats = - 1..50_000 - |> Enum.flat_map(fn _ -> - user_id = SipHash.hash!("0123456789ABCDEF", :crypto.strong_rand_bytes(64)) - - [ - build(:pageview, pathname: "/go/to/blog/foo", user_id: user_id), - build(:event, name: "Signup", user_id: user_id), - build(:pageview, pathname: "/checkout", user_id: user_id) - ] - end) - - populate_stats(site, stats) - - query = - Plausible.Stats.Query.from(site, %{"period" => "all", "sample_threshold" => "10000"}) - - {:ok, funnel_data} = Stats.funnel(site, query, funnel.id) - assert_in_delta funnel_data[:all_visitors], 50_000, 5000 - end - end end end diff --git a/test/plausible/stats/funnel_test.exs b/test/plausible/stats/funnel_test.exs new file mode 100644 index 000000000000..f50559a2f7f1 --- /dev/null +++ b/test/plausible/stats/funnel_test.exs @@ -0,0 +1,563 @@ +defmodule Plausible.Stats.FunnelTest do + use Plausible.DataCase + + @moduletag :ee_only + + on_ee do + alias Plausible.Funnels + alias Plausible.Goals + alias Plausible.Stats + alias Plausible.Stats.QueryBuilder + + setup do + site = new_site() + + {:ok, g1} = Goals.create(site, %{"page_path" => "/go/to/blog/**"}) + {:ok, g2} = Goals.create(site, %{"event_name" => "Signup"}) + {:ok, g3} = Goals.create(site, %{"page_path" => "/checkout"}) + + {:ok, + %{ + site: site, + goals: [g1, g2, g3], + steps: [g1, g2, g3] |> Enum.map(&%{"goal_id" => &1.id}) + }} + end + + describe "suggest/3" do + test "returns empty on no goals and no stats", %{site: site} do + query = QueryBuilder.build!(site, input_date_range: :all) + + assert {[], []} = Stats.Funnel.suggest(site, query) + end + + test "returns all goals as unmatched when there are not stats", %{ + site: site, + goals: [g1, g2, g3 | _] + } do + query = QueryBuilder.build!(site, input_date_range: :all) + + assert {[], unmatched} = Stats.Funnel.suggest(site, query, goals: [g1, g2, g3]) + + unmatched_ids = Enum.map(unmatched, & &1.id) + + assert length(unmatched_ids) == 3 + assert g1.id in unmatched_ids + assert g2.id in unmatched_ids + assert g3.id in unmatched_ids + end + + test "returns suggestions from stats when no goals provided", %{site: site} do + query = QueryBuilder.build!(site, input_date_range: :all) + + populate_stats(site, [ + build(:pageview, pathname: "/irrelevant/page/not/in/funnel", user_id: 999), + build(:pageview, pathname: "/go/to/blog/foo", user_id: 123), + build(:event, name: "Signup", user_id: 123), + build(:pageview, pathname: "/checkout", user_id: 123), + build(:pageview, pathname: "/go/to/blog/bar", user_id: 666), + build(:event, name: "Signup", user_id: 666) + ]) + + assert {suggestions, []} = Stats.Funnel.suggest(site, query) + suggestions = Enum.map(suggestions, &{&1.event_name, &1.page_path}) + + assert length(suggestions) == 6 + assert {"Signup", nil} in suggestions + assert {nil, "/go/to/blog/foo"} in suggestions + assert {nil, "/go/to/blog/**"} in suggestions + assert {nil, "/go/to/blog/bar"} in suggestions + assert {nil, "/checkout"} in suggestions + assert {nil, "/irrelevant/page/not/in/funnel"} in suggestions + end + + test "returns only unmatched goals", %{site: site, goals: [g1, g2, g3 | _]} do + {:ok, g4} = Goals.create(site, %{"event_name" => "Unmatched"}) + query = QueryBuilder.build!(site, input_date_range: :all) + + populate_stats(site, [ + build(:pageview, pathname: "/irrelevant/page/not/in/funnel", user_id: 999), + build(:pageview, pathname: "/go/to/blog/foo", user_id: 123), + build(:event, name: "Signup", user_id: 123), + build(:pageview, pathname: "/checkout", user_id: 123), + build(:pageview, pathname: "/go/to/blog/bar", user_id: 666), + build(:event, name: "Signup", user_id: 666) + ]) + + assert {suggestions, [unmatched]} = + Stats.Funnel.suggest(site, query, goals: [g1, g2, g3, g4]) + + suggestions = Enum.map(suggestions, &{&1.event_name, &1.page_path}) + + assert length(suggestions) == 6 + assert {"Signup", nil} in suggestions + assert {nil, "/go/to/blog/foo"} in suggestions + assert {nil, "/go/to/blog/**"} in suggestions + assert {nil, "/go/to/blog/bar"} in suggestions + assert {nil, "/checkout"} in suggestions + assert {nil, "/irrelevant/page/not/in/funnel"} in suggestions + + assert unmatched.event_name == "Unmatched" + end + end + + describe "funnel/3" do + test "funnels can be evaluated per site within a time range against an ephemeral definition", + %{ + site: site, + goals: [g1, g2, g3 | _] + } do + funnel_definition = + Funnels.ephemeral_definition( + site, + "From blog to signup and purchase", + [ + %{ + "goal_id" => "#{g1.id}", + "goal" => %{ + "id" => "#{g1.id}", + "event_name" => g1.event_name, + "page_path" => g1.page_path + } + }, + %{ + "goal_id" => g2.id, + "goal" => %{ + "id" => g2.id, + "event_name" => g2.event_name, + "page_path" => g2.page_path + } + }, + %{ + "goal_id" => g3.id, + "goal" => %{ + "id" => g3.id, + "event_name" => g3.event_name, + "page_path" => g3.page_path + } + } + ] + ) + + populate_stats(site, [ + build(:pageview, pathname: "/irrelevant/page/not/in/funnel", user_id: 999), + build(:pageview, pathname: "/go/to/blog/foo", user_id: 123), + build(:event, name: "Signup", user_id: 123), + build(:pageview, pathname: "/checkout", user_id: 123), + build(:pageview, pathname: "/go/to/blog/bar", user_id: 666), + build(:event, name: "Signup", user_id: 666) + ]) + + query = QueryBuilder.build!(site, input_date_range: :all) + + funnel_data = Stats.funnel(site, query, funnel_definition) + + assert {:ok, + %{ + all_visitors: 3, + entering_visitors: 2, + entering_visitors_percentage: "66.67", + never_entering_visitors: 1, + never_entering_visitors_percentage: "33.33", + steps: [ + %{ + label: "Visit /go/to/blog/**", + visitors: 2, + conversion_rate: "100", + conversion_rate_step: "0", + dropoff: 0, + dropoff_percentage: "0" + }, + %{ + label: "Signup", + visitors: 2, + conversion_rate: "100", + conversion_rate_step: "100", + dropoff: 0, + dropoff_percentage: "0" + }, + %{ + label: "Visit /checkout", + visitors: 1, + conversion_rate: "50", + conversion_rate_step: "50", + dropoff: 1, + dropoff_percentage: "50" + } + ] + }} = funnel_data + end + + test "ephemeral definition creation crashes when passed data are invalid", %{site: site} do + assert_raise MatchError, fn -> + Funnels.ephemeral_definition(site, "Empty", []) + end + end + + test "mixed funnels can be evaluated per site within a time range against an ephemeral definition", + %{ + site: site, + goals: [g1, g2, g3 | _] + } do + funnel_definition = + Funnels.ephemeral_definition( + site, + "From blog to signup and purchase", + [ + %{ + "goal_id" => "#{g1.id}", + "goal" => %{ + "id" => "#{g1.id}", + "event_name" => g1.event_name, + "page_path" => g1.page_path + } + }, + %{"event_name" => g2.event_name}, + %{"page_path" => g3.page_path} + ] + ) + + populate_stats(site, [ + build(:pageview, pathname: "/irrelevant/page/not/in/funnel", user_id: 999), + build(:pageview, pathname: "/go/to/blog/foo", user_id: 123), + build(:event, name: "Signup", user_id: 123), + build(:pageview, pathname: "/checkout", user_id: 123), + build(:pageview, pathname: "/go/to/blog/bar", user_id: 666), + build(:event, name: "Signup", user_id: 666) + ]) + + query = QueryBuilder.build!(site, input_date_range: :all) + + funnel_data = Stats.funnel(site, query, funnel_definition) + + assert {:ok, + %{ + all_visitors: 3, + entering_visitors: 2, + entering_visitors_percentage: "66.67", + never_entering_visitors: 1, + never_entering_visitors_percentage: "33.33", + steps: [ + %{ + label: "Visit /go/to/blog/**", + visitors: 2, + conversion_rate: "100", + conversion_rate_step: "0", + dropoff: 0, + dropoff_percentage: "0" + }, + %{ + label: "Signup", + visitors: 2, + conversion_rate: "100", + conversion_rate_step: "100", + dropoff: 0, + dropoff_percentage: "0" + }, + %{ + label: "Visit /checkout", + visitors: 1, + conversion_rate: "50", + conversion_rate_step: "50", + dropoff: 1, + dropoff_percentage: "50" + } + ] + }} = funnel_data + end + + test "funnels can be evaluated per site within a time range", %{ + site: site, + steps: [g1, g2, g3 | _] + } do + {:ok, funnel} = + Funnels.create( + site, + "From blog to signup and purchase", + [g1, g2, g3] + ) + + populate_stats(site, [ + build(:pageview, pathname: "/go/to/blog/foo", user_id: 123), + build(:event, name: "Signup", user_id: 123), + build(:pageview, pathname: "/checkout", user_id: 123), + build(:pageview, pathname: "/go/to/blog/bar", user_id: 666), + build(:event, name: "Signup", user_id: 666) + ]) + + query = QueryBuilder.build!(site, input_date_range: :all) + + funnel_data = Stats.funnel(site, query, funnel.id) + + assert {:ok, + %{ + all_visitors: 2, + entering_visitors: 2, + entering_visitors_percentage: "100", + never_entering_visitors: 0, + never_entering_visitors_percentage: "0", + steps: [ + %{ + label: "Visit /go/to/blog/**", + visitors: 2, + conversion_rate: "100", + conversion_rate_step: "0", + dropoff: 0 + }, + %{ + label: "Signup", + visitors: 2, + conversion_rate: "100", + conversion_rate_step: "100", + dropoff: 0, + dropoff_percentage: "0" + }, + %{ + label: "Visit /checkout", + visitors: 1, + conversion_rate: "50", + conversion_rate_step: "50", + dropoff: 1, + dropoff_percentage: "50" + } + ] + }} = funnel_data + end + + test "strict-order funnels stop at intervention events", %{ + site: site, + steps: [g1, g2, g3 | _] + } do + {:ok, non_strict_funnel} = + Funnels.create( + site, + "From blog to signup and purchase", + [g1, g2, g3] + ) + + {:ok, strict_funnel} = + Funnels.create( + site, + "Strict from blog to signup and purchase", + [g1, g2, g3], + funnel_type: :strict + ) + + populate_stats(site, [ + build(:pageview, + pathname: "/go/to/blog/foo", + user_id: 123, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/some/other/page", + user_id: 123, + timestamp: ~N[2021-01-01 00:00:01] + ), + build(:event, + name: "Signup", + user_id: 123, + timestamp: ~N[2021-01-01 00:00:02] + ), + build(:pageview, + pathname: "/checkout", + user_id: 123, + timestamp: ~N[2021-01-01 00:00:03] + ) + ]) + + query = QueryBuilder.build!(site, input_date_range: :all) + + {:ok, non_strict_funnel_data} = Stats.funnel(site, query, non_strict_funnel.id) + {:ok, strict_funnel_data} = Stats.funnel(site, query, strict_funnel.id) + + assert Enum.at(non_strict_funnel_data.steps, 2).visitors == 1 + assert Enum.at(strict_funnel_data.steps, 2).visitors == 0 + + assert non_strict_funnel_data.funnel_type == :sequential + assert strict_funnel_data.funnel_type == :strict + end + + test "flexible funnels count everyone who reaches first and last step in the last step", %{ + site: site, + steps: [g1, g2, g3 | _] + } do + {:ok, non_flexible_funnel} = + Funnels.create( + site, + "From blog to signup and purchase", + [g1, g2, g3] + ) + + {:ok, flexible_funnel} = + Funnels.create( + site, + "Flexible from blog to signup and purchase", + [g1, g2, g3], + funnel_type: :flexible + ) + + populate_stats(site, [ + build(:pageview, + pathname: "/go/to/blog/foo", + user_id: 123, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/checkout", + user_id: 123, + timestamp: ~N[2021-01-01 00:00:03] + ) + ]) + + query = QueryBuilder.build!(site, input_date_range: :all) + + {:ok, non_flexible_funnel_data} = Stats.funnel(site, query, non_flexible_funnel.id) + {:ok, flexible_funnel_data} = Stats.funnel(site, query, flexible_funnel.id) + + assert Enum.at(non_flexible_funnel_data.steps, 0).visitors == 1 + assert Enum.at(non_flexible_funnel_data.steps, 1).visitors == 0 + assert Enum.at(non_flexible_funnel_data.steps, 2).visitors == 0 + assert Enum.at(flexible_funnel_data.steps, 0).visitors == 1 + assert Enum.at(flexible_funnel_data.steps, 1).visitors == 0 + assert Enum.at(flexible_funnel_data.steps, 2).visitors == 1 + + assert non_flexible_funnel_data.funnel_type == :sequential + assert flexible_funnel_data.funnel_type == :flexible + end + + test "flexible mixed funnels count everyone who reaches first and last step in the last step", + %{ + site: site, + steps: [g1 | _] + } do + s2 = %{"event_name" => "Signup"} + s3 = %{"page_path" => "/checkout"} + + {:ok, non_flexible_funnel} = + Funnels.create( + site, + "From blog to signup and purchase", + [g1, s2, s3] + ) + + {:ok, flexible_funnel} = + Funnels.create( + site, + "Flexible from blog to signup and purchase", + [g1, s2, s3], + funnel_type: :flexible + ) + + populate_stats(site, [ + build(:pageview, + pathname: "/go/to/blog/foo", + user_id: 123, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/checkout", + user_id: 123, + timestamp: ~N[2021-01-01 00:00:03] + ) + ]) + + query = QueryBuilder.build!(site, input_date_range: :all) + + {:ok, non_flexible_funnel_data} = Stats.funnel(site, query, non_flexible_funnel.id) + {:ok, flexible_funnel_data} = Stats.funnel(site, query, flexible_funnel.id) + + assert Enum.at(non_flexible_funnel_data.steps, 0).visitors == 1 + assert Enum.at(non_flexible_funnel_data.steps, 1).visitors == 0 + assert Enum.at(non_flexible_funnel_data.steps, 2).visitors == 0 + assert Enum.at(flexible_funnel_data.steps, 0).visitors == 1 + assert Enum.at(flexible_funnel_data.steps, 1).visitors == 0 + assert Enum.at(flexible_funnel_data.steps, 2).visitors == 1 + + assert non_flexible_funnel_data.funnel_type == :sequential + assert flexible_funnel_data.funnel_type == :flexible + end + + test "funnels can be evaluated even where there are no visits yet", %{ + site: site, + steps: [g1, g2, g3 | _] + } do + {:ok, funnel} = + Funnels.create( + site, + "From blog to signup and purchase", + [g1, g2, g3] + ) + + query = QueryBuilder.build!(site, input_date_range: :all) + + funnel_data = Stats.funnel(site, query, funnel.id) + + assert {:ok, + %{ + all_visitors: 0, + entering_visitors: 0, + entering_visitors_percentage: "0", + never_entering_visitors: 0, + never_entering_visitors_percentage: "0", + steps: [ + %{ + label: "Visit /go/to/blog/**", + visitors: 0, + conversion_rate: "0", + conversion_rate_step: "0", + dropoff: 0, + dropoff_percentage: "0" + }, + %{ + label: "Signup", + visitors: 0, + conversion_rate: "0", + conversion_rate_step: "0", + dropoff: 0, + dropoff_percentage: "0" + }, + %{ + label: "Visit /checkout", + visitors: 0, + conversion_rate: "0", + conversion_rate_step: "0", + dropoff: 0, + dropoff_percentage: "0" + } + ] + }} = funnel_data + end + + @tag :slow + test "sampling", %{site: site, steps: [g1, g2, g3 | _]} do + {:ok, funnel} = + Funnels.create( + site, + "From blog to signup and purchase", + [g1, g2, g3] + ) + + stats = + 1..50_000 + |> Enum.flat_map(fn _ -> + user_id = SipHash.hash!("0123456789ABCDEF", :crypto.strong_rand_bytes(64)) + + [ + build(:pageview, pathname: "/go/to/blog/foo", user_id: user_id), + build(:event, name: "Signup", user_id: user_id), + build(:pageview, pathname: "/checkout", user_id: user_id) + ] + end) + + populate_stats(site, stats) + + query = + Plausible.Stats.Query.from(site, %{"period" => "all", "sample_threshold" => "10000"}) + + {:ok, funnel_data} = Stats.funnel(site, query, funnel.id) + assert_in_delta funnel_data[:all_visitors], 50_000, 5000 + end + end + end +end From 8ebe0af7e9e081941f34dea08ca6c6031d2ffa83 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Mon, 19 Oct 2026 10:24:10 +0200 Subject: [PATCH 08/36] Remove unnecessary virtual `display_name` field from `DynamicStep` --- extra/lib/plausible/funnel/dynamic_step.ex | 4 +--- lib/plausible/goal.ex | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/extra/lib/plausible/funnel/dynamic_step.ex b/extra/lib/plausible/funnel/dynamic_step.ex index 0793f0de407f..7dfd61d8cdad 100644 --- a/extra/lib/plausible/funnel/dynamic_step.ex +++ b/extra/lib/plausible/funnel/dynamic_step.ex @@ -12,7 +12,6 @@ defmodule Plausible.Funnel.DynamicStep do @fields [ :event_name, :page_path, - :display_name, :scroll_threshold, :custom_props, :currency @@ -23,7 +22,6 @@ defmodule Plausible.Funnel.DynamicStep do field :step_order, :integer field :event_name, :string field :page_path, :string - field :display_name, :string, virtual: true field :scroll_threshold, :integer, default: -1 field :currency, Ecto.Enum, values: Money.Currency.known_current_currencies() @@ -46,7 +44,7 @@ defmodule Plausible.Funnel.DynamicStep do def as_goal(step) do %Plausible.Goal{ id: 0, - display_name: step.display_name || display_name(step), + display_name: display_name(step), event_name: step.event_name, page_path: step.page_path, scroll_threshold: step.scroll_threshold, diff --git a/lib/plausible/goal.ex b/lib/plausible/goal.ex index 5c67503045b7..202b6bf851ca 100644 --- a/lib/plausible/goal.ex +++ b/lib/plausible/goal.ex @@ -75,6 +75,7 @@ defmodule Plausible.Goal do goal |> cast(attrs, @fields) |> base_changeset() + |> maybe_put_display_name() |> unique_constraint(:display_name, name: :goals_display_name_unique) |> unique_constraint(:event_name, name: :goals_event_config_unique) |> unique_constraint([:page_path, :scroll_threshold], @@ -101,7 +102,6 @@ defmodule Plausible.Goal do message: "Should be -1 (missing) or in range [0, 100]" ) |> maybe_drop_currency() - |> maybe_put_display_name() end @spec display_name(t()) :: String.t() From 8d14b180a9edeb4f7d288aff5cf3d80cb6762b30 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Mon, 19 Oct 2026 11:06:48 +0200 Subject: [PATCH 09/36] Don't match on goals with exact same display name --- extra/lib/plausible/stats/funnel.ex | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/extra/lib/plausible/stats/funnel.ex b/extra/lib/plausible/stats/funnel.ex index 75858a64dd4d..4ea9f1b364e3 100644 --- a/extra/lib/plausible/stats/funnel.ex +++ b/extra/lib/plausible/stats/funnel.ex @@ -53,10 +53,12 @@ defmodule Plausible.Stats.Funnel do {Enum.reverse(suggestions), Map.values(unmatched_goals)} end + @spec step_to_goal(Exploration.Journey.Step.t(), [Plausible.Goal.t()]) :: + {Plausible.Goal.t(), :static, {binary(), binary()}} | {Plausible.Goal.t(), :dynamic} def step_to_goal(step, goals_map) do goal = goals_map[{step.name, step.pathname}] - if not is_nil(goal) and step.is_goal do + if not is_nil(goal) and step.is_goal and display_name(step) == goal.display_name do {goal, :static, {step.name, step.pathname}} else goal = @@ -114,6 +116,16 @@ defmodule Plausible.Stats.Funnel do })} end + defp display_name(step) do + case step do + %{pathname: pathname} when byte_size(pathname) > 0 -> + "Visit " <> pathname + + %{name: name} when byte_size(name) > 0 -> + name + end + end + defp recompute_flexible(final_funnel, query, site, %{funnel_type: :flexible} = funnel) do if length(Funnel.steps(funnel)) > 2 do short_funnel = convert_to_first_and_last(funnel) From be8b3d8886ef1c22fc651dc701404798e1774e72 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Mon, 19 Oct 2026 13:20:57 +0200 Subject: [PATCH 10/36] Make ordering in suggestions more predictable and exclude previous steps better --- .../live/funnel_settings/form.ex | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/extra/lib/plausible_web/live/funnel_settings/form.ex b/extra/lib/plausible_web/live/funnel_settings/form.ex index 879decb3840d..6ef91fe9eb83 100644 --- a/extra/lib/plausible_web/live/funnel_settings/form.ex +++ b/extra/lib/plausible_web/live/funnel_settings/form.ex @@ -376,15 +376,25 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do {suggestions, unmatched_goals} = Plausible.Stats.Funnel.suggest(site, query, steps: steps, goals: goals, input: input) - (suggestions ++ unmatched_goals) + unmatched_goal_entries = + unmatched_goals + |> to_suggestion_entries(steps) + |> then(&PlausibleWeb.Live.Components.ComboBox.StaticSearch.suggest(input, &1)) + + suggestions + |> to_suggestion_entries(steps) + |> Enum.concat(unmatched_goal_entries) + |> segment() + end + + defp to_suggestion_entries(suggestions, steps) do + suggestions |> exclude_existing_steps(steps) |> Enum.map( &{to_step_data(&1), to_string(&1), [type: if(&1.event_name, do: :custom_event, else: :pageview)]} ) |> set_icons() - |> then(&PlausibleWeb.Live.Components.ComboBox.StaticSearch.suggest(input, &1)) - |> segment() end defp set_icons(suggestions) do @@ -429,8 +439,16 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do end defp exclude_existing_steps(goals, steps) do - existing_steps = MapSet.new(steps, &{&1.event_name, &1.page_path}) - Enum.reject(goals, &MapSet.member?(existing_steps, {&1.event_name, &1.page_path})) + existing_steps = + MapSet.new(steps, &{&1.event_name, &1.page_path, &1.scroll_threshold, &1.custom_props}) + + Enum.reject( + goals, + &MapSet.member?( + existing_steps, + {&1.event_name, &1.page_path, &1.scroll_threshold, &1.custom_props} + ) + ) end defp has_steps_errors?(f) do From 031244246771383d802b11a18dd8b811a0425209 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Mon, 19 Oct 2026 14:55:07 +0200 Subject: [PATCH 11/36] Move modal backdrop to `FunnelSettings` --- extra/lib/plausible_web/live/funnel_settings.ex | 12 ++++++++++++ extra/lib/plausible_web/live/funnel_settings/form.ex | 6 ------ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/extra/lib/plausible_web/live/funnel_settings.ex b/extra/lib/plausible_web/live/funnel_settings.ex index 8255cd75ccae..d78b9dccd6f9 100644 --- a/extra/lib/plausible_web/live/funnel_settings.ex +++ b/extra/lib/plausible_web/live/funnel_settings.ex @@ -65,6 +65,13 @@ defmodule PlausibleWeb.Live.FunnelSettings do <:subtitle :if={Enum.count(@all_funnels) > 0}> Compose goals into funnels to track user flows and conversion rates. +

+
<%= if @setup_funnel? do %> {live_render( @socket, @@ -128,6 +135,11 @@ defmodule PlausibleWeb.Live.FunnelSettings do {:noreply, assign(socket, setup_funnel?: true)} end + def handle_event("cancel-setup-funnel", _value, socket) do + send(self(), :cancel_setup_funnel) + {:noreply, socket} + end + def handle_event("edit-funnel", %{"funnel-id" => id}, socket) do {:noreply, assign(socket, setup_funnel?: true, funnel_id: String.to_integer(id))} end diff --git a/extra/lib/plausible_web/live/funnel_settings/form.ex b/extra/lib/plausible_web/live/funnel_settings/form.ex index 6ef91fe9eb83..58c277501078 100644 --- a/extra/lib/plausible_web/live/funnel_settings/form.ex +++ b/extra/lib/plausible_web/live/funnel_settings/form.ex @@ -42,12 +42,6 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do def render(assigns) do ~H""" -
-
From b591b11f3b23094006c4775b3374b10fc36c18c9 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Mon, 19 Oct 2026 15:30:29 +0200 Subject: [PATCH 12/36] Hide spinner when form is rendered --- extra/lib/plausible_web/live/funnel_settings.ex | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/extra/lib/plausible_web/live/funnel_settings.ex b/extra/lib/plausible_web/live/funnel_settings.ex index d78b9dccd6f9..71b8c6b4a4f9 100644 --- a/extra/lib/plausible_web/live/funnel_settings.ex +++ b/extra/lib/plausible_web/live/funnel_settings.ex @@ -47,7 +47,7 @@ defmodule PlausibleWeb.Live.FunnelSettings do # Normally, we'd have to use live_patch which we can't do with views unmounted at the router it seems. def render(assigns) do ~H""" -
+
<.flash_messages flash={@flash} /> <.tile @@ -67,10 +67,15 @@ defmodule PlausibleWeb.Live.FunnelSettings do
+
+
+ <.spinner class="inline-block h-8 w-8" /> +
+
<%= if @setup_funnel? do %> {live_render( From c07adb20ae7f1b6361294430e1e88cfcc8eec218 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Mon, 19 Oct 2026 16:02:36 +0200 Subject: [PATCH 13/36] Filter static goals in consistence with exploration suggestions --- .../live/funnel_settings/form.ex | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/extra/lib/plausible_web/live/funnel_settings/form.ex b/extra/lib/plausible_web/live/funnel_settings/form.ex index 58c277501078..9f7dd4899db9 100644 --- a/extra/lib/plausible_web/live/funnel_settings/form.ex +++ b/extra/lib/plausible_web/live/funnel_settings/form.ex @@ -372,8 +372,8 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do unmatched_goal_entries = unmatched_goals + |> static_suggest(input) |> to_suggestion_entries(steps) - |> then(&PlausibleWeb.Live.Components.ComboBox.StaticSearch.suggest(input, &1)) suggestions |> to_suggestion_entries(steps) @@ -381,6 +381,28 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do |> segment() end + defp static_suggest(goals, input) do + # NOTE: static search for goals logic should be in sync with + # `Plausible.Stats.Exploration.maybe_search/2`. + + case String.trim(input) do + term when byte_size(term) > 2 -> + term = String.downcase(term) + + Enum.filter(goals, fn g -> + display_name = String.downcase(g.display_name) + event_name = String.downcase(g.event_name || "") + page_path = String.downcase(g.page_path || "") + + String.contains?(display_name, term) or String.contains?(event_name, term) or + String.contains?(page_path, term) + end) + + _ -> + goals + end + end + defp to_suggestion_entries(suggestions, steps) do suggestions |> exclude_existing_steps(steps) From 708448316277fc7142ed753652167c4e397596d3 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Mon, 19 Oct 2026 16:05:24 +0200 Subject: [PATCH 14/36] Revert changes made to `StaticSearch` --- lib/plausible_web/live/components/combo_box/static_search.ex | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/plausible_web/live/components/combo_box/static_search.ex b/lib/plausible_web/live/components/combo_box/static_search.ex index e13d97926110..1a68f3e251eb 100644 --- a/lib/plausible_web/live/components/combo_box/static_search.ex +++ b/lib/plausible_web/live/components/combo_box/static_search.ex @@ -9,7 +9,7 @@ defmodule PlausibleWeb.Live.Components.ComboBox.StaticSearch do Allows fuzzy matching based on Jaro Distance. """ - @spec suggest(String.t(), [item]) :: item when item: {any(), any()} | {any(), any(), list()} + @spec suggest(String.t(), [{any(), any()}]) :: [{any(), any()}] def suggest(input, choices, opts \\ []) do input = String.trim(input) @@ -21,9 +21,6 @@ defmodule PlausibleWeb.Live.Components.ComboBox.StaticSearch do {_, value} = choice -> {choice, weight(value, input, opts)} - {_, value, _} = choice -> - {choice, weight(value, input, opts)} - value -> {value, weight(value, input, opts)} end) From 3dc4437ee0b05f6cd29446f136b927d304892f87 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Mon, 19 Oct 2026 16:37:53 +0200 Subject: [PATCH 15/36] DRY display name building logic --- extra/lib/plausible/funnel/dynamic_step.ex | 12 +----------- extra/lib/plausible/stats/funnel.ex | 13 ++----------- lib/plausible/goal.ex | 12 ++++++++++-- test/support/factory.ex | 8 ++++---- 4 files changed, 17 insertions(+), 28 deletions(-) diff --git a/extra/lib/plausible/funnel/dynamic_step.ex b/extra/lib/plausible/funnel/dynamic_step.ex index 7dfd61d8cdad..1e95f9916c8a 100644 --- a/extra/lib/plausible/funnel/dynamic_step.ex +++ b/extra/lib/plausible/funnel/dynamic_step.ex @@ -44,7 +44,7 @@ defmodule Plausible.Funnel.DynamicStep do def as_goal(step) do %Plausible.Goal{ id: 0, - display_name: display_name(step), + display_name: Plausible.Goal.display_name(step.event_name, step.page_path), event_name: step.event_name, page_path: step.page_path, scroll_threshold: step.scroll_threshold, @@ -60,14 +60,4 @@ defmodule Plausible.Funnel.DynamicStep do changeset end end - - defp display_name(step) do - case step do - %{page_path: page_path} when is_binary(page_path) -> - "Visit " <> page_path - - %{event_name: event_name} when is_binary(event_name) -> - event_name - end - end end diff --git a/extra/lib/plausible/stats/funnel.ex b/extra/lib/plausible/stats/funnel.ex index 4ea9f1b364e3..73ab4373c338 100644 --- a/extra/lib/plausible/stats/funnel.ex +++ b/extra/lib/plausible/stats/funnel.ex @@ -58,7 +58,8 @@ defmodule Plausible.Stats.Funnel do def step_to_goal(step, goals_map) do goal = goals_map[{step.name, step.pathname}] - if not is_nil(goal) and step.is_goal and display_name(step) == goal.display_name do + if not is_nil(goal) and step.is_goal and + Plausible.Goal.display_name(step.name, step.pathname) == goal.display_name do {goal, :static, {step.name, step.pathname}} else goal = @@ -116,16 +117,6 @@ defmodule Plausible.Stats.Funnel do })} end - defp display_name(step) do - case step do - %{pathname: pathname} when byte_size(pathname) > 0 -> - "Visit " <> pathname - - %{name: name} when byte_size(name) > 0 -> - name - end - end - defp recompute_flexible(final_funnel, query, site, %{funnel_type: :flexible} = funnel) do if length(Funnel.steps(funnel)) > 2 do short_funnel = convert_to_first_and_last(funnel) diff --git a/lib/plausible/goal.ex b/lib/plausible/goal.ex index 202b6bf851ca..0c613d04cf23 100644 --- a/lib/plausible/goal.ex +++ b/lib/plausible/goal.ex @@ -109,6 +109,14 @@ defmodule Plausible.Goal do goal.display_name end + @spec display_name(event_name :: String.t() | nil, page_path :: String.t() | nil) :: String.t() + def display_name(event_name, page_path) do + case {event_name, page_path} do + {event, path} when event in [nil, "", "pageview"] and is_binary(path) -> "Visit " <> path + {event, _} when is_binary(event) -> event + end + end + @spec type(t()) :: :event | :scroll | :page def type(goal) do cond do @@ -217,10 +225,10 @@ defmodule Plausible.Goal do case clause do [nil, path, _] when is_binary(path) -> - put_change(changeset, :display_name, "Visit " <> path) + put_change(changeset, :display_name, display_name(nil, path)) [nil, _, event_name] when is_binary(event_name) -> - put_change(changeset, :display_name, event_name) + put_change(changeset, :display_name, display_name(event_name, nil)) _ -> changeset diff --git a/test/support/factory.ex b/test/support/factory.ex index df1d0af1b5ff..c973fdbd4729 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -178,19 +178,19 @@ defmodule Plausible.Factory do attrs = case {attrs, display_name_provided?} do {%{page_path: path}, false} when is_binary(path) -> - Map.put(attrs, :display_name, "Visit " <> path) + Map.put(attrs, :display_name, Plausible.Goal.display_name(nil, path)) {%{page_path: path}, false} when is_function(path, 0) -> attrs - |> Map.put(:display_name, "Visit " <> path.()) + |> Map.put(:display_name, Plausible.Goal.display_name(nil, path.())) |> Map.put(:page_path, path.()) {%{event_name: event_name}, false} when is_binary(event_name) -> - Map.put(attrs, :display_name, event_name) + Map.put(attrs, :display_name, Plausible.Goal.display_name(event_name, nil)) {%{event_name: event_name}, false} when is_function(event_name, 0) -> attrs - |> Map.put(:display_name, event_name.()) + |> Map.put(:display_name, Plausible.Goal.display_name(event_name.(), nil)) |> Map.put(:event_name, event_name.()) _ -> From 00808e211ad460aaea925e3549a66374ea7b7624 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Wed, 21 Oct 2026 16:44:01 +0200 Subject: [PATCH 16/36] Add identifiers and classes to Prima combobox component for easier testing --- lib/plausible_web/live/components/prima_combobox.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/plausible_web/live/components/prima_combobox.ex b/lib/plausible_web/live/components/prima_combobox.ex index 6df684281a96..006b67350f11 100644 --- a/lib/plausible_web/live/components/prima_combobox.ex +++ b/lib/plausible_web/live/components/prima_combobox.ex @@ -56,6 +56,7 @@ defmodule PlausibleWeb.Live.Components.PrimaCombobox do > <.combobox :let={input_value} name={@submit_name} id={@id} class={@class} selections={@selected}> <.combobox_input + id={"combobox-search-input-#{@id}"} value={input_value} class={ Enum.join( @@ -103,7 +104,7 @@ defmodule PlausibleWeb.Live.Components.PrimaCombobox do <.combobox_option id={"input-picker-dropdown-#{@id}-option-#{idx}"} - class="px-4 py-2.5 leading-5 rounded-md text-sm text-gray-800 dark:text-gray-200 hover:text-gray-900 dark:hover:text-gray-100 cursor-pointer select-none data-focus:bg-gray-100 data-focus:text-gray-900 dark:data-focus:bg-gray-700 dark:data-focus:text-gray-100 data-selected:bg-gray-100 data-selected:text-gray-900 dark:data-selected:bg-gray-700 dark:data-selected:text-gray-100" + class="input-picker-dropdown-option px-4 py-2.5 leading-5 rounded-md text-sm text-gray-800 dark:text-gray-200 hover:text-gray-900 dark:hover:text-gray-100 cursor-pointer select-none data-focus:bg-gray-100 data-focus:text-gray-900 dark:data-focus:bg-gray-700 dark:data-focus:text-gray-100 data-selected:bg-gray-100 data-selected:text-gray-900 dark:data-selected:bg-gray-700 dark:data-selected:text-gray-100" value={value} display={display_name} > From c6ab68ca606901009488b80ff4fc1e7301fce45f Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Wed, 21 Oct 2026 16:44:34 +0200 Subject: [PATCH 17/36] Fix accounting for extra properties when evaluating ephemeral funnel --- extra/lib/plausible_web/live/funnel_settings/form.ex | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/extra/lib/plausible_web/live/funnel_settings/form.ex b/extra/lib/plausible_web/live/funnel_settings/form.ex index 9f7dd4899db9..862abbd239d2 100644 --- a/extra/lib/plausible_web/live/funnel_settings/form.ex +++ b/extra/lib/plausible_web/live/funnel_settings/form.ex @@ -570,7 +570,10 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do "goal" => %{ "id" => goal.id, "event_name" => goal.event_name, - "page_path" => goal.page_path + "page_path" => goal.page_path, + "currency" => goal.currency, + "scroll_threshold" => goal.scroll_threshold, + "custom_props" => goal.custom_props } } end) From 9b6d87e3dc441e7ca2abeab60e929de3ce99af4b Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Wed, 21 Oct 2026 16:45:20 +0200 Subject: [PATCH 18/36] Expand E2E helper controller with ability to fetch funnel and add dynamic steps --- lib/plausible_web/router.ex | 1 + .../support/dev/controllers/e2e_controller.ex | 42 +++++++++++++++++-- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/lib/plausible_web/router.ex b/lib/plausible_web/router.ex index 9250703ec295..f59bbc911ae9 100644 --- a/lib/plausible_web/router.ex +++ b/lib/plausible_web/router.ex @@ -191,6 +191,7 @@ defmodule PlausibleWeb.Router do post "/stats", E2EController, :populate_stats post "/funnel", E2EController, :create_funnel + get "/funnel", E2EController, :get_funnel post "/goal", E2EController, :create_goal put "/verification", E2EController, :put_verification_scenario end diff --git a/test/support/dev/controllers/e2e_controller.ex b/test/support/dev/controllers/e2e_controller.ex index 0691da387456..311c291021fd 100644 --- a/test/support/dev/controllers/e2e_controller.ex +++ b/test/support/dev/controllers/e2e_controller.ex @@ -87,9 +87,13 @@ defmodule PlausibleWeb.E2EController do site = Plausible.Repo.get_by!(Plausible.Site, domain: domain) steps = - Enum.map(steps, fn step -> - goal = get_goal(site, step) - %{"goal_id" => goal.id} + Enum.map(steps, fn + goal_name when is_binary(goal_name) -> + goal = get_goal(site, goal_name) + %{"goal_id" => goal.id} + + goal_data when is_map(goal_data) -> + goal_data end) {:ok, _} = Plausible.Funnels.create(site, name, steps) @@ -97,6 +101,38 @@ defmodule PlausibleWeb.E2EController do send_resp(conn, 200, Jason.encode!(%{"ok" => true})) end + def get_funnel(conn, %{"domain" => domain, "name" => name}) do + site = Plausible.Repo.get_by!(Plausible.Site, domain: domain) + + funnel = + Plausible.Funnel + |> Plausible.Repo.get_by!(site_id: site.id, name: name) + |> Plausible.Repo.preload(steps: :goal) + + steps = Plausible.Funnel.goals(funnel) + + funnel_data = + funnel + |> Map.take([:id, :name, :funnel_type, :site_id]) + |> Map.put( + :steps, + Enum.map( + steps, + &Map.take(&1, [ + :id, + :event_name, + :page_path, + :scroll_threshold, + :display_name, + :currency, + :custom_props + ]) + ) + ) + + send_resp(conn, 200, Jason.encode!(funnel_data)) + end + def put_verification_scenario(conn, %{"domain" => domain, "scenario" => scenario} = params) do opts = [ slowdown: params["options"]["slowdown"] || 0, From 4341a3750485f13fa48fc2b7d6db08cc641a5687 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Wed, 21 Oct 2026 16:45:50 +0200 Subject: [PATCH 19/36] Expand e2e fixtures with ability to fetch funnel --- e2e/tests/fixtures.ts | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/e2e/tests/fixtures.ts b/e2e/tests/fixtures.ts index 2f53f7171394..c195501ae000 100644 --- a/e2e/tests/fixtures.ts +++ b/e2e/tests/fixtures.ts @@ -62,6 +62,11 @@ type ImportedVisitors = { date?: EventDate } +type FunnelInputStep = + | string + | { event_name: string; currency?: string } + | { page_path: string; scroll_threshold?: string } + export type StatsEntry = Event | ImportedVisitors export async function register({ @@ -504,7 +509,7 @@ export async function addFunnel({ request: APIRequestContext domain: string name: string - steps: string[] + steps: FunnelInputStep[] }) { const response = await request.post('/e2e-tests/funnel', { headers: { @@ -517,6 +522,28 @@ export async function addFunnel({ expect(response.ok()).toBeTruthy() } +export async function getFunnel({ + request, + domain, + name +}: { + request: APIRequestContext + domain: string + name: string +}) { + const response = await request.get('/e2e-tests/funnel', { + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json' + }, + params: { domain: domain, name: name } + }) + + expect(response.ok()).toBeTruthy() + + return await response.json() +} + export async function subscribeToPlan({ page, planId From 8fd803f1a4762270b9a76bf5a2dc6c22e1b47366 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Wed, 21 Oct 2026 16:46:09 +0200 Subject: [PATCH 20/36] Convert no longer feasible LV tests to e2e tests --- .../site-settings/funnel-settings.spec.ts | 369 ++++++++++++++++++ .../live/funnel_settings/form_test.exs | 115 ------ .../live/funnel_settings_test.exs | 179 +-------- 3 files changed, 390 insertions(+), 273 deletions(-) create mode 100644 e2e/tests/site-settings/funnel-settings.spec.ts delete mode 100644 test/plausible_web/live/funnel_settings/form_test.exs diff --git a/e2e/tests/site-settings/funnel-settings.spec.ts b/e2e/tests/site-settings/funnel-settings.spec.ts new file mode 100644 index 000000000000..12643346a612 --- /dev/null +++ b/e2e/tests/site-settings/funnel-settings.spec.ts @@ -0,0 +1,369 @@ +import { test, expect } from '@playwright/test' +import { + setupSite, + populateStats, + getFunnel, + addGoal, + addFunnel +} from '../fixtures' + +test.describe('funnel settings', () => { + test('form integration tests', async ({ page, request }) => { + const { domain } = await setupSite({ page, request }) + + await populateStats({ + request, + domain, + events: [ + { user_id: 123, name: 'Hello World', timestamp: { hoursAgo: 60 } }, + { user_id: 123, name: 'Plausible', timestamp: { hoursAgo: 50 } }, + { user_id: 123, name: 'Another World', timestamp: { hoursAgo: 45 } } + ] + }) + + await page.goto(`/${domain}/settings/funnels`, { waitUntil: 'commit' }) + + await test.step('search reacts to the input the user types in', async () => { + await page.getByRole('button', { name: 'Add funnel' }).click() + + await expect(page.locator('#funnel-form')).toBeVisible() + + await page.locator('#step-1 input').fill('hello') + + await expect( + page + .locator('#step-1 .input-picker-dropdown-option') + .getByText('Hello World') + ).toBeVisible() + + await page.locator('#step-1 input').fill('hello') + + await expect( + page + .locator('#step-1 .input-picker-dropdown-option') + .getByText('Plausible') + ).toBeVisible() + + await page.keyboard.press('Escape') + await page.keyboard.press('Escape') + + await expect(page.locator('#funnel-form')).toBeHidden() + }) + + await test.step('selecting option prefills input values', async () => { + await page.getByRole('button', { name: 'Add funnel' }).click() + + await expect(page.locator('#funnel-form')).toBeVisible() + + await page.locator('#step-1 input').fill('another') + + await page + .locator('#step-1 .input-picker-dropdown-option') + .getByText('Another World') + .click() + + await expect(page.locator('#step-1 input')).toHaveValue('Another World') + + await page.keyboard.press('Escape') + await page.keyboard.press('Escape') + + await expect(page.locator('#funnel-form')).toBeHidden() + }) + + await test.step('selecting one option reduces suggestions in the other', async () => { + await page.getByRole('button', { name: 'Add funnel' }).click() + + await expect(page.locator('#funnel-form')).toBeVisible() + + await page.locator('#step-1 input').fill('another') + + await page + .locator('#step-1 .input-picker-dropdown-option') + .getByText('Another World') + .click() + + await page.locator('#step-2 input').fill('a') + + await expect( + page + .locator('#step-2 .input-picker-dropdown-option') + .getByText('Another World') + ).toBeHidden() + + await page.keyboard.press('Escape') + await page.keyboard.press('Escape') + + await expect(page.locator('#funnel-form')).toBeHidden() + }) + + await test.step('removing one option alters suggestions for other', async () => { + await page.getByRole('button', { name: 'Add funnel' }).click() + + await expect(page.locator('#funnel-form')).toBeVisible() + + await page.locator('a[phx-click=add-step]').click() + + await page.locator('#step-2 input').fill('hello') + + await page + .locator('#step-2 .input-picker-dropdown-option') + .getByText('Hello World') + .click() + + await page.locator('#step-1 input').fill('hello') + + await expect( + page + .locator('#step-1 .input-picker-dropdown-option') + .getByText('Hello World') + ).toBeHidden() + + await page.locator('#remove-step-2').click() + + await page.locator('#step-1 input').fill('hello') + + await expect( + page + .locator('#step-1 .input-picker-dropdown-option') + .getByText('Hello World') + ).toBeVisible() + + await page.keyboard.press('Escape') + await page.keyboard.press('Escape') + + await expect(page.locator('#funnel-form')).toBeHidden() + }) + }) + + test('save button saves a new funnel', async ({ page, request }) => { + const { domain } = await setupSite({ page, request }) + + await populateStats({ + request, + domain, + events: [ + { + user_id: 123, + name: 'pageview', + pathname: '/page1', + timestamp: { hoursAgo: 60 } + }, + { + user_id: 123, + name: 'Form: Submission', + 'meta.key': ['path'], + 'meta.value': ['/form-action/path'], + timestamp: { hoursAgo: 50 } + }, + { + user_id: 123, + name: 'pageview', + pathname: '/page2', + timestamp: { hoursAgo: 45 } + } + ] + }) + + await page.goto(`/${domain}/settings/funnels`, { waitUntil: 'commit' }) + + await page.getByRole('button', { name: 'Add funnel' }).click() + + await expect(page.locator('#funnel-form')).toBeVisible() + + await expect(page.locator('button#save')).toBeDisabled() + + await page.locator('#step-1 input').fill('page') + + await page + .locator('#step-1 .input-picker-dropdown-option') + .getByText('Visit /page1') + .click() + + await expect(page.locator('#step-1 input')).toHaveValue('Visit /page1') + + await page.locator('#step-2 input').click() + + await page + .locator('#step-2 .input-picker-dropdown-option') + .getByText('Form: Submission') + .click() + + await page.locator('input#funnel_name').fill('First funnel') + + await expect(page.locator('button#save')).toBeEnabled() + + await page.locator('button#save').click() + + await expect(page.locator('#funnel-form')).toBeHidden() + + await expect(page.locator('#funnel-settings-main table')).toContainText( + 'First funnel' + ) + + const { + name: funnel_name, + steps: [step1, step2] + } = await getFunnel({ request, domain, name: 'First funnel' }) + + expect(funnel_name).toBe('First funnel') + expect(step1.id).toBe(0) // dynamic step + expect(step1.page_path).toBe('/page1') + expect(step2.id).toBeGreaterThan(0) // static step + expect(step2.event_name).toBe('Form: Submission') + }) + + test('clicking save after editing the funnel updates it', async ({ + page, + request + }) => { + const { domain } = await setupSite({ page, request }) + + await addGoal({ + request, + domain, + params: { page_path: '/page1' } + }) + + await addGoal({ + request, + domain, + params: { event_name: 'Form: Submission' } + }) + + await populateStats({ + request, + domain, + events: [ + { + user_id: 123, + name: 'pageview', + pathname: '/page1', + timestamp: { hoursAgo: 60 } + }, + { + user_id: 123, + name: 'Form: Submission', + 'meta.key': ['path'], + 'meta.value': ['/form-action/path'], + timestamp: { hoursAgo: 50 } + }, + { + user_id: 123, + name: 'pageview', + pathname: '/page2', + timestamp: { hoursAgo: 45 } + }, + { + user_id: 124, + name: 'pageview', + pathname: '/page1', + timestamp: { hoursAgo: 60 } + }, + { + user_id: 124, + name: 'pageview', + pathname: '/page2', + timestamp: { hoursAgo: 45 } + } + ] + }) + + await addFunnel({ + request, + domain, + name: 'Existing funnel', + steps: ['Visit /page1', { page_path: '/page2' }] + }) + + await page.goto(`/${domain}/settings/funnels`, { waitUntil: 'commit' }) + + await page.locator('button[phx-click=edit-funnel]').click() + + await expect(page.locator('#funnel-form')).toBeVisible() + + await page.locator('#step-2 input').fill('form') + + await page + .locator('#step-2 .input-picker-dropdown-option') + .getByText('Form: Submission') + .click() + + await expect(page.locator('#step-2 input')).toHaveValue('Form: Submission') + + await page.locator('input#funnel_name').fill('Updated funnel') + + await expect(page.locator('button#save')).toBeEnabled() + + await page.locator('button#save').click() + + await expect(page.locator('#funnel-form')).toBeHidden() + + const { + name: funnel_name, + steps: [step1, step2] + } = await getFunnel({ request, domain, name: 'Updated funnel' }) + + expect(funnel_name).toBe('Updated funnel') + expect(step1.id).toBeGreaterThan(0) // static step + expect(step1.page_path).toBe('/page1') + expect(step2.id).toBeGreaterThan(0) // static step + expect(step2.event_name).toBe('Form: Submission') + }) + + test('funnel gets evaluated on every select, assuming a second has passed between selections', async ({ + page, + request + }) => { + const { domain } = await setupSite({ page, request }) + + await populateStats({ + request, + domain, + events: [ + { + user_id: 123, + name: 'pageview', + pathname: '/page1', + timestamp: { hoursAgo: 60 } + }, + { + user_id: 123, + name: 'Form: Submission', + 'meta.key': ['path'], + 'meta.value': ['/form-action/path'], + timestamp: { hoursAgo: 50 } + }, + { + user_id: 123, + name: 'pageview', + pathname: '/page2', + timestamp: { hoursAgo: 45 } + } + ] + }) + + await page.goto(`/${domain}/settings/funnels`, { waitUntil: 'commit' }) + + await page.getByRole('button', { name: 'Add funnel' }).click() + + await expect(page.locator('#funnel-form')).toBeVisible() + + await page.locator('#step-1 input').click() + + await page.locator('#step-1 .input-picker-dropdown-option').first().click() + + await expect(page.locator('#step-1 input')).not.toHaveValue('') + + await page.locator('#step-2 input').click() + + await page.locator('#step-2 .input-picker-dropdown-option').first().click() + + await expect(page.locator('#step-2 input')).not.toHaveValue('') + + await expect(page.locator('#step-eval-0')).toHaveText('Visitors: 1') + await expect(page.locator('#step-eval-1')).toHaveText('Dropoff: 100%') + await expect(page.locator('#funnel-eval')).toHaveText( + 'Last month conversion rate: 0%' + ) + }) +}) diff --git a/test/plausible_web/live/funnel_settings/form_test.exs b/test/plausible_web/live/funnel_settings/form_test.exs deleted file mode 100644 index aa8e06cb3874..000000000000 --- a/test/plausible_web/live/funnel_settings/form_test.exs +++ /dev/null @@ -1,115 +0,0 @@ -defmodule PlausibleWeb.Live.FunnelSettings.FormTest do - use PlausibleWeb.ConnCase, async: true - @moduletag :ee_only - - import Phoenix.LiveViewTest - - 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-1") == "Hello World" - - doc = type_into_combo(lv, 1, "plausible") - - assert text_of_element(doc, "#dropdown-step-1-option-1") == "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-1 a") - |> render_click() - - 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-1 a") - |> render_click() - - doc = type_into_combo(lv, 2, "another") - - refute text_of_element(doc, "ul#dropdown-step-2 li") =~ "Another World" - - doc = type_into_combo(lv, 1, "a") - - refute text_of_element(doc, "ul#dropdown-step-1 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-1 a") - |> render_click() - - doc = type_into_combo(lv, 1, "hello") - - refute text_of_element(doc, "ul#dropdown-step-0 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}, max_goals_per_site: 100) - - 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 diff --git a/test/plausible_web/live/funnel_settings_test.exs b/test/plausible_web/live/funnel_settings_test.exs index bfdea34e88e3..2ae4ee3a04e3 100644 --- a/test/plausible_web/live/funnel_settings_test.exs +++ b/test/plausible_web/live/funnel_settings_test.exs @@ -186,16 +186,11 @@ defmodule PlausibleWeb.Live.FunnelSettingsTest do assert element_exists?(doc, ~s/form input[type="text"][name="funnel[name]"]/) - assert element_exists?( - doc, - ~s/input[type="hidden"][name="funnel[steps][1][step_data]"]#submit-step-1/ - ) - step_setup_controls = [ - ~s/input[type="hidden"][name="funnel[steps][1][step_data]"]#submit-step-1/, - ~s/input[type="hidden"][name="funnel[steps][2][step_data]"]#submit-step-2/, - ~s/input[type="text"][name="display-step-1"]#step-1/, - ~s/input[type="text"][name="display-step-2"]#step-2/, + ~s/#step-1 select[hidden][name="funnel[steps][1][step_data]"]/, + ~s/#step-2 select[hidden][name="funnel[steps][2][step_data]"]/, + ~s/#step-1 input[type="text"]/, + ~s/#step-2 input[type="text"]/, ~s/a[phx-click="add-step"]/ ] @@ -217,10 +212,10 @@ defmodule PlausibleWeb.Live.FunnelSettingsTest do assert element_exists?( doc, - ~s/input[type="hidden"][name="funnel[steps][3][step_data]"]#submit-step-3/ + ~s/#step-3 select[hidden][name="funnel[steps][3][step_data]"]/ ) - assert element_exists?(doc, ~s/input[type="text"][name="display-step-1"]#step-1/) + assert element_exists?(doc, ~s/#step-3 input[type="text"]/) assert element_exists?( doc, @@ -249,85 +244,9 @@ defmodule PlausibleWeb.Live.FunnelSettingsTest do doc = lv |> element(~s/#remove-step-2/) |> render_click() - assert element_exists?(doc, ~s/input#step-1/) - assert element_exists?(doc, ~s/input#step-3/) - refute element_exists?(doc, ~s/input#step-2/) - end - - test "save button becomes active once at least two steps are selected", %{ - conn: conn, - site: site - } do - setup_goals(site) - lv = get_liveview(conn, site) - lv |> element(~s/button[phx-click="add-funnel"]/) |> render_click() - - assert lv = find_live_child(lv, "funnels-form") - - lv - |> element("li#dropdown-step-1-option-1 a") - |> render_click() - - doc = - lv - |> element("li#dropdown-step-2-option-1 a") - |> render_click() - - assert element_exists?(doc, ~s/form button#save:disabled/) - - doc = - lv - |> element("form") - |> render_change(%{ - funnel: %{ - name: "My test funnel", - steps: [ - %{goal_id: 1}, - %{goal_id: 2} - ] - } - }) - - assert element_exists?(doc, ~s/form button#save/) - refute element_exists?(doc, ~s/form button#save:disabled/) - end - - test "save button saves a new funnel", %{ - conn: conn, - site: site - } do - {:ok, [g1, g2]} = setup_goals(site) - lv = get_liveview(conn, site) - lv |> element(~s/button[phx-click="add-funnel"]/) |> render_click() - - assert lv = find_live_child(lv, "funnels-form") - - lv - |> element("li#dropdown-step-1-option-1 a") - |> render_click() - - lv - |> element("li#dropdown-step-2-option-2 a") - |> render_click() - - lv - |> element("form") - |> render_change(%{ - funnel: %{ - name: "My test funnel", - steps: [ - %{step_data: JSON.encode!(%{goal_id: g1.id})}, - %{step_data: JSON.encode!(%{goal_id: g2.id})} - ] - } - }) - - lv - |> element(~s/form/) - |> render_submit() - - assert %Plausible.Funnel{steps: [_, _], funnel_type: :sequential} = - Plausible.Funnels.get(site, "My test funnel") + assert element_exists?(doc, ~s/#step-1 input/) + assert element_exists?(doc, ~s/#step-3 input/) + refute element_exists?(doc, ~s/#step-2 input/) end test "save button saves strict-order setting", %{ @@ -441,10 +360,19 @@ defmodule PlausibleWeb.Live.FunnelSettingsTest do assert lv = find_live_child(lv, "funnels-form") - assert lv |> element("#step-1") |> render() |> text_of_attr("value") == - "Visit /go/to/blog/**" + %{"display_name" => "Visit /go/to/blog/**"} = + lv + |> element("#step-1_submit option[selected]") + |> render() + |> text_of_attr("value") + |> JSON.decode!() - assert lv |> element("#step-2") |> render() |> text_of_attr("value") == "Signup" + %{"display_name" => "Signup"} = + lv + |> element("#step-2_submit option[selected]") + |> render() + |> text_of_attr("value") + |> JSON.decode!() end test "funnel is re-evaluated when strict-order is changed", %{ @@ -619,46 +547,6 @@ defmodule PlausibleWeb.Live.FunnelSettingsTest do assert text_of_element(render(lv), ~s/#funnel-eval/) =~ "Last month conversion rate: 50%" end - test "clicking save after editing the funnel, updates it", %{ - conn: conn, - site: site - } do - {:ok, [f1_id, _]} = setup_funnels(site, ["Existing funnel"]) - {:ok, %{id: goal_id}} = Plausible.Goals.create(site, %{"page_path" => "/"}) - %{steps: [%{id: step1_goal_id}, _]} = Plausible.Funnels.get(site, "Existing funnel") - - lv = get_liveview(conn, site) - - lv - |> element(~s/button[phx-click="edit-funnel"][phx-value-funnel-id="#{f1_id}"]/) - |> render_click() - - assert lv = find_live_child(lv, "funnels-form") - - lv - |> element("li#dropdown-step-2-option-2 a") - |> render_click() - - lv - |> element("form") - |> render_change(%{ - funnel: %{ - name: "Updated funnel", - steps: [ - %{step_data: JSON.encode!(%{goal_id: step1_goal_id})}, - %{step_data: JSON.encode!(%{goal_id: goal_id})} - ] - } - }) - - lv - |> element(~s/form/) - |> render_submit() - - assert %Plausible.Funnel{steps: [_, %Plausible.Funnel.Step{goal_id: ^goal_id}]} = - Plausible.Funnels.get(site, "Updated funnel") - end - test "clicking save after editing the funnel, updates strict-order setting", %{ conn: conn, site: site @@ -726,31 +614,6 @@ defmodule PlausibleWeb.Live.FunnelSettingsTest do assert %Plausible.Funnel{funnel_type: :flexible} = Plausible.Funnels.get(site, "Editable flexible funnel") end - - test "funnel gets evaluated on every select, assuming a second has passed between selections", - %{ - conn: conn, - site: site - } do - setup_goals(site) - lv = get_liveview(conn, site) - lv |> element(~s/button[phx-click="add-funnel"]/) |> render_click() - - assert lv = find_live_child(lv, "funnels-form") - - lv |> element("li#dropdown-step-1-option-1 a") |> render_click() - - lv |> element("li#dropdown-step-2-option-1 a") |> render_click() - - doc = lv |> element("#step-eval-0") |> render() - assert text_of_element(doc, ~s/#step-eval-0/) =~ "Visitors: 0" - - doc = lv |> element("#step-eval-1") |> render() - assert text_of_element(doc, ~s/#step-eval-1/) =~ "Dropoff: 0%" - - doc = lv |> element("#funnel-eval") |> render() - assert text_of_element(doc, ~s/#funnel-eval/) =~ "Last month conversion rate: 0%" - end end defp setup_funnels(site, names \\ []) do From ebfecd5c4f7ba0b1b08ee4f691fb27a0b110a5c6 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 22 Oct 2026 10:07:17 +0200 Subject: [PATCH 21/36] Fix typespec --- extra/lib/plausible/stats/funnel.ex | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/extra/lib/plausible/stats/funnel.ex b/extra/lib/plausible/stats/funnel.ex index 73ab4373c338..713ecda5de1a 100644 --- a/extra/lib/plausible/stats/funnel.ex +++ b/extra/lib/plausible/stats/funnel.ex @@ -53,7 +53,9 @@ defmodule Plausible.Stats.Funnel do {Enum.reverse(suggestions), Map.values(unmatched_goals)} end - @spec step_to_goal(Exploration.Journey.Step.t(), [Plausible.Goal.t()]) :: + @spec step_to_goal(Exploration.Journey.Step.t(), %{ + {String.t(), String.t()} => Plausible.Goal.t() + }) :: {Plausible.Goal.t(), :static, {binary(), binary()}} | {Plausible.Goal.t(), :dynamic} def step_to_goal(step, goals_map) do goal = goals_map[{step.name, step.pathname}] From 8f4896b6ed41a378062841399ca7c420ed11a583 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 22 Oct 2026 10:49:37 +0200 Subject: [PATCH 22/36] Don't consider journey when querying suggestions from stats --- extra/lib/plausible/stats/funnel.ex | 9 +-------- extra/lib/plausible_web/live/funnel_settings/form.ex | 2 +- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/extra/lib/plausible/stats/funnel.ex b/extra/lib/plausible/stats/funnel.ex index 713ecda5de1a..ea8b989138e2 100644 --- a/extra/lib/plausible/stats/funnel.ex +++ b/extra/lib/plausible/stats/funnel.ex @@ -21,21 +21,14 @@ defmodule Plausible.Stats.Funnel do @spec suggest(Plausible.Site.t(), Plausible.Stats.Query.t(), Keyword.t()) :: {[Plausible.Goal.t()], [Plausible.Goal.t()]} def suggest(site, query, opts \\ []) do - steps = Keyword.get(opts, :steps, []) goals = Keyword.get(opts, :goals, []) input = Keyword.get(opts, :input, "") - journey = Enum.map(steps, &Exploration.Journey.Step.from/1) - goals_map = Map.new(goals, fn g -> {{g.event_name || "pageview", g.page_path || ""}, g} end) {:ok, suggestions} = - site - |> Exploration.next_steps(query, journey, - search_term: input, - include_wildcard?: false - ) + Exploration.next_steps(site, query, [], search_term: input, include_wildcard?: false) {suggestions, unmatched_goals} = suggestions diff --git a/extra/lib/plausible_web/live/funnel_settings/form.ex b/extra/lib/plausible_web/live/funnel_settings/form.ex index 862abbd239d2..d9845558062d 100644 --- a/extra/lib/plausible_web/live/funnel_settings/form.ex +++ b/extra/lib/plausible_web/live/funnel_settings/form.ex @@ -368,7 +368,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do ) {suggestions, unmatched_goals} = - Plausible.Stats.Funnel.suggest(site, query, steps: steps, goals: goals, input: input) + Plausible.Stats.Funnel.suggest(site, query, goals: goals, input: input) unmatched_goal_entries = unmatched_goals From e9fc57b09c6afc1d4c14765a43cd8ea8bd5f49ce Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 22 Oct 2026 12:27:41 +0200 Subject: [PATCH 23/36] Consider extra goal properties when evaluating ephemeral funnel --- extra/lib/plausible_web/live/funnel_settings/form.ex | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/extra/lib/plausible_web/live/funnel_settings/form.ex b/extra/lib/plausible_web/live/funnel_settings/form.ex index d9845558062d..5d202b5ea8e0 100644 --- a/extra/lib/plausible_web/live/funnel_settings/form.ex +++ b/extra/lib/plausible_web/live/funnel_settings/form.ex @@ -28,7 +28,15 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do |> Enum.map(fn goal -> struct!( Plausible.Goal, - Map.take(goal, [:id, :display_name, :event_name, :page_path, :currency]) + Map.take(goal, [ + :id, + :display_name, + :event_name, + :page_path, + :scroll_threshold, + :currency, + :custom_props + ]) ) end) From a384c9786eb6d9691eadb33c7d466a4e40d17b78 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 22 Oct 2026 12:28:02 +0200 Subject: [PATCH 24/36] Don't pass goals with extra properties to suggestion query --- .../site-settings/funnel-settings.spec.ts | 66 +++++++++++++++++++ .../live/funnel_settings/form.ex | 11 +++- 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/e2e/tests/site-settings/funnel-settings.spec.ts b/e2e/tests/site-settings/funnel-settings.spec.ts index 12643346a612..de8511165765 100644 --- a/e2e/tests/site-settings/funnel-settings.spec.ts +++ b/e2e/tests/site-settings/funnel-settings.spec.ts @@ -366,4 +366,70 @@ test.describe('funnel settings', () => { 'Last month conversion rate: 0%' ) }) + + test('scroll goal can be picked for a matching pageview event from the previous step', async ({ + page, + request + }) => { + const { domain } = await setupSite({ page, request }) + + await addGoal({ + request, + domain, + params: { + page_path: '/page1', + scroll_threshold: 90, + display_name: 'Scroll 90% on /page1' + } + }) + + await populateStats({ + request, + domain, + events: [ + { + user_id: 123, + name: 'pageview', + pathname: '/page1', + timestamp: { minutesAgo: 1500 } + }, + { + user_id: 123, + name: 'engagement', + pathname: '/page1', + scroll_depth: 90, + timestamp: { minutesAgo: 1499 } + }, + { + user_id: 123, + name: 'pageview', + pathname: '/page2', + timestamp: { minutesAgo: 1498 } + } + ] + }) + + await page.goto(`/${domain}/settings/funnels`, { waitUntil: 'commit' }) + + await page.getByRole('button', { name: 'Add funnel' }).click() + + await expect(page.locator('#funnel-form')).toBeVisible() + + await page.locator('#step-1 input').click() + + await page + .locator('#step-1 .input-picker-dropdown-option') + .getByText('Visit /page1') + .click() + + await expect(page.locator('#step-1 input')).toHaveValue('Visit /page1') + + await page.locator('#step-2 input').click() + + await expect( + page + .locator('#step-2 .input-picker-dropdown-option') + .getByText('Scroll 90% on /page1') + ).toBeVisible() + }) }) diff --git a/extra/lib/plausible_web/live/funnel_settings/form.ex b/extra/lib/plausible_web/live/funnel_settings/form.ex index 5d202b5ea8e0..367dd50959f0 100644 --- a/extra/lib/plausible_web/live/funnel_settings/form.ex +++ b/extra/lib/plausible_web/live/funnel_settings/form.ex @@ -375,11 +375,16 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do input_date_range: {:last_n_days, 30} ) - {suggestions, unmatched_goals} = - Plausible.Stats.Funnel.suggest(site, query, goals: goals, input: input) + {goals_for_query, other_goals} = + Enum.split_with(goals, fn g -> + g.scroll_threshold == -1 and map_size(g.custom_props) == 0 and is_nil(g.currency) + end) + + {suggestions, unmatched_query_goals} = + Plausible.Stats.Funnel.suggest(site, query, goals: goals_for_query, input: input) unmatched_goal_entries = - unmatched_goals + (unmatched_query_goals ++ other_goals) |> static_suggest(input) |> to_suggestion_entries(steps) From cc4291ab6bda9d675caa2923d9aa4f899ea0380f Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 22 Oct 2026 12:59:58 +0200 Subject: [PATCH 25/36] Remove constraint on repeating funnel steps --- .../live/funnel_settings/form.ex | 30 ++++--------------- 1 file changed, 5 insertions(+), 25 deletions(-) diff --git a/extra/lib/plausible_web/live/funnel_settings/form.ex b/extra/lib/plausible_web/live/funnel_settings/form.ex index 367dd50959f0..08ca31fec353 100644 --- a/extra/lib/plausible_web/live/funnel_settings/form.ex +++ b/extra/lib/plausible_web/live/funnel_settings/form.ex @@ -146,7 +146,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do selected={selected_option(@steps, @funnel_modified?, step_idx)} suggest_trigger={@steps} suggest_fun={ - fn input, _choices -> suggest(input, @site, @goals, @steps, step_idx) end + fn input, _choices -> suggest(input, @site, @goals) end } />
@@ -362,13 +362,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do {:noreply, evaluate_funnel(socket)} end - defp suggest(input, site, goals, steps, step_idx) do - steps = - steps - |> Enum.sort_by(&elem(&1, 0)) - |> Enum.take_while(fn {idx, _} -> idx != "step-#{step_idx}" end) - |> Enum.map(&elem(&1, 1)) - + defp suggest(input, site, goals) do query = QueryBuilder.build!(site, metrics: [:pageviews], @@ -386,10 +380,10 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do unmatched_goal_entries = (unmatched_query_goals ++ other_goals) |> static_suggest(input) - |> to_suggestion_entries(steps) + |> to_suggestion_entries() suggestions - |> to_suggestion_entries(steps) + |> to_suggestion_entries() |> Enum.concat(unmatched_goal_entries) |> segment() end @@ -416,9 +410,8 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do end end - defp to_suggestion_entries(suggestions, steps) do + defp to_suggestion_entries(suggestions) do suggestions - |> exclude_existing_steps(steps) |> Enum.map( &{to_step_data(&1), to_string(&1), [type: if(&1.event_name, do: :custom_event, else: :pageview)]} @@ -467,19 +460,6 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do end) end - defp exclude_existing_steps(goals, steps) do - existing_steps = - MapSet.new(steps, &{&1.event_name, &1.page_path, &1.scroll_threshold, &1.custom_props}) - - Enum.reject( - goals, - &MapSet.member?( - existing_steps, - {&1.event_name, &1.page_path, &1.scroll_threshold, &1.custom_props} - ) - ) - end - defp has_steps_errors?(f) do not f.source.valid? end From d2782cd9d449dd20ad4c3d32924074c010f7b50e Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 22 Oct 2026 13:15:03 +0200 Subject: [PATCH 26/36] WIP --- extra/lib/plausible_web/live/funnel_settings/form.ex | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/extra/lib/plausible_web/live/funnel_settings/form.ex b/extra/lib/plausible_web/live/funnel_settings/form.ex index 08ca31fec353..15826195e2da 100644 --- a/extra/lib/plausible_web/live/funnel_settings/form.ex +++ b/extra/lib/plausible_web/live/funnel_settings/form.ex @@ -51,7 +51,16 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do def render(assigns) do ~H"""
-
+
+ + +
<.form :let={f} From 76bffd81fb30dc770cacb5ac0a916f4b70ace53b Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Thu, 22 Oct 2026 13:23:00 +0200 Subject: [PATCH 27/36] fix spinner --- .../plausible_web/live/funnel_settings/form.ex | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/extra/lib/plausible_web/live/funnel_settings/form.ex b/extra/lib/plausible_web/live/funnel_settings/form.ex index 15826195e2da..ed9cf2dc9255 100644 --- a/extra/lib/plausible_web/live/funnel_settings/form.ex +++ b/extra/lib/plausible_web/live/funnel_settings/form.ex @@ -51,17 +51,12 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do def render(assigns) do ~H"""
-
- - +
+
+ -
<.form :let={f} for={@form} From b7b481ecccacaf151f8e50502a40e1ee2c9653fa Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Thu, 22 Oct 2026 13:25:52 +0200 Subject: [PATCH 28/36] Don't close the modal on click on spinner layer --- extra/lib/plausible_web/live/funnel_settings/form.ex | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/extra/lib/plausible_web/live/funnel_settings/form.ex b/extra/lib/plausible_web/live/funnel_settings/form.ex index ed9cf2dc9255..dfb710947dfa 100644 --- a/extra/lib/plausible_web/live/funnel_settings/form.ex +++ b/extra/lib/plausible_web/live/funnel_settings/form.ex @@ -52,7 +52,12 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do ~H"""
-
+
@@ -63,7 +68,6 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do 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-900 shadow-2xl rounded-lg px-8 pt-6 pb-8 mb-4 mt-8" > From 126770eeba842e7987eac9779234ea08a54443b2 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 22 Oct 2026 13:41:02 +0200 Subject: [PATCH 29/36] Update tests removing no longer valid ones --- .../site-settings/funnel-settings.spec.ts | 66 +------------------ .../live/funnel_settings_test.exs | 2 +- 2 files changed, 2 insertions(+), 66 deletions(-) diff --git a/e2e/tests/site-settings/funnel-settings.spec.ts b/e2e/tests/site-settings/funnel-settings.spec.ts index de8511165765..be577d97b019 100644 --- a/e2e/tests/site-settings/funnel-settings.spec.ts +++ b/e2e/tests/site-settings/funnel-settings.spec.ts @@ -69,70 +69,6 @@ test.describe('funnel settings', () => { await expect(page.locator('#funnel-form')).toBeHidden() }) - - await test.step('selecting one option reduces suggestions in the other', async () => { - await page.getByRole('button', { name: 'Add funnel' }).click() - - await expect(page.locator('#funnel-form')).toBeVisible() - - await page.locator('#step-1 input').fill('another') - - await page - .locator('#step-1 .input-picker-dropdown-option') - .getByText('Another World') - .click() - - await page.locator('#step-2 input').fill('a') - - await expect( - page - .locator('#step-2 .input-picker-dropdown-option') - .getByText('Another World') - ).toBeHidden() - - await page.keyboard.press('Escape') - await page.keyboard.press('Escape') - - await expect(page.locator('#funnel-form')).toBeHidden() - }) - - await test.step('removing one option alters suggestions for other', async () => { - await page.getByRole('button', { name: 'Add funnel' }).click() - - await expect(page.locator('#funnel-form')).toBeVisible() - - await page.locator('a[phx-click=add-step]').click() - - await page.locator('#step-2 input').fill('hello') - - await page - .locator('#step-2 .input-picker-dropdown-option') - .getByText('Hello World') - .click() - - await page.locator('#step-1 input').fill('hello') - - await expect( - page - .locator('#step-1 .input-picker-dropdown-option') - .getByText('Hello World') - ).toBeHidden() - - await page.locator('#remove-step-2').click() - - await page.locator('#step-1 input').fill('hello') - - await expect( - page - .locator('#step-1 .input-picker-dropdown-option') - .getByText('Hello World') - ).toBeVisible() - - await page.keyboard.press('Escape') - await page.keyboard.press('Escape') - - await expect(page.locator('#funnel-form')).toBeHidden() - }) }) test('save button saves a new funnel', async ({ page, request }) => { @@ -356,7 +292,7 @@ test.describe('funnel settings', () => { await page.locator('#step-2 input').click() - await page.locator('#step-2 .input-picker-dropdown-option').first().click() + await page.locator('#step-2 .input-picker-dropdown-option').nth(1).click() await expect(page.locator('#step-2 input')).not.toHaveValue('') diff --git a/test/plausible_web/live/funnel_settings_test.exs b/test/plausible_web/live/funnel_settings_test.exs index 2ae4ee3a04e3..a41091317a75 100644 --- a/test/plausible_web/live/funnel_settings_test.exs +++ b/test/plausible_web/live/funnel_settings_test.exs @@ -181,7 +181,7 @@ defmodule PlausibleWeb.Live.FunnelSettingsTest do assert element_exists?( doc, - ~s/form[phx-change="validate"][phx-submit="save"][phx-click-away="cancel-add-funnel"]/ + ~s/form[phx-change="validate"][phx-submit="save"]/ ) assert element_exists?(doc, ~s/form input[type="text"][name="funnel[name]"]/) From 0aa453ce961463162dd1aa59bc3b11682d312f43 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 22 Oct 2026 13:48:34 +0200 Subject: [PATCH 30/36] Remove forced triggering of suggestions refresh --- .../live/funnel_settings/form.ex | 5 +-- .../live/components/prima_combobox.ex | 38 ++++++------------- 2 files changed, 13 insertions(+), 30 deletions(-) diff --git a/extra/lib/plausible_web/live/funnel_settings/form.ex b/extra/lib/plausible_web/live/funnel_settings/form.ex index dfb710947dfa..98ad9dcd84aa 100644 --- a/extra/lib/plausible_web/live/funnel_settings/form.ex +++ b/extra/lib/plausible_web/live/funnel_settings/form.ex @@ -152,10 +152,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do module={PlausibleWeb.Live.Components.PrimaCombobox} submit_name={"funnel[steps][#{step_idx}][step_data]"} selected={selected_option(@steps, @funnel_modified?, step_idx)} - suggest_trigger={@steps} - suggest_fun={ - fn input, _choices -> suggest(input, @site, @goals) end - } + suggest_fun={fn input, _choices -> suggest(input, @site, @goals) end} />
diff --git a/lib/plausible_web/live/components/prima_combobox.ex b/lib/plausible_web/live/components/prima_combobox.ex index 006b67350f11..c84c0a9bbe2c 100644 --- a/lib/plausible_web/live/components/prima_combobox.ex +++ b/lib/plausible_web/live/components/prima_combobox.ex @@ -24,19 +24,8 @@ defmodule PlausibleWeb.Live.Components.PrimaCombobox do |> assign(assigns) |> assign(:selected, selected) |> assign_new(:suggestions, fn -> [] end) - |> assign_new(:last_query, fn -> "" end) - suggest_trigger = assigns[:suggest_trigger] && hash(assigns[:suggest_trigger]) - - socket = - if suggest_trigger && suggest_trigger != socket.assigns[:suggest_trigger] do - suggestions = handle_suggest(socket, socket.assigns.last_query) - assign(socket, :suggestions, suggestions) - else - socket - end - - {:ok, assign(socket, :suggest_trigger, suggest_trigger)} + {:ok, socket} end attr(:id, :string, required: true) @@ -146,22 +135,19 @@ defmodule PlausibleWeb.Live.Components.PrimaCombobox do end def handle_event("async_combobox_search", %{"query" => query}, socket) do - suggestions = handle_suggest(socket, query) - - {:noreply, assign(socket, suggestions: suggestions, last_query: query)} - end - - defp handle_suggest(socket, query) do options = socket.assigns[:options] || [] - if suggest_fun = socket.assigns[:suggest_fun] do - suggest_fun.(query, options) - else - Enum.filter(options, fn option -> - String.contains?(String.downcase(option), String.downcase(query)) - end) - end - |> Enum.map(&{hash(&1), &1}) + suggestions = + if suggest_fun = socket.assigns[:suggest_fun] do + suggest_fun.(query, options) + else + Enum.filter(options, fn option -> + String.contains?(String.downcase(option), String.downcase(query)) + end) + end + |> Enum.map(&{hash(&1), &1}) + + {:noreply, assign(socket, suggestions: suggestions)} end defp hash(value) do From 728ff1bb8da51d7352b0efb98b81d6f581ad5ff7 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 22 Oct 2026 14:00:23 +0200 Subject: [PATCH 31/36] Revert "Remove forced triggering of suggestions refresh" This reverts commit 0aa453ce961463162dd1aa59bc3b11682d312f43. --- .../live/funnel_settings/form.ex | 5 ++- .../live/components/prima_combobox.ex | 38 +++++++++++++------ 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/extra/lib/plausible_web/live/funnel_settings/form.ex b/extra/lib/plausible_web/live/funnel_settings/form.ex index 98ad9dcd84aa..dfb710947dfa 100644 --- a/extra/lib/plausible_web/live/funnel_settings/form.ex +++ b/extra/lib/plausible_web/live/funnel_settings/form.ex @@ -152,7 +152,10 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do module={PlausibleWeb.Live.Components.PrimaCombobox} submit_name={"funnel[steps][#{step_idx}][step_data]"} selected={selected_option(@steps, @funnel_modified?, step_idx)} - suggest_fun={fn input, _choices -> suggest(input, @site, @goals) end} + suggest_trigger={@steps} + suggest_fun={ + fn input, _choices -> suggest(input, @site, @goals) end + } />
diff --git a/lib/plausible_web/live/components/prima_combobox.ex b/lib/plausible_web/live/components/prima_combobox.ex index c84c0a9bbe2c..006b67350f11 100644 --- a/lib/plausible_web/live/components/prima_combobox.ex +++ b/lib/plausible_web/live/components/prima_combobox.ex @@ -24,8 +24,19 @@ defmodule PlausibleWeb.Live.Components.PrimaCombobox do |> assign(assigns) |> assign(:selected, selected) |> assign_new(:suggestions, fn -> [] end) + |> assign_new(:last_query, fn -> "" end) - {:ok, socket} + suggest_trigger = assigns[:suggest_trigger] && hash(assigns[:suggest_trigger]) + + socket = + if suggest_trigger && suggest_trigger != socket.assigns[:suggest_trigger] do + suggestions = handle_suggest(socket, socket.assigns.last_query) + assign(socket, :suggestions, suggestions) + else + socket + end + + {:ok, assign(socket, :suggest_trigger, suggest_trigger)} end attr(:id, :string, required: true) @@ -135,19 +146,22 @@ defmodule PlausibleWeb.Live.Components.PrimaCombobox do end def handle_event("async_combobox_search", %{"query" => query}, socket) do - options = socket.assigns[:options] || [] + suggestions = handle_suggest(socket, query) - suggestions = - if suggest_fun = socket.assigns[:suggest_fun] do - suggest_fun.(query, options) - else - Enum.filter(options, fn option -> - String.contains?(String.downcase(option), String.downcase(query)) - end) - end - |> Enum.map(&{hash(&1), &1}) + {:noreply, assign(socket, suggestions: suggestions, last_query: query)} + end - {:noreply, assign(socket, suggestions: suggestions)} + defp handle_suggest(socket, query) do + options = socket.assigns[:options] || [] + + if suggest_fun = socket.assigns[:suggest_fun] do + suggest_fun.(query, options) + else + Enum.filter(options, fn option -> + String.contains?(String.downcase(option), String.downcase(query)) + end) + end + |> Enum.map(&{hash(&1), &1}) end defp hash(value) do From 373d83d1df857d7aadee5b8d16261c8c089de6b3 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 22 Oct 2026 15:04:35 +0200 Subject: [PATCH 32/36] Revert "Remove constraint on repeating funnel steps" This reverts commit cc4291ab6bda9d675caa2923d9aa4f899ea0380f. --- .../live/funnel_settings/form.ex | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/extra/lib/plausible_web/live/funnel_settings/form.ex b/extra/lib/plausible_web/live/funnel_settings/form.ex index dfb710947dfa..1b439fb0e4c4 100644 --- a/extra/lib/plausible_web/live/funnel_settings/form.ex +++ b/extra/lib/plausible_web/live/funnel_settings/form.ex @@ -154,7 +154,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do selected={selected_option(@steps, @funnel_modified?, step_idx)} suggest_trigger={@steps} suggest_fun={ - fn input, _choices -> suggest(input, @site, @goals) end + fn input, _choices -> suggest(input, @site, @goals, @steps, step_idx) end } />
@@ -370,7 +370,13 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do {:noreply, evaluate_funnel(socket)} end - defp suggest(input, site, goals) do + defp suggest(input, site, goals, steps, step_idx) do + steps = + steps + |> Enum.sort_by(&elem(&1, 0)) + |> Enum.take_while(fn {idx, _} -> idx != "step-#{step_idx}" end) + |> Enum.map(&elem(&1, 1)) + query = QueryBuilder.build!(site, metrics: [:pageviews], @@ -388,10 +394,10 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do unmatched_goal_entries = (unmatched_query_goals ++ other_goals) |> static_suggest(input) - |> to_suggestion_entries() + |> to_suggestion_entries(steps) suggestions - |> to_suggestion_entries() + |> to_suggestion_entries(steps) |> Enum.concat(unmatched_goal_entries) |> segment() end @@ -418,8 +424,9 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do end end - defp to_suggestion_entries(suggestions) do + defp to_suggestion_entries(suggestions, steps) do suggestions + |> exclude_existing_steps(steps) |> Enum.map( &{to_step_data(&1), to_string(&1), [type: if(&1.event_name, do: :custom_event, else: :pageview)]} @@ -468,6 +475,19 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do end) end + defp exclude_existing_steps(goals, steps) do + existing_steps = + MapSet.new(steps, &{&1.event_name, &1.page_path, &1.scroll_threshold, &1.custom_props}) + + Enum.reject( + goals, + &MapSet.member?( + existing_steps, + {&1.event_name, &1.page_path, &1.scroll_threshold, &1.custom_props} + ) + ) + end + defp has_steps_errors?(f) do not f.source.valid? end From c8ce17923aa93d87e53a600780278b162bbeccba Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 22 Oct 2026 15:38:40 +0200 Subject: [PATCH 33/36] Exclude repeating steps regardless if they are before or after --- extra/lib/plausible_web/live/funnel_settings/form.ex | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/extra/lib/plausible_web/live/funnel_settings/form.ex b/extra/lib/plausible_web/live/funnel_settings/form.ex index 1b439fb0e4c4..3a26b1f57f4d 100644 --- a/extra/lib/plausible_web/live/funnel_settings/form.ex +++ b/extra/lib/plausible_web/live/funnel_settings/form.ex @@ -373,8 +373,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do defp suggest(input, site, goals, steps, step_idx) do steps = steps - |> Enum.sort_by(&elem(&1, 0)) - |> Enum.take_while(fn {idx, _} -> idx != "step-#{step_idx}" end) + |> Enum.filter(fn {idx, _} -> idx != "step-#{step_idx}" end) |> Enum.map(&elem(&1, 1)) query = From 4a37edfae0551d75a603515ccb70b9bec41a38eb Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 22 Oct 2026 16:30:46 +0200 Subject: [PATCH 34/36] Avoid losing display name when storing static steps --- extra/lib/plausible_web/live/funnel_settings/form.ex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/extra/lib/plausible_web/live/funnel_settings/form.ex b/extra/lib/plausible_web/live/funnel_settings/form.ex index 3a26b1f57f4d..b7fecab3479c 100644 --- a/extra/lib/plausible_web/live/funnel_settings/form.ex +++ b/extra/lib/plausible_web/live/funnel_settings/form.ex @@ -513,7 +513,8 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do defp to_goal(%{"goal_id" => goal_id} = step_data) do step_data |> Map.delete("goal_id") - |> to_goal() + |> then(&Plausible.Goal.changeset(%Plausible.Goal{}, &1)) + |> Ecto.Changeset.apply_changes() |> Map.put(:id, goal_id) end From bc1f3c4b38fdd35d66977646b14c658fb361e75f Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 22 Oct 2026 17:21:56 +0200 Subject: [PATCH 35/36] Revert "Update tests removing no longer valid ones" This reverts commit 126770eeba842e7987eac9779234ea08a54443b2. --- .../site-settings/funnel-settings.spec.ts | 66 ++++++++++++++++++- .../live/funnel_settings_test.exs | 2 +- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/e2e/tests/site-settings/funnel-settings.spec.ts b/e2e/tests/site-settings/funnel-settings.spec.ts index be577d97b019..de8511165765 100644 --- a/e2e/tests/site-settings/funnel-settings.spec.ts +++ b/e2e/tests/site-settings/funnel-settings.spec.ts @@ -69,6 +69,70 @@ test.describe('funnel settings', () => { await expect(page.locator('#funnel-form')).toBeHidden() }) + + await test.step('selecting one option reduces suggestions in the other', async () => { + await page.getByRole('button', { name: 'Add funnel' }).click() + + await expect(page.locator('#funnel-form')).toBeVisible() + + await page.locator('#step-1 input').fill('another') + + await page + .locator('#step-1 .input-picker-dropdown-option') + .getByText('Another World') + .click() + + await page.locator('#step-2 input').fill('a') + + await expect( + page + .locator('#step-2 .input-picker-dropdown-option') + .getByText('Another World') + ).toBeHidden() + + await page.keyboard.press('Escape') + await page.keyboard.press('Escape') + + await expect(page.locator('#funnel-form')).toBeHidden() + }) + + await test.step('removing one option alters suggestions for other', async () => { + await page.getByRole('button', { name: 'Add funnel' }).click() + + await expect(page.locator('#funnel-form')).toBeVisible() + + await page.locator('a[phx-click=add-step]').click() + + await page.locator('#step-2 input').fill('hello') + + await page + .locator('#step-2 .input-picker-dropdown-option') + .getByText('Hello World') + .click() + + await page.locator('#step-1 input').fill('hello') + + await expect( + page + .locator('#step-1 .input-picker-dropdown-option') + .getByText('Hello World') + ).toBeHidden() + + await page.locator('#remove-step-2').click() + + await page.locator('#step-1 input').fill('hello') + + await expect( + page + .locator('#step-1 .input-picker-dropdown-option') + .getByText('Hello World') + ).toBeVisible() + + await page.keyboard.press('Escape') + await page.keyboard.press('Escape') + + await expect(page.locator('#funnel-form')).toBeHidden() + }) }) test('save button saves a new funnel', async ({ page, request }) => { @@ -292,7 +356,7 @@ test.describe('funnel settings', () => { await page.locator('#step-2 input').click() - await page.locator('#step-2 .input-picker-dropdown-option').nth(1).click() + await page.locator('#step-2 .input-picker-dropdown-option').first().click() await expect(page.locator('#step-2 input')).not.toHaveValue('') diff --git a/test/plausible_web/live/funnel_settings_test.exs b/test/plausible_web/live/funnel_settings_test.exs index a41091317a75..2ae4ee3a04e3 100644 --- a/test/plausible_web/live/funnel_settings_test.exs +++ b/test/plausible_web/live/funnel_settings_test.exs @@ -181,7 +181,7 @@ defmodule PlausibleWeb.Live.FunnelSettingsTest do assert element_exists?( doc, - ~s/form[phx-change="validate"][phx-submit="save"]/ + ~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]"]/) From e64798f7fbae1146db142994c9a6866ead1ed636 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 22 Oct 2026 17:34:36 +0200 Subject: [PATCH 36/36] Fix test --- test/plausible_web/live/funnel_settings_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/plausible_web/live/funnel_settings_test.exs b/test/plausible_web/live/funnel_settings_test.exs index 2ae4ee3a04e3..a41091317a75 100644 --- a/test/plausible_web/live/funnel_settings_test.exs +++ b/test/plausible_web/live/funnel_settings_test.exs @@ -181,7 +181,7 @@ defmodule PlausibleWeb.Live.FunnelSettingsTest do assert element_exists?( doc, - ~s/form[phx-change="validate"][phx-submit="save"][phx-click-away="cancel-add-funnel"]/ + ~s/form[phx-change="validate"][phx-submit="save"]/ ) assert element_exists?(doc, ~s/form input[type="text"][name="funnel[name]"]/)