Edit goals with display names (#4415)

* Update Goal schema

* Equip ComboBox with the ability of JS selection callbacks

* Update factory so display_name is always present

* Extend Goals context interface

* Update seeds

Also farming unsuspecting BEAM programmers for better
sample page paths :)

* Update ComboBox test

* Unify error message color class with helpers seen elsewhere

* Use goal.display_name where applicable

* Implement LiveView extensions for editing goals

* Sprinkle display name in external stats controller tests

* Format

* Fix goal list mobile view

* Update lib/plausible_web/live/goal_settings/list.ex

Co-authored-by: Artur Pata <artur.pata@gmail.com>

* Update lib/plausible_web/live/goal_settings/form.ex

Co-authored-by: Artur Pata <artur.pata@gmail.com>

* Update the APIs: plugins and external

* Update test so the intent is clearer

* Format

* Update CHANGELOG

* Simplify form tabs tests

* Revert "Format"

This reverts commit c1647b5307.

* Fixup format commit that went too far

* ComboBox: select the input contents on first focus

* Update lib/plausible/goal/schema.ex

Co-authored-by: Adrian Gruntkowski <adrian.gruntkowski@gmail.com>

* Update lib/plausible/goals/goals.ex

Co-authored-by: Adrian Gruntkowski <adrian.gruntkowski@gmail.com>

* Update lib/plausible_web/live/goal_settings/form.ex

Co-authored-by: Adrian Gruntkowski <adrian.gruntkowski@gmail.com>

* Pass form goal instead of just ID

* Make tab component dumber

* Extract separate render functions for edit and create forms

* Update test to account for extracted forms

* Inline goal get query

* Extract revenue goal settings to a component and avoid computing assigns in flight

* Make LV modal preload optional

* Disable preload for goal settings form modal

* Get rid of phash component ID hack

* For another render after render_submit when testing goal updates

* Fix LV preload option

* Enable preload back for goals modal for now

* Make formatter happy

* Implement support for preopening of LV modal

* Preopen goals modal to avoid feedback gap on loading edited goal

* Remove `console.log` call from modal JS

* Clean up display name input IDs

* Make revenue settings functional on first edit again

* Display names: 2nd stage migration

* Update migration with data backfill

---------

Co-authored-by: Artur Pata <artur.pata@gmail.com>
Co-authored-by: Adrian Gruntkowski <adrian.gruntkowski@gmail.com>
This commit is contained in:
hq1 2024-08-09 12:12:00 +03:00 committed by GitHub
parent 2e6ba4ba42
commit cc769dfb3d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 869 additions and 273 deletions

View File

@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file.
## Unreleased
### Added
- UI to edit goals along with display names
- Support contains filter for goals
- UI to edit funnels
- Add Details views for browsers, browser versions, os-s, os versions, and screen sizes reports

View File

@ -6,12 +6,17 @@ export default (id) => ({
id: id,
focus: null,
selectionInProgress: false,
firstFocusRegistered: false,
setFocus(f) {
this.focus = f;
},
initFocus() {
if (this.focus === null) {
this.setFocus(this.leastFocusableIndex())
if (!this.firstFocusRegistered) {
document.getElementById(this.id).select();
this.firstFocusRegistered = true;
}
}
},
trackSubmitValueChange() {

View File

@ -11,3 +11,8 @@ window.addEventListener(`phx:js-exec`, ({ detail }) => {
window.liveSocket.execJS(el, el.getAttribute(detail.attr))
})
})
window.addEventListener(`phx:notify-selection-change`, (event) => {
let el = document.getElementById(event.detail.id)
el.dispatchEvent(new CustomEvent("selection-change", { detail: event.detail }))
})

View File

@ -14,9 +14,18 @@ defmodule Plausible.Goal.Revenue do
def currency_options() do
options =
for code <- valid_currencies() do
{code, "#{code} - #{Cldr.Currency.display_name!(code)}"}
{code, display(code)}
end
options
end
def currency_option(code) do
true = "#{code}" in valid_currencies()
{"#{code}", display(code)}
end
def display(code) do
"#{code} - #{Cldr.Currency.display_name!(code)}"
end
end

View File

@ -41,7 +41,7 @@ defmodule PlausibleWeb.Api.ExternalSitesController do
Enum.map(page.entries, fn goal ->
%{
id: goal.id,
display_name: Goal.display_name(goal),
display_name: goal.display_name,
goal_type: Goal.type(goal),
event_name: goal.event_name,
page_path: goal.page_path

View File

@ -23,7 +23,10 @@ defmodule PlausibleWeb.Live.FunnelSettings.Form do
|> Goals.for_site()
|> Enum.map(fn goal ->
{goal.id,
struct!(Plausible.Goal, Map.take(goal, [:id, :event_name, :page_path, :currency]))}
struct!(
Plausible.Goal,
Map.take(goal, [:id, :display_name, :event_name, :page_path, :currency])
)}
end)
socket =

View File

@ -8,6 +8,7 @@ defmodule Plausible.Goal do
schema "goals" do
field :event_name, :string
field :page_path, :string
field :display_name, :string
on_ee do
field :currency, Ecto.Enum, values: Money.Currency.known_current_currencies()
@ -22,7 +23,8 @@ defmodule Plausible.Goal do
timestamps()
end
@fields [:id, :site_id, :event_name, :page_path] ++ on_ee(do: [:currency], else: [])
@fields [:id, :site_id, :event_name, :page_path, :display_name] ++
on_ee(do: [:currency], else: [])
@max_event_name_length 120
@ -35,10 +37,10 @@ defmodule Plausible.Goal do
|> 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)
|> maybe_put_display_name()
|> unique_constraint(:event_name, name: :goals_event_name_unique)
|> unique_constraint(:page_path, name: :goals_page_path_unique)
|> unique_constraint(:display_name, name: :goals_site_id_display_name_index)
|> validate_length(:event_name, max: @max_event_name_length)
|> check_constraint(:event_name,
name: :check_event_name_or_page_path,
@ -48,12 +50,8 @@ defmodule Plausible.Goal do
end
@spec display_name(t()) :: String.t()
def display_name(%{page_path: path}) when is_binary(path) do
"Visit " <> path
end
def display_name(%{event_name: name}) when is_binary(name) do
name
def display_name(goal) do
goal.display_name
end
@spec type(t()) :: :event | :page
@ -76,6 +74,8 @@ defmodule Plausible.Goal do
defp validate_event_name_and_page_path(changeset) do
if validate_page_path(changeset) || validate_event_name(changeset) do
changeset
|> update_change(:event_name, &String.trim/1)
|> update_change(:page_path, &String.trim/1)
else
changeset
|> add_error(:event_name, "this field is required and cannot be blank")
@ -100,6 +100,24 @@ defmodule Plausible.Goal do
changeset
end
end
defp maybe_put_display_name(changeset) do
clause =
Enum.map([:display_name, :page_path, :event_name], &get_field(changeset, &1))
case clause do
[nil, path, _] when is_binary(path) ->
put_change(changeset, :display_name, "Visit " <> path)
[nil, _, event_name] when is_binary(event_name) ->
put_change(changeset, :display_name, event_name)
_ ->
changeset
end
|> update_change(:display_name, &String.trim/1)
|> validate_required(:display_name)
end
end
defimpl Jason.Encoder, for: Plausible.Goal do
@ -110,14 +128,14 @@ defimpl Jason.Encoder, for: Plausible.Goal do
|> Map.put(:goal_type, Plausible.Goal.type(value))
|> Map.take([:id, :goal_type, :event_name, :page_path])
|> Map.put(:domain, domain)
|> Map.put(:display_name, Plausible.Goal.display_name(value))
|> Map.put(:display_name, value.display_name)
|> Jason.Encode.map(opts)
end
end
defimpl String.Chars, for: Plausible.Goal do
def to_string(goal) do
Plausible.Goal.display_name(goal)
goal.display_name
end
end

View File

@ -8,6 +8,17 @@ defmodule Plausible.Goals do
alias Plausible.Goal
alias Ecto.Multi
@spec get(Plausible.Site.t(), pos_integer()) :: nil | Plausible.Goal.t()
def get(site, id) when is_integer(id) do
q =
from g in Plausible.Goal,
where: g.site_id == ^site.id,
order_by: [desc: g.id],
where: g.id == ^id
Repo.one(q)
end
@spec create(Plausible.Site.t(), map(), Keyword.t()) ::
{:ok, Goal.t()} | {:error, Ecto.Changeset.t()} | {:error, :upgrade_required}
@doc """
@ -41,14 +52,31 @@ defmodule Plausible.Goals do
end)
end
def find_or_create(site, %{
"goal_type" => "event",
"event_name" => event_name,
"currency" => currency
})
when is_binary(event_name) and is_binary(currency) do
params = %{"event_name" => event_name, "currency" => currency}
@spec update(Plausible.Goal.t(), map()) ::
{:ok, Goal.t()} | {:error, Ecto.Changeset.t()} | {:error, :upgrade_required}
def update(goal, params) do
changeset = Goal.changeset(goal, params)
site = Repo.preload(goal, :site).site
with :ok <- maybe_check_feature_access(site, changeset),
{:ok, goal} <- Repo.update(changeset) do
on_ee do
{:ok, Repo.preload(goal, :funnels)}
else
{:ok, goal}
end
end
end
def find_or_create(
site,
%{
"goal_type" => "event",
"event_name" => event_name,
"currency" => currency
} = params
)
when is_binary(event_name) and is_binary(currency) do
with {:ok, goal} <- create(site, params, upsert?: true) do
if to_string(goal.currency) == currency do
{:ok, goal}
@ -67,15 +95,15 @@ defmodule Plausible.Goals do
end
end
def find_or_create(site, %{"goal_type" => "event", "event_name" => event_name})
def find_or_create(site, %{"goal_type" => "event", "event_name" => event_name} = params)
when is_binary(event_name) do
create(site, %{"event_name" => event_name}, upsert?: true)
create(site, params, upsert?: true)
end
def find_or_create(_, %{"goal_type" => "event"}), do: {:missing, "event_name"}
def find_or_create(site, %{"goal_type" => "page", "page_path" => page_path}) do
create(site, %{"page_path" => page_path}, upsert?: true)
def find_or_create(site, %{"goal_type" => "page", "page_path" => _} = params) do
create(site, params, upsert?: true)
end
def find_or_create(_, %{"goal_type" => "page"}), do: {:missing, "page_path"}

View File

@ -5,7 +5,6 @@ defmodule Plausible.Plugins.API.Goals do
"""
use Plausible
import Ecto.Query
import Plausible.Pagination
alias Plausible.Repo
@ -36,10 +35,7 @@ defmodule Plausible.Plugins.API.Goals do
@spec get(Plausible.Site.t(), pos_integer()) :: nil | Plausible.Goal.t()
def get(site, id) when is_integer(id) do
site
|> get_query()
|> where([g], g.id == ^id)
|> Repo.one()
Plausible.Goals.get(site, id)
end
@spec delete(Plausible.Site.t(), [pos_integer()] | pos_integer()) :: :ok
@ -55,13 +51,6 @@ defmodule Plausible.Plugins.API.Goals do
:ok
end
defp get_query(site) do
from g in Plausible.Goal,
where: g.site_id == ^site.id,
order_by: [desc: g.id],
group_by: g.id
end
defp convert_to_create_params(%CreateRequest.CustomEvent{goal: %{event_name: event_name}}) do
%{"goal_type" => "event", "event_name" => event_name}
end

View File

@ -155,7 +155,7 @@ defmodule Plausible.Stats.FilterSuggestions do
def filter_suggestions(site, _query, "goal", filter_search) do
site
|> Plausible.Goals.for_site()
|> Enum.map(&Plausible.Goal.display_name/1)
|> Enum.map(& &1.display_name)
|> Enum.filter(fn goal ->
String.contains?(
String.downcase(goal),

View File

@ -341,8 +341,9 @@ defmodule PlausibleWeb.Api.ExternalStatsController do
defp validate_filter(site, [_type, "event:goal", goal_filter]) do
configured_goals =
Plausible.Goals.for_site(site)
|> Enum.map(&Plausible.Goal.display_name/1)
site
|> Plausible.Goals.for_site()
|> Enum.map(& &1.display_name)
goals_in_filter = List.wrap(goal_filter)

View File

@ -104,6 +104,7 @@ defmodule PlausibleWeb.Live.Components.ComboBox do
name={"display-#{@id}"}
placeholder={@placeholder}
x-on:focus="open"
x-on:selection-change={assigns[:"x-on-selection-change"]}
phx-change="search"
x-on:keydown="open"
phx-target={@myself}
@ -268,8 +269,14 @@ defmodule PlausibleWeb.Live.Components.ComboBox do
"""
end
def select_option(js \\ %JS{}, _id, submit_value, display_value) do
def select_option(js \\ %JS{}, id, submit_value, display_value) do
js
|> JS.dispatch("phx:notify-selection-change",
detail: %{
id: id,
value: %{"submitValue" => submit_value, "displayValue" => display_value}
}
)
|> JS.push("select-option",
value: %{"submit-value" => submit_value, "display-value" => display_value}
)
@ -363,6 +370,12 @@ defmodule PlausibleWeb.Live.Components.ComboBox do
{{submit_value, display_value}, nil} ->
assign(socket, submit_value: submit_value, display_value: display_value)
{submit_and_display_value, nil} when is_binary(submit_and_display_value) ->
assign(socket,
submit_value: submit_and_display_value,
display_value: submit_and_display_value
)
_ ->
socket
end

View File

@ -52,13 +52,22 @@ defmodule PlausibleWeb.Live.Components.Modal do
be skipped if embedded component handles state reset explicitly
(via, for instance, `phx-click-away` callback).
`Modal` exposes two functions for managing window state:
`Modal` exposes a number of functions for managing window state:
* `Modal.JS.preopen/1` - to preopen the modal on the frontend.
Useful when the actual opening is done server-side with
`Modal.open/2` - helps avoid lack of feedback to the end user
when server-side state change before opening the modal is
still in progress.
* `Modal.JS.open/1` - to open the modal from the frontend. It's
important to make sure the element triggering that call is
wrapped in an Alpine UI component - or is an Alpine component
itself - adding `x-data` attribute without any value is enough
to ensure that.
* `Modal.open/2` - to open the modal from the backend; usually
called from `handle_event/2` of component wrapping the modal
and providing the state. Should be used together with
`Modal.JS.preopen/1` for optimal user experience.
* `Modal.close/2` - to close the modal from the backend; usually
done inside wrapped component's `handle_event/2`. The example
quoted above shows one way to implement this, under that assumption
@ -104,6 +113,11 @@ defmodule PlausibleWeb.Live.Components.Modal do
def open(id) do
"$dispatch('open-modal', '#{id}')"
end
@spec preopen(String.t()) :: String.t()
def preopen(id) do
"$dispatch('preopen-modal', '#{id}')"
end
end
@spec open(Phoenix.LiveView.Socket.t(), String.t()) :: Phoenix.LiveView.Socket.t()
@ -118,6 +132,8 @@ defmodule PlausibleWeb.Live.Components.Modal do
@impl true
def update(assigns, socket) do
preload? = Map.get(assigns, :preload?, true)
socket =
assign(socket,
id: assigns.id,
@ -127,7 +143,8 @@ defmodule PlausibleWeb.Live.Components.Modal do
# established. Otherwise, there will be problems
# with live components relying on ID for setup
# on mount (using AlpineJS, for instance).
load_content?: true,
load_content?: preload?,
preload?: preload?,
modal_sequence_id: 0
)
@ -136,6 +153,7 @@ defmodule PlausibleWeb.Live.Components.Modal do
attr :id, :any, required: true
attr :class, :string, default: ""
attr :preload?, :boolean, default: true
slot :inner_block, required: true
def render(assigns) do
@ -159,6 +177,12 @@ defmodule PlausibleWeb.Live.Components.Modal do
x-data="{
firstLoadDone: false,
modalOpen: false,
modalPreopen: false,
preopenModal() {
document.body.style['overflow-y'] = 'hidden';
this.modalPreopen = true;
},
openModal() {
document.body.style['overflow-y'] = 'hidden';
@ -168,9 +192,11 @@ defmodule PlausibleWeb.Live.Components.Modal do
this.firstLoadDone = true;
}
this.modalPreopen = false;
this.modalOpen = true;
},
closeModal() {
this.modalPreopen = false;
this.modalOpen = false;
liveSocket.execJS($el, $el.dataset.onclose);
@ -179,6 +205,8 @@ defmodule PlausibleWeb.Live.Components.Modal do
}, 200);
}
}"
x-init={"firstLoadDone = #{not @preload?}"}
x-on:preopen-modal.window={"if ($event.detail === '#{@id}') preopenModal()"}
x-on:open-modal.window={"if ($event.detail === '#{@id}') openModal()"}
x-on:close-modal.window={"if ($event.detail === '#{@id}') closeModal()"}
data-onopen={LiveView.JS.push("open", target: @myself)}
@ -188,7 +216,7 @@ defmodule PlausibleWeb.Live.Components.Modal do
aria-modal="true"
>
<div
x-show="modalOpen"
x-show="modalOpen || modalPreopen"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="bg-opacity-0"
x-transition:enter-end="bg-opacity-75"
@ -198,6 +226,16 @@ defmodule PlausibleWeb.Live.Components.Modal do
class="fixed inset-0 bg-gray-500 bg-opacity-75 z-50"
>
</div>
<div
x-show="modalPreopen"
class="fixed flex inset-0 items-start z-50 overflow-y-auto overflow-x-hidden"
>
<div class="modal-pre-loading w-full self-center">
<div class="text-center">
<PlausibleWeb.Components.Generic.spinner class="inline-block h-8 w-8" />
</div>
</div>
</div>
<div
x-show="modalOpen"
class="fixed flex inset-0 items-start z-50 overflow-y-auto overflow-x-hidden"

View File

@ -43,7 +43,8 @@ defmodule PlausibleWeb.Live.GoalSettings do
site_id: site_id,
domain: domain,
displayed_goals: socket.assigns.all_goals,
filter_text: ""
filter_text: "",
form_goal: nil
)}
end
@ -53,6 +54,7 @@ defmodule PlausibleWeb.Live.GoalSettings do
~H"""
<div id="goal-settings-main">
<.flash_messages flash={@flash} />
<.live_component :let={modal_unique_id} module={Modal} id="goals-form-modal">
<.live_component
module={PlausibleWeb.Live.GoalSettings.Form}
@ -63,6 +65,7 @@ defmodule PlausibleWeb.Live.GoalSettings do
site={@site}
current_user={@current_user}
existing_goals={@all_goals}
goal={@form_goal}
on_save_goal={
fn goal, socket ->
send(self(), {:goal_added, goal})
@ -103,6 +106,19 @@ defmodule PlausibleWeb.Live.GoalSettings do
{:noreply, assign(socket, displayed_goals: new_list, filter_text: filter_text)}
end
def handle_event("edit-goal", %{"goal-id" => goal_id}, socket) do
goal_id = String.to_integer(goal_id)
form_goal = Plausible.Goals.get(socket.assigns.site, goal_id)
socket = socket |> assign(form_goal: form_goal) |> Modal.open("goals-form-modal")
{:noreply, socket}
end
def handle_event("add-goal", _, socket) do
socket = socket |> assign(form_goal: nil) |> Modal.open("goals-form-modal")
{:noreply, socket}
end
def handle_event("delete-goal", %{"goal-id" => goal_id} = params, socket) do
goal_id = String.to_integer(goal_id)
@ -132,14 +148,17 @@ defmodule PlausibleWeb.Live.GoalSettings do
end
def handle_info({:goal_added, goal}, socket) do
all_goals = Goals.for_site(socket.assigns.site, preload_funnels?: true)
socket =
socket
|> assign(
filter_text: "",
all_goals: [goal | socket.assigns.all_goals],
all_goals: all_goals,
event_name_options:
Enum.reject(socket.assigns.event_name_options, &(&1 == goal.event_name)),
displayed_goals: [goal | socket.assigns.all_goals]
displayed_goals: all_goals,
form_goal: nil
)
|> put_live_flash(:success, "Goal saved successfully")

View File

@ -11,7 +11,7 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do
alias Plausible.Repo
def update(assigns, socket) do
site = Repo.preload(assigns.site, [:owner])
site = Repo.preload(assigns.site, :owner)
owner = Plausible.Users.with_subscription(site.owner)
site = %{site | owner: owner}
@ -19,10 +19,17 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do
Plausible.Billing.Feature.RevenueGoals.check_availability(owner) == :ok
form =
%Plausible.Goal{}
(assigns.goal || %Plausible.Goal{})
|> Plausible.Goal.changeset()
|> to_form()
selected_tab =
if assigns.goal && assigns.goal.page_path do
"pageviews"
else
"custom_events"
end
socket =
socket
|> assign(
@ -33,87 +40,135 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do
event_name_options: Enum.map(assigns.event_name_options, &{&1, &1}),
current_user: assigns.current_user,
domain: assigns.domain,
selected_tab: "custom_events",
selected_tab: selected_tab,
tab_sequence_id: 0,
site: site,
has_access_to_revenue_goals?: has_access_to_revenue_goals?,
existing_goals: assigns.existing_goals,
on_save_goal: assigns.on_save_goal,
on_autoconfigure: assigns.on_autoconfigure
on_autoconfigure: assigns.on_autoconfigure,
goal: assigns.goal
)
{:ok, socket}
end
# Regular functions instead of component calls are used here
# explicitly to avoid breaking change tracking. Done following
# advice from https://hexdocs.pm/phoenix_live_view/assigns-eex.html#the-assigns-variable.
def render(assigns) do
~H"""
<div id={@id}>
<.form
:let={f}
x-data="{ tabSelectionInProgress: false }"
for={@form}
phx-submit="save-goal"
<%= if @goal, do: edit_form(assigns) %>
<%= if is_nil(@goal), do: create_form(assigns) %>
</div>
"""
end
def edit_form(assigns) do
~H"""
<.form :let={f} for={@form} phx-submit="save-goal" phx-target={@myself}>
<h2 class="text-xl font-black dark:text-gray-100">
Edit Goal for <%= @domain %>
</h2>
<.custom_event_fields
:if={@selected_tab == "custom_events"}
f={f}
suffix={@context_unique_id}
current_user={@current_user}
site={@site}
goal={@goal}
existing_goals={@existing_goals}
goal_options={@event_name_options}
has_access_to_revenue_goals?={@has_access_to_revenue_goals?}
/>
<.pageview_fields
:if={@selected_tab == "pageviews"}
f={f}
goal={@goal}
suffix={@context_unique_id}
site={@site}
/>
<div class="py-4">
<PlausibleWeb.Components.Generic.button type="submit" class="w-full">
Update Goal
</PlausibleWeb.Components.Generic.button>
</div>
</.form>
"""
end
def create_form(assigns) do
~H"""
<.form
:let={f}
x-data="{ tabSelectionInProgress: false }"
for={@form}
phx-submit="save-goal"
phx-target={@myself}
>
<PlausibleWeb.Components.Generic.spinner
class="spinner block absolute right-9 top-8"
x-show="tabSelectionInProgress"
/>
<h2 class="text-xl font-black dark:text-gray-100">
Add Goal for <%= @domain %>
</h2>
<.tabs selected_tab={@selected_tab} myself={@myself} />
<.custom_event_fields
:if={@selected_tab == "custom_events"}
x-show="!tabSelectionInProgress"
f={f}
suffix={suffix(@context_unique_id, @tab_sequence_id)}
current_user={@current_user}
site={@site}
existing_goals={@existing_goals}
goal_options={@event_name_options}
has_access_to_revenue_goals?={@has_access_to_revenue_goals?}
x-init="tabSelectionInProgress = false"
/>
<.pageview_fields
:if={@selected_tab == "pageviews"}
x-show="!tabSelectionInProgress"
f={f}
suffix={suffix(@context_unique_id, @tab_sequence_id)}
site={@site}
x-init="tabSelectionInProgress = false"
/>
<div class="py-4" x-show="!tabSelectionInProgress">
<PlausibleWeb.Components.Generic.button type="submit" class="w-full">
Add Goal
</PlausibleWeb.Components.Generic.button>
</div>
<button
:if={@selected_tab == "custom_events" && @event_name_options_count > 0}
x-show="!tabSelectionInProgress"
class="mt-2 text-sm hover:underline text-indigo-600 dark:text-indigo-400 text-left"
phx-click="autoconfigure"
phx-target={@myself}
>
<PlausibleWeb.Components.Generic.spinner
class="spinner block absolute right-9 top-8"
x-show="tabSelectionInProgress"
/>
<h2 class="text-xl font-black dark:text-gray-100">Add Goal for <%= @domain %></h2>
<.tabs selected_tab={@selected_tab} myself={@myself} />
<.custom_event_fields
:if={@selected_tab == "custom_events"}
x-show="!tabSelectionInProgress"
f={f}
suffix={suffix(@context_unique_id, @tab_sequence_id)}
current_user={@current_user}
site={@site}
existing_goals={@existing_goals}
goal_options={@event_name_options}
has_access_to_revenue_goals?={@has_access_to_revenue_goals?}
x-init="tabSelectionInProgress = false"
/>
<.pageview_fields
:if={@selected_tab == "pageviews"}
x-show="!tabSelectionInProgress"
f={f}
suffix={suffix(@context_unique_id, @tab_sequence_id)}
site={@site}
x-init="tabSelectionInProgress = false"
/>
<div class="py-4" x-show="!tabSelectionInProgress">
<PlausibleWeb.Components.Generic.button type="submit" class="w-full">
Add Goal
</PlausibleWeb.Components.Generic.button>
</div>
<button
:if={@selected_tab == "custom_events" && @event_name_options_count > 0}
x-show="!tabSelectionInProgress"
class="mt-2 text-sm hover:underline text-indigo-600 dark:text-indigo-400 text-left"
phx-click="autoconfigure"
phx-target={@myself}
>
<span :if={@event_name_options_count > 1}>
Already sending custom events? We've found <%= @event_name_options_count %> custom events from the last 6 months that are not yet configured as goals. Click here to add them.
</span>
<span :if={@event_name_options_count == 1}>
Already sending custom events? We've found 1 custom event from the last 6 months that is not yet configured as a goal. Click here to add it.
</span>
</button>
</.form>
</div>
<span :if={@event_name_options_count > 1}>
Already sending custom events? We've found <%= @event_name_options_count %> custom events from the last 6 months that are not yet configured as goals. Click here to add them.
</span>
<span :if={@event_name_options_count == 1}>
Already sending custom events? We've found 1 custom event from the last 6 months that is not yet configured as a goal. Click here to add it.
</span>
</button>
</.form>
"""
end
attr(:f, Phoenix.HTML.Form)
attr(:site, Plausible.Site)
attr(:suffix, :string)
attr(:goal, Plausible.Goal, default: nil)
attr(:rest, :global)
def pageview_fields(assigns) do
@ -131,14 +186,29 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do
]}
module={ComboBox}
suggest_fun={fn input, _options -> suggest_page_paths(input, @site) end}
selected={if @goal && @goal.page_path, do: @goal.page_path}
creatable
x-on-selection-change="document.getElementById('pageview_display_name_input').setAttribute('value', 'Visit ' + $event.detail.value.displayValue)"
/>
<.error :for={{msg, opts} <- @f[:page_path].errors}>
<%= Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end)
end) %>
<.error :for={msg <- Enum.map(@f[:page_path].errors, &translate_error/1)}>
<%= msg %>
</.error>
<div class="mt-2">
<.label for="pageview_display_name_input">
Display Name
</.label>
<.input
id="pageview_display_name_input"
field={@f[:display_name]}
type="text"
x-data="{ firstFocus: true }"
x-on:focus="if (firstFocus) { $el.select(); firstFocus = false; }"
class="mt-2 dark:bg-gray-900 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 dark:border-gray-500 rounded-md dark:text-gray-300"
/>
</div>
</div>
"""
end
@ -149,6 +219,7 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do
attr(:suffix, :string)
attr(:existing_goals, :list)
attr(:goal_options, :list)
attr(:goal, Plausible.Goal, default: nil)
attr(:has_access_to_revenue_goals?, :boolean)
attr(:rest, :global)
@ -181,92 +252,124 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do
module={ComboBox}
suggest_fun={fn input, _options -> suggest_event_names(input, @site, @existing_goals) end}
options={@goal_options}
selected={if @goal && @goal.event_name, do: @goal.event_name}
creatable
x-on-selection-change="document.getElementById('custom_event_display_name_input').setAttribute('value', $event.detail.value.displayValue)"
/>
<.error :for={msg <- Enum.map(@f[:event_name].errors, &translate_error/1)}>
<%= msg %>
</.error>
</div>
<div class="mt-2">
<.label for="custom_event_display_name_input">
Display Name
</.label>
<.input
id="custom_event_display_name_input"
field={@f[:display_name]}
type="text"
x-data="{ firstFocus: true }"
x-on:focus="if (firstFocus) { $el.select(); firstFocus = false; }"
class="mt-2 dark:bg-gray-900 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 dark:border-gray-500 rounded-md dark:text-gray-300"
/>
</div>
<div
<.revenue_goal_settings
:if={ee?()}
class="mt-6 space-y-3"
x-data={
Jason.encode!(%{
active: !!@f[:currency].value and @f[:currency].value != "",
currency: @f[:currency].value
})
}
f={@f}
site={@site}
current_user={@current_user}
has_access_to_revenue_goals?={@has_access_to_revenue_goals?}
goal={@goal}
suffix={@suffix}
/>
</div>
</div>
"""
end
def revenue_goal_settings(assigns) do
js_data =
Jason.encode!(%{
active: !!assigns.f[:currency].value and assigns.f[:currency].value != "",
currency: assigns.f[:currency].value
})
assigns = assign(assigns, selected_currency: currency_option(assigns.goal), js_data: js_data)
~H"""
<div class="mt-6 space-y-3" x-data={@js_data}>
<PlausibleWeb.Components.Billing.Notice.premium_feature
billable_user={@site.owner}
current_user={@current_user}
feature_mod={Plausible.Billing.Feature.RevenueGoals}
size={:xs}
class="rounded-b-md"
/>
<button
id={"currency-toggle-#{@suffix}"}
class={[
"flex items-center w-max mb-3",
if @has_access_to_revenue_goals? and is_nil(@goal) do
"cursor-pointer"
else
"cursor-not-allowed"
end
]}
aria-labelledby="enable-revenue-tracking"
role="switch"
type="button"
x-on:click="active = !active; currency = ''"
x-bind:aria-checked="active"
disabled={not @has_access_to_revenue_goals? or not is_nil(@goal)}
>
<span
id={"currency-container1-#{@suffix}"}
class="relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2"
x-bind:class="active ? 'bg-indigo-600' : 'dark:bg-gray-700 bg-gray-200'"
>
<PlausibleWeb.Components.Billing.Notice.premium_feature
billable_user={@site.owner}
current_user={@current_user}
feature_mod={Plausible.Billing.Feature.RevenueGoals}
size={:xs}
class="rounded-b-md"
<span
id={"currency-container2-#{@suffix}"}
aria-hidden="true"
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
x-bind:class="active ? 'dark:bg-gray-800 translate-x-5' : 'dark:bg-gray-800 translate-x-0'"
/>
<button
class={[
"flex items-center w-max mb-3",
if @has_access_to_revenue_goals? do
"cursor-pointer"
else
"cursor-not-allowed"
</span>
<span
class={[
"ml-3 font-medium",
if(@has_access_to_revenue_goals?,
do: "text-gray-900 dark:text-gray-100",
else: "text-gray-500 dark:text-gray-300"
)
]}
id="enable-revenue-tracking"
>
Enable Revenue Tracking
</span>
</button>
<div x-show="active" id={"revenue-input-#{@suffix}"}>
<.live_component
id={"currency_input_#{@suffix}"}
submit_name={@f[:currency].name}
module={ComboBox}
selected={@selected_currency}
suggest_fun={
on_ee do
fn
"", [] ->
Plausible.Goal.Revenue.currency_options()
input, options ->
ComboBox.StaticSearch.suggest(input, options, weight_threshold: 0.8)
end
]}
aria-labelledby="enable-revenue-tracking"
role="switch"
type="button"
x-on:click="active = !active; currency = ''"
x-bind:aria-checked="active"
disabled={not @has_access_to_revenue_goals?}
>
<span
class="relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2"
x-bind:class="active ? 'bg-indigo-600' : 'dark:bg-gray-700 bg-gray-200'"
>
<span
aria-hidden="true"
class="pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
x-bind:class="active ? 'dark:bg-gray-800 translate-x-5' : 'dark:bg-gray-800 translate-x-0'"
/>
</span>
<span
class={[
"ml-3 font-medium",
if(assigns.has_access_to_revenue_goals?,
do: "text-gray-900 dark:text-gray-100",
else: "text-gray-500 dark:text-gray-300"
)
]}
id="enable-revenue-tracking"
>
Enable Revenue Tracking
</span>
</button>
<div x-show="active">
<.live_component
id={"currency_input_#{@suffix}"}
submit_name={@f[:currency].name}
module={ComboBox}
suggest_fun={
on_ee do
fn
"", [] ->
Plausible.Goal.Revenue.currency_options()
input, options ->
ComboBox.StaticSearch.suggest(input, options, weight_threshold: 0.8)
end
end
}
/>
</div>
</div>
<.error :for={{msg, opts} <- @f[:event_name].errors}>
<%= Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end)
end) %>
</.error>
end
}
/>
</div>
</div>
"""
@ -330,8 +433,8 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do
{:noreply, socket}
end
def handle_event("save-goal", %{"goal" => goal}, socket) do
case Plausible.Goals.create(socket.assigns.site, goal) do
def handle_event("save-goal", %{"goal" => goal_params}, %{assigns: %{goal: nil}} = socket) do
case Plausible.Goals.create(socket.assigns.site, goal_params) do
{:ok, goal} ->
socket =
goal
@ -345,6 +448,22 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do
end
end
def handle_event(
"save-goal",
%{"goal" => goal_params},
%{assigns: %{goal: %Plausible.Goal{} = goal}} = socket
) do
case Plausible.Goals.update(goal, goal_params) do
{:ok, goal} ->
socket = socket.assigns.on_save_goal.(goal, socket)
{:noreply, socket}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
def handle_event("autoconfigure", _params, socket) do
{:noreply, socket.assigns.on_autoconfigure.(socket)}
end
@ -371,4 +490,15 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do
defp suffix(context_unique_id, tab_sequence_id) do
"#{context_unique_id}-tabseq#{tab_sequence_id}"
end
on_ee do
defp currency_option(nil), do: nil
defp currency_option(goal) do
Plausible.Goal.Revenue.revenue?(goal) &&
Plausible.Goal.Revenue.currency_option(goal.currency)
end
else
defp currency_option(_), do: nil
end
end

View File

@ -14,8 +14,7 @@ defmodule PlausibleWeb.Live.GoalSettings.List do
def render(assigns) do
revenue_goals_enabled? = Plausible.Billing.Feature.RevenueGoals.enabled?(assigns.site)
goals = Enum.map(assigns.goals, &{goal_label(&1), &1})
assigns = assign(assigns, goals: goals, revenue_goals_enabled?: revenue_goals_enabled?)
assigns = assign(assigns, revenue_goals_enabled?: revenue_goals_enabled?)
~H"""
<div>
@ -47,8 +46,9 @@ defmodule PlausibleWeb.Live.GoalSettings.List do
<div class="mt-4 flex sm:ml-4 sm:mt-0">
<PlausibleWeb.Components.Generic.button
id="add-goal-button"
phx-click="add-goal"
x-data
x-on:click={Modal.JS.open("goals-form-modal")}
x-on:click={Modal.JS.preopen("goals-form-modal")}
>
+ Add Goal
</PlausibleWeb.Components.Generic.button>
@ -56,43 +56,71 @@ defmodule PlausibleWeb.Live.GoalSettings.List do
</div>
<%= if Enum.count(@goals) > 0 do %>
<div class="mt-12">
<%= for {goal_label, goal} <- @goals do %>
<div class="border-b border-gray-300 dark:border-gray-500 py-3 flex justify-between">
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 w-3/4">
<div class="flex">
<span class="truncate">
<%= for goal <- @goals do %>
<div class="border-b border-gray-300 dark:border-gray-500 py-3 flex justify-between items-center h-16">
<span class="text-sm font-medium text-gray-900 dark:text-gray-100 w-2/3 cursor-help pr-4">
<div class="flex" title={goal.page_path || goal.event_name}>
<div class="truncate block">
<div class="text-xs text-gray-400 block mb-1 font-normal">
<.goal_description goal={goal} revenue_goals_enabled?={@revenue_goals_enabled?} />
</div>
<%= if not @revenue_goals_enabled? && goal.currency do %>
<div class="text-gray-600 flex items-center">
<Heroicons.lock_closed class="w-4 h-4 mr-1 inline" />
<span><%= goal_label %></span>
<div class="truncate"><%= goal %></div>
</div>
<% else %>
<%= goal_label %>
<div class="truncate"><%= goal %></div>
<% end %>
<span class="text-sm text-gray-400 block mt-1 font-normal">
<span :if={goal.page_path}>Pageview</span>
<span :if={goal.event_name && !goal.currency}>Custom Event</span>
<span :if={goal.currency && @revenue_goals_enabled?}>
Revenue Goal
</span>
<span :if={goal.currency && not @revenue_goals_enabled?} class="text-red-600">
Unlock Revenue Goals by upgrading to a business plan
</span>
<span :if={not Enum.empty?(goal.funnels)}> - belongs to funnel(s)</span>
</span>
</span>
</div>
</div>
</span>
<button
id={"delete-goal-#{goal.id}"}
phx-click="delete-goal"
phx-value-goal-id={goal.id}
phx-value-goal-name={goal.event_name}
class="text-sm text-red-600"
data-confirm={delete_confirmation_text(goal)}
>
<Heroicons.trash class="feather feather-sm" />
</button>
<div class="flex items-center w-1/3">
<div class="text-xs w-full mr-6 text-gray-400">
<div class="hidden md:block">
<div :if={goal.page_path} class="text-gray-600">Pageview</div>
<div :if={goal.event_name && !goal.currency} class="text-gray-600">
Custom Event
</div>
<div :if={goal.currency} class="text-gray-600">
Revenue Goal (<%= goal.currency %>)
</div>
<div :if={not Enum.empty?(goal.funnels)}>Belongs to funnel(s)</div>
</div>
</div>
<button
:if={!goal.currency || (goal.currency && @revenue_goals_enabled?)}
x-data
x-on:click={Modal.JS.preopen("goals-form-modal")}
phx-click="edit-goal"
phx-value-goal-id={goal.id}
id={"edit-goal-#{goal.id}"}
class="mr-4"
>
<Heroicons.pencil_square class="feather feather-sm text-indigo-800 hover:text-indigo-500 dark:text-indigo-500 dark:hover:text-indigo-300" />
</button>
<button
:if={goal.currency && !@revenue_goals_enabled?}
id={"edit-goal-#{goal.id}-disabled"}
disabled
class="mr-4 cursor-not-allowed"
>
<Heroicons.pencil_square class="feather feather-sm text-gray-400 dark:text-gray-600" />
</button>
<button
id={"delete-goal-#{goal.id}"}
phx-click="delete-goal"
phx-value-goal-id={goal.id}
phx-value-goal-name={goal.event_name}
class="text-sm text-red-600"
data-confirm={delete_confirmation_text(goal)}
>
<Heroicons.trash class="feather feather-sm" />
</button>
</div>
</div>
<% end %>
</div>
@ -117,12 +145,33 @@ defmodule PlausibleWeb.Live.GoalSettings.List do
"""
end
defp goal_label(%{currency: currency} = goal) when not is_nil(currency) do
to_string(goal) <> " (#{currency})"
def pageview_description(goal) do
path = goal.page_path
case goal.display_name do
"Visit " <> ^path -> ""
_ -> "#{path}"
end
end
defp goal_label(goal) do
to_string(goal)
def custom_event_description(goal) do
if goal.display_name == goal.event_name, do: "", else: goal.event_name
end
def goal_description(assigns) do
~H"""
<span :if={@goal.page_path} class="block w-full truncate">
<%= pageview_description(@goal) %>
</span>
<span :if={@goal.event_name}>
<%= custom_event_description(@goal) %>
</span>
<span :if={@goal.currency && not @revenue_goals_enabled?} class="text-red-600">
Unlock Revenue Goals by upgrading to a business plan
</span>
"""
end
defp delete_confirmation_text(goal) do

View File

@ -34,7 +34,7 @@ defmodule PlausibleWeb.Plugins.API.Views.Goal do
goal_type: "Goal.Pageview",
goal: %{
id: pageview.id,
display_name: to_string(pageview),
display_name: pageview.display_name,
path: pageview.page_path
}
}
@ -47,7 +47,7 @@ defmodule PlausibleWeb.Plugins.API.Views.Goal do
goal_type: "Goal.CustomEvent",
goal: %{
id: custom_event.id,
display_name: to_string(custom_event),
display_name: custom_event.display_name,
event_name: custom_event.event_name
}
}
@ -61,7 +61,7 @@ defmodule PlausibleWeb.Plugins.API.Views.Goal do
goal_type: "Goal.Revenue",
goal: %{
id: revenue_goal.id,
display_name: to_string(revenue_goal),
display_name: revenue_goal.display_name,
event_name: revenue_goal.event_name,
currency: revenue_goal.currency
}

View File

@ -3,7 +3,7 @@ defmodule PlausibleWeb.ErrorHelpers do
def error_tag(%{errors: errors}, field) do
Enum.map(Keyword.get_values(errors, field), fn error ->
content_tag(:div, translate_error(error), class: "mt-2 text-sm text-red-600")
content_tag(:div, translate_error(error), class: "mt-2 text-sm text-red-500")
end)
end
@ -11,7 +11,7 @@ defmodule PlausibleWeb.ErrorHelpers do
error = assigns[field]
if error do
content_tag(:div, error, class: "mt-2 text-sm text-red-600")
content_tag(:div, error, class: "mt-2 text-sm text-red-500")
end
end

View File

@ -0,0 +1,37 @@
defmodule Plausible.Repo.Migrations.MakeGoalDisplayNamesUnique do
use Ecto.Migration
def up do
fill_display_names()
alter table(:goals) do
modify :display_name, :text, null: false
end
create unique_index(:goals, [:site_id, :display_name])
end
def down do
drop unique_index(:goals, [:site_id, :display_name])
alter table(:goals) do
modify :display_name, :text, null: true
end
execute """
UPDATE goals
SET display_name = NULL
"""
end
def fill_display_names do
execute """
UPDATE goals
SET display_name =
CASE
WHEN page_path IS NOT NULL THEN 'Visit ' || page_path
WHEN event_name IS NOT NULL THEN event_name
END
"""
end
end

View File

@ -10,6 +10,10 @@
# We recommend using the bang functions (`insert!`, `update!`
# and so on) as they will fail if something goes wrong.
words =
for i <- 0..(:erlang.system_info(:atom_count) - 1),
do: :erlang.binary_to_term(<<131, 75, i::24>>)
user = Plausible.Factory.insert(:user, email: "user@plausible.test", password: "plausible")
native_stats_range =
@ -32,8 +36,13 @@ imported_stats_range =
long_random_paths =
for _ <- 1..100 do
l = Enum.random(40..300)
"/long/#{l}/path/#{String.duplicate("0x", l)}/end"
path =
words
|> Enum.shuffle()
|> Enum.take(Enum.random(1..20))
|> Enum.join("/")
"/#{path}.html"
end
long_random_urls =
@ -74,8 +83,17 @@ seeded_token = Plausible.Plugins.API.Token.generate("seed-token")
{:ok, goal1} = Plausible.Goals.create(site, %{"page_path" => "/"})
{:ok, goal2} = Plausible.Goals.create(site, %{"page_path" => "/register"})
{:ok, goal3} = Plausible.Goals.create(site, %{"page_path" => "/login"})
{:ok, goal4} = Plausible.Goals.create(site, %{"event_name" => "Purchase", "currency" => "USD"})
{:ok, goal3} =
Plausible.Goals.create(site, %{"page_path" => "/login", "display_name" => "User logs in"})
{:ok, goal4} =
Plausible.Goals.create(site, %{
"event_name" => "Purchase",
"currency" => "USD",
"display_name" => "North America Purchases"
})
{:ok, _goal5} = Plausible.Goals.create(site, %{"page_path" => Enum.random(long_random_paths)})
{:ok, outbound} = Plausible.Goals.create(site, %{"event_name" => "Outbound Link: Click"})

View File

@ -7,9 +7,16 @@ defmodule Plausible.GoalsTest do
site = insert(:site)
{:ok, goal} = Goals.create(site, %{"page_path" => "/foo bar "})
assert goal.page_path == "/foo bar"
assert goal.display_name == "Visit /foo bar"
{:ok, goal} =
Goals.create(site, %{
"event_name" => " some event name ",
"display_name" => " DisplayName "
})
{:ok, goal} = Goals.create(site, %{"event_name" => " some event name "})
assert goal.event_name == "some event name"
assert goal.display_name == "DisplayName"
end
test "create/2 creates pageview goal and adds a leading slash if missing" do
@ -92,6 +99,15 @@ defmodule Plausible.GoalsTest do
assert [currency: {"is invalid", _}] = changeset.errors
end
test "update/2 updates a goal" do
site = insert(:site)
{:ok, goal1} = Goals.create(site, %{"page_path" => "/foo bar "})
{:ok, goal2} = Goals.update(goal1, %{"page_path" => "/", "display_name" => "Homepage"})
assert goal1.id == goal2.id
assert goal2.page_path == "/"
assert goal2.display_name == "Homepage"
end
@tag :ee_only
test "list_revenue_goals/1 lists event_names and currencies for each revenue goal" do
site = insert(:site)

View File

@ -305,6 +305,38 @@ defmodule PlausibleWeb.Api.ExternalSitesControllerTest do
assert res["domain"] == new_domain
end
test "can add a goal as event with display name", %{conn: conn, site: site} do
conn =
put(conn, "/api/v1/sites/goals", %{
site_id: site.domain,
goal_type: "event",
event_name: "Signup",
display_name: "Customer Acquired"
})
res = json_response(conn, 200)
assert res["goal_type"] == "event"
assert res["event_name"] == "Signup"
assert res["display_name"] == "Customer Acquired"
assert res["domain"] == site.domain
end
test "can add a goal as page with display name", %{conn: conn, site: site} do
conn =
put(conn, "/api/v1/sites/goals", %{
site_id: site.domain,
goal_type: "page",
page_path: "/foo",
display_name: "Visit the foo page"
})
res = json_response(conn, 200)
assert res["goal_type"] == "page"
assert res["display_name"] == "Visit the foo page"
assert res["page_path"] == "/foo"
assert res["domain"] == site.domain
end
test "is idempotent find or create op", %{conn: conn, site: site} do
conn =
put(conn, "/api/v1/sites/goals", %{

View File

@ -415,7 +415,7 @@ defmodule PlausibleWeb.Api.ExternalStatsController.AggregateTest do
build(:pageview, timestamp: today)
])
insert(:goal, %{site: site, event_name: "Signup"})
insert(:goal, %{site: site, event_name: "Signup", display_name: "Signup Display Name"})
conn =
get(conn, "/api/v1/stats/aggregate", %{
@ -423,7 +423,7 @@ defmodule PlausibleWeb.Api.ExternalStatsController.AggregateTest do
"period" => "day",
"date" => "2023-05-05",
"metrics" => "conversion_rate",
"filters" => "event:goal==Signup",
"filters" => "event:goal==Signup Display Name",
"compare" => "previous_period"
})
@ -712,7 +712,7 @@ defmodule PlausibleWeb.Api.ExternalStatsController.AggregateTest do
test "returns stats with page + pageview goal filter",
%{conn: conn, site: site, site_import: site_import} do
insert(:goal, site: site, page_path: "/blog/**")
insert(:goal, site: site, page_path: "/blog/**", display_name: "Blog Visit")
populate_stats(site, site_import.id, [
build(:imported_pages, page: "/blog/1", visitors: 1, pageviews: 1),
@ -725,7 +725,7 @@ defmodule PlausibleWeb.Api.ExternalStatsController.AggregateTest do
"site_id" => site.domain,
"period" => "day",
"metrics" => "visitors,events,conversion_rate",
"filters" => "event:page==/blog/2;event:goal==Visit /blog/**",
"filters" => "event:page==/blog/2;event:goal==Blog Visit",
"with_imported" => "true"
})

View File

@ -24,7 +24,7 @@ defmodule PlausibleWeb.Live.Components.ComboBoxTest do
end
end
test "renders preselected default value" do
test "renders preselected default value (tuple)" do
options = new_options(10)
assert doc = render_sample_component(options, selected: List.last(options))
@ -36,6 +36,18 @@ defmodule PlausibleWeb.Live.Components.ComboBoxTest do
)
end
test "renders preselected default value (string)" do
options = [{"a", "a"}, {"b", "b"}, {"c", "c"}]
assert doc = render_sample_component(options, selected: "b")
assert element_exists?(doc, "input[type=hidden][name=test-submit-name][value=b]")
assert element_exists?(
doc,
~s|input[type=text][name=display-test-component][value="b"]|
)
end
test "renders up to 15 suggestions by default" do
assert doc = render_sample_component(new_options(20))

View File

@ -27,43 +27,65 @@ defmodule PlausibleWeb.Live.GoalSettings.FormTest do
setup [:create_user, :log_in, :create_site]
@tag :ee_only
test "renders form fields (with currency)", %{conn: conn, site: site} do
test "renders form fields per tab, with currency", %{conn: conn, site: site} do
lv = get_liveview(conn, site)
html = render(lv)
[event_name_display, event_name, currency_display, currency_submit] =
find(html, "#goals-form-modalseq0 input")
refute element_exists?(html, "#pageviews-form")
assert name_of(event_name_display) == "display-event_name_input_modalseq0-tabseq0"
assert name_of(event_name) == "goal[event_name]"
assert name_of(currency_display) == "display-currency_input_modalseq0-tabseq0"
assert name_of(currency_submit) == "goal[currency]"
input_names = html |> find("#custom-events-form input") |> Enum.map(&name_of/1)
assert input_names ==
[
"display-event_name_input_modalseq0-tabseq0",
"goal[event_name]",
"goal[display_name]",
"display-currency_input_modalseq0-tabseq0",
"goal[currency]"
]
lv |> element(~s/a#pageview-tab/) |> render_click()
html = lv |> render()
html = render(lv)
refute element_exists?(html, "#custom-events-form")
[page_path_display, page_path] = find(html, "#goals-form-modalseq0 input")
assert name_of(page_path_display) == "display-page_path_input_modalseq0-tabseq1"
assert name_of(page_path) == "goal[page_path]"
input_names = html |> find("#pageviews-form input") |> Enum.map(&name_of/1)
assert input_names == [
"display-page_path_input_modalseq0-tabseq1",
"goal[page_path]",
"goal[display_name]"
]
end
@tag :ce_build_only
test "renders form fields (no currency)", %{conn: conn, site: site} do
test "renders form fields per tab (no currency)", %{conn: conn, site: site} do
lv = get_liveview(conn, site)
html = render(lv)
[event_name_display, event_name | _] = find(html, "#goals-form-modalseq0 input")
assert name_of(event_name_display) == "display-event_name_input_modalseq0-tabseq0"
assert name_of(event_name) == "goal[event_name]"
refute element_exists?(html, "#pageviews-form")
input_names = html |> find("#custom-events-form input") |> Enum.map(&name_of/1)
assert input_names ==
[
"display-event_name_input_modalseq0-tabseq0",
"goal[event_name]",
"goal[display_name]"
]
lv |> element(~s/a#pageview-tab/) |> render_click()
html = lv |> render()
html = render(lv)
refute element_exists?(html, "#custom-events-form")
[page_path_display, page_path] = find(html, "#goals-form-modalseq0 input")
assert name_of(page_path_display) == "display-page_path_input_modalseq0-tabseq1"
assert name_of(page_path) == "goal[page_path]"
input_names = html |> find("#pageviews-form input") |> Enum.map(&name_of/1)
assert input_names == [
"display-page_path_input_modalseq0-tabseq1",
"goal[page_path]",
"goal[display_name]"
]
end
test "renders error on empty submission", %{conn: conn, site: site} do
@ -96,12 +118,19 @@ defmodule PlausibleWeb.Live.GoalSettings.FormTest do
lv
|> element("#goals-form-modalseq0 form")
|> render_submit(%{goal: %{event_name: "SampleRevenueGoal", currency: "EUR"}})
|> render_submit(%{
goal: %{
event_name: "SampleRevenueGoal",
currency: "EUR",
display_name: "Sample Display Name"
}
})
html = render(lv)
assert html =~ "SampleRevenueGoal (EUR)"
assert html =~ "Revenue Goal"
assert html =~ "SampleRevenueGoal"
assert html =~ "Revenue Goal (EUR)"
assert html =~ "Sample Display Name"
end
test "creates a pageview goal", %{conn: conn, site: site} do
@ -118,6 +147,119 @@ defmodule PlausibleWeb.Live.GoalSettings.FormTest do
end
end
describe "Editing goals" do
setup [:create_user, :log_in, :create_site]
@tag :ee_only
test "tabless view is rendered with goal type change disabled", %{conn: conn, site: site} do
{:ok, [pageview, custom_event, revenue_goal]} = setup_goals(site)
lv = get_liveview(conn, site)
# pageviews
lv |> element(~s/button#edit-goal-#{pageview.id}/) |> render_click()
html = render(lv)
assert element_exists?(html, "#pageviews-form")
refute element_exists?(html, "#custom-events-form")
refute element_exists?(
html,
~s/button[role=switch][aria-labelledby=enable-revenue-tracking]/
)
# custom events
lv |> element(~s/button#edit-goal-#{custom_event.id}/) |> render_click()
html = render(lv)
refute element_exists?(html, "#pageviews-form")
assert element_exists?(html, "#custom-events-form")
assert element_exists?(
html,
~s/button[role=switch][aria-labelledby=enable-revenue-tracking][disabled="disabled"]/
)
# revenue goals
lv |> element(~s/button#edit-goal-#{revenue_goal.id}/) |> render_click()
html = render(lv)
refute element_exists?(html, "#pageviews-form")
assert element_exists?(html, "#custom-events-form")
assert element_exists?(
html,
~s/button[role=switch][aria-labelledby=enable-revenue-tracking][disabled="disabled"]/
)
end
test "updates a custom event", %{conn: conn, site: site} do
{:ok, [_, g, _]} = setup_goals(site)
lv = get_liveview(conn, site)
lv |> element(~s/button#edit-goal-#{g.id}/) |> render_click()
html = render(lv)
assert element_exists?(html, "#event_name_input_modalseq0[value=Signup]")
lv
|> element("#goals-form-modalseq0 form")
|> render_submit(%{goal: %{event_name: "Updated", display_name: "UPDATED"}})
_html = render(lv)
updated = Plausible.Goals.get(site, g.id)
assert updated.event_name == "Updated"
assert updated.display_name == "UPDATED"
assert updated.id == g.id
end
@tag :ee_only
test "updates a revenue goal", %{conn: conn, site: site} do
{:ok, [_, _, g]} = setup_goals(site)
lv = get_liveview(conn, site)
lv |> element(~s/button#edit-goal-#{g.id}/) |> render_click()
html = render(lv)
assert element_exists?(html, "#event_name_input_modalseq0[value=Purchase]")
assert element_exists?(html, ~s/#currency_input_modalseq0[value="EUR - Euro"]/)
lv
|> element("#goals-form-modalseq0 form")
|> render_submit(%{goal: %{event_name: "Updated", currency: "USD"}})
_html = render(lv)
updated = Plausible.Goals.get(site, g.id)
assert updated.event_name == "Updated"
assert updated.display_name == "Purchase"
assert updated.currency == :USD
assert updated.currency != g.currency
assert updated.id == g.id
end
test "updates a pageview", %{conn: conn, site: site} do
{:ok, [g, _, _]} = setup_goals(site)
lv = get_liveview(conn, site)
lv |> element(~s/button#edit-goal-#{g.id}/) |> render_click()
html = render(lv)
assert element_exists?(html, ~s|#page_path_input_modalseq0[value="/go/to/blog/**"|)
lv
|> element("#goals-form-modalseq0 form")
|> render_submit(%{goal: %{page_path: "/updated", display_name: "Visit /updated"}})
_html = render(lv)
updated = Plausible.Goals.get(site, g.id)
assert updated.page_path == "/updated"
assert updated.display_name == "Visit /updated"
assert updated.id == g.id
end
end
describe "Combos integration" do
setup [:create_user, :log_in, :create_site]

View File

@ -41,9 +41,14 @@ defmodule PlausibleWeb.Live.GoalSettingsTest do
assert g3.currency
assert resp =~ to_string(g3)
assert resp =~ "Unlock Revenue Goals by upgrading to a business plan"
refute element_exists?(
resp,
~s/button[phx-click="edit-goal"][phx-value-goal-id=#{g3.id}][disabled]#edit-goal-#{g3.id}/
)
end
test "lists goals with delete actions", %{conn: conn, site: site} do
test "lists goals with actions", %{conn: conn, site: site} do
{:ok, goals} = setup_goals(site)
conn = get(conn, "/#{site.domain}/settings/goals")
resp = html_response(conn, 200)
@ -53,6 +58,11 @@ defmodule PlausibleWeb.Live.GoalSettingsTest do
resp,
~s/button[phx-click="delete-goal"][phx-value-goal-id=#{g.id}]#delete-goal-#{g.id}/
)
assert element_exists?(
resp,
~s/button[phx-click="edit-goal"][phx-value-goal-id=#{g.id}]#edit-goal-#{g.id}/
)
end
end
@ -75,10 +85,7 @@ defmodule PlausibleWeb.Live.GoalSettingsTest do
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#add-goal-button[x-data]/)
attr = text_of_attr(resp, ~s/button#add-goal-button/, "x-on:click")
assert attr =~ "open-modal"
assert attr =~ "goals-form-modal"
assert element_exists?(resp, ~s/button#add-goal-button[phx-click="add-goal"]/)
end
test "search goals input is rendered", %{conn: conn, site: site} do

View File

@ -107,8 +107,32 @@ defmodule Plausible.Factory do
}
end
def goal_factory do
%Plausible.Goal{}
def goal_factory(attrs) do
display_name_provided? = Map.has_key?(attrs, :display_name)
attrs =
case {attrs, display_name_provided?} do
{%{page_path: path}, false} when is_binary(path) ->
Map.put(attrs, :display_name, "Visit " <> path)
{%{page_path: path}, false} when is_function(path, 0) ->
attrs
|> Map.put(:display_name, "Visit " <> path.())
|> Map.put(:page_path, path.())
{%{event_name: event_name}, false} when is_binary(event_name) ->
Map.put(attrs, :display_name, event_name)
{%{event_name: event_name}, false} when is_function(event_name, 0) ->
attrs
|> Map.put(:display_name, event_name.())
|> Map.put(:event_name, event_name.())
_ ->
attrs
end
merge_attributes(%Plausible.Goal{}, attrs)
end
def subscription_factory do