From b277f2fc042d4db380366f511e2a043c3ba1d52a Mon Sep 17 00:00:00 2001 From: Sanne de Vries Date: Wed, 26 Nov 2025 17:19:34 +0100 Subject: [PATCH 01/21] Add static custom property UI to goal settings form --- lib/plausible_web/live/goal_settings/form.ex | 72 ++++++++++++++++---- 1 file changed, 58 insertions(+), 14 deletions(-) diff --git a/lib/plausible_web/live/goal_settings/form.ex b/lib/plausible_web/live/goal_settings/form.ex index 716a7eb99e2a..fb3ed40dcb23 100644 --- a/lib/plausible_web/live/goal_settings/form.ex +++ b/lib/plausible_web/live/goal_settings/form.ex @@ -200,10 +200,9 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do def pageview_fields(assigns) do ~H""" -
-
- Pageview goals allow you to measure how many people visit a specific page or section of your site. - <.styled_link +
+
+ Pageview goals allow you to measure how many people visit a specific page or section of your site. Learn more in <.styled_link href="https://plausible.io/docs/pageview-goals" new_tab={true} > @@ -240,6 +239,8 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do x-data="{ firstFocus: true }" x-on:focus="if (firstFocus) { $el.select(); firstFocus = false; }" /> + + <.custom_property_section suffix={@suffix} />
""" end @@ -258,6 +259,7 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do scrollThreshold: '90', pagePath: '', displayName: '', + addCustomProperty: false, updateDisplayName() { if (this.scrollThreshold && this.pagePath) { this.displayName = `Scroll ${this.scrollThreshold}% on ${this.pagePath}` @@ -271,6 +273,7 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do scrollThreshold: '#{assigns.goal.scroll_threshold}', pagePath: '#{assigns.goal.page_path}', displayName: '#{assigns.goal.display_name}', + addCustomProperty: false, updateDisplayName() {} } """ @@ -336,6 +339,8 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do x-data="{ firstFocus: true }" x-on:focus="if (firstFocus) { $el.select(); firstFocus = false; }" /> + + <.custom_property_section suffix={@suffix} />
""" end @@ -354,7 +359,7 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do def custom_event_fields(assigns) do ~H""" -
+
Custom Events are not tracked by default - you have to configure them on your site to be sent to Plausible. See examples and learn more in <.styled_link @@ -401,6 +406,8 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do />
+ <.custom_property_section suffix={@suffix} /> + <%= if ee?() and Plausible.Sites.regular?(@site) and not editing_non_revenue_goal?(assigns) do %> <.revenue_goal_settings f={@f} @@ -419,6 +426,43 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do """ end + attr(:suffix, :string, required: true) + + def custom_property_section(assigns) do + ~H""" +
+ + Add custom property + + <.toggle_switch + id="add-custom-property" + id_suffix={@suffix} + js_active_var="addCustomProperty" + /> +
+ +
+ <.live_component + id={"property_input_#{@suffix}"} + submit_name="goal[property]" + placeholder="Select property" + module={ComboBox} + suggest_fun={fn _input, _options -> [] end} + creatable + /> + is + <.live_component + id={"value_input_#{@suffix}"} + submit_name="goal[value]" + placeholder="Select value" + module={ComboBox} + suggest_fun={fn _input, _options -> [] end} + creatable + /> +
+ """ + end + def revenue_goal_settings(assigns) do js_data = Jason.encode!(%{ @@ -431,7 +475,7 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do ~H"""
<%= if is_nil(@goal) do %> -
+
<.revenue_toggle {assigns} />
<% else %> @@ -552,15 +596,9 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do />.
-
- +
Enable revenue tracking +
""" From 185dfa93787174709d2abf6fe5a3628bfde9f303 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Mon, 5 Jan 2026 08:13:39 +0100 Subject: [PATCH 02/21] Implement custom property suggestions for goals --- lib/plausible/stats/goal_suggestions.ex | 84 ++++ .../plausible/stats/goal_suggestions_test.exs | 414 ++++++++++++++++++ 2 files changed, 498 insertions(+) diff --git a/lib/plausible/stats/goal_suggestions.ex b/lib/plausible/stats/goal_suggestions.ex index 5ab861f4052f..397b6f78a736 100644 --- a/lib/plausible/stats/goal_suggestions.ex +++ b/lib/plausible/stats/goal_suggestions.ex @@ -1,6 +1,8 @@ defmodule Plausible.Stats.GoalSuggestions do @moduledoc false + use Plausible.Stats.SQL.Fragments + alias Plausible.{Repo, ClickhouseRepo} alias Plausible.Stats.{Query, QueryBuilder} import Plausible.Stats.Base @@ -93,6 +95,88 @@ defmodule Plausible.Stats.GoalSuggestions do |> Enum.reject(&(String.length(&1) > Plausible.Goal.max_event_name_length())) end + def suggest_custom_property_names(site, search_input, _opts \\ []) do + filter_query = if search_input == nil, do: "%", else: "%#{search_input}%" + + query = custom_props_query_30d(site) + + search_q = + from(e in base_event_query(query), + join: meta in "meta", + hints: "ARRAY", + on: true, + as: :meta, + select: meta.key, + where: fragment("? ilike ?", meta.key, ^filter_query), + group_by: meta.key, + order_by: [desc: fragment("count(*)")], + limit: 25 + ) + + event_prop_names = ClickhouseRepo.all(search_q) + + allowed_props = site.allowed_event_props || [] + + allowed_prop_names = + if search_input == nil or search_input == "" do + allowed_props + else + search_lower = String.downcase(search_input) + + Enum.filter(allowed_props, fn prop -> + String.contains?(String.downcase(prop), search_lower) + end) + end + + # Combine results, prioritizing event_prop_names (they have usage data), + # then append allowed_prop_names that aren't already in event_prop_names + event_prop_set = MapSet.new(event_prop_names) + + allowed_only = + allowed_prop_names + |> Enum.reject(&MapSet.member?(event_prop_set, &1)) + + event_prop_names ++ Enum.sort(allowed_only) + end + + def suggest_custom_property_values(site, prop_key, search_input) do + filter_query = if search_input == nil, do: "%", else: "%#{search_input}%" + + query = custom_props_query_30d(site) + + search_q = + from(e in base_event_query(query), + select: get_by_key(e, :meta, ^prop_key), + where: + has_key(e, :meta, ^prop_key) and + fragment( + "? ilike ?", + get_by_key(e, :meta, ^prop_key), + ^filter_query + ), + group_by: get_by_key(e, :meta, ^prop_key), + order_by: [desc: fragment("count(*)")], + limit: 25 + ) + + ClickhouseRepo.all(search_q) + end + + defp custom_props_query_30d(site) do + Plausible.Stats.Query.parse_and_build!( + site, + %{ + "site_id" => site.domain, + "date_range" => [ + Date.to_iso8601(Date.shift(Date.utc_today(), day: -30)), + Date.to_iso8601(Date.utc_today()) + ], + "metrics" => ["pageviews"], + "include" => %{"imports" => true} + } + ) + end + defp maybe_set_limit(q, :unlimited) do q end diff --git a/test/plausible/stats/goal_suggestions_test.exs b/test/plausible/stats/goal_suggestions_test.exs index fec40f3a60d7..bf050c177b3f 100644 --- a/test/plausible/stats/goal_suggestions_test.exs +++ b/test/plausible/stats/goal_suggestions_test.exs @@ -102,4 +102,418 @@ defmodule Plausible.Stats.GoalSuggestionsTest do assert GoalSuggestions.suggest_event_names(site, "") == [] end end + + describe "suggest_custom_property_names/3" do + setup [:create_user, :create_site] + + test "returns custom property names from the last 30 days, ordered by usage count", %{ + site: site + } do + populate_stats(site, [ + build(:event, + name: "Purchase", + "meta.key": ["amount", "plan"], + "meta.value": ["100", "business"], + timestamp: NaiveDateTime.utc_now() |> NaiveDateTime.shift(day: -1) + ), + build(:event, + name: "Purchase", + "meta.key": ["plan"], + "meta.value": ["enterprise"], + timestamp: NaiveDateTime.utc_now() |> NaiveDateTime.shift(day: -5) + ), + build(:event, + name: "Signup", + "meta.key": ["plan", "referrer"], + "meta.value": ["starter", "friend"], + timestamp: NaiveDateTime.utc_now() |> NaiveDateTime.shift(day: -10) + ), + build(:event, + name: "Click", + "meta.key": ["button_id"], + "meta.value": ["submit-btn"], + timestamp: NaiveDateTime.utc_now() |> NaiveDateTime.shift(day: -20) + ) + ]) + + # "plan" appears 3 times, "amount" 1 time, "referrer" 1 time, "button_id" 1 time + suggestions = GoalSuggestions.suggest_custom_property_names(site, "") + assert "plan" == hd(suggestions) + assert length(suggestions) == 4 + assert "plan" in suggestions + assert "amount" in suggestions + assert "referrer" in suggestions + assert "button_id" in suggestions + end + + test "filters property names by search input", %{site: site} do + populate_stats(site, [ + build(:event, + name: "Purchase", + "meta.key": ["plan_type", "payment_method", "amount"], + "meta.value": ["business", "credit_card", "100"], + timestamp: NaiveDateTime.utc_now() |> NaiveDateTime.shift(day: -1) + ), + build(:event, + name: "Signup", + "meta.key": ["referrer", "plan_name"], + "meta.value": ["google", "starter"], + timestamp: NaiveDateTime.utc_now() |> NaiveDateTime.shift(day: -2) + ) + ]) + + suggestions = GoalSuggestions.suggest_custom_property_names(site, "plan") + assert length(suggestions) == 2 + assert "plan_type" in suggestions + assert "plan_name" in suggestions + refute "amount" in suggestions + refute "referrer" in suggestions + end + + test "excludes events older than 30 days", %{site: site} do + populate_stats(site, [ + build(:event, + name: "Purchase", + "meta.key": ["recent_prop"], + "meta.value": ["value1"], + timestamp: NaiveDateTime.utc_now() |> NaiveDateTime.shift(day: -29) + ), + build(:event, + name: "Purchase", + "meta.key": ["old_prop"], + "meta.value": ["value2"], + timestamp: NaiveDateTime.utc_now() |> NaiveDateTime.shift(day: -31) + ) + ]) + + suggestions = GoalSuggestions.suggest_custom_property_names(site, "") + assert "recent_prop" in suggestions + refute "old_prop" in suggestions + + suggestions = GoalSuggestions.suggest_custom_property_names(site, "prop") + assert "recent_prop" in suggestions + refute "old_prop" in suggestions + end + + test "returns empty list when no custom properties exist", %{site: site} do + populate_stats(site, [ + build(:event, name: "Purchase", timestamp: NaiveDateTime.utc_now()) + ]) + + assert GoalSuggestions.suggest_custom_property_names(site, "") == [] + end + + test "handles nil search input", %{site: site} do + populate_stats(site, [ + build(:event, + name: "Purchase", + "meta.key": ["plan"], + "meta.value": ["business"], + timestamp: NaiveDateTime.utc_now() + ) + ]) + + suggestions = GoalSuggestions.suggest_custom_property_names(site, nil) + assert "plan" in suggestions + end + + test "includes allowed_prop_names from site configuration when no event data exists", %{ + site: site + } do + site = + site + |> Ecto.Changeset.change(allowed_event_props: ["prop_a", "prop_b", "prop_c"]) + |> Repo.update!() + + suggestions = GoalSuggestions.suggest_custom_property_names(site, "") + + assert suggestions == ["prop_a", "prop_b", "prop_c"] + end + + test "combines event_prop_names and allowed_prop_names, prioritizing event usage", %{ + site: site + } do + site = + site + |> Ecto.Changeset.change( + allowed_event_props: ["allowed_prop", "shared_prop", "another_allowed"] + ) + |> Repo.update!() + + populate_stats(site, [ + build(:event, + name: "Purchase", + "meta.key": ["event_prop"], + "meta.value": ["value1"], + timestamp: NaiveDateTime.utc_now() |> NaiveDateTime.shift(day: -1) + ), + build(:event, + name: "Purchase", + "meta.key": ["event_prop"], + "meta.value": ["value2"], + timestamp: NaiveDateTime.utc_now() |> NaiveDateTime.shift(day: -2) + ), + build(:event, + name: "Signup", + "meta.key": ["shared_prop"], + "meta.value": ["value3"], + timestamp: NaiveDateTime.utc_now() |> NaiveDateTime.shift(day: -3) + ) + ]) + + suggestions = GoalSuggestions.suggest_custom_property_names(site, "") + + assert suggestions == ["event_prop", "shared_prop", "allowed_prop", "another_allowed"] + end + + test "filters allowed_prop_names by search input (case-insensitive)", %{ + site: site + } do + site = + site + |> Ecto.Changeset.change( + allowed_event_props: ["plan_type", "user_plan", "payment_method", "amount"] + ) + |> Repo.update!() + + suggestions = GoalSuggestions.suggest_custom_property_names(site, "plan") + + assert suggestions == ["plan_type", "user_plan"] + end + + test "filters both event_prop_names and allowed_prop_names by search input", %{site: site} do + site = + site + |> Ecto.Changeset.change(allowed_event_props: ["plan_type", "user_plan", "amount"]) + |> Repo.update!() + + populate_stats(site, [ + build(:event, + name: "Purchase", + "meta.key": ["plan_name", "payment_method"], + "meta.value": ["business", "credit_card"], + timestamp: NaiveDateTime.utc_now() |> NaiveDateTime.shift(day: -1) + ) + ]) + + suggestions = GoalSuggestions.suggest_custom_property_names(site, "plan") + + assert suggestions == ["plan_name", "plan_type", "user_plan"] + end + + test "handles case-insensitive search matching for allowed_prop_names", %{site: site} do + site = + site + |> Ecto.Changeset.change(allowed_event_props: ["UserPlan", "PLAN_TYPE", "Payment_Method"]) + |> Repo.update!() + + suggestions = GoalSuggestions.suggest_custom_property_names(site, "plan") + + assert suggestions == ["PLAN_TYPE", "UserPlan"] + end + + test "deduplicates when property exists in both event_prop_names and allowed_prop_names", %{ + site: site + } do + site = + site + |> Ecto.Changeset.change(allowed_event_props: ["plan", "amount", "referrer"]) + |> Repo.update!() + + populate_stats(site, [ + build(:event, + name: "Purchase", + "meta.key": ["plan", "category"], + "meta.value": ["business", "software"], + timestamp: NaiveDateTime.utc_now() |> NaiveDateTime.shift(day: -1) + ), + build(:event, + name: "Purchase", + "meta.key": ["plan"], + "meta.value": ["enterprise"], + timestamp: NaiveDateTime.utc_now() |> NaiveDateTime.shift(day: -2) + ) + ]) + + suggestions = GoalSuggestions.suggest_custom_property_names(site, "") + + # plan appears only once + assert suggestions == ["plan", "category", "amount", "referrer"] + end + + test "handles empty allowed_event_props gracefully", %{site: site} do + site = + site + |> Ecto.Changeset.change(allowed_event_props: []) + |> Repo.update!() + + populate_stats(site, [ + build(:event, + name: "Purchase", + "meta.key": ["plan"], + "meta.value": ["business"], + timestamp: NaiveDateTime.utc_now() + ) + ]) + + suggestions = GoalSuggestions.suggest_custom_property_names(site, "") + assert suggestions == ["plan"] + end + + test "handles nil allowed_event_props gracefully", %{site: site} do + site = + site + |> Ecto.Changeset.change(allowed_event_props: nil) + |> Repo.update!() + + populate_stats(site, [ + build(:event, + name: "Purchase", + "meta.key": ["plan"], + "meta.value": ["business"], + timestamp: NaiveDateTime.utc_now() + ) + ]) + + suggestions = GoalSuggestions.suggest_custom_property_names(site, "") + assert suggestions == ["plan"] + end + end + + describe "suggest_custom_property_values/3" do + setup [:create_user, :create_site] + + test "returns custom property values for a given key from the last 30 days", %{site: site} do + populate_stats(site, [ + build(:event, + name: "Purchase", + "meta.key": ["plan"], + "meta.value": ["business"], + timestamp: NaiveDateTime.utc_now() |> NaiveDateTime.shift(day: -1) + ), + build(:event, + name: "Purchase", + "meta.key": ["plan"], + "meta.value": ["business"], + timestamp: NaiveDateTime.utc_now() |> NaiveDateTime.shift(day: -2) + ), + build(:event, + name: "Purchase", + "meta.key": ["plan"], + "meta.value": ["enterprise"], + timestamp: NaiveDateTime.utc_now() |> NaiveDateTime.shift(day: -3) + ), + build(:event, + name: "Signup", + "meta.key": ["plan"], + "meta.value": ["starter"], + timestamp: NaiveDateTime.utc_now() |> NaiveDateTime.shift(day: -5) + ) + ]) + + suggestions = GoalSuggestions.suggest_custom_property_values(site, "plan", "") + + assert hd(suggestions) == "business" + assert length(suggestions) == 3 + assert "business" in suggestions + assert "enterprise" in suggestions + assert "starter" in suggestions + end + + test "filters property values by search input", %{site: site} do + populate_stats(site, [ + build(:event, + name: "Purchase", + "meta.key": ["plan"], + "meta.value": ["business_monthly"], + timestamp: NaiveDateTime.utc_now() |> NaiveDateTime.shift(day: -1) + ), + build(:event, + name: "Purchase", + "meta.key": ["plan"], + "meta.value": ["business_yearly"], + timestamp: NaiveDateTime.utc_now() |> NaiveDateTime.shift(day: -2) + ), + build(:event, + name: "Purchase", + "meta.key": ["plan"], + "meta.value": ["starter"], + timestamp: NaiveDateTime.utc_now() |> NaiveDateTime.shift(day: -3) + ) + ]) + + suggestions = GoalSuggestions.suggest_custom_property_values(site, "plan", "business") + assert length(suggestions) == 2 + assert "business_monthly" in suggestions + assert "business_yearly" in suggestions + refute "starter" in suggestions + end + + test "excludes events older than 30 days", %{site: site} do + populate_stats(site, [ + build(:event, + name: "Purchase", + "meta.key": ["plan"], + "meta.value": ["recent_plan"], + timestamp: NaiveDateTime.utc_now() |> NaiveDateTime.shift(day: -29) + ), + build(:event, + name: "Purchase", + "meta.key": ["plan"], + "meta.value": ["old_plan"], + timestamp: NaiveDateTime.utc_now() |> NaiveDateTime.shift(day: -31) + ) + ]) + + suggestions = GoalSuggestions.suggest_custom_property_values(site, "plan", "") + assert "recent_plan" in suggestions + refute "old_plan" in suggestions + end + + test "handles nil search input", %{site: site} do + populate_stats(site, [ + build(:event, + name: "Purchase", + "meta.key": ["plan"], + "meta.value": ["business"], + timestamp: NaiveDateTime.utc_now() + ) + ]) + + suggestions = GoalSuggestions.suggest_custom_property_values(site, "plan", nil) + assert "business" in suggestions + end + + test "orders values by count", %{site: site} do + populate_stats(site, [ + build(:event, + name: "Purchase", + "meta.key": ["plan"], + "meta.value": ["starter"], + timestamp: NaiveDateTime.utc_now() + ), + build(:event, + name: "Purchase", + "meta.key": ["plan"], + "meta.value": ["business"], + timestamp: NaiveDateTime.utc_now() + ), + build(:event, + name: "Purchase", + "meta.key": ["plan"], + "meta.value": ["business"], + timestamp: NaiveDateTime.utc_now() + ), + build(:event, + name: "Purchase", + "meta.key": ["plan"], + "meta.value": ["business"], + timestamp: NaiveDateTime.utc_now() + ) + ]) + + assert ["business", "starter"] = + GoalSuggestions.suggest_custom_property_values(site, "plan", "") + end + end end From c201eacdd53ea3f30a680da15b51e6c4978066f6 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Mon, 5 Jan 2026 08:12:36 +0100 Subject: [PATCH 03/21] Implement PropertyPairInput live component --- .../live/goal_settings/property_pair_input.ex | 85 ++++++++ .../property_pair_input_test.exs | 192 ++++++++++++++++++ 2 files changed, 277 insertions(+) create mode 100644 lib/plausible_web/live/goal_settings/property_pair_input.ex create mode 100644 test/plausible_web/live/goal_settings/property_pair_input_test.exs diff --git a/lib/plausible_web/live/goal_settings/property_pair_input.ex b/lib/plausible_web/live/goal_settings/property_pair_input.ex new file mode 100644 index 000000000000..b9a7b4956447 --- /dev/null +++ b/lib/plausible_web/live/goal_settings/property_pair_input.ex @@ -0,0 +1,85 @@ +defmodule PlausibleWeb.Live.GoalSettings.PropertyPairInput do + @moduledoc """ + LiveComponent for a property name + value ComboBox pair. + + When a property name is selected, it notifies the value ComboBox + to update its suggestions based on the selected property. + """ + use PlausibleWeb, :live_component + + alias PlausibleWeb.Live.Components.ComboBox + + def update(assigns, socket) do + socket = + socket + |> assign(assigns) + |> assign_new(:selected_property, fn -> assigns[:initial_prop_key] end) + + {:ok, socket} + end + + attr(:site, Plausible.Site) + attr(:initial_prop_key, :string, default: "") + attr(:initial_prop_value, :string, default: "") + + def render(assigns) do + ~H""" +
+
+ <.live_component + id={"#{@id}_key"} + submit_name="goal[custom_props][keys][]" + placeholder="Select property" + module={ComboBox} + suggest_fun={fn input, _options -> suggest_property_names(@site, input) end} + selected={if @initial_prop_key != "", do: @initial_prop_key, else: nil} + on_selection_made={ + fn value, _by_id -> + send_update(__MODULE__, + id: @id, + selected_property: value + ) + end + } + creatable + /> +
+ + is + +
+ <.live_component + id={"#{@id}_value"} + submit_name="goal[custom_props][values][]" + placeholder={if @selected_property, do: "Select value", else: "Select property first"} + module={ComboBox} + suggest_fun={ + fn input, _options -> + suggest_property_values(@site, input, @selected_property) + end + } + selected={if @initial_prop_value != "", do: @initial_prop_value, else: nil} + options={suggest_property_values(@site, "", @selected_property)} + creatable + /> +
+
+ """ + end + + defp suggest_property_names(_site, nil), do: [] + + defp suggest_property_names(site, input) do + suggestions = Plausible.Stats.GoalSuggestions.suggest_custom_property_names(site, input) + Enum.zip(suggestions, suggestions) + end + + defp suggest_property_values(_site, _, nil), do: [] + + defp suggest_property_values(site, input, property_name) do + suggestions = + Plausible.Stats.GoalSuggestions.suggest_custom_property_values(site, property_name, input) + + Enum.zip(suggestions, suggestions) + end +end diff --git a/test/plausible_web/live/goal_settings/property_pair_input_test.exs b/test/plausible_web/live/goal_settings/property_pair_input_test.exs new file mode 100644 index 000000000000..603425fb4aab --- /dev/null +++ b/test/plausible_web/live/goal_settings/property_pair_input_test.exs @@ -0,0 +1,192 @@ +defmodule PlausibleWeb.Live.GoalSettings.PropertyPairInputTest do + use PlausibleWeb.ConnCase, async: true + import Phoenix.LiveViewTest + + alias PlausibleWeb.Live.GoalSettings.PropertyPairInput + + describe "static rendering" do + setup [:create_user, :create_site] + + test "renders both property name and value comboboxes", %{site: site} do + doc = render_pair_component(site, "test-pair") + + assert element_exists?( + doc, + ~s/input#test-pair_key[name="display-test-pair_key"][phx-change="search"]/ + ) + + assert element_exists?( + doc, + ~s/input#test-pair_value[name="display-test-pair_value"][phx-change="search"]/ + ) + end + + test "property value combobox shows 'Select property first' when no property selected", %{ + site: site + } do + doc = render_pair_component(site, "test-pair") + + assert element_exists?( + doc, + ~s/input#test-pair_value[placeholder="Select property first"]/ + ) + end + + test "property value combobox shows 'Select value' when property is selected", %{site: site} do + doc = render_pair_component(site, "test-pair", selected_property: "product") + + assert element_exists?( + doc, + ~s/input#test-pair_value[placeholder="Select value"]/ + ) + end + + test "both comboboxes have correct submit names", %{site: site} do + doc = render_pair_component(site, "test-pair") + + assert element_exists?( + doc, + ~s/input[type="hidden"][name="goal[custom_props][keys][]"]/ + ) + + assert element_exists?( + doc, + ~s/input[type="hidden"][name="goal[custom_props][values][]"]/ + ) + end + + test "renders wrapper div with correct ID", %{site: site} do + doc = render_pair_component(site, "my-custom-id") + + assert element_exists?(doc, ~s/div#my-custom-id/) + end + + test "both comboboxes are marked as creatable", %{site: site} do + doc = render_pair_component(site, "test-pair") + + assert text_of_element(doc, "#dropdown-test-pair_key") =~ "Create an item by typing." + assert text_of_element(doc, "#dropdown-test-pair_value") =~ "Create an item by typing." + end + + test "pre-populates property key when initial_prop_key is provided", %{site: site} do + doc = render_pair_component(site, "test-pair", initial_prop_key: "product") + + assert element_exists?( + doc, + ~s/input#test-pair_key[name="display-test-pair_key"][value="product"]/ + ) + end + + test "pre-populates property value when initial_prop_value is provided", %{site: site} do + doc = render_pair_component(site, "test-pair", initial_prop_value: "Shirt") + + assert element_exists?( + doc, + ~s/input#test-pair_value[name="display-test-pair_value"][value="Shirt"]/ + ) + end + + test "pre-populates both property key and value when both are provided", %{site: site} do + doc = + render_pair_component(site, "test-pair", + initial_prop_key: "product", + initial_prop_value: "Shirt" + ) + + assert element_exists?( + doc, + ~s/input#test-pair_key[name="display-test-pair_key"][value="product"]/ + ) + + assert element_exists?( + doc, + ~s/input#test-pair_value[name="display-test-pair_value"][value="Shirt"]/ + ) + end + + test "value combobox shows 'Select value' when property is pre-populated", %{site: site} do + doc = render_pair_component(site, "test-pair", initial_prop_key: "product") + + assert element_exists?( + doc, + ~s/input#test-pair_value[placeholder="Select value"]/ + ) + end + end + + describe "integration with live view" do + setup [:create_user, :create_site] + + defmodule TestLiveView do + use Phoenix.LiveView + + def render(assigns) do + ~H""" +
+ <.live_component + id="test-property-pair" + module={PlausibleWeb.Live.GoalSettings.PropertyPairInput} + site={@site} + /> +
+ """ + end + + def mount(_params, %{"site" => site}, socket) do + {:ok, assign(socket, site: site)} + end + end + + test "selecting property value based on name selection", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, + name: "Purchase", + "meta.key": ["product"], + "meta.value": ["Shirt"] + ) + ]) + + {:ok, lv, html} = live_isolated(conn, TestLiveView, session: %{"site" => site}) + + assert html =~ "Select property first" + + html = type_into_combo(lv, "test-property-pair_key", "zzz") + refute element_exists?(html, "li#dropdown-test-property-pair_key-option-1 a") + + type_into_combo(lv, "test-property-pair_key", "duct") + + lv + |> element("li#dropdown-test-property-pair_key-option-1 a") + |> render_click() + + html = render(lv) + + refute html =~ "Select property first" + assert html =~ "Select value" + + html = type_into_combo(lv, "test-property-pair_value", "zzz") + refute html =~ "Shirt" + + html = type_into_combo(lv, "test-property-pair_value", "irt") + assert html =~ "Shirt" + + lv + |> element("li#dropdown-test-property-pair_value-option-1 a") + |> render_click() + end + end + + defp render_pair_component(site, id, extra_opts \\ []) do + opts = Keyword.merge([id: id, site: site], extra_opts) + render_component(PropertyPairInput, opts) + end + + defp type_into_combo(lv, id, text) do + lv + |> element("input##{id}") + |> render_change(%{ + "_target" => ["display-#{id}"], + "display-#{id}" => "#{text}" + }) + end +end From 571b3ce0aa4b37e957b0c2f735aafa8b0ec5810d Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Mon, 5 Jan 2026 08:12:55 +0100 Subject: [PATCH 04/21] Implement PropertyPairs live component --- .../live/goal_settings/property_pairs.ex | 114 ++++++++++++ .../goal_settings/property_pairs_test.exs | 169 ++++++++++++++++++ 2 files changed, 283 insertions(+) create mode 100644 lib/plausible_web/live/goal_settings/property_pairs.ex create mode 100644 test/plausible_web/live/goal_settings/property_pairs_test.exs diff --git a/lib/plausible_web/live/goal_settings/property_pairs.ex b/lib/plausible_web/live/goal_settings/property_pairs.ex new file mode 100644 index 000000000000..2540dfe36235 --- /dev/null +++ b/lib/plausible_web/live/goal_settings/property_pairs.ex @@ -0,0 +1,114 @@ +defmodule PlausibleWeb.Live.GoalSettings.PropertyPairs do + @moduledoc """ + LiveComponent for managing multiple custom property name + value pairs + """ + use PlausibleWeb, :live_component + + alias PlausibleWeb.Live.GoalSettings.PropertyPairInput + + def update(assigns, socket) do + socket = + socket + |> assign(assigns) + |> assign_new(:max_slots, fn -> Plausible.Goal.max_custom_props_per_goal() end) + |> assign_new(:slots, fn + %{goal: %Plausible.Goal{custom_props: custom_props}} + when is_map(custom_props) and map_size(custom_props) > 0 -> + to_list_with_ids(custom_props) + + _ -> + to_list_with_ids(empty_row()) + end) + + {:ok, socket} + end + + attr(:goal, Plausible.Goal, default: nil) + attr(:site, Plausible.Site) + + def render(assigns) do + ~H""" +
+
+
+ <.live_component + id={"property_pair_#{id}"} + module={PropertyPairInput} + site={@site} + initial_prop_key={prop_key} + initial_prop_value={prop_value} + /> +
+ +
+ <.remove_property_button + :if={length(@slots) > 1} + pair_id={id} + myself={@myself} + /> +
+
+ + + + Add another property + +
+ """ + end + + attr(:pair_id, :string, required: true) + attr(:myself, :any, required: true) + + def remove_property_button(assigns) do + ~H""" +
+ + + + + + + +
+ """ + end + + def handle_event("add-slot", _, socket) do + slots = socket.assigns.slots ++ to_list_with_ids(empty_row()) + {:noreply, assign(socket, slots: slots)} + end + + def handle_event("remove-slot", %{"pair-id" => id}, socket) do + slots = List.keydelete(socket.assigns.slots, id, 0) + {:noreply, assign(socket, slots: slots)} + end + + defp to_list_with_ids(custom_props_map) do + Enum.into(custom_props_map, [], fn {k, v} -> {Ecto.UUID.generate(), {k, v}} end) + end + + defp empty_row() do + %{"" => ""} + end +end diff --git a/test/plausible_web/live/goal_settings/property_pairs_test.exs b/test/plausible_web/live/goal_settings/property_pairs_test.exs new file mode 100644 index 000000000000..16a2b8940f1f --- /dev/null +++ b/test/plausible_web/live/goal_settings/property_pairs_test.exs @@ -0,0 +1,169 @@ +defmodule PlausibleWeb.Live.GoalSettings.PropertyPairsTest do + use PlausibleWeb.ConnCase, async: true + import Phoenix.LiveViewTest + + alias PlausibleWeb.Live.GoalSettings.PropertyPairs + + describe "static rendering" do + setup [:create_user, :create_site] + + test "renders empty property pair by default", %{site: site} do + doc = render_pairs_component(site) + + assert element_exists?(doc, ~s/div[data-test-id="custom-property-pairs"]/) + assert element_exists?(doc, ~s/div[id^="property_pair_"]/) + assert element_exists?(doc, ~s/input[name="goal[custom_props][keys][]"]/) + assert element_exists?(doc, ~s/input[name="goal[custom_props][values][]"]/) + end + + test "renders 'Add another property' link when slots are below max", %{site: site} do + doc = render_pairs_component(site) + assert text_of_element(doc, ~s/a[phx-click="add-slot"]/) == "+ Add another property" + end + + test "does not render 'Add another property' link when slots are at max", %{site: site} do + {:ok, goal} = + Plausible.Goals.create(site, %{ + "event_name" => "TestEvent", + "custom_props" => %{"prop1" => "val1", "prop2" => "val2", "prop3" => "val3"} + }) + + doc = render_pairs_component(site, goal: goal) + + refute element_exists?(doc, ~s/a[phx-click="add-slot"]/) + refute doc =~ "+ Add another property" + end + + test "does not show remove button when only one slot exists", %{site: site} do + doc = render_pairs_component(site) + refute element_exists?(doc, ~s/div[data-test-id^="remove-property-"]/) + end + + test "shows remove button when multiple slots exist", %{site: site} do + {:ok, goal} = + Plausible.Goals.create(site, %{ + "event_name" => "TestEvent", + "custom_props" => %{"prop1" => "val1", "prop2" => "val2"} + }) + + doc = render_pairs_component(site, goal: goal) + + assert element_exists?(doc, ~s/div[data-test-id^="remove-property-"]/) + end + + test "renders property pairs for goal with existing custom props", %{site: site} do + {:ok, goal} = + Plausible.Goals.create(site, %{ + "event_name" => "Purchase", + "custom_props" => %{"product" => "Shirt", "color" => "Blue"} + }) + + doc = render_pairs_component(site, goal: goal) + + assert element_exists?(doc, ~s/input[value="product"]/) + assert element_exists?(doc, ~s/input[value="Shirt"]/) + assert element_exists?(doc, ~s/input[value="color"]/) + assert element_exists?(doc, ~s/input[value="Blue"]/) + end + + test "pre-populates property pairs when editing goal with custom props", %{site: site} do + {:ok, goal} = + Plausible.Goals.create(site, %{ + "event_name" => "Signup", + "custom_props" => %{"tier" => "Premium"} + }) + + doc = render_pairs_component(site, goal: goal) + + assert element_exists?(doc, ~s/input[value="tier"]/) + assert element_exists?(doc, ~s/input[value="Premium"]/) + end + end + + describe "integration with live view" do + setup [:create_user, :create_site] + + defmodule TestLiveView do + use Phoenix.LiveView + + def render(assigns) do + ~H""" +
+ <.live_component + id="test-property-pairs" + module={PlausibleWeb.Live.GoalSettings.PropertyPairs} + site={@site} + goal={@goal} + /> +
+ """ + end + + def mount(_params, session, socket) do + {:ok, assign(socket, site: session["site"], goal: session["goal"])} + end + end + + test "adds new slot when 'Add another property' is clicked", %{conn: conn, site: site} do + {:ok, lv, html} = + live_isolated(conn, TestLiveView, session: %{"site" => site, "goal" => nil}) + + assert elem_count(html, ~s/div[id^="property_pair_"]/) == 1 + + lv + |> element("a[phx-click='add-slot']") + |> render_click() + + html = render(lv) + + assert elem_count(html, ~s/div[id^="property_pair_"]/) == 2 + end + + test "removes slot when remove button is clicked", %{conn: conn, site: site} do + {:ok, goal} = + Plausible.Goals.create(site, %{ + "event_name" => "TestEvent", + "custom_props" => %{"prop1" => "val1", "prop2" => "val2"} + }) + + {:ok, lv, html} = + live_isolated(conn, TestLiveView, session: %{"site" => site, "goal" => goal}) + + assert elem_count(html, ~s/div[id^="property_pair_"]/) == 2 + + first_remove_button = + html + |> find(~s/div[data-test-id^="remove-property-"] [phx-click="remove-slot"]/) + |> Enum.at(0) + |> text_of_attr("id") + + lv + |> element("##{first_remove_button}") + |> render_click() + + html = render(lv) + + assert elem_count(html, ~s/div[id^="property_pair_"]/) == 1 + end + + test "does not add more slots when at max capacity", %{conn: conn, site: site} do + {:ok, goal} = + Plausible.Goals.create(site, %{ + "event_name" => "TestEvent", + "custom_props" => %{"prop1" => "val1", "prop2" => "val2", "prop3" => "val3"} + }) + + {:ok, _lv, html} = + live_isolated(conn, TestLiveView, session: %{"site" => site, "goal" => goal}) + + assert elem_count(html, ~s/div[id^="property_pair_"]/) == 3 + assert elem_count(html, ~s/[phx-click="remove-slot"]/) == 3 + refute element_exists?(html, ~s/a[phx-click="add-slot"]/) + end + end + + defp render_pairs_component(site, extra_opts \\ []) do + opts = Keyword.merge([id: "test-pairs", site: site], extra_opts) + render_component(PropertyPairs, opts) + end +end From 7869e5e5dfb16e1eebbb2cc409297d8e91dc4311 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Mon, 5 Jan 2026 08:14:25 +0100 Subject: [PATCH 05/21] Basic integration of custom properties in goal settings form --- lib/plausible_web/live/goal_settings/form.ex | 133 ++++++++++++++---- .../live/goal_settings/form_test.exs | 88 +++++++++--- 2 files changed, 173 insertions(+), 48 deletions(-) diff --git a/lib/plausible_web/live/goal_settings/form.ex b/lib/plausible_web/live/goal_settings/form.ex index fb3ed40dcb23..d4750d026644 100644 --- a/lib/plausible_web/live/goal_settings/form.ex +++ b/lib/plausible_web/live/goal_settings/form.ex @@ -96,6 +96,7 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do existing_goals={@existing_goals} goal_options={@event_name_options} has_access_to_revenue_goals?={@has_access_to_revenue_goals?} + myself={@myself} /> <.pageview_fields :if={@form_type == "pageviews"} @@ -103,6 +104,7 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do goal={@goal} suffix={@context_unique_id} site={@site} + myself={@myself} /> <.scroll_fields :if={@form_type == "scroll"} @@ -110,6 +112,7 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do goal={@goal} suffix={@context_unique_id} site={@site} + myself={@myself} /> <.button type="submit" class="w-full"> @@ -171,18 +174,21 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do existing_goals={@existing_goals} goal_options={@event_name_options} has_access_to_revenue_goals?={@has_access_to_revenue_goals?} + myself={@myself} /> <.pageview_fields :if={@form_type == "pageviews"} f={f} suffix={@context_unique_id} site={@site} + myself={@myself} /> <.scroll_fields :if={@form_type == "scroll"} f={f} suffix={@context_unique_id} site={@site} + myself={@myself} /> <.button type="submit" class="w-full"> @@ -196,13 +202,20 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do attr(:site, Plausible.Site) attr(:suffix, :string) attr(:goal, Plausible.Goal, default: nil) + attr(:myself, :any) attr(:rest, :global) def pageview_fields(assigns) do ~H""" -
-
- Pageview goals allow you to measure how many people visit a specific page or section of your site. Learn more in <.styled_link +
+
+ Pageview goals allow you to measure how many people visit a specific page or section of your site. + <.styled_link href="https://plausible.io/docs/pageview-goals" new_tab={true} > @@ -240,7 +253,13 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do x-on:focus="if (firstFocus) { $el.select(); firstFocus = false; }" /> - <.custom_property_section suffix={@suffix} /> + <.custom_property_section + f={@f} + suffix={@suffix} + goal={@goal} + myself={@myself} + site={@site} + />
""" end @@ -249,6 +268,7 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do attr(:site, Plausible.Site) attr(:suffix, :string) attr(:goal, Plausible.Goal, default: nil) + attr(:myself, :any) attr(:rest, :global) def scroll_fields(assigns) do @@ -256,10 +276,10 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do if is_nil(assigns.goal) do """ { + addCustomProperty: false, scrollThreshold: '90', pagePath: '', displayName: '', - addCustomProperty: false, updateDisplayName() { if (this.scrollThreshold && this.pagePath) { this.displayName = `Scroll ${this.scrollThreshold}% on ${this.pagePath}` @@ -270,10 +290,10 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do else """ { + addCustomProperty: false, scrollThreshold: '#{assigns.goal.scroll_threshold}', pagePath: '#{assigns.goal.page_path}', displayName: '#{assigns.goal.display_name}', - addCustomProperty: false, updateDisplayName() {} } """ @@ -340,7 +360,13 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do x-on:focus="if (firstFocus) { $el.select(); firstFocus = false; }" /> - <.custom_property_section suffix={@suffix} /> + <.custom_property_section + f={@f} + suffix={@suffix} + goal={@goal} + myself={@myself} + site={@site} + />
""" end @@ -354,12 +380,18 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do attr(:goal_options, :list) attr(:goal, Plausible.Goal, default: nil) attr(:has_access_to_revenue_goals?, :boolean) + attr(:myself, :any) attr(:rest, :global) def custom_event_fields(assigns) do ~H""" -
+
Custom Events are not tracked by default - you have to configure them on your site to be sent to Plausible. See examples and learn more in <.styled_link @@ -406,7 +438,13 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do />
- <.custom_property_section suffix={@suffix} /> + <.custom_property_section + f={@f} + suffix={@suffix} + goal={@goal} + myself={@myself} + site={@site} + /> <%= if ee?() and Plausible.Sites.regular?(@site) and not editing_non_revenue_goal?(assigns) do %> <.revenue_goal_settings @@ -427,10 +465,25 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do end attr(:suffix, :string, required: true) + attr(:myself, :any, required: true) + attr(:f, Phoenix.HTML.Form, required: true) + attr(:goal, Plausible.Goal, default: nil) + attr(:site, Plausible.Site, required: true) def custom_property_section(assigns) do + has_custom_props? = + case assigns[:goal] do + %Plausible.Goal{custom_props: custom_props} when map_size(custom_props) > 0 -> + true + + _ -> + false + end + + assigns = assign(assigns, :has_custom_props?, has_custom_props?) + ~H""" -
+
Add custom property @@ -441,23 +494,22 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do />
-
- <.live_component - id={"property_input_#{@suffix}"} - submit_name="goal[property]" - placeholder="Select property" - module={ComboBox} - suggest_fun={fn _input, _options -> [] end} - creatable - /> - is +
+ + Custom properties + +
+ + <.error :for={msg <- Enum.map(@f[:custom_props].errors, &translate_error/1)}> + {msg} + + +
<.live_component - id={"value_input_#{@suffix}"} - submit_name="goal[value]" - placeholder="Select value" - module={ComboBox} - suggest_fun={fn _input, _options -> [] end} - creatable + id={"property-pairs-#{@suffix}"} + module={PlausibleWeb.Live.GoalSettings.PropertyPairs} + site={@site} + goal={@goal} />
""" @@ -510,7 +562,9 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do end def handle_event("save-goal", %{"goal" => goal_params}, %{assigns: %{goal: nil}} = socket) do - case Plausible.Goals.create(socket.assigns.site, goal_params) do + {:ok, transformed_params} = transform_property_params(goal_params) + + case Plausible.Goals.create(socket.assigns.site, transformed_params) do {:ok, goal} -> socket = goal @@ -529,7 +583,9 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do %{"goal" => goal_params}, %{assigns: %{goal: %Plausible.Goal{} = goal}} = socket ) do - case Plausible.Goals.update(goal, goal_params) do + {:ok, transformed_params} = transform_property_params(goal_params) + + case Plausible.Goals.update(goal, transformed_params) do {:ok, goal} -> socket = socket.assigns.on_save_goal.(goal, socket) @@ -626,4 +682,25 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do else defp editing_non_revenue_goal?(_assigns), do: false end + + defp transform_property_params( + %{"custom_props" => %{"keys" => prop_keys, "values" => prop_values}} = goal_params + ) + when is_list(prop_keys) and is_list(prop_values) do + transformed = + goal_params + |> Map.put( + "custom_props", + prop_keys + |> Enum.zip(prop_values) + |> Enum.reject(fn {k, v} -> String.trim(k) == "" and String.trim(v) == "" end) + |> Enum.into(%{}) + ) + + {:ok, transformed} + end + + defp transform_property_params(goal_params) do + {:ok, goal_params} + end end diff --git a/test/plausible_web/live/goal_settings/form_test.exs b/test/plausible_web/live/goal_settings/form_test.exs index 42506abf2024..73fc0405852d 100644 --- a/test/plausible_web/live/goal_settings/form_test.exs +++ b/test/plausible_web/live/goal_settings/form_test.exs @@ -63,17 +63,13 @@ defmodule PlausibleWeb.Live.GoalSettings.FormTest do input_names = html |> find("#custom-events-form input") |> Enum.map(&name_of/1) - assert input_names == - [ - "display-event_name_input_modalseq0", - "goal[event_name]", - "goal[display_name]", - "display-currency_input_modalseq0", - "goal[currency]" - ] + assert "goal[event_name]" in input_names + assert "goal[display_name]" in input_names + assert "goal[custom_props][keys][]" in input_names + assert "goal[custom_props][values][]" in input_names + assert "display-currency_input_modalseq0" in input_names end - @tag :ee_only test "renders form fields for pageview", %{conn: conn, site: site} do lv = get_liveview(conn, site) |> open_modal_with_goal_type("pageviews") @@ -83,11 +79,11 @@ defmodule PlausibleWeb.Live.GoalSettings.FormTest do input_names = html |> find("#pageviews-form input") |> Enum.map(&name_of/1) - assert input_names == [ - "display-page_path_input_modalseq0", - "goal[page_path]", - "goal[display_name]" - ] + assert "display-page_path_input_modalseq0" in input_names + assert "goal[page_path]" in input_names + assert "goal[display_name]" in input_names + assert "goal[custom_props][keys][]" in input_names + assert "goal[custom_props][values][]" in input_names end @tag :ce_build_only @@ -100,12 +96,11 @@ defmodule PlausibleWeb.Live.GoalSettings.FormTest do input_names = html |> find("#custom-events-form input") |> Enum.map(&name_of/1) - assert input_names == - [ - "display-event_name_input_modalseq0", - "goal[event_name]", - "goal[display_name]" - ] + assert "goal[event_name]" in input_names + assert "goal[display_name]" in input_names + assert "goal[custom_props][keys][]" in input_names + assert "goal[custom_props][values][]" in input_names + refute "display-currency_input_modalseq0" in input_names end test "renders error on empty submission", %{conn: conn, site: site} do @@ -340,6 +335,59 @@ defmodule PlausibleWeb.Live.GoalSettings.FormTest do assert html =~ "has already been taken" end + + test "hides 'Add custom property' toggle when editing goal with custom props", %{ + conn: conn, + site: site + } do + {:ok, goal} = + Plausible.Goals.create(site, %{ + "event_name" => "Purchase", + "custom_props" => %{"product" => "Shirt", "color" => "Blue"} + }) + + lv = get_liveview(conn, site) + lv |> element(~s/button#edit-goal-#{goal.id}/) |> render_click() + + html = render(lv) + + refute html =~ "Add custom property" + assert html =~ "Custom properties" + end + + test "shows 'Add custom property' toggle when editing goal without custom props", %{ + conn: conn, + site: site + } do + {:ok, goal} = Plausible.Goals.create(site, %{"event_name" => "Signup"}) + + lv = get_liveview(conn, site) + lv |> element(~s/button#edit-goal-#{goal.id}/) |> render_click() + + html = render(lv) + + assert html =~ "Add custom property" + refute html =~ "Custom properties" + end + + test "property pairs section is visible when editing goal with custom props", %{ + conn: conn, + site: site + } do + {:ok, goal} = + Plausible.Goals.create(site, %{ + "event_name" => "Purchase", + "custom_props" => %{"product" => "Shirt"} + }) + + lv = get_liveview(conn, site) + lv |> element(~s/button#edit-goal-#{goal.id}/) |> render_click() + + html = render(lv) + + assert html =~ "Custom properties" + assert element_exists?(html, ~s/[data-test-id="custom-property-pairs"]/) + end end describe "Combos integration" do From 92963dc7a40bf706a3aef95b1464f48d3036e5b1 Mon Sep 17 00:00:00 2001 From: Sanne de Vries Date: Tue, 6 Jan 2026 12:01:40 +0100 Subject: [PATCH 06/21] Add icon to list indicating custom properties on goals --- lib/plausible_web/components/generic.ex | 6 +++--- lib/plausible_web/live/goal_settings/list.ex | 17 +++++++++++++++-- lib/plausible_web/live/shared_link_settings.ex | 6 +++--- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/lib/plausible_web/components/generic.ex b/lib/plausible_web/components/generic.ex index c4407ba58561..d7e7032b4ef4 100644 --- a/lib/plausible_web/components/generic.ex +++ b/lib/plausible_web/components/generic.ex @@ -582,12 +582,12 @@ defmodule PlausibleWeb.Components.Generic do current_team={@current_team} site={@site} > -
+
{render_slot(@inner_block)}
<% else %> -
+
{render_slot(@inner_block)}
<% end %> @@ -854,7 +854,7 @@ defmodule PlausibleWeb.Components.Generic do class={ [ @height, - "text-sm px-6 py-4 first:pl-0 last:pr-0 whitespace-nowrap", + "text-sm px-3 md:px-6 py-3 md:py-4 first:pl-0 last:pr-0 whitespace-nowrap", # allow tooltips overflow cells vertically "overflow-visible", @truncate && "truncate", diff --git a/lib/plausible_web/live/goal_settings/list.ex b/lib/plausible_web/live/goal_settings/list.ex index 1a6b62c6a8ec..9ba27f223a8e 100644 --- a/lib/plausible_web/live/goal_settings/list.ex +++ b/lib/plausible_web/live/goal_settings/list.ex @@ -65,7 +65,7 @@ defmodule PlausibleWeb.Live.GoalSettings.List do <.th hide_on_mobile>Type <:tbody :let={goal}> - <.td max_width="max-w-64" height="h-16"> + <.td max_width="max-w-52 sm:max-w-64" height="h-16"> <%= if not @revenue_goals_enabled? && goal.currency do %>
{goal}
<.tooltip> @@ -86,7 +86,20 @@ defmodule PlausibleWeb.Live.GoalSettings.List do <:tooltip_content> Belongs to funnel - + + + <.tooltip :if={goal.custom_props && map_size(goal.custom_props) > 0} centered?={true}> + <:tooltip_content> +
+
+ {key} is {value} +
+
+ + + + +
diff --git a/lib/plausible_web/live/shared_link_settings.ex b/lib/plausible_web/live/shared_link_settings.ex index 97839539d0c9..4499ff908575 100644 --- a/lib/plausible_web/live/shared_link_settings.ex +++ b/lib/plausible_web/live/shared_link_settings.ex @@ -129,7 +129,7 @@ defmodule PlausibleWeb.Live.SharedLinkSettings do <:tooltip_content> Password protected - + <.tooltip :if={!Plausible.Site.SharedLink.password_protected?(link)} @@ -139,13 +139,13 @@ defmodule PlausibleWeb.Live.SharedLinkSettings do <:tooltip_content> No password protection - + <.tooltip :if={link.segment_id} enabled?={true} centered?={true}> <:tooltip_content> Limited to segment of data - +
From e6d043a899cdf50920c611faa4c762899599dea6 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 6 Jan 2026 12:21:08 +0100 Subject: [PATCH 07/21] Ensure dashboard filtering by goal w/ props = filtering by goal+props --- lib/plausible/goal.ex | 8 + lib/plausible/stats/goals.ex | 2 +- lib/plausible/stats/sql/query_builder.ex | 82 +++- lib/plausible_web/live/goal_settings/form.ex | 10 +- .../live/goal_settings/property_pairs.ex | 12 +- .../query_goal_custom_props_test.exs | 36 +- ...oal_custom_props_visit_dimensions_test.exs | 411 ++++++++++++++++++ 7 files changed, 514 insertions(+), 47 deletions(-) create mode 100644 test/plausible_web/controllers/api/external_stats_controller/query_goal_custom_props_visit_dimensions_test.exs diff --git a/lib/plausible/goal.ex b/lib/plausible/goal.ex index 44ba3ae7cfd0..a8e8e3633c72 100644 --- a/lib/plausible/goal.ex +++ b/lib/plausible/goal.ex @@ -87,6 +87,14 @@ defmodule Plausible.Goal do end end + @spec has_custom_props?(t()) :: boolean() + def has_custom_props?(%__MODULE__{custom_props: custom_props}) + when map_size(custom_props) > 0 do + true + end + + def has_custom_props?(_), do: false + defp update_leading_slash(changeset) do case get_field(changeset, :page_path) do "/" <> _ -> diff --git a/lib/plausible/stats/goals.ex b/lib/plausible/stats/goals.ex index d7f803f64efa..e4ed4f9bd7c3 100644 --- a/lib/plausible/stats/goals.ex +++ b/lib/plausible/stats/goals.ex @@ -200,7 +200,7 @@ defmodule Plausible.Stats.Goals do defp goal_condition(:event, goal, _) do name_condition = dynamic([e], e.name == ^goal.event_name) - if map_size(goal.custom_props) > 0 do + if Plausible.Goal.has_custom_props?(goal) do custom_props_condition = build_custom_props_condition(goal.custom_props) dynamic([e], ^name_condition and ^custom_props_condition) else diff --git a/lib/plausible/stats/sql/query_builder.ex b/lib/plausible/stats/sql/query_builder.ex index a31d3bffbcd3..74ae595efbe8 100644 --- a/lib/plausible/stats/sql/query_builder.ex +++ b/lib/plausible/stats/sql/query_builder.ex @@ -105,20 +105,19 @@ defmodule Plausible.Stats.SQL.QueryBuilder do end end + @doc """ + Joins events to sessions query when needed for filtering by event dimensions. + + When a sessions query needs to filter by event-level dimensions (like event:goal), + this function creates a subquery on the events table and joins it to sessions by session_id. + + For goals with custom props, we use the event_goal_join ARRAY JOIN approach to ensure + custom properties are properly validated. Without it, sessions would match on + event name alone, ignoring custom prop requirements. + """ def join_events_if_needed(q, query) do if TableDecider.sessions_join_events?(query) do - events_q = - from(e in "events_v2", - where: ^SQL.WhereBuilder.build(:events, query), - select: %{ - session_id: fragment("DISTINCT ?", e.session_id), - _sample_factor: fragment("_sample_factor") - } - ) - - on_ee do - events_q = Plausible.Stats.Sampling.add_query_hint(events_q, query) - end + events_q = build_events_subquery_for_sessions(query) from(s in q, join: e in subquery(events_q), @@ -129,6 +128,65 @@ defmodule Plausible.Stats.SQL.QueryBuilder do end end + defp build_events_subquery_for_sessions(query) do + if has_goal_with_custom_props_filters?(query) do + build_events_subquery_with_goal_join(query) + else + build_events_subquery_simple(query) + end + end + + defp build_events_subquery_simple(query) do + events_q = + from(e in "events_v2", + where: ^SQL.WhereBuilder.build(:events, query), + select: %{ + session_id: fragment("DISTINCT ?", e.session_id), + _sample_factor: fragment("_sample_factor") + } + ) + + on_ee do + events_q = Plausible.Stats.Sampling.add_query_hint(events_q, query) + end + + events_q + end + + defp build_events_subquery_with_goal_join(query) do + goal_join_data = Plausible.Stats.Goals.goal_join_data(query) + + events_q = + from(e in "events_v2", + where: ^SQL.WhereBuilder.build(:events, query), + join: goal in Expression.event_goal_join(goal_join_data), + hints: "ARRAY", + on: true, + select: %{ + session_id: fragment("DISTINCT ?", e.session_id), + _sample_factor: fragment("_sample_factor") + } + ) + + on_ee do + events_q = Plausible.Stats.Sampling.add_query_hint(events_q, query) + end + + events_q + end + + defp has_goal_with_custom_props_filters?(query) do + Enum.any?(query.filters, fn + [_, "event:goal" | _] -> + Enum.any?(query.preloaded_goals.matching_toplevel_filters, fn goal -> + Plausible.Goal.has_custom_props?(goal) + end) + + _ -> + false + end) + end + defp select_event_metrics(query) do query.metrics |> Enum.map(&SQL.Expression.event_metric(&1, query)) diff --git a/lib/plausible_web/live/goal_settings/form.ex b/lib/plausible_web/live/goal_settings/form.ex index d4750d026644..202a65131abc 100644 --- a/lib/plausible_web/live/goal_settings/form.ex +++ b/lib/plausible_web/live/goal_settings/form.ex @@ -471,15 +471,7 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do attr(:site, Plausible.Site, required: true) def custom_property_section(assigns) do - has_custom_props? = - case assigns[:goal] do - %Plausible.Goal{custom_props: custom_props} when map_size(custom_props) > 0 -> - true - - _ -> - false - end - + has_custom_props? = Plausible.Goal.has_custom_props?(assigns[:goal]) assigns = assign(assigns, :has_custom_props?, has_custom_props?) ~H""" diff --git a/lib/plausible_web/live/goal_settings/property_pairs.ex b/lib/plausible_web/live/goal_settings/property_pairs.ex index 2540dfe36235..f0c9eafe7da0 100644 --- a/lib/plausible_web/live/goal_settings/property_pairs.ex +++ b/lib/plausible_web/live/goal_settings/property_pairs.ex @@ -12,12 +12,12 @@ defmodule PlausibleWeb.Live.GoalSettings.PropertyPairs do |> assign(assigns) |> assign_new(:max_slots, fn -> Plausible.Goal.max_custom_props_per_goal() end) |> assign_new(:slots, fn - %{goal: %Plausible.Goal{custom_props: custom_props}} - when is_map(custom_props) and map_size(custom_props) > 0 -> - to_list_with_ids(custom_props) - - _ -> - to_list_with_ids(empty_row()) + %{goal: goal} -> + if Plausible.Goal.has_custom_props?(goal) do + to_list_with_ids(goal.custom_props) + else + to_list_with_ids(empty_row()) + end end) {:ok, socket} diff --git a/test/plausible_web/controllers/api/external_stats_controller/query_goal_custom_props_test.exs b/test/plausible_web/controllers/api/external_stats_controller/query_goal_custom_props_test.exs index 2548053902e2..360e00a64249 100644 --- a/test/plausible_web/controllers/api/external_stats_controller/query_goal_custom_props_test.exs +++ b/test/plausible_web/controllers/api/external_stats_controller/query_goal_custom_props_test.exs @@ -1,8 +1,6 @@ defmodule PlausibleWeb.Api.ExternalStatsController.QueryGoalCustomPropsTest do use PlausibleWeb.ConnCase - @user_id Enum.random(1000..9999) - setup [:create_user, :create_site, :create_api_key, :use_api_key] alias Plausible.Goals @@ -72,19 +70,19 @@ defmodule PlausibleWeb.Api.ExternalStatsController.QueryGoalCustomPropsTest do name: "Purchase", "meta.key": ["variant", "plan"], "meta.value": ["A", "free"], - user_id: @user_id + user_id: 1 ), build(:event, name: "Purchase", "meta.key": ["variant", "plan"], "meta.value": ["B", "premium"], - user_id: @user_id + 1 + user_id: 2 ), build(:event, name: "Purchase", "meta.key": ["variant"], "meta.value": ["A"], - user_id: @user_id + 2 + user_id: 3 ) ]) @@ -149,19 +147,19 @@ defmodule PlausibleWeb.Api.ExternalStatsController.QueryGoalCustomPropsTest do name: "Purchase A", "meta.key": ["variant"], "meta.value": ["A"], - user_id: @user_id + user_id: 1 ), build(:event, name: "Purchase A", "meta.key": ["variant"], "meta.value": ["A"], - user_id: @user_id + user_id: 1 ), build(:event, name: "Purchase B", "meta.key": ["variant"], "meta.value": ["B"], - user_id: @user_id + user_id: 1 ) ]) @@ -187,21 +185,21 @@ defmodule PlausibleWeb.Api.ExternalStatsController.QueryGoalCustomPropsTest do }) populate_stats(site, [ - build(:pageview, user_id: @user_id), + build(:pageview, user_id: 1), build(:event, name: "Signup", "meta.key": ["method"], "meta.value": ["email"], - user_id: @user_id + user_id: 1 ), - build(:pageview, user_id: @user_id + 1), + build(:pageview, user_id: 2), build(:event, name: "Signup", "meta.key": ["method"], "meta.value": ["google"], - user_id: @user_id + 1 + user_id: 2 ), - build(:pageview, user_id: @user_id + 2) + build(:pageview, user_id: 3) ]) conn = @@ -237,13 +235,13 @@ defmodule PlausibleWeb.Api.ExternalStatsController.QueryGoalCustomPropsTest do name: "Purchase", "meta.key": ["variant", "plan"], "meta.value": ["A", "free"], - user_id: @user_id + 1 + user_id: 2 ), build(:event, name: "Purchase", "meta.key": ["variant", "plan"], "meta.value": ["B", "premium"], - user_id: @user_id + 2 + user_id: 3 ) ]) @@ -329,21 +327,21 @@ defmodule PlausibleWeb.Api.ExternalStatsController.QueryGoalCustomPropsTest do name: "Button Click", "meta.key": ["color"], "meta.value": ["red"], - user_id: @user_id + user_id: 1 ), build(:event, name: "Button Click", "meta.key": ["color"], "meta.value": ["red"], - user_id: @user_id + user_id: 1 ), build(:event, name: "Button Click", "meta.key": ["color"], "meta.value": ["blue"], - user_id: @user_id + 1 + user_id: 2 ), - build(:event, name: "Button Click", user_id: @user_id + 2) + build(:event, name: "Button Click", user_id: 3) ]) conn = diff --git a/test/plausible_web/controllers/api/external_stats_controller/query_goal_custom_props_visit_dimensions_test.exs b/test/plausible_web/controllers/api/external_stats_controller/query_goal_custom_props_visit_dimensions_test.exs new file mode 100644 index 000000000000..070d1bc124f9 --- /dev/null +++ b/test/plausible_web/controllers/api/external_stats_controller/query_goal_custom_props_visit_dimensions_test.exs @@ -0,0 +1,411 @@ +defmodule PlausibleWeb.Api.ExternalStatsController.QueryGoalCustomPropsVisitDimensionsTest do + use PlausibleWeb.ConnCase + + setup [:create_user, :create_site, :create_api_key, :use_api_key] + + alias Plausible.Goals + + describe "visit dimensions filtered by goals with custom props" do + test "filters visit:country by goal with custom props correctly", %{conn: conn, site: site} do + {:ok, _goal} = + Goals.create(site, %{ + "event_name" => "Signup", + "custom_props" => %{"plan" => "premium"} + }) + + populate_stats(site, [ + # Session 1 (US): Has correct event (Signup with plan=premium) + build(:pageview, + user_id: 1, + country_code: "US", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + user_id: 1, + name: "Signup", + "meta.key": ["plan"], + "meta.value": ["premium"], + timestamp: ~N[2021-01-01 00:00:01] + ), + # Session 2 (GB): Has wrong event (Signup with plan=basic) + build(:pageview, + user_id: 2, + country_code: "GB", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + user_id: 2, + name: "Signup", + "meta.key": ["plan"], + "meta.value": ["basic"], + timestamp: ~N[2021-01-01 00:00:02] + ), + # Session 3 (CA): Has event with event name but no custom props + build(:pageview, + user_id: 3, + country_code: "CA", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + user_id: 4, + name: "Signup", + timestamp: ~N[2021-01-01 00:00:03] + ), + # Session 4 (DE): Has no Signup event at all + build(:pageview, + user_id: 5, + country_code: "DE", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "events"], + "dimensions" => ["visit:country"], + "filters" => [["is", "event:goal", ["Signup"]]] + }) + + resp = json_response(conn, 200) + + # Only session 1 (US) should be included, as it's the only one with + # the correct event name AND matching custom props + assert resp["results"] == [ + %{"dimensions" => ["US"], "metrics" => [1, 1]} + ] + end + + test "filters visit:browser by goal with multiple custom props", %{conn: conn, site: site} do + {:ok, _goal} = + Goals.create(site, %{ + "event_name" => "Purchase", + "custom_props" => %{"variant" => "A", "tier" => "gold"} + }) + + populate_stats(site, [ + # Chrome - matches both props + build(:pageview, + user_id: 1, + browser: "Chrome", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + user_id: 1, + name: "Purchase", + "meta.key": ["variant", "tier"], + "meta.value": ["A", "gold"], + timestamp: ~N[2021-01-01 00:00:01] + ), + # Firefox - matches only variant, not tier + build(:pageview, + user_id: 2, + browser: "Firefox", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + user_id: 3, + name: "Purchase", + "meta.key": ["variant", "tier"], + "meta.value": ["A", "silver"], + timestamp: ~N[2021-01-01 00:00:02] + ), + # Safari - matches only tier, not variant + build(:pageview, + user_id: 4, + browser: "Safari", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + user_id: 5, + name: "Purchase", + "meta.key": ["variant", "tier"], + "meta.value": ["B", "gold"], + timestamp: ~N[2021-01-01 00:00:03] + ), + # Edge - has event name but missing custom props entirely + build(:pageview, + user_id: 6, + browser: "Edge", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + user_id: 7, + name: "Purchase", + timestamp: ~N[2021-01-01 00:00:04] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "events"], + "dimensions" => ["visit:browser"], + "filters" => [["is", "event:goal", ["Purchase"]]] + }) + + resp = json_response(conn, 200) + + # Only session 1 (Chrome) should be included + assert resp["results"] == [ + %{"dimensions" => ["Chrome"], "metrics" => [1, 1]} + ] + end + + test "filters visit:city by goal with custom props", %{conn: conn, site: site} do + {:ok, _goal} = + Goals.create(site, %{ + "event_name" => "Download", + "custom_props" => %{"file_type" => "pdf"} + }) + + populate_stats(site, [ + # San Francisco - correct props + build(:pageview, + user_id: 1, + city_geoname_id: 5_391_959, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + user_id: 1, + name: "Download", + "meta.key": ["file_type"], + "meta.value": ["pdf"], + timestamp: ~N[2021-01-01 00:00:01] + ), + # New York - wrong prop value + build(:pageview, + user_id: 2, + city_geoname_id: 5_128_581, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + user_id: 3, + name: "Download", + "meta.key": ["file_type"], + "meta.value": ["docx"], + timestamp: ~N[2021-01-01 00:00:02] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "events"], + "dimensions" => ["visit:city"], + "filters" => [["is", "event:goal", ["Download"]]] + }) + + resp = json_response(conn, 200) + + # Only San Francisco session should be included + assert length(resp["results"]) == 1 + assert [%{"dimensions" => [5_391_959], "metrics" => [1, 1]}] = resp["results"] + end + + test "filters visit:os by goal with custom prop containing special characters", %{ + conn: conn, + site: site + } do + {:ok, _goal} = + Goals.create(site, %{ + "event_name" => "Custom Event", + "custom_props" => %{"tag" => "test-value"} + }) + + populate_stats(site, [ + # Mac - correct props + build(:pageview, + user_id: 1, + operating_system: "Mac", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + user_id: 1, + name: "Custom Event", + "meta.key": ["tag"], + "meta.value": ["test-value"], + timestamp: ~N[2021-01-01 00:00:01] + ), + # Windows - wrong value + build(:pageview, + user_id: 2, + operating_system: "Windows", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + user_id: 2, + name: "Custom Event", + "meta.key": ["tag"], + "meta.value": ["other-value"], + timestamp: ~N[2021-01-01 00:00:02] + ) + ]) + + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "events"], + "dimensions" => ["visit:os"], + "filters" => [["is", "event:goal", ["Custom Event"]]] + }) + + resp = json_response(conn, 200) + + # Only Mac session should be included + assert resp["results"] == [ + %{"dimensions" => ["Mac"], "metrics" => [1, 1]} + ] + end + + test "works with additional visit dimension filters combined", %{conn: conn, site: site} do + {:ok, _goal} = + Goals.create(site, %{ + "event_name" => "Newsletter", + "custom_props" => %{"source" => "sidebar"} + }) + + populate_stats(site, [ + # US/Chrome - correct props + build(:pageview, + user_id: 1, + country_code: "US", + browser: "Chrome", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + user_id: 1, + name: "Newsletter", + "meta.key": ["source"], + "meta.value": ["sidebar"], + timestamp: ~N[2021-01-01 00:00:01] + ), + # US/Firefox - wrong props + build(:pageview, + user_id: 2, + country_code: "US", + browser: "Firefox", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + user_id: 2, + name: "Newsletter", + "meta.key": ["source"], + "meta.value": ["footer"], + timestamp: ~N[2021-01-01 00:00:02] + ), + # GB/Chrome - correct props + build(:pageview, + user_id: 3, + country_code: "GB", + browser: "Chrome", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + user_id: 3, + name: "Newsletter", + "meta.key": ["source"], + "meta.value": ["sidebar"], + timestamp: ~N[2021-01-01 00:00:03] + ) + ]) + + # Filter by goal with custom props AND by country=US + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "events"], + "dimensions" => ["visit:browser"], + "filters" => [ + ["is", "event:goal", ["Newsletter"]], + ["is", "visit:country", ["US"]] + ] + }) + + resp = json_response(conn, 200) + + # Only session 1 (US/Chrome + correct props) should match + assert resp["results"] == [ + %{"dimensions" => ["Chrome"], "metrics" => [1, 1]} + ] + end + + test "handles multiple goal filters with different custom props", %{conn: conn, site: site} do + {:ok, _goal1} = + Goals.create(site, %{ + "event_name" => "Action A", + "custom_props" => %{"type" => "alpha"} + }) + + {:ok, _goal2} = + Goals.create(site, %{ + "event_name" => "Action B", + "custom_props" => %{"type" => "beta"} + }) + + populate_stats(site, [ + build(:pageview, + user_id: 1, + country_code: "US", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + user_id: 1, + name: "Action A", + "meta.key": ["type"], + "meta.value": ["alpha"], + timestamp: ~N[2021-01-01 00:00:01] + ), + build(:pageview, + user_id: 2, + country_code: "GB", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + user_id: 2, + name: "Action B", + "meta.key": ["type"], + "meta.value": ["beta"], + timestamp: ~N[2021-01-01 00:00:02] + ), + build(:pageview, + user_id: 3, + country_code: "CA", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + user_id: 3, + name: "Action A", + "meta.key": ["type"], + "meta.value": ["gamma"], + timestamp: ~N[2021-01-01 00:00:03] + ) + ]) + + # Query for both goals (OR logic) + conn = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "events"], + "dimensions" => ["visit:country"], + "filters" => [["is", "event:goal", ["Action A", "Action B"]]] + }) + + resp = json_response(conn, 200) + + # Should match sessions 1 and 2 (both with correct props), but not 3 + assert length(resp["results"]) == 2 + + assert Enum.sort(resp["results"]) == [ + %{"dimensions" => ["GB"], "metrics" => [1, 1]}, + %{"dimensions" => ["US"], "metrics" => [1, 1]} + ] + end + end +end From 79098c7ce276eda3009d4e6b377d8b84fc8e0461 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 6 Jan 2026 12:38:04 +0100 Subject: [PATCH 08/21] Fixup 0f709634b5 --- lib/plausible_web/live/goal_settings/list.ex | 21 +++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/lib/plausible_web/live/goal_settings/list.ex b/lib/plausible_web/live/goal_settings/list.ex index 9ba27f223a8e..f727db0128bb 100644 --- a/lib/plausible_web/live/goal_settings/list.ex +++ b/lib/plausible_web/live/goal_settings/list.ex @@ -88,17 +88,28 @@ defmodule PlausibleWeb.Live.GoalSettings.List do - <.tooltip :if={goal.custom_props && map_size(goal.custom_props) > 0} centered?={true}> + <.tooltip :if={Plausible.Goal.has_custom_props?(goal)} centered?={true}> <:tooltip_content>
-
+
{key} is {value}
- - - + + +
From 8313207de19f74cfda58a24f10651d64d9107bf2 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 6 Jan 2026 12:44:17 +0100 Subject: [PATCH 09/21] Seed some goals w/ props + funnels --- priv/repo/seeds.exs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 44df81d2654d..2549bbf09d2a 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -127,6 +127,20 @@ seeded_token = Plausible.Plugins.API.Token.generate("seed-token") {:ok, _goal5} = Plausible.Goals.create(site, %{"page_path" => Enum.random(long_random_paths)}) {:ok, outbound} = Plausible.Goals.create(site, %{"event_name" => "Outbound Link: Click"}) +{:ok, goal6} = + Plausible.Goals.create(site, %{ + "page_path" => "/", + "display_name" => "Anonymous visit to /", + "custom_props" => %{"logged_in" => "false"} + }) + +{:ok, goal7} = + Plausible.Goals.create(site, %{ + "page_path" => "/", + "display_name" => "Authenticated visit to /", + "custom_props" => %{"logged_in" => "true"} + }) + if ee?() do {:ok, _funnel} = Plausible.Funnels.create(site, "From homepage to login", [ @@ -134,6 +148,20 @@ if ee?() do %{"goal_id" => goal2.id}, %{"goal_id" => goal3.id} ]) + + {:ok, _funnel} = + Plausible.Funnels.create(site, "From anonymous homepage to login", [ + %{"goal_id" => goal6.id}, + %{"goal_id" => goal2.id}, + %{"goal_id" => goal3.id} + ]) + + {:ok, _funnel} = + Plausible.Funnels.create(site, "From logged in homepage to Purchase", [ + %{"goal_id" => goal6.id}, + %{"goal_id" => goal2.id}, + %{"goal_id" => revenue_goal.id} + ]) end geolocations = [ From acf682c406df805e5ecce1da22393daf284a243b Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Tue, 6 Jan 2026 13:10:55 +0100 Subject: [PATCH 10/21] Fixup test --- .../live/goal_settings/property_pairs.ex | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/lib/plausible_web/live/goal_settings/property_pairs.ex b/lib/plausible_web/live/goal_settings/property_pairs.ex index f0c9eafe7da0..d6b554029604 100644 --- a/lib/plausible_web/live/goal_settings/property_pairs.ex +++ b/lib/plausible_web/live/goal_settings/property_pairs.ex @@ -11,13 +11,12 @@ defmodule PlausibleWeb.Live.GoalSettings.PropertyPairs do socket |> assign(assigns) |> assign_new(:max_slots, fn -> Plausible.Goal.max_custom_props_per_goal() end) - |> assign_new(:slots, fn - %{goal: goal} -> - if Plausible.Goal.has_custom_props?(goal) do - to_list_with_ids(goal.custom_props) - else - to_list_with_ids(empty_row()) - end + |> assign_new(:slots, fn assigns -> + if Plausible.Goal.has_custom_props?(assigns[:goal]) do + to_list_with_ids(assigns.goal.custom_props) + else + to_list_with_ids(empty_row()) + end end) {:ok, socket} From 498a74d8a1206f46b9b33ec88b67471cf46368d8 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Wed, 7 Jan 2026 08:45:54 +0100 Subject: [PATCH 11/21] Another take at equivalence --- lib/plausible/stats/goals.ex | 17 +- lib/plausible/stats/sql/query_builder.ex | 82 +---- ...ery_goal_custom_props_equivalence_test.exs | 329 ++++++++++++++++++ 3 files changed, 356 insertions(+), 72 deletions(-) create mode 100644 test/plausible_web/controllers/api/external_stats_controller/query_goal_custom_props_equivalence_test.exs diff --git a/lib/plausible/stats/goals.ex b/lib/plausible/stats/goals.ex index e4ed4f9bd7c3..0c86e92f448a 100644 --- a/lib/plausible/stats/goals.ex +++ b/lib/plausible/stats/goals.ex @@ -215,7 +215,14 @@ defmodule Plausible.Stats.Goals do scroll_condition = dynamic([e], e.scroll_depth <= 100 and e.scroll_depth >= ^goal.scroll_threshold) - dynamic([e], ^pathname_condition and ^name_condition and ^scroll_condition) + base_condition = dynamic([e], ^pathname_condition and ^name_condition and ^scroll_condition) + + if Plausible.Goal.has_custom_props?(goal) do + custom_props_condition = build_custom_props_condition(goal.custom_props) + dynamic([e], ^base_condition and ^custom_props_condition) + else + base_condition + end end defp goal_condition(:page, goal, true = _imported?) do @@ -225,8 +232,14 @@ defmodule Plausible.Stats.Goals do defp goal_condition(:page, goal, false = _imported?) do name_condition = dynamic([e], e.name == "pageview") pathname_condition = page_path_condition(goal.page_path, _imported? = false) + base_condition = dynamic([e], ^pathname_condition and ^name_condition) - dynamic([e], ^pathname_condition and ^name_condition) + if Plausible.Goal.has_custom_props?(goal) do + custom_props_condition = build_custom_props_condition(goal.custom_props) + dynamic([e], ^base_condition and ^custom_props_condition) + else + base_condition + end end defp page_path_condition(page_path, imported?) do diff --git a/lib/plausible/stats/sql/query_builder.ex b/lib/plausible/stats/sql/query_builder.ex index 74ae595efbe8..a31d3bffbcd3 100644 --- a/lib/plausible/stats/sql/query_builder.ex +++ b/lib/plausible/stats/sql/query_builder.ex @@ -105,19 +105,20 @@ defmodule Plausible.Stats.SQL.QueryBuilder do end end - @doc """ - Joins events to sessions query when needed for filtering by event dimensions. - - When a sessions query needs to filter by event-level dimensions (like event:goal), - this function creates a subquery on the events table and joins it to sessions by session_id. - - For goals with custom props, we use the event_goal_join ARRAY JOIN approach to ensure - custom properties are properly validated. Without it, sessions would match on - event name alone, ignoring custom prop requirements. - """ def join_events_if_needed(q, query) do if TableDecider.sessions_join_events?(query) do - events_q = build_events_subquery_for_sessions(query) + events_q = + from(e in "events_v2", + where: ^SQL.WhereBuilder.build(:events, query), + select: %{ + session_id: fragment("DISTINCT ?", e.session_id), + _sample_factor: fragment("_sample_factor") + } + ) + + on_ee do + events_q = Plausible.Stats.Sampling.add_query_hint(events_q, query) + end from(s in q, join: e in subquery(events_q), @@ -128,65 +129,6 @@ defmodule Plausible.Stats.SQL.QueryBuilder do end end - defp build_events_subquery_for_sessions(query) do - if has_goal_with_custom_props_filters?(query) do - build_events_subquery_with_goal_join(query) - else - build_events_subquery_simple(query) - end - end - - defp build_events_subquery_simple(query) do - events_q = - from(e in "events_v2", - where: ^SQL.WhereBuilder.build(:events, query), - select: %{ - session_id: fragment("DISTINCT ?", e.session_id), - _sample_factor: fragment("_sample_factor") - } - ) - - on_ee do - events_q = Plausible.Stats.Sampling.add_query_hint(events_q, query) - end - - events_q - end - - defp build_events_subquery_with_goal_join(query) do - goal_join_data = Plausible.Stats.Goals.goal_join_data(query) - - events_q = - from(e in "events_v2", - where: ^SQL.WhereBuilder.build(:events, query), - join: goal in Expression.event_goal_join(goal_join_data), - hints: "ARRAY", - on: true, - select: %{ - session_id: fragment("DISTINCT ?", e.session_id), - _sample_factor: fragment("_sample_factor") - } - ) - - on_ee do - events_q = Plausible.Stats.Sampling.add_query_hint(events_q, query) - end - - events_q - end - - defp has_goal_with_custom_props_filters?(query) do - Enum.any?(query.filters, fn - [_, "event:goal" | _] -> - Enum.any?(query.preloaded_goals.matching_toplevel_filters, fn goal -> - Plausible.Goal.has_custom_props?(goal) - end) - - _ -> - false - end) - end - defp select_event_metrics(query) do query.metrics |> Enum.map(&SQL.Expression.event_metric(&1, query)) diff --git a/test/plausible_web/controllers/api/external_stats_controller/query_goal_custom_props_equivalence_test.exs b/test/plausible_web/controllers/api/external_stats_controller/query_goal_custom_props_equivalence_test.exs new file mode 100644 index 000000000000..5ad4d7a0f46a --- /dev/null +++ b/test/plausible_web/controllers/api/external_stats_controller/query_goal_custom_props_equivalence_test.exs @@ -0,0 +1,329 @@ +defmodule PlausibleWeb.Api.ExternalStatsController.QueryGoalCustomPropsEquivalenceTest do + @moduledoc """ + Tests that filtering by a goal with custom props produces the same results + as filtering by the goal without custom props AND a separate custom prop filter. + + For example, these two queries should return identical results: + 1. Filter by goal "Visit /" with custom_props: {"browser_language" => "FR"} + 2. Filter by goal "Visit /" (no custom props) AND filter by event:props:browser_language = "FR" + """ + use PlausibleWeb.ConnCase + + setup [:create_user, :create_site, :create_api_key, :use_api_key] + + alias Plausible.Goals + + describe "goal with custom props equivalence" do + test "page goal with custom props equals page goal + separate prop filter", %{ + conn: conn, + site: site + } do + # Create page goal WITH custom props + {:ok, _goal_with_props} = + Goals.create(site, %{ + "page_path" => "/landing", + "custom_props" => %{"browser_language" => "FR"} + }) + + # Create page goal WITHOUT custom props + {:ok, _goal_without_props} = + Goals.create(site, %{ + "page_path" => "/landing-no-props" + }) + + populate_stats(site, [ + # Session 1 (FR): Has pageview with browser_language=FR + build(:pageview, + user_id: 1, + pathname: "/landing", + country_code: "FR", + "meta.key": ["browser_language"], + "meta.value": ["FR"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: 1, + pathname: "/landing-no-props", + country_code: "FR", + "meta.key": ["browser_language"], + "meta.value": ["FR"], + timestamp: ~N[2021-01-01 00:00:01] + ), + # Session 2 (US): Has pageview with browser_language=EN + build(:pageview, + user_id: 2, + pathname: "/landing", + country_code: "US", + "meta.key": ["browser_language"], + "meta.value": ["EN"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: 2, + pathname: "/landing-no-props", + country_code: "US", + "meta.key": ["browser_language"], + "meta.value": ["EN"], + timestamp: ~N[2021-01-01 00:00:01] + ), + # Session 3 (GB): Has pageview with no props + build(:pageview, + user_id: 3, + pathname: "/landing", + country_code: "GB", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: 3, + pathname: "/landing-no-props", + country_code: "GB", + timestamp: ~N[2021-01-01 00:00:01] + ) + ]) + + # Query 1: Page goal with custom props + conn1 = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "visits", "events"], + "dimensions" => ["visit:country"], + "filters" => [["is", "event:goal", ["Visit /landing"]]] + }) + + result1 = json_response(conn1, 200)["results"] + + # Query 2: Page goal without props + separate prop filter + conn2 = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "visits", "events"], + "dimensions" => ["visit:country"], + "filters" => [ + ["is", "event:goal", ["Visit /landing-no-props"]], + ["is", "event:props:browser_language", ["FR"]] + ] + }) + + result2 = json_response(conn2, 200)["results"] + + # Both should return only FR + assert result1 == [%{"dimensions" => ["FR"], "metrics" => [1, 1, 1]}] + assert result2 == [%{"dimensions" => ["FR"], "metrics" => [1, 1, 1]}] + assert result1 == result2 + end + + test "event goal with custom props equals event goal + separate prop filter", %{ + conn: conn, + site: site + } do + # Create event goal WITH custom props + {:ok, _goal_with_props} = + Goals.create(site, %{ + "event_name" => "Signup", + "custom_props" => %{"plan" => "premium"} + }) + + # Create event goal WITHOUT custom props + {:ok, _goal_without_props} = + Goals.create(site, %{ + "event_name" => "SignupNoProps" + }) + + populate_stats(site, [ + # Session 1 (US): Has correct event with plan=premium + build(:pageview, + user_id: 1, + country_code: "US", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + user_id: 1, + name: "Signup", + "meta.key": ["plan"], + "meta.value": ["premium"], + timestamp: ~N[2021-01-01 00:00:01] + ), + build(:event, + user_id: 1, + name: "SignupNoProps", + "meta.key": ["plan"], + "meta.value": ["premium"], + timestamp: ~N[2021-01-01 00:00:02] + ), + # Session 2 (GB): Has event with plan=basic + build(:pageview, + user_id: 2, + country_code: "GB", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + user_id: 2, + name: "Signup", + "meta.key": ["plan"], + "meta.value": ["basic"], + timestamp: ~N[2021-01-01 00:00:01] + ), + build(:event, + user_id: 2, + name: "SignupNoProps", + "meta.key": ["plan"], + "meta.value": ["basic"], + timestamp: ~N[2021-01-01 00:00:02] + ), + # Session 3 (CA): Has event but no props at all + build(:pageview, + user_id: 3, + country_code: "CA", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + user_id: 3, + name: "Signup", + timestamp: ~N[2021-01-01 00:00:01] + ), + build(:event, + user_id: 3, + name: "SignupNoProps", + timestamp: ~N[2021-01-01 00:00:02] + ) + ]) + + # Query 1: Event goal with custom props + conn1 = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "visits", "events"], + "dimensions" => ["visit:country"], + "filters" => [["is", "event:goal", ["Signup"]]] + }) + + result1 = json_response(conn1, 200)["results"] + + # Query 2: Event goal without props + separate prop filter + conn2 = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "visits", "events"], + "dimensions" => ["visit:country"], + "filters" => [ + ["is", "event:goal", ["SignupNoProps"]], + ["is", "event:props:plan", ["premium"]] + ] + }) + + result2 = json_response(conn2, 200)["results"] + + # Both should return only US + assert result1 == [%{"dimensions" => ["US"], "metrics" => [1, 1, 1]}] + assert result2 == [%{"dimensions" => ["US"], "metrics" => [1, 1, 1]}] + assert result1 == result2 + end + + test "page goal with multiple custom props equals page goal + multiple separate prop filters", + %{conn: conn, site: site} do + # Create page goal WITH multiple custom props + {:ok, _goal_with_props} = + Goals.create(site, %{ + "page_path" => "/checkout", + "custom_props" => %{"currency" => "EUR", "country" => "DE"} + }) + + # Create page goal WITHOUT custom props + {:ok, _goal_without_props} = + Goals.create(site, %{ + "page_path" => "/checkout-no-props" + }) + + populate_stats(site, [ + # Session 1 (DE): Has pageview with both props matching + build(:pageview, + user_id: 1, + pathname: "/checkout", + country_code: "DE", + "meta.key": ["currency", "country"], + "meta.value": ["EUR", "DE"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: 1, + pathname: "/checkout-no-props", + country_code: "DE", + "meta.key": ["currency", "country"], + "meta.value": ["EUR", "DE"], + timestamp: ~N[2021-01-01 00:00:01] + ), + # Session 2 (FR): Has pageview with only currency matching + build(:pageview, + user_id: 2, + pathname: "/checkout", + country_code: "FR", + "meta.key": ["currency", "country"], + "meta.value": ["EUR", "FR"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: 2, + pathname: "/checkout-no-props", + country_code: "FR", + "meta.key": ["currency", "country"], + "meta.value": ["EUR", "FR"], + timestamp: ~N[2021-01-01 00:00:01] + ), + # Session 3 (US): Has pageview with neither matching + build(:pageview, + user_id: 3, + pathname: "/checkout", + country_code: "US", + "meta.key": ["currency", "country"], + "meta.value": ["USD", "US"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: 3, + pathname: "/checkout-no-props", + country_code: "US", + "meta.key": ["currency", "country"], + "meta.value": ["USD", "US"], + timestamp: ~N[2021-01-01 00:00:01] + ) + ]) + + # Query 1: Page goal with multiple custom props + conn1 = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "visits", "events"], + "dimensions" => ["visit:country"], + "filters" => [["is", "event:goal", ["Visit /checkout"]]] + }) + + result1 = json_response(conn1, 200)["results"] + + # Query 2: Page goal without props + multiple separate prop filters + conn2 = + post(conn, "/api/v2/query", %{ + "site_id" => site.domain, + "date_range" => "all", + "metrics" => ["visitors", "visits", "events"], + "dimensions" => ["visit:country"], + "filters" => [ + ["is", "event:goal", ["Visit /checkout-no-props"]], + ["is", "event:props:currency", ["EUR"]], + ["is", "event:props:country", ["DE"]] + ] + }) + + result2 = json_response(conn2, 200)["results"] + + # Both should return only DE (both props must match) + assert result1 == [%{"dimensions" => ["DE"], "metrics" => [1, 1, 1]}] + assert result2 == [%{"dimensions" => ["DE"], "metrics" => [1, 1, 1]}] + assert result1 == result2 + end + end +end From 75b13bf7930d486040168e857f761b67a0d6ab46 Mon Sep 17 00:00:00 2001 From: Sanne de Vries Date: Wed, 7 Jan 2026 11:19:13 +0100 Subject: [PATCH 12/21] Allow overflow of combobox dropdown to max the width of the property pair - Add input and dropdown class attributes to combobox. - Adjust combobox dropdown within a property pair to overflow to max the width of the property pair container, with help of container-type:inline-size. - Ensure trash icon wrapper is hidden when icon is hidden. - Add title attribute to option when it is being truncated. --- .../live/components/combo_box.ex | 22 ++++++++++++++----- .../live/goal_settings/property_pair_input.ex | 4 +++- .../live/goal_settings/property_pairs.ex | 6 +++-- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/lib/plausible_web/live/components/combo_box.ex b/lib/plausible_web/live/components/combo_box.ex index 1ef4bd34206f..fc20e8ffe831 100644 --- a/lib/plausible_web/live/components/combo_box.ex +++ b/lib/plausible_web/live/components/combo_box.ex @@ -69,6 +69,8 @@ defmodule PlausibleWeb.Live.Components.ComboBox do attr(:suggest_fun, :any, required: true) attr(:suggestions_limit, :integer) attr(:class, :string, default: "") + attr(:input_class, :string, default: "") + attr(:dropdown_class, :string, default: "") attr(:required, :boolean, default: false) attr(:creatable, :boolean, default: false) attr(:errors, :list, default: []) @@ -105,7 +107,10 @@ defmodule PlausibleWeb.Live.Components.ComboBox do phx-target={@myself} phx-debounce={200} value={@display_value} - class="text-sm [&.phx-change-loading+svg.spinner]:block border-none py-1.5 px-1.5 w-full inline-block rounded-md focus:outline-hidden focus:ring-0" + class={[ + "text-sm [&.phx-change-loading+svg.spinner]:block border-none py-1.5 px-1.5 w-full inline-block rounded-md focus:outline-hidden focus:ring-0", + @input_class + ]} style="background-color: inherit;" required={@required} /> @@ -137,6 +142,7 @@ defmodule PlausibleWeb.Live.Components.ComboBox do target={@myself} creatable={@creatable} display_value={@display_value} + dropdown_class={@dropdown_class} />
@@ -172,6 +178,7 @@ defmodule PlausibleWeb.Live.Components.ComboBox do attr(:target, :any) attr(:creatable, :boolean, required: true) attr(:display_value, :string, required: true) + attr(:dropdown_class, :string, default: "") def combo_dropdown(assigns) do ~H""" @@ -180,7 +187,10 @@ defmodule PlausibleWeb.Live.Components.ComboBox do id={"dropdown-#{@ref}"} x-show="isOpen" x-ref="suggestions" - class="text-sm w-full dropdown z-50 absolute mt-1 max-h-60 overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1/5 ring-black focus:outline-hidden dark:bg-gray-800" + class={[ + "text-sm w-full dropdown z-50 absolute mt-1 max-h-60 overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1/5 ring-black focus:outline-hidden dark:bg-gray-800", + @dropdown_class + ]} style="display: none;" > <.option @@ -248,11 +258,13 @@ defmodule PlausibleWeb.Live.Components.ComboBox do $el.clientWidth; $el.title = isTruncated ? $el.dataset.displayValue : ''"} phx-click={select_option(@ref, @submit_value, @display_value)} phx-value-submit-value={@submit_value} phx-value-display-value={@display_value} phx-target={@target} class="block truncate py-2 px-3" + data-display-value={@display_value} > <%= if @creatable do %> Create "{@display_value}" @@ -261,9 +273,9 @@ defmodule PlausibleWeb.Live.Components.ComboBox do <% end %> -
- Max results reached. Refine your search by typing. -
+
  • + Max results reached, type to refine search. +
  • """ end diff --git a/lib/plausible_web/live/goal_settings/property_pair_input.ex b/lib/plausible_web/live/goal_settings/property_pair_input.ex index b9a7b4956447..6b10e1b2e9d5 100644 --- a/lib/plausible_web/live/goal_settings/property_pair_input.ex +++ b/lib/plausible_web/live/goal_settings/property_pair_input.ex @@ -24,13 +24,14 @@ defmodule PlausibleWeb.Live.GoalSettings.PropertyPairInput do def render(assigns) do ~H""" -
    +
    <.live_component id={"#{@id}_key"} submit_name="goal[custom_props][keys][]" placeholder="Select property" module={ComboBox} + dropdown_class="left-0 w-max min-w-full max-w-[100cqw]" suggest_fun={fn input, _options -> suggest_property_names(@site, input) end} selected={if @initial_prop_key != "", do: @initial_prop_key, else: nil} on_selection_made={ @@ -53,6 +54,7 @@ defmodule PlausibleWeb.Live.GoalSettings.PropertyPairInput do submit_name="goal[custom_props][values][]" placeholder={if @selected_property, do: "Select value", else: "Select property first"} module={ComboBox} + dropdown_class="right-0 w-max min-w-full max-w-[100cqw]" suggest_fun={ fn input, _options -> suggest_property_values(@site, input, @selected_property) diff --git a/lib/plausible_web/live/goal_settings/property_pairs.ex b/lib/plausible_web/live/goal_settings/property_pairs.ex index d6b554029604..555f4e1ad446 100644 --- a/lib/plausible_web/live/goal_settings/property_pairs.ex +++ b/lib/plausible_web/live/goal_settings/property_pairs.ex @@ -42,9 +42,11 @@ defmodule PlausibleWeb.Live.GoalSettings.PropertyPairs do />
    -
    +
    1} + class="w-min inline-flex items-center align-middle" + > <.remove_property_button - :if={length(@slots) > 1} pair_id={id} myself={@myself} /> From b11aa25bb27afd62846dd4e679e4d0697f5d49dc Mon Sep 17 00:00:00 2001 From: Sanne de Vries Date: Wed, 7 Jan 2026 11:34:23 +0100 Subject: [PATCH 13/21] Fix formatting --- lib/plausible_web/live/components/combo_box.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/plausible_web/live/components/combo_box.ex b/lib/plausible_web/live/components/combo_box.ex index fc20e8ffe831..37bf12667a7a 100644 --- a/lib/plausible_web/live/components/combo_box.ex +++ b/lib/plausible_web/live/components/combo_box.ex @@ -258,7 +258,7 @@ defmodule PlausibleWeb.Live.Components.ComboBox do $el.clientWidth; $el.title = isTruncated ? $el.dataset.displayValue : ''"} + x-on:mouseenter="const isTruncated = $el.scrollWidth > $el.clientWidth; $el.title = isTruncated ? $el.dataset.displayValue : ''" phx-click={select_option(@ref, @submit_value, @display_value)} phx-value-submit-value={@submit_value} phx-value-display-value={@display_value} From 503638aa1bf04f61b200c7969a452c90d2cb5d17 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Mon, 12 Jan 2026 13:42:28 +0100 Subject: [PATCH 14/21] Add include_goals_with_custom_props? option to Goals.for_site_query --- lib/plausible/goals/goals.ex | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/plausible/goals/goals.ex b/lib/plausible/goals/goals.ex index 9f7af75431c1..d6d3db86a17e 100644 --- a/lib/plausible/goals/goals.ex +++ b/lib/plausible/goals/goals.ex @@ -181,6 +181,13 @@ defmodule Plausible.Goals do query end + query = + if Keyword.get(opts, :include_goals_with_custom_props?, true) == false do + from g in query, where: g.custom_props == ^%{} + else + query + end + if ee?() and opts[:preload_funnels?] == true do from(g in query, left_join: assoc(g, :funnels), From ccdcab11de82d2708ee7131393ab8d1ed53d9d1d Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Mon, 12 Jan 2026 13:43:03 +0100 Subject: [PATCH 15/21] Add server-side mode support to toggle_switch component --- lib/plausible_web/components/generic.ex | 45 +++++++++++++++++++++---- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/lib/plausible_web/components/generic.ex b/lib/plausible_web/components/generic.ex index d7e7032b4ef4..89a88409ef49 100644 --- a/lib/plausible_web/components/generic.ex +++ b/lib/plausible_web/components/generic.ex @@ -438,7 +438,8 @@ defmodule PlausibleWeb.Components.Generic do end attr :id, :string, required: true - attr :js_active_var, :string, required: true + attr :js_active_var, :string, default: nil + attr :checked, :boolean, default: nil attr :id_suffix, :string, default: "" attr :disabled, :boolean, default: false @@ -446,17 +447,30 @@ defmodule PlausibleWeb.Components.Generic do @doc """ Renders toggle input. - Needs `:js_active_var` that controls toggle state. - Set this outside this component with `x-data="{ : }"`. - ### Examples + Can be used in two modes: + + 1. Alpine JS mode: Pass `:js_active_var` to control toggle state via Alpine JS. + Set this outside this component with `x-data="{ : }"`. + + 2. Server-side mode: Pass `:checked` boolean and `phx-click` event handler. + + ### Examples - Alpine JS mode ```
    - ``` + ``` + + ### Examples - Server-side mode + ``` + <.toggle_switch id="my_toggle" checked={@my_toggle} phx-click="toggle-my-setting" phx-target={@myself} /> + ``` """ def toggle_switch(assigns) do + server_mode? = not is_nil(assigns.checked) + assigns = assign(assigns, :server_mode?, server_mode?) + ~H"""
    """ @@ -380,7 +425,9 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do attr(:goal_options, :list) attr(:goal, Plausible.Goal, default: nil) attr(:has_access_to_revenue_goals?, :boolean) + attr(:has_access_to_props?, :boolean) attr(:myself, :any) + attr(:use_custom_props, :boolean, default: false) attr(:rest, :global) @@ -388,7 +435,6 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do ~H"""
    @@ -444,6 +490,10 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do goal={@goal} myself={@myself} site={@site} + current_user={@current_user} + site_team={@site_team} + has_access_to_props?={@has_access_to_props?} + use_custom_props={@use_custom_props} /> <%= if ee?() and Plausible.Sites.regular?(@site) and not editing_non_revenue_goal?(assigns) do %> @@ -469,34 +519,49 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do attr(:f, Phoenix.HTML.Form, required: true) attr(:goal, Plausible.Goal, default: nil) attr(:site, Plausible.Site, required: true) + attr(:current_user, Plausible.Auth.User, required: true) + attr(:site_team, Plausible.Teams.Team, required: true) + attr(:has_access_to_props?, :boolean, required: true) + attr(:use_custom_props, :boolean, default: false) def custom_property_section(assigns) do - has_custom_props? = Plausible.Goal.has_custom_props?(assigns[:goal]) - assigns = assign(assigns, :has_custom_props?, has_custom_props?) - ~H""" -
    - - Add custom property - - <.toggle_switch - id="add-custom-property" - id_suffix={@suffix} - js_active_var="addCustomProperty" - /> -
    - -
    - - Custom properties - -
    + <.tooltip enabled?={not @has_access_to_props?}> + <:tooltip_content> +
    + To get access to this feature + +
    + +
    + + {if @use_custom_props, do: "Custom properties", else: "Add custom property"} + + <.toggle_switch + id="add-custom-property" + id_suffix={@suffix} + checked={@use_custom_props} + phx-click="toggle-custom-props" + phx-target={@myself} + disabled={not @has_access_to_props?} + /> +
    + <.error :for={msg <- Enum.map(@f[:custom_props].errors, &translate_error/1)}> {msg} -
    +
    <.live_component id={"property-pairs-#{@suffix}"} module={PlausibleWeb.Live.GoalSettings.PropertyPairs} @@ -554,7 +619,10 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do end def handle_event("save-goal", %{"goal" => goal_params}, %{assigns: %{goal: nil}} = socket) do - {:ok, transformed_params} = transform_property_params(goal_params) + {:ok, transformed_params} = + goal_params + |> maybe_clear_custom_props(socket.assigns.use_custom_props) + |> transform_property_params() case Plausible.Goals.create(socket.assigns.site, transformed_params) do {:ok, goal} -> @@ -575,7 +643,10 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do %{"goal" => goal_params}, %{assigns: %{goal: %Plausible.Goal{} = goal}} = socket ) do - {:ok, transformed_params} = transform_property_params(goal_params) + {:ok, transformed_params} = + goal_params + |> maybe_clear_custom_props(socket.assigns.use_custom_props) + |> transform_property_params() case Plausible.Goals.update(goal, transformed_params) do {:ok, goal} -> @@ -588,6 +659,10 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do end end + def handle_event("toggle-custom-props", _params, socket) do + {:noreply, assign(socket, use_custom_props: !socket.assigns.use_custom_props)} + end + def handle_event("autoconfigure", _params, socket) do socket = assign(socket, show_autoconfigure_modal?: false) {:noreply, socket.assigns.on_autoconfigure.(socket)} @@ -641,7 +716,7 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do . + />
    @@ -675,6 +750,12 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do defp editing_non_revenue_goal?(_assigns), do: false end + defp maybe_clear_custom_props(goal_params, true = _use_custom_props), do: goal_params + + defp maybe_clear_custom_props(goal_params, false = _use_custom_props) do + Map.put(goal_params, "custom_props", %{}) + end + defp transform_property_params( %{"custom_props" => %{"keys" => prop_keys, "values" => prop_values}} = goal_params ) From 1ec32851f586ef57c561d5c1b66a47429d9a1ac5 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Mon, 12 Jan 2026 13:43:58 +0100 Subject: [PATCH 17/21] Apply Props feature gating to goal queries and API --- lib/plausible/stats/filter_suggestions.ex | 5 ++++- lib/plausible/stats/goals.ex | 7 ++++++- .../controllers/api/external_stats_controller.ex | 5 ++++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/lib/plausible/stats/filter_suggestions.ex b/lib/plausible/stats/filter_suggestions.ex index dd1da7355b85..e890c7f36bdc 100644 --- a/lib/plausible/stats/filter_suggestions.ex +++ b/lib/plausible/stats/filter_suggestions.ex @@ -148,8 +148,11 @@ defmodule Plausible.Stats.FilterSuggestions do end def filter_suggestions(site, _query, "goal", filter_search) do + site = Plausible.Repo.preload(site, :team) + props_available? = Plausible.Billing.Feature.Props.check_availability(site.team) == :ok + site - |> Plausible.Goals.for_site() + |> Plausible.Goals.for_site(include_goals_with_custom_props?: props_available?) |> Enum.map(& &1.display_name) |> Enum.filter(fn goal -> String.contains?( diff --git a/lib/plausible/stats/goals.ex b/lib/plausible/stats/goals.ex index 0c86e92f448a..f6930b3ae56d 100644 --- a/lib/plausible/stats/goals.ex +++ b/lib/plausible/stats/goals.ex @@ -14,7 +14,12 @@ defmodule Plausible.Stats.Goals do def preload_needed_goals(site, dimensions, filters) do if Enum.member?(dimensions, "event:goal") or Filters.filtering_on_dimension?(filters, "event:goal") do - goals = Plausible.Goals.for_site(site) + site = Plausible.Repo.preload(site, :team) + props_available? = Plausible.Billing.Feature.Props.check_availability(site.team) == :ok + + goals = + site + |> Plausible.Goals.for_site(include_goals_with_custom_props?: props_available?) %{ # When grouping by event:goal, later pipeline needs to know which goals match filters exactly. diff --git a/lib/plausible_web/controllers/api/external_stats_controller.ex b/lib/plausible_web/controllers/api/external_stats_controller.ex index 6890d470585b..0e69b0ab0c7d 100644 --- a/lib/plausible_web/controllers/api/external_stats_controller.ex +++ b/lib/plausible_web/controllers/api/external_stats_controller.ex @@ -336,9 +336,12 @@ defmodule PlausibleWeb.Api.ExternalStatsController do end defp validate_filter(site, [_type, "event:goal", goal_filter | _rest]) do + site = Plausible.Repo.preload(site, :team) + props_available? = Plausible.Billing.Feature.Props.check_availability(site.team) == :ok + configured_goals = site - |> Plausible.Goals.for_site() + |> Plausible.Goals.for_site(include_goals_with_custom_props?: props_available?) |> Enum.map(& &1.display_name) goals_in_filter = List.wrap(goal_filter) From ff047975ef43c43bfe18405f2306c5a16bd5dc3c Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Mon, 12 Jan 2026 13:44:16 +0100 Subject: [PATCH 18/21] Show upgrade required badge for goals with custom props --- lib/plausible_web/live/goal_settings/list.ex | 25 ++++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/lib/plausible_web/live/goal_settings/list.ex b/lib/plausible_web/live/goal_settings/list.ex index f727db0128bb..bf47e3bf56f6 100644 --- a/lib/plausible_web/live/goal_settings/list.ex +++ b/lib/plausible_web/live/goal_settings/list.ex @@ -13,10 +13,12 @@ defmodule PlausibleWeb.Live.GoalSettings.List do def render(assigns) do revenue_goals_enabled? = Plausible.Billing.Feature.RevenueGoals.enabled?(assigns.site) + props_available? = Plausible.Billing.Feature.Props.check_availability(assigns.site.team) == :ok assigns = assigns |> assign(:revenue_goals_enabled?, revenue_goals_enabled?) + |> assign(:props_available?, props_available?) |> assign(:searching?, String.trim(assigns.filter_text) != "") ~H""" @@ -66,13 +68,19 @@ defmodule PlausibleWeb.Live.GoalSettings.List do <:tbody :let={goal}> <.td max_width="max-w-52 sm:max-w-64" height="h-16"> - <%= if not @revenue_goals_enabled? && goal.currency do %> + <% has_unavailable_revenue? = not @revenue_goals_enabled? and not is_nil(goal.currency) %> + <% has_unavailable_props? = not @props_available? and Plausible.Goal.has_custom_props?(goal) %> + <%= if has_unavailable_revenue? or has_unavailable_props? do %>
    {goal}
    <.tooltip> <:tooltip_content>

    - Revenue Goals act like regular custom
    - events without a Business subscription
    + <%= if has_unavailable_revenue? do %> + Revenue Goals act like regular custom
    + events without a Business subscription
    + <% else %> + Custom Properties on Goals require
    a Business subscription
    + <% end %>

    @@ -127,8 +135,9 @@ defmodule PlausibleWeb.Live.GoalSettings.List do <.pill :if={goal.currency} color={:indigo}>Revenue Goal ({goal.currency}) <.td actions height="h-16"> + <% goal_editable? = goal_editable?(goal, @revenue_goals_enabled?, @props_available?) %> <.edit_button - :if={!goal.currency || (goal.currency && @revenue_goals_enabled?)} + :if={goal_editable?} x-data x-on:click={Modal.JS.preopen("goals-form-modal")} phx-click="edit-goal" @@ -137,7 +146,7 @@ defmodule PlausibleWeb.Live.GoalSettings.List do id={"edit-goal-#{goal.id}"} /> <.edit_button - :if={goal.currency && !@revenue_goals_enabled?} + :if={not goal_editable?} id={"edit-goal-#{goal.id}-disabled"} disabled class="cursor-not-allowed mt-1" @@ -281,4 +290,10 @@ defmodule PlausibleWeb.Live.GoalSettings.List do """ end end + + defp goal_editable?(goal, revenue_goals_enabled?, props_available?) do + revenue_ok? = not is_nil(goal.currency) or revenue_goals_enabled? + props_ok? = not Plausible.Goal.has_custom_props?(goal) or props_available? + revenue_ok? and props_ok? + end end From fcc5cb947cee474220e4ddd1c490b3536fd2fbaa Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Mon, 12 Jan 2026 13:46:07 +0100 Subject: [PATCH 19/21] Add tests for custom props feature gating on goals --- test/plausible/goals_test.exs | 39 +++++++ .../api/stats_controller/conversions_test.exs | 62 +++++++++++ .../api/stats_controller/suggestions_test.exs | 47 ++++++++ .../live/goal_settings/form_test.exs | 105 +++++++++++++++++- .../plausible_web/live/goal_settings_test.exs | 29 +++++ 5 files changed, 276 insertions(+), 6 deletions(-) diff --git a/test/plausible/goals_test.exs b/test/plausible/goals_test.exs index 78ded940c71d..05817d1a9a2e 100644 --- a/test/plausible/goals_test.exs +++ b/test/plausible/goals_test.exs @@ -379,6 +379,45 @@ defmodule Plausible.GoalsTest do Goals.create(site, %{"event_name" => "Purchase", "currency" => "EUR"}) end + test "create/2 returns error when site does not have access to custom props on goals" do + user = new_user() |> subscribe_to_growth_plan() + site = new_site(owner: user) + + {:error, :upgrade_required} = + Goals.create(site, %{"event_name" => "Signup", "custom_props" => %{"plan" => "premium"}}) + end + + test "for_site/2 with include_goals_with_custom_props?: false excludes goals with custom props" do + site = new_site() + + _goal_with_props = + insert(:goal, site: site, event_name: "Purchase", custom_props: %{"product" => "Shirt"}) + + goal_without_props = insert(:goal, site: site, event_name: "Signup") + + filtered = Goals.for_site(site, include_goals_with_custom_props?: false) + + assert length(filtered) == 1 + assert hd(filtered).id == goal_without_props.id + end + + test "for_site/2 includes all goals by default (include_goals_with_custom_props? defaults to true)" do + user = new_user() + site = new_site(owner: user) + + {:ok, _goal_with_props} = + Goals.create(site, %{ + "event_name" => "Purchase", + "custom_props" => %{"product" => "Shirt"} + }) + + {:ok, _goal_without_props} = Goals.create(site, %{"event_name" => "Signup"}) + + all_goals = Goals.for_site(site) + + assert length(all_goals) == 2 + end + test "create/2 returns error when creating a revenue goal for consolidated view" do user = new_user() new_site(owner: user) diff --git a/test/plausible_web/controllers/api/stats_controller/conversions_test.exs b/test/plausible_web/controllers/api/stats_controller/conversions_test.exs index 2adcf2341734..5a2f8cc82965 100644 --- a/test/plausible_web/controllers/api/stats_controller/conversions_test.exs +++ b/test/plausible_web/controllers/api/stats_controller/conversions_test.exs @@ -461,6 +461,68 @@ defmodule PlausibleWeb.Api.StatsController.ConversionsTest do ] end + @tag :ee_only + test "excludes goals with custom props when Props feature is unavailable", %{ + conn: conn, + site: site, + user: user + } do + user + |> team_of() + |> Plausible.Teams.Team.end_trial() + |> Plausible.Repo.update!() + + populate_stats(site, [ + build(:event, name: "Signup"), + build(:event, name: "Signup"), + build(:event, name: "Signup") + ]) + + {:ok, _goal_with_props} = + Plausible.Goals.create(site, %{ + "event_name" => "Purchase", + "custom_props" => %{"product" => "Shirt"} + }) + + insert(:goal, %{site: site, event_name: "Signup"}) + + conn = get(conn, "/api/stats/#{site.domain}/conversions?period=day") + + assert json_response(conn, 200)["results"] == [ + %{ + "name" => "Signup", + "visitors" => 3, + "events" => 3, + "conversion_rate" => 100.0 + } + ] + end + + @tag :ee_only + test "includes goals with custom props when Props feature is available", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:event, name: "Purchase", "meta.key": ["product"], "meta.value": ["Shirt"]), + build(:event, name: "Purchase", "meta.key": ["product"], "meta.value": ["Shirt"]) + ]) + + {:ok, _goal_with_props} = + Plausible.Goals.create(site, %{ + "event_name" => "Purchase", + "custom_props" => %{"product" => "Shirt"} + }) + + conn = get(conn, "/api/stats/#{site.domain}/conversions?period=day") + + results = json_response(conn, 200)["results"] + + assert length(results) == 1 + assert hd(results)["name"] == "Purchase" + assert hd(results)["visitors"] == 2 + end + @tag :ee_only test "returns revenue metrics as nil for non-revenue goals", %{ conn: conn, diff --git a/test/plausible_web/controllers/api/stats_controller/suggestions_test.exs b/test/plausible_web/controllers/api/stats_controller/suggestions_test.exs index e05ffd3d1a11..bc368d252a20 100644 --- a/test/plausible_web/controllers/api/stats_controller/suggestions_test.exs +++ b/test/plausible_web/controllers/api/stats_controller/suggestions_test.exs @@ -73,6 +73,53 @@ defmodule PlausibleWeb.Api.StatsController.SuggestionsTest do assert json_response(conn, 200) == [%{"label" => "Signup", "value" => "Signup"}] end + @tag :ee_only + test "excludes goals with custom props when Props feature is unavailable", %{ + conn: conn, + site: site, + user: user + } do + user + |> team_of() + |> Plausible.Teams.Team.end_trial() + |> Plausible.Repo.update!() + + {:ok, _goal_with_props} = + Plausible.Goals.create(site, %{ + "event_name" => "Purchase", + "custom_props" => %{"product" => "Shirt"} + }) + + {:ok, _goal_without_props} = + Plausible.Goals.create(site, %{"event_name" => "Signup"}) + + conn = get(conn, "/api/stats/#{site.domain}/suggestions/goal?period=day&q=") + + assert json_response(conn, 200) == [%{"label" => "Signup", "value" => "Signup"}] + end + + @tag :ee_only + test "includes goals with custom props when Props feature is available", %{ + conn: conn, + site: site + } do + {:ok, _goal_with_props} = + Plausible.Goals.create(site, %{ + "event_name" => "Purchase", + "custom_props" => %{"product" => "Shirt"} + }) + + {:ok, _goal_without_props} = + Plausible.Goals.create(site, %{"event_name" => "Signup"}) + + conn = get(conn, "/api/stats/#{site.domain}/suggestions/goal?period=day&q=") + + suggestions = json_response(conn, 200) + assert length(suggestions) == 2 + assert %{"label" => "Purchase", "value" => "Purchase"} in suggestions + assert %{"label" => "Signup", "value" => "Signup"} in suggestions + end + test "returns suggestions for sources", %{conn: conn, site: site} do populate_stats(site, [ build(:pageview, timestamp: ~N[2019-01-01 23:00:00], referrer_source: "Bing"), diff --git a/test/plausible_web/live/goal_settings/form_test.exs b/test/plausible_web/live/goal_settings/form_test.exs index 73fc0405852d..1e191844e189 100644 --- a/test/plausible_web/live/goal_settings/form_test.exs +++ b/test/plausible_web/live/goal_settings/form_test.exs @@ -65,9 +65,9 @@ defmodule PlausibleWeb.Live.GoalSettings.FormTest do assert "goal[event_name]" in input_names assert "goal[display_name]" in input_names - assert "goal[custom_props][keys][]" in input_names - assert "goal[custom_props][values][]" in input_names assert "display-currency_input_modalseq0" in input_names + + assert html =~ "Add custom property" end test "renders form fields for pageview", %{conn: conn, site: site} do @@ -82,8 +82,8 @@ defmodule PlausibleWeb.Live.GoalSettings.FormTest do assert "display-page_path_input_modalseq0" in input_names assert "goal[page_path]" in input_names assert "goal[display_name]" in input_names - assert "goal[custom_props][keys][]" in input_names - assert "goal[custom_props][values][]" in input_names + + assert html =~ "Add custom property" end @tag :ce_build_only @@ -98,9 +98,9 @@ defmodule PlausibleWeb.Live.GoalSettings.FormTest do assert "goal[event_name]" in input_names assert "goal[display_name]" in input_names - assert "goal[custom_props][keys][]" in input_names - assert "goal[custom_props][values][]" in input_names refute "display-currency_input_modalseq0" in input_names + + assert html =~ "Add custom property" end test "renders error on empty submission", %{conn: conn, site: site} do @@ -388,6 +388,99 @@ defmodule PlausibleWeb.Live.GoalSettings.FormTest do assert html =~ "Custom properties" assert element_exists?(html, ~s/[data-test-id="custom-property-pairs"]/) end + + test "toggle can be clicked to show/hide custom props section", %{conn: conn, site: site} do + lv = get_liveview(conn, site) |> open_modal_with_goal_type("custom_events") + + html = render(lv) + assert html =~ "Add custom property" + refute element_exists?(html, ~s/[data-test-id="custom-property-pairs"]/) + + lv |> element(~s/button[phx-click="toggle-custom-props"]/) |> render_click() + html = render(lv) + + assert html =~ "Custom properties" + assert element_exists?(html, ~s/[data-test-id="custom-property-pairs"]/) + + lv |> element(~s/button[phx-click="toggle-custom-props"]/) |> render_click() + html = render(lv) + + assert html =~ "Add custom property" + refute element_exists?(html, ~s/[data-test-id="custom-property-pairs"]/) + end + + test "disabling custom props toggle clears custom props on save", %{conn: conn, site: site} do + {:ok, goal} = + Plausible.Goals.create(site, %{ + "event_name" => "Purchase", + "custom_props" => %{"product" => "Shirt", "color" => "Blue"} + }) + + assert Plausible.Goal.has_custom_props?(goal) + + lv = get_liveview(conn, site) + lv |> element(~s/button#edit-goal-#{goal.id}/) |> render_click() + + html = render(lv) + assert html =~ "Custom properties" + + lv |> element(~s/button[phx-click="toggle-custom-props"]/) |> render_click() + html = render(lv) + + assert html =~ "Add custom property" + refute element_exists?(html, ~s/[data-test-id="custom-property-pairs"]/) + + lv + |> element("#goals-form-modalseq0 form") + |> render_submit(%{goal: %{event_name: "Purchase"}}) + + updated_goal = Plausible.Goals.get(site, goal.id) + refute Plausible.Goal.has_custom_props?(updated_goal) + assert updated_goal.custom_props == %{} + end + + @tag :ee_only + test "custom props toggle is disabled when Props feature is unavailable", %{ + conn: conn, + user: user, + site: site + } do + user + |> team_of() + |> Plausible.Teams.Team.end_trial() + |> Plausible.Repo.update!() + + lv = get_liveview(conn, site) |> open_modal_with_goal_type("custom_events") + + html = render(lv) + + assert element_exists?( + html, + ~s/button[role="switch"][disabled]#add-custom-property-modalseq0/ + ) + + assert html =~ "To get access to this feature" + end + + @tag :ee_only + test "custom props toggle is enabled when user has Business plan", %{ + conn: conn, + user: user, + site: site + } do + subscribe_to_business_plan(user) + + lv = get_liveview(conn, site) |> open_modal_with_goal_type("custom_events") + + html = render(lv) + + refute element_exists?( + html, + ~s/button[role="switch"][disabled]#add-custom-property-modalseq0/ + ) + + assert element_exists?(html, ~s/button[role="switch"]#add-custom-property-modalseq0/) + end end describe "Combos integration" do diff --git a/test/plausible_web/live/goal_settings_test.exs b/test/plausible_web/live/goal_settings_test.exs index 36ebb8f7b0ef..054e529551af 100644 --- a/test/plausible_web/live/goal_settings_test.exs +++ b/test/plausible_web/live/goal_settings_test.exs @@ -53,6 +53,35 @@ defmodule PlausibleWeb.Live.GoalSettingsTest do ) end + @tag :ee_only + test "lists goals with custom props with feature availability annotation if the plan does not cover them", + %{conn: conn, user: user, site: site} do + {:ok, goal_with_props} = + Plausible.Goals.create(site, %{ + "event_name" => "Signup", + "custom_props" => %{"plan" => "premium"} + }) + + user + |> team_of() + |> Plausible.Teams.Team.end_trial() + |> Plausible.Repo.update!() + + conn = get(conn, "/#{site.domain}/settings/goals") + + resp = html_response(conn, 200) + + assert Plausible.Goal.has_custom_props?(goal_with_props) + assert resp =~ to_string(goal_with_props) + assert resp =~ "Upgrade Required" + assert resp =~ "Custom Properties on Goals require" + + assert element_exists?( + resp, + ~s/button[disabled]#edit-goal-#{goal_with_props.id}-disabled/ + ) + end + test "lists goals with actions", %{conn: conn, site: site} do {:ok, goals} = setup_goals(site) conn = get(conn, "/#{site.domain}/settings/goals") From f86872f8e4c73c52a317a84ba629b3d36a7b3838 Mon Sep 17 00:00:00 2001 From: Adam Rutkowski Date: Mon, 12 Jan 2026 18:28:27 +0100 Subject: [PATCH 20/21] Format --- lib/plausible_web/live/goal_settings/list.ex | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/plausible_web/live/goal_settings/list.ex b/lib/plausible_web/live/goal_settings/list.ex index bf47e3bf56f6..38963977a4ff 100644 --- a/lib/plausible_web/live/goal_settings/list.ex +++ b/lib/plausible_web/live/goal_settings/list.ex @@ -13,7 +13,9 @@ defmodule PlausibleWeb.Live.GoalSettings.List do def render(assigns) do revenue_goals_enabled? = Plausible.Billing.Feature.RevenueGoals.enabled?(assigns.site) - props_available? = Plausible.Billing.Feature.Props.check_availability(assigns.site.team) == :ok + + props_available? = + Plausible.Billing.Feature.Props.check_availability(assigns.site.team) == :ok assigns = assigns @@ -69,7 +71,8 @@ defmodule PlausibleWeb.Live.GoalSettings.List do <:tbody :let={goal}> <.td max_width="max-w-52 sm:max-w-64" height="h-16"> <% has_unavailable_revenue? = not @revenue_goals_enabled? and not is_nil(goal.currency) %> - <% has_unavailable_props? = not @props_available? and Plausible.Goal.has_custom_props?(goal) %> + <% has_unavailable_props? = + not @props_available? and Plausible.Goal.has_custom_props?(goal) %> <%= if has_unavailable_revenue? or has_unavailable_props? do %>
    {goal}
    <.tooltip> From 0daac2f7aa445c0aea9e0c103f4a837c216680c6 Mon Sep 17 00:00:00 2001 From: Sanne de Vries Date: Wed, 14 Jan 2026 11:24:46 +0100 Subject: [PATCH 21/21] Improve upgrade call to action styles and content - Improve `Upgrade` CTA in list and turn it into a link - Improve `Upgrade` CTA tooltip copy to make it more clear what happens with locked goals on the dashboard - Add disabled (faded) styling to icon button and toggle switch - Move disabled styling to toggle only in goal form - Add `link_class` attribute to `upgrade_call_to_action` component in order to override link color when displayed inside tooltip - Add `Business` pill to goal form to make it more obvious that the feature is locked, and make it a link to the billing page - Improve prop styling inside tooltip in goal list to make it more readable and ensure truncation when prop is long --- .../components/billing/billing.ex | 13 ++- lib/plausible_web/components/generic.ex | 15 ++-- lib/plausible_web/live/goal_settings/form.ex | 80 +++++++++-------- lib/plausible_web/live/goal_settings/list.ex | 90 ++++++++++--------- 4 files changed, 113 insertions(+), 85 deletions(-) diff --git a/lib/plausible_web/components/billing/billing.ex b/lib/plausible_web/components/billing/billing.ex index a55c80d19309..886b4de8d649 100644 --- a/lib/plausible_web/components/billing/billing.ex +++ b/lib/plausible_web/components/billing/billing.ex @@ -11,6 +11,7 @@ defmodule PlausibleWeb.Components.Billing do attr :current_user, Plausible.Auth.User, required: true attr :current_team, :any, required: true attr :locked?, :boolean, required: true + attr :link_class, :string, default: "" slot :inner_block, required: true def feature_gate(assigns) do @@ -36,7 +37,11 @@ defmodule PlausibleWeb.Components.Billing do class="max-w-sm sm:max-w-md mb-2 text-sm text-gray-600 dark:text-gray-100/60 leading-normal text-center" > To access this feature, - <.upgrade_call_to_action current_user={@current_user} current_team={@current_team} /> + <.upgrade_call_to_action + current_user={@current_user} + current_team={@current_team} + link_class={@link_class} + />
    @@ -357,6 +362,8 @@ defmodule PlausibleWeb.Components.Billing do defp change_plan_or_upgrade_text(_subscription), do: "Change plan" + attr :link_class, :string, default: "" + def upgrade_call_to_action(assigns) do user = assigns.current_user site = assigns[:site] @@ -389,7 +396,7 @@ defmodule PlausibleWeb.Components.Billing do upgrade_assistance_required? -> ~H""" contact - <.styled_link href="mailto:hello@plausible.io" class="font-medium"> + <.styled_link href="mailto:hello@plausible.io" class={"font-medium " <> @link_class}> hello@plausible.io to upgrade your subscription. @@ -398,7 +405,7 @@ defmodule PlausibleWeb.Components.Billing do true -> ~H""" <.styled_link - class="inline-block font-medium" + class={"inline-block font-medium " <> @link_class} href={Routes.billing_path(PlausibleWeb.Endpoint, :choose_plan)} > upgrade your subscription. diff --git a/lib/plausible_web/components/generic.ex b/lib/plausible_web/components/generic.ex index 89a88409ef49..3f14b6a69312 100644 --- a/lib/plausible_web/components/generic.ex +++ b/lib/plausible_web/components/generic.ex @@ -488,7 +488,8 @@ defmodule PlausibleWeb.Components.Generic do :if={@server_mode?} class={[ "relative inline-flex h-6 w-11 shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-hidden focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2", - if(@checked, do: "bg-indigo-600", else: "dark:bg-gray-600 bg-gray-200") + if(@checked, do: "bg-indigo-600", else: "dark:bg-gray-600 bg-gray-200"), + if(@disabled, do: "opacity-50") ]} > -
    +
    {render_slot(@tooltip_content)}
    @@ -1035,6 +1039,7 @@ defmodule PlausibleWeb.Components.Generic do "dark:group-hover/button:" <> text.dark_hover, "transition-colors", "duration-150", + "group-disabled/button:opacity-50", assigns.icon_class ] @@ -1251,7 +1256,7 @@ defmodule PlausibleWeb.Components.Generic do ~H""" - <:tooltip_content> -
    - To get access to this feature +
    +
    + + {if @use_custom_props, do: "Custom properties", else: "Add custom property"} + + <.link + :if={not @has_access_to_props? and not @use_custom_props} + href={Routes.billing_path(PlausibleWeb.Endpoint, :choose_plan)} + class="inline-block" + > + <.pill color={:indigo}> + Business + + +
    + <.tooltip enabled?={not @has_access_to_props?} centered?={true}> + <:tooltip_content> + To get access to this feature, -
    - -
    - - {if @use_custom_props, do: "Custom properties", else: "Add custom property"} - + <.toggle_switch id="add-custom-property" id_suffix={@suffix} @@ -554,8 +558,8 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do phx-target={@myself} disabled={not @has_access_to_props?} /> -
    - + +
    <.error :for={msg <- Enum.map(@f[:custom_props].errors, &translate_error/1)}> {msg} @@ -709,34 +713,38 @@ defmodule PlausibleWeb.Live.GoalSettings.Form do defp revenue_toggle(assigns) do ~H""" - <.tooltip enabled?={not @has_access_to_revenue_goals?}> - <:tooltip_content> -
    - To get access to this feature +
    +
    + + Enable revenue tracking + + <.link + :if={not @has_access_to_revenue_goals?} + href={Routes.billing_path(PlausibleWeb.Endpoint, :choose_plan)} + class="inline-block" + > + <.pill color={:indigo}> + Business + + +
    + <.tooltip enabled?={not @has_access_to_revenue_goals?} centered?={true}> + <:tooltip_content> + To get access to this feature, -
    - -
    - - Enable revenue tracking - + -
    - + +
    """ end diff --git a/lib/plausible_web/live/goal_settings/list.ex b/lib/plausible_web/live/goal_settings/list.ex index 38963977a4ff..59532a756a30 100644 --- a/lib/plausible_web/live/goal_settings/list.ex +++ b/lib/plausible_web/live/goal_settings/list.ex @@ -73,57 +73,26 @@ defmodule PlausibleWeb.Live.GoalSettings.List do <% has_unavailable_revenue? = not @revenue_goals_enabled? and not is_nil(goal.currency) %> <% has_unavailable_props? = not @props_available? and Plausible.Goal.has_custom_props?(goal) %> + <.goal_name_with_icons goal={goal} /> <%= if has_unavailable_revenue? or has_unavailable_props? do %> -
    {goal}
    - <.tooltip> + <.tooltip centered?={true}> <:tooltip_content>

    <%= if has_unavailable_revenue? do %> - Revenue Goals act like regular custom
    - events without a Business subscription
    + Revenue goals appear as regular goals on the dashboard. Upgrade to Business to see revenue data. <% else %> - Custom Properties on Goals require
    a Business subscription
    + Upgrade to Business to show goals with custom properties on the dashboard. <% end %>

    - - Upgrade Required - + <.styled_link + class="w-max flex items-center text-sm" + href={Routes.billing_path(PlausibleWeb.Endpoint, :choose_plan)} + > + Upgrade + <% else %> -
    - {goal} - <.tooltip :if={not Enum.empty?(goal.funnels)} centered?={true}> - <:tooltip_content> - Belongs to funnel - - - - <.tooltip :if={Plausible.Goal.has_custom_props?(goal)} centered?={true}> - <:tooltip_content> -
    -
    - {key} is {value} -
    -
    - - - - - - -
    <.goal_description goal={goal} />
    @@ -294,6 +263,45 @@ defmodule PlausibleWeb.Live.GoalSettings.List do end end + defp goal_name_with_icons(assigns) do + ~H""" +
    + {@goal} + <.tooltip :if={not Enum.empty?(@goal.funnels)} centered?={true}> + <:tooltip_content> + Belongs to funnel + + + + <.tooltip :if={Plausible.Goal.has_custom_props?(@goal)} centered?={true}> + <:tooltip_content> +
    +
    + {key} + is {value} +
    +
    + + + + + + +
    + """ + end + defp goal_editable?(goal, revenue_goals_enabled?, props_available?) do revenue_ok? = not is_nil(goal.currency) or revenue_goals_enabled? props_ok? = not Plausible.Goal.has_custom_props?(goal) or props_available?