Funnel site settings (#3039)

* Update formatter config

* Install LiveView JS integration & hooks

* Temporarily update endpoint/session config

* Optionally allow preloading funnels for goals

* Site controller

* Implement funnel settings

lib/plausible_web/live/funnel_settings/combo_box.ex - restored from:

054de6e2 Fix the tab/blur bug again
20da4c89 Rename InputPicker to ComboBox

lib/plausible_web/live/funnel_settings/form.ex - restored from:

9bedda3b Remove potential FIXME
20da4c89 Rename InputPicker to ComboBox
028036ad Review comments
aea4ebc4 Access Funnel min/max steps via the __using__/1 macro
0dde27fd Remove inspect call
eed588a7 Start testing the funnel editor
0e95228b Extract funnel settings test module
7b16ace5 Leverage aplinejs to deal with the tyranny
8dc6a3e7 wip
cf228630 wip
30a43fd1 wip
89f10ecb wip
950a18d9 Dirty funnel save
298a6a53 wip
7690d50f wip
639c6238 fixup
aa59adeb wip
ff75c00b wip

lib/plausible_web/live/funnel_settings/list.ex - restored from:

4eae122c Fix data-confirm attr interpolation
51f0397d Implement deleting funnels
1f6fe25d Add number of steps to funnels list
298a6a53 wip
ff75c00b wip

test/plausible_web/live/funnel_settings/funnel_settings/combo_box_test.exs - restored from:

20da4c89 Rename InputPicker to ComboBox

test/plausible_web/live/funnel_settings/funnel_settings_test.exs - restored from:

34822ff4 Bootstrap InputPicker tests

lib/plausible_web/live/funnel_settings.ex - restored from:

028036ad Review comments
acd9c4f2 Prepare ephemeral funnel definitions so that users can test funnels
51f0397d Implement deleting funnels
0e95228b Extract funnel settings test module
8dc6a3e7 wip
89f10ecb wip
950a18d9 Dirty funnel save
298a6a53 wip
aa59adeb wip
ff75c00b wip

test/plausible_web/controllers/error_report_controller_test.exs - restored from:

34822ff4 Bootstrap InputPicker tests

test/support/html.ex - restored from:

0a53979d Improve InputPicker tests - include AlpineJS assertions
34822ff4 Bootstrap InputPicker tests

lib/plausible_web/views/layout_view.ex - restored from:

b490403b !ifxup

lib/plausible_web/templates/site/settings_funnels.html.eex - restored from:

51f0397d Implement deleting funnels
ea1315f3 Test funnels list in settings
7b16ace5 Leverage aplinejs to deal with the tyranny
ff75c00b wip
4da25c35 Fixup

lib/plausible_web/templates/layout/app.html.eex - restored from:

ff75c00b wip

* Add funnel settings route

* Warn about funnels deletion when deleting goals

lib/plausible_web/templates/site/settings_goals.html.eex - restored from:

fdd9bcd0 Fixup
f1e6364d Merge remote-tracking branch 'origin/master' into funnels-rebase
9d0b7c6d Fix markup error
4a4ddbdc Optionally preload funnels for goals and stub funnel-goal deletion
ebdc4333 Extend the prompt in case of funnel-goal deletion
639c6238 fixup
aa59adeb wip

* Split new JS LiveView additions

* Put funnels behind a feature flag

* Integrate dashboard feature toggle

* Update signing salt for live view

* Update moduledocs

* Update live reloader config

* Use Phoenix.HTML.Safe for goal names

* Workaround to get flashes working in embedded liveview

* Keep feature toggles idempotent, rename property to setting

We'll still retain the ability to flip bools on a lower level.

* Update moduledocs

* Make live flash disappear after 5s

* Tailwind: purge .heex files too

* Update docs link

* Add live components to tailwind purge config

* Update another flaky test

Ref f0bdf872
cc @vinibrsl

* Fix combobox input length w/ WebKit

* Intoduce generic notice component

* Revert "Fix combobox input length w/ WebKit"

This reverts commit 3c653a6d85d5000167631e10ef45a93c13b41ed1.

* Fix combobox input length on webkit

* Make whole combobox item clickable, not only text

* Fix glitch moving Save button on activation

* Tweak dark mode

* Show funnel form without waiting for funnel name input

* Tweak dark mode

* Include static Phoenix components in tailwind purge

* Tune funnels form into a liveview of its own

This is so that ComboBoxes can publish their selections
and unavailable choices can be propagated to other siblings.

* Push less data over websocket

* Undo Lsp/formatter race condition

* Fixup typespecs

* Bust CI cache
This commit is contained in:
hq1 2023-06-22 09:00:07 +02:00 committed by GitHub
parent 086045bde8
commit d8543c81cc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 1891 additions and 215 deletions

View File

@ -1,4 +1,5 @@
[
plugins: [Phoenix.LiveView.HTMLFormatter],
import_deps: [:ecto, :ecto_sql, :phoenix],
subdirectories: ["priv/*/migrations"],
inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"]

View File

@ -58,8 +58,8 @@ jobs:
deps
_build
priv/plts
key: ${{ runner.os }}-mix-v4-${{ hashFiles('**/mix.lock') }}
restore-keys: ${{ runner.os }}-mix-v4-
key: ${{ runner.os }}-mix-v5-${{ hashFiles('**/mix.lock') }}
restore-keys: ${{ runner.os }}-mix-v5-
- name: Install dependencies
run: mix deps.get && npm install --prefix ./tracker
- name: Check Formatting

View File

@ -2,8 +2,10 @@ import "../css/app.css"
import "flatpickr/dist/flatpickr.min.css"
import "./polyfills/closest"
import 'abortcontroller-polyfill/dist/polyfill-patch-fetch'
import "phoenix_html"
import 'alpinejs'
import "./liveview/live_socket"
import "./liveview/suggestions_dropdown"
import "./liveview/phx_events"
const triggers = document.querySelectorAll('[data-dropdown-trigger]')
@ -49,7 +51,7 @@ if (registerForm) {
}
}
/* eslint-disable-next-line no-undef */
plausible('Signup', {callback: submitForm});
plausible('Signup', { callback: submitForm });
})
}
@ -58,12 +60,12 @@ const changelogNotification = document.getElementById('changelog-notification')
if (changelogNotification) {
showChangelogNotification(changelogNotification)
fetch('https://plausible.io/changes.txt', {headers: {'Content-Type': 'text/plain'}})
fetch('https://plausible.io/changes.txt', { headers: { 'Content-Type': 'text/plain' } })
.then((res) => res.text())
.then((res) => {
localStorage.lastChangelogUpdate = new Date(res).getTime()
showChangelogNotification(changelogNotification)
})
})
}
function showChangelogNotification(el) {
@ -71,7 +73,7 @@ function showChangelogNotification(el) {
const lastChecked = Number(localStorage.lastChangelogClick)
const hasNewUpdateSinceLastClicked = lastUpdated > lastChecked
const notOlderThanThreeDays = Date.now() - lastUpdated < 1000 * 60 * 60 * 72
const notOlderThanThreeDays = Date.now() - lastUpdated < 1000 * 60 * 60 * 72
if ((!lastChecked || hasNewUpdateSinceLastClicked) && notOlderThanThreeDays) {
el.innerHTML = `
<a href="https://plausible.io/changelog" target="_blank">

View File

@ -0,0 +1,18 @@
import "phoenix_html"
import { Socket } from "phoenix"
import { LiveSocket } from "phoenix_live_view"
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let liveSocket = new LiveSocket("/live", Socket, {
params: { _csrf_token: csrfToken }, hooks: {}, dom: {
// for alpinejs integration
onBeforeElUpdated(from, to) {
if (from.__x) {
window.Alpine.clone(from.__x, to);
}
},
}
})
liveSocket.connect()
window.liveSocket = liveSocket

View File

@ -0,0 +1,7 @@
window.addEventListener(`phx:update-value`, (e) => {
let el = document.getElementById(e.detail.id)
el.value = e.detail.value
if (e.detail.fire) {
el.dispatchEvent(new Event("input", { bubbles: true }))
}
})

View File

@ -0,0 +1,40 @@
// Courtesy of Benjamin von Polheim:
// https://blog.devgenius.io/build-a-performat-autocomplete-using-phoenix-liveview-and-alpine-js-8bcbbed17ba7
let suggestionsDropdown = function(id) {
return {
isOpen: false,
id: id,
open() { this.isOpen = true },
close() { this.isOpen = false },
focus: 0,
setFocus(f) {
this.focus = f;
},
select() {
this.$refs[`dropdown-${this.id}-option-${this.focus}`]?.click()
this.focusPrev()
},
scrollTo(idx) {
this.$refs[`dropdown-${this.id}-option-${idx}`]?.scrollIntoView(
{ block: 'center', behavior: 'smooth', inline: 'start' }
)
},
focusNext() {
const nextIndex = this.focus + 1
const total = this.$refs.suggestions?.childElementCount ?? 0
if (this.isOpen && nextIndex < total) {
this.setFocus(nextIndex)
this.scrollTo(nextIndex);
}
},
focusPrev() {
const nextIndex = this.focus - 1
if (this.isOpen && nextIndex >= 0) {
this.setFocus(nextIndex)
this.scrollTo(nextIndex)
}
},
}
}
window.suggestionsDropdown = suggestionsDropdown

View File

@ -5,6 +5,9 @@ module.exports = {
content: [
'./js/**/*.js',
'../lib/plausible_web/templates/**/*.html.eex',
'../lib/plausible_web/templates/**/*.html.heex',
'../lib/plausible_web/live/**/*.ex',
'../lib/plausible_web/components/**/*.ex',
],
safelist: [
// PlausibleWeb.StatsView.stats_container_class/1 uses this class

View File

@ -4,6 +4,8 @@ config :plausible,
ecto_repos: [Plausible.Repo, Plausible.IngestRepo]
config :plausible, PlausibleWeb.Endpoint,
# Does not to have to be secret, as per: https://github.com/phoenixframework/phoenix/issues/2146
live_view: [signing_salt: "f+bZg/crMtgjZJJY7X6OwIWc3XJR2C5Y"],
pubsub_server: Plausible.PubSub,
render_errors: [
view: PlausibleWeb.ErrorView,

View File

@ -25,8 +25,10 @@ config :plausible, PlausibleWeb.Endpoint,
~r{priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$},
~r{lib/plausible_web/views/.*(ex)$},
~r{lib/plausible_web/templates/.*(eex)$},
~r{lib/plausible_web/templates/.*(heex)$},
~r{lib/plausible_web/controllers/.*(ex)$},
~r{lib/plausible_web/plugs/.*(ex)$}
~r{lib/plausible_web/plugs/.*(ex)$},
~r{lib/plausible_web/live/.*(ex)$}
]
]

View File

@ -13,6 +13,11 @@ defmodule Plausible.Funnels do
import Ecto.Query
@spec enabled_for?(any()) :: boolean()
def enabled_for?(actor) do
FunWithFlags.enabled?(:funnels, for: actor)
end
@spec create(Plausible.Site.t(), String.t(), [Plausible.Goal.t()]) ::
{:ok, Funnel.t()}
| {:error, Ecto.Changeset.t() | :invalid_funnel_size}

View File

@ -91,6 +91,17 @@ defmodule Plausible.Goals do
query
end
query =
if opts[:preload_funnels?] do
from(g in query,
left_join: assoc(g, :funnels),
group_by: g.id,
preload: [:funnels]
)
else
query
end
query
|> Repo.all()
|> Enum.map(&maybe_trim/1)
@ -149,10 +160,9 @@ defmodule Plausible.Goals do
|> Multi.delete_all(
:delete_goals,
fn _ ->
from(g in Goal,
from g in Goal,
where: g.id == ^id,
where: g.site_id == ^site.id
)
end
)
|> Repo.transaction()
@ -163,6 +173,17 @@ defmodule Plausible.Goals do
end
end
@spec count(Plausible.Site.t()) :: non_neg_integer()
def count(site) do
Repo.aggregate(
from(
g in Goal,
where: g.site_id == ^site.id
),
:count
)
end
defp maybe_trim(%Goal{} = goal) do
# we make sure that even if we saved goals erroneously with trailing
# space, it's removed during fetch

View File

@ -18,9 +18,9 @@ defmodule Plausible.Site do
field :stats_start_date, :date
field :native_stats_start_at, :naive_datetime
field :allowed_event_props, {:array, :string}
field :conversions_enabled, :boolean
field :props_enabled, :boolean
field :funnels_enabled, :boolean
field :conversions_enabled, :boolean, default: true
field :props_enabled, :boolean, default: true
field :funnels_enabled, :boolean, default: true
field :ingest_rate_limit_scale_seconds, :integer, default: 60
# default is set via changeset/2
@ -170,13 +170,20 @@ defmodule Plausible.Site do
change(site, allowed_event_props: list)
end
def disable_feature(site, "conversions"), do: change(site, conversions_enabled: false)
def disable_feature(site, "funnels"), do: change(site, funnels_enabled: false)
def disable_feature(site, "props"), do: change(site, props_enabled: false)
@togglable_features ~w[conversions_enabled funnels_enabled props_enabled]a
def feature_toggle_change(site, property, opts \\ [])
when property in @togglable_features do
override = Keyword.get(opts, :override)
def enable_feature(site, "conversions"), do: change(site, conversions_enabled: true)
def enable_feature(site, "funnels"), do: change(site, funnels_enabled: true)
def enable_feature(site, "props"), do: change(site, props_enabled: true)
attrs =
if is_boolean(override) do
%{property => override}
else
%{property => !Map.fetch!(site, property)}
end
cast(site, attrs, @togglable_features)
end
def remove_imported_data(site) do
change(site, imported_data: nil)

View File

@ -0,0 +1,40 @@
defmodule PlausibleWeb.Components.Generic do
@moduledoc """
Generic reusable components
"""
use Phoenix.Component
attr :title, :string, default: "Notice"
slot :inner_block
def notice(assigns) do
~H"""
<div class="rounded-md bg-yellow-50 p-4 dark:bg-transparent dark:border border-yellow-200">
<div class="flex">
<div class="flex-shrink-0">
<svg
class="h-5 w-5 text-yellow-400"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-yellow-800 dark:text-yellow-400"><%= @title %></h3>
<div class="mt-2 text-sm text-yellow-700 dark:text-gray-400">
<p>
<%= render_slot(@inner_block) %>
</p>
</div>
</div>
</div>
</div>
"""
end
end

View File

@ -0,0 +1,56 @@
defmodule PlausibleWeb.Components.Site.Feature do
@moduledoc """
Phoenix Component for rendering a user-facing feature toggle
capable of flipping booleans in `Plausible.Site` via the `toggle_feature` controller action.
"""
use PlausibleWeb, :view
attr :site, Plausible.Site, required: true
attr :setting, :atom, required: true
attr :label, :string, required: true
attr :conn, Plug.Conn, required: true
slot :inner_block
def toggle(assigns) do
~H"""
<div>
<div class="mt-4 mb-8 flex items-center">
<%= if Map.fetch!(@site, @setting) do %>
<.button_active to={target(@site, @setting, @conn, false)} />
<% else %>
<.button_inactive to={target(@site, @setting, @conn, true)} />
<% end %>
<span class="ml-2 text-sm font-medium text-gray-900 leading-5 dark:text-gray-100">
<%= @label %>
</span>
</div>
<div :if={Map.fetch!(@site, @setting)}>
<%= render_slot(@inner_block) %>
</div>
</div>
"""
end
def target(site, setting, conn, set_to) when is_boolean(set_to) do
r = conn.request_path
Routes.site_path(conn, :update_feature_visibility, site.domain, setting, r: r, set: set_to)
end
def button_active(assigns) do
~H"""
<%= button(to: @to, method: :put, class: "bg-indigo-600 relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring") do %>
<span class="translate-x-5 inline-block h-5 w-5 rounded-full bg-white dark:bg-gray-800 shadow transform transition ease-in-out duration-200">
</span>
<% end %>
"""
end
def button_inactive(assigns) do
~H"""
<%= button(to: @to, method: :put, class: "bg-gray-200 dark:bg-gray-700 relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring") do %>
<span class="translate-x-0 inline-block h-5 w-5 rounded-full bg-white dark:bg-gray-800 shadow transform transition ease-in-out duration-200">
</span>
<% end %>
"""
end
end

View File

@ -36,8 +36,15 @@ defmodule PlausibleWeb.Api.InternalController do
with %User{id: user_id} <- conn.assigns[:current_user],
site <- Sites.get_by_domain(domain),
true <- Sites.has_admin_access?(user_id, site) || Auth.is_super_admin?(user_id) do
Site.disable_feature(site, feature)
|> Repo.update()
property =
case feature do
"funnels" -> :funnels_enabled
"props" -> :props_enabled
"conversions" -> :conversions_enabled
end
change = Plausible.Site.feature_toggle_change(site, property, override: false)
Repo.update!(change)
json(conn, "ok")
else

View File

@ -171,32 +171,45 @@ defmodule PlausibleWeb.SiteController do
end
end
def set_feature_status(conn, %{"action" => action, "feature" => feature}) do
@feature_titles %{
funnels_enabled: "Funnels",
conversions_enabled: "Goals",
props_enabled: "Properties"
}
def update_feature_visibility(conn, %{
"setting" => setting,
"r" => "/" <> _ = redirect_path,
"set" => value
})
when setting in ~w[conversions_enabled funnels_enabled props_enabled] and
value in ["true", "false"] do
site = conn.assigns[:site]
report_title =
case feature do
"conversions" -> "Goals"
"funnels" -> "Funnels"
"props" -> "Properties"
end
setting = String.to_existing_atom(setting)
change = Plausible.Site.feature_toggle_change(site, setting, override: value == "true")
result = Repo.update(change)
{change, flash_msg} =
case action do
"enable" ->
{Plausible.Site.enable_feature(site, feature),
"#{report_title} are now visible again on your dashboard"}
case result do
{:ok, updated_site} ->
message =
if Map.fetch!(updated_site, setting) do
"#{@feature_titles[setting]} are now visible again on your dashboard"
else
"#{@feature_titles[setting]} are now hidden from your dashboard"
end
"disable" ->
{Plausible.Site.disable_feature(site, feature),
"#{report_title} are now hidden from your dashboard"}
end
conn
|> put_flash(:success, message)
|> redirect(to: redirect_path)
Repo.update(change)
conn
|> put_flash(:success, flash_msg)
|> redirect(to: Routes.site_path(conn, :settings_goals, conn.assigns[:site].domain))
{:error, _} ->
conn
|> put_flash(
:error,
"Something went wrong. Failed to toggle #{@feature_titles[setting]} on your dashboard."
)
|> redirect(to: redirect_path)
end
end
def settings(conn, %{"website" => website}) do
@ -253,7 +266,7 @@ defmodule PlausibleWeb.SiteController do
def settings_goals(conn, _params) do
site = conn.assigns[:site] |> Repo.preload(:custom_domain)
goals = Goals.for_site(site)
goals = Goals.for_site(site, preload_funnels?: true)
conn
|> assign(:skip_plausible_tracking, true)
@ -264,6 +277,21 @@ defmodule PlausibleWeb.SiteController do
)
end
def settings_funnels(conn, _params) do
if Plausible.Funnels.enabled_for?(conn.assigns[:current_user]) do
site = conn.assigns[:site] |> Repo.preload(:custom_domain)
conn
|> assign(:skip_plausible_tracking, true)
|> render("settings_funnels.html",
site: site,
layout: {PlausibleWeb.LayoutView, "site_settings.html"}
)
else
conn |> Plug.Conn.put_status(401) |> Plug.Conn.halt()
end
end
def settings_search_console(conn, _params) do
site =
conn.assigns[:site]
@ -877,11 +905,10 @@ defmodule PlausibleWeb.SiteController do
cond do
site.imported_data ->
Oban.cancel_all_jobs(
from(j in Oban.Job,
from j in Oban.Job,
where:
j.queue == "google_analytics_imports" and
fragment("(? ->> 'site_id')::int", j.args) == ^site.id
)
)
Plausible.Imported.forget(site)

View File

@ -328,7 +328,7 @@ defmodule PlausibleWeb.StatsController do
defp get_flags(user) do
%{
funnels: FunWithFlags.enabled?(:funnels, for: user),
funnels: Plausible.Funnels.enabled_for?(user),
props: FunWithFlags.enabled?(:props, for: user)
}
end

View File

@ -2,6 +2,15 @@ defmodule PlausibleWeb.Endpoint do
use Sentry.PlugCapture
use Phoenix.Endpoint, otp_app: :plausible
@session_options [
store: :cookie,
key: "_plausible_key",
signing_salt: "3IL0ob4k",
# 5 years, this is super long but the SlidingSessionTimeout will log people out if they don't return for 2 weeks
max_age: 60 * 60 * 24 * 365 * 5,
extra: "SameSite=Lax"
]
# Serve at "/" the static files from "priv/static" directory.
#
# You should set gzip to true if you are running phx.digest
@ -43,13 +52,9 @@ defmodule PlausibleWeb.Endpoint do
plug Plug.MethodOverride
plug Plug.Head
plug Plug.Session,
store: :cookie,
key: "_plausible_key",
signing_salt: "3IL0ob4k",
# 5 years, this is super long but the SlidingSessionTimeout will log people out if they don't return for 2 weeks
max_age: 60 * 60 * 24 * 365 * 5,
extra: "SameSite=Lax"
plug Plug.Session, @session_options
socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]]
plug CORSPlug
plug PlausibleWeb.Router

View File

@ -0,0 +1,137 @@
defmodule PlausibleWeb.Live.Flash do
@moduledoc """
Flash component for LiveViews - works also when embedded within dead views
"""
use Phoenix.LiveComponent
alias Phoenix.LiveView.JS
alias Phoenix.Flash
def render(assigns) do
~H"""
<div id="liveview-flash">
<div :if={@flash != %{}}>
<.flash>
<:icon>
<.icon_success :if={Flash.get(@flash, :success)} />
<.icon_error :if={Flash.get(@flash, :error)} />
</:icon>
<:title :if={Flash.get(@flash, :success)}>
<%= Flash.get(@flash, :success_title) || "Success!" %>
</:title>
<:message>
<%= Flash.get(@flash, :success) %>
</:message>
</.flash>
</div>
<div
id="live-view-connection-status"
class="hidden"
phx-disconnected={JS.show()}
phx-connected={JS.hide()}
>
<.flash on_close={JS.hide(to: "#live-view-connection-status")}>
<:icon>
<.icon_error />
</:icon>
<:title>
Oops, a server blip
</:title>
<:message>
Please wait while we're trying to reconnect...
</:message>
</.flash>
</div>
</div>
"""
end
slot(:icon, required: true)
slot(:title, require: true)
slot(:message, required: true)
attr(:on_close, :any, default: "lv:clear-flash")
def flash(assigns) do
~H"""
<div class="z-50 fixed inset-0 flex items-end justify-center px-4 py-6 pointer-events-none sm:p-6 sm:items-start sm:justify-end">
<div class="max-w-sm w-full bg-white dark:bg-gray-800 shadow-lg rounded-lg pointer-events-auto">
<div class="rounded-lg ring-1 ring-black ring-opacity-5 overflow-hidden">
<div class="p-4">
<div class="flex items-start">
<%= render_slot(@icon) %>
<div class="ml-3 w-0 flex-1 pt-0.5">
<p class="text-sm leading-5 font-medium text-gray-900 dark:text-gray-100">
<%= render_slot(@title) %>
</p>
<p class="mt-1 text-sm leading-5 text-gray-500 dark:text-gray-200">
<%= render_slot(@message) %>
</p>
</div>
<div class="ml-4 flex-shrink-0 flex">
<.clear_flash_button on_close={@on_close} />
</div>
</div>
</div>
</div>
</div>
</div>
"""
end
def icon_success(assigns) do
~H"""
<div class="flex-shrink-0">
<svg
class="h-6 w-6 text-green-400"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
"""
end
def icon_error(assigns) do
~H"""
<svg
class="w-6 h-6 text-red-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
>
</path>
</svg>
"""
end
def clear_flash_button(assigns) do
~H"""
<button
class="inline-flex text-gray-400 focus:outline-none focus:text-gray-500 dark:focus:text-gray-200 transition ease-in-out duration-150"
phx-click={@on_close}
>
<svg class="h-5 w-5" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
</button>
"""
end
end

View File

@ -0,0 +1,99 @@
defmodule PlausibleWeb.Live.FunnelSettings do
@moduledoc """
LiveView allowing listing, creating and deleting funnels.
"""
use Phoenix.LiveView
use Phoenix.HTML
use Plausible.Funnel
alias Plausible.{Sites, Goals, Funnels}
def mount(
_params,
%{"site_id" => _site_id, "domain" => domain, "current_user_id" => user_id},
socket
) do
true = Plausible.Funnels.enabled_for?("user:#{user_id}")
site = Sites.get_for_user!(user_id, domain, [:owner, :admin])
funnels = Funnels.list(site)
goal_count = Goals.count(site)
{:ok,
assign(socket,
site_id: site.id,
domain: site.domain,
funnels: funnels,
goal_count: goal_count,
add_funnel?: false,
current_user_id: user_id
)}
end
# Flash sharing with live views within dead views can be done via re-rendering the flash partial.
# Normally, we'd have to use live_patch which we can't do with views unmounted at the router it seems.
def render(assigns) do
~H"""
<div id="funnel-settings-main">
<.live_component id="embedded_liveview_flash" module={PlausibleWeb.Live.Flash} flash={@flash} />
<%= if @add_funnel? do %>
<%= live_render(
@socket,
PlausibleWeb.Live.FunnelSettings.Form,
id: "funnels-form",
session: %{
"current_user_id" => @current_user_id,
"domain" => @domain
}
) %>
<% else %>
<div :if={@goal_count >= Funnel.min_steps()}>
<.live_component
module={PlausibleWeb.Live.FunnelSettings.List}
id="funnels-list"
funnels={@funnels}
/>
<button type="button" class="button mt-6" phx-click="add-funnel">+ Add funnel</button>
</div>
<div :if={@goal_count < Funnel.min_steps()}>
<PlausibleWeb.Components.Generic.notice>
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),
class: "text-indigo-500 w-full text-center"
) %> to proceed.
</PlausibleWeb.Components.Generic.notice>
</div>
<% end %>
</div>
"""
end
def handle_event("add-funnel", _value, socket) do
{:noreply, assign(socket, add_funnel?: true)}
end
def handle_event("cancel-add-funnel", _value, socket) do
{:noreply, assign(socket, add_funnel?: false)}
end
def handle_event("delete-funnel", %{"funnel-id" => id}, socket) do
id = String.to_integer(id)
:ok = Funnels.delete(socket.assigns.site, id)
socket = put_flash(socket, :success, "Funnel deleted successfully")
Process.send_after(self(), :clear_flash, 5000)
{:noreply, assign(socket, funnels: Funnels.list(socket.assigns.site))}
end
def handle_info({:funnel_saved, funnel}, socket) do
socket = put_flash(socket, :success, "Funnel saved successfully")
Process.send_after(self(), :clear_flash, 5000)
{:noreply, assign(socket, add_funnel?: false, funnels: [funnel | socket.assigns.funnels])}
end
def handle_info(:clear_flash, socket) do
{:noreply, clear_flash(socket)}
end
end

View File

@ -0,0 +1,257 @@
defmodule PlausibleWeb.Live.FunnelSettings.ComboBox do
@moduledoc """
Phoenix LiveComponent for a combobox UI element with search and selection
functionality.
The component allows users to select an option from a list of options,
which can be searched by typing in the input field.
The component renders an input field with a dropdown anchor and a
hidden input field for submitting the selected value.
The number of options displayed in the dropdown is limited to 15
by default but can be customized. When a user types into the input
field, the component searches the available options and provides
suggestions based on the input.
"""
use Phoenix.LiveComponent
alias Phoenix.LiveView.JS
@max_options_displayed 15
def update(assigns, socket) do
socket =
socket
|> assign(assigns)
|> assign_new(:suggestions, fn ->
Enum.take(assigns.options, @max_options_displayed)
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(:submit_name, :string, required: true)
attr(:display_value, :string, default: "")
attr(:submit_value, :string, default: "")
def render(assigns) do
~H"""
<div
id={"input-picker-main-#{@id}"}
class="mb-3"
x-data={"window.suggestionsDropdown('#{@id}')"}
x-on:keydown.arrow-up="focusPrev"
x-on:keydown.arrow-down="focusNext"
x-on:keydown.enter="select()"
x-on:keydown.tab="close"
>
<div class="relative w-full">
<div
@click.away="close"
class="pl-2 pr-8 py-1 w-full dark:bg-gray-900 dark:text-gray-300 rounded-md shadow-sm border border-gray-300 dark:border-gray-700 focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500"
>
<input
type="text"
autocomplete="off"
id={@id}
name={"display-#{@id}"}
placeholder={@placeholder}
x-on:focus="open"
phx-change="search"
phx-target={@myself}
value={@display_value}
class="border-none py-1 px-1 p-0 w-full inline-block rounded-md focus:outline-none focus:ring-0 text-sm"
style="background-color: inherit;"
/>
<.dropdown_anchor id={@id} />
<input
type="hidden"
name={@submit_name}
value={@submit_value}
phx-target={@myself}
id={"submit-#{@id}"}
/>
</div>
</div>
<.dropdown ref={@id} options={@options} suggestions={@suggestions} target={@myself} />
</div>
"""
end
attr(:id, :any, required: true)
def dropdown_anchor(assigns) do
~H"""
<div x-on:click="open" class="cursor-pointer absolute inset-y-0 right-0 flex items-center pr-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
class="h-4 w-4 text-gray-500"
>
<path
fill-rule="evenodd"
d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
clip-rule="evenodd"
>
</path>
</svg>
</div>
"""
end
attr(:ref, :string, required: true)
attr(:options, :list, default: [])
attr(:suggestions, :list, default: [])
attr(:target, :any)
def dropdown(assigns) do
~H"""
<ul
tabindex="-1"
id={"dropdown-#{@ref}"}
x-show="isOpen"
x-ref="suggestions"
class="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
:for={
{{submit_value, display_value}, idx} <-
Enum.with_index(
@suggestions,
fn {option_value, option}, idx -> {{option_value, to_string(option)}, idx} end
)
}
:if={@suggestions != []}
idx={idx}
submit_value={submit_value}
display_value={display_value}
target={@target}
ref={@ref}
/>
<div
:if={@suggestions == []}
class="relative cursor-default select-none py-2 px-4 text-gray-700 dark:text-gray-300"
>
No matches found. Try searching for something different.
</div>
</ul>
"""
end
attr(:display_value, :string, required: true)
attr(:submit_value, :integer, required: true)
attr(:ref, :string, required: true)
attr(:target, :any)
attr(:idx, :integer, required: true)
def option(assigns) do
assigns = assign(assigns, :max_options_displayed, @max_options_displayed)
~H"""
<li
class="relative select-none cursor-pointer dark:text-gray-300"
@mouseenter={"setFocus(#{@idx})"}
x-bind:class={ "{'text-white bg-indigo-500': focus === #{@idx}}" }
id={"dropdown-#{@ref}-option-#{@idx}"}
>
<a
x-ref={"dropdown-#{@ref}-option-#{@idx}"}
phx-click={select_option(@ref, @submit_value, @display_value)}
phx-value-display-value={@display_value}
phx-target={@target}
class="block py-2 px-3"
>
<span class="block truncate">
<%= @display_value %>
</span>
</a>
</li>
<li :if={@idx == @max_options_displayed - 1} class="text-xs text-gray-500 relative py-2 px-3">
Max results reached. Refine your search by typing in goal name.
</li>
"""
end
def select_option(js \\ %JS{}, _id, submit_value, display_value) do
js
|> JS.push("select-option",
value: %{"submit-value" => submit_value, "display-value" => display_value}
)
end
def handle_event(
"select-option",
%{"submit-value" => submit_value, "display-value" => display_value},
socket
) do
socket = do_select(socket, submit_value, display_value)
{:noreply, socket}
end
def handle_event("search", %{"_target" => [target]} = params, socket) do
input = params[target]
input_len = input |> String.trim() |> String.length()
if input_len > 0 do
suggestions = suggest(input, socket.assigns.options)
{:noreply, assign(socket, %{suggestions: suggestions})}
else
{:noreply, socket}
end
end
def suggest(input, options) do
input_len = String.length(input)
options
|> Enum.reject(fn {_, value} ->
input_len > String.length(to_string(value))
end)
|> Enum.sort_by(
fn {_, value} ->
if to_string(value) == input do
3
else
value = to_string(value)
input = String.downcase(input)
value = String.downcase(value)
weight = if String.contains?(value, input), do: 1, else: 0
weight + String.jaro_distance(value, input)
end
end,
:desc
)
|> Enum.take(@max_options_displayed)
end
defp do_select(socket, submit_value, display_value) do
id = socket.assigns.id
socket =
socket
|> push_event("update-value", %{id: id, value: display_value, fire: false})
|> push_event("update-value", %{id: "submit-#{id}", value: submit_value, fire: true})
|> assign(:display_value, display_value)
|> assign(:submit_value, submit_value)
send(
self(),
{:selection_made,
%{
by: id,
submit_value: submit_value
}}
)
socket
end
end

View File

@ -0,0 +1,271 @@
defmodule PlausibleWeb.Live.FunnelSettings.Form do
@moduledoc """
Phoenix LiveComponent that renders a form used for setting up funnels.
Makes use of dynamically placed `PlausibleWeb.Live.FunnelSettings.ComboBox` components
to allow building searchable funnel definitions out of list of goals available.
"""
use Phoenix.LiveView
use Phoenix.HTML
use Plausible.Funnel
alias Plausible.{Sites, Goals}
def mount(_params, %{"current_user_id" => user_id, "domain" => domain}, socket) do
site = Sites.get_for_user!(user_id, domain, [:owner, :admin])
# 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{}
# fields, so that `String.Chars` protocol and `Funnels.ephemeral_definition/3`
# are applicable downstream.
goals =
site
|> Goals.for_site()
|> Enum.map(fn goal ->
{goal.id, struct!(Plausible.Goal, Map.take(goal, [:id, :event_name, :page_path]))}
end)
{:ok,
assign(socket,
step_ids: Enum.to_list(1..Funnel.min_steps()),
form: to_form(Plausible.Funnels.create_changeset(site, "", [])),
goals: goals,
site: site,
already_selected: Map.new()
)}
end
def render(assigns) do
~H"""
<div id="funnel-form" class="grid grid-cols-4 gap-6 mt-6">
<div class="col-span-4 sm:col-span-2">
<.form
:let={f}
for={@form}
phx-change="validate"
phx-submit="save"
phx-target="#funnel-form"
onkeydown="return event.key != 'Enter';"
>
<%= label(f, "Funnel name",
class: "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
) %>
<.input field={f[:name]} />
<div id="steps-builder">
<%= label(f, "Funnel Steps",
class: "mt-6 block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
) %>
<div :for={step_idx <- @step_ids} class="flex">
<div class="w-full flex-1">
<.live_component
submit_name="funnel[steps][][goal_id]"
module={PlausibleWeb.Live.FunnelSettings.ComboBox}
id={"step-#{step_idx}"}
options={reject_alrady_selected("step-#{step_idx}", @goals, @already_selected)}
/>
</div>
<.remove_step_button :if={length(@step_ids) > Funnel.min_steps()} step_idx={step_idx} />
</div>
<.add_step_button :if={
length(@step_ids) < Funnel.max_steps() and
map_size(@already_selected) < length(@goals)
} />
<div class="mt-6">
<%= if has_steps_errors?(f) do %>
<.submit_button_inactive />
<% else %>
<.submit_button />
<% end %>
<.cancel_button />
</div>
</div>
</.form>
</div>
</div>
"""
end
attr(:field, Phoenix.HTML.FormField)
def input(assigns) do
~H"""
<div phx-feedback-for={@field.name}>
<input
autocomplete="off"
autofocus
type="text"
id={@field.id}
name={@field.name}
value={@field.value}
phx-debounce="300"
class="focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-900 dark:text-gray-300 block w-full rounded-md sm:text-sm border-gray-300 dark:border-gray-500"
/>
<.error :for={{msg, _} <- @field.errors}>Funnel name <%= msg %></.error>
</div>
"""
end
def error(assigns) do
~H"""
<div class="mt-2 text-sm text-red-600">
<%= render_slot(@inner_block) %>
</div>
"""
end
attr(:step_idx, :integer, required: true)
def remove_step_button(assigns) do
~H"""
<div class="inline-flex items-center ml-2 mb-2 text-red-600">
<svg
id={"remove-step-#{@step_idx}"}
class="feather feather-sm cursor-pointer"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
phx-click="remove-step"
phx-value-step-idx={@step_idx}
>
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2">
</path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
</div>
"""
end
def add_step_button(assigns) do
~H"""
<a class="underline text-indigo-500 text-sm cursor-pointer mt-6" phx-click="add-step">
+ Add another step
</a>
"""
end
def submit_button(assigns) do
~H"""
<button id="save" type="submit" class="button mt-6">Save</button>
"""
end
def submit_button_inactive(assigns) do
~H"""
<button
type="none"
id="save"
class="inline-block mt-6 px-4 py-2 border border-gray-300 dark:border-gray-500 text-sm leading-5 font-medium rounded-md text-gray-300 bg-white dark:bg-gray-800 hover:text-gray-500 dark:hover:text-gray-400 focus:outline-none focus:border-blue-300 focus:ring active:text-gray-800 active:bg-gray-50 transition ease-in-out duration-150 cursor-not-allowed"
>
Save
</button>
"""
end
def cancel_button(assigns) do
~H"""
<button
type="button"
id="cancel"
class="inline-block mt-4 ml-2 px-4 py-2 border border-gray-300 dark:border-gray-500 text-sm leading-5 font-medium rounded-md text-red-700 bg-white dark:bg-gray-800 hover:text-red-500 dark:hover:text-red-400 focus:outline-none focus:border-blue-300 focus:ring active:text-red-800 active:bg-gray-50 transition ease-in-out duration-150 "
phx-click="cancel-add-funnel"
phx-target="#funnel-settings-main"
>
Cancel
</button>
"""
end
def handle_event("add-step", _value, socket) do
step_ids = socket.assigns.step_ids
if length(step_ids) < Funnel.max_steps() do
first_free_idx = find_sequence_break(step_ids)
new_ids = step_ids ++ [first_free_idx]
{:noreply, assign(socket, step_ids: new_ids)}
else
{:noreply, socket}
end
end
def handle_event("remove-step", %{"step-idx" => idx}, socket) do
idx = String.to_integer(idx)
step_ids = List.delete(socket.assigns.step_ids, idx)
already_selected = socket.assigns.already_selected
step_input_id = "step-#{idx}"
new_already_selected = Map.delete(already_selected, step_input_id)
{:noreply, assign(socket, step_ids: step_ids, already_selected: new_already_selected)}
end
def handle_event("validate", %{"funnel" => params}, socket) do
changeset =
socket.assigns.site
|> Plausible.Funnels.create_changeset(
params["name"],
params["steps"] || []
)
|> Map.put(:action, :validate)
{:noreply, assign(socket, form: to_form(changeset))}
end
def handle_event("save", %{"funnel" => params}, %{assigns: %{site: site}} = socket) do
case Plausible.Funnels.create(site, params["name"], params["steps"]) do
{:ok, funnel} ->
send(
socket.parent_pid,
{:funnel_saved, Map.put(funnel, :steps_count, length(params["steps"]))}
)
{:noreply, socket}
{:error, changeset} ->
{:noreply,
assign(socket,
form: to_form(Map.put(changeset, :action, :validate))
)}
end
end
def handle_info({:selection_made, %{submit_value: goal_id, by: combo_box}}, socket) do
already_selected = Map.put(socket.assigns.already_selected, combo_box, goal_id)
{:noreply, assign(socket, already_selected: already_selected)}
end
defp reject_alrady_selected(combo_box, goals, already_selected) do
result = Enum.reject(goals, fn {goal_id, _} -> goal_id in Map.values(already_selected) end)
send_update(PlausibleWeb.Live.FunnelSettings.ComboBox, id: combo_box, suggestions: result)
result
end
defp find_sequence_break(input) do
input
|> Enum.sort()
|> Enum.with_index(1)
|> Enum.reduce_while(nil, fn {x, order}, _ ->
if x != order do
{:halt, order}
else
{:cont, order + 1}
end
end)
end
defp has_steps_errors?(f) do
not f.source.valid?
end
end

View File

@ -0,0 +1,59 @@
defmodule PlausibleWeb.Live.FunnelSettings.List do
@moduledoc """
Phoenix LiveComponent module that renders a list of funnels with their names
and the number of steps they have.
Each funnel is displayed with a delete button, which triggers a confirmation
message before deleting the funnel from the UI. If there are no funnels
configured for the site, a message is displayed indicating so.
"""
use Phoenix.LiveComponent
use Phoenix.HTML
def render(assigns) do
~H"""
<div>
<%= if Enum.count(@funnels) > 0 do %>
<div class="mt-4">
<%= for funnel <- @funnels 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">
<%= funnel.name %>
<br />
<span class="text-sm text-gray-400 font-normal">
<%= funnel.steps_count %>-step funnel
</span>
</span>
<button
phx-click="delete-funnel"
phx-value-funnel-id={funnel.id}
class="text-sm text-red-600"
data-confirm={"Are you sure you want to remove funnel '#{funnel.name}'? This will just affect the UI, all of your analytics data will stay intact."}
>
<svg
class="feather feather-sm"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2">
</path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
</button>
</div>
<% end %>
</div>
<% else %>
<div class="mt-4 dark:text-gray-100">No funnels configured for this site yet</div>
<% end %>
</div>
"""
end
end

View File

@ -1,11 +1,12 @@
defmodule PlausibleWeb.Router do
use PlausibleWeb, :router
import Phoenix.LiveView.Router
@two_weeks_in_seconds 60 * 60 * 24 * 14
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_flash
plug :fetch_live_flash
plug :put_secure_browser_headers
plug PlausibleWeb.FirstLaunchPlug, redirect_to: "/register"
plug PlausibleWeb.SessionTimeoutPlug, timeout_after_seconds: @two_weeks_in_seconds
@ -148,6 +149,11 @@ 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]
@ -246,6 +252,7 @@ defmodule PlausibleWeb.Router do
get "/:website/settings/people", SiteController, :settings_people
get "/:website/settings/visibility", SiteController, :settings_visibility
get "/:website/settings/goals", SiteController, :settings_goals
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
@ -253,7 +260,11 @@ defmodule PlausibleWeb.Router do
get "/:website/goals/new", SiteController, :new_goal
post "/:website/goals", SiteController, :create_goal
delete "/:website/goals/:id", SiteController, :delete_goal
put "/:website/settings/features/:action/:feature", SiteController, :set_feature_status
put "/:website/settings/features/visibility/:setting",
SiteController,
:update_feature_visibility
put "/:website/settings", SiteController, :update_settings
put "/:website/settings/google", SiteController, :update_google_auth
delete "/:website/settings/google-search", SiteController, :delete_google_auth

View File

@ -5,6 +5,7 @@
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta name="description" content="Plausible is a lightweight and open-source web analytics tool. Your website data is 100% yours and the privacy of your visitors is respected."/>
<meta name="csrf-token" content=<%= Plug.CSRFProtection.get_csrf_token() %> />
<link rel="icon" type="image/png" sizes="32x32" href="<%= PlausibleWeb.Router.Helpers.static_path(@conn, "/images/icon/plausible_favicon.png") %>">
<link rel="apple-touch-icon" href="/images/icon/apple-touch-icon.png">
<title><%= assigns[:title] || "Plausible · Simple, privacy-friendly alternative to Google Analytics" %></title>

View File

@ -0,0 +1,35 @@
<div class="shadow bg-white dark:bg-gray-800 sm:rounded-md sm:overflow-hidden py-6 px-4 sm:p-6">
<header class="relative">
<h2 class="text-lg leading-6 font-medium text-gray-900 dark:text-gray-100">Funnels</h2>
<p class="mt-1 text-sm leading-5 text-gray-500 dark:text-gray-200">
Compose goals into funnels
</p>
<%= link(to: "https://plausible.io/docs/funnel-analysis", target: "_blank", rel: "noreferrer") do %>
<svg
class="w-6 h-6 absolute top-0 right-0 text-gray-400"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clip-rule="evenodd"
>
</path>
</svg>
<% end %>
</header>
<PlausibleWeb.Components.Site.Feature.toggle
site={@site}
setting={:funnels_enabled}
label="Show funnels in the dashboard"
conn={@conn}
>
<%= live_render(@conn, PlausibleWeb.Live.FunnelSettings,
session: %{"site_id" => @site.id, "domain" => @site.domain},
router: PlausibleWeb.Router
) %>
</PlausibleWeb.Components.Site.Feature.toggle>
</div>

View File

@ -1,42 +0,0 @@
<div class="shadow bg-white dark:bg-gray-800 sm:rounded-md sm:overflow-hidden py-6 px-4 sm:p-6">
<header class="relative">
<h2 class="text-lg leading-6 font-medium text-gray-900 dark:text-gray-100">Goals</h2>
<p class="mt-1 text-sm leading-5 text-gray-500 dark:text-gray-200">Define actions that you want your users to take like visiting a certain page, submitting a form, etc.</p>
<%= link(to: "https://plausible.io/docs/goal-conversions", target: "_blank", rel: "noreferrer") do %>
<svg class="w-6 h-6 absolute top-0 right-0 text-gray-400" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path></svg>
<% end %>
<div class="mt-4 mb-8 flex items-center">
<%= if @site.conversions_enabled do %>
<%= button(to: "/#{URI.encode_www_form(@site.domain)}/settings/features/disable/conversions", method: :put, class: "bg-indigo-600 relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring") do %>
<span class="translate-x-5 inline-block h-5 w-5 rounded-full bg-white dark:bg-gray-800 shadow transform transition ease-in-out duration-200"></span>
<% end %>
<% else %>
<%= button(to: "/#{URI.encode_www_form(@site.domain)}/settings/features/enable/conversions", method: :put, class: "bg-gray-200 dark:bg-gray-700 relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring") do %>
<span class="translate-x-0 inline-block h-5 w-5 rounded-full bg-white dark:bg-gray-800 shadow transform transition ease-in-out duration-200"></span>
<% end %>
<% end %>
<span class="ml-2 text-sm font-medium text-gray-900 leading-5 dark:text-gray-100">Show goals in the dashboard</span>
</div>
</header>
<%= if @site.conversions_enabled do %>
<%= if Enum.count(@goals) > 0 do %>
<div class="mt-4">
<%= for goal <- @goals do %>
<div class="border-b border-gray-300 dark:border-gray-500 py-3 flex justify-between">
<span class="text-sm font-medium text-gray-900 dark:text-gray-100"><%= goal_name(goal) %></span>
<%= 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_name(goal)}? This will just affect the UI, all of your analytics data will stay intact."]) do %>
<svg class="feather feather-sm" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg>
<% end %>
</div>
<% end %>
</div>
<% else %>
<div class="my-4 text-center text-md text-gray-500 dark:text-gray-400">No goals configured for this site yet</div>
<% end %>
<%= link("+ Add goal", to: "/#{URI.encode_www_form(@site.domain)}/goals/new", class: "button mt-6") %>
<% end %>
</div>

View File

@ -0,0 +1,96 @@
<div class="shadow bg-white dark:bg-gray-800 sm:rounded-md sm:overflow-hidden py-6 px-4 sm:p-6">
<header class="relative">
<h2 class="text-lg leading-6 font-medium text-gray-900 dark:text-gray-100">Goals</h2>
<p class="mt-1 text-sm leading-5 text-gray-500 dark:text-gray-200">
Define actions that you want your users to take like visiting a certain page, submitting a form, etc.
</p>
<%= link(to: "https://plausible.io/docs/goal-conversions", target: "_blank", rel: "noreferrer") do %>
<svg
class="w-6 h-6 absolute top-0 right-0 text-gray-400"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clip-rule="evenodd"
>
</path>
</svg>
<% end %>
</header>
<PlausibleWeb.Components.Site.Feature.toggle
site={@site}
setting={:conversions_enabled}
label="Show goals in the dashboard"
conn={@conn}
>
<%= if Enum.count(@goals) > 0 do %>
<div class="mt-4">
<%= for goal <- @goals do %>
<div class="border-b border-gray-300 dark:border-gray-500 py-3 flex justify-between">
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
<%= goal %>
</span>
<%= if not Enum.empty?(goal.funnels) do %>
<%= button(to: "/#{URI.encode_www_form(@site.domain)}/goals/#{goal.id}", method: :delete, class: "text-sm text-red-600", data: [confirm: "The goal '#{goal}' is part of some funnel(s). If you are going to delete it, the associated funnels will be either reduced or deleted completely. Are you sure you want to remove goal '#{goal}'?"]) do %>
<svg
class="feather feather-sm"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2">
</path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
<% end %>
<% else %>
<%= button(to: "/#{URI.encode_www_form(@site.domain)}/goals/#{goal.id}", method: :delete, class: "text-sm text-red-600", data: [confirm: "Are you sure you want to remove goal '#{goal}'? This will just affect the UI, all of your analytics data will stay intact."]) do %>
<svg
class="feather feather-sm"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2">
</path>
<line x1="10" y1="11" x2="10" y2="17"></line>
<line x1="14" y1="11" x2="14" y2="17"></line>
</svg>
<% end %>
<% end %>
</div>
<% end %>
</div>
<% else %>
<div class="mt-4 dark:text-gray-100">No goals configured for this site yet</div>
<% end %>
<%= link("+ Add goal",
to: "/#{URI.encode_www_form(@site.domain)}/goals/new",
class: "button mt-6"
) %>
<%= if Enum.count(@goals) >= Funnel.min_steps() do %>
<%= link("Set up funnels",
to: Routes.site_path(@conn, :settings_funnels, @site.domain),
class: "mt-6 ml-2 text-indigo-500 underline text-sm"
) %>
<% end %>
</PlausibleWeb.Components.Site.Feature.toggle>
</div>

View File

@ -23,17 +23,16 @@ defmodule PlausibleWeb.LayoutView do
[key: "People", value: "people"],
[key: "Visibility", value: "visibility"],
[key: "Goals", value: "goals"],
if Plausible.Funnels.enabled_for?(conn.assigns[:current_user]) do
[key: "Funnels", value: "funnels"]
end,
[key: "Search Console", value: "search-console"],
[key: "Email reports", value: "email-reports"],
if !is_selfhost() && conn.assigns[:site].custom_domain do
[key: "Custom domain", value: "custom-domain"]
else
nil
end,
if conn.assigns[:current_user_role] == :owner do
[key: "Danger zone", value: "danger-zone"]
else
nil
end
]
end

View File

@ -1,6 +1,7 @@
defmodule PlausibleWeb.SiteView do
use PlausibleWeb, :view
import Phoenix.Pagination.HTML
use Plausible.Funnel
def plausible_url do
PlausibleWeb.Endpoint.url()

View File

@ -3,6 +3,7 @@ defmodule PlausibleWeb.ErrorReportControllerTest do
use Bamboo.Test
import Phoenix.View
import Plausible.Test.Support.HTML
alias PlausibleWeb.Endpoint
alias PlausibleWeb.ErrorView
@ -144,25 +145,4 @@ defmodule PlausibleWeb.ErrorReportControllerTest do
end
end
end
defp form_exists?(html, action_path) do
element_exists?(html, "form[action=\"" <> action_path <> "\"]")
end
defp element_exists?(html, selector) do
html
|> find(selector)
|> Enum.empty?()
|> Kernel.not()
end
defp submit_button(html, form) do
find(html, "#{form} button[type=\"submit\"]")
end
defp find(html, value) do
html
|> Floki.parse_document!()
|> Floki.find(value)
end
end

View File

@ -346,6 +346,16 @@ defmodule PlausibleWeb.SiteControllerTest do
assert html_response(conn, 200) =~ "Custom event"
assert html_response(conn, 200) =~ "Visit /register"
end
test "goal names are HTML safe", %{conn: conn, site: site} do
insert(:goal, site: site, event_name: "<some_event>")
conn = get(conn, "/#{site.domain}/settings/goals")
resp = html_response(conn, 200)
assert resp =~ "&lt;some_event&gt;"
refute resp =~ "<some_event>"
end
end
describe "PUT /:website/settings" do
@ -739,106 +749,99 @@ defmodule PlausibleWeb.SiteControllerTest do
end
end
describe "PUT /:website/settings/features/:action/:feature" do
setup [:create_user, :log_in]
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"))}
end
test "can disable conversions, funnels, and props with admin access", %{
conn: conn,
user: user
} do
setup [:build_conn_with_some_url, :create_user, :log_in]
for {title, setting} <- %{
"Goals" => :conversions_enabled,
"Funnels" => :funnels_enabled,
"Properties" => :props_enabled
} do
test "can toggle #{title} with admin access", %{
user: user,
conn: conn0
} do
site = insert(:site)
insert(:site_membership, user: user, site: site, role: :admin)
conn =
put(
conn0,
PlausibleWeb.Components.Site.Feature.target(site, unquote(setting), conn0, false)
)
assert Phoenix.Flash.get(conn.assigns.flash, :success) ==
"#{unquote(title)} are now hidden from your dashboard"
assert redirected_to(conn, 302) =~ "/some_parent_path"
assert %{unquote(setting) => false} = Plausible.Sites.get_by_domain(site.domain)
conn =
put(
conn0,
PlausibleWeb.Components.Site.Feature.target(site, unquote(setting), conn0, true)
)
assert Phoenix.Flash.get(conn.assigns.flash, :success) ==
"#{unquote(title)} are now visible again on your dashboard"
assert redirected_to(conn, 302) =~ "/some_parent_path"
assert %{unquote(setting) => true} = Plausible.Sites.get_by_domain(site.domain)
end
end
for {title, setting} <- %{
"Goals" => :conversions_enabled,
"Funnels" => :funnels_enabled,
"Properties" => :props_enabled
} do
test "cannot toggle #{title} with viewer access", %{
user: user,
conn: conn0
} do
site = insert(:site)
insert(:site_membership, user: user, site: site, role: :viewer)
conn =
put(
conn0,
PlausibleWeb.Components.Site.Feature.target(site, unquote(setting), conn0, false)
)
assert conn.status == 404
assert conn.halted
end
end
test "setting feature visibility is idempotent", %{user: user, conn: conn0} do
site = insert(:site)
insert(:site_membership, user: user, site: site, role: :admin)
conn1 = put(conn, "/#{site.domain}/settings/features/disable/conversions")
conn2 = put(conn, "/#{site.domain}/settings/features/disable/funnels")
conn3 = put(conn, "/#{site.domain}/settings/features/disable/props")
setting = :funnels_enabled
assert %{conversions_enabled: false, funnels_enabled: false, props_enabled: false} =
Plausible.Sites.get_by_domain(site.domain)
conn =
put(
conn0,
PlausibleWeb.Components.Site.Feature.target(site, setting, conn0, false)
)
assert Phoenix.Flash.get(conn1.assigns.flash, :success) ==
"Goals are now hidden from your dashboard"
assert %{^setting => false} = Plausible.Sites.get_by_domain(site.domain)
assert redirected_to(conn, 302) =~ "/some_parent_path"
assert Phoenix.Flash.get(conn2.assigns.flash, :success) ==
"Funnels are now hidden from your dashboard"
conn =
put(
conn0,
PlausibleWeb.Components.Site.Feature.target(site, setting, conn0, false)
)
assert Phoenix.Flash.get(conn3.assigns.flash, :success) ==
"Properties are now hidden from your dashboard"
assert redirected_to(conn1, 302) =~ "/#{site.domain}/settings/goals"
end
test "can enable conversions, funnels, and props with admin access", %{
conn: conn,
user: user
} do
site =
insert(:site, conversions_enabled: false, funnels_enabled: false, props_enabled: false)
insert(:site_membership, user: user, site: site, role: :owner)
conn1 = put(conn, "/#{site.domain}/settings/features/enable/conversions")
conn2 = put(conn, "/#{site.domain}/settings/features/enable/funnels")
conn3 = put(conn, "/#{site.domain}/settings/features/enable/props")
assert %{conversions_enabled: true, funnels_enabled: true, props_enabled: true} =
Plausible.Sites.get_by_domain(site.domain)
assert Phoenix.Flash.get(conn1.assigns.flash, :success) ==
"Goals are now visible again on your dashboard"
assert Phoenix.Flash.get(conn2.assigns.flash, :success) ==
"Funnels are now visible again on your dashboard"
assert Phoenix.Flash.get(conn3.assigns.flash, :success) ==
"Properties are now visible again on your dashboard"
assert redirected_to(conn3, 302) =~ "/#{site.domain}/settings/goals"
end
test "can enable and disable with super-admin access", %{
conn: conn,
user: user
} do
site = insert(:site)
patch_env(:super_admin_user_ids, [user.id])
conn1 = put(conn, "/#{site.domain}/settings/features/disable/funnels")
assert %{funnels_enabled: false} = Plausible.Sites.get_by_domain(site.domain)
assert Phoenix.Flash.get(conn1.assigns.flash, :success) ==
"Funnels are now hidden from your dashboard"
conn2 = put(conn, "/#{site.domain}/settings/features/enable/funnels")
assert %{funnels_enabled: true} = Plausible.Sites.get_by_domain(site.domain)
assert Phoenix.Flash.get(conn2.assigns.flash, :success) ==
"Funnels are now visible again on your dashboard"
assert redirected_to(conn1, 302) =~ "/#{site.domain}/settings/goals"
assert redirected_to(conn2, 302) =~ "/#{site.domain}/settings/goals"
end
test "fails to set feature status with viewer access", %{
conn: conn,
user: user
} do
site = insert(:site)
insert(:site_membership, user: user, site: site, role: :viewer)
conn = put(conn, "/#{site.domain}/settings/features/disable/conversions")
assert %{conversions_enabled: true} = Plausible.Sites.get_by_domain(site.domain)
assert conn.status == 404
end
test "fails to set feature status for a foreign site", %{conn: conn} do
site = insert(:site)
conn = put(conn, "/#{site.domain}/settings/features/disable/conversions")
assert %{conversions_enabled: true} = Plausible.Sites.get_by_domain(site.domain)
assert conn.status == 404
assert %{^setting => false} = Plausible.Sites.get_by_domain(site.domain)
assert redirected_to(conn, 302) =~ "/some_parent_path"
end
end
@ -1301,7 +1304,10 @@ defmodule PlausibleWeb.SiteControllerTest do
delete(conn, "/#{site.domain}/settings/forget-imported")
assert Plausible.Stats.Clickhouse.imported_pageview_count(site) == 0
assert eventually(fn ->
count = Plausible.Stats.Clickhouse.imported_pageview_count(site)
{count == 0, count}
end)
end
test "cancels Oban job if it exists", %{conn: conn, site: site} do

View File

@ -0,0 +1,270 @@
defmodule PlausibleWeb.Live.FunnelSettings.ComboBoxTest do
use PlausibleWeb.ConnCase, async: true
import Phoenix.LiveViewTest
import Plausible.Test.Support.HTML
alias PlausibleWeb.Live.FunnelSettings.ComboBox
@ul "ul#dropdown-test-component[x-show=isOpen][x-ref=suggestions]"
defp suggestion_li(idx) do
~s/#{@ul} li#dropdown-test-component-option-#{idx - 1}/
end
describe "static rendering" do
test "renders suggestions" do
assert doc = render_sample_component(new_options(10))
assert element_exists?(
doc,
~s/input#test-component[name="display-test-component"][phx-change="search"]/
)
assert element_exists?(doc, @ul)
for i <- 1..10 do
assert element_exists?(doc, suggestion_li(i))
end
end
test "renders up to 15 suggestions by default" do
assert doc = render_sample_component(new_options(20))
assert element_exists?(doc, suggestion_li(14))
assert element_exists?(doc, suggestion_li(15))
refute element_exists?(doc, suggestion_li(16))
refute element_exists?(doc, suggestion_li(17))
assert Floki.text(doc) =~ "Max results reached"
end
test "Alpine.js: renders attrs focusing suggestion elements" do
assert doc = render_sample_component(new_options(10))
li1 = doc |> find(suggestion_li(1)) |> List.first()
li2 = doc |> find(suggestion_li(2)) |> List.first()
assert text_of_attr(li1, "@mouseenter") == "setFocus(0)"
assert text_of_attr(li2, "@mouseenter") == "setFocus(1)"
assert text_of_attr(li1, "x-bind:class") =~ "focus === 0"
assert text_of_attr(li2, "x-bind:class") =~ "focus === 1"
end
test "Alpine.js: component refers to window.suggestionsDropdown" do
assert new_options(2)
|> render_sample_component()
|> find("div#input-picker-main-test-component")
|> text_of_attr("x-data") =~ "window.suggestionsDropdown('test-component')"
end
test "Alpine.js: component sets up keyboard navigation" do
main =
new_options(2)
|> render_sample_component()
|> find("div#input-picker-main-test-component")
assert text_of_attr(main, "x-on:keydown.arrow-up") == "focusPrev"
assert text_of_attr(main, "x-on:keydown.arrow-down") == "focusNext"
assert text_of_attr(main, "x-on:keydown.enter") == "select()"
end
test "Alpine.js: component sets up close on click-away" do
assert new_options(2)
|> render_sample_component()
|> find("div#input-picker-main-test-component div div")
|> text_of_attr("@click.away") == "close"
end
test "Alpine.js: component sets up open on focusing the display input" do
assert new_options(2)
|> render_sample_component()
|> find("input#test-component")
|> text_of_attr("x-on:focus") == "open"
end
test "Alpine.js: dropdown is annotated and shows when isOpen is true" do
dropdown =
new_options(2)
|> render_sample_component()
|> find("#dropdown-test-component")
assert text_of_attr(dropdown, "x-show") == "isOpen"
assert text_of_attr(dropdown, "x-ref") == "suggestions"
end
test "Dropdown shows a notice when no suggestions exist" do
doc = render_sample_component([])
assert text_of_element(doc, "#dropdown-test-component") ==
"No matches found. Try searching for something different."
end
end
describe "autosuggest algorithm" do
test "favours exact match" do
options = fake_options(["yellow", "hello", "cruel hello world"])
assert [{_, "hello"}, {_, "cruel hello world"}, {_, "yellow"}] =
ComboBox.suggest("hello", options)
end
test "skips entries shorter than input" do
options = fake_options(["yellow", "hello", "cruel hello world"])
assert [{_, "cruel hello world"}] = ComboBox.suggest("cruel hello", options)
end
test "favours similiarity" do
options = fake_options(["melon", "hello", "yellow"])
assert [{_, "hello"}, {_, "yellow"}, {_, "melon"}] = ComboBox.suggest("hell", options)
end
test "allows fuzzy matching" do
options = fake_options(["/url/0xC0FFEE", "/url/0xDEADBEEF", "/url/other"])
assert [{_, "/url/0xC0FFEE"}, {_, "/url/0xDEADBEEF"}, {_, "/url/other"}] =
ComboBox.suggest("0x FF", options)
end
test "suggests up to 15 entries" do
options =
1..20
|> Enum.map(&"Option #{&1}")
|> fake_options()
suggestions = ComboBox.suggest("Option", options)
assert Enum.count(suggestions) == 15
end
end
describe "integration - live rendering" do
setup [:create_user, :log_in, :create_site]
test "search reacts to the input, the user types in", %{conn: conn, site: site} do
setup_goals(site, ["Hello World", "Plausible", "Another World"])
lv = get_liveview(conn, site)
doc = type_into_combo(lv, 1, "hello")
assert text_of_element(doc, "#dropdown-step-1-option-0") == "Hello World"
doc = type_into_combo(lv, 1, "plausible")
assert text_of_element(doc, "#dropdown-step-1-option-0") == "Plausible"
end
test "selecting an option prefills input values", %{conn: conn, site: site} do
{:ok, [_, _, g3]} = setup_goals(site, ["Hello World", "Plausible", "Another World"])
lv = get_liveview(conn, site)
doc = type_into_combo(lv, 1, "another")
refute element_exists?(doc, ~s/input[type="hidden"][value="#{g3.id}"]/)
refute element_exists?(doc, ~s/input[type="text"][value="Another World"]/)
lv
|> element("li#dropdown-step-1-option-0 a")
|> render_click()
assert lv
|> element("#submit-step-1")
|> render()
|> element_exists?(~s/input[type="hidden"][value="#{g3.id}"]/)
assert lv
|> element("#step-1")
|> render()
|> element_exists?(~s/input[type="text"][value="Another World"]/)
end
test "selecting one option reduces suggestions in the other", %{conn: conn, site: site} do
setup_goals(site, ["Hello World", "Plausible", "Another World"])
lv = get_liveview(conn, site)
type_into_combo(lv, 1, "another")
lv
|> element("li#dropdown-step-1-option-0 a")
|> render_click()
doc = type_into_combo(lv, 2, "another")
refute text_of_element(doc, "ul#dropdown-step-1 li") =~ "Another World"
assert text_of_element(doc, "ul#dropdown-step-2 li") =~ "Hello World"
assert text_of_element(doc, "ul#dropdown-step-2 li") =~ "Plausible"
refute text_of_element(doc, "ul#dropdown-step-2 li") =~ "Another World"
end
test "removing one option alters suggestions for other", %{conn: conn, site: site} do
setup_goals(site, ["Hello World", "Plausible", "Another World"])
lv = get_liveview(conn, site)
lv |> element(~s/a[phx-click="add-step"]/) |> render_click()
type_into_combo(lv, 2, "hello")
lv
|> element("li#dropdown-step-2-option-0 a")
|> render_click()
doc = type_into_combo(lv, 1, "hello")
refute text_of_element(doc, "ul#dropdown-step-1 li") =~ "Hello World"
lv |> element(~s/#remove-step-2/) |> render_click()
doc = type_into_combo(lv, 1, "hello")
assert text_of_element(doc, "ul#dropdown-step-1 li") =~ "Hello World"
end
end
defp render_sample_component(options) do
render_component(ComboBox,
options: options,
submit_name: "test-submit-name",
id: "test-component"
)
end
defp new_options(n) do
Enum.map(1..n, &{&1, "TestOption #{&1}"})
end
defp get_liveview(conn, site) do
conn = assign(conn, :live_module, PlausibleWeb.Live.FunnelSettings)
{:ok, lv, _html} = live(conn, "/#{site.domain}/settings/funnels")
lv |> element(~s/button[phx-click="add-funnel"]/) |> render_click()
assert form_view = find_live_child(lv, "funnels-form")
form_view |> element("form") |> render_change(%{funnel: %{name: "My test funnel"}})
form_view
end
defp setup_goals(site, goal_names) when is_list(goal_names) do
goals =
Enum.map(goal_names, fn goal_name ->
{:ok, g} = Plausible.Goals.create(site, %{"event_name" => goal_name})
g
end)
{:ok, goals}
end
defp fake_options(option_names) do
option_names
|> Enum.shuffle()
|> Enum.with_index(fn element, index -> {index, element} end)
end
defp type_into_combo(lv, idx, text) do
lv
|> element("input#step-#{idx}")
|> render_change(%{
"_target" => ["display-step-#{idx}"],
"display-step-#{idx}" => "#{text}"
})
end
end

View File

@ -0,0 +1,212 @@
defmodule PlausibleWeb.Live.FunnelSettingsTest do
use PlausibleWeb.ConnCase, async: true
import Phoenix.LiveViewTest
import Plausible.Test.Support.HTML
describe "GET /:website/settings/funnels" do
setup [:create_user, :log_in, :create_site]
test "lists funnels for the site and renders help link", %{conn: conn, site: site} do
:ok = setup_funnels(site)
conn = get(conn, "/#{site.domain}/settings/funnels")
resp = html_response(conn, 200)
assert resp =~ "Compose goals into funnels"
assert resp =~ "From blog to signup"
assert resp =~ "From signup to blog"
assert element_exists?(resp, "a[href=\"https://plausible.io/docs/funnel-analysis\"]")
end
test "if goals are present, Add Funnel button is rendered", %{conn: conn, site: site} do
:ok = setup_funnels(site)
conn = get(conn, "/#{site.domain}/settings/funnels")
resp = conn |> html_response(200)
assert element_exists?(resp, ~S/button[phx-click="add-funnel"]/)
end
test "if not enough goals are present, a hint to create goals is rendered", %{
conn: conn,
site: site
} do
{:ok, _} = Plausible.Goals.create(site, %{"page_path" => "/go/to/blog/**"})
conn = get(conn, "/#{site.domain}/settings/funnels")
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)
assert element_exists?(doc, ~s/a[href="#{add_goals_path}"]/)
end
end
describe "FunnelSettings component" do
setup [:create_user, :log_in, :create_site]
test "renders the funnel form on clicking 'Add Funnel' button", %{conn: conn, site: site} do
setup_goals(site)
lv = get_liveview(conn, site)
doc = render_click(lv, "add-funnel")
assert element_exists?(doc, ~s/form[phx-change="validate"][phx-submit="save"]/)
assert element_exists?(doc, ~s/form input[type="text"][name="funnel[name]"]/)
assert element_exists?(
doc,
~s/input[type="hidden"][name="funnel[steps][][goal_id]"]#submit-step-1/
)
step_setup_controls = [
~s/input[type="hidden"][name="funnel[steps][][goal_id]"]#submit-step-1/,
~s/input[type="hidden"][name="funnel[steps][][goal_id]"]#submit-step-2/,
~s/input[type="text"][name="display-step-1"]#step-1/,
~s/input[type="text"][name="display-step-2"]#step-2/,
~s/a[phx-click="add-step"]/
]
Enum.each(step_setup_controls, &assert(element_exists?(doc, &1)))
end
test "clicking 'Add another step' adds a pair of inputs and renders remove step buttons", %{
conn: conn,
site: site
} do
setup_goals(site)
lv = get_liveview(conn, site)
lv |> element(~s/button[phx-click="add-funnel"]/) |> render_click()
assert lv = find_live_child(lv, "funnels-form")
lv |> element("form") |> render_change(%{funnel: %{name: "My test funnel"}})
doc = lv |> element(~s/a[phx-click="add-step"]/) |> render_click()
assert element_exists?(
doc,
~s/input[type="hidden"][name="funnel[steps][][goal_id]"]#submit-step-3/
)
assert element_exists?(doc, ~s/input[type="text"][name="display-step-1"]#step-1/)
assert element_exists?(
doc,
~s/svg#remove-step-1[phx-click="remove-step"][phx-value-step-idx="1"]/
)
assert element_exists?(
doc,
~s/svg#remove-step-2[phx-click="remove-step"][phx-value-step-idx="2"]/
)
assert element_exists?(
doc,
~s/svg#remove-step-3[phx-click="remove-step"][phx-value-step-idx="3"]/
)
end
test "clicking the 'remove step' button removes a step", %{site: site, conn: conn} do
setup_goals(site)
lv = get_liveview(conn, site)
lv |> element(~s/button[phx-click="add-funnel"]/) |> render_click()
assert lv = find_live_child(lv, "funnels-form")
lv |> element("form") |> render_change(%{funnel: %{name: "My test funnel"}})
lv |> element(~s/a[phx-click="add-step"]/) |> render_click()
doc = lv |> element(~s/#remove-step-2/) |> render_click()
assert element_exists?(doc, ~s/input#step-1/)
assert element_exists?(doc, ~s/input#step-3/)
refute element_exists?(doc, ~s/input#step-2/)
end
test "save button becomes active once at least two steps are selected", %{
conn: conn,
site: site
} do
setup_goals(site)
lv = get_liveview(conn, site)
lv |> element(~s/button[phx-click="add-funnel"]/) |> render_click()
assert lv = find_live_child(lv, "funnels-form")
doc =
lv
|> element("form")
|> render_change(%{funnel: %{name: "My test funnel"}})
save_inactive = ~s/form button#save.cursor-not-allowed/
save_active = ~s/form button#save[type="submit"]/
refute element_exists?(doc, save_active)
assert element_exists?(doc, save_inactive)
doc =
lv
|> element("form")
|> render_change(%{
funnel: %{
name: "My test funnel",
steps: [
%{goal_id: 1},
%{goal_id: 2}
]
}
})
assert element_exists?(doc, save_active)
refute element_exists?(doc, save_inactive)
end
test "cancel buttons renders the funnel list", %{
conn: conn,
site: site
} do
setup_goals(site)
lv = get_liveview(conn, site)
doc = lv |> element(~s/button[phx-click="add-funnel"]/) |> render_click()
cancel_button = ~s/button#cancel[phx-click="cancel-add-funnel"]/
assert element_exists?(doc, cancel_button)
doc =
lv
|> element(cancel_button)
|> render_click()
assert doc =~ "No funnels configured for this site yet"
assert element_exists?(doc, ~S/button[phx-click="add-funnel"]/)
end
end
defp setup_funnels(site) do
{:ok, [g1, g2]} = setup_goals(site)
{:ok, _} =
Plausible.Funnels.create(
site,
"From blog to signup",
[%{"goal_id" => g1.id}, %{"goal_id" => g2.id}]
)
{:ok, _} =
Plausible.Funnels.create(
site,
"From signup to blog",
[%{"goal_id" => g2.id}, %{"goal_id" => g1.id}]
)
:ok
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, [g1, g2]}
end
defp get_liveview(conn, site) do
conn = assign(conn, :live_module, PlausibleWeb.Live.FunnelSettings)
{:ok, lv, _html} = live(conn, "/#{site.domain}/settings/funnels")
lv
end
end

40
test/support/html.ex Normal file
View File

@ -0,0 +1,40 @@
defmodule Plausible.Test.Support.HTML do
@moduledoc """
Floki wrappers to help make assertions about HTML/DOM structures
"""
def element_exists?(html, selector) do
html
|> find(selector)
|> Enum.empty?()
|> Kernel.not()
end
def find(html, value) do
html
|> Floki.parse_document!()
|> Floki.find(value)
end
def submit_button(html, form) do
find(html, "#{form} button[type=\"submit\"]")
end
def form_exists?(html, action_path) do
element_exists?(html, "form[action=\"" <> action_path <> "\"]")
end
def text_of_element(html, element) do
html
|> find(element)
|> Floki.text()
|> String.trim()
end
def text_of_attr(element, attr) do
element
|> Floki.attribute(attr)
|> Floki.text()
|> String.trim()
end
end

View File

@ -2,5 +2,6 @@
Mox.defmock(Plausible.HTTPClient.Mock, for: Plausible.HTTPClient.Interface)
Application.ensure_all_started(:double)
FunWithFlags.enable(:revenue_goals)
FunWithFlags.enable(:funnels)
Ecto.Adapters.SQL.Sandbox.mode(Plausible.Repo, :manual)
ExUnit.configure(exclude: [:slow])