Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
87e86e9
Add static custom property UI to goal settings form
sanne-san Nov 26, 2025
00a7d65
Implement custom property suggestions for goals
aerosol Jan 5, 2026
5fd93a1
Implement PropertyPairInput live component
aerosol Jan 5, 2026
4deec5d
Implement PropertyPairs live component
aerosol Jan 5, 2026
a54502f
Basic integration of custom properties in goal settings form
aerosol Jan 5, 2026
b2ba446
Add icon to list indicating custom properties on goals
sanne-san Jan 6, 2026
9891a2d
Ensure dashboard filtering by goal w/ props = filtering by goal+props
aerosol Jan 6, 2026
14f9f42
Fixup 0f709634b5
aerosol Jan 6, 2026
1071ffa
Seed some goals w/ props + funnels
aerosol Jan 6, 2026
c717723
Fixup test
aerosol Jan 6, 2026
2600cb0
Another take at equivalence
aerosol Jan 7, 2026
acc7620
Allow overflow of combobox dropdown to max the width of the property …
sanne-san Jan 7, 2026
ba1e17e
Fix formatting
sanne-san Jan 7, 2026
85ee459
Add include_goals_with_custom_props? option to Goals.for_site_query
aerosol Jan 12, 2026
1697779
Add server-side mode support to toggle_switch component
aerosol Jan 12, 2026
8675ea6
Gate custom props toggle on Props feature availability
aerosol Jan 12, 2026
1f0ae18
Apply Props feature gating to goal queries and API
aerosol Jan 12, 2026
b02b36f
Show upgrade required badge for goals with custom props
aerosol Jan 12, 2026
0c8e45a
Add tests for custom props feature gating on goals
aerosol Jan 12, 2026
54dda5d
Format
aerosol Jan 12, 2026
d8fdc6b
Improve upgrade call to action styles and content
sanne-san Jan 14, 2026
c0a4360
!fixup compiler warnings
aerosol Jan 19, 2026
0e56cbb
Fixup tests and fix logic for determining goal editable
aerosol Jan 19, 2026
31af72b
goals editable always on CE
aerosol Jan 19, 2026
4a39634
Unused bindings
aerosol Jan 19, 2026
992cd2f
Prevent currency change on existing revenue goals
aerosol Jan 20, 2026
6dbd9f2
Merge branch 'master' into feat-preview-custom-props-on-goals
aerosol Jan 21, 2026
6919d75
Merge remote-tracking branch 'origin/master' into feat-preview-custom…
aerosol Jan 26, 2026
be7b5f0
Make test more concise
aerosol Jan 26, 2026
bcd5b3a
Assert exact lists
aerosol Jan 26, 2026
3b6388f
Fixup equivalence tests
aerosol Jan 26, 2026
3ad495c
Don't pipe sigle argument
aerosol Jan 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions lib/plausible/goal.ex
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ defmodule Plausible.Goal do
message: "cannot co-exist with page_path"
)
|> maybe_drop_currency()
|> prevent_currency_change()
end

@spec display_name(t()) :: String.t()
Expand All @@ -87,6 +88,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
"/" <> _ ->
Expand Down Expand Up @@ -160,6 +169,14 @@ defmodule Plausible.Goal do
end
end

defp prevent_currency_change(changeset) do
if (ee?() and changeset.data.id) && Map.has_key?(changeset.changes, :currency) do
add_error(changeset, :currency, "cannot change currency of existing goal")
else
changeset
end
end

defp maybe_put_display_name(changeset) do
clause =
Enum.map([:display_name, :page_path, :event_name], &get_field(changeset, &1))
Expand Down
7 changes: 7 additions & 0 deletions lib/plausible/goals/goals.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
5 changes: 4 additions & 1 deletion lib/plausible/stats/filter_suggestions.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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?(
Expand Down
84 changes: 84 additions & 0 deletions lib/plausible/stats/goal_suggestions.ex
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
23 changes: 19 additions & 4 deletions lib/plausible/stats/goals.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ 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 = Plausible.Goals.for_site(site, include_goals_with_custom_props?: props_available?)

%{
# When grouping by event:goal, later pipeline needs to know which goals match filters exactly.
Expand Down Expand Up @@ -200,7 +202,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
Expand All @@ -215,7 +217,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
Expand All @@ -225,8 +234,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
Expand Down
15 changes: 12 additions & 3 deletions lib/plausible_web/components/billing/billing.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}
/>
</span>
</div>
</div>
Expand Down Expand Up @@ -357,6 +362,10 @@ defmodule PlausibleWeb.Components.Billing do

defp change_plan_or_upgrade_text(_subscription), do: "Change plan"

attr :link_class, :string, default: ""
attr :current_team, :any, required: true
attr :current_user, :atom, required: true

def upgrade_call_to_action(assigns) do
user = assigns.current_user
site = assigns[:site]
Expand Down Expand Up @@ -394,7 +403,7 @@ defmodule PlausibleWeb.Components.Billing do
upgrade_assistance_required? ->
~H"""
contact
<.styled_link href="mailto:[email protected]" class="font-medium">
<.styled_link href="mailto:[email protected]" class={"font-medium " <> @link_class}>
[email protected]
</.styled_link>
to upgrade your subscription.
Expand All @@ -403,7 +412,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.
Expand Down
Loading
Loading