Improve goal settings UX (#3293)

* Add Heroicons dependency

* Add name_of/1 html helper

Currently with Floki there's no way to query for
`[name=foo[some]]` selector

* Update changelog

* Make goal deletion possible with only goal id

* Remove stale goal controllers

* Improve ComboBox component

- make sure the list options are always of the parent input width
- allow passing a suggestion function instead of a module

* Stale fixup

* Update routes

* Use the new goals route in funnel settings

* Use a function in the funnel combo

* Use function in the props combo

* Remove old goals form

* Implement new goal settings

* Update moduledoc

* Fix revenue switch in dark mode

* Connect live socket on goal settings page

* Fixup

* Use Heroicons.trash icon

* Tweak goals search input

* Remove unused alias

* Fix search/button alignment

* Fix backspace icon alignment

* Delegate :superadmin check to get_for_user/3

I'll do props settings separately, it's work in progress
in a branch on top of this one already. cc @ukutaht

* Rename socket assigns

* Fixup to 5c9f58e

* Fixup

* Render ComboBox suggestions asynchronously

This commit:
  - prevents redundant work by checking the socket connection
  - allows passing no options to the ComboBox component,
    so that when combined with the `async` option, the options
    are asynchronously initialized post-render
  - allows updating the suggestions asynchronously with the
    `async` option set to `true` - helpful in case of DB
    queries used for suggestions

* Update tests

* Throttle comboboxes

* Update tests

* Dim the search input

* Use debounce=200 in ComboBox component

* Move creatable option to the top

* Ensure there's always a leading slash for goals

* Test pageview goals with leading / missing

* Make the modal scrollable on small viewports
This commit is contained in:
hq1 2023-09-04 13:44:22 +02:00 committed by GitHub
parent f740cc899f
commit b3ff695797
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 1274 additions and 445 deletions

View File

@ -29,6 +29,7 @@ All notable changes to this project will be documented in this file.
- Fixed [Sentry reports](https://github.com/plausible/analytics/discussions/3166) for ingestion requests plausible/analytics#3182 - Fixed [Sentry reports](https://github.com/plausible/analytics/discussions/3166) for ingestion requests plausible/analytics#3182
- Fix breakdown pagination bug in the dashboard details view when filtering by goals - Fix breakdown pagination bug in the dashboard details view when filtering by goals
- Update bot detection (matomo 6.1.4, ua_inspector 3.4.0) - Update bot detection (matomo 6.1.4, ua_inspector 3.4.0)
- Improved the Goal Settings page (search, autcompletion etc.)
## v2.0.0 - 2023-07-12 ## v2.0.0 - 2023-07-12

View File

@ -27,10 +27,10 @@ defmodule Plausible.Goal do
def currency_options do def currency_options do
options = options =
for code <- valid_currencies() do for code <- valid_currencies() do
{"#{code} - #{Cldr.Currency.display_name!(code)}", code} {code, "#{code} - #{Cldr.Currency.display_name!(code)}"}
end end
[{"Select reporting currency", nil}] ++ options options
end end
def changeset(goal, attrs \\ %{}) do def changeset(goal, attrs \\ %{}) do
@ -38,6 +38,7 @@ defmodule Plausible.Goal do
|> cast(attrs, [:id, :site_id, :event_name, :page_path, :currency]) |> cast(attrs, [:id, :site_id, :event_name, :page_path, :currency])
|> validate_required([:site_id]) |> validate_required([:site_id])
|> cast_assoc(:site) |> cast_assoc(:site)
|> update_leading_slash()
|> validate_event_name_and_page_path() |> validate_event_name_and_page_path()
|> update_change(:event_name, &String.trim/1) |> update_change(:event_name, &String.trim/1)
|> update_change(:page_path, &String.trim/1) |> update_change(:page_path, &String.trim/1)
@ -45,6 +46,19 @@ defmodule Plausible.Goal do
|> maybe_drop_currency() |> maybe_drop_currency()
end end
defp update_leading_slash(changeset) do
case get_field(changeset, :page_path) do
"/" <> _ ->
changeset
page_path when is_binary(page_path) ->
put_change(changeset, :page_path, "/" <> page_path)
_ ->
changeset
end
end
defp validate_event_name_and_page_path(changeset) do defp validate_event_name_and_page_path(changeset) do
if validate_page_path(changeset) || validate_event_name(changeset) do if validate_page_path(changeset) || validate_event_name(changeset) do
changeset changeset

View File

@ -118,14 +118,18 @@ defmodule Plausible.Goals do
Otherwise, for associated funnel(s) consisting of minimum number steps only, Otherwise, for associated funnel(s) consisting of minimum number steps only,
funnel record(s) are removed completely along with the targeted goal. funnel record(s) are removed completely along with the targeted goal.
""" """
def delete(id, site) do def delete(id, %Plausible.Site{id: site_id}) do
delete(id, site_id)
end
def delete(id, site_id) do
result = result =
Multi.new() Multi.new()
|> Multi.one( |> Multi.one(
:goal, :goal,
from(g in Goal, from(g in Goal,
where: g.id == ^id, where: g.id == ^id,
where: g.site_id == ^site.id, where: g.site_id == ^site_id,
preload: [funnels: :steps] preload: [funnels: :steps]
) )
) )
@ -162,7 +166,7 @@ defmodule Plausible.Goals do
fn _ -> fn _ ->
from g in Goal, from g in Goal,
where: g.id == ^id, where: g.id == ^id,
where: g.site_id == ^site.id where: g.site_id == ^site_id
end end
) )
|> Repo.transaction() |> Repo.transaction()

View File

@ -8,6 +8,10 @@ defmodule Plausible.Sites do
Repo.get_by(Site, domain: domain) Repo.get_by(Site, domain: domain)
end end
def get_by_domain!(domain) do
Repo.get_by!(Site, domain: domain)
end
def create(user, params) do def create(user, params) do
site_changeset = Site.changeset(%Site{}, params) site_changeset = Site.changeset(%Site{}, params)
@ -93,11 +97,25 @@ defmodule Plausible.Sites do
base <> domain <> "?auth=" <> link.slug base <> domain <> "?auth=" <> link.slug
end end
def get_for_user!(user_id, domain, roles \\ [:owner, :admin, :viewer]), def get_for_user!(user_id, domain, roles \\ [:owner, :admin, :viewer]) do
do: Repo.one!(get_for_user_q(user_id, domain, roles)) if :superuser in roles and Plausible.Auth.is_super_admin?(domain) do
get_by_domain!(domain)
else
user_id
|> get_for_user_q(domain, List.delete(roles, :superadmin))
|> Repo.one!()
end
end
def get_for_user(user_id, domain, roles \\ [:owner, :admin, :viewer]), def get_for_user(user_id, domain, roles \\ [:owner, :admin, :viewer]) do
do: Repo.one(get_for_user_q(user_id, domain, roles)) if :superuser in roles and Plausible.Auth.is_super_admin?(domain) do
get_by_domain(domain)
else
user_id
|> get_for_user_q(domain, List.delete(roles, :superadmin))
|> Repo.one()
end
end
defp get_for_user_q(user_id, domain, roles) do defp get_for_user_q(user_id, domain, roles) do
from(s in Site, from(s in Site,

View File

@ -121,53 +121,6 @@ defmodule PlausibleWeb.SiteController do
) )
end end
def new_goal(conn, _params) do
site = conn.assigns[:site]
changeset = Plausible.Goal.changeset(%Plausible.Goal{})
conn
|> assign(:skip_plausible_tracking, true)
|> render("new_goal.html",
site: site,
changeset: changeset,
layout: {PlausibleWeb.LayoutView, "focus.html"}
)
end
def create_goal(conn, %{"goal" => goal}) do
site = conn.assigns[:site]
case Plausible.Goals.create(site, goal) do
{:ok, _} ->
conn
|> put_flash(:success, "Goal created successfully")
|> redirect(to: Routes.site_path(conn, :settings_goals, site.domain))
{:error, changeset} ->
conn
|> assign(:skip_plausible_tracking, true)
|> render("new_goal.html",
site: site,
changeset: changeset,
layout: {PlausibleWeb.LayoutView, "focus.html"}
)
end
end
def delete_goal(conn, %{"id" => goal_id}) do
case Plausible.Goals.delete(goal_id, conn.assigns[:site]) do
:ok ->
conn
|> put_flash(:success, "Goal deleted successfully")
|> redirect(to: Routes.site_path(conn, :settings_goals, conn.assigns[:site].domain))
{:error, :not_found} ->
conn
|> put_flash(:error, "Could not find goal")
|> redirect(to: Routes.site_path(conn, :settings_goals, conn.assigns[:site].domain))
end
end
@feature_titles %{ @feature_titles %{
funnels_enabled: "Funnels", funnels_enabled: "Funnels",
conversions_enabled: "Goals", conversions_enabled: "Goals",
@ -270,6 +223,7 @@ defmodule PlausibleWeb.SiteController do
|> render("settings_goals.html", |> render("settings_goals.html",
site: site, site: site,
goals: goals, goals: goals,
connect_live_socket: true,
layout: {PlausibleWeb.LayoutView, "site_settings.html"} layout: {PlausibleWeb.LayoutView, "site_settings.html"}
) )
end end

View File

@ -14,8 +14,19 @@ defmodule PlausibleWeb.Live.Components.ComboBox do
field, the component searches the available options and provides field, the component searches the available options and provides
suggestions based on the input. suggestions based on the input.
Any module exposing suggest/2 function can be supplied via `suggest_mod` Any function can be supplied via `suggest_fun` attribute
attribute - see the provided `ComboBox.StaticSearch`. - see the provided `ComboBox.StaticSearch`.
In case the `suggest_fun` runs an operation that could be deferred,
the `async=true` attr calls it in a background Task and updates the
suggestions asynchronously.
Similarly, the initial `options` don't have to be provided up-front
if e.g. querying the database for suggestions at initial render is
undesirable. In such case, lack of `options` attr value combined
with `async=true` will call `suggest_fun.("", [])` asynchronously
- that special clause can be used to provide the initial set
of suggestions updated right after the initial render.
""" """
use Phoenix.LiveComponent use Phoenix.LiveComponent
alias Phoenix.LiveView.JS alias Phoenix.LiveView.JS
@ -23,36 +34,40 @@ defmodule PlausibleWeb.Live.Components.ComboBox do
@default_suggestions_limit 15 @default_suggestions_limit 15
def update(assigns, socket) do def update(assigns, socket) do
assigns = socket = assign(socket, assigns)
if assigns[:suggestions] do
Map.put(assigns, :suggestions, Enum.take(assigns.suggestions, suggestions_limit(assigns)))
else
assigns
end
socket = socket =
socket if connected?(socket) do
|> assign(assigns) socket
|> assign_new(:suggestions, fn -> |> assign_options()
Enum.take(assigns.options, suggestions_limit(assigns)) |> assign_suggestions()
end) else
socket
end
{:ok, socket} {:ok, socket}
end end
attr(:placeholder, :string, default: "Select option or search by typing") attr(:placeholder, :string, default: "Select option or search by typing")
attr(:id, :any, required: true) attr(:id, :any, required: true)
attr(:options, :list, required: true) attr(:options, :list, default: [])
attr(:submit_name, :string, required: true) attr(:submit_name, :string, required: true)
attr(:display_value, :string, default: "") attr(:display_value, :string, default: "")
attr(:submit_value, :string, default: "") attr(:submit_value, :string, default: "")
attr(:suggest_mod, :atom, required: true) attr(:suggest_fun, :any, required: true)
attr(:suggestions_limit, :integer) attr(:suggestions_limit, :integer)
attr(:class, :string, default: "") attr(:class, :string, default: "")
attr(:required, :boolean, default: false) attr(:required, :boolean, default: false)
attr(:creatable, :boolean, default: false) attr(:creatable, :boolean, default: false)
attr(:errors, :list, default: [])
attr(:async, :boolean, default: false)
def render(assigns) do def render(assigns) do
assigns =
assign_new(assigns, :suggestions, fn ->
Enum.take(assigns.options, suggestions_limit(assigns))
end)
~H""" ~H"""
<div <div
id={"input-picker-main-#{@id}"} id={"input-picker-main-#{@id}"}
@ -78,6 +93,7 @@ defmodule PlausibleWeb.Live.Components.ComboBox do
x-on:focus="open" x-on:focus="open"
phx-change="search" phx-change="search"
phx-target={@myself} phx-target={@myself}
phx-debounce={200}
value={@display_value} value={@display_value}
class="border-none py-1 px-1 p-0 w-full inline-block rounded-md focus:outline-none focus:ring-0 text-sm" class="border-none py-1 px-1 p-0 w-full inline-block rounded-md focus:outline-none focus:ring-0 text-sm"
style="background-color: inherit;" style="background-color: inherit;"
@ -94,16 +110,16 @@ defmodule PlausibleWeb.Live.Components.ComboBox do
id={"submit-#{@id}"} id={"submit-#{@id}"}
/> />
</div> </div>
</div>
<.dropdown <.dropdown
ref={@id} ref={@id}
suggest_mod={@suggest_mod} suggest_fun={@suggest_fun}
suggestions={@suggestions} suggestions={@suggestions}
target={@myself} target={@myself}
creatable={@creatable} creatable={@creatable}
display_value={@display_value} display_value={@display_value}
/> />
</div>
</div> </div>
""" """
end end
@ -133,7 +149,7 @@ defmodule PlausibleWeb.Live.Components.ComboBox do
attr(:ref, :string, required: true) attr(:ref, :string, required: true)
attr(:suggestions, :list, default: []) attr(:suggestions, :list, default: [])
attr(:suggest_mod, :atom, required: true) attr(:suggest_fun, :any, required: true)
attr(:target, :any) attr(:target, :any)
attr(:creatable, :boolean, required: true) attr(:creatable, :boolean, required: true)
attr(:display_value, :string, required: true) attr(:display_value, :string, required: true)
@ -145,8 +161,18 @@ defmodule PlausibleWeb.Live.Components.ComboBox do
id={"dropdown-#{@ref}"} id={"dropdown-#{@ref}"}
x-show="isOpen" x-show="isOpen"
x-ref="suggestions" x-ref="suggestions"
class="max-w-xs md:max-w-md lg:max-w-lg dropdown z-50 absolute mt-1 max-h-60 overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm dark:bg-gray-900" class="w-full dropdown z-50 absolute mt-1 max-h-60 overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm dark:bg-gray-900"
> >
<.option
:if={display_creatable_option?(assigns)}
idx={length(@suggestions)}
submit_value={@display_value}
display_value={@display_value}
target={@target}
ref={@ref}
creatable
/>
<.option <.option
:for={ :for={
{{submit_value, display_value}, idx} <- {{submit_value, display_value}, idx} <-
@ -163,16 +189,6 @@ defmodule PlausibleWeb.Live.Components.ComboBox do
ref={@ref} ref={@ref}
/> />
<.option
:if={display_creatable_option?(assigns)}
idx={length(@suggestions)}
submit_value={@display_value}
display_value={@display_value}
target={@target}
ref={@ref}
creatable
/>
<div <div
:if={@suggestions == [] && !@creatable} :if={@suggestions == [] && !@creatable}
class="relative cursor-default select-none py-2 px-4 text-gray-700 dark:text-gray-300" class="relative cursor-default select-none py-2 px-4 text-gray-700 dark:text-gray-300"
@ -239,9 +255,10 @@ defmodule PlausibleWeb.Live.Components.ComboBox do
def handle_event( def handle_event(
"search", "search",
%{"_target" => [target]} = params, %{"_target" => [target]} = params,
%{assigns: %{suggest_mod: suggest_mod, options: options}} = socket %{assigns: %{options: options}} = socket
) do ) do
input = params[target] input = params[target]
input_len = input |> String.trim() |> String.length() input_len = input |> String.trim() |> String.length()
socket = socket =
@ -253,7 +270,7 @@ defmodule PlausibleWeb.Live.Components.ComboBox do
suggestions = suggestions =
if input_len > 0 do if input_len > 0 do
suggest_mod.suggest(input, options) run_suggest_fun(input, options, socket.assigns, :suggestions)
else else
options options
end end
@ -296,4 +313,46 @@ defmodule PlausibleWeb.Live.Components.ComboBox do
assigns.creatable && not empty_input? && not input_matches_suggestion? assigns.creatable && not empty_input? && not input_matches_suggestion?
end end
defp assign_options(socket) do
assign_new(socket, :options, fn ->
run_suggest_fun("", [], socket.assigns, :options)
end)
end
defp assign_suggestions(socket) do
if socket.assigns[:suggestions] do
assign(
socket,
suggestions: Enum.take(socket.assigns.suggestions, suggestions_limit(socket.assigns))
)
else
socket
end
end
defp run_suggest_fun(input, options, %{id: id, suggest_fun: fun} = assigns, key_to_update) do
if assigns[:async] do
pid = self()
Task.start(fn ->
result = fun.(input, options)
send_update(
pid,
__MODULE__,
Keyword.new([
{:id, id},
{key_to_update, result}
])
)
end)
# This prevents flashing the suggestions container
# before the update is received on a subsequent render
assigns[key_to_update] || []
else
fun.(input, options)
end
end
end end

View File

@ -10,12 +10,26 @@ defmodule PlausibleWeb.Live.Components.ComboBox.StaticSearch do
""" """
@spec suggest(String.t(), [{any(), any()}]) :: [{any(), any()}] @spec suggest(String.t(), [{any(), any()}]) :: [{any(), any()}]
def suggest(input, options) do def suggest(input, choices, opts \\ []) do
options input = String.trim(input)
|> Enum.map(fn {_, value} = option -> {option, weight(value, input)} end)
|> Enum.reject(fn {_option, weight} -> weight < 0.6 end) if input != "" do
|> Enum.sort_by(fn {_option, weight} -> weight end, :desc) weight_threshold = Keyword.get(opts, :weight_threshold, 0.6)
|> Enum.map(fn {option, _weight} -> option end)
choices
|> Enum.map(fn
{_, value} = choice ->
{choice, weight(value, input)}
value ->
{value, weight(value, input)}
end)
|> Enum.reject(fn {_choice, weight} -> weight < weight_threshold end)
|> Enum.sort_by(fn {_choice, weight} -> weight end, :desc)
|> Enum.map(fn {choice, _weight} -> choice end)
else
choices
end
end end
defp weight(value, input) do defp weight(value, input) do

View File

@ -0,0 +1,110 @@
defmodule PlausibleWeb.Live.Components.Form do
@moduledoc """
Generic components stolen from mix phx.new templates
"""
use Phoenix.Component
@doc """
Renders an input with label and error messages.
A `Phoenix.HTML.FormField` may be passed as argument,
which is used to retrieve the input name, id, and values.
Otherwise all attributes may be passed explicitly.
## Examples
<.input field={@form[:email]} type="email" />
<.input name="my-input" errors={["oh no!"]} />
"""
attr(:id, :any, default: nil)
attr(:name, :any)
attr(:label, :string, default: nil)
attr(:value, :any)
attr(:type, :string,
default: "text",
values: ~w(checkbox color date datetime-local email file hidden month number password
range radio search select tel text textarea time url week)
)
attr(:field, Phoenix.HTML.FormField,
doc: "a form field struct retrieved from the form, for example: @form[:email]"
)
attr(:errors, :list, default: [])
attr(:checked, :boolean, doc: "the checked flag for checkbox inputs")
attr(:prompt, :string, default: nil, doc: "the prompt for select inputs")
attr(:options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2")
attr(:multiple, :boolean, default: false, doc: "the multiple flag for select inputs")
attr(:rest, :global,
include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
multiple pattern placeholder readonly required rows size step)
)
slot(:inner_block)
def input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
assigns
|> assign(field: nil, id: assigns.id || field.id)
|> assign(:errors, Enum.map(field.errors, &translate_error(&1)))
|> assign_new(:name, fn -> if assigns.multiple, do: field.name <> "[]", else: field.name end)
|> assign_new(:value, fn -> field.value end)
|> input()
end
# All other inputs text, datetime-local, url, password, etc. are handled here...
def input(assigns) do
~H"""
<div phx-feedback-for={@name}>
<.label for={@id}>
<%= @label %>
</.label>
<input
type={@type}
name={@name}
id={@id}
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
{@rest}
/>
<.error :for={msg <- @errors}>
<%= msg %>
</.error>
</div>
"""
end
@doc """
Renders a label.
"""
attr(:for, :string, default: nil)
slot(:inner_block, required: true)
def label(assigns) do
~H"""
<label for={@for} class="block font-medium dark:text-gray-100">
<%= render_slot(@inner_block) %>
</label>
"""
end
@doc """
Generates a generic error message.
"""
slot(:inner_block, required: true)
def error(assigns) do
~H"""
<p class="flex gap-3 text-sm leading-6 text-red-500 phx-no-feedback:hidden">
<%= render_slot(@inner_block) %>
</p>
"""
end
def translate_error({msg, opts}) do
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end)
end)
end
end

View File

@ -16,12 +16,7 @@ defmodule PlausibleWeb.Live.FunnelSettings do
) do ) do
true = Plausible.Funnels.enabled_for?("user:#{user_id}") true = Plausible.Funnels.enabled_for?("user:#{user_id}")
site = site = Sites.get_for_user!(user_id, domain, [:owner, :admin, :superadmin])
if Plausible.Auth.is_super_admin?(user_id) do
Sites.get_by_domain(domain)
else
Sites.get_for_user!(user_id, domain, [:owner, :admin])
end
funnels = Funnels.list(site) funnels = Funnels.list(site)
goal_count = Goals.count(site) goal_count = Goals.count(site)
@ -67,7 +62,7 @@ defmodule PlausibleWeb.Live.FunnelSettings do
<PlausibleWeb.Components.Generic.notice class="mt-4" title="Not enough goals"> <PlausibleWeb.Components.Generic.notice class="mt-4" title="Not enough goals">
You need to define at least two goals to create a funnel. Go ahead and <%= link( You need to define at least two goals to create a funnel. Go ahead and <%= link(
"add goals", "add goals",
to: PlausibleWeb.Router.Helpers.site_path(@socket, :new_goal, @domain), to: PlausibleWeb.Router.Helpers.site_path(@socket, :settings_goals, @domain),
class: "text-indigo-500 w-full text-center" class: "text-indigo-500 w-full text-center"
) %> to proceed. ) %> to proceed.
</PlausibleWeb.Components.Generic.notice> </PlausibleWeb.Components.Generic.notice>

View File

@ -12,12 +12,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
alias Plausible.{Sites, Goals} alias Plausible.{Sites, Goals}
def mount(_params, %{"current_user_id" => user_id, "domain" => domain}, socket) do def mount(_params, %{"current_user_id" => user_id, "domain" => domain}, socket) do
site = site = Sites.get_for_user!(user_id, domain, [:owner, :admin, :superadmin])
if Plausible.Auth.is_super_admin?(user_id) do
Sites.get_by_domain(domain)
else
Sites.get_for_user!(user_id, domain, [:owner, :admin])
end
# We'll have the options trimmed to only the data we care about, to keep # 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{} # it minimal at the socket assigns, yet, we want to retain specific %Goal{}
@ -69,7 +64,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
<.live_component <.live_component
submit_name="funnel[steps][][goal_id]" submit_name="funnel[steps][][goal_id]"
module={PlausibleWeb.Live.Components.ComboBox} module={PlausibleWeb.Live.Components.ComboBox}
suggest_mod={PlausibleWeb.Live.Components.ComboBox.StaticSearch} suggest_fun={&PlausibleWeb.Live.Components.ComboBox.StaticSearch.suggest/2}
id={"step-#{step_idx}"} id={"step-#{step_idx}"}
options={reject_alrady_selected("step-#{step_idx}", @goals, @selections_made)} options={reject_alrady_selected("step-#{step_idx}", @goals, @selections_made)}
/> />

View File

@ -0,0 +1,123 @@
defmodule PlausibleWeb.Live.GoalSettings do
@moduledoc """
LiveView allowing listing, creating and deleting goals.
"""
use Phoenix.LiveView
use Phoenix.HTML
use Plausible.Funnel
alias Plausible.{Sites, Goals}
def mount(
_params,
%{"site_id" => _site_id, "domain" => domain, "current_user_id" => user_id},
socket
) do
site = Sites.get_for_user!(user_id, domain, [:owner, :admin, :superadmin])
goals = Goals.for_site(site, preload_funnels?: true)
{:ok,
assign(socket,
site_id: site.id,
domain: site.domain,
all_goals: goals,
displayed_goals: goals,
add_goal?: false,
current_user_id: user_id,
filter_text: ""
)}
end
# Flash sharing with live views within dead views can be done via re-rendering the flash partial.
# Normally, we'd have to use live_patch which we can't do with views unmounted at the router it seems.
def render(assigns) do
~H"""
<div id="goal-settings-main">
<.live_component id="embedded_liveview_flash" module={PlausibleWeb.Live.Flash} flash={@flash} />
<%= if @add_goal? do %>
<%= live_render(
@socket,
PlausibleWeb.Live.GoalSettings.Form,
id: "goals-form",
session: %{
"current_user_id" => @current_user_id,
"domain" => @domain,
"site_id" => @site_id,
"rendered_by" => self()
}
) %>
<% end %>
<.live_component
module={PlausibleWeb.Live.GoalSettings.List}
id="goals-list"
goals={@displayed_goals}
domain={@domain}
filter_text={@filter_text}
/>
</div>
"""
end
def handle_event("reset-filter-text", _params, socket) do
{:noreply, assign(socket, filter_text: "", displayed_goals: socket.assigns.all_goals)}
end
def handle_event("filter", %{"filter-text" => filter_text}, socket) do
new_list =
PlausibleWeb.Live.Components.ComboBox.StaticSearch.suggest(
filter_text,
socket.assigns.all_goals
)
{:noreply, assign(socket, displayed_goals: new_list, filter_text: filter_text)}
end
def handle_event("add-goal", _value, socket) do
{:noreply, assign(socket, add_goal?: true)}
end
def handle_event("delete-goal", %{"goal-id" => goal_id}, socket) do
goal_id = String.to_integer(goal_id)
case Plausible.Goals.delete(goal_id, socket.assigns.site_id) do
:ok ->
socket =
socket
|> put_flash(:success, "Goal deleted successfully")
|> assign(
all_goals: Enum.reject(socket.assigns.all_goals, &(&1.id == goal_id)),
displayed_goals: Enum.reject(socket.assigns.displayed_goals, &(&1.id == goal_id))
)
Process.send_after(self(), :clear_flash, 5000)
{:noreply, socket}
_ ->
{:noreply, socket}
end
end
def handle_info(:cancel_add_goal, socket) do
{:noreply, assign(socket, add_goal?: false)}
end
def handle_info({:goal_added, goal}, socket) do
socket =
socket
|> assign(
add_goal?: false,
filter_text: "",
all_goals: [goal | socket.assigns.all_goals],
displayed_goals: [goal | socket.assigns.all_goals]
)
|> put_flash(:success, "Goal saved successfully")
{:noreply, socket}
end
def handle_info(:clear_flash, socket) do
{:noreply, clear_flash(socket)}
end
end

View File

@ -0,0 +1,273 @@
defmodule PlausibleWeb.Live.GoalSettings.Form do
@moduledoc """
Live view for the goal creation form
"""
use Phoenix.LiveView
import PlausibleWeb.Live.Components.Form
alias PlausibleWeb.Live.Components.ComboBox
alias Plausible.Repo
def mount(
_params,
%{
"site_id" => _site_id,
"current_user_id" => user_id,
"domain" => domain,
"rendered_by" => pid
},
socket
) do
form = to_form(Plausible.Goal.changeset(%Plausible.Goal{}))
site = Plausible.Sites.get_for_user!(user_id, domain, [:owner, :admin, :superadmin])
{:ok,
assign(socket,
current_user: Repo.get(Plausible.Auth.User, user_id),
form: form,
domain: domain,
rendered_by: pid,
tabs: %{custom_events: true, pageviews: false},
site: site
)}
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-goal"
phx-key="Escape"
>
</div>
<div class="fixed inset-0 flex items-center justify-center mt-16 z-50 overflow-y-auto overflow-x-hidden">
<div class="w-1/2 h-full">
<.form
:let={f}
for={@form}
class="max-w-md w-full mx-auto bg-white dark:bg-gray-800 shadow-md rounded px-8 pt-6 pb-8 mb-4 mt-8"
phx-submit="save-goal"
phx-click-away="cancel-add-goal"
>
<h2 class="text-xl font-black dark:text-gray-100">Add goal for <%= @domain %></h2>
<.tabs tabs={@tabs} />
<.custom_event_fields :if={@tabs.custom_events} f={f} />
<.pageview_fields :if={@tabs.pageviews} f={f} site={@site} />
<div class="py-4">
<button type="submit" class="button text-base font-bold w-full">
Add goal
</button>
</div>
</.form>
</div>
</div>
"""
end
attr(:f, Phoenix.HTML.Form)
attr(:site, Plausible.Site)
def pageview_fields(assigns) do
~H"""
<div class="py-2">
<.label for="page_path_input">
Page path
</.label>
<.live_component
id="page_path_input"
submit_name="goal[page_path]"
class={[
"py-2"
]}
module={ComboBox}
suggest_fun={fn input, options -> suggest_page_paths(input, options, @site) end}
async={true}
creatable
/>
<.error :for={{msg, opts} <- @f[:page_path].errors}>
<%= Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end)
end) %>
</.error>
</div>
"""
end
attr(:f, Phoenix.HTML.Form)
def custom_event_fields(assigns) do
~H"""
<div class="my-6">
<div id="event-fields">
<div class="pb-6 text-xs text-gray-700 dark:text-gray-200 text-justify rounded-md">
Custom events are not tracked by default - you have to configure them on your site to be sent to Plausible. See examples and learn more in <a
class="text-indigo-500 hover:underline"
target="_blank"
rel="noreferrer"
href="https://plausible.io/docs/custom-event-goals"
> our docs</a>.
</div>
<div>
<.input
autofocus
field={@f[:event_name]}
label="Event name"
class="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"
placeholder="e.g. Signup"
autocomplete="off"
/>
</div>
<div
class="mt-6 space-y-3"
x-data={
Jason.encode!(%{
active: !!@f[:currency].value and @f[:currency].value != "",
currency: @f[:currency].value
})
}
>
<div
class="flex items-center w-max cursor-pointer"
x-on:click="active = !active; currency = ''"
>
<button
class="relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2"
x-bind:class="active ? 'bg-indigo-600' : 'dark:bg-gray-700 bg-gray-200'"
x-bind:aria-checked="active"
aria-labelledby="enable-revenue-tracking"
role="switch"
type="button"
>
<span
aria-hidden="true"
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
x-bind:class="active ? 'dark:bg-gray-800 translate-x-5' : 'dark:bg-gray-800 translate-x-0'"
/>
</button>
<span
class="ml-3 font-medium text-gray-900 dark:text-gray-200"
id="enable-revenue-tracking"
>
Enable revenue tracking
</span>
</div>
<div class="rounded-md bg-yellow-50 dark:bg-yellow-900 p-4" x-show="active">
<p class="text-xs text-yellow-700 dark:text-yellow-50 text-justify">
Revenue tracking is an upcoming premium feature that's free-to-use
during the private preview. Pricing will be announced soon. See
examples and learn more in <a
class="font-medium text-yellow underline hover:text-yellow-600"
href="https://plausible.io/docs/ecommerce-revenue-tracking"
>our docs</a>.
</p>
</div>
<div x-show="active">
<.live_component
id="currency_input"
submit_name={@f[:currency].name}
module={ComboBox}
suggest_fun={
fn
"", [] ->
Plausible.Goal.currency_options()
input, options ->
ComboBox.StaticSearch.suggest(input, options, weight_threshold: 0.8)
end
}
async={true}
/>
</div>
</div>
</div>
</div>
"""
end
def tabs(assigns) do
~H"""
<div class="mt-6 font-medium dark:text-gray-100">Goal trigger</div>
<div class="my-3 w-full flex rounded border border-gray-300 dark:border-gray-500">
<.custom_events_tab tabs={@tabs} />
<.pageviews_tab tabs={@tabs} />
</div>
"""
end
defp custom_events_tab(assigns) do
~H"""
<a
class={[
"w-1/2 text-center py-2 border-r dark:border-gray-500",
"cursor-pointer",
@tabs.custom_events && "shadow-inner font-bold bg-indigo-600 text-white",
!@tabs.custom_events && "dark:text-gray-100 text-gray-800"
]}
id="event-tab"
phx-click="switch-tab"
>
Custom event
</a>
"""
end
def pageviews_tab(assigns) do
~H"""
<a
class={[
"w-1/2 text-center py-2 cursor-pointer",
@tabs.pageviews && "shadow-inner font-bold bg-indigo-600 text-white",
!@tabs.pageviews && "dark:text-gray-100 text-gray-800"
]}
id="pageview-tab"
phx-click="switch-tab"
>
Pageview
</a>
"""
end
def handle_event("switch-tab", _params, socket) do
{:noreply,
assign(socket,
tabs: %{
custom_events: !socket.assigns.tabs.custom_events,
pageviews: !socket.assigns.tabs.pageviews
}
)}
end
def handle_event("save-goal", %{"goal" => goal}, socket) do
case Plausible.Goals.create(socket.assigns.site, goal) do
{:ok, goal} ->
send(socket.assigns.rendered_by, {:goal_added, Map.put(goal, :funnels, [])})
{:noreply, socket}
{:error, changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
def handle_event("cancel-add-goal", _value, socket) do
send(socket.assigns.rendered_by, :cancel_add_goal)
{:noreply, socket}
end
def suggest_page_paths(input, _options, site) do
query = Plausible.Stats.Query.from(site, %{})
site
|> Plausible.Stats.filter_suggestions(query, "page", input)
|> Enum.map(fn %{label: label, value: value} -> {label, value} end)
end
end

View File

@ -0,0 +1,120 @@
defmodule PlausibleWeb.Live.GoalSettings.List do
@moduledoc """
Phoenix LiveComponent module that renders a list of goals
"""
use Phoenix.LiveComponent
use Phoenix.HTML
use Plausible.Funnel
attr(:goals, :list, required: true)
attr(:domain, :string, required: true)
attr(:filter_text, :string)
def render(assigns) do
~H"""
<div>
<div class="border-t border-gray-200 pt-4 sm:flex sm:items-center sm:justify-between">
<form id="filter-form" phx-change="filter">
<div class="text-gray-800 text-sm inline-flex items-center">
<div class="relative rounded-md shadow-sm flex">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<Heroicons.magnifying_glass class="feather mr-1 dark:text-gray-300" />
</div>
<input
type="text"
name="filter-text"
id="filter-text"
class="pl-8 shadow-sm dark:bg-gray-900 dark:text-gray-300 focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 dark:border-gray-500 rounded-md dark:bg-gray-800"
placeholder="Search Goals"
value={@filter_text}
/>
</div>
<Heroicons.backspace
:if={String.trim(@filter_text) != ""}
class="feather ml-2 cursor-pointer hover:text-red-500 dark:text-gray-300 dark:hover:text-red-500"
phx-click="reset-filter-text"
id="reset-filter"
/>
</div>
</form>
<div class="mt-4 flex sm:ml-4 sm:mt-0">
<button type="button" phx-click="add-goal" class="button">
+ Add Goal
</button>
</div>
</div>
<%= if Enum.count(@goals) > 0 do %>
<div class="mt-12">
<%= for goal <- @goals do %>
<div class="border-b border-gray-300 dark:border-gray-500 py-3 flex justify-between">
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 w-3/4">
<div class="flex">
<span class="truncate">
<%= goal %>
<br />
<span class="text-sm text-gray-400 block mt-1 font-normal">
<span :if={goal.page_path}>Pageview</span>
<span :if={goal.event_name && !goal.currency}>Custom Event</span>
<span :if={goal.currency}>
Revenue Goal: <%= goal.currency %>
</span>
<span :if={not Enum.empty?(goal.funnels)}> - belongs to funnel(s)</span>
</span>
</span>
</div>
</span>
<button
id={"delete-goal-#{goal.id}"}
phx-click="delete-goal"
phx-value-goal-id={goal.id}
class="text-sm text-red-600"
data-confirm={delete_confirmation_text(goal)}
>
<Heroicons.trash class="feather feather-sm" />
</button>
</div>
<% end %>
</div>
<% else %>
<p class="text-sm text-gray-800 dark:text-gray-200 mt-12 mb-8 text-center">
<span :if={String.trim(@filter_text) != ""}>
No goals found for this site. Please refine or
<a
class="text-indigo-500 cursor-pointer underline"
phx-click="reset-filter-text"
id="reset-filter-hint"
>
reset your search.
</a>
</span>
<span :if={String.trim(@filter_text) == "" && Enum.empty?(@goals)}>
No goals configured for this site.
</span>
</p>
<% end %>
</div>
"""
end
defp delete_confirmation_text(goal) do
if Enum.empty?(goal.funnels) do
"""
Are you sure you want to remove the following goal:
#{goal}
This will just affect the UI, all of your analytics data will stay intact.
"""
else
"""
The goal:
#{goal}
is part of some funnel(s). If you are going to delete it, the associated funnels will be either reduced or deleted completely. Are you sure you want to remove the goal?
"""
end
end
end

View File

@ -54,7 +54,7 @@ defmodule PlausibleWeb.Live.PropsSettings do
submit_name="prop" submit_name="prop"
class="flex-1" class="flex-1"
module={ComboBox} module={ComboBox}
suggest_mod={ComboBox.StaticSearch} suggest_fun={&ComboBox.StaticSearch.suggest/2}
options={@suggestions} options={@suggestions}
required required
creatable creatable

View File

@ -155,11 +155,6 @@ defmodule PlausibleWeb.Router do
post "/share/:slug/authenticate", StatsController, :authenticate_shared_link post "/share/:slug/authenticate", StatsController, :authenticate_shared_link
end end
scope "/:website/settings/funnels/", PlausibleWeb do
pipe_through [:browser, :csrf]
get "/", SiteController, :settings_funnels
end
scope "/", PlausibleWeb do scope "/", PlausibleWeb do
pipe_through [:browser, :csrf] pipe_through [:browser, :csrf]
@ -259,14 +254,12 @@ defmodule PlausibleWeb.Router do
get "/:website/settings/visibility", SiteController, :settings_visibility get "/:website/settings/visibility", SiteController, :settings_visibility
get "/:website/settings/goals", SiteController, :settings_goals get "/:website/settings/goals", SiteController, :settings_goals
get "/:website/settings/properties", SiteController, :settings_props get "/:website/settings/properties", SiteController, :settings_props
get "/:website/settings/funnels", SiteController, :settings_funnels
get "/:website/settings/search-console", SiteController, :settings_search_console get "/:website/settings/search-console", SiteController, :settings_search_console
get "/:website/settings/email-reports", SiteController, :settings_email_reports get "/:website/settings/email-reports", SiteController, :settings_email_reports
get "/:website/settings/custom-domain", SiteController, :settings_custom_domain get "/:website/settings/custom-domain", SiteController, :settings_custom_domain
get "/:website/settings/danger-zone", SiteController, :settings_danger_zone get "/:website/settings/danger-zone", SiteController, :settings_danger_zone
get "/:website/goals/new", SiteController, :new_goal
post "/:website/goals", SiteController, :create_goal
delete "/:website/goals/:id", SiteController, :delete_goal
put "/:website/settings/features/visibility/:setting", put "/:website/settings/features/visibility/:setting",
SiteController, SiteController,

View File

@ -1,124 +0,0 @@
<%= form_for @changeset, "/#{URI.encode_www_form(@site.domain)}/goals", [class: "max-w-md w-full mx-auto bg-white dark:bg-gray-800 shadow-md rounded px-8 pt-6 pb-8 mb-4 mt-8"], fn f -> %>
<h2 class="text-xl font-black dark:text-gray-100">Add goal for <%= @site.domain %></h2>
<div class="mt-6 font-medium dark:text-gray-100">Goal trigger</div>
<div class="my-3 w-full flex rounded border border-gray-300 dark:border-gray-500">
<div
class="w-1/2 text-center py-2 border-r border-gray-300 dark:border-gray-500 shadow-inner font-bold cursor-pointer text-white dark:text-gray-100 bg-indigo-600"
id="event-tab"
>
Custom event
</div>
<div class="w-1/2 text-center py-2 cursor-pointer dark:text-gray-100" id="pageview-tab">
Pageview
</div>
</div>
<div class="my-6">
<div id="event-fields">
<div class="pb-6 text-xs text-gray-700 dark:text-gray-200 text-justify rounded-md">
Custom events are not tracked by default - you have to configure them on your site to be sent to Plausible. See examples and learn more in <a
class="text-indigo-500 hover:underline"
target="_blank"
rel="noreferrer"
href="https://plausible.io/docs/custom-event-goals"
> our docs</a>.
</div>
<div>
<%= label(f, :event_name, class: "block font-medium dark:text-gray-100") %>
<%= text_input(f, :event_name,
class:
"transition mt-3 bg-gray-100 dark:bg-gray-900 outline-none appearance-none border border-transparent rounded w-full p-2 text-gray-700 dark:text-gray-300 leading-normal focus:outline-none focus:bg-white dark:focus:bg-gray-800 focus:border-gray-300 dark:focus:border-gray-500",
placeholder: "Signup"
) %>
<%= error_tag(f, :event_name) %>
</div>
<div
class="mt-6 space-y-3"
x-data={
Jason.encode!(%{
active: !!Ecto.Changeset.get_field(@changeset, :currency),
currency: Ecto.Changeset.get_field(@changeset, :currency)
})
}
>
<div
class="flex items-center w-max cursor-pointer"
x-on:click="active = !active; currency = ''"
>
<button
class="relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2"
x-bind:class="active ? 'bg-indigo-600' : 'bg-gray-200'"
x-bind:aria-checked="active"
aria-labelledby="enable-revenue-tracking"
role="switch"
type="button"
>
<span
aria-hidden="true"
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
x-bind:class="active ? 'translate-x-5' : 'translate-x-0'"
/>
</button>
<span
class="ml-3 font-medium text-gray-900 dark:text-gray-200"
id="enable-revenue-tracking"
>
Enable revenue tracking
</span>
</div>
<div class="rounded-md bg-yellow-50 dark:bg-yellow-900 p-4" x-show="active">
<p class="text-xs text-yellow-700 dark:text-yellow-50 text-justify">
Revenue tracking is an upcoming premium feature that's free-to-use
during the private preview. Pricing will be announced soon. See
examples and learn more in <a
class="font-medium text-yellow underline hover:text-yellow-600"
href="https://plausible.io/docs/ecommerce-revenue-tracking"
>our docs</a>.
</p>
</div>
<div x-show="active">
<%= select(f, :currency, Plausible.Goal.currency_options(),
class:
"transition bg-gray-100 dark:bg-gray-900 outline-none appearance-none border border-transparent rounded w-full p-2 text-gray-700 dark:text-gray-300 leading-normal focus:outline-none focus:bg-white dark:focus:bg-gray-800 focus:border-gray-300 dark:focus:border-gray-500",
"aria-label": "Reporting currency",
"x-model": "currency",
"x-bind:required": "active"
) %>
<%= error_tag(f, :currency) %>
</div>
</div>
</div>
<div id="pageview-fields" class="hidden">
<%= label(f, :page_path, class: "block font-medium dark:text-gray-100") %>
<%= text_input(f, :page_path,
class:
"transition mt-3 bg-gray-100 dark:bg-gray-900 outline-none appearance-none border border-transparent rounded w-full p-2 text-gray-700 dark:text-gray-300 leading-normal focus:outline-none focus:bg-white dark:focus:bg-gray-800 focus:border-gray-300 dark:focus:border-gray-500",
placeholder: "/success"
) %>
<%= error_tag(f, :page_path) %>
</div>
</div>
<%= submit("Add goal →", class: "button text-base font-bold w-full") %>
<% end %>
<script>
document.getElementById('pageview-tab').onclick = function() {
document.getElementById('pageview-fields').classList.remove('hidden')
document.getElementById('pageview-tab').classList.add('shadow-inner', 'font-bold', 'bg-indigo-600', 'text-white')
document.getElementById('event-fields').classList.add('hidden')
document.getElementById('event-tab').classList.remove('shadow-inner', 'font-bold', 'bg-indigo-600', 'text-white')
document.getElementById('event-tab').classList.add('dark:text-gray-100')
}
document.getElementById('event-tab').onclick = function() {
document.getElementById('event-fields').classList.remove('hidden')
document.getElementById('event-tab').classList.add('shadow-inner', 'font-bold', 'bg-indigo-600', 'text-white')
document.getElementById('pageview-fields').classList.add('hidden')
document.getElementById('pageview-tab').classList.remove('shadow-inner', 'font-bold', 'bg-indigo-600', 'text-white')
document.getElementById('pageview-tab').classList.add('dark:text-gray-100')
document.getElementById('goal_page_path').value = ''
}
</script>

View File

@ -1,8 +1,14 @@
<div class="shadow bg-white dark:bg-gray-800 sm:rounded-md sm:overflow-hidden py-6 px-4 sm:p-6"> <div class="shadow bg-white dark:bg-gray-800 sm:rounded-md sm:overflow-hidden py-6 px-4 sm:p-6">
<header class="relative"> <header class="relative">
<h2 class="text-lg leading-6 font-medium text-gray-900 dark:text-gray-100">Goals</h2> <h2 class="text-lg leading-6 font-medium text-gray-900 dark:text-gray-100">Goals</h2>
<p class="mt-1 text-sm leading-5 text-gray-500 dark:text-gray-200"> <p class="mt-2 text-sm leading-5 text-gray-500 dark:text-gray-200">
Define actions that you want your users to take like visiting a certain page, submitting a form, etc. Define actions that you want your users to take, like visiting a certain page, submitting a form, etc.
</p>
<p class="text-sm leading-5 text-gray-500 dark:text-gray-200">
You can also <a
href={Routes.site_path(@conn, :settings_funnels, @site.domain)}
class="text-indigo-500 underline"
>compose goals into funnels</a>.
</p> </p>
<%= link(to: "https://plausible.io/docs/goal-conversions", target: "_blank", rel: "noreferrer") do %> <%= link(to: "https://plausible.io/docs/goal-conversions", target: "_blank", rel: "noreferrer") do %>
@ -28,69 +34,8 @@
label="Show goals in the dashboard" label="Show goals in the dashboard"
conn={@conn} conn={@conn}
> >
<%= if Enum.count(@goals) > 0 do %> <%= live_render(@conn, PlausibleWeb.Live.GoalSettings,
<div class="mt-4"> session: %{"site_id" => @site.id, "domain" => @site.domain}
<%= for goal <- @goals do %>
<div class="border-b border-gray-300 dark:border-gray-500 py-3 flex justify-between">
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
<%= goal %>
</span>
<%= if not Enum.empty?(goal.funnels) do %>
<%= button(to: "/#{URI.encode_www_form(@site.domain)}/goals/#{goal.id}", method: :delete, class: "text-sm text-red-600", data: [confirm: "The goal '#{goal}' is part of some funnel(s). If you are going to delete it, the associated funnels will be either reduced or deleted completely. Are you sure you want to remove goal '#{goal}'?"]) do %>
<svg
class="feather feather-sm"
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"
>
<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>
<% end %>
<% else %>
<%= button(to: "/#{URI.encode_www_form(@site.domain)}/goals/#{goal.id}", method: :delete, class: "text-sm text-red-600", data: [confirm: "Are you sure you want to remove goal '#{goal}'? This will just affect the UI, all of your analytics data will stay intact."]) do %>
<svg
class="feather feather-sm"
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"
>
<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>
<% end %>
<% end %>
</div>
<% end %>
</div>
<% else %>
<div class="mt-4 dark:text-gray-100">No goals configured for this site yet</div>
<% end %>
<%= link("+ Add goal",
to: "/#{URI.encode_www_form(@site.domain)}/goals/new",
class: "button mt-6"
) %> ) %>
<%= if Enum.count(@goals) >= Funnel.min_steps() and Plausible.Funnels.enabled_for?(@current_user) do %>
<%= link("Set up funnels",
to: Routes.site_path(@conn, :settings_funnels, @site.domain),
class: "mt-6 ml-2 text-indigo-500 underline text-sm"
) %>
<% end %>
</PlausibleWeb.Components.Site.Feature.toggle> </PlausibleWeb.Components.Site.Feature.toggle>
</div> </div>

View File

@ -120,7 +120,8 @@ defmodule Plausible.MixProject do
{:ex_doc, "~> 0.28", only: :dev, runtime: false}, {:ex_doc, "~> 0.28", only: :dev, runtime: false},
{:ex_money, "~> 5.12"}, {:ex_money, "~> 5.12"},
{:mjml_eex, "~> 0.9.0"}, {:mjml_eex, "~> 0.9.0"},
{:mjml, "~> 1.5.0"} {:mjml, "~> 1.5.0"},
{:heroicons, "~> 0.5.0"}
] ]
end end

View File

@ -60,6 +60,7 @@
"grpcbox": {:hex, :grpcbox, "0.16.0", "b83f37c62d6eeca347b77f9b1ec7e9f62231690cdfeb3a31be07cd4002ba9c82", [:rebar3], [{:acceptor_pool, "~>1.0.0", [hex: :acceptor_pool, repo: "hexpm", optional: false]}, {:chatterbox, "~>0.13.0", [hex: :ts_chatterbox, repo: "hexpm", optional: false]}, {:ctx, "~>0.6.0", [hex: :ctx, repo: "hexpm", optional: false]}, {:gproc, "~>0.8.0", [hex: :gproc, repo: "hexpm", optional: false]}], "hexpm", "294df743ae20a7e030889f00644001370a4f7ce0121f3bbdaf13cf3169c62913"}, "grpcbox": {:hex, :grpcbox, "0.16.0", "b83f37c62d6eeca347b77f9b1ec7e9f62231690cdfeb3a31be07cd4002ba9c82", [:rebar3], [{:acceptor_pool, "~>1.0.0", [hex: :acceptor_pool, repo: "hexpm", optional: false]}, {:chatterbox, "~>0.13.0", [hex: :ts_chatterbox, repo: "hexpm", optional: false]}, {:ctx, "~>0.6.0", [hex: :ctx, repo: "hexpm", optional: false]}, {:gproc, "~>0.8.0", [hex: :gproc, repo: "hexpm", optional: false]}], "hexpm", "294df743ae20a7e030889f00644001370a4f7ce0121f3bbdaf13cf3169c62913"},
"hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"},
"hammer": {:hex, :hammer, "6.1.0", "f263e3c3e9946bd410ea0336b2abe0cb6260af4afb3a221e1027540706e76c55", [:make, :mix], [{:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}], "hexpm", "b47e415a562a6d072392deabcd58090d8a41182cf9044cdd6b0d0faaaf68ba57"}, "hammer": {:hex, :hammer, "6.1.0", "f263e3c3e9946bd410ea0336b2abe0cb6260af4afb3a221e1027540706e76c55", [:make, :mix], [{:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}], "hexpm", "b47e415a562a6d072392deabcd58090d8a41182cf9044cdd6b0d0faaaf68ba57"},
"heroicons": {:hex, :heroicons, "0.5.3", "ee8ae8335303df3b18f2cc07f46e1cb6e761ba4cf2c901623fbe9a28c0bc51dd", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:phoenix_live_view, ">= 0.18.2", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "a210037e8a09ac17e2a0a0779d729e89c821c944434c3baa7edfc1f5b32f3502"},
"hpack": {:hex, :hpack_erl, "0.2.3", "17670f83ff984ae6cd74b1c456edde906d27ff013740ee4d9efaa4f1bf999633", [:rebar3], [], "hexpm", "06f580167c4b8b8a6429040df36cc93bba6d571faeaec1b28816523379cbb23a"}, "hpack": {:hex, :hpack_erl, "0.2.3", "17670f83ff984ae6cd74b1c456edde906d27ff013740ee4d9efaa4f1bf999633", [:rebar3], [], "hexpm", "06f580167c4b8b8a6429040df36cc93bba6d571faeaec1b28816523379cbb23a"},
"hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"}, "hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"},
"html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"}, "html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"},

View File

@ -3,7 +3,7 @@ defmodule Plausible.GoalsTest do
alias Plausible.Goals alias Plausible.Goals
test "create/2 trims input" do test "create/2 creates goals and trims input" do
site = insert(:site) site = insert(:site)
{:ok, goal} = Goals.create(site, %{"page_path" => "/foo bar "}) {:ok, goal} = Goals.create(site, %{"page_path" => "/foo bar "})
assert goal.page_path == "/foo bar" assert goal.page_path == "/foo bar"
@ -12,6 +12,12 @@ defmodule Plausible.GoalsTest do
assert goal.event_name == "some event name" assert goal.event_name == "some event name"
end end
test "create/2 creates pageview goal and adds a leading slash if missing" do
site = insert(:site)
{:ok, goal} = Goals.create(site, %{"page_path" => "foo bar"})
assert goal.page_path == "/foo bar"
end
test "create/2 validates goal name is at most 120 chars" do test "create/2 validates goal name is at most 120 chars" do
site = insert(:site) site = insert(:site)
assert {:error, changeset} = Goals.create(site, %{"event_name" => String.duplicate("a", 130)}) assert {:error, changeset} = Goals.create(site, %{"event_name" => String.duplicate("a", 130)})
@ -32,7 +38,32 @@ defmodule Plausible.GoalsTest do
:eq :eq
end end
test "for_site2 returns trimmed input even if it was saved with trailing whitespace" do test "create/2 creates revenue goal" do
site = insert(:site)
{:ok, goal} = Goals.create(site, %{"event_name" => "Purchase", "currency" => "EUR"})
assert goal.event_name == "Purchase"
assert goal.page_path == nil
assert goal.currency == :EUR
end
test "create/2 fails for unknown currency code" do
site = insert(:site)
assert {:error, changeset} =
Goals.create(site, %{"event_name" => "Purchase", "currency" => "Euro"})
assert [currency: {"is invalid", _}] = changeset.errors
end
test "create/2 clears currency for pageview goals" do
site = insert(:site)
{:ok, goal} = Goals.create(site, %{"page_path" => "/purchase", "currency" => "EUR"})
assert goal.event_name == nil
assert goal.page_path == "/purchase"
assert goal.currency == nil
end
test "for_site/1 returns trimmed input even if it was saved with trailing whitespace" do
site = insert(:site) site = insert(:site)
insert(:goal, %{site: site, event_name: " Signup "}) insert(:goal, %{site: site, event_name: " Signup "})
insert(:goal, %{site: site, page_path: " /Signup "}) insert(:goal, %{site: site, page_path: " /Signup "})

View File

@ -621,131 +621,6 @@ defmodule PlausibleWeb.SiteControllerTest do
end end
end end
describe "GET /:website/goals/new" do
setup [:create_user, :log_in, :create_site]
test "shows form to create a new goal", %{conn: conn, site: site} do
conn = get(conn, "/#{site.domain}/goals/new")
assert html_response(conn, 200) =~ "Add goal"
end
end
describe "POST /:website/goals" do
setup [:create_user, :log_in, :create_site]
test "creates a pageview goal for the website", %{conn: conn, site: site} do
conn =
post(conn, "/#{site.domain}/goals", %{
goal: %{
page_path: "/success",
event_name: ""
}
})
goal = Repo.one(Plausible.Goal)
assert goal.page_path == "/success"
assert goal.event_name == nil
assert redirected_to(conn, 302) == "/#{site.domain}/settings/goals"
end
test "creates a custom event goal for the website", %{conn: conn, site: site} do
conn =
post(conn, "/#{site.domain}/goals", %{
goal: %{
page_path: "",
event_name: "Signup"
}
})
goal = Repo.one(Plausible.Goal)
assert goal.event_name == "Signup"
assert goal.page_path == nil
assert redirected_to(conn, 302) == "/#{site.domain}/settings/goals"
end
test "creates a custom event goal with a revenue value", %{conn: conn, site: site} do
conn =
post(conn, "/#{site.domain}/goals", %{
goal: %{
page_path: "",
event_name: "Purchase",
currency: "EUR"
}
})
goal = Repo.get_by(Plausible.Goal, site_id: site.id)
assert goal.event_name == "Purchase"
assert goal.page_path == nil
assert goal.currency == :EUR
assert redirected_to(conn, 302) == "/#{site.domain}/settings/goals"
end
test "fails to create a custom event goal with a non-existant currency", %{
conn: conn,
site: site
} do
conn =
post(conn, "/#{site.domain}/goals", %{
goal: %{
page_path: "",
event_name: "Purchase",
currency: "EEEE"
}
})
refute Repo.get_by(Plausible.Goal, site_id: site.id)
assert html_response(conn, 200) =~ "is invalid"
end
test "Cleans currency for pageview goal creation", %{conn: conn, site: site} do
conn =
post(conn, "/#{site.domain}/goals", %{
goal: %{
page_path: "/purchase",
event_name: "",
currency: "EUR"
}
})
goal = Repo.get_by(Plausible.Goal, site_id: site.id)
assert goal.event_name == nil
assert goal.page_path == "/purchase"
assert goal.currency == nil
assert redirected_to(conn, 302) == "/#{site.domain}/settings/goals"
end
end
describe "DELETE /:website/goals/:id" do
setup [:create_user, :log_in, :create_site]
test "deletes goal", %{conn: conn, site: site} do
goal = insert(:goal, site: site, event_name: "Custom event")
conn = delete(conn, "/#{site.domain}/goals/#{goal.id}")
assert Repo.aggregate(Plausible.Goal, :count, :id) == 0
assert redirected_to(conn, 302) == "/#{site.domain}/settings/goals"
end
test "fails to delete goal for a foreign site", %{conn: conn, site: site} do
another_site = insert(:site)
goal = insert(:goal, site: another_site, event_name: "Custom event")
conn = delete(conn, "/#{site.domain}/goals/#{goal.id}")
assert Repo.aggregate(Plausible.Goal, :count, :id) == 1
assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Could not find goal"
end
end
describe "PUT /:website/settings/features/visibility/:setting" do describe "PUT /:website/settings/features/visibility/:setting" do
def build_conn_with_some_url(context) do def build_conn_with_some_url(context) do
{:ok, Map.put(context, :conn, build_conn(:get, "/some_parent_path"))} {:ok, Map.put(context, :conn, build_conn(:get, "/some_parent_path"))}

View File

@ -154,7 +154,7 @@ defmodule PlausibleWeb.Live.Components.ComboBoxTest do
<.live_component <.live_component
submit_name="some_submit_name" submit_name="some_submit_name"
module={PlausibleWeb.Live.Components.ComboBox} module={PlausibleWeb.Live.Components.ComboBox}
suggest_mod={__MODULE__.SampleSuggest} suggest_fun={&SampleSuggest.suggest/2}
id="test-component" id="test-component"
options={for i <- 1..20, do: {i, "Option #{i}"}} options={for i <- 1..20, do: {i, "Option #{i}"}}
suggestions_limit={7} suggestions_limit={7}
@ -210,7 +210,7 @@ defmodule PlausibleWeb.Live.Components.ComboBoxTest do
<.live_component <.live_component
submit_name="some_submit_name" submit_name="some_submit_name"
module={PlausibleWeb.Live.Components.ComboBox} module={PlausibleWeb.Live.Components.ComboBox}
suggest_mod={ComboBox.StaticSearch} suggest_fun={&ComboBox.StaticSearch.suggest/2}
id="test-creatable-component" id="test-creatable-component"
options={for i <- 1..20, do: {i, "Option #{i}"}} options={for i <- 1..20, do: {i, "Option #{i}"}}
creatable creatable
@ -274,6 +274,70 @@ defmodule PlausibleWeb.Live.Components.ComboBoxTest do
end end
end end
describe "async suggestions" do
defmodule SampleViewAsync do
use Phoenix.LiveView
defmodule SampleSuggest do
def suggest("", []) do
:timer.sleep(500)
[{1, "One"}, {2, "Two"}, {3, "Three"}]
end
def suggest("Echo me", _options) do
:timer.sleep(500)
[{1, "Echo me"}]
end
end
def render(assigns) do
~H"""
<.live_component
submit_name="some_submit_name"
module={PlausibleWeb.Live.Components.ComboBox}
suggest_fun={&SampleSuggest.suggest/2}
id="test-component"
async={true}
suggestions_limit={7}
/>
"""
end
end
test "options are empty at immediate render" do
doc =
render_component(
ComboBox,
submit_name: "test-submit-name",
id: "test-component",
suggest_fun: &ComboBox.StaticSearch.suggest/2,
async: true
)
refute element_exists?(doc, "#dropdown-test-component-option-0")
end
test "pre-fills the suggestions asynchronously", %{conn: conn} do
{:ok, lv, doc} = live_isolated(conn, SampleViewAsync, session: %{})
refute element_exists?(doc, "#dropdown-test-component-option-0")
:timer.sleep(1000)
doc = render(lv)
assert text_of_element(doc, "#dropdown-test-component-option-0") == "One"
assert text_of_element(doc, "#dropdown-test-component-option-1") == "Two"
assert text_of_element(doc, "#dropdown-test-component-option-2") == "Three"
end
test "uses the suggestions function asynchronously", %{conn: conn} do
{:ok, lv, _html} = live_isolated(conn, SampleViewAsync, session: %{})
doc = type_into_combo(lv, "test-component", "Echo me")
refute element_exists?(doc, "#dropdown-test-component-option-0")
:timer.sleep(1000)
doc = render(lv)
assert element_exists?(doc, "#dropdown-test-component-option-0")
assert text_of_element(doc, "#dropdown-test-component-option-0") == "Echo me"
end
end
defp render_sample_component(options, extra_opts \\ []) do defp render_sample_component(options, extra_opts \\ []) do
render_component( render_component(
ComboBox, ComboBox,
@ -282,7 +346,7 @@ defmodule PlausibleWeb.Live.Components.ComboBoxTest do
options: options, options: options,
submit_name: "test-submit-name", submit_name: "test-submit-name",
id: "test-component", id: "test-component",
suggest_mod: ComboBox.StaticSearch suggest_fun: &ComboBox.StaticSearch.suggest/2
], ],
extra_opts extra_opts
) )

View File

@ -51,12 +51,12 @@ defmodule PlausibleWeb.Live.FunnelSettingsTest do
doc = conn |> html_response(200) doc = conn |> html_response(200)
assert Floki.text(doc) =~ "You need to define at least two goals to create a funnel." assert Floki.text(doc) =~ "You need to define at least two goals to create a funnel."
add_goals_path = Routes.site_path(conn, :new_goal, site.domain) add_goals_path = Routes.site_path(conn, :settings_goals, site.domain)
assert element_exists?(doc, ~s/a[href="#{add_goals_path}"]/) assert element_exists?(doc, ~s/a[href="#{add_goals_path}"]/)
end end
end end
describe "FunnelSettings component" do describe "FunnelSettings live view" do
setup [:create_user, :log_in, :create_site] setup [:create_user, :log_in, :create_site]
test "allows to delete funnels", %{conn: conn, site: site} do test "allows to delete funnels", %{conn: conn, site: site} do

View File

@ -0,0 +1,182 @@
defmodule PlausibleWeb.Live.GoalSettings.FormTest do
use PlausibleWeb.ConnCase, async: true
import Phoenix.LiveViewTest
import Plausible.Test.Support.HTML
describe "integration - live rendering" do
setup [:create_user, :log_in, :create_site]
test "tabs switching", %{conn: conn, site: site} do
setup_goals(site)
lv = get_liveview(conn, site)
html = lv |> render()
assert element_exists?(html, ~s/a#pageview-tab/)
assert element_exists?(html, ~s/a#event-tab/)
pageview_tab = lv |> element(~s/a#pageview-tab/) |> render_click()
assert pageview_tab =~ "Page path"
event_tab = lv |> element(~s/a#event-tab/) |> render_click()
assert event_tab =~ "Event name"
end
test "escape closes the form", %{conn: conn, site: site} do
{parent, lv} = get_liveview(conn, site, with_parent?: true)
html = render(parent)
assert html =~ "Goal trigger"
render_keydown(lv, "cancel-add-goal")
html = render(parent)
refute html =~ "Goal trigger"
end
end
describe "Goal submission" do
setup [:create_user, :log_in, :create_site]
test "renders form fields", %{conn: conn, site: site} do
lv = get_liveview(conn, site)
html = render(lv)
[event_name, currency_display, currency_submit] = find(html, "input")
assert name_of(event_name) == "goal[event_name]"
assert name_of(currency_display) == "display-currency_input"
assert name_of(currency_submit) == "goal[currency]"
html = lv |> element(~s/a#pageview-tab/) |> render_click()
[page_path_display, page_path] = find(html, "input")
assert name_of(page_path_display) == "display-page_path_input"
assert name_of(page_path) == "goal[page_path]"
end
test "renders error on empty submission", %{conn: conn, site: site} do
lv = get_liveview(conn, site)
html = lv |> element("form") |> render_submit()
assert html =~ "this field is required and cannot be blank"
pageview_tab = lv |> element(~s/a#pageview-tab/) |> render_click()
assert pageview_tab =~ "this field is required and must start with a /"
end
test "creates a custom event", %{conn: conn, site: site} do
{parent, lv} = get_liveview(conn, site, with_parent?: true)
refute render(parent) =~ "Foo"
lv |> element("form") |> render_submit(%{goal: %{event_name: "Foo"}})
parent_html = render(parent)
assert parent_html =~ "Foo"
assert parent_html =~ "Custom Event"
end
test "creates a revenue goal", %{conn: conn, site: site} do
{parent, lv} = get_liveview(conn, site, with_parent?: true)
refute render(parent) =~ "Foo"
lv |> element("form") |> render_submit(%{goal: %{event_name: "Foo", currency: "EUR"}})
parent_html = render(parent)
assert parent_html =~ "Foo"
assert parent_html =~ "Revenue Goal: EUR"
end
test "creates a pageview goal", %{conn: conn, site: site} do
{parent, lv} = get_liveview(conn, site, with_parent?: true)
refute render(parent) =~ "Foo"
lv |> element("form") |> render_submit(%{goal: %{page_path: "/page/**"}})
parent_html = render(parent)
assert parent_html =~ "Visit /page/**"
assert parent_html =~ "Pageview"
end
end
describe "Combos integration" do
setup [:create_user, :log_in, :create_site]
test "currency combo works", %{conn: conn, site: site} do
lv = get_liveview(conn, site)
# Account for asynchronous updates. Here, the options, while static,
# are still a sizeable payload that we can defer before the user manages
# to click "this is revenue goal" switch.
# There's also throttling applied to the ComboBox.
:timer.sleep(200)
type_into_combo(lv, "currency_input", "Polish")
:timer.sleep(200)
html = render(lv)
assert element_exists?(html, ~s/a[phx-value-display-value="PLN - Polish Zloty"]/)
refute element_exists?(html, ~s/a[phx-value-display-value="EUR - Euro"]/)
type_into_combo(lv, "currency_input", "Euro")
:timer.sleep(200)
html = render(lv)
refute element_exists?(html, ~s/a[phx-value-display-value="PLN - Polish Zloty"]/)
assert element_exists?(html, ~s/a[phx-value-display-value="EUR - Euro"]/)
end
test "pageview combo works", %{conn: conn, site: site} do
lv = get_liveview(conn, site)
lv |> element(~s/a#pageview-tab/) |> render_click()
html = type_into_combo(lv, "page_path_input", "/hello")
assert html =~ "Create &quot;/hello&quot;"
end
test "pageview combo uses filter suggestions", %{conn: conn, site: site} do
populate_stats(site, [
build(:pageview, pathname: "/go/to/page/1"),
build(:pageview, pathname: "/go/home")
])
lv = get_liveview(conn, site)
lv |> element(~s/a#pageview-tab/) |> render_click()
type_into_combo(lv, "page_path_input", "/go/to/p")
# Account some large-ish margin for Clickhouse latency when providing suggestions asynchronously
# Might be too much, but also not enough on sluggish CI
:timer.sleep(350)
html = render(lv)
assert html =~ "Create &quot;/go/to/p&quot;"
assert html =~ "/go/to/page/1"
refute html =~ "/go/home"
type_into_combo(lv, "page_path_input", "/go/h")
:timer.sleep(350)
html = render(lv)
assert html =~ "/go/home"
refute html =~ "/go/to/page/1"
end
end
defp type_into_combo(lv, id, text) do
lv
|> element("input##{id}")
|> render_change(%{
"_target" => ["display-#{id}"],
"display-#{id}" => "#{text}"
})
end
defp setup_goals(site) do
{:ok, g1} = Plausible.Goals.create(site, %{"page_path" => "/go/to/blog/**"})
{:ok, g2} = Plausible.Goals.create(site, %{"event_name" => "Signup"})
{:ok, g3} = Plausible.Goals.create(site, %{"event_name" => "Purchase", "currency" => "EUR"})
{:ok, [g1, g2, g3]}
end
defp get_liveview(conn, site, opts \\ []) do
conn = assign(conn, :live_module, PlausibleWeb.Live.GoalSettings)
{:ok, lv, _html} = live(conn, "/#{site.domain}/settings/goals")
lv |> element(~s/button[phx-click="add-goal"]/) |> render_click()
assert form_view = find_live_child(lv, "goals-form")
if opts[:with_parent?] do
{lv, form_view}
else
form_view
end
end
end

View File

@ -0,0 +1,177 @@
defmodule PlausibleWeb.Live.GoalSettingsTest do
use PlausibleWeb.ConnCase, async: true
import Phoenix.LiveViewTest
import Plausible.Test.Support.HTML
describe "GET /:website/settings/goals" do
setup [:create_user, :log_in, :create_site]
test "lists goals for the site and renders links", %{conn: conn, site: site} do
{:ok, [g1, g2, g3]} = setup_goals(site)
conn = get(conn, "/#{site.domain}/settings/goals")
resp = html_response(conn, 200)
assert resp =~ "Define actions that you want your users to take"
assert resp =~ "compose goals into funnels"
assert resp =~ "/#{site.domain}/settings/funnels"
assert element_exists?(resp, ~s|a[href="https://plausible.io/docs/goal-conversions"]|)
assert resp =~ to_string(g1)
assert resp =~ "Pageview"
assert resp =~ to_string(g2)
assert resp =~ "Custom Event"
assert resp =~ to_string(g3)
assert resp =~ "Revenue Goal: EUR"
end
test "lists goals with delete actions", %{conn: conn, site: site} do
{:ok, goals} = setup_goals(site)
conn = get(conn, "/#{site.domain}/settings/goals")
resp = html_response(conn, 200)
for g <- goals do
assert element_exists?(
resp,
~s/button[phx-click="delete-goal"][phx-value-goal-id=#{g.id}]#delete-goal-#{g.id}/
)
end
end
test "if no goals are present, a proper info is displayed", %{conn: conn, site: site} do
conn = get(conn, "/#{site.domain}/settings/goals")
resp = html_response(conn, 200)
assert resp =~ "No goals configured for this site"
end
test "if goals are present, no info about missing goals is displayed", %{
conn: conn,
site: site
} do
{:ok, _goals} = setup_goals(site)
conn = get(conn, "/#{site.domain}/settings/goals")
resp = html_response(conn, 200)
refute resp =~ "No goals configured for this site"
end
test "add goal button is rendered", %{conn: conn, site: site} do
conn = get(conn, "/#{site.domain}/settings/goals")
resp = html_response(conn, 200)
assert element_exists?(resp, ~s/button[phx-click="add-goal"]/)
end
test "search goals input is rendered", %{conn: conn, site: site} do
conn = get(conn, "/#{site.domain}/settings/goals")
resp = html_response(conn, 200)
assert element_exists?(resp, ~s/input[type="text"]#filter-text/)
assert element_exists?(resp, ~s/form[phx-change="filter"]#filter-form/)
end
end
describe "GoalSettings live view" do
setup [:create_user, :log_in, :create_site]
test "allows goal deletion", %{conn: conn, site: site} do
{:ok, [g1, g2 | _]} = setup_goals(site)
{lv, html} = get_liveview(conn, site, with_html?: true)
assert html =~ to_string(g1)
assert html =~ to_string(g2)
html = lv |> element(~s/button#delete-goal-#{g1.id}/) |> render_click()
refute html =~ to_string(g1)
assert html =~ to_string(g2)
html = get(conn, "/#{site.domain}/settings/goals") |> html_response(200)
refute html =~ to_string(g1)
assert html =~ to_string(g2)
end
test "allows list filtering / search", %{conn: conn, site: site} do
{:ok, [g1, g2, g3]} = setup_goals(site)
{lv, html} = get_liveview(conn, site, with_html?: true)
assert html =~ to_string(g1)
assert html =~ to_string(g2)
assert html =~ to_string(g3)
html = type_into_search(lv, to_string(g3))
refute html =~ to_string(g1)
refute html =~ to_string(g2)
assert html =~ to_string(g3)
end
test "allows resetting filter text via backspace icon", %{conn: conn, site: site} do
{:ok, [g1, g2, g3]} = setup_goals(site)
{lv, html} = get_liveview(conn, site, with_html?: true)
refute element_exists?(html, ~s/svg[phx-click="reset-filter-text"]#reset-filter/)
html = type_into_search(lv, to_string(g3))
assert element_exists?(html, ~s/svg[phx-click="reset-filter-text"]#reset-filter/)
html = lv |> element(~s/svg#reset-filter/) |> render_click()
assert html =~ to_string(g1)
assert html =~ to_string(g2)
assert html =~ to_string(g3)
end
test "allows resetting filter text via no match link", %{conn: conn, site: site} do
{:ok, _goals} = setup_goals(site)
lv = get_liveview(conn, site)
html = type_into_search(lv, "Definitely this is not going to render any matches")
assert html =~ "No goals found for this site. Please refine or"
assert html =~ "reset your search"
assert element_exists?(html, ~s/a[phx-click="reset-filter-text"]#reset-filter-hint/)
html = lv |> element(~s/a#reset-filter-hint/) |> render_click()
refute html =~ "No goals found for this site. Please refine or"
end
test "clicking Add Goal button renders the form view", %{conn: conn, site: site} do
{:ok, _goals} = setup_goals(site)
lv = get_liveview(conn, site)
html = lv |> element(~s/button[phx-click="add-goal"]/) |> render_click()
assert html =~ "Add goal for #{site.domain}"
assert element_exists?(
html,
~s/div#goals-form form[phx-submit="save-goal"][phx-click-away="cancel-add-goal"]/
)
end
end
defp setup_goals(site) do
{:ok, g1} = Plausible.Goals.create(site, %{"page_path" => "/go/to/blog/**"})
{:ok, g2} = Plausible.Goals.create(site, %{"event_name" => "Signup"})
{:ok, g3} = Plausible.Goals.create(site, %{"event_name" => "Purchase", "currency" => "EUR"})
{:ok, [g1, g2, g3]}
end
defp get_liveview(conn, site, opts \\ []) do
conn = assign(conn, :live_module, PlausibleWeb.Live.GoalSettings)
{:ok, lv, html} = live(conn, "/#{site.domain}/settings/goals")
if Keyword.get(opts, :with_html?) do
{lv, html}
else
lv
end
end
defp type_into_search(lv, text) do
lv
|> element("form#filter-form")
|> render_change(%{
"_target" => ["filter-text"],
"filter-text" => "#{text}"
})
end
end

View File

@ -37,4 +37,8 @@ defmodule Plausible.Test.Support.HTML do
|> Floki.text() |> Floki.text()
|> String.trim() |> String.trim()
end end
def name_of(element) do
List.first(Floki.attribute(element, "name"))
end
end end