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/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), 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/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/lib/plausible/stats/goals.ex b/lib/plausible/stats/goals.ex index d7f803f64efa..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. @@ -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 @@ -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 @@ -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 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 c4407ba58561..3f14b6a69312 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"""