diff --git a/assets/js/liveview/dashboard_tabs.js b/assets/js/liveview/dashboard_tabs.js index 006cf52c3838..476fbc9ebf24 100644 --- a/assets/js/liveview/dashboard_tabs.js +++ b/assets/js/liveview/dashboard_tabs.js @@ -16,19 +16,19 @@ export default buildHook({ this.addListener('click', this.el, (e) => { const button = e.target.closest('button') - const tab = button && button.dataset.tab + const tabKey = button && button.dataset.tabKey const span = button && button.querySelector('span') if (span && span.dataset.active === 'false') { - const label = button.dataset.label + const reportLabel = button.dataset.reportLabel const storageKey = button.dataset.storageKey const target = button.dataset.target const tile = this.el.closest('[data-tile]') const title = tile.querySelector('[data-title]') - title.innerText = label + title.innerText = reportLabel - this.el.querySelectorAll(`button[data-tab] span`).forEach((s) => { + this.el.querySelectorAll(`button[data-tab-key] span`).forEach((s) => { this.js().setAttribute(s, 'data-active', 'false') }) @@ -39,10 +39,10 @@ export default buildHook({ ) if (storageKey) { - localStorage.setItem(`${storageKey}__${domain}`, tab) + localStorage.setItem(`${storageKey}__${domain}`, tabKey) } - this.pushEventTo(target, 'set-tab', { tab: tab }) + this.pushEventTo(target, 'set-tab', { tab: tabKey }) } }) } diff --git a/assets/js/liveview/live_socket.js b/assets/js/liveview/live_socket.js index 627eaff7a44e..60ae66a0cd81 100644 --- a/assets/js/liveview/live_socket.js +++ b/assets/js/liveview/live_socket.js @@ -79,7 +79,12 @@ if (csrfToken && websocketUrl) { // The prefs are used by dashboard LiveView to persist // user preferences across the reloads. user_prefs: { - pages_tab: localStorage.getItem(`pageTab__${domainName}`) + pages_tab: localStorage.getItem(`pageTab__${domainName}`), + period: localStorage.getItem(`period__${domainName}`), + comparison: localStorage.getItem(`comparison_mode__${domainName}`), + match_day_of_week: localStorage.getItem( + `comparison_match_day_of_week__${domainName}` + ) }, _csrf_token: token } diff --git a/lib/plausible/stats/api_query_parser.ex b/lib/plausible/stats/api_query_parser.ex index f285a5749e2a..e16dccf1907f 100644 --- a/lib/plausible/stats/api_query_parser.ex +++ b/lib/plausible/stats/api_query_parser.ex @@ -13,7 +13,8 @@ defmodule Plausible.Stats.ApiQueryParser do trim_relative_date_range: false, compare: nil, compare_match_day_of_week: false, - legacy_time_on_page_cutoff: nil + legacy_time_on_page_cutoff: nil, + dashboard_metric_labels: false } def default_include(), do: @default_include diff --git a/lib/plausible/stats/dashboard_query_parser.ex b/lib/plausible/stats/dashboard_query_parser.ex index 75875287f736..4b95d9fe9163 100644 --- a/lib/plausible/stats/dashboard_query_parser.ex +++ b/lib/plausible/stats/dashboard_query_parser.ex @@ -14,12 +14,13 @@ defmodule Plausible.Stats.DashboardQueryParser do # might still want to know whether imported data can be toggled # on/off on the dashboard. imports_meta: true, - time_labels: true, + time_labels: false, total_rows: false, trim_relative_date_range: true, compare: nil, compare_match_day_of_week: true, - legacy_time_on_page_cutoff: nil + legacy_time_on_page_cutoff: nil, + dashboard_metric_labels: true } def default_include(), do: @default_include @@ -28,22 +29,47 @@ defmodule Plausible.Stats.DashboardQueryParser do def default_pagination(), do: @default_pagination - def parse(query_string, defaults \\ %{}) when is_binary(query_string) do + @valid_period_shorthands %{ + "realtime" => :realtime, + "day" => :day, + "month" => :month, + "year" => :year, + "all" => :all, + "7d" => {:last_n_days, 7}, + "28d" => {:last_n_days, 28}, + "30d" => {:last_n_days, 30}, + "91d" => {:last_n_days, 91}, + "6mo" => {:last_n_months, 6}, + "12mo" => {:last_n_months, 12} + } + + @valid_period_shorthand_keys Map.keys(@valid_period_shorthands) + + @valid_comparison_shorthands %{ + "previous_period" => :previous_period, + "year_over_year" => :year_over_year + } + + @valid_comparison_shorthand_keys Map.keys(@valid_comparison_shorthands) + + def parse(query_string, site, user_prefs) when is_binary(query_string) do query_string = String.trim_leading(query_string, "?") - params_map = Map.merge(defaults, URI.decode_query(query_string)) + params_map = URI.decode_query(query_string) with {:ok, filters} <- parse_filters(query_string), {:ok, relative_date} <- parse_relative_date(params_map) do + input_date_range = parse_input_date_range(params_map, site, user_prefs) + include = Map.merge(@default_include, %{ imports: parse_include_imports(params_map), - compare: parse_include_compare(params_map), - compare_match_day_of_week: parse_include_compare_match_day_of_week(params_map) + compare: parse_include_compare(params_map, user_prefs), + compare_match_day_of_week: parse_match_day_of_week(params_map, user_prefs) }) {:ok, ParsedQueryParams.new!(%{ - input_date_range: parse_input_date_range(params_map), + input_date_range: input_date_range, relative_date: relative_date, filters: filters, include: include @@ -51,25 +77,29 @@ defmodule Plausible.Stats.DashboardQueryParser do end end - defp parse_input_date_range(%{"period" => "realtime"}), do: :realtime - defp parse_input_date_range(%{"period" => "day"}), do: :day - defp parse_input_date_range(%{"period" => "month"}), do: :month - defp parse_input_date_range(%{"period" => "year"}), do: :year - defp parse_input_date_range(%{"period" => "all"}), do: :all - defp parse_input_date_range(%{"period" => "7d"}), do: {:last_n_days, 7} - defp parse_input_date_range(%{"period" => "28d"}), do: {:last_n_days, 28} - defp parse_input_date_range(%{"period" => "30d"}), do: {:last_n_days, 30} - defp parse_input_date_range(%{"period" => "91d"}), do: {:last_n_days, 91} - defp parse_input_date_range(%{"period" => "6mo"}), do: {:last_n_months, 6} - defp parse_input_date_range(%{"period" => "12mo"}), do: {:last_n_months, 12} + defp parse_input_date_range(%{"period" => period}, _site, _user_prefs) + when period in @valid_period_shorthand_keys do + @valid_period_shorthands[period] + end - defp parse_input_date_range(%{"period" => "custom", "from" => from, "to" => to}) do + defp parse_input_date_range( + %{"period" => "custom", "from" => from, "to" => to}, + _site, + _user_prefs + ) do from_date = Date.from_iso8601!(String.trim(from)) to_date = Date.from_iso8601!(String.trim(to)) {:date_range, from_date, to_date} end - defp parse_input_date_range(_), do: nil + defp parse_input_date_range(_params, _site, %{"period" => period}) + when period in @valid_period_shorthand_keys do + @valid_period_shorthands[period] + end + + defp parse_input_date_range(_params, site, _user_prefs) do + if recently_created?(site), do: :day, else: {:last_n_days, 28} + end defp parse_relative_date(%{"date" => date}) do case Date.from_iso8601(date) do @@ -83,19 +113,30 @@ defmodule Plausible.Stats.DashboardQueryParser do defp parse_include_imports(%{"with_imported" => "false"}), do: false defp parse_include_imports(_), do: true - defp parse_include_compare(%{"comparison" => "previous_period"}), do: :previous_period - defp parse_include_compare(%{"comparison" => "year_over_year"}), do: :year_over_year + defp parse_include_compare(%{"comparison" => "off"}, _user_prefs), do: nil + + defp parse_include_compare(%{"comparison" => comparison}, _user_prefs) + when comparison in @valid_comparison_shorthand_keys do + @valid_comparison_shorthands[comparison] + end - defp parse_include_compare(%{"comparison" => "custom"} = params) do + defp parse_include_compare(%{"comparison" => "custom"} = params, _user_prefs) do from_date = Date.from_iso8601!(params["compare_from"]) to_date = Date.from_iso8601!(params["compare_to"]) {:date_range, from_date, to_date} end - defp parse_include_compare(_options), do: nil + defp parse_include_compare(_params, %{"comparison" => comparison}) + when comparison in @valid_comparison_shorthand_keys do + @valid_comparison_shorthands[comparison] + end + + defp parse_include_compare(_params, _user_prefs), do: nil - defp parse_include_compare_match_day_of_week(%{"match_day_of_week" => "false"}), do: false - defp parse_include_compare_match_day_of_week(_), do: true + defp parse_match_day_of_week(%{"match_day_of_week" => "false"}, _user_prefs), do: false + defp parse_match_day_of_week(%{"match_day_of_week" => "true"}, _user_prefs), do: true + defp parse_match_day_of_week(_params, %{"match_day_of_week" => "false"}), do: false + defp parse_match_day_of_week(_params, _user_prefs), do: true defp parse_filters(query_string) do with {:ok, filters} <- decode_filters(query_string) do @@ -155,4 +196,9 @@ defmodule Plausible.Stats.DashboardQueryParser do defp event_dimension?(dimension) do dimension in @event_dimensions or String.starts_with?(dimension, @event_props_prefix) end + + defp recently_created?(site) do + stats_start_date = NaiveDateTime.to_date(site.native_stats_start_at) + Date.diff(stats_start_date, Date.utc_today()) >= -1 + end end diff --git a/lib/plausible/stats/filters/filters.ex b/lib/plausible/stats/filters/filters.ex index 3d1eb4389979..9bcfa2f545dc 100644 --- a/lib/plausible/stats/filters/filters.ex +++ b/lib/plausible/stats/filters/filters.ex @@ -114,9 +114,9 @@ defmodule Plausible.Stats.Filters do |> Enum.map(fn {[_operator, dimension | _rest], _depth} -> dimension end) end - def filtering_on_dimension?(query, dimension, opts \\ []) do + def filtering_on_dimension?(query_or_filters, dimension, opts \\ []) do filters = - case query do + case query_or_filters do %Query{filters: filters} -> filters %{filters: filters} -> filters filters when is_list(filters) -> filters diff --git a/lib/plausible/stats/metrics.ex b/lib/plausible/stats/metrics.ex index c4673ae73818..d835c1141875 100644 --- a/lib/plausible/stats/metrics.ex +++ b/lib/plausible/stats/metrics.ex @@ -55,4 +55,17 @@ defmodule Plausible.Stats.Metrics do def from_string(str) do Map.fetch(@metric_mappings, str) end + + def dashboard_metric_label(:visitors, %{realtime?: true}), do: "Current visitors" + def dashboard_metric_label(:visitors, %{goal_filter?: true}), do: "Conversions" + + def dashboard_metric_label(:visitors, %{dimensions: ["visit:entry_page"]}), + do: "Unique entrances" + + def dashboard_metric_label(:visitors, %{dimensions: ["visit:exit_page"]}), do: "Unique exits" + def dashboard_metric_label(:visitors, _context), do: "Visitors" + + def dashboard_metric_label(:conversion_rate, _context), do: "CR" + + def dashboard_metric_label(metric, _context), do: "#{metric}" end diff --git a/lib/plausible/stats/parsed_query_params.ex b/lib/plausible/stats/parsed_query_params.ex index 0c473db7a83d..f596be2a51ce 100644 --- a/lib/plausible/stats/parsed_query_params.ex +++ b/lib/plausible/stats/parsed_query_params.ex @@ -44,4 +44,11 @@ defmodule Plausible.Stats.ParsedQueryParams do struct!(parsed_query_params, filters: new_filters) end + + def conversion_goal_filter?(%__MODULE__{filters: filters}) do + Plausible.Stats.Filters.filtering_on_dimension?(filters, "event:goal", + max_depth: 0, + behavioral_filters: :ignore + ) + end end diff --git a/lib/plausible/stats/query_include.ex b/lib/plausible/stats/query_include.ex index 1a128e5ea04d..12a559e222b1 100644 --- a/lib/plausible/stats/query_include.ex +++ b/lib/plausible/stats/query_include.ex @@ -8,7 +8,8 @@ defmodule Plausible.Stats.QueryInclude do trim_relative_date_range: false, compare: nil, compare_match_day_of_week: false, - legacy_time_on_page_cutoff: nil + legacy_time_on_page_cutoff: nil, + dashboard_metric_labels: false @type date_range_tuple() :: {:date_range, Date.t(), Date.t()} @type datetime_range_tuple() :: {:datetime_range, DateTime.t(), DateTime.t()} @@ -22,6 +23,7 @@ defmodule Plausible.Stats.QueryInclude do compare: nil | :previous_period | :year_over_year | date_range_tuple() | datetime_range_tuple(), compare_match_day_of_week: boolean(), - legacy_time_on_page_cutoff: any() + legacy_time_on_page_cutoff: any(), + dashboard_metric_labels: boolean() } end diff --git a/lib/plausible/stats/query_result.ex b/lib/plausible/stats/query_result.ex index 64c7c4f5936f..46b8088421b2 100644 --- a/lib/plausible/stats/query_result.ex +++ b/lib/plausible/stats/query_result.ex @@ -8,7 +8,7 @@ defmodule Plausible.Stats.QueryResult do """ use Plausible - alias Plausible.Stats.{Query, QueryRunner} + alias Plausible.Stats.{Query, QueryRunner, QueryInclude} defstruct results: [], meta: %{}, @@ -62,6 +62,7 @@ defmodule Plausible.Stats.QueryResult do %{} |> add_imports_meta(runner.main_query) |> add_metric_warnings_meta(runner.main_query) + |> add_dashboard_metric_labels(runner.main_query) |> add_time_labels_meta(runner.main_query) |> add_total_rows_meta(runner.main_query, runner.total_rows) end @@ -90,6 +91,32 @@ defmodule Plausible.Stats.QueryResult do end end + defp add_dashboard_metric_labels(meta, %Query{ + include: %QueryInclude{dashboard_metric_labels: false} + }) do + meta + end + + defp add_dashboard_metric_labels(meta, query) do + context = %{ + goal_filter?: + Plausible.Stats.Filters.filtering_on_dimension?(query, "event:goal", + max_depth: 0, + behavioral_filters: :ignore + ), + realtime?: query.input_date_range in [:realtime, :realtime_30m], + dimensions: query.dimensions + } + + metric_labels = + query.metrics + |> Enum.map(fn metric -> + Plausible.Stats.Metrics.dashboard_metric_label(metric, context) + end) + + Map.put(meta, :metric_labels, metric_labels) + end + defp add_time_labels_meta(meta, query) do if query.include.time_labels do Map.put(meta, :time_labels, Plausible.Stats.Time.time_labels(query)) diff --git a/lib/plausible_web/live/components/dashboard/base.ex b/lib/plausible_web/live/components/dashboard/base.ex index e57201ff4d82..b44167d25bf4 100644 --- a/lib/plausible_web/live/components/dashboard/base.ex +++ b/lib/plausible_web/live/components/dashboard/base.ex @@ -77,6 +77,7 @@ defmodule PlausibleWeb.Components.Dashboard.Base do
diff --git a/lib/plausible_web/live/components/dashboard/report_list.ex b/lib/plausible_web/live/components/dashboard/report_list.ex index 649c177a71e6..474748c2075c 100644 --- a/lib/plausible_web/live/components/dashboard/report_list.ex +++ b/lib/plausible_web/live/components/dashboard/report_list.ex @@ -7,6 +7,7 @@ defmodule PlausibleWeb.Components.Dashboard.ReportList do alias PlausibleWeb.Components.Dashboard.Base alias PlausibleWeb.Components.Dashboard.Metric + alias Plausible.Stats.QueryResult @max_items 9 @min_height 380 @@ -28,49 +29,44 @@ defmodule PlausibleWeb.Components.Dashboard.ReportList do col_min_width: @col_min_width ) - if assigns.results.loading || !assigns.results.ok? do + if assigns.query_result.loading || !assigns.query_result.ok? do ~H""" """ else - results = assigns.results.result - metrics = assigns.metrics.result - meta = assigns.meta.result - skip_imported_reason = assigns.skip_imported_reason.result - - max_value = - results - |> Enum.map(& &1.visitors) - |> Enum.max(&>=/2, fn -> 0 end) + %QueryResult{results: results, meta: meta, query: query} = assigns.query_result.result assigns = assign(assigns, - max_value: max_value, results: results, - metrics: metrics, - meta: meta, - skip_imported_reason: skip_imported_reason, + metric_keys: query[:metrics], + metric_labels: meta[:metric_labels], empty?: Enum.empty?(results) ) ~H""" <.no_data :if={@empty?} min_height={@min_height} /> -
+
- <.report_header key_label={@key_label} metrics={@metrics} col_min_width={@col_min_width} /> + <.report_header + key_label={@key_label} + metric_labels={@metric_labels} + col_min_width={@col_min_width} + />
<.report_row - :for={item <- @results} + :for={{item, item_index} <- Enum.with_index(@results)} link_fn={assigns[:external_link_fn]} item={item} - metrics={@metrics} - bar_value={item.visitors} - bar_max_value={@max_value} + item_index={item_index} + item_name={List.first(item.dimensions)} + metrics={Enum.zip(@metric_keys, item.metrics)} + bar_max_value={bar_max_value(@results, @metric_keys)} site={@site} params={@params} - filter_dimension={@filter_dimension} + dimension={@dimension} row_height={@row_height} row_gap_height={@row_gap_height} col_min_width={@col_min_width} @@ -132,13 +128,14 @@ defmodule PlausibleWeb.Components.Dashboard.ReportList do defp report_header(assigns) do ~H"""
- {@key_label} + {@key_label}
- {metric.label} + {metric_label}
""" @@ -151,9 +148,9 @@ defmodule PlausibleWeb.Components.Dashboard.ReportList do class="group flex w-full items-center hover:bg-gray-100/60 dark:hover:bg-gray-850 rounded-sm transition-colors duration-150" style={"margin-top: #{@row_gap_height}px;"} > -
+
@@ -163,9 +160,9 @@ defmodule PlausibleWeb.Components.Dashboard.ReportList do class="max-w-max w-full flex items-center md:overflow-hidden hover:underline" site={@site} params={@params} - filter={[:is, @filter_dimension, [@item.name]]} + filter={[:is, @dimension, [@item_name]]} > - {trim_name(@item.name, @col_min_width)} + {trim_name(@item_name, @col_min_width)} <.external_link item={@item} link_fn={assigns[:link_fn]} /> @@ -173,12 +170,15 @@ defmodule PlausibleWeb.Components.Dashboard.ReportList do
- - + +
@@ -212,6 +212,15 @@ defmodule PlausibleWeb.Components.Dashboard.ReportList do """ end + @bar_metric :visitors + defp bar_max_value(results, metrics) do + index = Enum.find_index(metrics, &(&1 == @bar_metric)) + + results + |> Enum.map(&Enum.at(&1.metrics, index)) + |> Enum.max(&>=/2, fn -> 0 end) + end + defp trim_name(name, max_length) do if String.length(name) <= max_length do name diff --git a/lib/plausible_web/live/components/dashboard/tile.ex b/lib/plausible_web/live/components/dashboard/tile.ex index 7561a5abc5dd..e8b7145fa29b 100644 --- a/lib/plausible_web/live/components/dashboard/tile.ex +++ b/lib/plausible_web/live/components/dashboard/tile.ex @@ -10,6 +10,7 @@ defmodule PlausibleWeb.Components.Dashboard.Tile do attr :title, :string, required: true attr :height, :integer, required: true attr :connected?, :boolean, required: true + attr :target, :any, required: true slot :tabs slot :inner_block, required: true @@ -26,6 +27,7 @@ defmodule PlausibleWeb.Components.Dashboard.Tile do :if={@tabs != []} id={@id <> "-tabs"} phx-hook="DashboardTabs" + phx-target={@target} class="tile-tabs flex text-xs font-medium text-gray-500 dark:text-gray-400 space-x-2 items-baseline" > {render_slot(@tabs)} @@ -48,9 +50,9 @@ defmodule PlausibleWeb.Components.Dashboard.Tile do """ end - attr :label, :string, required: true - attr :value, :string, required: true - attr :active, :string, required: true + attr :report_label, :string, required: true + attr :tab_key, :string, required: true + attr :active_tab, :string, required: true attr :target, :any, required: true def tab(assigns) do @@ -58,7 +60,7 @@ defmodule PlausibleWeb.Components.Dashboard.Tile do assign( assigns, data_attrs: - if(assigns.value == assigns.active, + if(assigns.tab_key == assigns.active_tab, do: %{"data-active": "true"}, else: %{"data-active": "false"} ) @@ -67,8 +69,8 @@ defmodule PlausibleWeb.Components.Dashboard.Tile do ~H""" """ diff --git a/lib/plausible_web/live/dashboard.ex b/lib/plausible_web/live/dashboard.ex index 5a7cd197d8b4..6e1360bbfaa6 100644 --- a/lib/plausible_web/live/dashboard.ex +++ b/lib/plausible_web/live/dashboard.ex @@ -9,10 +9,6 @@ defmodule PlausibleWeb.Live.Dashboard do alias Plausible.Stats.DashboardQueryParser alias Plausible.Teams - @default_prefs %{ - "period" => "28d" - } - @spec enabled?(Plausible.Site.t() | nil) :: boolean() def enabled?(nil), do: false @@ -21,8 +17,7 @@ defmodule PlausibleWeb.Live.Dashboard do end def mount(_params, %{"domain" => domain, "url" => url}, socket) do - # NOTE: implement a dedicated, permissive params fallback. - user_prefs = Map.merge(@default_prefs, get_connect_params(socket)["user_prefs"] || %{}) + user_prefs = get_connect_params(socket)["user_prefs"] || %{} # As domain is passed via session, the associated site has already passed # validation logic on plug level. @@ -49,7 +44,13 @@ defmodule PlausibleWeb.Live.Dashboard do def handle_params_internal(_params, url, socket) do uri = URI.parse(url) path = uri.path |> String.split("/") |> Enum.drop(2) - {:ok, params} = DashboardQueryParser.parse(uri.query || "", socket.assigns.user_prefs) + + {:ok, params} = + DashboardQueryParser.parse( + uri.query || "", + socket.assigns.site, + socket.assigns.user_prefs + ) socket = assign(socket, diff --git a/lib/plausible_web/live/dashboard/pages.ex b/lib/plausible_web/live/dashboard/pages.ex index ffb9c89e7e2f..b711db1af24f 100644 --- a/lib/plausible_web/live/dashboard/pages.ex +++ b/lib/plausible_web/live/dashboard/pages.ex @@ -9,78 +9,30 @@ defmodule PlausibleWeb.Live.Dashboard.Pages do alias PlausibleWeb.Components.Dashboard.Tile alias Plausible.Stats - alias Plausible.Stats.Filters - alias Plausible.Stats.ParsedQueryParams - alias Plausible.Stats.QueryBuilder + alias Plausible.Stats.{ParsedQueryParams, QueryBuilder, QueryResult} @tabs [ - {"pages", "Top Pages"}, - {"entry-pages", "Entry Pages"}, - {"exit-pages", "Exit Pages"} - ] - - @key_labels %{ - "pages" => "Page", - "entry-pages" => "Entry page", - "exit-pages" => "Exit page" - } - - @max_items 9 - @pagination_params {@max_items, 1} - - @metrics %{ - "pages" => %{ - visitors: %{ - width: "w-24", - key: :visitors, - label: "Visitors", - sortable: true, - plot: true - }, - conversion_rate: %{ - width: "w-24", - key: :conversion_rate, - label: "CR", - sortable: true - } + %{ + tab_key: "pages", + report_label: "Top pages", + key_label: "Page", + dimension: "event:page" }, - "entry-pages" => %{ - visitors: %{ - width: "w-24", - key: :visitors, - label: "Unique Entrances", - sortable: true, - plot: true - }, - conversion_rate: %{ - width: "w-24", - key: :conversion_rate, - label: "CR", - sortable: true - } + %{ + tab_key: "entry-pages", + report_label: "Entry pages", + key_label: "Entry page", + dimension: "visit:entry_page" }, - "exit-pages" => %{ - visitors: %{ - width: "w-24", - key: :visitors, - label: "Unique Exits", - sortable: true, - plot: true - }, - conversion_rate: %{ - width: "w-24", - key: :conversion_rate, - label: "CR", - sortable: true - } + %{ + tab_key: "exit-pages", + report_label: "Exit pages", + key_label: "Exit page", + dimension: "visit:exit_page" } - } + ] - @filter_dimensions %{ - "pages" => "event:page", - "entry-pages" => "visit:entry_page", - "exit-pages" => "visit:exit_page" - } + @pagination %{limit: 9, offset: 0} def update(assigns, socket) do active_tab = assigns.user_prefs["pages_tab"] || "pages" @@ -90,12 +42,10 @@ defmodule PlausibleWeb.Live.Dashboard.Pages do site: assigns.site, params: assigns.params, tabs: @tabs, - key_labels: @key_labels, - filter_dimensions: @filter_dimensions, active_tab: active_tab, connected?: assigns.connected? ) - |> load_metrics() + |> load_stats() {:ok, socket} end @@ -108,29 +58,28 @@ defmodule PlausibleWeb.Live.Dashboard.Pages do <:tabs> @@ -143,7 +92,7 @@ defmodule PlausibleWeb.Live.Dashboard.Pages do socket = socket |> assign(:active_tab, tab) - |> load_metrics() + |> load_stats() {:noreply, socket} else @@ -155,97 +104,40 @@ defmodule PlausibleWeb.Live.Dashboard.Pages do "https://example.com" end - defp load_metrics(socket) do + defp load_stats(socket) do %{active_tab: active_tab, site: site, params: params} = socket.assigns - assign_async(socket, [:metrics, :results, :meta, :skip_imported_reason], fn -> - %{results: pages, meta: meta, query: query, metrics: metrics} = - metrics_for_tab(active_tab, site, params) - - {:ok, - %{ - metrics: Enum.map(metrics, &Map.fetch!(@metrics[active_tab], &1)), - results: Enum.take(pages, @max_items), - meta: Map.merge(meta, Stats.Breakdown.formatted_date_ranges(query)), - skip_imported_reason: meta[:imports_skip_reason] - }} - end) - end - - defp metrics_for_tab("pages", site, params) do - params = - params - |> ParsedQueryParams.set(dimensions: ["event:page"]) - |> ParsedQueryParams.set_include(:time_labels, false) - - {:ok, query} = QueryBuilder.build(site, params, %{}) - metrics = breakdown_metrics(query) - - %{results: results, meta: meta} = Stats.breakdown(site, query, metrics, @pagination_params) - - pages = - results - |> transform_keys(%{page: :name}) - - %{query: query, results: pages, meta: meta, metrics: metrics} - end - - defp metrics_for_tab("entry-pages", site, params) do - params = - params - |> ParsedQueryParams.set(dimensions: ["visit:entry_page"]) - |> ParsedQueryParams.set_include(:time_labels, false) + assign_async(socket, :query_result, fn -> + metrics = choose_metrics(params) + dimension = get_tab_info(active_tab, :dimension) - {:ok, query} = QueryBuilder.build(site, params, %{}) - metrics = breakdown_metrics(query) + params = + params + |> ParsedQueryParams.set( + metrics: metrics, + dimensions: [dimension], + pagination: @pagination + ) - %{results: results, meta: meta} = Stats.breakdown(site, query, metrics, @pagination_params) + query = QueryBuilder.build!(site, params) - pages = - results - |> transform_keys(%{entry_page: :name}) + %QueryResult{} = query_result = Stats.query(site, query) - %{query: query, results: pages, meta: meta, metrics: metrics} - end - - defp metrics_for_tab("exit-pages", site, params) do - params = - params - |> ParsedQueryParams.set(dimensions: ["visit:exit_page"]) - |> ParsedQueryParams.set_include(:time_labels, false) - - {:ok, query} = QueryBuilder.build(site, params, %{}) - metrics = breakdown_metrics(query) - - %{results: results, meta: meta} = Stats.breakdown(site, query, metrics, @pagination_params) - - pages = - results - |> transform_keys(%{exit_page: :name}) - - %{query: query, results: pages, meta: meta, metrics: metrics} + {:ok, %{query_result: query_result}} + end) end - defp breakdown_metrics(query) do - if toplevel_goal_filter?(query) do + defp choose_metrics(%ParsedQueryParams{} = params) do + if ParsedQueryParams.conversion_goal_filter?(params) do [:visitors, :conversion_rate] else [:visitors] end end - defp transform_keys(result, keys_to_replace) when is_map(result) do - for {key, val} <- result, do: {Map.get(keys_to_replace, key, key), val}, into: %{} - end - - defp transform_keys(results, keys_to_replace) when is_list(results) do - Enum.map(results, &transform_keys(&1, keys_to_replace)) - end - - defp toplevel_goal_filter?(query) do - Filters.filtering_on_dimension?(query, "event:goal", - max_depth: 0, - behavioral_filters: :ignore - ) + defp get_tab_info(tab_key, field) do + @tabs + |> Enum.find(&(&1.tab_key == tab_key)) + |> Map.fetch!(field) end end diff --git a/test/plausible/stats/query/dashboard_query_parser_test.exs b/test/plausible/stats/query/dashboard_query_parser_test.exs index c63c8ebefb50..b0906eb63336 100644 --- a/test/plausible/stats/query/dashboard_query_parser_test.exs +++ b/test/plausible/stats/query/dashboard_query_parser_test.exs @@ -5,11 +5,14 @@ defmodule Plausible.Stats.DashboardQueryParserTest do @default_include default_include() + @yesterday NaiveDateTime.utc_now(:second) |> NaiveDateTime.add(-1, :day) + @before_yesterday @yesterday |> NaiveDateTime.add(-1, :day) + test "parses an empty query string" do - {:ok, parsed} = parse("") + {:ok, parsed} = parse("", build(:site), %{}) expected = %Plausible.Stats.ParsedQueryParams{ - input_date_range: nil, + input_date_range: {:last_n_days, 28}, relative_date: nil, metrics: [], filters: [], @@ -25,81 +28,120 @@ defmodule Plausible.Stats.DashboardQueryParserTest do describe "period -> input_date_range" do for period <- [:realtime, :day, :month, :year, :all] do test "parses #{period} period" do - {:ok, parsed} = parse("?period=#{Atom.to_string(unquote(period))}") + {:ok, parsed} = parse("?period=#{Atom.to_string(unquote(period))}", build(:site), %{}) + assert_matches %ParsedQueryParams{input_date_range: ^unquote(period)} = parsed + end - assert_matches %ParsedQueryParams{ - input_date_range: ^unquote(period), - include: ^@default_include - } = parsed + test "parses #{period} period from user prefs" do + {:ok, parsed} = parse("", build(:site), %{"period" => Atom.to_string(unquote(period))}) + assert_matches %ParsedQueryParams{input_date_range: ^unquote(period)} = parsed end end for i <- [7, 28, 30, 91] do test "parses #{i}d period" do - {:ok, parsed} = parse("?period=#{unquote(i)}d") + {:ok, parsed} = parse("?period=#{unquote(i)}d", build(:site), %{}) + + assert_matches %ParsedQueryParams{ + input_date_range: {:last_n_days, ^unquote(i)} + } = parsed + end + + test "parses #{i}d period from user_prefs" do + {:ok, parsed} = parse("", build(:site), %{"period" => "#{unquote(i)}d"}) assert_matches %ParsedQueryParams{ - input_date_range: {:last_n_days, ^unquote(i)}, - include: ^@default_include + input_date_range: {:last_n_days, ^unquote(i)} } = parsed end end for i <- [6, 12] do test "parses #{i}mo period" do - {:ok, parsed} = parse("?period=#{unquote(i)}mo") + {:ok, parsed} = parse("?period=#{unquote(i)}mo", build(:site), %{}) + + assert_matches %ParsedQueryParams{ + input_date_range: {:last_n_months, ^unquote(i)} + } = + parsed + end + + test "parses #{i}mo period from user prefs" do + {:ok, parsed} = parse("", build(:site), %{"period" => "#{unquote(i)}mo"}) assert_matches %ParsedQueryParams{ - input_date_range: {:last_n_months, ^unquote(i)}, - include: ^@default_include + input_date_range: {:last_n_months, ^unquote(i)} } = parsed end end test "parses custom period" do - {:ok, parsed} = parse("?period=custom&from=2021-01-01&to=2021-03-05") + {:ok, parsed} = parse("?period=custom&from=2021-01-01&to=2021-03-05", build(:site), %{}) assert %ParsedQueryParams{ - input_date_range: {:date_range, ~D[2021-01-01], ~D[2021-03-05]}, - include: @default_include + input_date_range: {:date_range, ~D[2021-01-01], ~D[2021-03-05]} } = parsed end - test "defaults to nil when period param is invalid" do - {:ok, parsed} = parse("?period=abcde") + test "falls back to last 28 days when site created before yesterday" do + site = build(:site, native_stats_start_at: @before_yesterday) + {:ok, parsed} = parse("", site, %{}) + assert %ParsedQueryParams{input_date_range: {:last_n_days, 28}} = parsed + end - assert %ParsedQueryParams{ - input_date_range: nil, - include: @default_include - } = parsed + test "falls back to :day for a recently created site" do + site = build(:site, native_stats_start_at: @yesterday) + {:ok, parsed} = parse("", site, %{}) + assert %ParsedQueryParams{input_date_range: :day} = parsed + end + + test "falls back to valid user preference" do + {:ok, parsed} = parse("", build(:site), %{"period" => "year"}) + assert %ParsedQueryParams{input_date_range: :year} = parsed + end + + test "falls back to valid user preference when period in query string is invalid" do + {:ok, parsed} = parse("?period=invalid", build(:site), %{"period" => "month"}) + assert %ParsedQueryParams{input_date_range: :month} = parsed + end + + test "valid period param in query string takes precedence over valid user preference" do + {:ok, parsed} = parse("?period=all", build(:site), %{"period" => "month"}) + assert %ParsedQueryParams{input_date_range: :all} = parsed + end + + test "falls back to site default when both query string and user preference period are invalid" do + site = build(:site, native_stats_start_at: @yesterday) + {:ok, parsed} = parse("?period=invalid", site, %{"period" => "invalid"}) + assert %ParsedQueryParams{input_date_range: :day} = parsed end end describe "date -> relative_date" do test "parses a valid iso8601 date string" do - {:ok, parsed} = parse("?date=2021-05-05") + {:ok, parsed} = parse("?date=2021-05-05", build(:site), %{}) assert %ParsedQueryParams{relative_date: ~D[2021-05-05], include: @default_include} = parsed end test "errors when invalid date" do - {:error, :invalid_date} = parse("?date=2021-13-32") + {:error, :invalid_date} = parse("?date=2021-13-32", build(:site), %{}) end end describe "with_imported -> include.imports" do test "true -> true" do - {:ok, parsed} = parse("?with_imported=true") + {:ok, parsed} = parse("?with_imported=true", build(:site), %{}) assert %ParsedQueryParams{include: @default_include} = parsed end test "invalid -> true" do - {:ok, parsed} = parse("?with_imported=foo") + {:ok, parsed} = parse("?with_imported=foo", build(:site), %{}) assert %ParsedQueryParams{include: @default_include} = parsed end test "false -> false" do - {:ok, parsed} = parse("?with_imported=false") + {:ok, parsed} = parse("?with_imported=false", build(:site), %{}) expected_include = Map.put(@default_include, :imports, false) assert %ParsedQueryParams{include: ^expected_include} = parsed end @@ -108,14 +150,25 @@ defmodule Plausible.Stats.DashboardQueryParserTest do describe "comparison -> include.compare" do for mode <- [:previous_period, :year_over_year] do test "parses #{mode} mode" do - {:ok, parsed} = parse("?comparison=#{unquote(mode)}") + {:ok, parsed} = parse("?comparison=#{unquote(mode)}", build(:site), %{}) + expected_include = Map.put(@default_include, :compare, unquote(mode)) + assert_matches %ParsedQueryParams{include: ^expected_include} = parsed + end + + test "parses #{mode} mode from user prefs" do + {:ok, parsed} = parse("", build(:site), %{"comparison" => "#{unquote(mode)}"}) expected_include = Map.put(@default_include, :compare, unquote(mode)) assert_matches %ParsedQueryParams{include: ^expected_include} = parsed end end test "parses custom date range mode" do - {:ok, parsed} = parse("?comparison=custom&compare_from=2021-01-01&compare_to=2021-04-30") + {:ok, parsed} = + parse( + "?comparison=custom&compare_from=2021-01-01&compare_to=2021-04-30", + build(:site), + %{} + ) expected_include = Map.put(@default_include, :compare, {:date_range, ~D[2021-01-01], ~D[2021-04-30]}) @@ -123,34 +176,101 @@ defmodule Plausible.Stats.DashboardQueryParserTest do assert_matches %ParsedQueryParams{include: ^expected_include} = parsed end - test "invalid -> nil" do - {:ok, parsed} = parse("?comparison=invalid_mode") + test "custom comparison in query string takes precedence over user prefs" do + {:ok, parsed} = + parse( + "?comparison=custom&compare_from=2021-01-01&compare_to=2021-04-30", + build(:site), + %{"comparison" => "year_over_year"} + ) + + expected_include = + Map.put(@default_include, :compare, {:date_range, ~D[2021-01-01], ~D[2021-04-30]}) + + assert_matches %ParsedQueryParams{include: ^expected_include} = parsed + end + + test "falls back to user preference when query string comparison param is invalid" do + {:ok, parsed} = + parse("?comparison=invalid_mode", build(:site), %{"comparison" => "previous_period"}) + + expected_include = + Map.put(@default_include, :compare, :previous_period) + + assert_matches %ParsedQueryParams{include: ^expected_include} = parsed + end + + test "comparison=off in query string skips stored comparison mode" do + {:ok, parsed} = + parse("?comparison=off", build(:site), %{"comparison" => "previous_period"}) + + assert %ParsedQueryParams{include: @default_include} = parsed + end + + test "falls back to nil when comparison param in both query string and user prefs is invalid" do + {:ok, parsed} = + parse("?comparison=invalid_mode", build(:site), %{"comparison" => "invalid_mode"}) + assert %ParsedQueryParams{include: @default_include} = parsed end end describe "match_day_of_week -> include.compare_match_day_of_week" do test "true -> true" do - {:ok, parsed} = parse("?match_day_of_week=true") + {:ok, parsed} = parse("?match_day_of_week=true", build(:site), %{}) assert %ParsedQueryParams{include: @default_include} = parsed end test "invalid -> true" do - {:ok, parsed} = parse("?match_day_of_week=foo") + {:ok, parsed} = parse("?match_day_of_week=foo", build(:site), %{}) assert %ParsedQueryParams{include: @default_include} = parsed end test "false -> false" do - {:ok, parsed} = parse("?match_day_of_week=false") + {:ok, parsed} = parse("?match_day_of_week=false", build(:site), %{}) + expected_include = Map.put(@default_include, :compare_match_day_of_week, false) + assert %ParsedQueryParams{include: ^expected_include} = parsed + end + + test "'true' in query string takes precedence over 'false' in user prefs" do + {:ok, parsed} = + parse("?match_day_of_week=true", build(:site), %{"match_day_of_week" => "false"}) + + assert %ParsedQueryParams{include: @default_include} = parsed + end + + test "'false' in query string takes precedence over 'true' in user prefs" do + {:ok, parsed} = + parse("?match_day_of_week=false", build(:site), %{"match_day_of_week" => "true"}) + expected_include = Map.put(@default_include, :compare_match_day_of_week, false) assert %ParsedQueryParams{include: ^expected_include} = parsed end + + test "falls back to user pref when value in query string is invalid" do + {:ok, parsed} = + parse("?match_day_of_week=foo", build(:site), %{"match_day_of_week" => "false"}) + + expected_include = Map.put(@default_include, :compare_match_day_of_week, false) + assert %ParsedQueryParams{include: ^expected_include} = parsed + end + + test "falls back to 'true' when invalid values in both query string and user prefs" do + {:ok, parsed} = + parse("?match_day_of_week=foo", build(:site), %{"match_day_of_week" => "bar"}) + + assert %ParsedQueryParams{include: @default_include} = parsed + end end describe "filters" do test "parses valid filters" do {:ok, parsed} = - parse("?f=is,exit_page,/:dashboard&f=is,source,Bing&f=is,props:theme,system") + parse( + "?f=is,exit_page,/:dashboard&f=is,source,Bing&f=is,props:theme,system", + build(:site), + %{} + ) assert %ParsedQueryParams{ filters: [ @@ -164,7 +284,7 @@ defmodule Plausible.Stats.DashboardQueryParserTest do test "parses city filter with multiple clauses" do {:ok, parsed} = - parse("?f=is,city,2988507,2950159") + parse("?f=is,city,2988507,2950159", build(:site), %{}) assert %ParsedQueryParams{ filters: [[:is, "visit:city", [2_988_507, 2_950_159]]], @@ -173,7 +293,7 @@ defmodule Plausible.Stats.DashboardQueryParserTest do end test "parses a segment filter" do - {:ok, parsed} = parse("?f=is,segment,123") + {:ok, parsed} = parse("?f=is,segment,123", build(:site), %{}) assert %ParsedQueryParams{ filters: [[:is, "segment", [123]]], @@ -182,15 +302,15 @@ defmodule Plausible.Stats.DashboardQueryParserTest do end test "errors when filter structure is wrong" do - assert {:error, :invalid_filters} = parse("?f=is,page,/&f=what") + assert {:error, :invalid_filters} = parse("?f=is,page,/&f=what", build(:site), %{}) end test "errors when city filter cannot be parsed to integer" do - assert {:error, :invalid_filters} = parse("?f=is,city,Berlin") + assert {:error, :invalid_filters} = parse("?f=is,city,Berlin", build(:site), %{}) end test "errors when segment filter cannot be parsed to integer" do - assert {:error, :invalid_filters} = parse("?f=is,segment,MySegment") + assert {:error, :invalid_filters} = parse("?f=is,segment,MySegment", build(:site), %{}) end end end diff --git a/test/plausible/stats/query/dashboard_query_serializer_test.exs b/test/plausible/stats/query/dashboard_query_serializer_test.exs index 727ac00fdcde..ca925c94b670 100644 --- a/test/plausible/stats/query/dashboard_query_serializer_test.exs +++ b/test/plausible/stats/query/dashboard_query_serializer_test.exs @@ -6,25 +6,25 @@ defmodule Plausible.Stats.DashboardQuerySerializerTest do @default_include default_include() describe "parse -> serialize is a reversible transformation" do - for query_string <- ["", "date=2021-07-07&f=is,browser,Chrome,Firefox&period=day"] do + for query_string <- ["period=month", "date=2021-07-07&f=is,browser,Chrome,Firefox&period=day"] do test "with query string being '#{query_string}'" do - {:ok, parsed} = parse(unquote(query_string)) + {:ok, parsed} = parse(unquote(query_string), build(:site), %{}) assert serialize(parsed) == unquote(query_string) end end test "but alphabetical ordering by key is enforced" do - {:ok, parsed} = parse("period=day&date=2021-07-07") + {:ok, parsed} = parse("period=day&date=2021-07-07", build(:site), %{}) assert serialize(parsed) == "date=2021-07-07&period=day" end test "but redundant values get removed" do - {:ok, parsed} = parse("with_imported=true") - assert serialize(parsed) == "" + {:ok, parsed} = parse("period=all&with_imported=true", build(:site), %{}) + assert serialize(parsed) == "period=all" end test "but leading ? gets removed" do - {:ok, parsed} = parse("?period=day") + {:ok, parsed} = parse("?period=day", build(:site), %{}) assert serialize(parsed) == "period=day" end end diff --git a/test/plausible/stats/query/query_test.exs b/test/plausible/stats/query/query_test.exs index 85fb3c6ba564..7afa30451e5a 100644 --- a/test/plausible/stats/query/query_test.exs +++ b/test/plausible/stats/query/query_test.exs @@ -126,4 +126,114 @@ defmodule Plausible.Stats.QueryTest do ] end end + + describe "include.dashboard_metric_labels" do + test "visitors -> Visitors (default)", %{site: site} do + {:ok, query} = + QueryBuilder.build(site, + metrics: [:visitors], + input_date_range: :all, + include: [dashboard_metric_labels: true] + ) + + %Stats.QueryResult{meta: meta} = Stats.query(site, query) + assert ["Visitors"] = meta[:metric_labels] + end + + test "visitors -> Current visitors (realtime)", %{site: site} do + {:ok, query} = + QueryBuilder.build(site, + metrics: [:visitors], + input_date_range: :realtime, + include: [dashboard_metric_labels: true] + ) + + %Stats.QueryResult{meta: meta} = Stats.query(site, query) + assert ["Current visitors"] = meta[:metric_labels] + end + + test "visitors -> Current visitors (realtime and goal filtered)", %{site: site} do + insert(:goal, site: site, event_name: "Signup") + + {:ok, query} = + QueryBuilder.build(site, + metrics: [:visitors], + input_date_range: :realtime, + filters: [[:is, "event:goal", ["Signup"]]], + include: [dashboard_metric_labels: true] + ) + + %Stats.QueryResult{meta: meta} = Stats.query(site, query) + assert ["Current visitors"] = meta[:metric_labels] + end + + test "visitors -> Conversions (goal filtered)", %{site: site} do + insert(:goal, site: site, event_name: "Signup") + + {:ok, query} = + QueryBuilder.build(site, + metrics: [:visitors], + input_date_range: :all, + filters: [[:is, "event:goal", ["Signup"]]], + include: [dashboard_metric_labels: true] + ) + + %Stats.QueryResult{meta: meta} = Stats.query(site, query) + assert ["Conversions"] = meta[:metric_labels] + end + + test "visitors -> Unique entrances (visit:entry_page dimension)", %{site: site} do + {:ok, query} = + QueryBuilder.build(site, + metrics: [:visitors], + input_date_range: :all, + dimensions: ["visit:entry_page"], + include: [dashboard_metric_labels: true] + ) + + %Stats.QueryResult{meta: meta} = Stats.query(site, query) + assert ["Unique entrances"] = meta[:metric_labels] + end + + test "visitors -> Unique exits (visit:exit_page dimension)", %{site: site} do + {:ok, query} = + QueryBuilder.build(site, + metrics: [:visitors], + input_date_range: :all, + dimensions: ["visit:exit_page"], + include: [dashboard_metric_labels: true] + ) + + %Stats.QueryResult{meta: meta} = Stats.query(site, query) + assert ["Unique exits"] = meta[:metric_labels] + end + + test "conversion_rate -> CR (default)", %{site: site} do + {:ok, query} = + QueryBuilder.build(site, + metrics: [:conversion_rate], + input_date_range: :all, + dimensions: ["event:goal"], + include: [dashboard_metric_labels: true] + ) + + %Stats.QueryResult{meta: meta} = Stats.query(site, query) + assert ["CR"] = meta[:metric_labels] + end + + test "maintains order with multiple metrics", %{site: site} do + insert(:goal, site: site, event_name: "Signup") + + {:ok, query} = + QueryBuilder.build(site, + metrics: [:conversion_rate, :visitors], + input_date_range: :all, + filters: [[:is, "event:goal", ["Signup"]]], + include: [dashboard_metric_labels: true] + ) + + %Stats.QueryResult{meta: meta} = Stats.query(site, query) + assert ["CR", "Conversions"] = meta[:metric_labels] + end + end end diff --git a/test/plausible_web/live/components/dashboard/report_list_test.exs b/test/plausible_web/live/components/dashboard/report_list_test.exs new file mode 100644 index 000000000000..e3c06c9c04ef --- /dev/null +++ b/test/plausible_web/live/components/dashboard/report_list_test.exs @@ -0,0 +1,65 @@ +defmodule PlausibleWeb.Components.Dashboard.ReportListTest do + use PlausibleWeb.ConnCase, async: true + import Phoenix.LiveViewTest, only: [render_component: 2] + + alias PlausibleWeb.Components.Dashboard.ReportList + alias Plausible.Stats.{ParsedQueryParams, QueryResult} + alias Phoenix.LiveView.AsyncResult + import Plausible.DashboardTestUtils + + @report_list_selector ~s|[data-test-id="pages-report-list"]| + @bar_indicator_selector ~s|[data-test-id="bar-indicator"]| + + setup do + assigns = [ + site: build(:site), + data_test_id: "pages-report-list", + key_label: "Page", + dimension: "event:page", + params: %ParsedQueryParams{}, + external_link_fn: fn _ -> "" end + ] + + {:ok, %{assigns: assigns}} + end + + test "renders empty when loading", %{assigns: assigns} do + assigns = Keyword.put(assigns, :query_result, AsyncResult.loading()) + assert render_component(&ReportList.report/1, assigns) == "" + end + + test "renders empty when result not ok", %{assigns: assigns} do + assigns = + Keyword.put(assigns, :query_result, AsyncResult.failed(AsyncResult.loading(), :some_error)) + + assert render_component(&ReportList.report/1, assigns) == "" + end + + test "item bar width depends on visitors metric", %{assigns: assigns} do + successful_async_result = + AsyncResult.ok(%QueryResult{ + results: [ + %{metrics: [100, 60.0], dimensions: ["/a"]}, + %{metrics: [70, 40.0], dimensions: ["/b"]}, + %{metrics: [30, 20.0], dimensions: ["/c"]} + ], + meta: Jason.OrderedObject.new(metric_labels: ["Conversions", "CR"]), + query: Jason.OrderedObject.new(metrics: [:visitors, :conversion_rate]) + }) + + assigns = Keyword.put(assigns, :query_result, successful_async_result) + + html = render_component(&ReportList.report/1, assigns) + + report_list = find(html, @report_list_selector) + + [{1, "100.0%"}, {2, "70.0%"}, {3, "30.0%"}] + |> Enum.each(fn {item, expected_width} -> + bar = + get_in_report_list(report_list, item, 0, text?: false) + |> find(@bar_indicator_selector) + + assert text_of_attr(bar, "style") =~ "width: #{expected_width}" + end) + end +end diff --git a/test/plausible_web/live/dashboard_test.exs b/test/plausible_web/live/dashboard/dashboard_test.exs similarity index 100% rename from test/plausible_web/live/dashboard_test.exs rename to test/plausible_web/live/dashboard/dashboard_test.exs diff --git a/test/plausible_web/live/dashboard/pages_test.exs b/test/plausible_web/live/dashboard/pages_test.exs new file mode 100644 index 000000000000..3a69f783e29c --- /dev/null +++ b/test/plausible_web/live/dashboard/pages_test.exs @@ -0,0 +1,228 @@ +defmodule PlausibleWeb.Live.Dashboard.PagesTest do + use PlausibleWeb.ConnCase, async: false + + import Phoenix.LiveViewTest + import Plausible.DashboardTestUtils + + setup [:create_user, :log_in, :create_site] + + @top_pages_report_list ~s|[data-test-id="pages-report-list"]| + @entry_pages_report_list ~s|[data-test-id="entry-pages-report-list"]| + @exit_pages_report_list ~s|[data-test-id="exit-pages-report-list"]| + + describe "Top Pages" do + test "eventually renders and orders items by visitor counts", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/one"), + build(:pageview, pathname: "/two"), + build(:pageview, pathname: "/two"), + build(:pageview, pathname: "/three"), + build(:pageview, pathname: "/three"), + build(:pageview, pathname: "/three") + ]) + + assert report_list = get_liveview(conn, site) |> get_report_list(@top_pages_report_list) + + assert report_list_as_table(report_list, 4, 2) == [ + ["Page", "Visitors"], + ["/three", "3"], + ["/two", "2"], + ["/one", "1"] + ] + end + + test "renders current visitors", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/one"), + build(:pageview, pathname: "/two"), + build(:pageview, pathname: "/two") + ]) + + assert report_list = + get_liveview(conn, site, "period=realtime") + |> get_report_list(@top_pages_report_list) + + assert report_list_as_table(report_list, 3, 2) == [ + ["Page", "Current visitors"], + ["/two", "2"], + ["/one", "1"] + ] + end + + test "renders conversions with conversion rate", %{conn: conn, site: site} do + insert(:goal, site: site, event_name: "Signup") + + populate_stats(site, [ + build(:pageview, pathname: "/one"), + build(:event, name: "Signup", pathname: "/two"), + build(:pageview, pathname: "/two") + ]) + + assert report_list = + get_liveview(conn, site, "period=day&f=is,goal,Signup") + |> get_report_list(@top_pages_report_list) + + assert report_list_as_table(report_list, 2, 3) == [ + ["Page", "Conversions", "CR"], + ["/two", "1", "33.33%"] + ] + + refute get_in_report_list(report_list, 2, 0) + end + end + + describe "Entry Pages" do + test "eventually renders and orders items by visitor counts", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/one"), + build(:pageview, pathname: "/two"), + build(:pageview, pathname: "/two"), + build(:pageview, pathname: "/three"), + build(:pageview, pathname: "/three"), + build(:pageview, pathname: "/three") + ]) + + assert report_list = + get_liveview(conn, site) + |> change_tab("entry-pages") + |> get_report_list(@entry_pages_report_list) + + assert report_list_as_table(report_list, 4, 2) == [ + ["Entry page", "Unique entrances"], + ["/three", "3"], + ["/two", "2"], + ["/one", "1"] + ] + end + + test "renders current visitors", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/one"), + build(:pageview, pathname: "/two"), + build(:pageview, pathname: "/two") + ]) + + assert report_list = + get_liveview(conn, site, "period=realtime") + |> change_tab("entry-pages") + |> get_report_list(@entry_pages_report_list) + + assert report_list_as_table(report_list, 3, 2) == [ + ["Entry page", "Current visitors"], + ["/two", "2"], + ["/one", "1"] + ] + end + + test "renders conversions with conversion rate", %{conn: conn, site: site} do + insert(:goal, site: site, event_name: "Signup") + + populate_stats(site, [ + build(:pageview, pathname: "/one"), + build(:pageview, user_id: 1, pathname: "/two"), + build(:event, user_id: 1, name: "Signup", pathname: "/two"), + build(:pageview, pathname: "/two") + ]) + + assert report_list = + get_liveview(conn, site, "period=day&f=is,goal,Signup") + |> change_tab("entry-pages") + |> get_report_list(@entry_pages_report_list) + + assert report_list_as_table(report_list, 2, 3) == [ + ["Entry page", "Conversions", "CR"], + ["/two", "1", "33.33%"] + ] + + refute get_in_report_list(report_list, 2, 0) + end + end + + describe "Exit Pages" do + test "eventually renders and orders items by visitor counts", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/one"), + build(:pageview, pathname: "/two"), + build(:pageview, pathname: "/two"), + build(:pageview, pathname: "/three"), + build(:pageview, pathname: "/three"), + build(:pageview, pathname: "/three") + ]) + + assert report_list = + get_liveview(conn, site) + |> change_tab("exit-pages") + |> get_report_list(@exit_pages_report_list) + + assert report_list_as_table(report_list, 4, 2) == [ + ["Exit page", "Unique exits"], + ["/three", "3"], + ["/two", "2"], + ["/one", "1"] + ] + end + + test "renders current visitors", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/one"), + build(:pageview, pathname: "/two"), + build(:pageview, pathname: "/two") + ]) + + assert report_list = + get_liveview(conn, site, "period=realtime") + |> change_tab("exit-pages") + |> get_report_list(@exit_pages_report_list) + + assert report_list_as_table(report_list, 3, 2) == [ + ["Exit page", "Current visitors"], + ["/two", "2"], + ["/one", "1"] + ] + end + + test "renders conversions with conversion rate", %{conn: conn, site: site} do + insert(:goal, site: site, event_name: "Signup") + + populate_stats(site, [ + build(:pageview, pathname: "/one"), + build(:event, user_id: 1, name: "Signup", pathname: "/two"), + build(:pageview, user_id: 1, pathname: "/two"), + build(:pageview, pathname: "/two") + ]) + + assert report_list = + get_liveview(conn, site, "period=day&f=is,goal,Signup") + |> change_tab("exit-pages") + |> get_report_list(@exit_pages_report_list) + + assert report_list_as_table(report_list, 2, 3) == [ + ["Exit page", "Conversions", "CR"], + ["/two", "1", "33.33%"] + ] + + refute get_in_report_list(report_list, 2, 0) + end + end + + defp get_report_list(lv, selector) do + eventually(fn -> + html = render(lv) + {element_exists?(html, selector), find(html, selector)} + end) + end + + defp get_liveview(conn, site, search_params \\ "period=day") do + conn = assign(conn, :live_module, PlausibleWeb.Live.Dashboard) + {:ok, lv, _html} = live(conn, "/#{site.domain}?#{search_params}") + lv + end + + defp change_tab(lv, tab) do + lv + |> element("#breakdown-tile-pages-tabs") + |> render_hook("set-tab", %{"tab" => tab}) + + lv + end +end diff --git a/test/support/dashboard_test_utils.ex b/test/support/dashboard_test_utils.ex new file mode 100644 index 000000000000..e1f83a10609f --- /dev/null +++ b/test/support/dashboard_test_utils.ex @@ -0,0 +1,33 @@ +defmodule Plausible.DashboardTestUtils do + @moduledoc false + + import Plausible.Test.Support.HTML + + @doc """ + Takes a LazyHTML rendered ReportList component argument with the number of + rows and columns it's supposed to have, and returns a table-like, 2D list + with all its data (including headers as the first row). + """ + def report_list_as_table(%LazyHTML{} = report_list, rows, columns) do + for row_index <- 0..(rows - 1) do + for column_index <- 0..(columns - 1) do + get_in_report_list(report_list, row_index, column_index) + end + end + end + + def get_in_report_list(%LazyHTML{} = report_list, row_index, column_index, opts \\ []) do + selector = ~s|[data-test-id="report-list-#{row_index}-#{column_index}"]| + + cond do + not element_exists?(report_list, selector) -> + nil + + Keyword.get(opts, :text?, true) -> + text_of_element(report_list, selector) + + true -> + find(report_list, selector) + end + end +end diff --git a/test/support/html.ex b/test/support/html.ex index 792d173f3acc..50b2db81bcc8 100644 --- a/test/support/html.ex +++ b/test/support/html.ex @@ -10,10 +10,15 @@ defmodule Plausible.Test.Support.HTML do |> Kernel.not() end + def find(%LazyHTML{} = html, selector) do + html + |> LazyHTML.query(selector) + end + def find(html, selector) do html |> lazy_parse() - |> LazyHTML.query(selector) + |> find(selector) end def submit_button(html, form) do