Skip to content

Commit c11d47a

Browse files
authored
Introduce LV version of pages breakdown (#5953)
* Adjust defaults in `DashboardQueryParser` * Add dedicated link and bar components in `Base` * Introduce `Metric.value` component * Implement basic `ReportList` component * Use `ReportList` component in LV pages breakdown * Make tile styling more in line with react one * Fix Phoenix/LV spinner opacity * Prevent repeat tab opening * Implement optimistic load state for tile on tab switch * Add custom variant for phx-hook-loading * Make tabs use data attribute and reliably set state * Use native hook API for setting data attributes * Drop default adjustments from DashboardQueryParser * Handle filters with ParsedQueryParams API and fix param defaults * Fix fetching max visitors for empty report list * Handle poorly formed URIs sent to dashboard gracefully * Defer query building until all parameters are known * Get rid of whitespace * Drop unnecessary phx-update=ignore from tabs component * Implement loading state for navigation events
1 parent 4d5372b commit c11d47a

File tree

12 files changed

+634
-51
lines changed

12 files changed

+634
-51
lines changed

assets/css/app.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@
115115

116116
@custom-variant dark (&:where(.dark, .dark *));
117117
@custom-variant phx-click-loading (.phx-click-loading&, .phx-click-loading &);
118+
@custom-variant phx-hook-loading (.phx-hook-loading&, .phx-hook-loading &);
118119
@custom-variant phx-submit-loading (.phx-submit-loading&, .phx-submit-loading &);
119120
@custom-variant phx-change-loading (.phx-change-loading&, .phx-change-loading &);
120121

assets/js/liveview/dashboard_root.js

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,26 @@
66

77
import { buildHook } from './hook_builder'
88

9+
function navigateWithLoader(url) {
10+
this.portalTargets.map((target) => {
11+
this.js().addClass(document.querySelector(target), 'phx-navigation-loading')
12+
13+
this.pushEvent('handle_dashboard_params', { url: url }, () => {
14+
this.js().removeClass(
15+
document.querySelector(target),
16+
'phx-navigation-loading'
17+
)
18+
})
19+
})
20+
}
21+
922
export default buildHook({
1023
initialize() {
1124
this.url = window.location.href
1225

26+
const portals = document.querySelectorAll('[data-phx-portal]')
27+
this.portalTargets = Array.from(portals, (p) => p.dataset.phxPortal)
28+
1329
this.addListener('click', document.body, (e) => {
1430
const type = e.target.dataset.type || null
1531

@@ -26,7 +42,7 @@ export default buildHook({
2642
})
2743
)
2844

29-
this.pushEvent('handle_dashboard_params', { url: this.url })
45+
navigateWithLoader.bind(this)(this.url)
3046

3147
e.preventDefault()
3248
}
@@ -35,9 +51,7 @@ export default buildHook({
3551
// Browser back and forward navigation triggers that event.
3652
this.addListener('popstate', window, () => {
3753
if (this.url !== window.location.href) {
38-
this.pushEvent('handle_dashboard_params', {
39-
url: window.location.href
40-
})
54+
navigateWithLoader.bind(this)(window.location.href)
4155
}
4256
})
4357

@@ -48,9 +62,7 @@ export default buildHook({
4862
typeof e.detail.search === 'string' &&
4963
this.url !== window.location.href
5064
) {
51-
this.pushEvent('handle_dashboard_params', {
52-
url: window.location.href
53-
})
65+
navigateWithLoader.bind(this)(window.location.href)
5466
}
5567
})
5668
}

assets/js/liveview/dashboard_tabs.js

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,27 +17,32 @@ export default buildHook({
1717
this.addListener('click', this.el, (e) => {
1818
const button = e.target.closest('button')
1919
const tab = button && button.dataset.tab
20+
const span = button && button.querySelector('span')
2021

21-
if (tab) {
22+
if (span && span.dataset.active === 'false') {
2223
const label = button.dataset.label
2324
const storageKey = button.dataset.storageKey
24-
const activeClasses = button.dataset.activeClasses
25-
const inactiveClasses = button.dataset.inactiveClasses
26-
const title = this.el
27-
.closest('[data-tile]')
28-
.querySelector('[data-title]')
25+
const target = button.dataset.target
26+
const tile = this.el.closest('[data-tile]')
27+
const title = tile.querySelector('[data-title]')
2928

3029
title.innerText = label
3130

3231
this.el.querySelectorAll(`button[data-tab] span`).forEach((s) => {
33-
s.className = inactiveClasses
32+
this.js().setAttribute(s, 'data-active', 'false')
3433
})
3534

36-
button.querySelector('span').className = activeClasses
35+
this.js().setAttribute(
36+
button.querySelector('span'),
37+
'data-active',
38+
'true'
39+
)
3740

3841
if (storageKey) {
3942
localStorage.setItem(`${storageKey}__${domain}`, tab)
4043
}
44+
45+
this.pushEventTo(target, 'set-tab', { tab: tab })
4146
}
4247
})
4348
}

lib/plausible/stats/dashboard_query_parser.ex

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,9 @@ defmodule Plausible.Stats.DashboardQueryParser do
2828

2929
def default_pagination(), do: @default_pagination
3030

31-
def parse(query_string) when is_binary(query_string) do
31+
def parse(query_string, defaults \\ %{}) when is_binary(query_string) do
3232
query_string = String.trim_leading(query_string, "?")
33-
params_map = URI.decode_query(query_string)
33+
params_map = Map.merge(defaults, URI.decode_query(query_string))
3434

3535
with {:ok, filters} <- parse_filters(query_string),
3636
{:ok, relative_date} <- parse_relative_date(params_map) do

lib/plausible/stats/parsed_query_params.ex

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ defmodule Plausible.Stats.ParsedQueryParams do
1919
struct!(__MODULE__, Map.to_list(params))
2020
end
2121

22+
def set(params, keywords) do
23+
struct!(params, keywords)
24+
end
25+
26+
def set_include(params, key, value) do
27+
struct!(params, include: struct!(params.include, [{key, value}]))
28+
end
29+
2230
@props_prefix "event:props:"
2331

2432
def add_or_replace_filter(%__MODULE__{filters: filters} = parsed_query_params, new_filter) do

lib/plausible_web/components/generic.ex

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -425,10 +425,10 @@ defmodule PlausibleWeb.Components.Generic do
425425
viewBox="0 0 24 24"
426426
{@rest}
427427
>
428-
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4">
428+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4">
429429
</circle>
430430
<path
431-
className="opacity-75"
431+
class="opacity-75"
432432
fill="currentColor"
433433
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
434434
>

lib/plausible_web/live/components/dashboard/base.ex

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,83 @@ defmodule PlausibleWeb.Components.Dashboard.Base do
55

66
use PlausibleWeb, :component
77

8-
attr :href, :string, required: true
8+
alias Plausible.Stats.DashboardQuerySerializer
9+
alias Plausible.Stats.ParsedQueryParams
10+
911
attr :site, Plausible.Site, required: true
12+
attr :params, :map, required: true
13+
attr :path, :string, default: ""
1014
attr :class, :string, default: ""
1115
attr :rest, :global
16+
1217
slot :inner_block, required: true
1318

1419
def dashboard_link(assigns) do
15-
url = "/" <> assigns.site.domain <> assigns.href
20+
query_string = DashboardQuerySerializer.serialize(assigns.params)
21+
url = "/" <> assigns.site.domain <> assigns.path
22+
23+
url =
24+
if query_string != "" do
25+
url <> "?" <> query_string
26+
else
27+
url
28+
end
1629

1730
assigns = assign(assigns, :url, url)
1831

1932
~H"""
2033
<.link
2134
data-type="dashboard-link"
2235
patch={@url}
36+
class={@class}
2337
{@rest}
2438
>
2539
{render_slot(@inner_block)}
2640
</.link>
2741
"""
2842
end
43+
44+
attr :site, Plausible.Site, required: true
45+
attr :params, :map, required: true
46+
attr :filter, :list, required: true
47+
attr :class, :string, default: ""
48+
attr :rest, :global
49+
50+
slot :inner_block, required: true
51+
52+
def filter_link(assigns) do
53+
params = ParsedQueryParams.add_or_replace_filter(assigns.params, assigns.filter)
54+
55+
assigns = assign(assigns, :params, params)
56+
57+
~H"""
58+
<.dashboard_link site={@site} params={@params} class={@class} {@rest}>
59+
{render_slot(@inner_block)}
60+
</.dashboard_link>
61+
"""
62+
end
63+
64+
attr :style, :string, default: ""
65+
attr :background_class, :string, default: ""
66+
attr :width, :integer, required: true
67+
attr :max_width, :integer, required: true
68+
69+
slot :inner_block, required: true
70+
71+
def bar(assigns) do
72+
width_percent = assigns.width / assigns.max_width * 100
73+
74+
assigns = assign(assigns, :width_percent, width_percent)
75+
76+
~H"""
77+
<div class="w-full h-full relative" style={@style}>
78+
<div
79+
class={"absolute top-0 left-0 h-full rounded-sm transition-colors duration-150 #{@background_class || ""}"}
80+
style={"width: #{@width_percent}%"}
81+
>
82+
</div>
83+
{render_slot(@inner_block)}
84+
</div>
85+
"""
86+
end
2987
end
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
defmodule PlausibleWeb.Components.Dashboard.Metric do
2+
@moduledoc """
3+
Components for rendering metric data.
4+
"""
5+
6+
use PlausibleWeb, :component
7+
8+
@formatters %{
9+
visitors: :number_short,
10+
conversion_rate: :percentage
11+
}
12+
13+
attr :name, :atom, required: true
14+
attr :value, :any
15+
16+
def value(assigns) do
17+
~H"""
18+
<div class="cursor-default">
19+
{format_value(@name, @value)}
20+
</div>
21+
"""
22+
end
23+
24+
defp format_value(name, value) do
25+
apply_format(@formatters[name], value)
26+
end
27+
28+
@hundred_billion :math.pow(10, 11)
29+
@billion :math.pow(10, 9)
30+
@hundred_million :math.pow(10, 8)
31+
@million :math.pow(10, 6)
32+
@hundred_thousand :math.pow(10, 5)
33+
@thousand :math.pow(10, 3)
34+
35+
defp apply_format(:number_short, value) when is_number(value) do
36+
cond do
37+
value >= @hundred_billion -> divided(value, @billion)
38+
value >= @billion -> divided(value, @billion, 2)
39+
value >= @hundred_million -> divided(value, @million)
40+
value >= @million -> divided(value, @million, 2)
41+
value >= @hundred_thousand -> divided(value, @thousand)
42+
value >= @thousand -> divided(value, @thousand, 2)
43+
true -> value
44+
end
45+
end
46+
47+
defp apply_format(:number_short, _), do: "-"
48+
49+
defp apply_format(:percentage, value) do
50+
if value do
51+
:erlang.float_to_binary(value, decimals: 2) <> "%"
52+
else
53+
"-"
54+
end
55+
end
56+
57+
defp divided(value, divisor, precision \\ 0) do
58+
:erlang.float_to_binary(value / divisor, decimals: precision)
59+
end
60+
end

0 commit comments

Comments
 (0)