- <.report_header key_label={@key_label} metrics={@metrics} col_min_width={@col_min_width} />
+ <.report_header
+ key_label={@key_label}
+ metric_labels={@metric_labels}
+ col_min_width={@col_min_width}
+ />
<.report_row
- :for={item <- @results}
+ :for={{item, item_index} <- Enum.with_index(@results)}
link_fn={assigns[:external_link_fn]}
item={item}
- metrics={@metrics}
- bar_value={item.visitors}
- bar_max_value={@max_value}
+ item_index={item_index}
+ item_name={List.first(item.dimensions)}
+ metrics={Enum.zip(@metric_keys, item.metrics)}
+ bar_max_value={bar_max_value(@results, @metric_keys)}
site={@site}
params={@params}
- filter_dimension={@filter_dimension}
+ dimension={@dimension}
row_height={@row_height}
row_gap_height={@row_gap_height}
col_min_width={@col_min_width}
@@ -132,13 +128,14 @@ defmodule PlausibleWeb.Components.Dashboard.ReportList do
defp report_header(assigns) do
~H"""
-
{@key_label}
+
{@key_label}
- {metric.label}
+ {metric_label}
"""
@@ -151,9 +148,9 @@ defmodule PlausibleWeb.Components.Dashboard.ReportList do
class="group flex w-full items-center hover:bg-gray-100/60 dark:hover:bg-gray-850 rounded-sm transition-colors duration-150"
style={"margin-top: #{@row_gap_height}px;"}
>
-
+
@@ -163,9 +160,9 @@ defmodule PlausibleWeb.Components.Dashboard.ReportList do
class="max-w-max w-full flex items-center md:overflow-hidden hover:underline"
site={@site}
params={@params}
- filter={[:is, @filter_dimension, [@item.name]]}
+ filter={[:is, @dimension, [@item_name]]}
>
- {trim_name(@item.name, @col_min_width)}
+ {trim_name(@item_name, @col_min_width)}
<.external_link item={@item} link_fn={assigns[:link_fn]} />
@@ -173,12 +170,15 @@ defmodule PlausibleWeb.Components.Dashboard.ReportList do
-
-
+
+
@@ -212,6 +212,15 @@ defmodule PlausibleWeb.Components.Dashboard.ReportList do
"""
end
+ @bar_metric :visitors
+ defp bar_max_value(results, metrics) do
+ index = Enum.find_index(metrics, &(&1 == @bar_metric))
+
+ results
+ |> Enum.map(&Enum.at(&1.metrics, index))
+ |> Enum.max(&>=/2, fn -> 0 end)
+ end
+
defp trim_name(name, max_length) do
if String.length(name) <= max_length do
name
diff --git a/lib/plausible_web/live/components/dashboard/tile.ex b/lib/plausible_web/live/components/dashboard/tile.ex
index 7561a5abc5dd..e8b7145fa29b 100644
--- a/lib/plausible_web/live/components/dashboard/tile.ex
+++ b/lib/plausible_web/live/components/dashboard/tile.ex
@@ -10,6 +10,7 @@ defmodule PlausibleWeb.Components.Dashboard.Tile do
attr :title, :string, required: true
attr :height, :integer, required: true
attr :connected?, :boolean, required: true
+ attr :target, :any, required: true
slot :tabs
slot :inner_block, required: true
@@ -26,6 +27,7 @@ defmodule PlausibleWeb.Components.Dashboard.Tile do
:if={@tabs != []}
id={@id <> "-tabs"}
phx-hook="DashboardTabs"
+ phx-target={@target}
class="tile-tabs flex text-xs font-medium text-gray-500 dark:text-gray-400 space-x-2 items-baseline"
>
{render_slot(@tabs)}
@@ -48,9 +50,9 @@ defmodule PlausibleWeb.Components.Dashboard.Tile do
"""
end
- attr :label, :string, required: true
- attr :value, :string, required: true
- attr :active, :string, required: true
+ attr :report_label, :string, required: true
+ attr :tab_key, :string, required: true
+ attr :active_tab, :string, required: true
attr :target, :any, required: true
def tab(assigns) do
@@ -58,7 +60,7 @@ defmodule PlausibleWeb.Components.Dashboard.Tile do
assign(
assigns,
data_attrs:
- if(assigns.value == assigns.active,
+ if(assigns.tab_key == assigns.active_tab,
do: %{"data-active": "true"},
else: %{"data-active": "false"}
)
@@ -67,8 +69,8 @@ defmodule PlausibleWeb.Components.Dashboard.Tile do
~H"""
"""
diff --git a/lib/plausible_web/live/dashboard.ex b/lib/plausible_web/live/dashboard.ex
index 5a7cd197d8b4..6e1360bbfaa6 100644
--- a/lib/plausible_web/live/dashboard.ex
+++ b/lib/plausible_web/live/dashboard.ex
@@ -9,10 +9,6 @@ defmodule PlausibleWeb.Live.Dashboard do
alias Plausible.Stats.DashboardQueryParser
alias Plausible.Teams
- @default_prefs %{
- "period" => "28d"
- }
-
@spec enabled?(Plausible.Site.t() | nil) :: boolean()
def enabled?(nil), do: false
@@ -21,8 +17,7 @@ defmodule PlausibleWeb.Live.Dashboard do
end
def mount(_params, %{"domain" => domain, "url" => url}, socket) do
- # NOTE: implement a dedicated, permissive params fallback.
- user_prefs = Map.merge(@default_prefs, get_connect_params(socket)["user_prefs"] || %{})
+ user_prefs = get_connect_params(socket)["user_prefs"] || %{}
# As domain is passed via session, the associated site has already passed
# validation logic on plug level.
@@ -49,7 +44,13 @@ defmodule PlausibleWeb.Live.Dashboard do
def handle_params_internal(_params, url, socket) do
uri = URI.parse(url)
path = uri.path |> String.split("/") |> Enum.drop(2)
- {:ok, params} = DashboardQueryParser.parse(uri.query || "", socket.assigns.user_prefs)
+
+ {:ok, params} =
+ DashboardQueryParser.parse(
+ uri.query || "",
+ socket.assigns.site,
+ socket.assigns.user_prefs
+ )
socket =
assign(socket,
diff --git a/lib/plausible_web/live/dashboard/pages.ex b/lib/plausible_web/live/dashboard/pages.ex
index ffb9c89e7e2f..b711db1af24f 100644
--- a/lib/plausible_web/live/dashboard/pages.ex
+++ b/lib/plausible_web/live/dashboard/pages.ex
@@ -9,78 +9,30 @@ defmodule PlausibleWeb.Live.Dashboard.Pages do
alias PlausibleWeb.Components.Dashboard.Tile
alias Plausible.Stats
- alias Plausible.Stats.Filters
- alias Plausible.Stats.ParsedQueryParams
- alias Plausible.Stats.QueryBuilder
+ alias Plausible.Stats.{ParsedQueryParams, QueryBuilder, QueryResult}
@tabs [
- {"pages", "Top Pages"},
- {"entry-pages", "Entry Pages"},
- {"exit-pages", "Exit Pages"}
- ]
-
- @key_labels %{
- "pages" => "Page",
- "entry-pages" => "Entry page",
- "exit-pages" => "Exit page"
- }
-
- @max_items 9
- @pagination_params {@max_items, 1}
-
- @metrics %{
- "pages" => %{
- visitors: %{
- width: "w-24",
- key: :visitors,
- label: "Visitors",
- sortable: true,
- plot: true
- },
- conversion_rate: %{
- width: "w-24",
- key: :conversion_rate,
- label: "CR",
- sortable: true
- }
+ %{
+ tab_key: "pages",
+ report_label: "Top pages",
+ key_label: "Page",
+ dimension: "event:page"
},
- "entry-pages" => %{
- visitors: %{
- width: "w-24",
- key: :visitors,
- label: "Unique Entrances",
- sortable: true,
- plot: true
- },
- conversion_rate: %{
- width: "w-24",
- key: :conversion_rate,
- label: "CR",
- sortable: true
- }
+ %{
+ tab_key: "entry-pages",
+ report_label: "Entry pages",
+ key_label: "Entry page",
+ dimension: "visit:entry_page"
},
- "exit-pages" => %{
- visitors: %{
- width: "w-24",
- key: :visitors,
- label: "Unique Exits",
- sortable: true,
- plot: true
- },
- conversion_rate: %{
- width: "w-24",
- key: :conversion_rate,
- label: "CR",
- sortable: true
- }
+ %{
+ tab_key: "exit-pages",
+ report_label: "Exit pages",
+ key_label: "Exit page",
+ dimension: "visit:exit_page"
}
- }
+ ]
- @filter_dimensions %{
- "pages" => "event:page",
- "entry-pages" => "visit:entry_page",
- "exit-pages" => "visit:exit_page"
- }
+ @pagination %{limit: 9, offset: 0}
def update(assigns, socket) do
active_tab = assigns.user_prefs["pages_tab"] || "pages"
@@ -90,12 +42,10 @@ defmodule PlausibleWeb.Live.Dashboard.Pages do
site: assigns.site,
params: assigns.params,
tabs: @tabs,
- key_labels: @key_labels,
- filter_dimensions: @filter_dimensions,
active_tab: active_tab,
connected?: assigns.connected?
)
- |> load_metrics()
+ |> load_stats()
{:ok, socket}
end
@@ -108,29 +58,28 @@ defmodule PlausibleWeb.Live.Dashboard.Pages do
<:tabs>
@@ -143,7 +92,7 @@ defmodule PlausibleWeb.Live.Dashboard.Pages do
socket =
socket
|> assign(:active_tab, tab)
- |> load_metrics()
+ |> load_stats()
{:noreply, socket}
else
@@ -155,97 +104,40 @@ defmodule PlausibleWeb.Live.Dashboard.Pages do
"https://example.com"
end
- defp load_metrics(socket) do
+ defp load_stats(socket) do
%{active_tab: active_tab, site: site, params: params} = socket.assigns
- assign_async(socket, [:metrics, :results, :meta, :skip_imported_reason], fn ->
- %{results: pages, meta: meta, query: query, metrics: metrics} =
- metrics_for_tab(active_tab, site, params)
-
- {:ok,
- %{
- metrics: Enum.map(metrics, &Map.fetch!(@metrics[active_tab], &1)),
- results: Enum.take(pages, @max_items),
- meta: Map.merge(meta, Stats.Breakdown.formatted_date_ranges(query)),
- skip_imported_reason: meta[:imports_skip_reason]
- }}
- end)
- end
-
- defp metrics_for_tab("pages", site, params) do
- params =
- params
- |> ParsedQueryParams.set(dimensions: ["event:page"])
- |> ParsedQueryParams.set_include(:time_labels, false)
-
- {:ok, query} = QueryBuilder.build(site, params, %{})
- metrics = breakdown_metrics(query)
-
- %{results: results, meta: meta} = Stats.breakdown(site, query, metrics, @pagination_params)
-
- pages =
- results
- |> transform_keys(%{page: :name})
-
- %{query: query, results: pages, meta: meta, metrics: metrics}
- end
-
- defp metrics_for_tab("entry-pages", site, params) do
- params =
- params
- |> ParsedQueryParams.set(dimensions: ["visit:entry_page"])
- |> ParsedQueryParams.set_include(:time_labels, false)
+ assign_async(socket, :query_result, fn ->
+ metrics = choose_metrics(params)
+ dimension = get_tab_info(active_tab, :dimension)
- {:ok, query} = QueryBuilder.build(site, params, %{})
- metrics = breakdown_metrics(query)
+ params =
+ params
+ |> ParsedQueryParams.set(
+ metrics: metrics,
+ dimensions: [dimension],
+ pagination: @pagination
+ )
- %{results: results, meta: meta} = Stats.breakdown(site, query, metrics, @pagination_params)
+ query = QueryBuilder.build!(site, params)
- pages =
- results
- |> transform_keys(%{entry_page: :name})
+ %QueryResult{} = query_result = Stats.query(site, query)
- %{query: query, results: pages, meta: meta, metrics: metrics}
- end
-
- defp metrics_for_tab("exit-pages", site, params) do
- params =
- params
- |> ParsedQueryParams.set(dimensions: ["visit:exit_page"])
- |> ParsedQueryParams.set_include(:time_labels, false)
-
- {:ok, query} = QueryBuilder.build(site, params, %{})
- metrics = breakdown_metrics(query)
-
- %{results: results, meta: meta} = Stats.breakdown(site, query, metrics, @pagination_params)
-
- pages =
- results
- |> transform_keys(%{exit_page: :name})
-
- %{query: query, results: pages, meta: meta, metrics: metrics}
+ {:ok, %{query_result: query_result}}
+ end)
end
- defp breakdown_metrics(query) do
- if toplevel_goal_filter?(query) do
+ defp choose_metrics(%ParsedQueryParams{} = params) do
+ if ParsedQueryParams.conversion_goal_filter?(params) do
[:visitors, :conversion_rate]
else
[:visitors]
end
end
- defp transform_keys(result, keys_to_replace) when is_map(result) do
- for {key, val} <- result, do: {Map.get(keys_to_replace, key, key), val}, into: %{}
- end
-
- defp transform_keys(results, keys_to_replace) when is_list(results) do
- Enum.map(results, &transform_keys(&1, keys_to_replace))
- end
-
- defp toplevel_goal_filter?(query) do
- Filters.filtering_on_dimension?(query, "event:goal",
- max_depth: 0,
- behavioral_filters: :ignore
- )
+ defp get_tab_info(tab_key, field) do
+ @tabs
+ |> Enum.find(&(&1.tab_key == tab_key))
+ |> Map.fetch!(field)
end
end
diff --git a/test/plausible/stats/query/dashboard_query_parser_test.exs b/test/plausible/stats/query/dashboard_query_parser_test.exs
index c63c8ebefb50..b0906eb63336 100644
--- a/test/plausible/stats/query/dashboard_query_parser_test.exs
+++ b/test/plausible/stats/query/dashboard_query_parser_test.exs
@@ -5,11 +5,14 @@ defmodule Plausible.Stats.DashboardQueryParserTest do
@default_include default_include()
+ @yesterday NaiveDateTime.utc_now(:second) |> NaiveDateTime.add(-1, :day)
+ @before_yesterday @yesterday |> NaiveDateTime.add(-1, :day)
+
test "parses an empty query string" do
- {:ok, parsed} = parse("")
+ {:ok, parsed} = parse("", build(:site), %{})
expected = %Plausible.Stats.ParsedQueryParams{
- input_date_range: nil,
+ input_date_range: {:last_n_days, 28},
relative_date: nil,
metrics: [],
filters: [],
@@ -25,81 +28,120 @@ defmodule Plausible.Stats.DashboardQueryParserTest do
describe "period -> input_date_range" do
for period <- [:realtime, :day, :month, :year, :all] do
test "parses #{period} period" do
- {:ok, parsed} = parse("?period=#{Atom.to_string(unquote(period))}")
+ {:ok, parsed} = parse("?period=#{Atom.to_string(unquote(period))}", build(:site), %{})
+ assert_matches %ParsedQueryParams{input_date_range: ^unquote(period)} = parsed
+ end
- assert_matches %ParsedQueryParams{
- input_date_range: ^unquote(period),
- include: ^@default_include
- } = parsed
+ test "parses #{period} period from user prefs" do
+ {:ok, parsed} = parse("", build(:site), %{"period" => Atom.to_string(unquote(period))})
+ assert_matches %ParsedQueryParams{input_date_range: ^unquote(period)} = parsed
end
end
for i <- [7, 28, 30, 91] do
test "parses #{i}d period" do
- {:ok, parsed} = parse("?period=#{unquote(i)}d")
+ {:ok, parsed} = parse("?period=#{unquote(i)}d", build(:site), %{})
+
+ assert_matches %ParsedQueryParams{
+ input_date_range: {:last_n_days, ^unquote(i)}
+ } = parsed
+ end
+
+ test "parses #{i}d period from user_prefs" do
+ {:ok, parsed} = parse("", build(:site), %{"period" => "#{unquote(i)}d"})
assert_matches %ParsedQueryParams{
- input_date_range: {:last_n_days, ^unquote(i)},
- include: ^@default_include
+ input_date_range: {:last_n_days, ^unquote(i)}
} = parsed
end
end
for i <- [6, 12] do
test "parses #{i}mo period" do
- {:ok, parsed} = parse("?period=#{unquote(i)}mo")
+ {:ok, parsed} = parse("?period=#{unquote(i)}mo", build(:site), %{})
+
+ assert_matches %ParsedQueryParams{
+ input_date_range: {:last_n_months, ^unquote(i)}
+ } =
+ parsed
+ end
+
+ test "parses #{i}mo period from user prefs" do
+ {:ok, parsed} = parse("", build(:site), %{"period" => "#{unquote(i)}mo"})
assert_matches %ParsedQueryParams{
- input_date_range: {:last_n_months, ^unquote(i)},
- include: ^@default_include
+ input_date_range: {:last_n_months, ^unquote(i)}
} =
parsed
end
end
test "parses custom period" do
- {:ok, parsed} = parse("?period=custom&from=2021-01-01&to=2021-03-05")
+ {:ok, parsed} = parse("?period=custom&from=2021-01-01&to=2021-03-05", build(:site), %{})
assert %ParsedQueryParams{
- input_date_range: {:date_range, ~D[2021-01-01], ~D[2021-03-05]},
- include: @default_include
+ input_date_range: {:date_range, ~D[2021-01-01], ~D[2021-03-05]}
} = parsed
end
- test "defaults to nil when period param is invalid" do
- {:ok, parsed} = parse("?period=abcde")
+ test "falls back to last 28 days when site created before yesterday" do
+ site = build(:site, native_stats_start_at: @before_yesterday)
+ {:ok, parsed} = parse("", site, %{})
+ assert %ParsedQueryParams{input_date_range: {:last_n_days, 28}} = parsed
+ end
- assert %ParsedQueryParams{
- input_date_range: nil,
- include: @default_include
- } = parsed
+ test "falls back to :day for a recently created site" do
+ site = build(:site, native_stats_start_at: @yesterday)
+ {:ok, parsed} = parse("", site, %{})
+ assert %ParsedQueryParams{input_date_range: :day} = parsed
+ end
+
+ test "falls back to valid user preference" do
+ {:ok, parsed} = parse("", build(:site), %{"period" => "year"})
+ assert %ParsedQueryParams{input_date_range: :year} = parsed
+ end
+
+ test "falls back to valid user preference when period in query string is invalid" do
+ {:ok, parsed} = parse("?period=invalid", build(:site), %{"period" => "month"})
+ assert %ParsedQueryParams{input_date_range: :month} = parsed
+ end
+
+ test "valid period param in query string takes precedence over valid user preference" do
+ {:ok, parsed} = parse("?period=all", build(:site), %{"period" => "month"})
+ assert %ParsedQueryParams{input_date_range: :all} = parsed
+ end
+
+ test "falls back to site default when both query string and user preference period are invalid" do
+ site = build(:site, native_stats_start_at: @yesterday)
+ {:ok, parsed} = parse("?period=invalid", site, %{"period" => "invalid"})
+ assert %ParsedQueryParams{input_date_range: :day} = parsed
end
end
describe "date -> relative_date" do
test "parses a valid iso8601 date string" do
- {:ok, parsed} = parse("?date=2021-05-05")
+ {:ok, parsed} = parse("?date=2021-05-05", build(:site), %{})
assert %ParsedQueryParams{relative_date: ~D[2021-05-05], include: @default_include} = parsed
end
test "errors when invalid date" do
- {:error, :invalid_date} = parse("?date=2021-13-32")
+ {:error, :invalid_date} = parse("?date=2021-13-32", build(:site), %{})
end
end
describe "with_imported -> include.imports" do
test "true -> true" do
- {:ok, parsed} = parse("?with_imported=true")
+ {:ok, parsed} = parse("?with_imported=true", build(:site), %{})
assert %ParsedQueryParams{include: @default_include} = parsed
end
test "invalid -> true" do
- {:ok, parsed} = parse("?with_imported=foo")
+ {:ok, parsed} = parse("?with_imported=foo", build(:site), %{})
assert %ParsedQueryParams{include: @default_include} = parsed
end
test "false -> false" do
- {:ok, parsed} = parse("?with_imported=false")
+ {:ok, parsed} = parse("?with_imported=false", build(:site), %{})
expected_include = Map.put(@default_include, :imports, false)
assert %ParsedQueryParams{include: ^expected_include} = parsed
end
@@ -108,14 +150,25 @@ defmodule Plausible.Stats.DashboardQueryParserTest do
describe "comparison -> include.compare" do
for mode <- [:previous_period, :year_over_year] do
test "parses #{mode} mode" do
- {:ok, parsed} = parse("?comparison=#{unquote(mode)}")
+ {:ok, parsed} = parse("?comparison=#{unquote(mode)}", build(:site), %{})
+ expected_include = Map.put(@default_include, :compare, unquote(mode))
+ assert_matches %ParsedQueryParams{include: ^expected_include} = parsed
+ end
+
+ test "parses #{mode} mode from user prefs" do
+ {:ok, parsed} = parse("", build(:site), %{"comparison" => "#{unquote(mode)}"})
expected_include = Map.put(@default_include, :compare, unquote(mode))
assert_matches %ParsedQueryParams{include: ^expected_include} = parsed
end
end
test "parses custom date range mode" do
- {:ok, parsed} = parse("?comparison=custom&compare_from=2021-01-01&compare_to=2021-04-30")
+ {:ok, parsed} =
+ parse(
+ "?comparison=custom&compare_from=2021-01-01&compare_to=2021-04-30",
+ build(:site),
+ %{}
+ )
expected_include =
Map.put(@default_include, :compare, {:date_range, ~D[2021-01-01], ~D[2021-04-30]})
@@ -123,34 +176,101 @@ defmodule Plausible.Stats.DashboardQueryParserTest do
assert_matches %ParsedQueryParams{include: ^expected_include} = parsed
end
- test "invalid -> nil" do
- {:ok, parsed} = parse("?comparison=invalid_mode")
+ test "custom comparison in query string takes precedence over user prefs" do
+ {:ok, parsed} =
+ parse(
+ "?comparison=custom&compare_from=2021-01-01&compare_to=2021-04-30",
+ build(:site),
+ %{"comparison" => "year_over_year"}
+ )
+
+ expected_include =
+ Map.put(@default_include, :compare, {:date_range, ~D[2021-01-01], ~D[2021-04-30]})
+
+ assert_matches %ParsedQueryParams{include: ^expected_include} = parsed
+ end
+
+ test "falls back to user preference when query string comparison param is invalid" do
+ {:ok, parsed} =
+ parse("?comparison=invalid_mode", build(:site), %{"comparison" => "previous_period"})
+
+ expected_include =
+ Map.put(@default_include, :compare, :previous_period)
+
+ assert_matches %ParsedQueryParams{include: ^expected_include} = parsed
+ end
+
+ test "comparison=off in query string skips stored comparison mode" do
+ {:ok, parsed} =
+ parse("?comparison=off", build(:site), %{"comparison" => "previous_period"})
+
+ assert %ParsedQueryParams{include: @default_include} = parsed
+ end
+
+ test "falls back to nil when comparison param in both query string and user prefs is invalid" do
+ {:ok, parsed} =
+ parse("?comparison=invalid_mode", build(:site), %{"comparison" => "invalid_mode"})
+
assert %ParsedQueryParams{include: @default_include} = parsed
end
end
describe "match_day_of_week -> include.compare_match_day_of_week" do
test "true -> true" do
- {:ok, parsed} = parse("?match_day_of_week=true")
+ {:ok, parsed} = parse("?match_day_of_week=true", build(:site), %{})
assert %ParsedQueryParams{include: @default_include} = parsed
end
test "invalid -> true" do
- {:ok, parsed} = parse("?match_day_of_week=foo")
+ {:ok, parsed} = parse("?match_day_of_week=foo", build(:site), %{})
assert %ParsedQueryParams{include: @default_include} = parsed
end
test "false -> false" do
- {:ok, parsed} = parse("?match_day_of_week=false")
+ {:ok, parsed} = parse("?match_day_of_week=false", build(:site), %{})
+ expected_include = Map.put(@default_include, :compare_match_day_of_week, false)
+ assert %ParsedQueryParams{include: ^expected_include} = parsed
+ end
+
+ test "'true' in query string takes precedence over 'false' in user prefs" do
+ {:ok, parsed} =
+ parse("?match_day_of_week=true", build(:site), %{"match_day_of_week" => "false"})
+
+ assert %ParsedQueryParams{include: @default_include} = parsed
+ end
+
+ test "'false' in query string takes precedence over 'true' in user prefs" do
+ {:ok, parsed} =
+ parse("?match_day_of_week=false", build(:site), %{"match_day_of_week" => "true"})
+
expected_include = Map.put(@default_include, :compare_match_day_of_week, false)
assert %ParsedQueryParams{include: ^expected_include} = parsed
end
+
+ test "falls back to user pref when value in query string is invalid" do
+ {:ok, parsed} =
+ parse("?match_day_of_week=foo", build(:site), %{"match_day_of_week" => "false"})
+
+ expected_include = Map.put(@default_include, :compare_match_day_of_week, false)
+ assert %ParsedQueryParams{include: ^expected_include} = parsed
+ end
+
+ test "falls back to 'true' when invalid values in both query string and user prefs" do
+ {:ok, parsed} =
+ parse("?match_day_of_week=foo", build(:site), %{"match_day_of_week" => "bar"})
+
+ assert %ParsedQueryParams{include: @default_include} = parsed
+ end
end
describe "filters" do
test "parses valid filters" do
{:ok, parsed} =
- parse("?f=is,exit_page,/:dashboard&f=is,source,Bing&f=is,props:theme,system")
+ parse(
+ "?f=is,exit_page,/:dashboard&f=is,source,Bing&f=is,props:theme,system",
+ build(:site),
+ %{}
+ )
assert %ParsedQueryParams{
filters: [
@@ -164,7 +284,7 @@ defmodule Plausible.Stats.DashboardQueryParserTest do
test "parses city filter with multiple clauses" do
{:ok, parsed} =
- parse("?f=is,city,2988507,2950159")
+ parse("?f=is,city,2988507,2950159", build(:site), %{})
assert %ParsedQueryParams{
filters: [[:is, "visit:city", [2_988_507, 2_950_159]]],
@@ -173,7 +293,7 @@ defmodule Plausible.Stats.DashboardQueryParserTest do
end
test "parses a segment filter" do
- {:ok, parsed} = parse("?f=is,segment,123")
+ {:ok, parsed} = parse("?f=is,segment,123", build(:site), %{})
assert %ParsedQueryParams{
filters: [[:is, "segment", [123]]],
@@ -182,15 +302,15 @@ defmodule Plausible.Stats.DashboardQueryParserTest do
end
test "errors when filter structure is wrong" do
- assert {:error, :invalid_filters} = parse("?f=is,page,/&f=what")
+ assert {:error, :invalid_filters} = parse("?f=is,page,/&f=what", build(:site), %{})
end
test "errors when city filter cannot be parsed to integer" do
- assert {:error, :invalid_filters} = parse("?f=is,city,Berlin")
+ assert {:error, :invalid_filters} = parse("?f=is,city,Berlin", build(:site), %{})
end
test "errors when segment filter cannot be parsed to integer" do
- assert {:error, :invalid_filters} = parse("?f=is,segment,MySegment")
+ assert {:error, :invalid_filters} = parse("?f=is,segment,MySegment", build(:site), %{})
end
end
end
diff --git a/test/plausible/stats/query/dashboard_query_serializer_test.exs b/test/plausible/stats/query/dashboard_query_serializer_test.exs
index 727ac00fdcde..ca925c94b670 100644
--- a/test/plausible/stats/query/dashboard_query_serializer_test.exs
+++ b/test/plausible/stats/query/dashboard_query_serializer_test.exs
@@ -6,25 +6,25 @@ defmodule Plausible.Stats.DashboardQuerySerializerTest do
@default_include default_include()
describe "parse -> serialize is a reversible transformation" do
- for query_string <- ["", "date=2021-07-07&f=is,browser,Chrome,Firefox&period=day"] do
+ for query_string <- ["period=month", "date=2021-07-07&f=is,browser,Chrome,Firefox&period=day"] do
test "with query string being '#{query_string}'" do
- {:ok, parsed} = parse(unquote(query_string))
+ {:ok, parsed} = parse(unquote(query_string), build(:site), %{})
assert serialize(parsed) == unquote(query_string)
end
end
test "but alphabetical ordering by key is enforced" do
- {:ok, parsed} = parse("period=day&date=2021-07-07")
+ {:ok, parsed} = parse("period=day&date=2021-07-07", build(:site), %{})
assert serialize(parsed) == "date=2021-07-07&period=day"
end
test "but redundant values get removed" do
- {:ok, parsed} = parse("with_imported=true")
- assert serialize(parsed) == ""
+ {:ok, parsed} = parse("period=all&with_imported=true", build(:site), %{})
+ assert serialize(parsed) == "period=all"
end
test "but leading ? gets removed" do
- {:ok, parsed} = parse("?period=day")
+ {:ok, parsed} = parse("?period=day", build(:site), %{})
assert serialize(parsed) == "period=day"
end
end
diff --git a/test/plausible/stats/query/query_test.exs b/test/plausible/stats/query/query_test.exs
index 85fb3c6ba564..7afa30451e5a 100644
--- a/test/plausible/stats/query/query_test.exs
+++ b/test/plausible/stats/query/query_test.exs
@@ -126,4 +126,114 @@ defmodule Plausible.Stats.QueryTest do
]
end
end
+
+ describe "include.dashboard_metric_labels" do
+ test "visitors -> Visitors (default)", %{site: site} do
+ {:ok, query} =
+ QueryBuilder.build(site,
+ metrics: [:visitors],
+ input_date_range: :all,
+ include: [dashboard_metric_labels: true]
+ )
+
+ %Stats.QueryResult{meta: meta} = Stats.query(site, query)
+ assert ["Visitors"] = meta[:metric_labels]
+ end
+
+ test "visitors -> Current visitors (realtime)", %{site: site} do
+ {:ok, query} =
+ QueryBuilder.build(site,
+ metrics: [:visitors],
+ input_date_range: :realtime,
+ include: [dashboard_metric_labels: true]
+ )
+
+ %Stats.QueryResult{meta: meta} = Stats.query(site, query)
+ assert ["Current visitors"] = meta[:metric_labels]
+ end
+
+ test "visitors -> Current visitors (realtime and goal filtered)", %{site: site} do
+ insert(:goal, site: site, event_name: "Signup")
+
+ {:ok, query} =
+ QueryBuilder.build(site,
+ metrics: [:visitors],
+ input_date_range: :realtime,
+ filters: [[:is, "event:goal", ["Signup"]]],
+ include: [dashboard_metric_labels: true]
+ )
+
+ %Stats.QueryResult{meta: meta} = Stats.query(site, query)
+ assert ["Current visitors"] = meta[:metric_labels]
+ end
+
+ test "visitors -> Conversions (goal filtered)", %{site: site} do
+ insert(:goal, site: site, event_name: "Signup")
+
+ {:ok, query} =
+ QueryBuilder.build(site,
+ metrics: [:visitors],
+ input_date_range: :all,
+ filters: [[:is, "event:goal", ["Signup"]]],
+ include: [dashboard_metric_labels: true]
+ )
+
+ %Stats.QueryResult{meta: meta} = Stats.query(site, query)
+ assert ["Conversions"] = meta[:metric_labels]
+ end
+
+ test "visitors -> Unique entrances (visit:entry_page dimension)", %{site: site} do
+ {:ok, query} =
+ QueryBuilder.build(site,
+ metrics: [:visitors],
+ input_date_range: :all,
+ dimensions: ["visit:entry_page"],
+ include: [dashboard_metric_labels: true]
+ )
+
+ %Stats.QueryResult{meta: meta} = Stats.query(site, query)
+ assert ["Unique entrances"] = meta[:metric_labels]
+ end
+
+ test "visitors -> Unique exits (visit:exit_page dimension)", %{site: site} do
+ {:ok, query} =
+ QueryBuilder.build(site,
+ metrics: [:visitors],
+ input_date_range: :all,
+ dimensions: ["visit:exit_page"],
+ include: [dashboard_metric_labels: true]
+ )
+
+ %Stats.QueryResult{meta: meta} = Stats.query(site, query)
+ assert ["Unique exits"] = meta[:metric_labels]
+ end
+
+ test "conversion_rate -> CR (default)", %{site: site} do
+ {:ok, query} =
+ QueryBuilder.build(site,
+ metrics: [:conversion_rate],
+ input_date_range: :all,
+ dimensions: ["event:goal"],
+ include: [dashboard_metric_labels: true]
+ )
+
+ %Stats.QueryResult{meta: meta} = Stats.query(site, query)
+ assert ["CR"] = meta[:metric_labels]
+ end
+
+ test "maintains order with multiple metrics", %{site: site} do
+ insert(:goal, site: site, event_name: "Signup")
+
+ {:ok, query} =
+ QueryBuilder.build(site,
+ metrics: [:conversion_rate, :visitors],
+ input_date_range: :all,
+ filters: [[:is, "event:goal", ["Signup"]]],
+ include: [dashboard_metric_labels: true]
+ )
+
+ %Stats.QueryResult{meta: meta} = Stats.query(site, query)
+ assert ["CR", "Conversions"] = meta[:metric_labels]
+ end
+ end
end
diff --git a/test/plausible_web/live/components/dashboard/report_list_test.exs b/test/plausible_web/live/components/dashboard/report_list_test.exs
new file mode 100644
index 000000000000..e3c06c9c04ef
--- /dev/null
+++ b/test/plausible_web/live/components/dashboard/report_list_test.exs
@@ -0,0 +1,65 @@
+defmodule PlausibleWeb.Components.Dashboard.ReportListTest do
+ use PlausibleWeb.ConnCase, async: true
+ import Phoenix.LiveViewTest, only: [render_component: 2]
+
+ alias PlausibleWeb.Components.Dashboard.ReportList
+ alias Plausible.Stats.{ParsedQueryParams, QueryResult}
+ alias Phoenix.LiveView.AsyncResult
+ import Plausible.DashboardTestUtils
+
+ @report_list_selector ~s|[data-test-id="pages-report-list"]|
+ @bar_indicator_selector ~s|[data-test-id="bar-indicator"]|
+
+ setup do
+ assigns = [
+ site: build(:site),
+ data_test_id: "pages-report-list",
+ key_label: "Page",
+ dimension: "event:page",
+ params: %ParsedQueryParams{},
+ external_link_fn: fn _ -> "" end
+ ]
+
+ {:ok, %{assigns: assigns}}
+ end
+
+ test "renders empty when loading", %{assigns: assigns} do
+ assigns = Keyword.put(assigns, :query_result, AsyncResult.loading())
+ assert render_component(&ReportList.report/1, assigns) == ""
+ end
+
+ test "renders empty when result not ok", %{assigns: assigns} do
+ assigns =
+ Keyword.put(assigns, :query_result, AsyncResult.failed(AsyncResult.loading(), :some_error))
+
+ assert render_component(&ReportList.report/1, assigns) == ""
+ end
+
+ test "item bar width depends on visitors metric", %{assigns: assigns} do
+ successful_async_result =
+ AsyncResult.ok(%QueryResult{
+ results: [
+ %{metrics: [100, 60.0], dimensions: ["/a"]},
+ %{metrics: [70, 40.0], dimensions: ["/b"]},
+ %{metrics: [30, 20.0], dimensions: ["/c"]}
+ ],
+ meta: Jason.OrderedObject.new(metric_labels: ["Conversions", "CR"]),
+ query: Jason.OrderedObject.new(metrics: [:visitors, :conversion_rate])
+ })
+
+ assigns = Keyword.put(assigns, :query_result, successful_async_result)
+
+ html = render_component(&ReportList.report/1, assigns)
+
+ report_list = find(html, @report_list_selector)
+
+ [{1, "100.0%"}, {2, "70.0%"}, {3, "30.0%"}]
+ |> Enum.each(fn {item, expected_width} ->
+ bar =
+ get_in_report_list(report_list, item, 0, text?: false)
+ |> find(@bar_indicator_selector)
+
+ assert text_of_attr(bar, "style") =~ "width: #{expected_width}"
+ end)
+ end
+end
diff --git a/test/plausible_web/live/dashboard_test.exs b/test/plausible_web/live/dashboard/dashboard_test.exs
similarity index 100%
rename from test/plausible_web/live/dashboard_test.exs
rename to test/plausible_web/live/dashboard/dashboard_test.exs
diff --git a/test/plausible_web/live/dashboard/pages_test.exs b/test/plausible_web/live/dashboard/pages_test.exs
new file mode 100644
index 000000000000..3a69f783e29c
--- /dev/null
+++ b/test/plausible_web/live/dashboard/pages_test.exs
@@ -0,0 +1,228 @@
+defmodule PlausibleWeb.Live.Dashboard.PagesTest do
+ use PlausibleWeb.ConnCase, async: false
+
+ import Phoenix.LiveViewTest
+ import Plausible.DashboardTestUtils
+
+ setup [:create_user, :log_in, :create_site]
+
+ @top_pages_report_list ~s|[data-test-id="pages-report-list"]|
+ @entry_pages_report_list ~s|[data-test-id="entry-pages-report-list"]|
+ @exit_pages_report_list ~s|[data-test-id="exit-pages-report-list"]|
+
+ describe "Top Pages" do
+ test "eventually renders and orders items by visitor counts", %{conn: conn, site: site} do
+ populate_stats(site, [
+ build(:pageview, pathname: "/one"),
+ build(:pageview, pathname: "/two"),
+ build(:pageview, pathname: "/two"),
+ build(:pageview, pathname: "/three"),
+ build(:pageview, pathname: "/three"),
+ build(:pageview, pathname: "/three")
+ ])
+
+ assert report_list = get_liveview(conn, site) |> get_report_list(@top_pages_report_list)
+
+ assert report_list_as_table(report_list, 4, 2) == [
+ ["Page", "Visitors"],
+ ["/three", "3"],
+ ["/two", "2"],
+ ["/one", "1"]
+ ]
+ end
+
+ test "renders current visitors", %{conn: conn, site: site} do
+ populate_stats(site, [
+ build(:pageview, pathname: "/one"),
+ build(:pageview, pathname: "/two"),
+ build(:pageview, pathname: "/two")
+ ])
+
+ assert report_list =
+ get_liveview(conn, site, "period=realtime")
+ |> get_report_list(@top_pages_report_list)
+
+ assert report_list_as_table(report_list, 3, 2) == [
+ ["Page", "Current visitors"],
+ ["/two", "2"],
+ ["/one", "1"]
+ ]
+ end
+
+ test "renders conversions with conversion rate", %{conn: conn, site: site} do
+ insert(:goal, site: site, event_name: "Signup")
+
+ populate_stats(site, [
+ build(:pageview, pathname: "/one"),
+ build(:event, name: "Signup", pathname: "/two"),
+ build(:pageview, pathname: "/two")
+ ])
+
+ assert report_list =
+ get_liveview(conn, site, "period=day&f=is,goal,Signup")
+ |> get_report_list(@top_pages_report_list)
+
+ assert report_list_as_table(report_list, 2, 3) == [
+ ["Page", "Conversions", "CR"],
+ ["/two", "1", "33.33%"]
+ ]
+
+ refute get_in_report_list(report_list, 2, 0)
+ end
+ end
+
+ describe "Entry Pages" do
+ test "eventually renders and orders items by visitor counts", %{conn: conn, site: site} do
+ populate_stats(site, [
+ build(:pageview, pathname: "/one"),
+ build(:pageview, pathname: "/two"),
+ build(:pageview, pathname: "/two"),
+ build(:pageview, pathname: "/three"),
+ build(:pageview, pathname: "/three"),
+ build(:pageview, pathname: "/three")
+ ])
+
+ assert report_list =
+ get_liveview(conn, site)
+ |> change_tab("entry-pages")
+ |> get_report_list(@entry_pages_report_list)
+
+ assert report_list_as_table(report_list, 4, 2) == [
+ ["Entry page", "Unique entrances"],
+ ["/three", "3"],
+ ["/two", "2"],
+ ["/one", "1"]
+ ]
+ end
+
+ test "renders current visitors", %{conn: conn, site: site} do
+ populate_stats(site, [
+ build(:pageview, pathname: "/one"),
+ build(:pageview, pathname: "/two"),
+ build(:pageview, pathname: "/two")
+ ])
+
+ assert report_list =
+ get_liveview(conn, site, "period=realtime")
+ |> change_tab("entry-pages")
+ |> get_report_list(@entry_pages_report_list)
+
+ assert report_list_as_table(report_list, 3, 2) == [
+ ["Entry page", "Current visitors"],
+ ["/two", "2"],
+ ["/one", "1"]
+ ]
+ end
+
+ test "renders conversions with conversion rate", %{conn: conn, site: site} do
+ insert(:goal, site: site, event_name: "Signup")
+
+ populate_stats(site, [
+ build(:pageview, pathname: "/one"),
+ build(:pageview, user_id: 1, pathname: "/two"),
+ build(:event, user_id: 1, name: "Signup", pathname: "/two"),
+ build(:pageview, pathname: "/two")
+ ])
+
+ assert report_list =
+ get_liveview(conn, site, "period=day&f=is,goal,Signup")
+ |> change_tab("entry-pages")
+ |> get_report_list(@entry_pages_report_list)
+
+ assert report_list_as_table(report_list, 2, 3) == [
+ ["Entry page", "Conversions", "CR"],
+ ["/two", "1", "33.33%"]
+ ]
+
+ refute get_in_report_list(report_list, 2, 0)
+ end
+ end
+
+ describe "Exit Pages" do
+ test "eventually renders and orders items by visitor counts", %{conn: conn, site: site} do
+ populate_stats(site, [
+ build(:pageview, pathname: "/one"),
+ build(:pageview, pathname: "/two"),
+ build(:pageview, pathname: "/two"),
+ build(:pageview, pathname: "/three"),
+ build(:pageview, pathname: "/three"),
+ build(:pageview, pathname: "/three")
+ ])
+
+ assert report_list =
+ get_liveview(conn, site)
+ |> change_tab("exit-pages")
+ |> get_report_list(@exit_pages_report_list)
+
+ assert report_list_as_table(report_list, 4, 2) == [
+ ["Exit page", "Unique exits"],
+ ["/three", "3"],
+ ["/two", "2"],
+ ["/one", "1"]
+ ]
+ end
+
+ test "renders current visitors", %{conn: conn, site: site} do
+ populate_stats(site, [
+ build(:pageview, pathname: "/one"),
+ build(:pageview, pathname: "/two"),
+ build(:pageview, pathname: "/two")
+ ])
+
+ assert report_list =
+ get_liveview(conn, site, "period=realtime")
+ |> change_tab("exit-pages")
+ |> get_report_list(@exit_pages_report_list)
+
+ assert report_list_as_table(report_list, 3, 2) == [
+ ["Exit page", "Current visitors"],
+ ["/two", "2"],
+ ["/one", "1"]
+ ]
+ end
+
+ test "renders conversions with conversion rate", %{conn: conn, site: site} do
+ insert(:goal, site: site, event_name: "Signup")
+
+ populate_stats(site, [
+ build(:pageview, pathname: "/one"),
+ build(:event, user_id: 1, name: "Signup", pathname: "/two"),
+ build(:pageview, user_id: 1, pathname: "/two"),
+ build(:pageview, pathname: "/two")
+ ])
+
+ assert report_list =
+ get_liveview(conn, site, "period=day&f=is,goal,Signup")
+ |> change_tab("exit-pages")
+ |> get_report_list(@exit_pages_report_list)
+
+ assert report_list_as_table(report_list, 2, 3) == [
+ ["Exit page", "Conversions", "CR"],
+ ["/two", "1", "33.33%"]
+ ]
+
+ refute get_in_report_list(report_list, 2, 0)
+ end
+ end
+
+ defp get_report_list(lv, selector) do
+ eventually(fn ->
+ html = render(lv)
+ {element_exists?(html, selector), find(html, selector)}
+ end)
+ end
+
+ defp get_liveview(conn, site, search_params \\ "period=day") do
+ conn = assign(conn, :live_module, PlausibleWeb.Live.Dashboard)
+ {:ok, lv, _html} = live(conn, "/#{site.domain}?#{search_params}")
+ lv
+ end
+
+ defp change_tab(lv, tab) do
+ lv
+ |> element("#breakdown-tile-pages-tabs")
+ |> render_hook("set-tab", %{"tab" => tab})
+
+ lv
+ end
+end
diff --git a/test/support/dashboard_test_utils.ex b/test/support/dashboard_test_utils.ex
new file mode 100644
index 000000000000..e1f83a10609f
--- /dev/null
+++ b/test/support/dashboard_test_utils.ex
@@ -0,0 +1,33 @@
+defmodule Plausible.DashboardTestUtils do
+ @moduledoc false
+
+ import Plausible.Test.Support.HTML
+
+ @doc """
+ Takes a LazyHTML rendered ReportList component argument with the number of
+ rows and columns it's supposed to have, and returns a table-like, 2D list
+ with all its data (including headers as the first row).
+ """
+ def report_list_as_table(%LazyHTML{} = report_list, rows, columns) do
+ for row_index <- 0..(rows - 1) do
+ for column_index <- 0..(columns - 1) do
+ get_in_report_list(report_list, row_index, column_index)
+ end
+ end
+ end
+
+ def get_in_report_list(%LazyHTML{} = report_list, row_index, column_index, opts \\ []) do
+ selector = ~s|[data-test-id="report-list-#{row_index}-#{column_index}"]|
+
+ cond do
+ not element_exists?(report_list, selector) ->
+ nil
+
+ Keyword.get(opts, :text?, true) ->
+ text_of_element(report_list, selector)
+
+ true ->
+ find(report_list, selector)
+ end
+ end
+end
diff --git a/test/support/html.ex b/test/support/html.ex
index 792d173f3acc..50b2db81bcc8 100644
--- a/test/support/html.ex
+++ b/test/support/html.ex
@@ -10,10 +10,15 @@ defmodule Plausible.Test.Support.HTML do
|> Kernel.not()
end
+ def find(%LazyHTML{} = html, selector) do
+ html
+ |> LazyHTML.query(selector)
+ end
+
def find(html, selector) do
html
|> lazy_parse()
- |> LazyHTML.query(selector)
+ |> find(selector)
end
def submit_button(html, form) do