diff --git a/assets/css/app.css b/assets/css/app.css index 71879d553ed0..abf486b726d3 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -115,6 +115,7 @@ @custom-variant dark (&:where(.dark, .dark *)); @custom-variant phx-click-loading (.phx-click-loading&, .phx-click-loading &); +@custom-variant phx-hook-loading (.phx-hook-loading&, .phx-hook-loading &); @custom-variant phx-submit-loading (.phx-submit-loading&, .phx-submit-loading &); @custom-variant phx-change-loading (.phx-change-loading&, .phx-change-loading &); diff --git a/assets/js/liveview/dashboard_root.js b/assets/js/liveview/dashboard_root.js index 42af690f103c..32a9ab2258a7 100644 --- a/assets/js/liveview/dashboard_root.js +++ b/assets/js/liveview/dashboard_root.js @@ -6,10 +6,26 @@ import { buildHook } from './hook_builder' +function navigateWithLoader(url) { + this.portalTargets.map((target) => { + this.js().addClass(document.querySelector(target), 'phx-navigation-loading') + + this.pushEvent('handle_dashboard_params', { url: url }, () => { + this.js().removeClass( + document.querySelector(target), + 'phx-navigation-loading' + ) + }) + }) +} + export default buildHook({ initialize() { this.url = window.location.href + const portals = document.querySelectorAll('[data-phx-portal]') + this.portalTargets = Array.from(portals, (p) => p.dataset.phxPortal) + this.addListener('click', document.body, (e) => { const type = e.target.dataset.type || null @@ -26,7 +42,7 @@ export default buildHook({ }) ) - this.pushEvent('handle_dashboard_params', { url: this.url }) + navigateWithLoader.bind(this)(this.url) e.preventDefault() } @@ -35,9 +51,7 @@ export default buildHook({ // Browser back and forward navigation triggers that event. this.addListener('popstate', window, () => { if (this.url !== window.location.href) { - this.pushEvent('handle_dashboard_params', { - url: window.location.href - }) + navigateWithLoader.bind(this)(window.location.href) } }) @@ -48,9 +62,7 @@ export default buildHook({ typeof e.detail.search === 'string' && this.url !== window.location.href ) { - this.pushEvent('handle_dashboard_params', { - url: window.location.href - }) + navigateWithLoader.bind(this)(window.location.href) } }) } diff --git a/assets/js/liveview/dashboard_tabs.js b/assets/js/liveview/dashboard_tabs.js index 0f645a7f0134..006cf52c3838 100644 --- a/assets/js/liveview/dashboard_tabs.js +++ b/assets/js/liveview/dashboard_tabs.js @@ -17,27 +17,32 @@ export default buildHook({ this.addListener('click', this.el, (e) => { const button = e.target.closest('button') const tab = button && button.dataset.tab + const span = button && button.querySelector('span') - if (tab) { + if (span && span.dataset.active === 'false') { const label = button.dataset.label const storageKey = button.dataset.storageKey - const activeClasses = button.dataset.activeClasses - const inactiveClasses = button.dataset.inactiveClasses - const title = this.el - .closest('[data-tile]') - .querySelector('[data-title]') + const target = button.dataset.target + const tile = this.el.closest('[data-tile]') + const title = tile.querySelector('[data-title]') title.innerText = label this.el.querySelectorAll(`button[data-tab] span`).forEach((s) => { - s.className = inactiveClasses + this.js().setAttribute(s, 'data-active', 'false') }) - button.querySelector('span').className = activeClasses + this.js().setAttribute( + button.querySelector('span'), + 'data-active', + 'true' + ) if (storageKey) { localStorage.setItem(`${storageKey}__${domain}`, tab) } + + this.pushEventTo(target, 'set-tab', { tab: tab }) } }) } diff --git a/lib/plausible/stats/dashboard_query_parser.ex b/lib/plausible/stats/dashboard_query_parser.ex index bd50c91026d3..75875287f736 100644 --- a/lib/plausible/stats/dashboard_query_parser.ex +++ b/lib/plausible/stats/dashboard_query_parser.ex @@ -28,9 +28,9 @@ defmodule Plausible.Stats.DashboardQueryParser do def default_pagination(), do: @default_pagination - def parse(query_string) when is_binary(query_string) do + def parse(query_string, defaults \\ %{}) when is_binary(query_string) do query_string = String.trim_leading(query_string, "?") - params_map = URI.decode_query(query_string) + params_map = Map.merge(defaults, URI.decode_query(query_string)) with {:ok, filters} <- parse_filters(query_string), {:ok, relative_date} <- parse_relative_date(params_map) do diff --git a/lib/plausible/stats/parsed_query_params.ex b/lib/plausible/stats/parsed_query_params.ex index 2d940f087370..0c473db7a83d 100644 --- a/lib/plausible/stats/parsed_query_params.ex +++ b/lib/plausible/stats/parsed_query_params.ex @@ -19,6 +19,14 @@ defmodule Plausible.Stats.ParsedQueryParams do struct!(__MODULE__, Map.to_list(params)) end + def set(params, keywords) do + struct!(params, keywords) + end + + def set_include(params, key, value) do + struct!(params, include: struct!(params.include, [{key, value}])) + end + @props_prefix "event:props:" def add_or_replace_filter(%__MODULE__{filters: filters} = parsed_query_params, new_filter) do diff --git a/lib/plausible_web/components/generic.ex b/lib/plausible_web/components/generic.ex index 696195bdcaf6..c4407ba58561 100644 --- a/lib/plausible_web/components/generic.ex +++ b/lib/plausible_web/components/generic.ex @@ -425,10 +425,10 @@ defmodule PlausibleWeb.Components.Generic do viewBox="0 0 24 24" {@rest} > - + diff --git a/lib/plausible_web/live/components/dashboard/base.ex b/lib/plausible_web/live/components/dashboard/base.ex index 041cac215d92..e57201ff4d82 100644 --- a/lib/plausible_web/live/components/dashboard/base.ex +++ b/lib/plausible_web/live/components/dashboard/base.ex @@ -5,14 +5,27 @@ defmodule PlausibleWeb.Components.Dashboard.Base do use PlausibleWeb, :component - attr :href, :string, required: true + alias Plausible.Stats.DashboardQuerySerializer + alias Plausible.Stats.ParsedQueryParams + attr :site, Plausible.Site, required: true + attr :params, :map, required: true + attr :path, :string, default: "" attr :class, :string, default: "" attr :rest, :global + slot :inner_block, required: true def dashboard_link(assigns) do - url = "/" <> assigns.site.domain <> assigns.href + query_string = DashboardQuerySerializer.serialize(assigns.params) + url = "/" <> assigns.site.domain <> assigns.path + + url = + if query_string != "" do + url <> "?" <> query_string + else + url + end assigns = assign(assigns, :url, url) @@ -20,10 +33,55 @@ defmodule PlausibleWeb.Components.Dashboard.Base do <.link data-type="dashboard-link" patch={@url} + class={@class} {@rest} > {render_slot(@inner_block)} """ end + + attr :site, Plausible.Site, required: true + attr :params, :map, required: true + attr :filter, :list, required: true + attr :class, :string, default: "" + attr :rest, :global + + slot :inner_block, required: true + + def filter_link(assigns) do + params = ParsedQueryParams.add_or_replace_filter(assigns.params, assigns.filter) + + assigns = assign(assigns, :params, params) + + ~H""" + <.dashboard_link site={@site} params={@params} class={@class} {@rest}> + {render_slot(@inner_block)} + + """ + end + + attr :style, :string, default: "" + attr :background_class, :string, default: "" + attr :width, :integer, required: true + attr :max_width, :integer, required: true + + slot :inner_block, required: true + + def bar(assigns) do + width_percent = assigns.width / assigns.max_width * 100 + + assigns = assign(assigns, :width_percent, width_percent) + + ~H""" +
+
+
+ {render_slot(@inner_block)} +
+ """ + end end diff --git a/lib/plausible_web/live/components/dashboard/metric.ex b/lib/plausible_web/live/components/dashboard/metric.ex new file mode 100644 index 000000000000..62fa1f7e0bd6 --- /dev/null +++ b/lib/plausible_web/live/components/dashboard/metric.ex @@ -0,0 +1,60 @@ +defmodule PlausibleWeb.Components.Dashboard.Metric do + @moduledoc """ + Components for rendering metric data. + """ + + use PlausibleWeb, :component + + @formatters %{ + visitors: :number_short, + conversion_rate: :percentage + } + + attr :name, :atom, required: true + attr :value, :any + + def value(assigns) do + ~H""" +
+ {format_value(@name, @value)} +
+ """ + end + + defp format_value(name, value) do + apply_format(@formatters[name], value) + end + + @hundred_billion :math.pow(10, 11) + @billion :math.pow(10, 9) + @hundred_million :math.pow(10, 8) + @million :math.pow(10, 6) + @hundred_thousand :math.pow(10, 5) + @thousand :math.pow(10, 3) + + defp apply_format(:number_short, value) when is_number(value) do + cond do + value >= @hundred_billion -> divided(value, @billion) + value >= @billion -> divided(value, @billion, 2) + value >= @hundred_million -> divided(value, @million) + value >= @million -> divided(value, @million, 2) + value >= @hundred_thousand -> divided(value, @thousand) + value >= @thousand -> divided(value, @thousand, 2) + true -> value + end + end + + defp apply_format(:number_short, _), do: "-" + + defp apply_format(:percentage, value) do + if value do + :erlang.float_to_binary(value, decimals: 2) <> "%" + else + "-" + end + end + + defp divided(value, divisor, precision \\ 0) do + :erlang.float_to_binary(value / divisor, decimals: precision) + end +end diff --git a/lib/plausible_web/live/components/dashboard/report_list.ex b/lib/plausible_web/live/components/dashboard/report_list.ex new file mode 100644 index 000000000000..649c177a71e6 --- /dev/null +++ b/lib/plausible_web/live/components/dashboard/report_list.ex @@ -0,0 +1,228 @@ +defmodule PlausibleWeb.Components.Dashboard.ReportList do + @moduledoc """ + ReportList component. + """ + + use PlausibleWeb, :component + + alias PlausibleWeb.Components.Dashboard.Base + alias PlausibleWeb.Components.Dashboard.Metric + + @max_items 9 + @min_height 380 + @row_height 32 + @row_gap_height 4 + @data_container_height (@row_height + @row_gap_height) * (@max_items - 1) + @row_height + @col_min_width 70 + + def height, do: @min_height + + def report(assigns) do + assigns = + assign(assigns, + max_items: @max_items, + min_height: @min_height, + row_height: @row_height, + row_gap_height: @row_gap_height, + data_container_height: @data_container_height, + col_min_width: @col_min_width + ) + + if assigns.results.loading || !assigns.results.ok? do + ~H""" + """ + else + results = assigns.results.result + metrics = assigns.metrics.result + meta = assigns.meta.result + skip_imported_reason = assigns.skip_imported_reason.result + + max_value = + results + |> Enum.map(& &1.visitors) + |> Enum.max(&>=/2, fn -> 0 end) + + assigns = + assign(assigns, + max_value: max_value, + results: results, + metrics: metrics, + meta: meta, + skip_imported_reason: skip_imported_reason, + 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_row + :for={item <- @results} + link_fn={assigns[:external_link_fn]} + item={item} + metrics={@metrics} + bar_value={item.visitors} + bar_max_value={@max_value} + site={@site} + params={@params} + filter_dimension={@filter_dimension} + row_height={@row_height} + row_gap_height={@row_gap_height} + col_min_width={@col_min_width} + /> +
+ +
+ <.details_link + site={@site} + params={@params} + path="/pages" + /> +
+
+ """ + end + end + + defp no_data(assigns) do + ~H""" +
+
+ No data yet +
+
+ """ + end + + defp external_link(assigns) do + url = if(assigns[:link_fn], do: assigns.link_fn.(assigns.item)) + + assigns = assign(assigns, :url, url) + + ~H""" + <.link + :if={@url} + target="_blank" + rel="noreferrer" + href={@url} + class="w-4 h-4 invisible group-hover:visible" + > + + + + + + + + """ + end + + defp report_header(assigns) do + ~H""" +
+ {@key_label} +
+ {metric.label} +
+
+ """ + end + + def report_row(assigns) do + ~H""" +
+
+
+ +
+ + + {trim_name(@item.name, @col_min_width)} + + + <.external_link item={@item} link_fn={assigns[:link_fn]} /> +
+
+
+
+ + + +
+
+
+ """ + end + + defp details_link(assigns) do + ~H""" + + + + + DETAILS + + """ + end + + defp trim_name(name, max_length) do + if String.length(name) <= max_length do + name + else + left_length = div(max_length, 2) + right_length = max_length - left_length + + left_side = String.slice(name, 0..left_length) + right_side = String.slice(name, -right_length..-1) + + left_side <> "..." <> right_side + end + end +end diff --git a/lib/plausible_web/live/components/dashboard/tile.ex b/lib/plausible_web/live/components/dashboard/tile.ex index b09ef681d7fd..7561a5abc5dd 100644 --- a/lib/plausible_web/live/components/dashboard/tile.ex +++ b/lib/plausible_web/live/components/dashboard/tile.ex @@ -6,10 +6,9 @@ defmodule PlausibleWeb.Components.Dashboard.Tile do use PlausibleWeb, :component attr :id, :string, required: true + attr :class, :string, default: "" attr :title, :string, required: true - # Optimistic rendering requires preventing LV patching of - # title and tabs. The update of those is handled by `tab` - # widget hook. + attr :height, :integer, required: true attr :connected?, :boolean, required: true slot :tabs @@ -17,7 +16,7 @@ defmodule PlausibleWeb.Components.Dashboard.Tile do def tile(assigns) do ~H""" -
+
"-title"} class="flex gap-x-1" phx-update="ignore">

{@title}

@@ -26,15 +25,25 @@ defmodule PlausibleWeb.Components.Dashboard.Tile do
"-tabs"} - phx-update="ignore" phx-hook="DashboardTabs" - class="flex text-xs font-medium text-gray-500 dark:text-gray-400 space-x-2 items-baseline" + class="tile-tabs flex text-xs font-medium text-gray-500 dark:text-gray-400 space-x-2 items-baseline" > {render_slot(@tabs)}
- {render_slot(@inner_block)} + + +
+ {render_slot(@inner_block)} +
""" end @@ -46,10 +55,13 @@ defmodule PlausibleWeb.Components.Dashboard.Tile do def tab(assigns) do assigns = - assign(assigns, - active_classes: - "text-indigo-600 dark:text-indigo-500 font-bold underline decoration-2 decoration-indigo-600 dark:decoration-indigo-500", - inactive_classes: "hover:text-indigo-700 dark:hover:text-indigo-400 cursor-pointer" + assign( + assigns, + data_attrs: + if(assigns.value == assigns.active, + do: %{"data-active": "true"}, + else: %{"data-active": "false"} + ) ) ~H""" @@ -58,13 +70,12 @@ defmodule PlausibleWeb.Components.Dashboard.Tile do data-tab={@value} data-label={@label} data-storage-key="pageTab" - data-active-classes={@active_classes} - data-inactive-classes={@inactive_classes} - phx-click="set-tab" - phx-value-tab={@value} - phx-target={@target} + data-target={@target} > - + {@label} diff --git a/lib/plausible_web/live/dashboard.ex b/lib/plausible_web/live/dashboard.ex index a1729f7355e2..5a7cd197d8b4 100644 --- a/lib/plausible_web/live/dashboard.ex +++ b/lib/plausible_web/live/dashboard.ex @@ -6,8 +6,13 @@ defmodule PlausibleWeb.Live.Dashboard do use PlausibleWeb, :live_view alias Plausible.Repo + alias Plausible.Stats.DashboardQueryParser alias Plausible.Teams + @default_prefs %{ + "period" => "28d" + } + @spec enabled?(Plausible.Site.t() | nil) :: boolean() def enabled?(nil), do: false @@ -16,7 +21,8 @@ defmodule PlausibleWeb.Live.Dashboard do end def mount(_params, %{"domain" => domain, "url" => url}, socket) do - user_prefs = get_connect_params(socket)["user_prefs"] || %{} + # NOTE: implement a dedicated, permissive params fallback. + user_prefs = Map.merge(@default_prefs, get_connect_params(socket)["user_prefs"] || %{}) # As domain is passed via session, the associated site has already passed # validation logic on plug level. @@ -34,14 +40,23 @@ defmodule PlausibleWeb.Live.Dashboard do |> assign(:connected?, connected?(socket)) |> assign(:site, site) |> assign(:user_prefs, user_prefs) - |> assign(:params, %{}) {:noreply, socket} = handle_params_internal(%{}, url, socket) {:ok, socket} end - def handle_params_internal(_params, _url, socket) 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) + + socket = + assign(socket, + path: path, + params: params + ) + {:noreply, socket} end @@ -55,6 +70,7 @@ defmodule PlausibleWeb.Live.Dashboard do site={@site} user_prefs={@user_prefs} connected?={@connected?} + params={@params} />
diff --git a/lib/plausible_web/live/dashboard/pages.ex b/lib/plausible_web/live/dashboard/pages.ex index fafb89282d42..ffb9c89e7e2f 100644 --- a/lib/plausible_web/live/dashboard/pages.ex +++ b/lib/plausible_web/live/dashboard/pages.ex @@ -5,16 +5,82 @@ defmodule PlausibleWeb.Live.Dashboard.Pages do use PlausibleWeb, :live_component - alias PlausibleWeb.Components.Dashboard.Base + alias PlausibleWeb.Components.Dashboard.ReportList alias PlausibleWeb.Components.Dashboard.Tile + alias Plausible.Stats + alias Plausible.Stats.Filters + alias Plausible.Stats.ParsedQueryParams + alias Plausible.Stats.QueryBuilder + @tabs [ {"pages", "Top Pages"}, {"entry-pages", "Entry Pages"}, {"exit-pages", "Exit Pages"} ] - @tab_labels Map.new(@tabs) + @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 + } + }, + "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 + } + } + } + + @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" @@ -22,19 +88,30 @@ defmodule PlausibleWeb.Live.Dashboard.Pages do socket = assign(socket, site: assigns.site, + params: assigns.params, tabs: @tabs, - tab_labels: @tab_labels, + key_labels: @key_labels, + filter_dimensions: @filter_dimensions, active_tab: active_tab, connected?: assigns.connected? ) + |> load_metrics() {:ok, socket} end def render(assigns) do + assigns = assign(assigns, :external_link_fn, &external_link/1) + ~H"""
- + <:tabs> -
- - Filter by source Direct / None - -
+
""" @@ -57,11 +140,112 @@ defmodule PlausibleWeb.Live.Dashboard.Pages do def handle_event("set-tab", %{"tab" => tab}, socket) do if tab != socket.assigns.active_tab do - socket = assign(socket, :active_tab, tab) + socket = + socket + |> assign(:active_tab, tab) + |> load_metrics() {:noreply, socket} else {:noreply, socket} end end + + defp external_link(_item) do + "https://example.com" + end + + defp load_metrics(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) + + {: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(%{entry_page: :name}) + + %{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} + end + + defp breakdown_metrics(query) do + if toplevel_goal_filter?(query) 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