mirror of
https://github.com/plausible/analytics.git
synced 2024-09-11 09:56:02 +03:00
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:
parent
086045bde8
commit
d8543c81cc
@ -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"]
|
||||
|
4
.github/workflows/elixir.yml
vendored
4
.github/workflows/elixir.yml
vendored
@ -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
|
||||
|
@ -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">
|
||||
|
18
assets/js/liveview/live_socket.js
Normal file
18
assets/js/liveview/live_socket.js
Normal 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
|
7
assets/js/liveview/phx_events.js
Normal file
7
assets/js/liveview/phx_events.js
Normal 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 }))
|
||||
}
|
||||
})
|
40
assets/js/liveview/suggestions_dropdown.js
Normal file
40
assets/js/liveview/suggestions_dropdown.js
Normal 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
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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)$}
|
||||
]
|
||||
]
|
||||
|
||||
|
@ -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}
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
40
lib/plausible_web/components/generic.ex
Normal file
40
lib/plausible_web/components/generic.ex
Normal 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
|
56
lib/plausible_web/components/site/feature.ex
Normal file
56
lib/plausible_web/components/site/feature.ex
Normal 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
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
137
lib/plausible_web/live/flash.ex
Normal file
137
lib/plausible_web/live/flash.ex
Normal 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
|
99
lib/plausible_web/live/funnel_settings.ex
Normal file
99
lib/plausible_web/live/funnel_settings.ex
Normal 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
|
257
lib/plausible_web/live/funnel_settings/combo_box.ex
Normal file
257
lib/plausible_web/live/funnel_settings/combo_box.ex
Normal 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
|
271
lib/plausible_web/live/funnel_settings/form.ex
Normal file
271
lib/plausible_web/live/funnel_settings/form.ex
Normal 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
|
59
lib/plausible_web/live/funnel_settings/list.ex
Normal file
59
lib/plausible_web/live/funnel_settings/list.ex
Normal 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
|
@ -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
|
||||
|
@ -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>
|
||||
|
35
lib/plausible_web/templates/site/settings_funnels.html.heex
Normal file
35
lib/plausible_web/templates/site/settings_funnels.html.heex
Normal 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>
|
@ -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>
|
96
lib/plausible_web/templates/site/settings_goals.html.heex
Normal file
96
lib/plausible_web/templates/site/settings_goals.html.heex
Normal 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>
|
@ -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
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
|
@ -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 =~ "<some_event>"
|
||||
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
|
||||
|
@ -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
|
212
test/plausible_web/live/funnel_settings/funnel_settings_test.exs
Normal file
212
test/plausible_web/live/funnel_settings/funnel_settings_test.exs
Normal 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
40
test/support/html.ex
Normal 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
|
@ -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])
|
||||
|
Loading…
Reference in New Issue
Block a user