+ """
+ end
+
+ @doc """
+ Renders a label.
+ """
+ attr(:for, :string, default: nil)
+ slot(:inner_block, required: true)
+
+ def label(assigns) do
+ ~H"""
+
+ """
+ end
+
+ @doc """
+ Generates a generic error message.
+ """
+ slot(:inner_block, required: true)
+
+ def error(assigns) do
+ ~H"""
+
+ <%= render_slot(@inner_block) %>
+
+ """
+ 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
diff --git a/lib/plausible_web/live/funnel_settings.ex b/lib/plausible_web/live/funnel_settings.ex
index 9de1f4b4a5..f4fa339ff7 100644
--- a/lib/plausible_web/live/funnel_settings.ex
+++ b/lib/plausible_web/live/funnel_settings.ex
@@ -16,12 +16,7 @@ defmodule PlausibleWeb.Live.FunnelSettings do
) do
true = Plausible.Funnels.enabled_for?("user:#{user_id}")
- site =
- 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
+ site = Sites.get_for_user!(user_id, domain, [:owner, :admin, :superadmin])
funnels = Funnels.list(site)
goal_count = Goals.count(site)
@@ -67,7 +62,7 @@ defmodule PlausibleWeb.Live.FunnelSettings do
You need to define at least two goals to create a funnel. Go ahead and <%= link(
"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"
) %> to proceed.
diff --git a/lib/plausible_web/live/funnel_settings/form.ex b/lib/plausible_web/live/funnel_settings/form.ex
index 5cd8d2c1d1..1f10de59ae 100644
--- a/lib/plausible_web/live/funnel_settings/form.ex
+++ b/lib/plausible_web/live/funnel_settings/form.ex
@@ -12,12 +12,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
alias Plausible.{Sites, Goals}
def mount(_params, %{"current_user_id" => user_id, "domain" => domain}, socket) do
- site =
- 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
+ site = Sites.get_for_user!(user_id, domain, [:owner, :admin, :superadmin])
# 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{}
@@ -69,7 +64,7 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
<.live_component
submit_name="funnel[steps][][goal_id]"
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}"}
options={reject_alrady_selected("step-#{step_idx}", @goals, @selections_made)}
/>
diff --git a/lib/plausible_web/live/goal_settings.ex b/lib/plausible_web/live/goal_settings.ex
new file mode 100644
index 0000000000..0add240f67
--- /dev/null
+++ b/lib/plausible_web/live/goal_settings.ex
@@ -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"""
+
+ """
+ end
+
+ attr(:f, Phoenix.HTML.Form)
+
+ def custom_event_fields(assigns) do
+ ~H"""
+
+
+
+ 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 our docs.
+
+ 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 our docs.
+
+
+ No goals found for this site. Please refine or
+
+ reset your search.
+
+
+
+ No goals configured for this site.
+
+
+ <% end %>
+
+ """
+ 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
diff --git a/lib/plausible_web/live/props_settings.ex b/lib/plausible_web/live/props_settings.ex
index fc2cf532e6..78d352ea27 100644
--- a/lib/plausible_web/live/props_settings.ex
+++ b/lib/plausible_web/live/props_settings.ex
@@ -54,7 +54,7 @@ defmodule PlausibleWeb.Live.PropsSettings do
submit_name="prop"
class="flex-1"
module={ComboBox}
- suggest_mod={ComboBox.StaticSearch}
+ suggest_fun={&ComboBox.StaticSearch.suggest/2}
options={@suggestions}
required
creatable
diff --git a/lib/plausible_web/router.ex b/lib/plausible_web/router.ex
index bdd98ef6f2..2226ff50af 100644
--- a/lib/plausible_web/router.ex
+++ b/lib/plausible_web/router.ex
@@ -155,11 +155,6 @@ defmodule PlausibleWeb.Router do
post "/share/:slug/authenticate", StatsController, :authenticate_shared_link
end
- scope "/:website/settings/funnels/", PlausibleWeb do
- pipe_through [:browser, :csrf]
- get "/", SiteController, :settings_funnels
- end
-
scope "/", PlausibleWeb do
pipe_through [:browser, :csrf]
@@ -259,14 +254,12 @@ defmodule PlausibleWeb.Router do
get "/:website/settings/visibility", SiteController, :settings_visibility
get "/:website/settings/goals", SiteController, :settings_goals
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/email-reports", SiteController, :settings_email_reports
get "/:website/settings/custom-domain", SiteController, :settings_custom_domain
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",
SiteController,
diff --git a/lib/plausible_web/templates/site/new_goal.html.heex b/lib/plausible_web/templates/site/new_goal.html.heex
deleted file mode 100644
index 2dbef121c6..0000000000
--- a/lib/plausible_web/templates/site/new_goal.html.heex
+++ /dev/null
@@ -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 -> %>
-
Add goal for <%= @site.domain %>
-
Goal trigger
-
-
- Custom event
-
-
- Pageview
-
-
-
-
-
- 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 our docs.
-
- 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 our docs.
-
<%= link(to: "https://plausible.io/docs/goal-conversions", target: "_blank", rel: "noreferrer") do %>
@@ -28,69 +34,8 @@
label="Show goals in the dashboard"
conn={@conn}
>
- <%= if Enum.count(@goals) > 0 do %>
-
- <%= for goal <- @goals do %>
-
-
- <%= goal %>
-
-
- <%= 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 %>
-
- <% 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 %>
-
- <% end %>
- <% end %>
-
- <% end %>
-
- <% else %>
-
No goals configured for this site yet
- <% end %>
-
- <%= link("+ Add goal",
- to: "/#{URI.encode_www_form(@site.domain)}/goals/new",
- class: "button mt-6"
+ <%= live_render(@conn, PlausibleWeb.Live.GoalSettings,
+ session: %{"site_id" => @site.id, "domain" => @site.domain}
) %>
- <%= 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 %>
diff --git a/mix.exs b/mix.exs
index e08bc6b30a..8958735d52 100644
--- a/mix.exs
+++ b/mix.exs
@@ -120,7 +120,8 @@ defmodule Plausible.MixProject do
{:ex_doc, "~> 0.28", only: :dev, runtime: false},
{:ex_money, "~> 5.12"},
{:mjml_eex, "~> 0.9.0"},
- {:mjml, "~> 1.5.0"}
+ {:mjml, "~> 1.5.0"},
+ {:heroicons, "~> 0.5.0"}
]
end
diff --git a/mix.lock b/mix.lock
index 6634da11c0..5399bac8ec 100644
--- a/mix.lock
+++ b/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"},
"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"},
+ "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"},
"hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"},
"html_entities": {:hex, :html_entities, "0.5.2", "9e47e70598da7de2a9ff6af8758399251db6dbb7eebe2b013f2bbd2515895c3c", [:mix], [], "hexpm", "c53ba390403485615623b9531e97696f076ed415e8d8058b1dbaa28181f4fdcc"},
diff --git a/test/plausible/goals_test.exs b/test/plausible/goals_test.exs
index 54f026a55c..7cf9d27234 100644
--- a/test/plausible/goals_test.exs
+++ b/test/plausible/goals_test.exs
@@ -3,7 +3,7 @@ defmodule Plausible.GoalsTest do
alias Plausible.Goals
- test "create/2 trims input" do
+ test "create/2 creates goals and trims input" do
site = insert(:site)
{:ok, goal} = Goals.create(site, %{"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"
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
site = insert(:site)
assert {:error, changeset} = Goals.create(site, %{"event_name" => String.duplicate("a", 130)})
@@ -32,7 +38,32 @@ defmodule Plausible.GoalsTest do
:eq
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)
insert(:goal, %{site: site, event_name: " Signup "})
insert(:goal, %{site: site, page_path: " /Signup "})
diff --git a/test/plausible_web/controllers/site_controller_test.exs b/test/plausible_web/controllers/site_controller_test.exs
index b022d00d72..34581205b4 100644
--- a/test/plausible_web/controllers/site_controller_test.exs
+++ b/test/plausible_web/controllers/site_controller_test.exs
@@ -621,131 +621,6 @@ defmodule PlausibleWeb.SiteControllerTest do
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
def build_conn_with_some_url(context) do
{:ok, Map.put(context, :conn, build_conn(:get, "/some_parent_path"))}
diff --git a/test/plausible_web/live/components/combo_box_test.exs b/test/plausible_web/live/components/combo_box_test.exs
index 70ff9ca46e..d2d2c43b7f 100644
--- a/test/plausible_web/live/components/combo_box_test.exs
+++ b/test/plausible_web/live/components/combo_box_test.exs
@@ -154,7 +154,7 @@ defmodule PlausibleWeb.Live.Components.ComboBoxTest do
<.live_component
submit_name="some_submit_name"
module={PlausibleWeb.Live.Components.ComboBox}
- suggest_mod={__MODULE__.SampleSuggest}
+ suggest_fun={&SampleSuggest.suggest/2}
id="test-component"
options={for i <- 1..20, do: {i, "Option #{i}"}}
suggestions_limit={7}
@@ -210,7 +210,7 @@ defmodule PlausibleWeb.Live.Components.ComboBoxTest do
<.live_component
submit_name="some_submit_name"
module={PlausibleWeb.Live.Components.ComboBox}
- suggest_mod={ComboBox.StaticSearch}
+ suggest_fun={&ComboBox.StaticSearch.suggest/2}
id="test-creatable-component"
options={for i <- 1..20, do: {i, "Option #{i}"}}
creatable
@@ -274,6 +274,70 @@ defmodule PlausibleWeb.Live.Components.ComboBoxTest do
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
render_component(
ComboBox,
@@ -282,7 +346,7 @@ defmodule PlausibleWeb.Live.Components.ComboBoxTest do
options: options,
submit_name: "test-submit-name",
id: "test-component",
- suggest_mod: ComboBox.StaticSearch
+ suggest_fun: &ComboBox.StaticSearch.suggest/2
],
extra_opts
)
diff --git a/test/plausible_web/live/funnel_settings_test.exs b/test/plausible_web/live/funnel_settings_test.exs
index 9bb89cf7df..618261daf6 100644
--- a/test/plausible_web/live/funnel_settings_test.exs
+++ b/test/plausible_web/live/funnel_settings_test.exs
@@ -51,12 +51,12 @@ defmodule PlausibleWeb.Live.FunnelSettingsTest do
doc = conn |> html_response(200)
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}"]/)
end
end
- describe "FunnelSettings component" do
+ describe "FunnelSettings live view" do
setup [:create_user, :log_in, :create_site]
test "allows to delete funnels", %{conn: conn, site: site} do
diff --git a/test/plausible_web/live/goal_settings/form_test.exs b/test/plausible_web/live/goal_settings/form_test.exs
new file mode 100644
index 0000000000..d0aaca8ef1
--- /dev/null
+++ b/test/plausible_web/live/goal_settings/form_test.exs
@@ -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
diff --git a/test/plausible_web/live/goal_settings_test.exs b/test/plausible_web/live/goal_settings_test.exs
new file mode 100644
index 0000000000..e90351ae0a
--- /dev/null
+++ b/test/plausible_web/live/goal_settings_test.exs
@@ -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
diff --git a/test/support/html.ex b/test/support/html.ex
index 4623eb7b1b..9c59cdcf9d 100644
--- a/test/support/html.ex
+++ b/test/support/html.ex
@@ -37,4 +37,8 @@ defmodule Plausible.Test.Support.HTML do
|> Floki.text()
|> String.trim()
end
+
+ def name_of(element) do
+ List.first(Floki.attribute(element, "name"))
+ end
end