Skip to content

Commit 16f1eb3

Browse files
authored
Add necessary scaffolding for enabling LV on dashboard (#5930)
* Use forked version of * Add necessary scaffolding for enabling LV on dashboard * Implement basics for LV pages breakdown * Make tile and tabs latency friendly * Bring back eslint-disable pragma in live_socket.js * Document the code somewhat * Fix live navigation callback in React * Make dashboard components inside portals testable * Add very rudimentary basic tests * Fix typo * Fix eslint pragma in `live_socket.js`
1 parent 007155b commit 16f1eb3

19 files changed

Lines changed: 627 additions & 10 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* Component used for embedding LiveView components inside React.
3+
*
4+
* The content of the portal is completely excluded from React re-renders with
5+
* a hardwired `React.memo`.
6+
*/
7+
8+
import React from 'react'
9+
import classNames from 'classnames'
10+
11+
const MIN_HEIGHT = 380
12+
13+
type LiveViewPortalProps = {
14+
id: string
15+
className?: string
16+
}
17+
18+
export const LiveViewPortal = React.memo(
19+
function ({ id, className }: LiveViewPortalProps) {
20+
return (
21+
<div
22+
id={id}
23+
className={classNames('group', className)}
24+
style={{ width: '100%', border: '0', minHeight: MIN_HEIGHT }}
25+
>
26+
<div
27+
className="w-full flex flex-col justify-center group-has-[[data-phx-teleported]]:hidden"
28+
style={{ minHeight: MIN_HEIGHT }}
29+
>
30+
<div className="mx-auto loading">
31+
<div />
32+
</div>
33+
</div>
34+
</div>
35+
)
36+
},
37+
() => true
38+
)

assets/js/dashboard/index.tsx

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import React, { useMemo, useState } from 'react'
1+
import React, { useMemo, useState, useEffect, useCallback } from 'react'
2+
import { LiveViewPortal } from './components/liveview-portal'
23
import VisitorGraph from './stats/graph/visitor-graph'
34
import Sources from './stats/sources'
45
import Pages from './stats/pages'
@@ -7,7 +8,10 @@ import Devices from './stats/devices'
78
import { TopBar } from './nav-menu/top-bar'
89
import Behaviours from './stats/behaviours'
910
import { useQueryContext } from './query-context'
11+
import { useSiteContext } from './site-context'
1012
import { isRealTimeDashboard } from './util/filters'
13+
import { useAppNavigate } from './navigation/use-app-navigate'
14+
import { parseSearch } from './util/url-search-params'
1115

1216
function DashboardStats({
1317
importedDataInView,
@@ -16,6 +20,36 @@ function DashboardStats({
1620
importedDataInView?: boolean
1721
updateImportedDataInView?: (v: boolean) => void
1822
}) {
23+
const navigate = useAppNavigate()
24+
const site = useSiteContext()
25+
26+
// Handler for navigation events delegated from LiveView dashboard.
27+
// Necessary to emulate navigation events in LiveView with pushState
28+
// manipulation disabled.
29+
const onLiveNavigate = useCallback(
30+
(e: CustomEvent) => {
31+
navigate({
32+
path: e.detail.path,
33+
search: () => parseSearch(e.detail.search)
34+
})
35+
},
36+
[navigate]
37+
)
38+
39+
useEffect(() => {
40+
window.addEventListener(
41+
'dashboard:live-navigate',
42+
onLiveNavigate as EventListener
43+
)
44+
45+
return () => {
46+
window.removeEventListener(
47+
'dashboard:live-navigate',
48+
onLiveNavigate as EventListener
49+
)
50+
}
51+
}, [navigate])
52+
1953
const statsBoxClass =
2054
'relative min-h-[436px] w-full mt-5 p-4 flex flex-col bg-white dark:bg-gray-900 shadow-sm rounded-md md:min-h-initial md:h-27.25rem md:w-[calc(50%-10px)] md:ml-[10px] md:mr-[10px] first:ml-0 last:mr-0'
2155

@@ -27,7 +61,14 @@ function DashboardStats({
2761
<Sources />
2862
</div>
2963
<div className={statsBoxClass}>
30-
<Pages />
64+
{site.flags.live_dashboard ? (
65+
<LiveViewPortal
66+
id="pages-breakdown-live"
67+
className="w-full h-full border-0 overflow-hidden"
68+
/>
69+
) : (
70+
<Pages />
71+
)}
3172
</div>
3273
</div>
3374

assets/js/dashboard/navigation/use-app-navigate.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,15 @@ export const useAppNavigate = () => {
6363
search,
6464
...options
6565
}: AppNavigationTarget & NavigateOptions) => {
66+
// Event dispatched for handling by LiveView dashboard via hook.
67+
// Necessary to emulate navigation events in LiveView with pushState
68+
// manipulation disabled.
69+
window.dispatchEvent(
70+
new CustomEvent('dashboard:live-navigate-back', {
71+
detail: { search: window.location.search }
72+
})
73+
)
74+
6675
return _navigate(getToOptions({ path, params, search }), options)
6776
},
6877
[getToOptions, _navigate]

assets/js/dashboard/site-context.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ export function parseSiteFromDataset(dataset: DOMStringMap): PlausibleSite {
2727
}
2828

2929
// Update this object when new feature flags are added to the frontend.
30-
type FeatureFlags = Record<never, boolean>
30+
type FeatureFlags = {
31+
live_dashboard?: boolean
32+
}
3133

3234
export const siteContextDefaultValue = {
3335
domain: '',
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/**
2+
* Hook used by LiveView dashboard.
3+
*
4+
* Defines various widgets to use by various dashboard specific components.
5+
*/
6+
7+
const WIDGETS = {
8+
// Hook widget delegating navigation events to and from React.
9+
// Necessary to emulate navigation events in LiveView with pushState
10+
// manipulation disabled.
11+
'dashboard-root': {
12+
initialize: function () {
13+
this.url = window.location.href
14+
15+
addListener.bind(this)('click', document.body, (e) => {
16+
const type = e.target.dataset.type || null
17+
18+
if (type === 'dashboard-link') {
19+
this.url = e.target.href
20+
const uri = new URL(this.url)
21+
// Domain is dropped from URL prefix, because that's what react-dom-router
22+
// expects.
23+
const path = '/' + uri.pathname.split('/').slice(2).join('/')
24+
this.el.dispatchEvent(
25+
new CustomEvent('dashboard:live-navigate', {
26+
bubbles: true,
27+
detail: { path: path, search: uri.search }
28+
})
29+
)
30+
31+
this.pushEvent('handle_dashboard_params', { url: this.url })
32+
33+
e.preventDefault()
34+
}
35+
})
36+
37+
// Browser back and forward navigation triggers that event.
38+
addListener.bind(this)('popstate', window, () => {
39+
if (this.url !== window.location.href) {
40+
this.pushEvent('handle_dashboard_params', {
41+
url: window.location.href
42+
})
43+
}
44+
})
45+
46+
// Navigation events triggered from liveview are propagated via this
47+
// handler.
48+
addListener.bind(this)('dashboard:live-navigate-back', window, (e) => {
49+
if (
50+
typeof e.detail.search === 'string' &&
51+
this.url !== window.location.href
52+
) {
53+
this.pushEvent('handle_dashboard_params', {
54+
url: window.location.href
55+
})
56+
}
57+
})
58+
},
59+
cleanup: function () {
60+
removeListeners.bind(this)()
61+
}
62+
},
63+
// Hook widget for optimistic loading of tabs and
64+
// client-side persistence of selection using localStorage.
65+
tabs: {
66+
initialize: function () {
67+
const domain = getDomain(window.location.href)
68+
69+
addListener.bind(this)('click', this.el, (e) => {
70+
const button = e.target.closest('button')
71+
const tab = button && button.dataset.tab
72+
73+
if (tab) {
74+
const label = button.dataset.label
75+
const storageKey = button.dataset.storageKey
76+
const activeClasses = button.dataset.activeClasses
77+
const inactiveClasses = button.dataset.inactiveClasses
78+
const title = this.el
79+
.closest('[data-tile]')
80+
.querySelector('[data-title]')
81+
82+
title.innerText = label
83+
84+
this.el.querySelectorAll(`button[data-tab] span`).forEach((s) => {
85+
s.className = inactiveClasses
86+
})
87+
88+
button.querySelector('span').className = activeClasses
89+
90+
if (storageKey) {
91+
localStorage.setItem(`${storageKey}__${domain}`, tab)
92+
}
93+
}
94+
})
95+
},
96+
cleanup: function () {
97+
removeListeners.bind(this)()
98+
}
99+
}
100+
}
101+
102+
function getDomain(url) {
103+
const uri = typeof url === 'object' ? url : new URL(url)
104+
return uri.pathname.split('/')[1]
105+
}
106+
107+
function addListener(eventName, listener, callback) {
108+
this.listeners = this.listeners || []
109+
110+
listener.addEventListener(eventName, callback)
111+
112+
this.listeners.push({
113+
element: listener,
114+
event: eventName,
115+
callback: callback
116+
})
117+
}
118+
119+
function removeListeners() {
120+
if (this.listeners) {
121+
this.listeners.forEach((l) => {
122+
l.element.removeEventListener(l.event, l.callback)
123+
})
124+
125+
this.listeners = null
126+
}
127+
}
128+
129+
export default {
130+
mounted() {
131+
this.widget = this.el.getAttribute('data-widget')
132+
133+
this.initialize()
134+
},
135+
136+
updated() {
137+
this.initialize()
138+
},
139+
140+
reconnected() {
141+
this.initialize()
142+
},
143+
144+
destroyed() {
145+
this.cleanup()
146+
},
147+
148+
initialize() {
149+
this.cleanup()
150+
WIDGETS[this.widget].initialize.bind(this)()
151+
},
152+
153+
cleanup() {
154+
WIDGETS[this.widget].cleanup.bind(this)()
155+
}
156+
}

assets/js/liveview/live_socket.js

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,26 @@
22
The modules below this comment block are resolved from '../deps' folder,
33
which does not exist when running the lint command in Github CI
44
*/
5+
56
/* eslint-disable import/no-unresolved */
67
import 'phoenix_html'
78
import { Socket } from 'phoenix'
89
import { LiveSocket } from 'phoenix_live_view'
910
import { Modal, Dropdown } from 'prima'
11+
import LiveDashboard from './live_dashboard'
1012
import topbar from 'topbar'
1113
/* eslint-enable import/no-unresolved */
1214

1315
import Alpine from 'alpinejs'
1416

1517
let csrfToken = document.querySelector("meta[name='csrf-token']")
1618
let websocketUrl = document.querySelector("meta[name='websocket-url']")
19+
let disablePushStateFlag = document.querySelector(
20+
"meta[name='live-socket-disable-push-state']"
21+
)
22+
let domain = document.querySelector("meta[name='dashboard-domain']")
1723
if (csrfToken && websocketUrl) {
18-
let Hooks = { Modal, Dropdown }
24+
let Hooks = { Modal, Dropdown, LiveDashboard }
1925
Hooks.Metrics = {
2026
mounted() {
2127
this.handleEvent('send-metrics', ({ event_name }) => {
@@ -48,9 +54,14 @@ if (csrfToken && websocketUrl) {
4854
let token = csrfToken.getAttribute('content')
4955
let url = websocketUrl.getAttribute('content')
5056
let liveUrl = url === '' ? '/live' : new URL('/live', url).href
57+
let disablePushState =
58+
!!disablePushStateFlag &&
59+
disablePushStateFlag.getAttribute('content') === 'true'
60+
let domainName = domain && domain.getAttribute('content')
5161
let liveSocket = new LiveSocket(liveUrl, Socket, {
62+
// For dashboard LV migration
63+
disablePushState: disablePushState,
5264
heartbeatIntervalMs: 10000,
53-
params: { _csrf_token: token },
5465
hooks: Hooks,
5566
uploaders: Uploaders,
5667
dom: {
@@ -60,6 +71,20 @@ if (csrfToken && websocketUrl) {
6071
Alpine.clone(from, to)
6172
}
6273
}
74+
},
75+
params: () => {
76+
if (domainName) {
77+
return {
78+
// The prefs are used by dashboard LiveView to persist
79+
// user preferences across the reloads.
80+
user_prefs: {
81+
pages_tab: localStorage.getItem(`pageTab__${domainName}`)
82+
},
83+
_csrf_token: token
84+
}
85+
} else {
86+
return { _csrf_token: token }
87+
}
6388
}
6489
})
6590

lib/plausible_web/controllers/stats_controller.ex

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,8 @@ defmodule PlausibleWeb.StatsController do
100100
hide_footer?: if(ce?() || demo, do: false, else: site_role != :public),
101101
consolidated_view?: consolidated_view?,
102102
consolidated_view_available?: consolidated_view_available?,
103-
team_identifier: team_identifier
103+
team_identifier: team_identifier,
104+
connect_live_socket: PlausibleWeb.Live.Dashboard.enabled?(site)
104105
)
105106

106107
!stats_start_date && can_see_stats? ->
@@ -455,7 +456,7 @@ defmodule PlausibleWeb.StatsController do
455456

456457
defp get_flags(user, site),
457458
do:
458-
[]
459+
[:live_dashboard]
459460
|> Enum.map(fn flag ->
460461
{flag, FunWithFlags.enabled?(flag, for: user) || FunWithFlags.enabled?(flag, for: site)}
461462
end)

0 commit comments

Comments
 (0)