diff --git a/assets/js/dashboard/site-context.test.tsx b/assets/js/dashboard/site-context.test.tsx index 183ec47b741e..d8329a39514e 100644 --- a/assets/js/dashboard/site-context.test.tsx +++ b/assets/js/dashboard/site-context.test.tsx @@ -15,6 +15,7 @@ describe('parseSiteFromDataset', () => { data-funnels-opted-out="false" data-props-opted-out="false" data-funnels-available="true" + data-exploration-available="false" data-site-segments-available="true" data-props-available="true" data-revenue-goals='[{"currency":"USD","display_name":"Purchase"}]' @@ -43,6 +44,7 @@ describe('parseSiteFromDataset', () => { propsOptedOut: false, funnelsAvailable: true, propsAvailable: true, + explorationAvailable: false, siteSegmentsAvailable: true, revenueGoals: [{ currency: 'USD', display_name: 'Purchase' }], funnels: [{ id: 1, name: 'From homepage to login', steps_count: 3 }], diff --git a/assets/js/dashboard/site-context.tsx b/assets/js/dashboard/site-context.tsx index ac6213b21c16..ada56d7a8128 100644 --- a/assets/js/dashboard/site-context.tsx +++ b/assets/js/dashboard/site-context.tsx @@ -8,6 +8,7 @@ export function parseSiteFromDataset(dataset: DOMStringMap): PlausibleSite { hasProps: dataset.hasProps === 'true', funnelsAvailable: dataset.funnelsAvailable === 'true', propsAvailable: dataset.propsAvailable === 'true', + explorationAvailable: dataset.explorationAvailable === 'true', siteSegmentsAvailable: dataset.siteSegmentsAvailable === 'true', conversionsOptedOut: dataset.conversionsOptedOut === 'true', funnelsOptedOut: dataset.funnelsOptedOut === 'true', @@ -36,6 +37,7 @@ export const siteContextDefaultValue = { hasGoals: false, hasProps: false, funnelsAvailable: false, + explorationAvailable: false, propsAvailable: false, siteSegmentsAvailable: false, conversionsOptedOut: false, diff --git a/assets/js/dashboard/stats/behaviours/index.js b/assets/js/dashboard/stats/behaviours/index.js index 0ca81e0b1f70..60e15a606ac2 100644 --- a/assets/js/dashboard/stats/behaviours/index.js +++ b/assets/js/dashboard/stats/behaviours/index.js @@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback } from 'react' import * as storage from '../../util/storage' import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning' import Properties from './props' +import { FunnelExploration } from '../exploration' import { FeatureSetupNotice } from '../../components/notice' import { hasConversionGoalFilter, @@ -290,6 +291,10 @@ function Behaviours({ importedDataInView, setMode, mode }) { } } + function renderExploration() { + return + } + function renderFunnels() { if (Funnel === null) { return featureUnavailable() @@ -380,6 +385,8 @@ function Behaviours({ importedDataInView, setMode, mode }) { return renderProps() case Mode.FUNNELS: return renderFunnels() + case Mode.EXPLORATION: + return renderExploration() } } @@ -518,6 +525,14 @@ function Behaviours({ importedDataInView, setMode, mode }) { Funnels ))} + {!site.isConsolidatedView && site.explorationAvailable && ( + + Exploration + + )} {isRealtime() && last 30min} {renderImportedQueryUnsupportedWarning()} diff --git a/assets/js/dashboard/stats/behaviours/modes-context.tsx b/assets/js/dashboard/stats/behaviours/modes-context.tsx index 5daf6c93ffb5..de7452fe47b1 100644 --- a/assets/js/dashboard/stats/behaviours/modes-context.tsx +++ b/assets/js/dashboard/stats/behaviours/modes-context.tsx @@ -5,7 +5,8 @@ import { UserContextValue, useUserContext } from '../../user-context' export enum Mode { CONVERSIONS = 'conversions', PROPS = 'props', - FUNNELS = 'funnels' + FUNNELS = 'funnels', + EXPLORATION = 'exploration' } export const MODES = { @@ -23,6 +24,11 @@ export const MODES = { title: 'Funnels', isAvailableKey: `${Mode.FUNNELS}Available`, optedOutKey: `${Mode.FUNNELS}OptedOut` + }, + [Mode.EXPLORATION]: { + title: 'Exploration', + isAvailableKey: null, // always available + optedOutKey: null } } as const @@ -48,7 +54,7 @@ function getInitiallyAvailableModes({ }): Mode[] { return Object.entries(MODES) .filter(([_, { isAvailableKey, optedOutKey }]) => { - const isOptedOut = site[optedOutKey] + const isOptedOut = optedOutKey ? site[optedOutKey] : false const isAvailable = isAvailableKey ? site[isAvailableKey] : true // If the feature is not supported by the site owner's subscription, diff --git a/assets/js/dashboard/stats/exploration.js b/assets/js/dashboard/stats/exploration.js new file mode 100644 index 000000000000..1b60e755f6bc --- /dev/null +++ b/assets/js/dashboard/stats/exploration.js @@ -0,0 +1,216 @@ +import React, { useState, useEffect } from 'react' +import * as api from '../api' +import * as url from '../util/url' +import { useDebounce } from '../custom-hooks' +import { useSiteContext } from '../site-context' +import { useDashboardStateContext } from '../dashboard-state-context' +import { numberShortFormatter } from '../util/number-formatter' + +const PAGE_FILTER_KEYS = ['page', 'entry_page', 'exit_page'] + +function fetchColumnData(site, dashboardState, steps, filter) { + // Page filters only apply to the first step — strip them for subsequent columns + const stateToUse = + steps.length > 0 + ? { + ...dashboardState, + filters: dashboardState.filters.filter( + ([_op, key]) => !PAGE_FILTER_KEYS.includes(key) + ) + } + : dashboardState + + const journey = [] + if (steps.length > 0) { + for (const s of steps) { + journey.push({ name: s.name, pathname: s.pathname }) + } + } + + return api.get(url.apiPath(site, '/exploration/next'), stateToUse, { + journey: JSON.stringify(journey), + search_term: filter + }) +} + +function ExplorationColumn({ + header, + steps, + selected, + onSelect, + dashboardState +}) { + const site = useSiteContext() + const [loading, setLoading] = useState(steps !== null) + const [results, setResults] = useState([]) + const [filter, setFilter] = useState('') + + const debouncedOnSearchInputChange = useDebounce((event) => + setFilter(event.target.value) + ) + + useEffect(() => { + if (steps === null) { + setFilter('') + setResults([]) + setLoading(false) + return + } + + setLoading(true) + setResults([]) + + fetchColumnData(site, dashboardState, steps, filter) + .then((response) => { + setResults(response || []) + }) + .catch(() => { + setResults([]) + }) + .finally(() => { + setLoading(false) + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dashboardState, steps, filter]) + + const maxVisitors = results.length > 0 ? results[0].visitors : 1 + + return ( +
+
+ + {header} + + {!selected && steps !== null && ( + + )} + {selected && ( + + )} +
+ + {loading ? ( +
+
+
+
+
+ ) : results.length === 0 ? ( +
+ {steps === null ? 'Select an event to continue' : 'No data'} +
+ ) : ( + + )} +
+ ) +} + +function columnHeader(index) { + if (index === 0) return 'Start' + return `${index} step${index === 1 ? '' : 's'} after` +} + +export function FunnelExploration() { + const { dashboardState } = useDashboardStateContext() + const [steps, setSteps] = useState([]) + + function handleSelect(columnIndex, selected) { + if (selected === null) { + setSteps(steps.slice(0, columnIndex)) + } else { + setSteps([...steps.slice(0, columnIndex), selected]) + } + } + + const numColumns = Math.max(steps.length + 1, 3) + + return ( +
+

+ Explore user journeys +

+
+ {Array.from({ length: numColumns }, (_, i) => ( + = i ? steps.slice(0, i) : null} + selected={steps[i] || null} + onSelect={(selected) => handleSelect(i, selected)} + dashboardState={dashboardState} + /> + ))} +
+
+ ) +} + +export default FunnelExploration diff --git a/assets/test-utils/app-context-providers.tsx b/assets/test-utils/app-context-providers.tsx index d6f2564d85f4..c0287a14d27f 100644 --- a/assets/test-utils/app-context-providers.tsx +++ b/assets/test-utils/app-context-providers.tsx @@ -29,6 +29,7 @@ export const DEFAULT_SITE: PlausibleSite = { hasGoals: false, hasProps: false, funnelsAvailable: false, + explorationAvailable: false, propsAvailable: false, siteSegmentsAvailable: false, conversionsOptedOut: false, diff --git a/extra/lib/plausible/stats/funnel.ex b/extra/lib/plausible/stats/funnel.ex index 7629dd595e03..81883944e510 100644 --- a/extra/lib/plausible/stats/funnel.ex +++ b/extra/lib/plausible/stats/funnel.ex @@ -11,6 +11,7 @@ defmodule Plausible.Stats.Funnel do import Ecto.Query import Plausible.Stats.SQL.Fragments + import Plausible.Stats.Util, only: [percentage: 2] alias Plausible.ClickhouseRepo alias Plausible.Stats.{Base, Query} @@ -167,24 +168,4 @@ defmodule Plausible.Stats.Funnel do |> elem(2) |> Enum.reverse() end - - defp percentage(x, y) when x in [0, nil] or y in [0, nil] do - "0" - end - - defp percentage(x, y) do - result = - x - |> Decimal.div(y) - |> Decimal.mult(100) - |> Decimal.round(2) - |> Decimal.to_string() - - case result do - <> -> compact - <> -> compact - <> -> compact - decimal -> decimal - end - end end diff --git a/lib/plausible/stats/exploration.ex b/lib/plausible/stats/exploration.ex new file mode 100644 index 000000000000..84ceaf1ae333 --- /dev/null +++ b/lib/plausible/stats/exploration.ex @@ -0,0 +1,244 @@ +defmodule Plausible.Stats.Exploration do + @moduledoc """ + Query logic for user journey exploration. + """ + + defmodule Journey.Step do + @moduledoc false + + @type t() :: %__MODULE__{} + + @derive {Jason.Encoder, only: [:name, :pathname]} + defstruct [:name, :pathname] + end + + import Ecto.Query + import Plausible.Stats.SQL.Fragments + import Plausible.Stats.Util, only: [percentage: 2] + + alias Plausible.ClickhouseRepo + alias Plausible.Stats.Base + alias Plausible.Stats.Query + + @type journey() :: [Journey.Step.t()] + + @type next_step() :: %{ + step: Journey.Step.t(), + visitors: pos_integer() + } + + @type funnel_step() :: %{ + step: Journey.Step.t(), + visitors: non_neg_integer(), + dropoff: non_neg_integer(), + dropoff_percentage: String.t() + } + + @spec next_steps(Query.t(), journey(), String.t()) :: + {:ok, [next_step()]} + def next_steps(query, journey, search_term \\ "") + + def next_steps(query, [], search_term) do + query + |> Base.base_event_query() + |> next_steps_first_query(search_term) + |> ClickhouseRepo.all() + |> then(&{:ok, &1}) + end + + def next_steps(query, journey, search_term) do + query + |> Base.base_event_query() + |> next_steps_query(journey, search_term) + |> ClickhouseRepo.all() + |> then(&{:ok, &1}) + end + + @spec journey_funnel(Query.t(), journey()) :: + {:ok, [funnel_step()]} | {:error, :empty_journey} + def journey_funnel(_query, []), do: {:error, :empty_journey} + + def journey_funnel(query, journey) do + query + |> Base.base_event_query() + |> journey_funnel_query(journey) + |> ClickhouseRepo.all() + |> to_funnel(journey) + |> then(&{:ok, &1}) + end + + defp next_steps_first_query(query, search_term) do + q_steps = steps_query(query, 1) + + from(s in subquery(q_steps), + where: selected_as(:next_name) != "", + select: %{ + step: %Journey.Step{ + name: selected_as(s.name1, :next_name), + pathname: selected_as(s.pathname1, :next_pathname) + }, + visitors: selected_as(scale_sample(fragment("uniq(?)", s.user_id)), :count) + }, + group_by: [selected_as(:next_name), selected_as(:next_pathname)], + order_by: [ + desc: selected_as(:count), + asc: selected_as(:next_pathname), + asc: selected_as(:next_name) + ], + limit: 10 + ) + |> maybe_search(search_term) + end + + defp next_steps_query(query, steps, search_term) do + next_step_idx = length(steps) + 1 + q_steps = steps_query(query, next_step_idx) + + next_name = :"name#{next_step_idx}" + next_pathname = :"pathname#{next_step_idx}" + + q_next = + from(s in subquery(q_steps), + # avoid cycling back to the beginning of the exploration + where: + selected_as(:next_name) != "" and + (selected_as(:next_name) != s.name1 or selected_as(:next_pathname) != s.pathname1), + select: %{ + step: %Journey.Step{ + name: selected_as(field(s, ^next_name), :next_name), + pathname: selected_as(field(s, ^next_pathname), :next_pathname) + }, + visitors: selected_as(scale_sample(fragment("uniq(?)", s.user_id)), :count) + }, + group_by: [selected_as(:next_name), selected_as(:next_pathname)], + order_by: [ + desc: selected_as(:count), + asc: selected_as(:next_pathname), + asc: selected_as(:next_name) + ], + limit: 10 + ) + |> maybe_search(search_term) + + steps + |> Enum.with_index() + |> Enum.reduce(q_next, fn {step, idx}, q -> + name = :"name#{idx + 1}" + pathname = :"pathname#{idx + 1}" + + from(s in q, + where: field(s, ^name) == ^step.name and field(s, ^pathname) == ^step.pathname + ) + end) + end + + defp journey_funnel_query(query, steps) do + q_steps = steps_query(query, length(steps)) + + [first_step | steps] = steps + + q_funnel = + from(s in subquery(q_steps), + where: s.name1 == ^first_step.name and s.pathname1 == ^first_step.pathname, + select: %{ + 1 => scale_sample(fragment("uniq(?)", s.user_id)) + } + ) + + steps + |> Enum.with_index() + |> Enum.reduce(q_funnel, fn {_step, idx}, q -> + current_steps = Enum.take(steps, idx + 1) + + step_conditions = + current_steps + |> Enum.with_index() + |> Enum.reduce(dynamic(true), fn {step, idx}, acc -> + step_condition = step_condition(step, idx + 2) + dynamic([q], fragment("? and ?", ^acc, ^step_condition)) + end) + + step_count = + dynamic([e], scale_sample(fragment("uniqIf(?, ?)", e.user_id, ^step_conditions))) + + from(e in q, select_merge: ^%{(idx + 2) => step_count}) + end) + end + + defp steps_query(query, steps) when is_integer(steps) do + q_steps = + from(e in query, + windows: [step_window: [partition_by: e.user_id, order_by: e.timestamp]], + select: %{ + user_id: e.user_id, + _sample_factor: e._sample_factor, + name1: e.name, + pathname1: e.pathname + }, + where: e.name != "engagement", + order_by: e.timestamp + ) + + if steps > 1 do + Enum.reduce(1..(steps - 1), q_steps, fn idx, q -> + from(e in q, + select_merge: %{ + ^:"name#{idx + 1}" => lead(e.name, ^idx) |> over(:step_window), + ^:"pathname#{idx + 1}" => lead(e.pathname, ^idx) |> over(:step_window) + } + ) + end) + else + q_steps + end + end + + defp step_condition(step, count) do + dynamic( + [s], + field(s, ^:"name#{count}") == ^step.name and + field(s, ^:"pathname#{count}") == ^step.pathname + ) + end + + defp maybe_search(query, search_term) do + case String.trim(search_term) do + term when byte_size(term) > 2 -> + from(s in query, + where: + ilike(selected_as(:next_name), ^"%#{term}%") or + ilike(selected_as(:next_pathname), ^"%#{term}%") + ) + + _ -> + query + end + end + + defp to_funnel([result], journey) do + journey + |> Enum.with_index() + |> Enum.reduce(%{funnel: [], visitors_at_previous: nil}, fn {step, idx}, acc -> + current_visitors = Map.get(result, idx + 1, 0) + + dropoff = + if acc.visitors_at_previous, do: acc.visitors_at_previous - current_visitors, else: 0 + + dropoff_percentage = percentage(dropoff, acc.visitors_at_previous) + + funnel = [ + %{ + step: step, + visitors: current_visitors, + dropoff: dropoff, + dropoff_percentage: dropoff_percentage + } + | acc.funnel + ] + + %{acc | funnel: funnel, visitors_at_previous: current_visitors} + end) + |> Map.fetch!(:funnel) + |> Enum.reverse() + end +end diff --git a/lib/plausible/stats/util.ex b/lib/plausible/stats/util.ex index cc02386dbef8..e866fdc3ef38 100644 --- a/lib/plausible/stats/util.ex +++ b/lib/plausible/stats/util.ex @@ -29,4 +29,24 @@ defmodule Plausible.Stats.Util do index = Enum.find_index(query.dimensions, &(&1 == dimension)) :"dim#{index}" end + + def percentage(x, y) when is_integer(x) and x > 0 and is_integer(y) and y > 0 do + result = + x + |> Decimal.div(y) + |> Decimal.mult(100) + |> Decimal.round(2) + |> Decimal.to_string() + + case result do + <> -> compact + <> -> compact + <> -> compact + decimal -> decimal + end + end + + def percentage(_x, _y) do + "0" + end end diff --git a/lib/plausible_web/controllers/api/stats_controller.ex b/lib/plausible_web/controllers/api/stats_controller.ex index 68b8e22d6de6..4711d1182e8e 100644 --- a/lib/plausible_web/controllers/api/stats_controller.ex +++ b/lib/plausible_web/controllers/api/stats_controller.ex @@ -9,6 +9,7 @@ defmodule PlausibleWeb.Api.StatsController do alias Plausible.Stats.{ Query, Comparisons, + Exploration, Filters, Time, TableDecider, @@ -25,6 +26,8 @@ defmodule PlausibleWeb.Api.StatsController do @revenue_metrics on_ee(do: Plausible.Stats.Goal.Revenue.revenue_metrics(), else: []) @not_set "(not set)" + plug PlausibleWeb.SuperAdminOnlyPlug when action in [:exploration_next, :exploration_funnel] + plug(:date_validation_plug when action not in [:query]) plug(:validate_required_filters_plug when action not in [:current_visitors]) @@ -332,6 +335,54 @@ defmodule PlausibleWeb.Api.StatsController do end end + def exploration_next(conn, %{"journey" => steps} = params) do + site = conn.assigns.site + search_term = params["search_term"] || "" + + with {:ok, journey} <- parse_journey(steps), + query = Query.from(site, params, debug_metadata: debug_metadata(conn)), + {:ok, next_steps} <- Exploration.next_steps(query, journey, search_term) do + json(conn, next_steps) + else + _ -> + bad_request(conn, "There was an error with your request") + end + end + + def exploration_funnel(conn, %{"journey" => steps} = params) do + site = conn.assigns.site + + with {:ok, journey} <- parse_journey(steps), + query = Query.from(site, params, debug_metadata: debug_metadata(conn)), + {:ok, funnel} <- Exploration.journey_funnel(query, journey) do + json(conn, funnel) + else + {:error, :empty_journey} -> + bad_request( + conn, + "We are unable to show funnels when journey is empty", + %{ + level: :normal + } + ) + end + end + + defp parse_journey(input) when is_binary(input) do + input + |> Jason.decode!() + |> Enum.map(&parse_journey_step/1) + |> Enum.reject(&is_nil/1) + |> then(&{:ok, &1}) + end + + defp parse_journey_step(%{"name" => name, "pathname" => pathname}) do + %Exploration.Journey.Step{ + name: name, + pathname: pathname + } + end + on_ee do def funnel(conn, %{"id" => funnel_id} = params) do site = Plausible.Repo.preload(conn.assigns.site, :team) @@ -839,51 +890,55 @@ defmodule PlausibleWeb.Api.StatsController do results |> transform_keys(%{country: :code}) - if params["csv"] do - countries = - countries - |> Enum.map(fn country -> - country_info = get_country(country[:code]) - Map.put(country, :name, country_info.name) - end) + countries_response(conn, query, meta, countries, !!params["csv"]) + end - if toplevel_goal_filter?(query) do - countries - |> transform_keys(%{visitors: :conversions}) - |> to_csv([:name, :conversions, :conversion_rate]) - else - countries |> to_csv([:name, :visitors]) - end - else - countries = - Enum.map(countries, fn row -> - country = get_country(row[:code]) - - if country do - Map.merge(row, %{ - name: country.name, - flag: country.flag, - alpha_3: country.alpha_3, - code: country.alpha_2 - }) - else - Map.merge(row, %{ - name: row[:code], - flag: "", - alpha_3: "", - code: "" - }) - end - end) + defp countries_response(_conn, query, _meta, countries, true = _csv?) do + countries = + countries + |> Enum.map(fn country -> + country_info = get_country(country[:code]) + Map.put(country, :name, country_info.name) + end) - json(conn, %{ - results: countries, - meta: Stats.Breakdown.formatted_date_ranges(query), - skip_imported_reason: meta[:imports_skip_reason] - }) + if toplevel_goal_filter?(query) do + countries + |> transform_keys(%{visitors: :conversions}) + |> to_csv([:name, :conversions, :conversion_rate]) + else + countries |> to_csv([:name, :visitors]) end end + defp countries_response(conn, query, meta, countries, _csv?) do + countries = + Enum.map(countries, fn row -> + country = get_country(row[:code]) + + if country do + Map.merge(row, %{ + name: country.name, + flag: country.flag, + alpha_3: country.alpha_3, + code: country.alpha_2 + }) + else + Map.merge(row, %{ + name: row[:code], + flag: "", + alpha_3: "", + code: "" + }) + end + end) + + json(conn, %{ + results: countries, + meta: Stats.Breakdown.formatted_date_ranges(query), + skip_imported_reason: meta[:imports_skip_reason] + }) + end + def regions(conn, params) do site = conn.assigns[:site] params = Map.put(params, "property", "visit:region") diff --git a/lib/plausible_web/controllers/stats_controller.ex b/lib/plausible_web/controllers/stats_controller.ex index d2c7b43f5cba..92bb86fc2682 100644 --- a/lib/plausible_web/controllers/stats_controller.ex +++ b/lib/plausible_web/controllers/stats_controller.ex @@ -63,6 +63,8 @@ defmodule PlausibleWeb.StatsController do consolidated_view? = Plausible.Sites.consolidated?(site) + exploration_available? = Plausible.Auth.is_super_admin?(current_user) + consolidated_view_available? = on_ee(do: Plausible.ConsolidatedView.ok_to_display?(site.team), else: false) @@ -94,12 +96,13 @@ defmodule PlausibleWeb.StatsController do title: title(conn, site), demo: demo, flags: flags, - is_dbip: is_dbip(), + dbip?: dbip?(), segments: segments, load_dashboard_js: true, hide_footer?: if(ce?() || demo, do: false, else: site_role != :public), consolidated_view?: consolidated_view?, consolidated_view_available?: consolidated_view_available?, + exploration_available?: exploration_available?, team_identifier: team_identifier, limited_to_segment_id: nil ) @@ -471,6 +474,8 @@ defmodule PlausibleWeb.StatsController do flags = get_flags(current_user, shared_link.site) + exploration_available? = Plausible.Auth.is_super_admin?(current_user) + limited_to_segment_id = if Plausible.Site.SharedLink.limited_to_segment?(shared_link) do shared_link.segment.id @@ -520,13 +525,14 @@ defmodule PlausibleWeb.StatsController do background: conn.params["background"], theme: conn.params["theme"], flags: flags, - is_dbip: is_dbip(), + dbip?: dbip?(), segments: segments, load_dashboard_js: true, hide_footer?: if(ce?(), do: embedded?, else: embedded? || site_role != :public), # no shared links for consolidated views consolidated_view?: false, consolidated_view_available?: false, + exploration_available?: exploration_available?, team_identifier: team_identifier, limited_to_segment_id: limited_to_segment_id ) @@ -546,7 +552,7 @@ defmodule PlausibleWeb.StatsController do end) |> Map.new() - defp is_dbip() do + defp dbip?() do on_ee do false else diff --git a/lib/plausible_web/router.ex b/lib/plausible_web/router.ex index b7ce2e7885c4..d353f2838033 100644 --- a/lib/plausible_web/router.ex +++ b/lib/plausible_web/router.ex @@ -282,6 +282,9 @@ defmodule PlausibleWeb.Router do get "/:domain/funnels/:id", StatsController, :funnel end + get "/:domain/exploration/next", StatsController, :exploration_next + get "/:domain/exploration/funnel", StatsController, :exploration_funnel + scope private: %{allow_consolidated_views: true} do post "/:domain/query", StatsController, :query get "/:domain/current-visitors", StatsController, :current_visitors diff --git a/lib/plausible_web/templates/stats/stats.html.heex b/lib/plausible_web/templates/stats/stats.html.heex index 04d1e8d36e6b..8a30d8cb7052 100644 --- a/lib/plausible_web/templates/stats/stats.html.heex +++ b/lib/plausible_web/templates/stats/stats.html.heex @@ -39,7 +39,7 @@ data-shared-link-auth={assigns[:shared_link_auth]} data-embedded={to_string(@conn.assigns[:embedded])} data-background={@conn.assigns[:background]} - data-is-dbip={to_string(@is_dbip)} + data-is-dbip={to_string(@dbip?)} data-current-user-role={@site_role} data-current-user-id={ if user = @conn.assigns[:current_user], do: user.id, else: Jason.encode!(nil) @@ -51,6 +51,7 @@ } data-is-consolidated-view={Jason.encode!(@consolidated_view?)} data-consolidated-view-available={Jason.encode!(@consolidated_view_available?)} + data-exploration-available={Jason.encode!(@exploration_available?)} data-team-identifier={@team_identifier} data-limited-to-segment-id={Jason.encode!(@limited_to_segment_id)} > diff --git a/test/plausible/stats/exploration_test.exs b/test/plausible/stats/exploration_test.exs new file mode 100644 index 000000000000..89765172362b --- /dev/null +++ b/test/plausible/stats/exploration_test.exs @@ -0,0 +1,201 @@ +defmodule Plausible.Stats.ExplorationTest do + use Plausible.DataCase + + alias Plausible.Stats.Exploration + alias Plausible.Stats.QueryBuilder + + setup do + site = new_site() + + now = DateTime.utc_now() + + populate_stats(site, [ + build(:pageview, + user_id: 123, + pathname: "/home", + browser: "Chrome", + timestamp: DateTime.shift(now, minute: -300) + ), + build(:pageview, + user_id: 123, + pathname: "/login", + browser: "Chrome", + timestamp: DateTime.shift(now, minute: -270) + ), + build(:pageview, + user_id: 123, + pathname: "/home", + browser: "Chrome", + timestamp: DateTime.shift(now, minute: -30) + ), + build(:pageview, + user_id: 123, + pathname: "/login", + browser: "Chrome", + timestamp: DateTime.shift(now, minute: -25) + ), + build(:pageview, + user_id: 123, + pathname: "/logout", + browser: "Chrome", + timestamp: DateTime.shift(now, minute: -20) + ), + build(:pageview, + user_id: 124, + pathname: "/home", + browser: "Firefox", + timestamp: DateTime.shift(now, minute: -30) + ), + build(:pageview, + user_id: 124, + pathname: "/login", + browser: "Firefox", + timestamp: DateTime.shift(now, minute: -25) + ), + build(:pageview, + user_id: 124, + pathname: "/docs", + browser: "Firefox", + timestamp: DateTime.shift(now, minute: -20) + ), + build(:pageview, + user_id: 124, + pathname: "/logout", + browser: "Firefox", + timestamp: DateTime.shift(now, minute: -15) + ) + ]) + + {:ok, site: site} + end + + describe "journey_funnel" do + test "queries 3-step journey", %{site: site} do + query = QueryBuilder.build!(site, input_date_range: :all) + + journey = [ + %Exploration.Journey.Step{name: "pageview", pathname: "/home"}, + %Exploration.Journey.Step{name: "pageview", pathname: "/login"}, + %Exploration.Journey.Step{name: "pageview", pathname: "/logout"} + ] + + assert {:ok, [step1, step2, step3]} = Exploration.journey_funnel(query, journey) + + assert step1.step.pathname == "/home" + assert step1.visitors == 2 + assert step1.dropoff == 0 + assert step1.dropoff_percentage == "0" + assert step2.step.pathname == "/login" + assert step2.visitors == 2 + assert step2.dropoff == 0 + assert step2.dropoff_percentage == "0" + assert step3.step.pathname == "/logout" + assert step3.visitors == 1 + assert step3.dropoff == 1 + assert step3.dropoff_percentage == "50" + end + + test "respects filters in the query", %{site: site} do + query = + QueryBuilder.build!(site, + input_date_range: :all, + filters: [[:is, "visit:browser", ["Firefox"]]] + ) + + journey = [ + %Exploration.Journey.Step{name: "pageview", pathname: "/home"}, + %Exploration.Journey.Step{name: "pageview", pathname: "/login"}, + %Exploration.Journey.Step{name: "pageview", pathname: "/logout"} + ] + + assert {:ok, [step1, step2, step3]} = Exploration.journey_funnel(query, journey) + + assert step1.step.pathname == "/home" + assert step1.visitors == 1 + assert step1.dropoff == 0 + assert step1.dropoff_percentage == "0" + assert step2.step.pathname == "/login" + assert step2.visitors == 1 + assert step2.dropoff == 0 + assert step2.dropoff_percentage == "0" + assert step3.step.pathname == "/logout" + assert step3.visitors == 0 + assert step3.dropoff == 1 + assert step3.dropoff_percentage == "100" + end + + test "returns error on empty journey", %{site: site} do + query = QueryBuilder.build!(site, input_date_range: :all) + + assert {:error, :empty_journey} = Exploration.journey_funnel(query, []) + end + end + + describe "next_steps" do + test "suggests the next step for a 2-step journey", %{site: site} do + query = QueryBuilder.build!(site, input_date_range: :all) + + journey = [ + %Exploration.Journey.Step{name: "pageview", pathname: "/home"}, + %Exploration.Journey.Step{name: "pageview", pathname: "/login"} + ] + + assert {:ok, [next_step1, next_step2]} = Exploration.next_steps(query, journey) + + assert next_step1.step.pathname == "/docs" + assert next_step1.visitors == 1 + assert next_step2.step.pathname == "/logout" + assert next_step2.visitors == 1 + end + + test "suggests the first step in the journey", %{site: site} do + query = QueryBuilder.build!(site, input_date_range: :all) + + assert {:ok, [next_step1, next_step2, next_step3, next_step4]} = + Exploration.next_steps(query, []) + + assert next_step1.step.pathname == "/home" + assert next_step1.visitors == 2 + assert next_step2.step.pathname == "/login" + assert next_step2.visitors == 2 + assert next_step3.step.pathname == "/logout" + assert next_step3.visitors == 2 + assert next_step4.step.pathname == "/docs" + assert next_step4.visitors == 1 + end + + test "respects filters in the query", %{site: site} do + query = + QueryBuilder.build!(site, + input_date_range: :all, + filters: [[:is, "visit:browser", ["Firefox"]]] + ) + + assert {:ok, [next_step1, next_step2, next_step3, next_step4]} = + Exploration.next_steps(query, []) + + assert next_step1.step.pathname == "/docs" + assert next_step1.visitors == 1 + assert next_step2.step.pathname == "/home" + assert next_step2.visitors == 1 + assert next_step3.step.pathname == "/login" + assert next_step3.visitors == 1 + assert next_step4.step.pathname == "/logout" + assert next_step4.visitors == 1 + end + + test "allows to filter the next step suggestions", %{site: site} do + query = QueryBuilder.build!(site, input_date_range: :all) + + journey = [ + %Exploration.Journey.Step{name: "pageview", pathname: "/home"}, + %Exploration.Journey.Step{name: "pageview", pathname: "/login"} + ] + + assert {:ok, [next_step]} = Exploration.next_steps(query, journey, "doc") + + assert next_step.step.pathname == "/docs" + assert next_step.visitors == 1 + end + end +end diff --git a/test/plausible_web/controllers/api/stats_controller/exploration_test.exs b/test/plausible_web/controllers/api/stats_controller/exploration_test.exs new file mode 100644 index 000000000000..0b7b71edffe2 --- /dev/null +++ b/test/plausible_web/controllers/api/stats_controller/exploration_test.exs @@ -0,0 +1,135 @@ +defmodule PlausibleWeb.Api.StatsController.ExplorationTest do + use PlausibleWeb.ConnCase, async: false + use Plausible + + on_ee do + setup [:create_user, :log_in, :create_site] + + setup %{user: user, site: site} do + patch_env(:super_admin_user_ids, [user.id]) + + now = DateTime.utc_now() + + populate_stats(site, [ + build(:pageview, + user_id: 123, + pathname: "/home", + timestamp: DateTime.shift(now, minute: -300) + ), + build(:pageview, + user_id: 123, + pathname: "/login", + timestamp: DateTime.shift(now, minute: -270) + ), + build(:pageview, + user_id: 123, + pathname: "/home", + timestamp: DateTime.shift(now, minute: -30) + ), + build(:pageview, + user_id: 123, + pathname: "/login", + timestamp: DateTime.shift(now, minute: -25) + ), + build(:pageview, + user_id: 123, + pathname: "/logout", + timestamp: DateTime.shift(now, minute: -20) + ), + build(:pageview, + user_id: 124, + pathname: "/home", + timestamp: DateTime.shift(now, minute: -30) + ), + build(:pageview, + user_id: 124, + pathname: "/login", + timestamp: DateTime.shift(now, minute: -25) + ), + build(:pageview, + user_id: 124, + pathname: "/docs", + timestamp: DateTime.shift(now, minute: -20) + ), + build(:pageview, + user_id: 124, + pathname: "/logout", + timestamp: DateTime.shift(now, minute: -15) + ) + ]) + + {:ok, site: site} + end + + describe "exploration_next/2" do + test "it works", %{conn: conn, site: site} do + journey = + Jason.encode!([ + %{name: "pageview", pathname: "/home"}, + %{name: "pageview", pathname: "/login"} + ]) + + resp = + conn + |> get("/api/stats/#{site.domain}/exploration/next/?journey=#{journey}&period=24h") + |> json_response(200) + + assert [next_step1, next_step2] = resp + assert next_step1["step"]["pathname"] == "/docs" + assert next_step1["visitors"] == 1 + assert next_step2["step"]["pathname"] == "/logout" + assert next_step2["visitors"] == 1 + end + + test "it filters", %{conn: conn, site: site} do + journey = + Jason.encode!([ + %{name: "pageview", pathname: "/home"}, + %{name: "pageview", pathname: "/login"} + ]) + + resp = + conn + |> get( + "/api/stats/#{site.domain}/exploration/next/?journey=#{journey}&search_term=doc&period=24h" + ) + |> json_response(200) + + assert [next_step] = resp + assert next_step["step"]["pathname"] == "/docs" + assert next_step["visitors"] == 1 + end + end + + describe "exploration_funnel/2" do + test "it works", %{conn: conn, site: site} do + journey = + Jason.encode!([ + %{name: "pageview", pathname: "/home"}, + %{name: "pageview", pathname: "/login"}, + %{name: "pageview", pathname: "/logout"} + ]) + + resp = + conn + |> get("/api/stats/#{site.domain}/exploration/funnel/?journey=#{journey}&period=24h") + |> json_response(200) + + assert [step1, step2, step3] = resp + + assert step1["step"]["pathname"] == "/home" + assert step1["visitors"] == 2 + assert step1["dropoff"] == 0 + assert step1["dropoff_percentage"] == "0" + assert step2["step"]["pathname"] == "/login" + assert step2["visitors"] == 2 + assert step2["dropoff"] == 0 + assert step2["dropoff_percentage"] == "0" + assert step3["step"]["pathname"] == "/logout" + assert step3["visitors"] == 1 + assert step3["dropoff"] == 1 + assert step3["dropoff_percentage"] == "50" + end + end + end +end diff --git a/test/plausible_web/controllers/stats_controller_test.exs b/test/plausible_web/controllers/stats_controller_test.exs index 504ee6ca2c85..6ba76a4e3236 100644 --- a/test/plausible_web/controllers/stats_controller_test.exs +++ b/test/plausible_web/controllers/stats_controller_test.exs @@ -22,6 +22,7 @@ defmodule PlausibleWeb.StatsControllerTest do assert text_of_attr(resp, @react_container, "data-props-available") == "true" assert text_of_attr(resp, @react_container, "data-site-segments-available") == "true" assert text_of_attr(resp, @react_container, "data-funnels-available") == "true" + assert text_of_attr(resp, @react_container, "data-exploration-available") == "false" assert text_of_attr(resp, @react_container, "data-has-props") == "false" assert text_of_attr(resp, @react_container, "data-logged-in") == "false" assert text_of_attr(resp, @react_container, "data-current-user-role") == "public" @@ -147,6 +148,23 @@ defmodule PlausibleWeb.StatsControllerTest do assert text_of_attr(resp, @react_container, "data-logged-in") == "true" end + test "non-superadmin can't see exploration funnel UI", %{conn: conn, site: site} do + populate_stats(site, [build(:pageview)]) + conn = get(conn, "/" <> site.domain) + resp = html_response(conn, 200) + assert text_of_attr(resp, @react_container, "data-exploration-available") == "false" + end + + on_ee do + test "superadmin can see exploration funnel UI", %{conn: conn, site: site, user: user} do + patch_env(:super_admin_user_ids, [user.id]) + populate_stats(site, [build(:pageview)]) + conn = get(conn, "/" <> site.domain) + resp = html_response(conn, 200) + assert text_of_attr(resp, @react_container, "data-exploration-available") == "true" + end + end + on_ee do test "first view of a consolidated dashboard sets stats_start_date and native_stats_start_at according to native_stats_start_at of the earliest team site", %{