Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion extra/lib/plausible/funnel.ex
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ defmodule Plausible.Funnel do
@type t() :: %__MODULE__{}
schema "funnels" do
field :name, :string
field :strict_order, :boolean, default: false
belongs_to :site, Plausible.Site

has_many :steps, Step,
Expand All @@ -55,7 +56,7 @@ defmodule Plausible.Funnel do

def changeset(funnel \\ %__MODULE__{}, attrs \\ %{}) do
funnel
|> cast(attrs, [:name])
|> cast(attrs, [:name, :strict_order])
|> validate_required([:name])
|> put_steps(attrs[:steps] || attrs["steps"])
|> validate_length(:steps, min: @min_steps, max: @max_steps)
Expand Down
44 changes: 28 additions & 16 deletions extra/lib/plausible/funnels.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ defmodule Plausible.Funnels do

import Ecto.Query

@spec create(Plausible.Site.t(), String.t(), [map()]) ::
@spec create(Plausible.Site.t(), String.t(), [map()], Keyword.t()) ::
{:ok, Funnel.t()}
| {:error, Ecto.Changeset.t() | :invalid_funnel_size | :upgrade_required}
def create(site, name, steps)
def create(site, name, steps, opts \\ [])

def create(site, name, steps, opts)
when is_list(steps) and length(steps) in Funnel.min_steps()..Funnel.max_steps() do
site = Plausible.Repo.preload(site, :team)

Expand All @@ -26,19 +28,19 @@ defmodule Plausible.Funnels do

:ok ->
site
|> create_changeset(name, steps)
|> create_changeset(name, steps, opts)
|> Repo.insert()
end
end

def create(_site, _name, _goals) do
def create(_site, _name, _goals, _opts) do
{:error, :invalid_funnel_size}
end

@spec update(Funnel.t(), String.t(), [map()]) ::
@spec update(Funnel.t(), String.t(), [map()], Keyword.t()) ::
{:ok, Funnel.t()}
| {:error, Ecto.Changeset.t() | :invalid_funnel_size | :upgrade_required}
def update(funnel, name, steps) do
def update(funnel, name, steps, opts \\ []) do
site = Plausible.Repo.preload(funnel, site: :team).site

case Plausible.Billing.Feature.Funnels.check_availability(site.team) do
Expand All @@ -47,27 +49,37 @@ defmodule Plausible.Funnels do

:ok ->
funnel
|> Funnel.changeset(%{name: name, steps: steps})
|> edit_changeset(name, steps,
strict_order?: Keyword.get(opts, :strict_order?, !!funnel.strict_order)
Comment thread
zoldar marked this conversation as resolved.
)
|> Repo.update()
end
end

@spec create_changeset(Plausible.Site.t(), String.t(), [map()]) ::
@spec create_changeset(Plausible.Site.t(), String.t(), [map()], Keyword.t()) ::
Ecto.Changeset.t()
def create_changeset(site, name, steps) do
Funnel.changeset(%Funnel{site_id: site.id}, %{name: name, steps: steps})
def create_changeset(site, name, steps, opts \\ []) do
Funnel.changeset(%Funnel{site_id: site.id}, %{
name: name,
steps: steps,
strict_order: Keyword.get(opts, :strict_order?, false)
})
end

@spec edit_changeset(Plausible.Funnel.t(), String.t(), [map()]) ::
@spec edit_changeset(Plausible.Funnel.t(), String.t(), [map()], Keyword.t()) ::
Ecto.Changeset.t()
def edit_changeset(funnel, name, steps) do
Funnel.changeset(funnel, %{name: name, steps: steps})
def edit_changeset(funnel, name, steps, opts) do
Funnel.changeset(funnel, %{
name: name,
steps: steps,
strict_order: Keyword.get(opts, :strict_order?, false)
})
end

@spec ephemeral_definition(Plausible.Site.t(), String.t(), [map()]) :: Funnel.t()
def ephemeral_definition(site, name, steps) do
@spec ephemeral_definition(Plausible.Site.t(), String.t(), [map()], Keyword.t()) :: Funnel.t()
def ephemeral_definition(site, name, steps, opts \\ []) do
site
|> create_changeset(name, steps)
|> create_changeset(name, steps, opts)
|> Ecto.Changeset.apply_changes()
end

Expand Down
19 changes: 15 additions & 4 deletions extra/lib/plausible/stats/funnel.ex
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,21 @@ defmodule Plausible.Stats.Funnel do
end)

dynamic_window_funnel =
dynamic(
[q],
fragment("windowFunnel(?)(timestamp, ?)", @funnel_window_duration, ^window_funnel_steps)
)
if funnel_definition.strict_order do
dynamic(
[q],
fragment(
"windowFunnel(?, 'strict_order')(timestamp, ?)",
@funnel_window_duration,
^window_funnel_steps
)
)
else
dynamic(
[q],
fragment("windowFunnel(?)(timestamp, ?)", @funnel_window_duration, ^window_funnel_steps)
)
end

from(q in db_query,
select_merge:
Expand Down
48 changes: 39 additions & 9 deletions extra/lib/plausible_web/live/funnel_settings/form.ex
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,20 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
label="Funnel name"
/>

<div class="mt-6 flex items-center justify-between gap-4">
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
Allow other steps between funnel steps
</span>
<div class="flex items-center gap-3">
<.toggle_switch
id="toggle-strict-order"
id_suffix="switch"
checked={!@strict_order?}
phx-click="toggle-strict-order"
/>
</div>
</div>

<div id="steps-builder" class="mt-6">
<.label>
Funnel steps
Expand Down Expand Up @@ -232,7 +246,16 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
assign(socket, step_ids: step_ids, selections_made: selections_made, funnel_modified?: true)}
end

def handle_event("toggle-strict-order", _params, socket) do
strict_order? = !socket.assigns.strict_order?
send(self(), :evaluate_funnel)

{:noreply, assign(socket, strict_order?: strict_order?)}
end

def handle_event("validate", %{"funnel" => params}, socket) do
strict_order? = socket.assigns.strict_order?

steps_from_assigns =
socket.assigns.step_ids
|> Enum.reduce([], fn step_id, acc ->
Expand All @@ -245,7 +268,8 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
socket.assigns.site
|> Funnels.create_changeset(
params["name"],
steps_from_assigns
steps_from_assigns,
strict_order?: strict_order?
)
|> Map.put(:action, :validate)

Expand All @@ -255,17 +279,17 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
def handle_event(
"save",
%{"funnel" => params},
%{assigns: %{site: site, funnel: funnel}} = socket
%{assigns: %{site: site, funnel: funnel, strict_order?: strict_order?}} = socket
) do
steps = Enum.map(params["steps"], fn {_idx, payload} -> payload end)

save_fn =
case funnel do
%Plausible.Funnel{} ->
fn -> Funnels.update(funnel, params["name"], steps) end
fn -> Funnels.update(funnel, params["name"], steps, strict_order?: strict_order?) end

nil ->
fn -> Funnels.create(site, params["name"], steps) end
fn -> Funnels.create(site, params["name"], steps, strict_order?: strict_order?) end
end

case save_fn.() do
Expand Down Expand Up @@ -314,11 +338,13 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
%{
assigns: %{
site: site,
selections_made: selections_made
selections_made: selections_made,
strict_order?: strict_order?
}
} = socket
) do
with {:ok, {definition, query}} <- build_ephemeral_funnel(site, selections_made),
with {:ok, {definition, query}} <-
build_ephemeral_funnel(site, selections_made, strict_order?: strict_order?),
{:ok, funnel} <- Plausible.Stats.funnel(site, query, definition) do
assign(socket, evaluation_result: funnel)
else
Expand All @@ -327,7 +353,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
end
end

defp build_ephemeral_funnel(site, selections_made) do
defp build_ephemeral_funnel(site, selections_made, opts) do
steps =
selections_made
|> Enum.sort_by(&elem(&1, 0))
Expand All @@ -346,7 +372,8 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
Funnels.ephemeral_definition(
site,
"Test funnel",
steps
steps,
opts
)

query =
Expand Down Expand Up @@ -417,7 +444,8 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
funnel
|> Funnels.edit_changeset(
funnel.name,
Enum.map(funnel.steps, &%{goal_id: &1.goal.id})
Enum.map(funnel.steps, &%{goal_id: &1.goal.id}),
strict_order?: funnel.strict_order
)
|> to_form()

Expand All @@ -431,6 +459,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
socket,
form: form,
funnel: funnel,
strict_order?: funnel.strict_order,
funnel_modified?: false,
selections_made: selections_made,
step_ids: Enum.to_list(1..Enum.count(funnel.steps))
Expand All @@ -446,6 +475,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
socket,
form: form,
funnel: nil,
strict_order?: false,
funnel_modified?: false,
selections_made: Map.new(),
step_ids: Enum.to_list(1..Funnel.min_steps())
Expand Down
82 changes: 82 additions & 0 deletions test/plausible/funnels_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ defmodule Plausible.FunnelsTest do

assert funnel.inserted_at
assert funnel.name == "From blog to signup and purchase"
assert funnel.strict_order == false
assert [fg1, fg2, fg3] = funnel.steps

assert fg1.goal_id == g1["goal_id"]
Expand Down Expand Up @@ -76,6 +77,36 @@ defmodule Plausible.FunnelsTest do
assert funnel1.id == funnel2.id
end

test "update funnel strict_order", %{site: site, steps: [g1, g2, g3 | _]} do
{:ok, funnel} =
Funnels.create(
site,
"Sample funnel",
[g1, g2]
)

assert funnel.strict_order == false

{:ok, strict_funnel} =
Funnels.update(
funnel,
"Sample funnel",
[g1, g2, g3],
strict_order?: true
)

assert strict_funnel.strict_order == true

{:ok, preserved_funnel} =
Funnels.update(
strict_funnel,
"Sample funnel",
[g1, g2]
)

assert preserved_funnel.strict_order == true
end

test "retrieve a funnel by id and site, get steps in order", %{
site: site,
steps: [g1, g2, g3 | _]
Expand Down Expand Up @@ -299,6 +330,57 @@ defmodule Plausible.FunnelsTest do
}} = 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],
strict_order?: true
)

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
end

test "funnels can be evaluated even where there are no visits yet", %{
site: site,
steps: [g1, g2, g3 | _]
Expand Down
Loading
Loading