diff --git a/CHANGELOG.md b/CHANGELOG.md index 15ffe4ea81..74872de1b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 - 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) +- Improved the Goal Settings page (search, autcompletion etc.) ## v2.0.0 - 2023-07-12 diff --git a/lib/plausible/goal/schema.ex b/lib/plausible/goal/schema.ex index 98a0bc78a9..17ad1123ba 100644 --- a/lib/plausible/goal/schema.ex +++ b/lib/plausible/goal/schema.ex @@ -27,10 +27,10 @@ defmodule Plausible.Goal do def currency_options do options = for code <- valid_currencies() do - {"#{code} - #{Cldr.Currency.display_name!(code)}", code} + {code, "#{code} - #{Cldr.Currency.display_name!(code)}"} end - [{"Select reporting currency", nil}] ++ options + options end def changeset(goal, attrs \\ %{}) do @@ -38,6 +38,7 @@ defmodule Plausible.Goal do |> cast(attrs, [:id, :site_id, :event_name, :page_path, :currency]) |> validate_required([:site_id]) |> cast_assoc(:site) + |> update_leading_slash() |> validate_event_name_and_page_path() |> update_change(:event_name, &String.trim/1) |> update_change(:page_path, &String.trim/1) @@ -45,6 +46,19 @@ defmodule Plausible.Goal do |> maybe_drop_currency() 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 if validate_page_path(changeset) || validate_event_name(changeset) do changeset diff --git a/lib/plausible/goals.ex b/lib/plausible/goals.ex index 5e1c3827dc..1d5cfebcc3 100644 --- a/lib/plausible/goals.ex +++ b/lib/plausible/goals.ex @@ -118,14 +118,18 @@ defmodule Plausible.Goals do Otherwise, for associated funnel(s) consisting of minimum number steps only, 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 = Multi.new() |> Multi.one( :goal, from(g in Goal, where: g.id == ^id, - where: g.site_id == ^site.id, + where: g.site_id == ^site_id, preload: [funnels: :steps] ) ) @@ -162,7 +166,7 @@ defmodule Plausible.Goals do fn _ -> from g in Goal, where: g.id == ^id, - where: g.site_id == ^site.id + where: g.site_id == ^site_id end ) |> Repo.transaction() diff --git a/lib/plausible/sites.ex b/lib/plausible/sites.ex index e544b60813..122e086fae 100644 --- a/lib/plausible/sites.ex +++ b/lib/plausible/sites.ex @@ -8,6 +8,10 @@ defmodule Plausible.Sites do Repo.get_by(Site, domain: domain) end + def get_by_domain!(domain) do + Repo.get_by!(Site, domain: domain) + end + def create(user, params) do site_changeset = Site.changeset(%Site{}, params) @@ -93,11 +97,25 @@ defmodule Plausible.Sites do base <> domain <> "?auth=" <> link.slug end - def get_for_user!(user_id, domain, roles \\ [:owner, :admin, :viewer]), - do: Repo.one!(get_for_user_q(user_id, domain, roles)) + def get_for_user!(user_id, domain, roles \\ [:owner, :admin, :viewer]) do + 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]), - do: Repo.one(get_for_user_q(user_id, domain, roles)) + def get_for_user(user_id, domain, roles \\ [:owner, :admin, :viewer]) do + 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 from(s in Site, diff --git a/lib/plausible_web/controllers/site_controller.ex b/lib/plausible_web/controllers/site_controller.ex index 95b99daea9..329a6ca739 100644 --- a/lib/plausible_web/controllers/site_controller.ex +++ b/lib/plausible_web/controllers/site_controller.ex @@ -121,53 +121,6 @@ defmodule PlausibleWeb.SiteController do ) 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 %{ funnels_enabled: "Funnels", conversions_enabled: "Goals", @@ -270,6 +223,7 @@ defmodule PlausibleWeb.SiteController do |> render("settings_goals.html", site: site, goals: goals, + connect_live_socket: true, layout: {PlausibleWeb.LayoutView, "site_settings.html"} ) end diff --git a/lib/plausible_web/live/components/combo_box.ex b/lib/plausible_web/live/components/combo_box.ex index 136f4a35d3..304175908b 100644 --- a/lib/plausible_web/live/components/combo_box.ex +++ b/lib/plausible_web/live/components/combo_box.ex @@ -14,8 +14,19 @@ defmodule PlausibleWeb.Live.Components.ComboBox do field, the component searches the available options and provides suggestions based on the input. - Any module exposing suggest/2 function can be supplied via `suggest_mod` - attribute - see the provided `ComboBox.StaticSearch`. + Any function can be supplied via `suggest_fun` attribute + - 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 alias Phoenix.LiveView.JS @@ -23,36 +34,40 @@ defmodule PlausibleWeb.Live.Components.ComboBox do @default_suggestions_limit 15 def update(assigns, socket) do - assigns = - if assigns[:suggestions] do - Map.put(assigns, :suggestions, Enum.take(assigns.suggestions, suggestions_limit(assigns))) - else - assigns - end + socket = assign(socket, assigns) socket = - socket - |> assign(assigns) - |> assign_new(:suggestions, fn -> - Enum.take(assigns.options, suggestions_limit(assigns)) - end) + if connected?(socket) do + socket + |> assign_options() + |> assign_suggestions() + else + socket + end {:ok, socket} end attr(:placeholder, :string, default: "Select option or search by typing") attr(:id, :any, required: true) - attr(:options, :list, required: true) + attr(:options, :list, default: []) attr(:submit_name, :string, required: true) attr(:display_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(:class, :string, default: "") attr(:required, :boolean, default: false) attr(:creatable, :boolean, default: false) + attr(:errors, :list, default: []) + attr(:async, :boolean, default: false) def render(assigns) do + assigns = + assign_new(assigns, :suggestions, fn -> + Enum.take(assigns.options, suggestions_limit(assigns)) + end) + ~H"""
- - <.dropdown - ref={@id} - suggest_mod={@suggest_mod} - suggestions={@suggestions} - target={@myself} - creatable={@creatable} - display_value={@display_value} - /> + <.dropdown + ref={@id} + suggest_fun={@suggest_fun} + suggestions={@suggestions} + target={@myself} + creatable={@creatable} + display_value={@display_value} + /> + """ end @@ -133,7 +149,7 @@ defmodule PlausibleWeb.Live.Components.ComboBox do attr(:ref, :string, required: true) attr(:suggestions, :list, default: []) - attr(:suggest_mod, :atom, required: true) + attr(:suggest_fun, :any, required: true) attr(:target, :any) attr(:creatable, :boolean, required: true) attr(:display_value, :string, required: true) @@ -145,8 +161,18 @@ defmodule PlausibleWeb.Live.Components.ComboBox do id={"dropdown-#{@ref}"} x-show="isOpen" 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 :for={ {{submit_value, display_value}, idx} <- @@ -163,16 +189,6 @@ defmodule PlausibleWeb.Live.Components.ComboBox do ref={@ref} /> - <.option - :if={display_creatable_option?(assigns)} - idx={length(@suggestions)} - submit_value={@display_value} - display_value={@display_value} - target={@target} - ref={@ref} - creatable - /> -
[target]} = params, - %{assigns: %{suggest_mod: suggest_mod, options: options}} = socket + %{assigns: %{options: options}} = socket ) do input = params[target] + input_len = input |> String.trim() |> String.length() socket = @@ -253,7 +270,7 @@ defmodule PlausibleWeb.Live.Components.ComboBox do suggestions = if input_len > 0 do - suggest_mod.suggest(input, options) + run_suggest_fun(input, options, socket.assigns, :suggestions) else options end @@ -296,4 +313,46 @@ defmodule PlausibleWeb.Live.Components.ComboBox do assigns.creatable && not empty_input? && not input_matches_suggestion? 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 diff --git a/lib/plausible_web/live/components/combo_box/static_search.ex b/lib/plausible_web/live/components/combo_box/static_search.ex index 4db7860bfd..8ec4c75592 100644 --- a/lib/plausible_web/live/components/combo_box/static_search.ex +++ b/lib/plausible_web/live/components/combo_box/static_search.ex @@ -10,12 +10,26 @@ defmodule PlausibleWeb.Live.Components.ComboBox.StaticSearch do """ @spec suggest(String.t(), [{any(), any()}]) :: [{any(), any()}] - def suggest(input, options) do - options - |> Enum.map(fn {_, value} = option -> {option, weight(value, input)} end) - |> Enum.reject(fn {_option, weight} -> weight < 0.6 end) - |> Enum.sort_by(fn {_option, weight} -> weight end, :desc) - |> Enum.map(fn {option, _weight} -> option end) + def suggest(input, choices, opts \\ []) do + input = String.trim(input) + + if input != "" do + weight_threshold = Keyword.get(opts, :weight_threshold, 0.6) + + 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 defp weight(value, input) do diff --git a/lib/plausible_web/live/components/form.ex b/lib/plausible_web/live/components/form.ex new file mode 100644 index 0000000000..4d672d2639 --- /dev/null +++ b/lib/plausible_web/live/components/form.ex @@ -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""" +
+ <.label for={@id}> + <%= @label %> + + + <.error :for={msg <- @errors}> + <%= msg %> + +
+ """ + 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""" +
+ <.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} + /> +
+ """ + 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 diff --git a/lib/plausible_web/live/goal_settings/form.ex b/lib/plausible_web/live/goal_settings/form.ex new file mode 100644 index 0000000000..4c33169caa --- /dev/null +++ b/lib/plausible_web/live/goal_settings/form.ex @@ -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""" +
+
+
+
+ <.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" + > +

Add goal for <%= @domain %>

+ + <.tabs tabs={@tabs} /> + + <.custom_event_fields :if={@tabs.custom_events} f={f} /> + <.pageview_fields :if={@tabs.pageviews} f={f} site={@site} /> + +
+ +
+ +
+
+ """ + end + + attr(:f, Phoenix.HTML.Form) + attr(:site, Plausible.Site) + + def pageview_fields(assigns) do + ~H""" +
+ <.label for="page_path_input"> + Page path + + + <.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) %> + +
+ """ + 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. +
+ +
+ <.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" + /> +
+ +
+
+ + + Enable revenue tracking + +
+ +
+

+ 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. +

+
+ +
+ <.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} + /> +
+
+
+
+ """ + end + + def tabs(assigns) do + ~H""" +
Goal trigger
+
+ <.custom_events_tab tabs={@tabs} /> + <.pageviews_tab tabs={@tabs} /> +
+ """ + end + + defp custom_events_tab(assigns) do + ~H""" + + Custom event + + """ + end + + def pageviews_tab(assigns) do + ~H""" + + Pageview + + """ + 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 diff --git a/lib/plausible_web/live/goal_settings/list.ex b/lib/plausible_web/live/goal_settings/list.ex new file mode 100644 index 0000000000..00fdabee7e --- /dev/null +++ b/lib/plausible_web/live/goal_settings/list.ex @@ -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""" +
+
+
+
+
+
+ +
+ +
+ + +
+
+
+ +
+
+ <%= if Enum.count(@goals) > 0 do %> +
+ <%= for goal <- @goals do %> +
+ +
+ + <%= goal %> +
+ + Pageview + Custom Event + + Revenue Goal: <%= goal.currency %> + + - belongs to funnel(s) + +
+
+
+ +
+ <% end %> +
+ <% else %> +

+ + 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. -
- -
- <%= 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) %> -
- -
-
- - - Enable revenue tracking - -
- -
-

- 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. -

-
- -
- <%= 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) %> -
-
-
- -
- - <%= submit("Add goal →", class: "button text-base font-bold w-full") %> -<% end %> - - diff --git a/lib/plausible_web/templates/site/settings_goals.html.heex b/lib/plausible_web/templates/site/settings_goals.html.heex index 1ce801c17a..064578ac67 100644 --- a/lib/plausible_web/templates/site/settings_goals.html.heex +++ b/lib/plausible_web/templates/site/settings_goals.html.heex @@ -1,8 +1,14 @@

Goals

-

- 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. +

+

+ You can also compose goals into funnels.

<%= 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