Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 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
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
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 @@ -389,7 +398,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
</.styled_link>
to upgrade your subscription.
Expand All @@ -398,7 +407,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
Loading
Loading