From 2b006b851eee358bd76b0085e436f485d6705861 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Fri, 26 Dec 2025 13:21:14 +0000 Subject: [PATCH 01/17] move test file into subfolder --- test/plausible_web/live/{ => dashboard}/dashboard_test.exs | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename test/plausible_web/live/{ => dashboard}/dashboard_test.exs (100%) 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 From 9ca02fa09289fe6bcc304e7e7642cadefb1e2e0b Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Fri, 26 Dec 2025 17:25:38 +0000 Subject: [PATCH 02/17] optionally put meta.dashboard_metric_labels in QueryResult --- lib/plausible/stats/api_query_parser.ex | 3 +- lib/plausible/stats/dashboard_query_parser.ex | 3 +- lib/plausible/stats/metrics.ex | 11 +++ lib/plausible/stats/query_include.ex | 6 +- lib/plausible/stats/query_result.ex | 30 +++++- test/plausible/stats/query/query_test.exs | 95 +++++++++++++++++++ 6 files changed, 143 insertions(+), 5 deletions(-) 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..fe37af0369d8 100644 --- a/lib/plausible/stats/dashboard_query_parser.ex +++ b/lib/plausible/stats/dashboard_query_parser.ex @@ -19,7 +19,8 @@ defmodule Plausible.Stats.DashboardQueryParser do 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 diff --git a/lib/plausible/stats/metrics.ex b/lib/plausible/stats/metrics.ex index c4673ae73818..43d1ee43f845 100644 --- a/lib/plausible/stats/metrics.ex +++ b/lib/plausible/stats/metrics.ex @@ -55,4 +55,15 @@ 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" 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..c34d8147679c 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,33 @@ 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 -> + {metric, Plausible.Stats.Metrics.dashboard_metric_label(metric, context)} + end) + |> Map.new() + + 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/test/plausible/stats/query/query_test.exs b/test/plausible/stats/query/query_test.exs index 85fb3c6ba564..d17a17667a2e 100644 --- a/test/plausible/stats/query/query_test.exs +++ b/test/plausible/stats/query/query_test.exs @@ -126,4 +126,99 @@ 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: "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 %{visitors: "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 %{visitors: "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 %{visitors: "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 %{visitors: "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 %{visitors: "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 %{conversion_rate: "CR"} = meta[:metric_labels] + end + end end From 36949d796b0eaf4f2badebee8aa0a033daeb7ec5 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Fri, 26 Dec 2025 17:32:03 +0000 Subject: [PATCH 03/17] DashboardQueryParser default include.timelabels to false --- lib/plausible/stats/dashboard_query_parser.ex | 2 +- lib/plausible_web/live/dashboard/pages.ex | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/plausible/stats/dashboard_query_parser.ex b/lib/plausible/stats/dashboard_query_parser.ex index fe37af0369d8..7c647b7f3020 100644 --- a/lib/plausible/stats/dashboard_query_parser.ex +++ b/lib/plausible/stats/dashboard_query_parser.ex @@ -14,7 +14,7 @@ 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, diff --git a/lib/plausible_web/live/dashboard/pages.ex b/lib/plausible_web/live/dashboard/pages.ex index ffb9c89e7e2f..b5c92ff57e58 100644 --- a/lib/plausible_web/live/dashboard/pages.ex +++ b/lib/plausible_web/live/dashboard/pages.ex @@ -176,7 +176,6 @@ defmodule PlausibleWeb.Live.Dashboard.Pages do params = params |> ParsedQueryParams.set(dimensions: ["event:page"]) - |> ParsedQueryParams.set_include(:time_labels, false) {:ok, query} = QueryBuilder.build(site, params, %{}) metrics = breakdown_metrics(query) @@ -194,7 +193,6 @@ defmodule PlausibleWeb.Live.Dashboard.Pages do params = params |> ParsedQueryParams.set(dimensions: ["visit:entry_page"]) - |> ParsedQueryParams.set_include(:time_labels, false) {:ok, query} = QueryBuilder.build(site, params, %{}) metrics = breakdown_metrics(query) @@ -212,7 +210,6 @@ defmodule PlausibleWeb.Live.Dashboard.Pages 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) From 1543614408d35f94fba4ed6ccde13004e80aaf02 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Fri, 26 Dec 2025 22:51:12 +0000 Subject: [PATCH 04/17] refactor Live.Pages to use Stats.query instead of Stats.breakdown + tests To make dealing with metrics in FE easier, also move metric labelling logic into Stats.query itself by introducing a dashboard_metric_labels flag under query.include, which writes a map under response.meta. --- lib/plausible/stats/parsed_query_params.ex | 6 + .../live/components/dashboard/report_list.ex | 62 +++-- .../live/components/dashboard/tile.ex | 2 + lib/plausible_web/live/dashboard/pages.ex | 160 ++--------- .../live/dashboard/pages_test.exs | 261 ++++++++++++++++++ test/support/dashboard_test_utils.ex | 29 ++ test/support/html.ex | 7 +- 7 files changed, 367 insertions(+), 160 deletions(-) create mode 100644 test/plausible_web/live/dashboard/pages_test.exs create mode 100644 test/support/dashboard_test_utils.ex diff --git a/lib/plausible/stats/parsed_query_params.ex b/lib/plausible/stats/parsed_query_params.ex index 0c473db7a83d..fc9a047af461 100644 --- a/lib/plausible/stats/parsed_query_params.ex +++ b/lib/plausible/stats/parsed_query_params.ex @@ -44,4 +44,10 @@ defmodule Plausible.Stats.ParsedQueryParams do struct!(parsed_query_params, filters: new_filters) end + + def conversion_goal_filter?(%__MODULE__{filters: filters}) do + Enum.any?(filters, fn [operator, dimension, _clauses] -> + operator in [:is, :contains] and dimension == "event:goal" + end) + end end diff --git a/lib/plausible_web/live/components/dashboard/report_list.ex b/lib/plausible_web/live/components/dashboard/report_list.ex index 649c177a71e6..1f98f248f129 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,45 +29,53 @@ 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 + %QueryResult{results: results, meta: meta, query: query} = assigns.query_result.result + + # TODO: Consider a `query.include` flag like `dashboard_style_response` to return + # metric values per key in a map to make this a bit easier. Currently, we need to + # fetch metrics by indices. For simplicity, we assume that `:visitors` is always + # the first metric. + :visitors = List.first(query[:metrics]) max_value = results - |> Enum.map(& &1.visitors) + |> Enum.map(&List.first(&1.metrics)) |> Enum.max(&>=/2, fn -> 0 end) assigns = assign(assigns, max_value: max_value, results: results, - metrics: metrics, - meta: meta, - skip_imported_reason: skip_imported_reason, + metrics: query[:metrics], + metric_labels: Enum.map(query[:metrics], &Map.get(meta[:metric_labels], &1)), 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} + item_index={item_index} + item_name={List.first(item.dimensions)} metrics={@metrics} - bar_value={item.visitors} + bar_value={List.first(item.metrics)} bar_max_value={@max_value} site={@site} params={@params} @@ -132,13 +141,14 @@ defmodule PlausibleWeb.Components.Dashboard.ReportList do defp report_header(assigns) do ~H"""
- {@key_label} + {@key_label}
- {metric.label} + {metric_label}
""" @@ -151,7 +161,7 @@ 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;"} > -
+
- {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 +183,18 @@ defmodule PlausibleWeb.Components.Dashboard.ReportList do
- - + +
diff --git a/lib/plausible_web/live/components/dashboard/tile.ex b/lib/plausible_web/live/components/dashboard/tile.ex index 7561a5abc5dd..a43c8b6d99e8 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)} diff --git a/lib/plausible_web/live/dashboard/pages.ex b/lib/plausible_web/live/dashboard/pages.ex index b5c92ff57e58..1cae97ad50ab 100644 --- a/lib/plausible_web/live/dashboard/pages.ex +++ b/lib/plausible_web/live/dashboard/pages.ex @@ -9,9 +9,7 @@ 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"}, @@ -25,56 +23,7 @@ defmodule PlausibleWeb.Live.Dashboard.Pages do "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 - } - }, - "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 - } - }, - "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 - } - } - } + @pagination %{limit: 9, offset: 0} @filter_dimensions %{ "pages" => "event:page", @@ -95,7 +44,7 @@ defmodule PlausibleWeb.Live.Dashboard.Pages do active_tab: active_tab, connected?: assigns.connected? ) - |> load_metrics() + |> load_stats() {:ok, socket} end @@ -110,6 +59,7 @@ defmodule PlausibleWeb.Live.Dashboard.Pages do class="group/report" title={@key_labels[@active_tab]} connected?={@connected?} + target={@myself} height={ReportList.height()} > <:tabs> @@ -124,13 +74,11 @@ defmodule PlausibleWeb.Live.Dashboard.Pages do @@ -143,7 +91,7 @@ defmodule PlausibleWeb.Live.Dashboard.Pages do socket = socket |> assign(:active_tab, tab) - |> load_metrics() + |> load_stats() {:noreply, socket} else @@ -155,94 +103,34 @@ 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"]) - - {:ok, query} = QueryBuilder.build(site, params, %{}) - metrics = breakdown_metrics(query) - - %{results: results, meta: meta} = Stats.breakdown(site, query, metrics, @pagination_params) + assign_async(socket, :query_result, fn -> + metrics = choose_metrics(params) + dimension = @filter_dimensions[active_tab] - pages = - results - |> transform_keys(%{page: :name}) + params = + params + |> ParsedQueryParams.set( + metrics: metrics, + dimensions: [dimension], + pagination: @pagination + ) - %{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"]) - - {:ok, query} = QueryBuilder.build(site, params, %{}) - metrics = breakdown_metrics(query) + query = QueryBuilder.build!(site, params) - %{results: results, meta: meta} = Stats.breakdown(site, query, metrics, @pagination_params) - - pages = - results - |> transform_keys(%{entry_page: :name}) - - %{query: query, results: pages, meta: meta, metrics: metrics} - end + %QueryResult{} = query_result = Stats.query(site, query) - defp metrics_for_tab("exit-pages", site, params) do - params = - params - |> ParsedQueryParams.set(dimensions: ["visit:exit_page"]) - - {: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 - ) - end end 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..0ab1921c230d --- /dev/null +++ b/test/plausible_web/live/dashboard/pages_test.exs @@ -0,0 +1,261 @@ +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 get_in_report_list(report_list, :key_label) =~ "Page" + assert get_in_report_list(report_list, metric_label: 0) =~ "Visitors" + + assert get_in_report_list(report_list, item_name: 0) =~ "/three" + assert get_in_report_list(report_list, item: 0, metric: 0) =~ "3" + + assert get_in_report_list(report_list, item_name: 1) =~ "/two" + assert get_in_report_list(report_list, item: 1, metric: 0) =~ "2" + + assert get_in_report_list(report_list, item_name: 2) =~ "/one" + assert get_in_report_list(report_list, item: 2, metric: 0) =~ "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 get_in_report_list(report_list, :key_label) =~ "Page" + assert get_in_report_list(report_list, metric_label: 0) =~ "Current visitors" + + assert get_in_report_list(report_list, item_name: 0) =~ "/two" + assert get_in_report_list(report_list, item: 0, metric: 0) =~ "2" + + assert get_in_report_list(report_list, item_name: 1) =~ "/one" + assert get_in_report_list(report_list, item: 1, metric: 0) =~ "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 get_in_report_list(report_list, :key_label) =~ "Page" + assert get_in_report_list(report_list, metric_label: 0) =~ "Conversions" + assert get_in_report_list(report_list, metric_label: 1) =~ "CR" + + assert get_in_report_list(report_list, item_name: 0) =~ "/two" + assert get_in_report_list(report_list, item: 0, metric: 0) =~ "1" + assert get_in_report_list(report_list, item: 0, metric: 1) =~ "33.33%" + + refute get_in_report_list(report_list, item_name: 1) + 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 get_in_report_list(report_list, :key_label) =~ "Entry page" + assert get_in_report_list(report_list, metric_label: 0) =~ "Unique entrances" + + assert get_in_report_list(report_list, item_name: 0) =~ "/three" + assert get_in_report_list(report_list, item: 0, metric: 0) =~ "3" + + assert get_in_report_list(report_list, item_name: 1) =~ "/two" + assert get_in_report_list(report_list, item: 1, metric: 0) =~ "2" + + assert get_in_report_list(report_list, item_name: 2) =~ "/one" + assert get_in_report_list(report_list, item: 2, metric: 0) =~ "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 get_in_report_list(report_list, :key_label) =~ "Entry page" + assert get_in_report_list(report_list, metric_label: 0) =~ "Current visitors" + + assert get_in_report_list(report_list, item_name: 0) =~ "/two" + assert get_in_report_list(report_list, item: 0, metric: 0) =~ "2" + + assert get_in_report_list(report_list, item_name: 1) =~ "/one" + assert get_in_report_list(report_list, item: 1, metric: 0) =~ "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 get_in_report_list(report_list, :key_label) =~ "Entry page" + assert get_in_report_list(report_list, metric_label: 0) =~ "Conversions" + assert get_in_report_list(report_list, metric_label: 1) =~ "CR" + + assert get_in_report_list(report_list, item_name: 0) =~ "/two" + assert get_in_report_list(report_list, item: 0, metric: 0) =~ "1" + assert get_in_report_list(report_list, item: 0, metric: 1) =~ "33.33%" + + refute get_in_report_list(report_list, item_name: 1) + 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 get_in_report_list(report_list, :key_label) =~ "Exit page" + assert get_in_report_list(report_list, metric_label: 0) =~ "Unique exits" + + assert get_in_report_list(report_list, item_name: 0) =~ "/three" + assert get_in_report_list(report_list, item: 0, metric: 0) =~ "3" + + assert get_in_report_list(report_list, item_name: 1) =~ "/two" + assert get_in_report_list(report_list, item: 1, metric: 0) =~ "2" + + assert get_in_report_list(report_list, item_name: 2) =~ "/one" + assert get_in_report_list(report_list, item: 2, metric: 0) =~ "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 get_in_report_list(report_list, :key_label) =~ "Exit page" + assert get_in_report_list(report_list, metric_label: 0) =~ "Current visitors" + + assert get_in_report_list(report_list, item_name: 0) =~ "/two" + assert get_in_report_list(report_list, item: 0, metric: 0) =~ "2" + + assert get_in_report_list(report_list, item_name: 1) =~ "/one" + assert get_in_report_list(report_list, item: 1, metric: 0) =~ "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 get_in_report_list(report_list, :key_label) =~ "Exit page" + assert get_in_report_list(report_list, metric_label: 0) =~ "Conversions" + assert get_in_report_list(report_list, metric_label: 1) =~ "CR" + + assert get_in_report_list(report_list, item_name: 0) =~ "/two" + assert get_in_report_list(report_list, item: 0, metric: 0) =~ "1" + assert get_in_report_list(report_list, item: 0, metric: 1) =~ "33.33%" + + refute get_in_report_list(report_list, item_name: 1) + 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..e17fcac84a60 --- /dev/null +++ b/test/support/dashboard_test_utils.ex @@ -0,0 +1,29 @@ +defmodule Plausible.DashboardTestUtils do + import Plausible.Test.Support.HTML + + def get_in_report_list(%LazyHTML{} = report_list, opts) do + selector = + case opts do + :key_label -> + ~s|[data-test-id="key-label"]| + + [metric_label: idx] -> + ~s|[data-test-id="metric-#{idx}-label"]| + + [item_name: idx] -> + ~s|[data-test-id="item-#{idx}-name"]| + + opts -> + item_idx = Keyword.fetch!(opts, :item) + metric_idx = Keyword.fetch!(opts, :metric) + + ~s|[data-test-id="item-#{item_idx}-metric-#{metric_idx}"]| + end + + if element_exists?(report_list, selector) do + text_of_element(report_list, selector) + else + nil + 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 From e298dcf328f3accaed2bada3ae0d8fb682b7fc47 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Sat, 27 Dec 2025 13:42:59 +0000 Subject: [PATCH 05/17] a few renamings --- assets/js/liveview/dashboard_tabs.js | 12 ++-- .../live/components/dashboard/report_list.ex | 4 +- .../live/components/dashboard/tile.ex | 14 ++--- lib/plausible_web/live/dashboard/pages.ex | 57 +++++++++++-------- 4 files changed, 47 insertions(+), 40 deletions(-) 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/lib/plausible_web/live/components/dashboard/report_list.ex b/lib/plausible_web/live/components/dashboard/report_list.ex index 1f98f248f129..9d2be515bc76 100644 --- a/lib/plausible_web/live/components/dashboard/report_list.ex +++ b/lib/plausible_web/live/components/dashboard/report_list.ex @@ -79,7 +79,7 @@ defmodule PlausibleWeb.Components.Dashboard.ReportList do bar_max_value={@max_value} 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} @@ -173,7 +173,7 @@ 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)} diff --git a/lib/plausible_web/live/components/dashboard/tile.ex b/lib/plausible_web/live/components/dashboard/tile.ex index a43c8b6d99e8..e8b7145fa29b 100644 --- a/lib/plausible_web/live/components/dashboard/tile.ex +++ b/lib/plausible_web/live/components/dashboard/tile.ex @@ -50,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 @@ -60,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"} ) @@ -69,8 +69,8 @@ defmodule PlausibleWeb.Components.Dashboard.Tile do ~H""" """ diff --git a/lib/plausible_web/live/dashboard/pages.ex b/lib/plausible_web/live/dashboard/pages.ex index 1cae97ad50ab..b711db1af24f 100644 --- a/lib/plausible_web/live/dashboard/pages.ex +++ b/lib/plausible_web/live/dashboard/pages.ex @@ -12,25 +12,28 @@ defmodule PlausibleWeb.Live.Dashboard.Pages do alias Plausible.Stats.{ParsedQueryParams, QueryBuilder, QueryResult} @tabs [ - {"pages", "Top Pages"}, - {"entry-pages", "Entry Pages"}, - {"exit-pages", "Exit Pages"} + %{ + tab_key: "pages", + report_label: "Top pages", + key_label: "Page", + dimension: "event:page" + }, + %{ + tab_key: "entry-pages", + report_label: "Entry pages", + key_label: "Entry page", + dimension: "visit:entry_page" + }, + %{ + tab_key: "exit-pages", + report_label: "Exit pages", + key_label: "Exit page", + dimension: "visit:exit_page" + } ] - @key_labels %{ - "pages" => "Page", - "entry-pages" => "Entry page", - "exit-pages" => "Exit page" - } - @pagination %{limit: 9, offset: 0} - @filter_dimensions %{ - "pages" => "event:page", - "entry-pages" => "visit:entry_page", - "exit-pages" => "visit:exit_page" - } - def update(assigns, socket) do active_tab = assigns.user_prefs["pages_tab"] || "pages" @@ -39,8 +42,6 @@ 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? ) @@ -57,17 +58,17 @@ defmodule PlausibleWeb.Live.Dashboard.Pages do <:tabs> @@ -75,8 +76,8 @@ defmodule PlausibleWeb.Live.Dashboard.Pages do metrics = choose_metrics(params) - dimension = @filter_dimensions[active_tab] + dimension = get_tab_info(active_tab, :dimension) params = params @@ -133,4 +134,10 @@ defmodule PlausibleWeb.Live.Dashboard.Pages do [:visitors] end end + + defp get_tab_info(tab_key, field) do + @tabs + |> Enum.find(&(&1.tab_key == tab_key)) + |> Map.fetch!(field) + end end From c17031b3d4accc110c84c3fa456b505541d173a5 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Sat, 27 Dec 2025 16:50:02 +0000 Subject: [PATCH 06/17] period from user prefs or default for site --- assets/js/liveview/live_socket.js | 3 +- lib/plausible/stats/dashboard_query_parser.ex | 61 ++++++--- lib/plausible_web/live/dashboard.ex | 15 +- .../query/dashboard_query_parser_test.exs | 129 ++++++++++++------ .../query/dashboard_query_serializer_test.exs | 12 +- 5 files changed, 150 insertions(+), 70 deletions(-) diff --git a/assets/js/liveview/live_socket.js b/assets/js/liveview/live_socket.js index 627eaff7a44e..ec60b6300bae 100644 --- a/assets/js/liveview/live_socket.js +++ b/assets/js/liveview/live_socket.js @@ -79,7 +79,8 @@ 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}`) }, _csrf_token: token } diff --git a/lib/plausible/stats/dashboard_query_parser.ex b/lib/plausible/stats/dashboard_query_parser.ex index 7c647b7f3020..5486f2b823a2 100644 --- a/lib/plausible/stats/dashboard_query_parser.ex +++ b/lib/plausible/stats/dashboard_query_parser.ex @@ -29,12 +29,30 @@ 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) + + 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), @@ -44,7 +62,7 @@ defmodule Plausible.Stats.DashboardQueryParser do {: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 @@ -52,25 +70,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" => "custom", "from" => from, "to" => to}) do + 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}, + _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 @@ -156,4 +178,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_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/test/plausible/stats/query/dashboard_query_parser_test.exs b/test/plausible/stats/query/dashboard_query_parser_test.exs index c63c8ebefb50..665975a4aad3 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)}, - include: ^@default_include + 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)} } = 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,19 @@ 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 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]}) @@ -124,24 +171,24 @@ defmodule Plausible.Stats.DashboardQueryParserTest do end test "invalid -> nil" do - {:ok, parsed} = parse("?comparison=invalid_mode") + {:ok, parsed} = parse("?comparison=invalid_mode", build(:site), %{}) 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 @@ -150,7 +197,11 @@ defmodule Plausible.Stats.DashboardQueryParserTest do 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 +215,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 +224,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 +233,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 From 2f4bff32477dfcfc8d25d171138631b9da6e238c Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Sat, 27 Dec 2025 23:33:00 +0000 Subject: [PATCH 07/17] parse comparison from user prefs --- assets/js/liveview/live_socket.js | 3 +- lib/plausible/stats/dashboard_query_parser.ex | 26 ++++++++--- .../query/dashboard_query_parser_test.exs | 43 ++++++++++++++++++- 3 files changed, 64 insertions(+), 8 deletions(-) diff --git a/assets/js/liveview/live_socket.js b/assets/js/liveview/live_socket.js index ec60b6300bae..4a6dba1a91c9 100644 --- a/assets/js/liveview/live_socket.js +++ b/assets/js/liveview/live_socket.js @@ -80,7 +80,8 @@ if (csrfToken && websocketUrl) { // user preferences across the reloads. user_prefs: { pages_tab: localStorage.getItem(`pageTab__${domainName}`), - period: localStorage.getItem(`period__${domainName}`) + period: localStorage.getItem(`period__${domainName}`), + comparison: localStorage.getItem(`comparison_mode__${domainName}`) }, _csrf_token: token } diff --git a/lib/plausible/stats/dashboard_query_parser.ex b/lib/plausible/stats/dashboard_query_parser.ex index 5486f2b823a2..1de06e0ef7bd 100644 --- a/lib/plausible/stats/dashboard_query_parser.ex +++ b/lib/plausible/stats/dashboard_query_parser.ex @@ -45,6 +45,13 @@ defmodule Plausible.Stats.DashboardQueryParser do @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 = URI.decode_query(query_string) @@ -56,7 +63,7 @@ defmodule Plausible.Stats.DashboardQueryParser do include = Map.merge(@default_include, %{ imports: parse_include_imports(params_map), - compare: parse_include_compare(params_map), + compare: parse_include_compare(params_map, user_prefs), compare_match_day_of_week: parse_include_compare_match_day_of_week(params_map) }) @@ -106,16 +113,25 @@ 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" => "custom"} = params) do + 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, _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 diff --git a/test/plausible/stats/query/dashboard_query_parser_test.exs b/test/plausible/stats/query/dashboard_query_parser_test.exs index 665975a4aad3..01ca3dc6fa3d 100644 --- a/test/plausible/stats/query/dashboard_query_parser_test.exs +++ b/test/plausible/stats/query/dashboard_query_parser_test.exs @@ -154,6 +154,12 @@ defmodule Plausible.Stats.DashboardQueryParserTest do 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 @@ -170,8 +176,41 @@ defmodule Plausible.Stats.DashboardQueryParserTest do assert_matches %ParsedQueryParams{include: ^expected_include} = parsed end - test "invalid -> nil" do - {:ok, parsed} = parse("?comparison=invalid_mode", build(:site), %{}) + 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 "comparion=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 From 2091e5b6954be2bab3f010bed215e0ae96d28bfe Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Sat, 27 Dec 2025 23:59:45 +0000 Subject: [PATCH 08/17] parse match_day_of_week from user prefs --- assets/js/liveview/live_socket.js | 3 +- lib/plausible/stats/dashboard_query_parser.ex | 8 +++-- .../query/dashboard_query_parser_test.exs | 30 +++++++++++++++++++ 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/assets/js/liveview/live_socket.js b/assets/js/liveview/live_socket.js index 4a6dba1a91c9..3f2758b47a05 100644 --- a/assets/js/liveview/live_socket.js +++ b/assets/js/liveview/live_socket.js @@ -81,7 +81,8 @@ if (csrfToken && websocketUrl) { user_prefs: { pages_tab: localStorage.getItem(`pageTab__${domainName}`), period: localStorage.getItem(`period__${domainName}`), - comparison: localStorage.getItem(`comparison_mode__${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/dashboard_query_parser.ex b/lib/plausible/stats/dashboard_query_parser.ex index 1de06e0ef7bd..4b95d9fe9163 100644 --- a/lib/plausible/stats/dashboard_query_parser.ex +++ b/lib/plausible/stats/dashboard_query_parser.ex @@ -64,7 +64,7 @@ defmodule Plausible.Stats.DashboardQueryParser do Map.merge(@default_include, %{ imports: parse_include_imports(params_map), compare: parse_include_compare(params_map, user_prefs), - compare_match_day_of_week: parse_include_compare_match_day_of_week(params_map) + compare_match_day_of_week: parse_match_day_of_week(params_map, user_prefs) }) {:ok, @@ -133,8 +133,10 @@ defmodule Plausible.Stats.DashboardQueryParser do 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 diff --git a/test/plausible/stats/query/dashboard_query_parser_test.exs b/test/plausible/stats/query/dashboard_query_parser_test.exs index 01ca3dc6fa3d..dfd2fdd77cb5 100644 --- a/test/plausible/stats/query/dashboard_query_parser_test.exs +++ b/test/plausible/stats/query/dashboard_query_parser_test.exs @@ -231,6 +231,36 @@ defmodule Plausible.Stats.DashboardQueryParserTest do 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 From c5a0d53b07b83bfbd2d3bbb0a987b94d3c915f6f Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Mon, 29 Dec 2025 16:55:55 +0000 Subject: [PATCH 09/17] npm run format --- assets/js/liveview/live_socket.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/assets/js/liveview/live_socket.js b/assets/js/liveview/live_socket.js index 3f2758b47a05..60ae66a0cd81 100644 --- a/assets/js/liveview/live_socket.js +++ b/assets/js/liveview/live_socket.js @@ -82,7 +82,9 @@ if (csrfToken && websocketUrl) { 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}`) + match_day_of_week: localStorage.getItem( + `comparison_match_day_of_week__${domainName}` + ) }, _csrf_token: token } From 0799cf1aa48fc3ea8709628582da6ac8443b9dc8 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Mon, 29 Dec 2025 16:57:24 +0000 Subject: [PATCH 10/17] credo --- lib/plausible_web/live/components/dashboard/report_list.ex | 2 +- test/support/dashboard_test_utils.ex | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/plausible_web/live/components/dashboard/report_list.ex b/lib/plausible_web/live/components/dashboard/report_list.ex index 9d2be515bc76..f5d6893952f5 100644 --- a/lib/plausible_web/live/components/dashboard/report_list.ex +++ b/lib/plausible_web/live/components/dashboard/report_list.ex @@ -35,7 +35,7 @@ defmodule PlausibleWeb.Components.Dashboard.ReportList do else %QueryResult{results: results, meta: meta, query: query} = assigns.query_result.result - # TODO: Consider a `query.include` flag like `dashboard_style_response` to return + # NOTE: Consider a `query.include` flag like `dashboard_style_response` to return # metric values per key in a map to make this a bit easier. Currently, we need to # fetch metrics by indices. For simplicity, we assume that `:visitors` is always # the first metric. diff --git a/test/support/dashboard_test_utils.ex b/test/support/dashboard_test_utils.ex index e17fcac84a60..fb4e96e3981e 100644 --- a/test/support/dashboard_test_utils.ex +++ b/test/support/dashboard_test_utils.ex @@ -1,4 +1,6 @@ defmodule Plausible.DashboardTestUtils do + @moduledoc false + import Plausible.Test.Support.HTML def get_in_report_list(%LazyHTML{} = report_list, opts) do From a5cd80a3e012cc30ef0a359b69754b2920bc2c10 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Mon, 29 Dec 2025 17:12:09 +0000 Subject: [PATCH 11/17] codespell --- test/plausible/stats/query/dashboard_query_parser_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/plausible/stats/query/dashboard_query_parser_test.exs b/test/plausible/stats/query/dashboard_query_parser_test.exs index dfd2fdd77cb5..b0906eb63336 100644 --- a/test/plausible/stats/query/dashboard_query_parser_test.exs +++ b/test/plausible/stats/query/dashboard_query_parser_test.exs @@ -200,7 +200,7 @@ defmodule Plausible.Stats.DashboardQueryParserTest do assert_matches %ParsedQueryParams{include: ^expected_include} = parsed end - test "comparion=off in query string skips stored comparison mode" do + test "comparison=off in query string skips stored comparison mode" do {:ok, parsed} = parse("?comparison=off", build(:site), %{"comparison" => "previous_period"}) From 9f7695b3200ab69ea29bb17f958e31c07bbb60c0 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Mon, 29 Dec 2025 18:42:24 +0000 Subject: [PATCH 12/17] improve ReportList rendering logic + metric labels to list instead of map --- lib/plausible/stats/metrics.ex | 2 + lib/plausible/stats/query_result.ex | 3 +- .../live/components/dashboard/report_list.ex | 39 ++++++++----------- test/plausible/stats/query/query_test.exs | 29 ++++++++++---- 4 files changed, 41 insertions(+), 32 deletions(-) diff --git a/lib/plausible/stats/metrics.ex b/lib/plausible/stats/metrics.ex index 43d1ee43f845..d835c1141875 100644 --- a/lib/plausible/stats/metrics.ex +++ b/lib/plausible/stats/metrics.ex @@ -66,4 +66,6 @@ defmodule Plausible.Stats.Metrics do 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/query_result.ex b/lib/plausible/stats/query_result.ex index c34d8147679c..46b8088421b2 100644 --- a/lib/plausible/stats/query_result.ex +++ b/lib/plausible/stats/query_result.ex @@ -111,9 +111,8 @@ defmodule Plausible.Stats.QueryResult do metric_labels = query.metrics |> Enum.map(fn metric -> - {metric, Plausible.Stats.Metrics.dashboard_metric_label(metric, context)} + Plausible.Stats.Metrics.dashboard_metric_label(metric, context) end) - |> Map.new() Map.put(meta, :metric_labels, metric_labels) end diff --git a/lib/plausible_web/live/components/dashboard/report_list.ex b/lib/plausible_web/live/components/dashboard/report_list.ex index f5d6893952f5..d58b949abca6 100644 --- a/lib/plausible_web/live/components/dashboard/report_list.ex +++ b/lib/plausible_web/live/components/dashboard/report_list.ex @@ -35,23 +35,11 @@ defmodule PlausibleWeb.Components.Dashboard.ReportList do else %QueryResult{results: results, meta: meta, query: query} = assigns.query_result.result - # NOTE: Consider a `query.include` flag like `dashboard_style_response` to return - # metric values per key in a map to make this a bit easier. Currently, we need to - # fetch metrics by indices. For simplicity, we assume that `:visitors` is always - # the first metric. - :visitors = List.first(query[:metrics]) - - max_value = - results - |> Enum.map(&List.first(&1.metrics)) - |> Enum.max(&>=/2, fn -> 0 end) - assigns = assign(assigns, - max_value: max_value, results: results, - metrics: query[:metrics], - metric_labels: Enum.map(query[:metrics], &Map.get(meta[:metric_labels], &1)), + metric_keys: query[:metrics], + metric_labels: meta[:metric_labels], empty?: Enum.empty?(results) ) @@ -74,9 +62,8 @@ defmodule PlausibleWeb.Components.Dashboard.ReportList do item={item} item_index={item_index} item_name={List.first(item.dimensions)} - metrics={@metrics} - bar_value={List.first(item.metrics)} - bar_max_value={@max_value} + metrics={Enum.zip(@metric_keys, item.metrics)} + bar_max_value={bar_max_value(@results, @metric_keys)} site={@site} params={@params} dimension={@dimension} @@ -163,7 +150,7 @@ defmodule PlausibleWeb.Components.Dashboard.ReportList do >
@@ -183,7 +170,7 @@ defmodule PlausibleWeb.Components.Dashboard.ReportList do
@@ -191,10 +178,7 @@ defmodule PlausibleWeb.Components.Dashboard.ReportList do class="font-medium text-sm dark:text-gray-200 text-right" data-test-id={"item-#{@item_index}-metric-#{metric_index}"} > - +
@@ -228,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/test/plausible/stats/query/query_test.exs b/test/plausible/stats/query/query_test.exs index d17a17667a2e..7afa30451e5a 100644 --- a/test/plausible/stats/query/query_test.exs +++ b/test/plausible/stats/query/query_test.exs @@ -137,7 +137,7 @@ defmodule Plausible.Stats.QueryTest do ) %Stats.QueryResult{meta: meta} = Stats.query(site, query) - assert %{visitors: "Visitors"} = meta[:metric_labels] + assert ["Visitors"] = meta[:metric_labels] end test "visitors -> Current visitors (realtime)", %{site: site} do @@ -149,7 +149,7 @@ defmodule Plausible.Stats.QueryTest do ) %Stats.QueryResult{meta: meta} = Stats.query(site, query) - assert %{visitors: "Current visitors"} = meta[:metric_labels] + assert ["Current visitors"] = meta[:metric_labels] end test "visitors -> Current visitors (realtime and goal filtered)", %{site: site} do @@ -164,7 +164,7 @@ defmodule Plausible.Stats.QueryTest do ) %Stats.QueryResult{meta: meta} = Stats.query(site, query) - assert %{visitors: "Current visitors"} = meta[:metric_labels] + assert ["Current visitors"] = meta[:metric_labels] end test "visitors -> Conversions (goal filtered)", %{site: site} do @@ -179,7 +179,7 @@ defmodule Plausible.Stats.QueryTest do ) %Stats.QueryResult{meta: meta} = Stats.query(site, query) - assert %{visitors: "Conversions"} = meta[:metric_labels] + assert ["Conversions"] = meta[:metric_labels] end test "visitors -> Unique entrances (visit:entry_page dimension)", %{site: site} do @@ -192,7 +192,7 @@ defmodule Plausible.Stats.QueryTest do ) %Stats.QueryResult{meta: meta} = Stats.query(site, query) - assert %{visitors: "Unique entrances"} = meta[:metric_labels] + assert ["Unique entrances"] = meta[:metric_labels] end test "visitors -> Unique exits (visit:exit_page dimension)", %{site: site} do @@ -205,7 +205,7 @@ defmodule Plausible.Stats.QueryTest do ) %Stats.QueryResult{meta: meta} = Stats.query(site, query) - assert %{visitors: "Unique exits"} = meta[:metric_labels] + assert ["Unique exits"] = meta[:metric_labels] end test "conversion_rate -> CR (default)", %{site: site} do @@ -218,7 +218,22 @@ defmodule Plausible.Stats.QueryTest do ) %Stats.QueryResult{meta: meta} = Stats.query(site, query) - assert %{conversion_rate: "CR"} = meta[:metric_labels] + 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 From 796a655bc81e89b47d2a6edc3319ed42e1efb6e7 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Mon, 29 Dec 2025 19:50:39 +0000 Subject: [PATCH 13/17] avoid duplication in conversion_goal_filter --- lib/plausible/stats/filters/filters.ex | 4 ++-- lib/plausible/stats/parsed_query_params.ex | 6 ------ lib/plausible_web/live/dashboard/pages.ex | 7 +++++-- 3 files changed, 7 insertions(+), 10 deletions(-) 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/parsed_query_params.ex b/lib/plausible/stats/parsed_query_params.ex index fc9a047af461..0c473db7a83d 100644 --- a/lib/plausible/stats/parsed_query_params.ex +++ b/lib/plausible/stats/parsed_query_params.ex @@ -44,10 +44,4 @@ defmodule Plausible.Stats.ParsedQueryParams do struct!(parsed_query_params, filters: new_filters) end - - def conversion_goal_filter?(%__MODULE__{filters: filters}) do - Enum.any?(filters, fn [operator, dimension, _clauses] -> - operator in [:is, :contains] and dimension == "event:goal" - end) - end end diff --git a/lib/plausible_web/live/dashboard/pages.ex b/lib/plausible_web/live/dashboard/pages.ex index b711db1af24f..02529a598aec 100644 --- a/lib/plausible_web/live/dashboard/pages.ex +++ b/lib/plausible_web/live/dashboard/pages.ex @@ -127,8 +127,11 @@ defmodule PlausibleWeb.Live.Dashboard.Pages do end) end - defp choose_metrics(%ParsedQueryParams{} = params) do - if ParsedQueryParams.conversion_goal_filter?(params) do + defp choose_metrics(%ParsedQueryParams{filters: filters}) do + if Plausible.Stats.Filters.filtering_on_dimension?(filters, "event:goal", + max_depth: 0, + behavioral_filters: :ignore + ) do [:visitors, :conversion_rate] else [:visitors] From 9340e3a88e0467a666556fcda3481a74882228d8 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Tue, 30 Dec 2025 10:08:53 +0000 Subject: [PATCH 14/17] add back conversion_goal_filter? fn --- lib/plausible/stats/parsed_query_params.ex | 7 +++++++ lib/plausible_web/live/dashboard/pages.ex | 7 ++----- 2 files changed, 9 insertions(+), 5 deletions(-) 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_web/live/dashboard/pages.ex b/lib/plausible_web/live/dashboard/pages.ex index 02529a598aec..b711db1af24f 100644 --- a/lib/plausible_web/live/dashboard/pages.ex +++ b/lib/plausible_web/live/dashboard/pages.ex @@ -127,11 +127,8 @@ defmodule PlausibleWeb.Live.Dashboard.Pages do end) end - defp choose_metrics(%ParsedQueryParams{filters: filters}) do - if Plausible.Stats.Filters.filtering_on_dimension?(filters, "event:goal", - max_depth: 0, - behavioral_filters: :ignore - ) do + defp choose_metrics(%ParsedQueryParams{} = params) do + if ParsedQueryParams.conversion_goal_filter?(params) do [:visitors, :conversion_rate] else [:visitors] From fa44c4d64d42ef8c093592fc63dc8d6597f3dfdc Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Tue, 30 Dec 2025 10:11:08 +0000 Subject: [PATCH 15/17] apply test util suggestion + more tests --- .../live/components/dashboard/base.ex | 1 + .../live/components/dashboard/report_list.ex | 8 +- .../components/dashboard/report_list_test.exs | 65 +++++++++ .../live/dashboard/pages_test.exs | 129 +++++++----------- test/support/dashboard_test_utils.ex | 35 +++-- 5 files changed, 134 insertions(+), 104 deletions(-) create mode 100644 test/plausible_web/live/components/dashboard/report_list_test.exs 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 d58b949abca6..474748c2075c 100644 --- a/lib/plausible_web/live/components/dashboard/report_list.ex +++ b/lib/plausible_web/live/components/dashboard/report_list.ex @@ -128,12 +128,12 @@ defmodule PlausibleWeb.Components.Dashboard.ReportList do defp report_header(assigns) do ~H"""
- {@key_label} + {@key_label}
{metric_label}
@@ -148,7 +148,7 @@ 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;"} > -
+
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/pages_test.exs b/test/plausible_web/live/dashboard/pages_test.exs index 0ab1921c230d..21f28fb41df9 100644 --- a/test/plausible_web/live/dashboard/pages_test.exs +++ b/test/plausible_web/live/dashboard/pages_test.exs @@ -23,17 +23,12 @@ defmodule PlausibleWeb.Live.Dashboard.PagesTest do assert report_list = get_liveview(conn, site) |> get_report_list(@top_pages_report_list) - assert get_in_report_list(report_list, :key_label) =~ "Page" - assert get_in_report_list(report_list, metric_label: 0) =~ "Visitors" - - assert get_in_report_list(report_list, item_name: 0) =~ "/three" - assert get_in_report_list(report_list, item: 0, metric: 0) =~ "3" - - assert get_in_report_list(report_list, item_name: 1) =~ "/two" - assert get_in_report_list(report_list, item: 1, metric: 0) =~ "2" - - assert get_in_report_list(report_list, item_name: 2) =~ "/one" - assert get_in_report_list(report_list, item: 2, metric: 0) =~ "1" + 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 @@ -47,14 +42,11 @@ defmodule PlausibleWeb.Live.Dashboard.PagesTest do get_liveview(conn, site, "period=realtime") |> get_report_list(@top_pages_report_list) - assert get_in_report_list(report_list, :key_label) =~ "Page" - assert get_in_report_list(report_list, metric_label: 0) =~ "Current visitors" - - assert get_in_report_list(report_list, item_name: 0) =~ "/two" - assert get_in_report_list(report_list, item: 0, metric: 0) =~ "2" - - assert get_in_report_list(report_list, item_name: 1) =~ "/one" - assert get_in_report_list(report_list, item: 1, metric: 0) =~ "1" + 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 @@ -70,15 +62,12 @@ defmodule PlausibleWeb.Live.Dashboard.PagesTest do get_liveview(conn, site, "period=day&f=is,goal,Signup") |> get_report_list(@top_pages_report_list) - assert get_in_report_list(report_list, :key_label) =~ "Page" - assert get_in_report_list(report_list, metric_label: 0) =~ "Conversions" - assert get_in_report_list(report_list, metric_label: 1) =~ "CR" + assert report_list_as_table(report_list, 2, 3) == [ + ["Page", "Conversions", "CR"], + ["/two", "1", "33.33%"] + ] - assert get_in_report_list(report_list, item_name: 0) =~ "/two" - assert get_in_report_list(report_list, item: 0, metric: 0) =~ "1" - assert get_in_report_list(report_list, item: 0, metric: 1) =~ "33.33%" - - refute get_in_report_list(report_list, item_name: 1) + refute get_in_report_list(report_list, 2, 0) end end @@ -98,17 +87,12 @@ defmodule PlausibleWeb.Live.Dashboard.PagesTest do |> change_tab("entry-pages") |> get_report_list(@entry_pages_report_list) - assert get_in_report_list(report_list, :key_label) =~ "Entry page" - assert get_in_report_list(report_list, metric_label: 0) =~ "Unique entrances" - - assert get_in_report_list(report_list, item_name: 0) =~ "/three" - assert get_in_report_list(report_list, item: 0, metric: 0) =~ "3" - - assert get_in_report_list(report_list, item_name: 1) =~ "/two" - assert get_in_report_list(report_list, item: 1, metric: 0) =~ "2" - - assert get_in_report_list(report_list, item_name: 2) =~ "/one" - assert get_in_report_list(report_list, item: 2, metric: 0) =~ "1" + 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 @@ -123,14 +107,11 @@ defmodule PlausibleWeb.Live.Dashboard.PagesTest do |> change_tab("entry-pages") |> get_report_list(@entry_pages_report_list) - assert get_in_report_list(report_list, :key_label) =~ "Entry page" - assert get_in_report_list(report_list, metric_label: 0) =~ "Current visitors" - - assert get_in_report_list(report_list, item_name: 0) =~ "/two" - assert get_in_report_list(report_list, item: 0, metric: 0) =~ "2" - - assert get_in_report_list(report_list, item_name: 1) =~ "/one" - assert get_in_report_list(report_list, item: 1, metric: 0) =~ "1" + 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 @@ -148,15 +129,12 @@ defmodule PlausibleWeb.Live.Dashboard.PagesTest do |> change_tab("entry-pages") |> get_report_list(@entry_pages_report_list) - assert get_in_report_list(report_list, :key_label) =~ "Entry page" - assert get_in_report_list(report_list, metric_label: 0) =~ "Conversions" - assert get_in_report_list(report_list, metric_label: 1) =~ "CR" - - assert get_in_report_list(report_list, item_name: 0) =~ "/two" - assert get_in_report_list(report_list, item: 0, metric: 0) =~ "1" - assert get_in_report_list(report_list, item: 0, metric: 1) =~ "33.33%" + assert report_list_as_table(report_list, 2, 3) == [ + ["Entry page", "Conversions", "CR"], + ["/two", "1", "33.33%"] + ] - refute get_in_report_list(report_list, item_name: 1) + refute get_in_report_list(report_list, 2, 0) end end @@ -176,17 +154,12 @@ defmodule PlausibleWeb.Live.Dashboard.PagesTest do |> change_tab("exit-pages") |> get_report_list(@exit_pages_report_list) - assert get_in_report_list(report_list, :key_label) =~ "Exit page" - assert get_in_report_list(report_list, metric_label: 0) =~ "Unique exits" - - assert get_in_report_list(report_list, item_name: 0) =~ "/three" - assert get_in_report_list(report_list, item: 0, metric: 0) =~ "3" - - assert get_in_report_list(report_list, item_name: 1) =~ "/two" - assert get_in_report_list(report_list, item: 1, metric: 0) =~ "2" - - assert get_in_report_list(report_list, item_name: 2) =~ "/one" - assert get_in_report_list(report_list, item: 2, metric: 0) =~ "1" + 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 @@ -201,14 +174,11 @@ defmodule PlausibleWeb.Live.Dashboard.PagesTest do |> change_tab("exit-pages") |> get_report_list(@exit_pages_report_list) - assert get_in_report_list(report_list, :key_label) =~ "Exit page" - assert get_in_report_list(report_list, metric_label: 0) =~ "Current visitors" - - assert get_in_report_list(report_list, item_name: 0) =~ "/two" - assert get_in_report_list(report_list, item: 0, metric: 0) =~ "2" - - assert get_in_report_list(report_list, item_name: 1) =~ "/one" - assert get_in_report_list(report_list, item: 1, metric: 0) =~ "1" + 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 @@ -226,15 +196,12 @@ defmodule PlausibleWeb.Live.Dashboard.PagesTest do |> change_tab("exit-pages") |> get_report_list(@exit_pages_report_list) - assert get_in_report_list(report_list, :key_label) =~ "Exit page" - assert get_in_report_list(report_list, metric_label: 0) =~ "Conversions" - assert get_in_report_list(report_list, metric_label: 1) =~ "CR" - - assert get_in_report_list(report_list, item_name: 0) =~ "/two" - assert get_in_report_list(report_list, item: 0, metric: 0) =~ "1" - assert get_in_report_list(report_list, item: 0, metric: 1) =~ "33.33%" + assert report_list_as_table(report_list, 2, 3) == [ + ["Exit page", "Conversions", "CR"], + ["/two", "1", "33.33%"] + ] - refute get_in_report_list(report_list, item_name: 1) + refute get_in_report_list(report_list, 2, 0) end end diff --git a/test/support/dashboard_test_utils.ex b/test/support/dashboard_test_utils.ex index fb4e96e3981e..9d51a7ff2596 100644 --- a/test/support/dashboard_test_utils.ex +++ b/test/support/dashboard_test_utils.ex @@ -3,29 +3,26 @@ defmodule Plausible.DashboardTestUtils do import Plausible.Test.Support.HTML - def get_in_report_list(%LazyHTML{} = report_list, opts) do - selector = - case opts do - :key_label -> - ~s|[data-test-id="key-label"]| - - [metric_label: idx] -> - ~s|[data-test-id="metric-#{idx}-label"]| + 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 - [item_name: idx] -> - ~s|[data-test-id="item-#{idx}-name"]| + 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}"]| - opts -> - item_idx = Keyword.fetch!(opts, :item) - metric_idx = Keyword.fetch!(opts, :metric) + cond do + not element_exists?(report_list, selector) -> + nil - ~s|[data-test-id="item-#{item_idx}-metric-#{metric_idx}"]| - end + Keyword.get(opts, :text?, true) -> + text_of_element(report_list, selector) - if element_exists?(report_list, selector) do - text_of_element(report_list, selector) - else - nil + true -> + find(report_list, selector) end end end From 3240cc3d61508178861672ffdd8af9c065fad845 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Tue, 30 Dec 2025 10:20:37 +0000 Subject: [PATCH 16/17] test util function doc --- test/support/dashboard_test_utils.ex | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/support/dashboard_test_utils.ex b/test/support/dashboard_test_utils.ex index 9d51a7ff2596..e1f83a10609f 100644 --- a/test/support/dashboard_test_utils.ex +++ b/test/support/dashboard_test_utils.ex @@ -3,6 +3,11 @@ defmodule Plausible.DashboardTestUtils do 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 From a9cdf388ea7b9e9ad5267792e254c6d5f8019fda Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Tue, 30 Dec 2025 10:58:37 +0000 Subject: [PATCH 17/17] fix test --- test/plausible_web/live/dashboard/pages_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/plausible_web/live/dashboard/pages_test.exs b/test/plausible_web/live/dashboard/pages_test.exs index 21f28fb41df9..3a69f783e29c 100644 --- a/test/plausible_web/live/dashboard/pages_test.exs +++ b/test/plausible_web/live/dashboard/pages_test.exs @@ -108,7 +108,7 @@ defmodule PlausibleWeb.Live.Dashboard.PagesTest do |> get_report_list(@entry_pages_report_list) assert report_list_as_table(report_list, 3, 2) == [ - ["Page", "Current visitors"], + ["Entry page", "Current visitors"], ["/two", "2"], ["/one", "1"] ]