Skip to content

Commit ea3d23d

Browse files
authored
Strict order funnels (#6237)
* Migration: add strict_order to funnels * Update funnel schema * Accept strict_order on funnel create/update * Integrate funnel strict order setting * Use toggle switch and reword label * Improve test * Improve test * Rely only on socket assign to retrieve strict-order * fixup * Carry `strict_order?` within opts * !fixup
1 parent 9527ed6 commit ea3d23d

File tree

6 files changed

+294
-31
lines changed

6 files changed

+294
-31
lines changed

extra/lib/plausible/funnel.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ defmodule Plausible.Funnel do
4141
@type t() :: %__MODULE__{}
4242
schema "funnels" do
4343
field :name, :string
44+
field :strict_order, :boolean, default: false
4445
belongs_to :site, Plausible.Site
4546

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

5657
def changeset(funnel \\ %__MODULE__{}, attrs \\ %{}) do
5758
funnel
58-
|> cast(attrs, [:name])
59+
|> cast(attrs, [:name, :strict_order])
5960
|> validate_required([:name])
6061
|> put_steps(attrs[:steps] || attrs["steps"])
6162
|> validate_length(:steps, min: @min_steps, max: @max_steps)

extra/lib/plausible/funnels.ex

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@ defmodule Plausible.Funnels do
1313

1414
import Ecto.Query
1515

16-
@spec create(Plausible.Site.t(), String.t(), [map()]) ::
16+
@spec create(Plausible.Site.t(), String.t(), [map()], Keyword.t()) ::
1717
{:ok, Funnel.t()}
1818
| {:error, Ecto.Changeset.t() | :invalid_funnel_size | :upgrade_required}
19-
def create(site, name, steps)
19+
def create(site, name, steps, opts \\ [])
20+
21+
def create(site, name, steps, opts)
2022
when is_list(steps) and length(steps) in Funnel.min_steps()..Funnel.max_steps() do
2123
site = Plausible.Repo.preload(site, :team)
2224

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

2729
:ok ->
2830
site
29-
|> create_changeset(name, steps)
31+
|> create_changeset(name, steps, opts)
3032
|> Repo.insert()
3133
end
3234
end
3335

34-
def create(_site, _name, _goals) do
36+
def create(_site, _name, _goals, _opts) do
3537
{:error, :invalid_funnel_size}
3638
end
3739

38-
@spec update(Funnel.t(), String.t(), [map()]) ::
40+
@spec update(Funnel.t(), String.t(), [map()], Keyword.t()) ::
3941
{:ok, Funnel.t()}
4042
| {:error, Ecto.Changeset.t() | :invalid_funnel_size | :upgrade_required}
41-
def update(funnel, name, steps) do
43+
def update(funnel, name, steps, opts \\ []) do
4244
site = Plausible.Repo.preload(funnel, site: :team).site
4345

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

4850
:ok ->
4951
funnel
50-
|> Funnel.changeset(%{name: name, steps: steps})
52+
|> edit_changeset(name, steps,
53+
strict_order?: Keyword.get(opts, :strict_order?, !!funnel.strict_order)
54+
)
5155
|> Repo.update()
5256
end
5357
end
5458

55-
@spec create_changeset(Plausible.Site.t(), String.t(), [map()]) ::
59+
@spec create_changeset(Plausible.Site.t(), String.t(), [map()], Keyword.t()) ::
5660
Ecto.Changeset.t()
57-
def create_changeset(site, name, steps) do
58-
Funnel.changeset(%Funnel{site_id: site.id}, %{name: name, steps: steps})
61+
def create_changeset(site, name, steps, opts \\ []) do
62+
Funnel.changeset(%Funnel{site_id: site.id}, %{
63+
name: name,
64+
steps: steps,
65+
strict_order: Keyword.get(opts, :strict_order?, false)
66+
})
5967
end
6068

61-
@spec edit_changeset(Plausible.Funnel.t(), String.t(), [map()]) ::
69+
@spec edit_changeset(Plausible.Funnel.t(), String.t(), [map()], Keyword.t()) ::
6270
Ecto.Changeset.t()
63-
def edit_changeset(funnel, name, steps) do
64-
Funnel.changeset(funnel, %{name: name, steps: steps})
71+
def edit_changeset(funnel, name, steps, opts) do
72+
Funnel.changeset(funnel, %{
73+
name: name,
74+
steps: steps,
75+
strict_order: Keyword.get(opts, :strict_order?, false)
76+
})
6577
end
6678

67-
@spec ephemeral_definition(Plausible.Site.t(), String.t(), [map()]) :: Funnel.t()
68-
def ephemeral_definition(site, name, steps) do
79+
@spec ephemeral_definition(Plausible.Site.t(), String.t(), [map()], Keyword.t()) :: Funnel.t()
80+
def ephemeral_definition(site, name, steps, opts \\ []) do
6981
site
70-
|> create_changeset(name, steps)
82+
|> create_changeset(name, steps, opts)
7183
|> Ecto.Changeset.apply_changes()
7284
end
7385

extra/lib/plausible/stats/funnel.ex

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,10 +93,21 @@ defmodule Plausible.Stats.Funnel do
9393
end)
9494

9595
dynamic_window_funnel =
96-
dynamic(
97-
[q],
98-
fragment("windowFunnel(?)(timestamp, ?)", @funnel_window_duration, ^window_funnel_steps)
99-
)
96+
if funnel_definition.strict_order do
97+
dynamic(
98+
[q],
99+
fragment(
100+
"windowFunnel(?, 'strict_order')(timestamp, ?)",
101+
@funnel_window_duration,
102+
^window_funnel_steps
103+
)
104+
)
105+
else
106+
dynamic(
107+
[q],
108+
fragment("windowFunnel(?)(timestamp, ?)", @funnel_window_duration, ^window_funnel_steps)
109+
)
110+
end
100111

101112
from(q in db_query,
102113
select_merge:

extra/lib/plausible_web/live/funnel_settings/form.ex

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,20 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
8080
label="Funnel name"
8181
/>
8282
83+
<div class="mt-6 flex items-center justify-between gap-4">
84+
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
85+
Allow other steps between funnel steps
86+
</span>
87+
<div class="flex items-center gap-3">
88+
<.toggle_switch
89+
id="toggle-strict-order"
90+
id_suffix="switch"
91+
checked={!@strict_order?}
92+
phx-click="toggle-strict-order"
93+
/>
94+
</div>
95+
</div>
96+
8397
<div id="steps-builder" class="mt-6">
8498
<.label>
8599
Funnel steps
@@ -232,7 +246,16 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
232246
assign(socket, step_ids: step_ids, selections_made: selections_made, funnel_modified?: true)}
233247
end
234248

249+
def handle_event("toggle-strict-order", _params, socket) do
250+
strict_order? = !socket.assigns.strict_order?
251+
send(self(), :evaluate_funnel)
252+
253+
{:noreply, assign(socket, strict_order?: strict_order?)}
254+
end
255+
235256
def handle_event("validate", %{"funnel" => params}, socket) do
257+
strict_order? = socket.assigns.strict_order?
258+
236259
steps_from_assigns =
237260
socket.assigns.step_ids
238261
|> Enum.reduce([], fn step_id, acc ->
@@ -245,7 +268,8 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
245268
socket.assigns.site
246269
|> Funnels.create_changeset(
247270
params["name"],
248-
steps_from_assigns
271+
steps_from_assigns,
272+
strict_order?: strict_order?
249273
)
250274
|> Map.put(:action, :validate)
251275

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

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

267291
nil ->
268-
fn -> Funnels.create(site, params["name"], steps) end
292+
fn -> Funnels.create(site, params["name"], steps, strict_order?: strict_order?) end
269293
end
270294

271295
case save_fn.() do
@@ -314,11 +338,13 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
314338
%{
315339
assigns: %{
316340
site: site,
317-
selections_made: selections_made
341+
selections_made: selections_made,
342+
strict_order?: strict_order?
318343
}
319344
} = socket
320345
) do
321-
with {:ok, {definition, query}} <- build_ephemeral_funnel(site, selections_made),
346+
with {:ok, {definition, query}} <-
347+
build_ephemeral_funnel(site, selections_made, strict_order?: strict_order?),
322348
{:ok, funnel} <- Plausible.Stats.funnel(site, query, definition) do
323349
assign(socket, evaluation_result: funnel)
324350
else
@@ -327,7 +353,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
327353
end
328354
end
329355

330-
defp build_ephemeral_funnel(site, selections_made) do
356+
defp build_ephemeral_funnel(site, selections_made, opts) do
331357
steps =
332358
selections_made
333359
|> Enum.sort_by(&elem(&1, 0))
@@ -346,7 +372,8 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
346372
Funnels.ephemeral_definition(
347373
site,
348374
"Test funnel",
349-
steps
375+
steps,
376+
opts
350377
)
351378

352379
query =
@@ -417,7 +444,8 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
417444
funnel
418445
|> Funnels.edit_changeset(
419446
funnel.name,
420-
Enum.map(funnel.steps, &%{goal_id: &1.goal.id})
447+
Enum.map(funnel.steps, &%{goal_id: &1.goal.id}),
448+
strict_order?: funnel.strict_order
421449
)
422450
|> to_form()
423451

@@ -431,6 +459,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
431459
socket,
432460
form: form,
433461
funnel: funnel,
462+
strict_order?: funnel.strict_order,
434463
funnel_modified?: false,
435464
selections_made: selections_made,
436465
step_ids: Enum.to_list(1..Enum.count(funnel.steps))
@@ -446,6 +475,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
446475
socket,
447476
form: form,
448477
funnel: nil,
478+
strict_order?: false,
449479
funnel_modified?: false,
450480
selections_made: Map.new(),
451481
step_ids: Enum.to_list(1..Funnel.min_steps())

test/plausible/funnels_test.exs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ defmodule Plausible.FunnelsTest do
4343

4444
assert funnel.inserted_at
4545
assert funnel.name == "From blog to signup and purchase"
46+
assert funnel.strict_order == false
4647
assert [fg1, fg2, fg3] = funnel.steps
4748

4849
assert fg1.goal_id == g1["goal_id"]
@@ -76,6 +77,36 @@ defmodule Plausible.FunnelsTest do
7677
assert funnel1.id == funnel2.id
7778
end
7879

80+
test "update funnel strict_order", %{site: site, steps: [g1, g2, g3 | _]} do
81+
{:ok, funnel} =
82+
Funnels.create(
83+
site,
84+
"Sample funnel",
85+
[g1, g2]
86+
)
87+
88+
assert funnel.strict_order == false
89+
90+
{:ok, strict_funnel} =
91+
Funnels.update(
92+
funnel,
93+
"Sample funnel",
94+
[g1, g2, g3],
95+
strict_order?: true
96+
)
97+
98+
assert strict_funnel.strict_order == true
99+
100+
{:ok, preserved_funnel} =
101+
Funnels.update(
102+
strict_funnel,
103+
"Sample funnel",
104+
[g1, g2]
105+
)
106+
107+
assert preserved_funnel.strict_order == true
108+
end
109+
79110
test "retrieve a funnel by id and site, get steps in order", %{
80111
site: site,
81112
steps: [g1, g2, g3 | _]
@@ -299,6 +330,57 @@ defmodule Plausible.FunnelsTest do
299330
}} = funnel_data
300331
end
301332

333+
test "strict-order funnels stop at intervention events", %{
334+
site: site,
335+
steps: [g1, g2, g3 | _]
336+
} do
337+
{:ok, non_strict_funnel} =
338+
Funnels.create(
339+
site,
340+
"From blog to signup and purchase",
341+
[g1, g2, g3]
342+
)
343+
344+
{:ok, strict_funnel} =
345+
Funnels.create(
346+
site,
347+
"Strict from blog to signup and purchase",
348+
[g1, g2, g3],
349+
strict_order?: true
350+
)
351+
352+
populate_stats(site, [
353+
build(:pageview,
354+
pathname: "/go/to/blog/foo",
355+
user_id: 123,
356+
timestamp: ~N[2021-01-01 00:00:00]
357+
),
358+
build(:pageview,
359+
pathname: "/some/other/page",
360+
user_id: 123,
361+
timestamp: ~N[2021-01-01 00:00:01]
362+
),
363+
build(:event,
364+
name: "Signup",
365+
user_id: 123,
366+
timestamp: ~N[2021-01-01 00:00:02]
367+
),
368+
build(:pageview,
369+
pathname: "/checkout",
370+
user_id: 123,
371+
timestamp: ~N[2021-01-01 00:00:03]
372+
)
373+
])
374+
375+
query = QueryBuilder.build!(site, input_date_range: :all)
376+
377+
{:ok, non_strict_funnel_data} = Stats.funnel(site, query, non_strict_funnel.id)
378+
{:ok, strict_funnel_data} = Stats.funnel(site, query, strict_funnel.id)
379+
380+
assert Enum.at(non_strict_funnel_data.steps, 2).visitors == 1
381+
assert Enum.at(strict_funnel_data.steps, 2).visitors == 0
382+
end
383+
302384
test "funnels can be evaluated even where there are no visits yet", %{
303385
site: site,
304386
steps: [g1, g2, g3 | _]

0 commit comments

Comments
 (0)