Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
b00f2ac
Add `dynamic_steps` to `funnels` db schema
zoldar Sep 21, 2026
4a4ddc5
Introduce `dynamic_steps` to Funnel Ecto.Schema and populate it
zoldar Sep 21, 2026
756f006
Abstract retrieving funnel steps and goals
zoldar Sep 21, 2026
5331dd0
Fix mixed funnels support in funnel stats query
zoldar Sep 21, 2026
a319b5f
Set dynamic step goal ID explicitly to 0 and add missing currency
zoldar Sep 22, 2026
86e714b
Test retrieval of a mixed funnel via Plugins API
zoldar Sep 22, 2026
cfaf1d1
Fix a typo bug
zoldar Sep 22, 2026
1992ecf
WIP
zoldar Sep 22, 2026
64049ee
Fix dynamic funnels after rebasing against flexible funnels feature
zoldar Sep 23, 2026
c8f0291
Remove unused join
zoldar Sep 23, 2026
de7dbdf
Fix test
zoldar Sep 23, 2026
1aa2913
Remove unnecessary preload
zoldar Sep 23, 2026
4c5b8c2
Change the way the first_and_last funnel is built
zoldar Sep 23, 2026
2d7a66a
Add more tests and enable fully dynamic funnels
zoldar Sep 23, 2026
13f9822
Expose basic suggestions API for funnels
zoldar Sep 24, 2026
10e6810
Add scaffolding of dynamic funnel form
zoldar Sep 24, 2026
89894cd
Implement half-working, Rube-Goldbergesque dynamic funnel suggestions
zoldar Sep 24, 2026
e34d44f
Implement ability to add creatable options directly from suggestions
zoldar Sep 28, 2026
f3576ba
Deserialize step data when saving steps
zoldar Sep 28, 2026
f1becc9
Fix listing dynamic funnels
zoldar Sep 28, 2026
5064c95
Improve building suggestions
zoldar Sep 29, 2026
c512ed6
Merge existing goals with funnel suggestions
zoldar Sep 29, 2026
3faac3f
Adjust most of the tests
zoldar Sep 29, 2026
d8d7c51
Filter out journey end event from suggestions
zoldar Sep 29, 2026
d5e8157
Remove creatable
zoldar Sep 30, 2026
9c45249
Update tests
zoldar Sep 30, 2026
e4dcaa0
Exclude existing selections from suggestions up to current step
zoldar Sep 30, 2026
33b81a6
WIP Prima combobox integration
zoldar Oct 1, 2026
513bee9
Add basic support for icons
zoldar Oct 1, 2026
f2baa01
Expand on icons and segmentation
zoldar Oct 1, 2026
ccf19da
Switch from local path dependency to prima git branch
zoldar Oct 1, 2026
36be6ec
Format
zoldar Oct 1, 2026
5f60de3
Alter query time range to fixed last 30 days
zoldar Oct 1, 2026
43e4333
Fix segment sorting
zoldar Oct 1, 2026
dbcaa3e
Try to fix option layout
zoldar Oct 1, 2026
5410a1b
Increase combobox width
zoldar Oct 1, 2026
d036448
Make further styling fixes
zoldar Oct 1, 2026
a871e4e
Use revised combobox version and try make this Rube-Goldberg contrapt…
zoldar Oct 5, 2026
c1c4588
Trigger CI
zoldar Oct 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions assets/js/liveview/live_socket.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import 'phoenix_html'
import { Socket } from 'phoenix'
import { LiveSocket } from 'phoenix_live_view'
import { Modal, Dropdown } from 'prima'
import { Modal, Dropdown, Combobox } from 'prima'
import topbar from 'topbar'
/* eslint-enable import/no-unresolved */

Expand All @@ -19,7 +19,7 @@ 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, CopySnippet, MemberRows }
let Hooks = { Modal, Dropdown, Combobox, CopySnippet, MemberRows }

Hooks.VerificationLifecycle = {
mounted() {
Expand Down
100 changes: 92 additions & 8 deletions extra/lib/plausible/funnel.ex
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ defmodule Plausible.Funnel do
import Ecto.Changeset

alias Plausible.Funnel.Step
alias Plausible.Funnel.DynamicStep

@funnel_types [:sequential, :flexible, :strict]

Expand Down Expand Up @@ -62,6 +63,8 @@ defmodule Plausible.Funnel do

belongs_to :site, Plausible.Site

embeds_many :dynamic_steps, DynamicStep, on_replace: :delete

has_many :steps, Step,
preload_order: [
asc: :step_order
Expand All @@ -72,25 +75,106 @@ defmodule Plausible.Funnel do
timestamps()
end

def goals(funnel) do
funnel
|> steps()
|> Enum.map(&as_goal/1)
end

def steps(funnel) do
funnel.steps
|> Enum.concat(funnel.dynamic_steps)
|> Enum.sort_by(& &1.step_order)
end

def as_goal(%Step{} = step), do: Step.as_goal(step)
def as_goal(%DynamicStep{} = step), do: DynamicStep.as_goal(step)

def changeset(funnel \\ %__MODULE__{}, attrs \\ %{}) do
funnel
|> cast(attrs, [:name, :funnel_type])
|> validate_required([:name])
|> set_funnel_type()
|> put_steps(attrs[:steps] || attrs["steps"])
|> validate_length(:steps, min: @min_steps, max: @max_steps)
|> validate_steps_length()
|> unique_constraint(:name,
name: :funnels_name_site_id_index
)
end

def put_steps(changeset, steps) do
steps
|> Enum.map(&Step.changeset(%Step{}, &1))
|> Enum.with_index(fn step, step_order ->
Ecto.Changeset.put_change(step, :step_order, step_order + 1)
end)
|> then(&Ecto.Changeset.put_assoc(changeset, :steps, &1))
@spec swap(t(), Keyword.t()) :: t()
def swap(funnel, opts) do
funnel_type = Keyword.get(opts, :funnel_type, funnel.funnel_type)

funnel
|> Map.put(:funnel_type, funnel_type)
|> swap_steps(Keyword.get(opts, :steps))
end

defp swap_steps(funnel, nil), do: funnel

defp swap_steps(funnel, steps) do
{static_steps, dynamic_steps} =
steps
|> Enum.with_index(1)
|> Enum.map(fn {step, idx} -> %{step | step_order: idx} end)
|> Enum.split_with(fn
%Step{} -> true
_ -> false
end)

%{funnel | steps: static_steps, dynamic_steps: dynamic_steps}
end

defp put_steps(changeset, steps) do
{static_steps, dynamic_steps} =
steps
|> Enum.with_index(1)
|> Enum.map(fn {input, idx} ->
input
|> schema_by_input()
|> build_step(input, idx)
end)
|> Enum.split_with(fn
%{data: %Step{}} -> true
_ -> false
end)

changeset
|> Ecto.Changeset.put_assoc(:steps, static_steps)
|> Ecto.Changeset.put_embed(:dynamic_steps, dynamic_steps)
end

defp validate_steps_length(changeset) do
steps_count =
length(Ecto.Changeset.get_assoc(changeset, :steps)) +
length(Ecto.Changeset.get_embed(changeset, :dynamic_steps))

if steps_count < @min_steps or steps_count > @max_steps do
end

cond do
steps_count < @min_steps ->
add_error(changeset, :steps, "should have at least #{@min_steps} item(s)")

steps_count > @max_steps ->
add_error(changeset, :steps, "should have no more than #{@max_steps} item(s)")

true ->
changeset
end
end

defp schema_by_input(%Plausible.Goal{}), do: Step
defp schema_by_input(%Step{}), do: Step
defp schema_by_input(%{goal_id: _}), do: Step
defp schema_by_input(%{"goal_id" => _}), do: Step
defp schema_by_input(_), do: DynamicStep

defp build_step(step_schema, params, step_order) do
params
|> step_schema.changeset()
|> Ecto.Changeset.put_change(:step_order, step_order)
end

defp set_funnel_type(%{valid?: true} = changeset) do
Expand Down
75 changes: 75 additions & 0 deletions extra/lib/plausible/funnel/dynamic_step.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
defmodule Plausible.Funnel.DynamicStep do
@moduledoc """
This module defines the database schema for a single Dynamic Funnel step.
See: `Plausible.Funnel` for more information.
"""

use Ecto.Schema
import Ecto.Changeset

@type t() :: %__MODULE__{}

@fields [
:event_name,
:page_path,
:display_name,
:scroll_threshold,
:custom_props,
:currency
]

@primary_key false
embedded_schema 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()

field :custom_props, :map, default: %{}
end

@spec changeset(map() | t()) :: Ecto.Changeset.t()
def changeset(step_or_attrs \\ %{})

def changeset(%__MODULE__{} = step), do: change(step)

def changeset(attrs) do
%__MODULE__{}
|> cast(attrs, @fields)
|> Plausible.Goal.base_changeset()
|> validate_page_or_custom_goal()
end

@spec as_goal(t()) :: Plausible.Goal.t()
def as_goal(step) do
%Plausible.Goal{
id: 0,
display_name: step.display_name || display_name(step),
event_name: step.event_name,
page_path: step.page_path,
scroll_threshold: step.scroll_threshold,
custom_props: step.custom_props,
currency: step.currency
}
end

defp validate_page_or_custom_goal(changeset) do
if get_field(changeset, :event_name) && get_field(changeset, :page_path) do
add_error(changeset, :event_name, "cannot co-exist with page_path")
else
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
18 changes: 13 additions & 5 deletions extra/lib/plausible/funnel/step.ex
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,27 @@ defmodule Plausible.Funnel.Step do
timestamps()
end

def changeset(step, goal_or_attrs \\ %{})
@spec changeset(map() | Plausible.Goal.t() | t()) :: Ecto.Changeset.t()
def changeset(goal_step_or_attrs \\ %{})

def changeset(step, %Plausible.Goal{id: goal_id}) do
changeset(step, %{goal_id: goal_id})
def changeset(%__MODULE__{} = step), do: change(step)

def changeset(%Plausible.Goal{id: goal_id}) do
changeset(%{goal_id: goal_id})
end

def changeset(step, attrs) do
step
def changeset(attrs) do
%__MODULE__{}
|> cast(attrs, [:goal_id])
|> cast_assoc(:goal)
|> validate_required([:goal_id])
|> unique_constraint(:goal,
name: :funnel_steps_goal_id_funnel_id_index
)
end

@spec as_goal(t()) :: Plausible.Goal.t()
def as_goal(step) do
step.goal
end
end
14 changes: 9 additions & 5 deletions extra/lib/plausible/funnels.ex
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,16 @@ defmodule Plausible.Funnels do
def list(%Plausible.Site{} = site) do
q =
from(f in Funnel,
inner_join: steps in assoc(f, :steps),
left_join: steps in assoc(f, :steps),
where: f.site_id == ^site.id,
group_by: f.id,
order_by: [desc: :id],
select: %{name: f.name, id: f.id, steps_count: count(steps)}
select: %{
name: f.name,
id: f.id,
steps_count:
count(steps) + fragment("coalesce(jsonb_array_length(?), 0)", f.dynamic_steps)
}
)

Repo.all(q)
Expand Down Expand Up @@ -137,7 +142,6 @@ defmodule Plausible.Funnels do
@spec with_goals_query(Plausible.Site.t()) :: Ecto.Query.t()
def with_goals_query(site) do
from(f in Funnel,
inner_join: steps in assoc(f, :steps),
where: f.site_id == ^site.id,
group_by: f.id,
order_by: [desc: :id],
Expand All @@ -148,8 +152,8 @@ defmodule Plausible.Funnels do
defp base_get_query(site_id) do
from(f in Funnel,
where: f.site_id == ^site_id,
inner_join: steps in assoc(f, :steps),
inner_join: goal in assoc(steps, :goal),
left_join: steps in assoc(f, :steps),
left_join: goal in assoc(steps, :goal),
order_by: steps.step_order,
preload: [
steps: {steps, goal: goal}
Expand Down
Loading
Loading