analytics/lib/plausible_web/live/funnel_settings/form.ex
Uku Taht 058d8cc6c9
Extract button component (#3474)
* Add button component

* Use new button in settings screen

* Use button component in registration screens

* Use new button component for Billing.upgrade_link

* Separate .button and .button_link

* Add attr definiton for disabled

* Fix funnels test
2023-11-08 11:40:07 +02:00

376 lines
12 KiB
Elixir

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.
"""
use Phoenix.LiveView
use Plausible.Funnel
import PlausibleWeb.Live.Components.Form
alias Plausible.{Sites, Goals}
def mount(_params, %{"current_user_id" => user_id, "domain" => domain}, socket) do
site = Sites.get_for_user!(user_id, domain, [:owner, :admin, :super_admin])
# 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()
|> Enum.map(fn goal ->
{goal.id, struct!(Plausible.Goal, Map.take(goal, [:id, :event_name, :page_path]))}
end)
{:ok,
assign(socket,
step_ids: Enum.to_list(1..Funnel.min_steps()),
form: to_form(Plausible.Funnels.create_changeset(site, "", [])),
goals: goals,
site: site,
selections_made: Map.new(),
evaluation_result: nil,
evaluation_at: System.monotonic_time()
)}
end
def render(assigns) do
~H"""
<div
class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity z-50"
phx-window-keydown="cancel-add-funnel"
phx-key="Escape"
>
</div>
<div class="fixed inset-0 flex items-center justify-center mt-16 z-50 overlofw-y-auto overflow-x-hidden">
<div class="w-2/5 h-full">
<div id="funnel-form">
<.form
:let={f}
for={@form}
phx-change="validate"
phx-submit="save"
phx-target="#funnel-form"
phx-click-away="cancel-add-funnel"
onkeydown="return event.key != 'Enter';"
class="bg-white dark:bg-gray-800 shadow-md rounded px-8 pt-6 pb-8 mb-4 mt-8"
>
<h2 class="text-xl font-black dark:text-gray-100 mb-6">Add Funnel</h2>
<label for={f[:name].name} class="block mb-3 font-medium dark:text-gray-100">
Funnel Name
</label>
<.input
field={f[:name]}
phx-debounce={200}
autocomplete="off"
placeholder="e.g. From Blog to Purchase"
autofocus
class="w-full focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-900 dark:text-gray-300 block w-7/12 rounded-md sm:text-sm border-gray-300 dark:border-gray-500 w-full p-2 mt-2"
/>
<div id="steps-builder" class="mt-6">
<label class="font-medium dark:text-gray-100">
Funnel Steps
</label>
<div :for={step_idx <- @step_ids} class="flex mb-3 mt-3">
<div class="w-2/5 flex-1">
<.live_component
submit_name="funnel[steps][][goal_id]"
module={PlausibleWeb.Live.Components.ComboBox}
suggest_fun={&PlausibleWeb.Live.Components.ComboBox.StaticSearch.suggest/2}
id={"step-#{step_idx}"}
options={reject_already_selected("step-#{step_idx}", @goals, @selections_made)}
/>
</div>
<div class="w-min inline-flex items-center align-middle">
<.remove_step_button
:if={length(@step_ids) > Funnel.min_steps()}
step_idx={step_idx}
/>
</div>
<div class="w-4/12 align-middle ml-4 text-gray-500 dark:text-gray-400">
<.evaluation
:if={@evaluation_result}
result={@evaluation_result}
at={Enum.find_index(@step_ids, &(&1 == step_idx))}
/>
</div>
</div>
<.add_step_button :if={
length(@step_ids) < Funnel.max_steps() and
map_size(@selections_made) < length(@goals)
} />
<div class="mt-6">
<p id="funnel-eval" class="text-gray-500 dark:text-gray-400 text-sm mt-2 mb-2">
<%= if @evaluation_result do %>
Last month conversion rate: <strong><%= List.last(@evaluation_result.steps).conversion_rate %></strong>%
<% else %>
<span class="text-red-600 text-sm">
Choose minimum <%= Funnel.min_steps() %> steps to evaluate funnel.
</span>
<% end %>
</p>
</div>
<div class="mt-6">
<PlausibleWeb.Components.Generic.button
id="save"
type="submit"
class="w-full"
disabled={
has_steps_errors?(f) or map_size(@selections_made) < Funnel.min_steps() or
length(@step_ids) > map_size(@selections_made)
}
>
Add Funnel →
</PlausibleWeb.Components.Generic.button>
</div>
</div>
</.form>
</div>
</div>
</div>
"""
end
attr(:step_idx, :integer, required: true)
def remove_step_button(assigns) do
~H"""
<div class="inline-flex items-center ml-2 mb-4 text-red-500">
<svg
id={"remove-step-#{@step_idx}"}
class="feather feather-sm cursor-pointer"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
phx-click="remove-step"
phx-value-step-idx={@step_idx}
>
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2">
</path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
</div>
"""
end
def add_step_button(assigns) do
~H"""
<a class="underline text-indigo-500 text-sm cursor-pointer mt-6" phx-click="add-step">
+ Add another step
</a>
"""
end
attr(:at, :integer, required: true)
attr(:result, :map, required: true)
def evaluation(assigns) do
~H"""
<span class="text-sm" id={"step-eval-#{@at}"}>
<% step = Enum.at(@result.steps, @at) %>
<span :if={step && @at == 0}>
<span
class="border-dotted border-b border-gray-400 "
tooltip="Sample calculation for last month"
>
Entering Visitors: <strong><%= @result.entering_visitors %></strong>
</span>
</span>
<span :if={step && @at > 0}>
Dropoff: <strong><%= Map.get(step, :dropoff_percentage) %>%</strong>
</span>
</span>
"""
end
def handle_event("add-step", _value, socket) do
step_ids = socket.assigns.step_ids
if length(step_ids) < Funnel.max_steps() do
first_free_idx = find_sequence_break(step_ids)
new_ids = step_ids ++ [first_free_idx]
{:noreply, assign(socket, step_ids: new_ids)}
else
{:noreply, socket}
end
end
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)
send(self(), :evaluate_funnel)
{:noreply, assign(socket, step_ids: step_ids, selections_made: selections_made)}
end
def handle_event("validate", %{"funnel" => params}, socket) do
changeset =
socket.assigns.site
|> Plausible.Funnels.create_changeset(
params["name"],
params["steps"] || []
)
|> Map.put(:action, :validate)
{:noreply, assign(socket, form: to_form(changeset))}
end
def handle_event("save", %{"funnel" => params}, %{assigns: %{site: site}} = socket) do
case Plausible.Funnels.create(site, params["name"], params["steps"]) do
{:ok, funnel} ->
send(
socket.parent_pid,
{:funnel_saved, Map.put(funnel, :steps_count, length(params["steps"]))}
)
{:noreply, socket}
{:error, changeset} ->
{:noreply,
assign(socket,
form: to_form(Map.put(changeset, :action, :validate))
)}
end
end
def handle_event("cancel-add-funnel", _value, socket) do
send(socket.parent_pid, :cancel_add_funnel)
{:noreply, socket}
end
def handle_info({:selection_made, %{submit_value: goal_id, by: combo_box}}, socket) do
selections_made = store_selection(socket.assigns, combo_box, goal_id)
send(self(), :evaluate_funnel)
{:noreply,
assign(socket,
selections_made: selections_made
)}
end
def handle_info(:evaluate_funnel, socket) do
{:noreply, evaluate_funnel(socket)}
end
defp evaluate_funnel(%{assigns: %{selections_made: selections_made}} = socket)
when map_size(selections_made) < Funnel.min_steps() do
socket
end
defp evaluate_funnel(
%{
assigns: %{
site: site,
selections_made: selections_made,
evaluation_at: evaluation_at
}
} = socket
) do
with true <- seconds_since_evaluation(evaluation_at) >= 1,
{:ok, {definition, query}} <- build_ephemeral_funnel(site, selections_made),
{:ok, funnel} <- Plausible.Stats.funnel(site, query, definition) do
assign(socket, evaluation_result: funnel, evaluation_at: System.monotonic_time())
else
_ ->
socket
end
end
defp seconds_since_evaluation(evaluation_at) do
System.convert_time_unit(System.monotonic_time() - evaluation_at, :native, :second)
end
defp build_ephemeral_funnel(site, selections_made) do
steps =
selections_made
|> Enum.sort_by(&elem(&1, 0))
|> Enum.map(fn {_, goal} ->
%{
"goal_id" => goal.id,
"goal" => %{
"id" => goal.id,
"event_name" => goal.event_name,
"page_path" => goal.page_path
}
}
end)
definition =
Plausible.Funnels.ephemeral_definition(
site,
"Test funnel",
steps
)
query = Plausible.Stats.Query.from(site, %{"period" => "month"})
{: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(combo_box, goals, selections_made) do
selection_ids =
Enum.map(selections_made, fn
{_, %{id: goal_id}} -> goal_id
end)
result = Enum.reject(goals, fn {goal_id, _} -> goal_id in selection_ids end)
send_update(PlausibleWeb.Live.Components.ComboBox, id: combo_box, suggestions: result)
result
end
end