Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b277f2f
Add static custom property UI to goal settings form
sanne-san Nov 26, 2025
185dfa9
Implement custom property suggestions for goals
aerosol Jan 5, 2026
c201eac
Implement PropertyPairInput live component
aerosol Jan 5, 2026
571b3ce
Implement PropertyPairs live component
aerosol Jan 5, 2026
7869e5e
Basic integration of custom properties in goal settings form
aerosol Jan 5, 2026
92963dc
Add icon to list indicating custom properties on goals
sanne-san Jan 6, 2026
e6d043a
Ensure dashboard filtering by goal w/ props = filtering by goal+props
aerosol Jan 6, 2026
79098c7
Fixup 0f709634b5
aerosol Jan 6, 2026
8313207
Seed some goals w/ props + funnels
aerosol Jan 6, 2026
acf682c
Fixup test
aerosol Jan 6, 2026
498a74d
Another take at equivalence
aerosol Jan 7, 2026
75b13bf
Allow overflow of combobox dropdown to max the width of the property …
sanne-san Jan 7, 2026
b11aa25
Fix formatting
sanne-san Jan 7, 2026
503638a
Add include_goals_with_custom_props? option to Goals.for_site_query
aerosol Jan 12, 2026
ccdcab1
Add server-side mode support to toggle_switch component
aerosol Jan 12, 2026
fb40de8
Gate custom props toggle on Props feature availability
aerosol Jan 12, 2026
1ec3285
Apply Props feature gating to goal queries and API
aerosol Jan 12, 2026
ff04797
Show upgrade required badge for goals with custom props
aerosol Jan 12, 2026
fcc5cb9
Add tests for custom props feature gating on goals
aerosol Jan 12, 2026
f86872f
Format
aerosol Jan 12, 2026
0daac2f
Improve upgrade call to action styles and content
sanne-san Jan 14, 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
8 changes: 8 additions & 0 deletions lib/plausible/goal.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
"/" <> _ ->
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
26 changes: 22 additions & 4 deletions lib/plausible/stats/goals.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -200,7 +205,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 +220,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 +237,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
13 changes: 10 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,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]
Expand Down Expand Up @@ -389,7 +396,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 @@ -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.
Expand Down
64 changes: 50 additions & 14 deletions lib/plausible_web/components/generic.ex
Original file line number Diff line number Diff line change
Expand Up @@ -438,39 +438,74 @@ 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

attr(:rest, :global)

@doc """
Renders toggle input.
Needs `:js_active_var` that controls toggle state.
Set this outside this component with `x-data="{ <variable name>: <initial state> }"`.

### 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="{ <variable name>: <initial state> }"`.

2. Server-side mode: Pass `:checked` boolean and `phx-click` event handler.

### Examples - Alpine JS mode
```
<div x-data="{ showGoals: false }>
<.toggle_switch id="show_goals" js_active_var="showGoals" />
</div>
```
```

### 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"""
<button
id={"#{@id}-#{@id_suffix}"}
class={["h-6", if(@disabled, do: "cursor-not-allowed", else: "cursor-pointer")]}
aria-labelledby={@id}
role="switch"
type="button"
x-on:click={"#{@js_active_var} = !#{@js_active_var}"}
x-bind:aria-checked={@js_active_var}
x-on:click={if(!@server_mode? && @js_active_var, do: "#{@js_active_var} = !#{@js_active_var}")}
x-bind:aria-checked={if(!@server_mode? && @js_active_var, do: @js_active_var)}
aria-checked={if(@server_mode?, do: to_string(@checked))}
disabled={@disabled}
{@rest}
>
<span
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={@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(@disabled, do: "opacity-50")
]}
>
<span
aria-hidden="true"
class={[
"pointer-events-none inline-block size-5 transform rounded-full bg-white shadow-sm ring-0 transition duration-200 ease-in-out",
if(@checked, do: "dark:bg-white translate-x-5", else: "dark:bg-white translate-x-0")
]}
/>
</span>
<span
: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(@disabled, do: "opacity-50")
]}
x-bind:class={"#{@js_active_var} ? 'bg-indigo-600' : 'dark:bg-gray-600 bg-gray-200'"}
>
<span
Expand Down Expand Up @@ -582,12 +617,12 @@ defmodule PlausibleWeb.Components.Generic do
current_team={@current_team}
site={@site}
>
<div class="p-6">
<div class="p-4 sm:p-6">
{render_slot(@inner_block)}
</div>
</PlausibleWeb.Components.Billing.feature_gate>
<% else %>
<div class="p-6">
<div class="p-4 sm:p-6">
{render_slot(@inner_block)}
</div>
<% end %>
Expand Down Expand Up @@ -615,7 +650,7 @@ defmodule PlausibleWeb.Components.Generic do
"top-0",
"-translate-y-full",
"z-[1000]",
"sm:max-w-72",
"sm:max-w-64",
"w-max"
]

Expand Down Expand Up @@ -653,7 +688,7 @@ defmodule PlausibleWeb.Components.Generic do
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
>
<div class="bg-gray-800 text-white rounded-sm px-2.5 py-1.5 text-xs font-medium">
<div class="bg-gray-800 text-white rounded-sm px-2.5 py-1.5 text-xs font-medium whitespace-normal">
{render_slot(@tooltip_content)}
</div>
</div>
Expand Down Expand Up @@ -854,7 +889,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",
Expand Down Expand Up @@ -1004,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
]

Expand Down Expand Up @@ -1220,7 +1256,7 @@ defmodule PlausibleWeb.Components.Generic do
~H"""
<span
class={[
"inline-flex items-center text-xs font-medium py-1 px-2 rounded-md",
"inline-flex items-center text-xs font-medium py-[3px] px-[7px] rounded-md",
@color_classes,
@class
]}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading