mirror of
https://github.com/plausible/analytics.git
synced 2025-01-03 15:17:58 +03:00
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:
parent
f740cc899f
commit
b3ff695797
@ -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
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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()
|
||||||
|
@ -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,
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
110
lib/plausible_web/live/components/form.ex
Normal file
110
lib/plausible_web/live/components/form.ex
Normal 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
|
@ -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>
|
||||||
|
@ -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)}
|
||||||
/>
|
/>
|
||||||
|
123
lib/plausible_web/live/goal_settings.ex
Normal file
123
lib/plausible_web/live/goal_settings.ex
Normal 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
|
273
lib/plausible_web/live/goal_settings/form.ex
Normal file
273
lib/plausible_web/live/goal_settings/form.ex
Normal 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
|
120
lib/plausible_web/live/goal_settings/list.ex
Normal file
120
lib/plausible_web/live/goal_settings/list.ex
Normal 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
|
@ -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
|
||||||
|
@ -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,
|
||||||
|
@ -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>
|
|
@ -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>
|
||||||
|
3
mix.exs
3
mix.exs
@ -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
|
||||||
|
|
||||||
|
1
mix.lock
1
mix.lock
@ -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"},
|
||||||
|
@ -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 "})
|
||||||
|
@ -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"))}
|
||||||
|
@ -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
|
||||||
)
|
)
|
||||||
|
@ -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
|
||||||
|
182
test/plausible_web/live/goal_settings/form_test.exs
Normal file
182
test/plausible_web/live/goal_settings/form_test.exs
Normal 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 "/hello""
|
||||||
|
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 "/go/to/p""
|
||||||
|
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
|
177
test/plausible_web/live/goal_settings_test.exs
Normal file
177
test/plausible_web/live/goal_settings_test.exs
Normal 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
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user