mirror of
https://github.com/plausible/analytics.git
synced 2024-12-29 04:22:34 +03:00
43be271836
* Add hint to creatable ComboBoxes without suggestions available * Load external resources once in funnel settings * Load external resources once in goal settings * Make Custom Props Settings UI match Goal Settings * Remove unnecessary goals query This should be done only once in the live view * Remove funnels feature flag * fixup * Make the modal scrollable * By default, focus first suggestion for creatables * Update StaticSearch So it's capable of casting custom data structures into weighted items. Missing tests added. * Add Search + modal to funnel settings * Add sample props to seeds * Load all suggestions asynchronously, unless `Mix.env == :test` * ComboBox: Fix inconsistent suggestions We require "Create ..." element to be only focused when there are no suggestions available. This causes some issues, depending on the state, the least focusable index might be either 0 ("Create...") or 1. This patch addresses all the quirks with focus. * Fix ComboBox max results message So that AlpineJS doesn't think it's a focusable option. * Keep the state up to date when changing props * Add hint to creatable ComboBoxes without suggestions available * Load external resources once in funnel settings * Load external resources once in goal settings * Make Custom Props Settings UI match Goal Settings * Remove unnecessary goals query This should be done only once in the live view * Remove funnels feature flag * fixup * Make the modal scrollable * By default, focus first suggestion for creatables * Add sample props to seeds * Load all suggestions asynchronously, unless `Mix.env == :test` * ComboBox: Fix inconsistent suggestions We require "Create ..." element to be only focused when there are no suggestions available. This causes some issues, depending on the state, the least focusable index might be either 0 ("Create...") or 1. This patch addresses all the quirks with focus. * Fix ComboBox max results message So that AlpineJS doesn't think it's a focusable option. * Keep the state up to date when changing props * Fixup site_id * Fix typo * fixup
390 lines
12 KiB
Elixir
390 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">
|
|
<%= if has_steps_errors?(f) or map_size(@selections_made) < Funnel.min_steps() or length(@step_ids) > map_size(@selections_made) do %>
|
|
<.submit_button_inactive />
|
|
<% else %>
|
|
<.submit_button />
|
|
<% end %>
|
|
</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
|
|
|
|
def submit_button(assigns) do
|
|
~H"""
|
|
<button id="save" type="submit" class="button text-base font-bold w-full">
|
|
Add Funnel →
|
|
</button>
|
|
"""
|
|
end
|
|
|
|
def submit_button_inactive(assigns) do
|
|
~H"""
|
|
<button
|
|
type="none"
|
|
id="save"
|
|
class="w-full text-base font-bold py-2 border border-gray-300 dark:border-gray-500 rounded-md text-gray-300 bg-white dark:bg-gray-800 hover:text-gray-500 dark:hover:text-gray-400 focus:outline-none focus:border-blue-300 focus:ring active:text-gray-800 active:bg-gray-50 transition ease-in-out duration-150 cursor-not-allowed"
|
|
>
|
|
Add Funnel →
|
|
</button>
|
|
"""
|
|
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
|