Props Settings UI to match Goals Settings (#3322)

* Add hint to creatable ComboBoxes without suggestions available

* Load external resources once in funnel settings

* Load external resources once in goal settings

* Make Custom Props Settings UI match Goal Settings

* Remove unnecessary goals query

This should be done only once in the live view

* Remove funnels feature flag

* fixup

* Make the modal scrollable

* By default, focus first suggestion for creatables

* Add sample props to seeds

* Load all suggestions asynchronously, unless `Mix.env == :test`

* ComboBox: Fix inconsistent suggestions

We require "Create ..." element to be only focused
when there are no suggestions available.
This causes some issues, depending on the state,
the least focusable index might be either 0 ("Create...")
or 1. This patch addresses all the quirks with focus.

* Fix ComboBox max results message

So that AlpineJS doesn't think it's a focusable
option.

* Keep the state up to date when changing props

* Update seeds with sensible prop names

* Make escape work for closing combobox suggestions

Co-authored-by: Uku Taht <Uku.taht@gmail.com>

* Revert "Make escape work for closing combobox suggestions"

This reverts commit 306866d2a1.

@ukutaht unfortunately this makes it impossible to select
an suggestion.

* Revert "Revert "Make escape work for closing combobox suggestions""

This reverts commit 4844857812.

* Make ESC great again

* Improve readability

---------

Co-authored-by: Uku Taht <Uku.taht@gmail.com>
This commit is contained in:
hq1 2023-09-13 14:55:29 +02:00 committed by GitHub
parent 7cb2c6bfa3
commit 0822bc61df
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 828 additions and 396 deletions

View File

@ -4,15 +4,54 @@ let suggestionsDropdown = function(id) {
return {
isOpen: false,
id: id,
open() { this.isOpen = true },
close() { this.isOpen = false },
focus: 0,
focus: null,
setFocus(f) {
this.focus = f;
},
initFocus() {
if (this.focus === null) {
this.setFocus(this.leastFocusableIndex())
}
},
open() {
this.initFocus()
this.isOpen = true
},
suggestionsCount() {
return this.$refs.suggestions?.querySelectorAll('li').length
},
hasCreatableOption() {
return this.$refs.suggestions?.querySelector('li').classList.contains("creatable")
},
leastFocusableIndex() {
if (this.suggestionsCount() === 0) {
return 0
}
return this.hasCreatableOption() ? 0 : 1
},
maxFocusableIndex() {
return this.hasCreatableOption() ? this.suggestionsCount() - 1 : this.suggestionsCount()
},
nextFocusableIndex() {
const currentFocus = this.focus
return currentFocus + 1 > this.maxFocusableIndex() ? this.leastFocusableIndex() : currentFocus + 1
},
prevFocusableIndex() {
const currentFocus = this.focus
return currentFocus - 1 >= this.leastFocusableIndex() ? currentFocus - 1 : this.maxFocusableIndex()
},
close(e) {
// Pressing Escape should not propagate to window,
// so we'll only close the suggestions pop-up
if (this.isOpen && e.key === "Escape") {
e.stopPropagation()
}
this.isOpen = false
},
select() {
this.$refs[`dropdown-${this.id}-option-${this.focus}`]?.click()
this.focusPrev()
this.close()
document.getElementById(this.id).blur()
},
scrollTo(idx) {
this.$refs[`dropdown-${this.id}-option-${idx}`]?.scrollIntoView(
@ -20,23 +59,21 @@ let suggestionsDropdown = function(id) {
)
},
focusNext() {
const nextIndex = this.focus + 1
const total = this.$refs.suggestions?.childElementCount ?? 0
const nextIndex = this.nextFocusableIndex()
if (!this.isOpen) this.open()
if (nextIndex < total) {
this.setFocus(nextIndex)
this.scrollTo(nextIndex);
}
this.setFocus(nextIndex)
this.scrollTo(nextIndex)
},
focusPrev() {
const nextIndex = this.focus - 1
if (this.isOpen && nextIndex >= 0) {
this.setFocus(nextIndex)
this.scrollTo(nextIndex)
}
},
const prevIndex = this.prevFocusableIndex()
if (!this.isOpen) this.open()
this.setFocus(prevIndex)
this.scrollTo(prevIndex)
}
}
}

View File

@ -1,7 +1,7 @@
defmodule PlausibleWeb.SiteController do
use PlausibleWeb, :controller
use Plausible.Repo
alias Plausible.{Sites, Goals}
alias Plausible.Sites
plug PlausibleWeb.RequireAccountPlug
@ -216,32 +216,26 @@ defmodule PlausibleWeb.SiteController do
def settings_goals(conn, _params) do
site = conn.assigns[:site] |> Repo.preload(:custom_domain)
goals = Goals.for_site(site, preload_funnels?: true)
conn
|> assign(:skip_plausible_tracking, true)
|> render("settings_goals.html",
site: site,
goals: goals,
connect_live_socket: true,
layout: {PlausibleWeb.LayoutView, "site_settings.html"}
)
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)
site = conn.assigns[:site] |> Repo.preload(:custom_domain)
conn
|> assign(:skip_plausible_tracking, true)
|> render("settings_funnels.html",
site: site,
connect_live_socket: true,
layout: {PlausibleWeb.LayoutView, "site_settings.html"}
)
else
conn |> Plug.Conn.put_status(401) |> Plug.Conn.halt()
end
conn
|> assign(:skip_plausible_tracking, true)
|> render("settings_funnels.html",
site: site,
connect_live_socket: true,
layout: {PlausibleWeb.LayoutView, "site_settings.html"}
)
end
def settings_props(conn, _params) do

View File

@ -335,10 +335,8 @@ defmodule PlausibleWeb.StatsController do
defp shared_link_cookie_name(slug), do: "shared-link-" <> slug
defp get_flags(user) do
%{
funnels: Plausible.Funnels.enabled_for?(user)
}
defp get_flags(_user) do
%{}
end
defp is_dbip() do

View File

@ -17,16 +17,24 @@ defmodule PlausibleWeb.Live.Components.ComboBox do
Any function can be supplied via `suggest_fun` attribute
- see the provided `ComboBox.StaticSearch`.
In case the `suggest_fun` runs an operation that could be deferred,
the `async=true` attr calls it in a background Task and updates the
suggestions asynchronously.
In most cases the `suggest_fun` runs an operation that could be deferred,
so by default, the `async={true}` attr calls it in a background Task
and updates the suggestions asynchronously. This way, you can render
the component without having to wait for suggestions to load.
Similarly, the initial `options` don't have to be provided up-front
if e.g. querying the database for suggestions at initial render is
undesirable. In such case, lack of `options` attr value combined
with `async=true` will call `suggest_fun.("", [])` asynchronously
- that special clause can be used to provide the initial set
of suggestions updated right after the initial render.
If you explicitly need to make the operation sychronous, you may
pass `async={false}` option.
If your initial `options` are not provided up-front at initial render,
lack of `options` attr value combined with `async=true` calls the
`suggest_fun.("", [])` asynchronously - that special clause can be used
to provide the initial set of suggestions updated right after the initial render.
To simplify integration testing, suggestions load up synchronously during
tests. This lets you skip waiting for suggestions messages
to arrive. The asynchronous behaviour alone is already tested in
ComboBox own test suite, so there is no need for additional
verification.
"""
use Phoenix.LiveComponent
alias Phoenix.LiveView.JS
@ -60,7 +68,7 @@ defmodule PlausibleWeb.Live.Components.ComboBox do
attr(:required, :boolean, default: false)
attr(:creatable, :boolean, default: false)
attr(:errors, :list, default: [])
attr(:async, :boolean, default: false)
attr(:async, :boolean, default: Mix.env() != :test)
def render(assigns) do
assigns =
@ -195,6 +203,12 @@ defmodule PlausibleWeb.Live.Components.ComboBox do
>
No matches found. Try searching for something different.
</div>
<div
:if={@suggestions == [] && @creatable && String.trim(@display_value) == ""}
class="relative cursor-default select-none py-2 px-4 text-gray-700 dark:text-gray-300"
>
Create an item by typing.
</div>
</ul>
"""
end
@ -211,7 +225,10 @@ defmodule PlausibleWeb.Live.Components.ComboBox do
~H"""
<li
class="relative select-none cursor-pointer dark:text-gray-300"
class={[
"relative select-none cursor-pointer dark:text-gray-300",
@creatable && "creatable"
]}
@mouseenter={"setFocus(#{@idx})"}
x-bind:class={ "{'text-white bg-indigo-500': focus === #{@idx}}" }
id={"dropdown-#{@ref}-option-#{@idx}"}
@ -230,9 +247,9 @@ defmodule PlausibleWeb.Live.Components.ComboBox do
<% end %>
</a>
</li>
<li :if={@idx == @suggestions_limit} class="text-xs text-gray-500 relative py-2 px-3">
Max results reached. Refine your search by typing in goal name.
</li>
<div :if={@idx == @suggestions_limit} class="text-xs text-gray-500 relative py-2 px-3">
Max results reached. Refine your search by typing.
</div>
"""
end

View File

@ -11,22 +11,25 @@ defmodule PlausibleWeb.Live.FunnelSettings do
def mount(
_params,
%{"site_id" => _site_id, "domain" => domain, "current_user_id" => user_id},
%{"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, :super_admin])
funnels = Funnels.list(site)
goal_count = Goals.count(site)
socket =
socket
|> assign_new(:site, fn ->
Sites.get_for_user!(user_id, domain, [:owner, :admin, :super_admin])
end)
|> assign_new(:funnels, fn %{site: site} ->
Funnels.list(site)
end)
|> assign_new(:goal_count, fn %{site: site} ->
Goals.count(site)
end)
{:ok,
assign(socket,
site_id: site.id,
domain: site.domain,
funnels: funnels,
goal_count: goal_count,
site_id: site_id,
domain: domain,
add_funnel?: false,
current_user_id: user_id
)}

View File

@ -11,19 +11,23 @@ defmodule PlausibleWeb.Live.GoalSettings do
def mount(
_params,
%{"site_id" => _site_id, "domain" => domain, "current_user_id" => user_id},
%{"site_id" => site_id, "domain" => domain, "current_user_id" => user_id},
socket
) do
site = Sites.get_for_user!(user_id, domain, [:owner, :admin, :super_admin])
goals = Goals.for_site(site, preload_funnels?: true)
socket =
socket
|> assign_new(:site, fn ->
Sites.get_for_user!(user_id, domain, [:owner, :admin, :super_admin])
end)
|> assign_new(:all_goals, fn %{site: site} ->
Goals.for_site(site, preload_funnels?: true)
end)
{:ok,
assign(socket,
site_id: site.id,
domain: site.domain,
all_goals: goals,
displayed_goals: goals,
site_id: site_id,
domain: domain,
displayed_goals: socket.assigns.all_goals,
add_goal?: false,
current_user_id: user_id,
filter_text: ""

View File

@ -86,7 +86,6 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do
]}
module={ComboBox}
suggest_fun={fn input, options -> suggest_page_paths(input, options, @site) end}
async={true}
creatable
/>
@ -185,7 +184,6 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do
ComboBox.StaticSearch.suggest(input, options, weight_threshold: 0.8)
end
}
async={true}
/>
</div>
</div>

View File

@ -9,27 +9,28 @@ defmodule PlausibleWeb.Live.PropsSettings do
def mount(
_params,
%{"site_id" => _site_id, "domain" => domain, "current_user_id" => user_id},
%{"site_id" => site_id, "domain" => domain, "current_user_id" => user_id},
socket
) do
site =
if Plausible.Auth.is_super_admin?(user_id) do
Plausible.Sites.get_by_domain(domain)
else
Plausible.Sites.get_for_user!(user_id, domain, [:owner, :admin])
end
suggestions =
site
|> Plausible.Props.suggest_keys_to_allow()
|> Enum.map(&{&1, &1})
socket =
socket
|> assign_new(:site, fn ->
Plausible.Sites.get_for_user!(user_id, domain, [:owner, :admin, :super_admin])
end)
|> assign_new(:all_props, fn %{site: site} ->
site.allowed_event_props || []
end)
|> assign_new(:displayed_props, fn %{all_props: props} ->
props
end)
{:ok,
assign(socket,
site: site,
site_id: site_id,
domain: domain,
current_user_id: user_id,
form: new_form(site),
suggestions: suggestions
add_prop?: false,
filter_text: ""
)}
end
@ -37,90 +38,27 @@ defmodule PlausibleWeb.Live.PropsSettings do
~H"""
<section id="props-settings-main">
<.live_component id="embedded_liveview_flash" module={PlausibleWeb.Live.Flash} flash={@flash} />
<%= if @add_prop? do %>
<%= live_render(
@socket,
PlausibleWeb.Live.PropsSettings.Form,
id: "props-form",
session: %{
"current_user_id" => @current_user_id,
"domain" => @domain,
"site_id" => @site_id,
"rendered_by" => self()
}
) %>
<% end %>
<h1 class="text-normal leading-6 font-medium text-gray-900 dark:text-gray-100">
Configured properties
</h1>
<h2 class="mt-1 text-sm leading-5 text-gray-500 dark:text-gray-400">
In order for the properties to show up on your dashboard, you need to
explicitly add them below first
</h2>
<.form :let={f} for={@form} id="props-form" phx-submit="allow" class="mt-5">
<div class="flex space-x-2">
<.live_component
id={:prop_input}
submit_name="prop"
class="flex-1"
module={ComboBox}
suggest_fun={&ComboBox.StaticSearch.suggest/2}
options={@suggestions}
required
creatable
/>
<button id="allow" type="submit" class="button">+ Add</button>
</div>
<div :if={length(f[:allowed_event_props].errors) > 0} id="prop-errors" role="alert">
<%= PlausibleWeb.ErrorHelpers.error_tag(f, :allowed_event_props) %>
</div>
</.form>
<button
:if={length(@suggestions) > 0}
title="Use this to add any existing properties from your past events into your settings. This allows you to set up properties without having to manually enter each item."
class="mt-1 text-sm hover:underline text-indigo-600 dark:text-indigo-400"
phx-click="allow-existing-props"
>
Already sending custom properties? Click to add all existing properties
</button>
<div class="mt-5">
<%= if is_list(@site.allowed_event_props) && length(@site.allowed_event_props) > 0 do %>
<ul id="allowed-props" class="divide-gray-200 divide-y dark:divide-gray-600">
<li
:for={{prop, index} <- Enum.with_index(@site.allowed_event_props)}
id={"prop-#{index}"}
class="flex py-4"
>
<span class="flex-1 truncate font-medium text-sm text-gray-800 dark:text-gray-200">
<%= prop %>
</span>
<button
data-confirm={"Are you sure you want to remove property '#{prop}'? This will just affect the UI, all of your analytics data will stay intact."}
phx-click="disallow"
phx-value-prop={prop}
class="w-4 h-4 text-red-600 hover:text-red-700"
aria-label={"Remove #{prop} property"}
>
<svg
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"
aria-hidden="true"
focusable="false"
>
<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>
</li>
</ul>
<% else %>
<p class="text-sm text-gray-800 dark:text-gray-200">
No properties configured for this site yet
</p>
<% end %>
</div>
<.live_component
module={PlausibleWeb.Live.PropsSettings.List}
id="props-list"
props={@displayed_props}
domain={@domain}
filter_text={@filter_text}
/>
</section>
"""
end
@ -133,8 +71,7 @@ defmodule PlausibleWeb.Live.PropsSettings do
{:noreply,
assign(socket,
site: site,
form: new_form(site),
suggestions: rebuild_suggestions(socket.assigns.suggestions, site.allowed_event_props)
form: new_form(site)
)}
{:error, changeset} ->
@ -145,9 +82,38 @@ defmodule PlausibleWeb.Live.PropsSettings do
end
end
def handle_event("disallow", %{"prop" => prop}, socket) do
def handle_event("add-prop", _value, socket) do
{:noreply, assign(socket, add_prop?: true)}
end
def handle_event("filter", %{"filter-text" => filter_text}, socket) do
new_list =
ComboBox.StaticSearch.suggest(
filter_text,
socket.assigns.all_props
)
{:noreply, assign(socket, displayed_props: new_list, filter_text: filter_text)}
end
def handle_event("reset-filter-text", _params, socket) do
{:noreply, assign(socket, filter_text: "", displayed_props: socket.assigns.all_props)}
end
def handle_event("disallow-prop", %{"prop" => prop}, socket) do
{:ok, site} = Plausible.Props.disallow(socket.assigns.site, prop)
{:noreply, assign(socket, site: site)}
socket =
socket
|> put_flash(:success, "Property removed successfully")
|> assign(
all_props: Enum.reject(socket.assigns.all_props, &(&1 == prop)),
displayed_props: Enum.reject(socket.assigns.displayed_props, &(&1 == prop)),
site: site
)
Process.send_after(self(), :clear_flash, 5000)
{:noreply, socket}
end
def handle_event("allow-existing-props", _params, socket) do
@ -155,21 +121,52 @@ defmodule PlausibleWeb.Live.PropsSettings do
{:noreply,
assign(socket,
site: site,
suggestions: rebuild_suggestions(socket.assigns.suggestions, site.allowed_event_props)
site: site
)}
end
defp rebuild_suggestions(suggestions, allowed_event_props) do
allowed_event_props = allowed_event_props || []
def handle_info(:cancel_add_prop, socket) do
{:noreply, assign(socket, add_prop?: false)}
end
suggestions =
for {suggestion, _} <- suggestions,
suggestion not in allowed_event_props,
do: {suggestion, suggestion}
def handle_info({:props_allowed, props}, socket) when is_list(props) do
socket =
socket
|> assign(
add_prop?: false,
filter_text: "",
all_props: props,
displayed_props: props,
site: %{socket.assigns.site | allowed_event_props: props}
)
|> put_flash(:success, "Properties added successfully")
send_update(ComboBox, id: :prop_input, suggestions: suggestions)
suggestions
{:noreply, socket}
end
def handle_info(
{:prop_allowed, prop},
%{assigns: %{site: site}} = socket
)
when is_binary(prop) do
allowed_event_props = [prop | site.allowed_event_props]
socket =
socket
|> assign(
add_prop?: false,
filter_text: "",
all_props: allowed_event_props,
displayed_props: allowed_event_props,
site: %{site | allowed_event_props: allowed_event_props}
)
|> put_flash(:success, "Property added successfully")
{:noreply, socket}
end
def handle_info(:clear_flash, socket) do
{:noreply, clear_flash(socket)}
end
defp new_form(site) do

View File

@ -0,0 +1,159 @@
defmodule PlausibleWeb.Live.PropsSettings.Form do
@moduledoc """
Live view for the custom props creation form
"""
use Phoenix.LiveView
import PlausibleWeb.Live.Components.Form
alias PlausibleWeb.Live.Components.ComboBox
def mount(
_params,
%{
"site_id" => _site_id,
"current_user_id" => user_id,
"domain" => domain,
"rendered_by" => pid
},
socket
) do
site = Plausible.Sites.get_for_user!(user_id, domain, [:owner, :admin, :super_admin])
form = new_form(site)
{:ok,
assign(socket,
form: form,
domain: domain,
rendered_by: pid,
prop_key_options_count: 0,
site: site
)}
end
def render(assigns) do
~H"""
<div
class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity z-50"
phx-window-keydown="cancel-allow-prop"
phx-key="Escape"
>
</div>
<div class="fixed inset-0 flex items-center justify-center mt-16 z-50 overflow-y-auto overflow-x-hidden">
<div class="w-1/2 h-full">
<.form
:let={f}
for={@form}
class="max-w-md w-full mx-auto bg-white dark:bg-gray-800 shadow-md rounded px-8 pt-6 pb-8 mb-4 mt-8"
phx-submit="allow-prop"
phx-click-away="cancel-allow-prop"
>
<h2 class="text-xl font-black dark:text-gray-100">Add Property for <%= @domain %></h2>
<div class="py-2">
<.label for="prop_input">
Property
</.label>
<.live_component
id="prop_input"
submit_name="prop"
class={[
"py-2"
]}
module={ComboBox}
suggest_fun={
pid = self()
fn
"", [] ->
options =
@site
|> Plausible.Props.suggest_keys_to_allow()
|> Enum.map(&{&1, &1})
send(pid, {:update_prop_key_options_count, Enum.count(options)})
options
input, options ->
ComboBox.StaticSearch.suggest(input, options)
end
}
creatable
/>
<.error :for={{msg, opts} <- f[:allowed_event_props].errors}>
<%= Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end)
end) %>
</.error>
</div>
<div class="py-4">
<button type="submit" class="button text-base font-bold w-full">
Add Property
</button>
</div>
<button
:if={@prop_key_options_count > 0}
title="Use this to add any existing properties from your past events into your settings. This allows you to set up properties without having to manually enter each item."
class="mt-2 text-sm hover:underline text-indigo-600 dark:text-indigo-400 text-left"
phx-click="allow-existing-props"
>
Already sending custom properties? Click to add <%= @prop_key_options_count %> existing properties we found.
</button>
</.form>
</div>
</div>
"""
end
def handle_info({:update_prop_key_options_count, count}, socket) do
{:noreply, assign(socket, prop_key_options_count: count)}
end
def handle_info({:selection_made, %{submit_value: _prop}}, socket) do
{:noreply, socket}
end
def handle_event("allow-prop", %{"prop" => prop}, socket) do
case Plausible.Props.allow(socket.assigns.site, prop) do
{:ok, site} ->
send(socket.assigns.rendered_by, {:prop_allowed, prop})
{:noreply,
assign(socket,
site: site,
form: new_form(site)
)}
{:error, changeset} ->
{:noreply,
assign(socket,
form: to_form(Map.put(changeset, :action, :validate))
)}
end
end
def handle_event("allow-existing-props", _params, socket) do
{:ok, site} = Plausible.Props.allow_existing_props(socket.assigns.site)
send(socket.assigns.rendered_by, {:props_allowed, site.allowed_event_props})
{:noreply,
assign(socket,
site: site,
form: new_form(site),
prop_key_options_count: 0
)}
end
def handle_event("cancel-allow-prop", _value, socket) do
send(socket.assigns.rendered_by, :cancel_add_prop)
{:noreply, socket}
end
defp new_form(site) do
to_form(Plausible.Props.allow_changeset(site, []))
end
end

View File

@ -0,0 +1,100 @@
defmodule PlausibleWeb.Live.PropsSettings.List do
@moduledoc """
Phoenix LiveComponent module that renders a list of custom properties
"""
use Phoenix.LiveComponent
use Phoenix.HTML
use Plausible.Funnel
attr(:props, :list, required: true)
attr(:domain, :string, required: true)
attr(:filter_text, :string)
def render(assigns) do
~H"""
<div>
<div class="border-t border-gray-200 pt-4 sm:flex sm:items-center sm:justify-between">
<form id="filter-form" phx-change="filter">
<div class="text-gray-800 text-sm inline-flex items-center">
<div class="relative mt-2 rounded-md shadow-sm flex">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<Heroicons.magnifying_glass class="feather mr-1 dark:text-gray-300" />
</div>
<input
type="text"
name="filter-text"
id="filter-text"
class="pl-8 shadow-sm dark:bg-gray-900 dark:text-gray-300 focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 dark:border-gray-500 rounded-md dark:bg-gray-800"
placeholder="Search Properties"
value={@filter_text}
/>
</div>
<Heroicons.backspace
:if={String.trim(@filter_text) != ""}
class="feather ml-2 cursor-pointer hover:text-red-500 dark:text-gray-300 dark:hover:text-red-500 mt-2"
phx-click="reset-filter-text"
id="reset-filter"
/>
</div>
</form>
<div class="mt-4 flex sm:ml-4 sm:mt-0">
<button
type="button"
phx-click="add-prop"
class="mt-2 block items-center rounded-md bg-indigo-600 p-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
+ Add Property
</button>
</div>
</div>
<%= if is_list(@props) && length(@props) > 0 do %>
<ul id="allowed-props" class="mt-12 divide-gray-200 divide-y dark:divide-gray-600">
<li :for={{prop, index} <- Enum.with_index(@props)} id={"prop-#{index}"} class="flex py-4">
<span class="flex-1 truncate font-medium text-sm text-gray-800 dark:text-gray-200">
<%= prop %>
</span>
<button
id={"disallow-prop-#{prop}"}
data-confirm={delete_confirmation_text(prop)}
phx-click="disallow-prop"
phx-value-prop={prop}
class="w-4 h-4 text-red-600 hover:text-red-700"
aria-label={"Remove #{prop} property"}
>
<Heroicons.trash class="feather feather-sm" />
</button>
</li>
</ul>
<% else %>
<p class="text-sm text-gray-800 dark:text-gray-200 mt-12 mb-8 text-center">
<span :if={String.trim(@filter_text) != ""}>
No properties found for this site. Please refine or
<a
class="text-indigo-500 cursor-pointer underline"
phx-click="reset-filter-text"
id="reset-filter-hint"
>
reset your search.
</a>
</span>
<span :if={String.trim(@filter_text) == "" && Enum.empty?(@props)}>
No properties configured for this site.
</span>
</p>
<% end %>
</div>
"""
end
defp delete_confirmation_text(prop) do
"""
Are you sure you want to remove the following property:
#{prop}
This will just affect the UI, all of your analytics data will stay intact.
"""
end
end

View File

@ -18,10 +18,14 @@
Custom Properties
</h1>
<h2 class="mt-1 text-sm leading-5 text-gray-500 dark:text-gray-400">
Attach custom properties when sending a pageview or an event to
create custom metrics
</h2>
<p class="mt-2 text-sm leading-5 text-gray-500 dark:text-gray-200">
Attach Custom Properties when sending a Pageview or an Event to
create custom metrics.
</p>
<p class="text-sm leading-5 text-gray-500 dark:text-gray-200">
In order for the properties to show up on your dashboard, you need to
explicitly add them below first.
</p>
</span>
<.link
@ -48,7 +52,7 @@
<PlausibleWeb.Components.Site.Feature.toggle
site={@site}
setting={:props_enabled}
label="Show properties in the dashboard"
label="Show Properties in the Dashboard"
conn={@conn}
>
<%= live_render(@conn, PlausibleWeb.Live.PropsSettings,

View File

@ -27,9 +27,7 @@ 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: "Funnels", value: "funnels"],
[key: "Custom Properties", value: "properties"],
[key: "Search Console", value: "search-console"],
[key: "Email Reports", value: "email-reports"],

View File

@ -12,8 +12,6 @@
user = Plausible.Factory.insert(:user, email: "user@plausible.test", password: "plausible")
FunWithFlags.enable(:funnels)
native_stats_range =
Date.range(
Date.add(Date.utc_today(), -720),
@ -207,9 +205,12 @@ native_stats_range
operating_system: Enum.random(["Windows", "macOS", "Linux"]),
operating_system_version: to_string(Enum.random(0..15)),
user_id: Enum.random(1..1200),
"meta.key": ["url"],
"meta.key": ["url", "logged_in", "is_customer", "amount"],
"meta.value": [
Enum.random(long_random_urls)
Enum.random(long_random_urls),
Enum.random(["true", "false"]),
Enum.random(["true", "false"]),
to_string(Enum.random(1..9000))
]
]
|> Keyword.merge(geolocation)

View File

@ -57,13 +57,6 @@ defmodule PlausibleWeb.Live.Components.ComboBoxTest do
assert text_of_attr(li2, "x-bind:class") =~ "focus === 2"
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)
@ -123,6 +116,15 @@ defmodule PlausibleWeb.Live.Components.ComboBoxTest do
"No matches found. Try searching for something different."
end
test "when no options available, hints the user to create one by typing" do
doc =
render_sample_component([],
creatable: true
)
assert text_of_element(doc, "#dropdown-test-component div") == "Create an item by typing."
end
test "makes the html input required when required option is passed" do
input_query = "input[type=text][required]"
assert render_sample_component([], required: true) |> element_exists?(input_query)

View File

@ -95,20 +95,13 @@ defmodule PlausibleWeb.Live.GoalSettings.FormTest do
test "currency combo works", %{conn: conn, site: site} do
lv = get_liveview(conn, site)
# Account for asynchronous updates. Here, the options, while static,
# are still a sizeable payload that we can defer before the user manages
# to click "this is revenue goal" switch.
# There's also throttling applied to the ComboBox.
:timer.sleep(200)
type_into_combo(lv, "currency_input", "Polish")
:timer.sleep(200)
html = render(lv)
assert element_exists?(html, ~s/a[phx-value-display-value="PLN - Polish Zloty"]/)
refute element_exists?(html, ~s/a[phx-value-display-value="EUR - Euro"]/)
type_into_combo(lv, "currency_input", "Euro")
:timer.sleep(200)
html = render(lv)
refute element_exists?(html, ~s/a[phx-value-display-value="PLN - Polish Zloty"]/)
@ -135,16 +128,12 @@ defmodule PlausibleWeb.Live.GoalSettings.FormTest do
type_into_combo(lv, "page_path_input", "/go/to/p")
# Account some large-ish margin for Clickhouse latency when providing suggestions asynchronously
# Might be too much, but also not enough on sluggish CI
:timer.sleep(350)
html = render(lv)
assert html =~ "Create &quot;/go/to/p&quot;"
assert html =~ "/go/to/page/1"
refute html =~ "/go/home"
type_into_combo(lv, "page_path_input", "/go/h")
:timer.sleep(350)
html = render(lv)
assert html =~ "/go/home"
refute html =~ "/go/to/page/1"

View File

@ -0,0 +1,156 @@
defmodule PlausibleWeb.Live.PropsSettings.FormTest do
use PlausibleWeb.ConnCase, async: true
import Phoenix.LiveViewTest
import Plausible.Test.Support.HTML
describe "Props submission" do
setup [:create_user, :log_in, :create_site]
test "renders form fields", %{conn: conn, site: site} do
lv = get_liveview(conn, site)
html = render(lv)
assert element_exists?(html, "form input[type=text][name=display-prop_input]")
assert element_exists?(html, "form input[type=hidden][name=prop]")
end
test "renders error on empty submission", %{conn: conn, site: site} do
lv = get_liveview(conn, site)
html = lv |> element("form") |> render_submit()
assert html =~ "must be between 1 and 300 characters"
end
test "renders error on whitespace submission", %{conn: conn, site: site} do
lv = get_liveview(conn, site)
html = lv |> element("form") |> render_submit(%{prop: " "})
assert html =~ "must be between 1 and 300 characters"
end
test "renders 'Create' suggestion", %{conn: conn, site: site} do
lv = get_liveview(conn, site)
type_into_combo(lv, "#prop_input", "Hello world")
html = render(lv)
assert text_of_element(html, "#dropdown-prop_input-option-0 a") == ~s/Create "Hello world"/
end
test "clicking suggestion fills out input", %{conn: conn, site: site} = context do
seed_props(context)
lv = get_liveview(conn, site)
type_into_combo(lv, "#prop_input", "amo")
doc =
lv
|> element(~s/ul#dropdown-prop_input li#dropdown-prop_input-option-1 a/)
|> render_click()
assert element_exists?(doc, ~s/input[type="hidden"][value="amount"]/)
end
test "allowing a single property", %{conn: conn, site: site} do
{parent, lv} = get_liveview(conn, site, with_parent?: true)
refute render(parent) =~ "foobarbaz"
lv |> element("form") |> render_submit(%{prop: "foobarbaz"})
parent_html = render(parent)
assert text_of_element(parent_html, "#prop-0") == "foobarbaz"
site = Plausible.Repo.reload!(site)
assert site.allowed_event_props == ["foobarbaz"]
end
test "allowing existing properties", %{conn: conn, site: site} = context do
seed_props(context)
{parent, lv} = get_liveview(conn, site, with_parent?: true)
parent_html = render(parent)
refute element_exists?(parent_html, "#prop-0")
refute element_exists?(parent_html, "#prop-1")
refute element_exists?(parent_html, "#prop-2")
lv
|> element(~s/button[phx-click="allow-existing-props"]/)
|> render_click()
parent_html = render(parent)
assert text_of_element(parent_html, "#prop-0") == "amount"
assert text_of_element(parent_html, "#prop-1") == "logged_in"
assert text_of_element(parent_html, "#prop-2") == "is_customer"
site = Plausible.Repo.reload!(site)
assert site.allowed_event_props == ["amount", "logged_in", "is_customer"]
end
test "does not show allow existing props button when there are no events with props", %{
conn: conn,
site: site
} do
lv = get_liveview(conn, site)
refute element_exists?(render(lv), ~s/button[phx-click="allow-existing-props"]/)
end
test "does not show allow existing props button after adding all suggestions",
%{
conn: conn,
site: site
} = context do
seed_props(context)
conn
|> get_liveview(site)
|> element(~s/button[phx-click="allow-existing-props"]/)
|> render_click()
site = Plausible.Repo.reload!(site)
assert site.allowed_event_props == ["amount", "logged_in", "is_customer"]
html =
conn
|> get_liveview(site)
|> render()
refute element_exists?(html, ~s/button[phx-click="allow-existing-props"]/)
end
end
defp seed_props(%{site: site}) do
populate_stats(site, [
build(:event,
name: "Payment",
"meta.key": ["amount"],
"meta.value": ["500"]
),
build(:event,
name: "Payment",
"meta.key": ["amount", "logged_in"],
"meta.value": ["100", "false"]
),
build(:event,
name: "Payment",
"meta.key": ["amount", "is_customer"],
"meta.value": ["100", "false"]
)
])
:ok
end
defp get_liveview(conn, site, opts \\ []) do
conn = assign(conn, :live_module, PlausibleWeb.Live.PropsSettings)
{:ok, lv, _html} = live(conn, "/#{site.domain}/settings/properties")
lv |> element(~s/button[phx-click="add-prop"]/) |> render_click()
assert form_view = find_live_child(lv, "props-form")
if opts[:with_parent?] do
{lv, form_view}
else
form_view
end
end
defp type_into_combo(lv, id, text) do
lv
|> element("input##{id}")
|> render_change(%{
"_target" => ["display-#{id}"],
"display-#{id}" => "#{text}"
})
end
end

View File

@ -1,202 +1,177 @@
defmodule PlausibleWeb.Live.PropsSettings.FormTest do
defmodule PlausibleWeb.Live.PropsSettingsTest do
use PlausibleWeb.ConnCase, async: true
import Phoenix.LiveViewTest
import Plausible.Test.Support.HTML
defp seed(%{site: site}) do
populate_stats(site, [
build(:event,
name: "Payment",
"meta.key": ["amount"],
"meta.value": ["500"]
),
build(:event,
name: "Payment",
"meta.key": ["amount", "logged_in"],
"meta.value": ["100", "false"]
),
build(:event,
name: "Payment",
"meta.key": ["amount", "is_customer"],
"meta.value": ["100", "false"]
)
])
describe "GET /:website/settings/properties" do
setup [:create_user, :log_in, :create_site]
:ok
test "lists props for the site and renders links", %{conn: conn, site: site} do
{:ok, site} = Plausible.Props.allow(site, ["amount", "logged_in", "is_customer"])
conn = get(conn, "/#{site.domain}/settings/properties")
resp = html_response(conn, 200)
assert resp =~ "Attach Custom Properties"
assert element_exists?(
resp,
~s|a[href="https://plausible.io/docs/custom-props/introduction"]|
)
assert resp =~ "amount"
assert resp =~ "logged_in"
assert resp =~ "is_customer"
end
test "lists props with disallow actions", %{conn: conn, site: site} do
{:ok, site} = Plausible.Props.allow(site, ["amount", "logged_in", "is_customer"])
conn = get(conn, "/#{site.domain}/settings/properties")
resp = html_response(conn, 200)
for p <- site.allowed_event_props do
assert element_exists?(
resp,
~s/button[phx-click="disallow-prop"][phx-value-prop=#{p}]#disallow-prop-#{p}/
)
end
end
test "if no props are allowed, a proper info is displayed", %{conn: conn, site: site} do
conn = get(conn, "/#{site.domain}/settings/properties")
resp = html_response(conn, 200)
assert resp =~ "No properties configured for this site"
end
test "if props are enabled, no info about missing props is displayed", %{
conn: conn,
site: site
} do
{:ok, site} = Plausible.Props.allow(site, ["amount", "logged_in", "is_customer"])
conn = get(conn, "/#{site.domain}/settings/properties")
resp = html_response(conn, 200)
refute resp =~ "No properties configured for this site"
end
test "add property button is rendered", %{conn: conn, site: site} do
conn = get(conn, "/#{site.domain}/settings/properties")
resp = html_response(conn, 200)
assert element_exists?(resp, ~s/button[phx-click="add-prop"]/)
end
test "search props input is rendered", %{conn: conn, site: site} do
conn = get(conn, "/#{site.domain}/settings/properties")
resp = html_response(conn, 200)
assert element_exists?(resp, ~s/input[type="text"]#filter-text/)
assert element_exists?(resp, ~s/form[phx-change="filter"]#filter-form/)
end
end
setup [:create_user, :log_in, :create_site, :seed]
# validating input
# clicking suggestions fills out input
# adding props
# error when reached props limit
# clearserror when fixed input
# removal
# removal shows confirmation
# allow existing props: shows/hides
# after adding all suggestions no allow existing props
test "shows message when site has no allowed properties", %{conn: conn, site: site} do
{:ok, _lv, doc} = get_liveview(conn, site)
assert doc =~ "No properties configured for this site yet"
describe "PropsSettings live view" do
setup [:create_user, :log_in, :create_site]
test "allows prop removal", %{conn: conn, site: site} do
{:ok, site} = Plausible.Props.allow(site, ["amount", "logged_in"])
{lv, html} = get_liveview(conn, site, with_html?: true)
assert html =~ "amount"
assert html =~ "logged_in"
html = lv |> element(~s/button#disallow-prop-amount/) |> render_click()
refute html =~ "amount"
assert html =~ "logged_in"
html = get(conn, "/#{site.domain}/settings/properties") |> html_response(200)
refute html =~ "amount"
assert html =~ "logged_in"
end
test "allows props filtering / search", %{conn: conn, site: site} do
{:ok, site} = Plausible.Props.allow(site, ["amount", "logged_in", "is_customer"])
{lv, html} = get_liveview(conn, site, with_html?: true)
assert html =~ to_string("amount")
assert html =~ to_string("logged_in")
assert html =~ to_string("is_customer")
html = type_into_search(lv, "is_customer")
refute html =~ to_string("amount")
refute html =~ to_string("logged_in")
assert html =~ to_string("is_customer")
end
test "allows resetting filter text via backspace icon", %{conn: conn, site: site} do
{:ok, site} = Plausible.Props.allow(site, ["amount", "logged_in", "is_customer"])
{lv, html} = get_liveview(conn, site, with_html?: true)
refute element_exists?(html, ~s/svg[phx-click="reset-filter-text"]#reset-filter/)
html = type_into_search(lv, to_string("is_customer"))
assert element_exists?(html, ~s/svg[phx-click="reset-filter-text"]#reset-filter/)
html = lv |> element(~s/svg#reset-filter/) |> render_click()
assert html =~ to_string("is_customer")
assert html =~ to_string("amount")
assert html =~ to_string("logged_in")
end
test "allows resetting filter text via no match link", %{conn: conn, site: site} do
lv = get_liveview(conn, site)
html = type_into_search(lv, "Definitely this is not going to render any matches")
assert html =~ "No properties found for this site. Please refine or"
assert html =~ "reset your search"
assert element_exists?(html, ~s/a[phx-click="reset-filter-text"]#reset-filter-hint/)
html = lv |> element(~s/a#reset-filter-hint/) |> render_click()
refute html =~ "No properties found for this site. Please refine or"
end
test "clicking Add Property button renders the form view", %{conn: conn, site: site} do
lv = get_liveview(conn, site)
html = lv |> element(~s/button[phx-click="add-prop"]/) |> render_click()
assert html =~ "Add Property for #{site.domain}"
assert element_exists?(
html,
~s/div#props-form form[phx-submit="allow-prop"][phx-click-away="cancel-allow-prop"]/
)
end
end
test "renders dropdown with suggestions", %{conn: conn, site: site} do
{:ok, _lv, doc} = get_liveview(conn, site)
assert text_of_element(doc, ~s/ul#dropdown-prop_input li#dropdown-prop_input-option-1/) ==
"amount"
assert text_of_element(doc, ~s/ul#dropdown-prop_input li#dropdown-prop_input-option-2/) ==
"logged_in"
assert text_of_element(doc, ~s/ul#dropdown-prop_input li#dropdown-prop_input-option-3/) ==
"is_customer"
end
test "input is a required field", %{conn: conn, site: site} do
{:ok, _lv, doc} = get_liveview(conn, site)
assert element_exists?(doc, ~s/input#prop_input[required]/)
end
test "clicking suggestion fills out input", %{conn: conn, site: site} do
{:ok, lv, _doc} = get_liveview(conn, site)
doc =
lv
|> element(~s/ul#dropdown-prop_input li#dropdown-prop_input-option-1 a/)
|> render_click()
assert element_exists?(doc, ~s/input[type="hidden"][value="amount"]/)
end
test "saving from suggestion adds to the list", %{conn: conn, site: site} do
{:ok, lv, _doc} = get_liveview(conn, site)
doc = select_and_submit(lv, 1)
assert text_of_element(doc, ~s/ul#allowed-props li#prop-0 span/) == "amount"
refute doc =~ "No properties configured for this site yet"
end
test "saving from manual input adds to the list", %{conn: conn, site: site} do
{:ok, lv, _doc} = get_liveview(conn, site)
type_into_combo(lv, "Operating System")
doc =
lv
|> form("#props-form")
|> render_submit()
assert text_of_element(doc, ~s/ul#allowed-props li#prop-0 span/) == "Operating System"
refute doc =~ "No properties configured for this site yet"
end
test "shows error when input is invalid", %{conn: conn, site: site} do
{:ok, lv, _doc} = get_liveview(conn, site)
type_into_combo(lv, " ")
doc = lv |> form("#props-form") |> render_submit()
assert text_of_element(doc, ~s/div#prop-errors div/) == "must be between 1 and 300 characters"
assert doc =~ "No properties configured for this site yet"
end
test "shows error when reached prop limit", %{conn: conn, site: site} do
props = for i <- 1..300, do: "my-prop-#{i}"
{:ok, site} = Plausible.Props.allow(site, props)
{:ok, lv, _doc} = get_liveview(conn, site)
type_into_combo(lv, "my-prop-301")
doc = lv |> form("#props-form") |> render_submit()
assert text_of_element(doc, ~s/div#prop-errors div/) == "should have at most 300 item(s)"
end
test "clears error message when user fixes input", %{conn: conn, site: site} do
{:ok, lv, _doc} = get_liveview(conn, site)
type_into_combo(lv, " ")
doc = lv |> form("#props-form") |> render_submit()
assert text_of_element(doc, ~s/div#prop-errors div/) == "must be between 1 and 300 characters"
type_into_combo(lv, "my-prop")
doc = lv |> form("#props-form") |> render_submit()
refute element_exists?(doc, ~s/div#prop-errors/)
end
test "clicking remove button removes from the list", %{conn: conn, site: site} do
{:ok, site} = Plausible.Props.allow(site, "my-prop")
{:ok, lv, doc} = get_liveview(conn, site)
assert text_of_element(doc, ~s/ul#allowed-props li#prop-0 span/) == "my-prop"
doc =
lv
|> element(~s/ul#allowed-props li#prop-0 button[phx-click="disallow"]/)
|> render_click()
refute element_exists?(doc, ~s/ul#allowed-props li#prop-0 span/)
assert doc =~ "No properties configured for this site yet"
end
test "remove button shows a confirmation popup", %{conn: conn, site: site} do
{:ok, site} = Plausible.Props.allow(site, "my-prop")
{:ok, _lv, doc} = get_liveview(conn, site)
assert "Are you sure you want to remove property 'my-prop'? This will just affect the UI, all of your analytics data will stay intact." ==
doc
|> Floki.find(~s/ul#allowed-props li#prop-0 button[phx-click="disallow"]/)
|> text_of_attr("data-confirm")
end
test "clicking allow existing props button saves props from events", %{conn: conn, site: site} do
{:ok, lv, _doc} = get_liveview(conn, site)
doc =
lv
|> element(~s/button[phx-click="allow-existing-props"]/)
|> render_click()
assert text_of_element(doc, ~s/ul#allowed-props li#prop-0 span/) == "amount"
assert text_of_element(doc, ~s/ul#allowed-props li#prop-1 span/) == "logged_in"
assert text_of_element(doc, ~s/ul#allowed-props li#prop-2 span/) == "is_customer"
end
test "does not show allow existing props button when there are no events with props", %{
conn: conn,
user: user
} do
{:ok, _lv, doc} = get_liveview(conn, insert(:site, members: [user]))
refute element_exists?(doc, ~s/button[phx-click="allow-existing-props"]/)
end
test "does not show allow existing props button after adding all suggestions", %{
conn: conn,
site: site
} do
{:ok, lv, _doc} = get_liveview(conn, site)
_doc = select_and_submit(lv, 1)
_doc = select_and_submit(lv, 1)
doc = select_and_submit(lv, 1)
refute element_exists?(doc, ~s/button[phx-click="allow-existing-props"]/)
end
defp get_liveview(conn, site) do
defp get_liveview(conn, site, opts \\ []) do
conn = assign(conn, :live_module, PlausibleWeb.Live.PropsSettings)
{:ok, _lv, _doc} = live(conn, "/#{site.domain}/settings/properties")
{:ok, lv, html} = live(conn, "/#{site.domain}/settings/properties")
if Keyword.get(opts, :with_html?) do
{lv, html}
else
lv
end
end
defp select_and_submit(lv, suggestion_index) do
defp type_into_search(lv, text) do
lv
|> element(~s/ul#dropdown-prop_input li#dropdown-prop_input-option-#{suggestion_index} a/)
|> render_click()
lv
|> form("#props-form")
|> render_submit()
end
defp type_into_combo(lv, input) do
lv
|> element("input#prop_input")
|> element("form#filter-form")
|> render_change(%{
"_target" => ["display-prop_input"],
"display-prop_input" => input
"_target" => ["filter-text"],
"filter-text" => "#{text}"
})
end
end