Skip to content

Commit 9d4c9c6

Browse files
committed
Add necessary scaffolding for enabling LV on dashboard
1 parent 6e1fc3c commit 9d4c9c6

10 files changed

Lines changed: 242 additions & 6 deletions

File tree

assets/js/dashboard/index.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useMemo, useState } from 'react'
1+
import React, { useMemo, useState, useEffect } from 'react'
22
import VisitorGraph from './stats/graph/visitor-graph'
33
import Sources from './stats/sources'
44
import Pages from './stats/pages'
@@ -8,6 +8,8 @@ import { TopBar } from './nav-menu/top-bar'
88
import Behaviours from './stats/behaviours'
99
import { useQueryContext } from './query-context'
1010
import { isRealTimeDashboard } from './util/filters'
11+
import { useAppNavigate } from './navigation/use-app-navigate'
12+
import { parseSearch } from './util/url-search-params'
1113

1214
function DashboardStats({
1315
importedDataInView,
@@ -16,6 +18,21 @@ function DashboardStats({
1618
importedDataInView?: boolean
1719
updateImportedDataInView?: (v: boolean) => void
1820
}) {
21+
const navigate = useAppNavigate()
22+
23+
useEffect(() => {
24+
const unsubscribe = window.addEventListener('dashboard:live-navigate', ((
25+
e: CustomEvent
26+
) => {
27+
navigate({
28+
path: e.detail.path,
29+
search: () => parseSearch(e.detail.search),
30+
replace: true
31+
})
32+
}) as EventListener)
33+
return unsubscribe
34+
}, [navigate])
35+
1936
const statsBoxClass =
2037
'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'
2138

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,12 @@ export const useAppNavigate = () => {
6363
search,
6464
...options
6565
}: AppNavigationTarget & NavigateOptions) => {
66+
window.dispatchEvent(
67+
new CustomEvent('dashboard:live-navigate-back', {
68+
detail: { search: window.location.search }
69+
})
70+
)
71+
6672
return _navigate(getToOptions({ path, params, search }), options)
6773
},
6874
[getToOptions, _navigate]
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/**
2+
* Hook used for LiveView dashboard. Currently its main purpose
3+
* is facilitiating migration from React to LiveView.
4+
*/
5+
6+
const WIDGETS = {
7+
'breakdown-tile': {
8+
initialize: function () {
9+
this.url = window.location.href
10+
11+
addListener.bind(this)('phx:update_local_storage', window, (e) => {
12+
localStorage.setItem(e.detail.key, e.detail.value)
13+
})
14+
15+
addListener.bind(this)('phx:update_local_storage', this.el, (e) => {
16+
const type = e.target.dataset.type || null
17+
18+
if (type && type == 'dashboard-link') {
19+
this.url = e.target.href
20+
const uri = new URL(this.url)
21+
const path = '/' + uri.pathname.split('/').slice(2).join('/')
22+
this.el.dispatchEvent(
23+
new CustomEvent('dashboard:live-navigate', {
24+
bubbles: true,
25+
detail: { path: path, search: uri.search }
26+
})
27+
)
28+
29+
this.pushEvent('handle_dashboard_params', { url: this.url })
30+
31+
e.preventDefault()
32+
}
33+
})
34+
35+
addListener.bind(this)('popstate', window, () => {
36+
if (this.url !== window.location.href) {
37+
this.pushEvent('handle_dashboard_params', {
38+
url: window.location.href
39+
})
40+
}
41+
})
42+
43+
addListener.bind(this)('dashboard:live-navigate-back', window, (e) => {
44+
if (
45+
typeof e.detail.search === 'string' &&
46+
this.url !== window.location.href
47+
) {
48+
this.pushEvent('handle_dashboard_params', {
49+
url: window.location.href
50+
})
51+
}
52+
})
53+
},
54+
cleanup: function () {
55+
removeListeners.bind(this)()
56+
}
57+
}
58+
}
59+
60+
function addListener(eventName, listener, callback) {
61+
this.listeners = this.listeners || []
62+
63+
listener.addEventListener(eventName, callback)
64+
65+
this.listeners.push({
66+
element: listener,
67+
event: eventName,
68+
callback: callback
69+
})
70+
}
71+
72+
function removeListeners() {
73+
if (this.listeners) {
74+
this.listeners.forEach((l) => {
75+
l.element.removeEventListener(l.event, l.callback)
76+
})
77+
78+
this.listeners = null
79+
}
80+
}
81+
82+
export default {
83+
mounted() {
84+
this.widget = this.el.getAttribute('data-widget')
85+
86+
this.initialize()
87+
},
88+
89+
updated() {
90+
this.initialize()
91+
},
92+
93+
reconnected() {
94+
this.initialize()
95+
},
96+
97+
destroyed() {
98+
this.cleanup()
99+
},
100+
101+
initialize() {
102+
this.cleanup()
103+
WIDGETS[this.widget].initialize.bind(this)()
104+
},
105+
106+
cleanup() {
107+
WIDGETS[this.widget].cleanup.bind(this)()
108+
}
109+
}

assets/js/liveview/live_socket.js

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,24 @@
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-
/* eslint-disable import/no-unresolved */
5+
66
import 'phoenix_html'
77
import { Socket } from 'phoenix'
88
import { LiveSocket } from 'phoenix_live_view'
99
import { Modal, Dropdown } from 'prima'
10+
import LiveDashboard from './live_dashboard'
1011
import topbar from 'topbar'
11-
/* eslint-enable import/no-unresolved */
1212

1313
import Alpine from 'alpinejs'
1414

1515
let csrfToken = document.querySelector("meta[name='csrf-token']")
1616
let websocketUrl = document.querySelector("meta[name='websocket-url']")
17+
let disablePushStateFlag = document.querySelector(
18+
"meta[name='live-socket-disable-push-state']"
19+
)
20+
let domain = document.querySelector("meta[name='dashboard-domain']")
1721
if (csrfToken && websocketUrl) {
18-
let Hooks = { Modal, Dropdown }
22+
let Hooks = { Modal, Dropdown, LiveDashboard }
1923
Hooks.Metrics = {
2024
mounted() {
2125
this.handleEvent('send-metrics', ({ event_name }) => {
@@ -48,9 +52,13 @@ if (csrfToken && websocketUrl) {
4852
let token = csrfToken.getAttribute('content')
4953
let url = websocketUrl.getAttribute('content')
5054
let liveUrl = url === '' ? '/live' : new URL('/live', url).href
55+
let disablePushState =
56+
!!disablePushStateFlag &&
57+
disablePushStateFlag.getAttribute('content') === 'true'
58+
let domainName = domain && domain.getAttribute('content')
5159
let liveSocket = new LiveSocket(liveUrl, Socket, {
60+
disablePushState: disablePushState,
5261
heartbeatIntervalMs: 10000,
53-
params: { _csrf_token: token },
5462
hooks: Hooks,
5563
uploaders: Uploaders,
5664
dom: {
@@ -60,6 +68,18 @@ if (csrfToken && websocketUrl) {
6068
Alpine.clone(from, to)
6169
}
6270
}
71+
},
72+
params: () => {
73+
if (domainName) {
74+
return {
75+
user_prefs: {
76+
page_tab: localStorage.getItem(`pageTab__${domainName}`)
77+
},
78+
_csrf_token: token
79+
}
80+
} else {
81+
return { _csrf_token: token }
82+
}
6383
}
6484
})
6585

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
defmodule PlausibleWeb.Live.Dashboard do
2+
@moduledoc """
3+
LiveView for site dashboard.
4+
"""
5+
6+
use PlausibleWeb, :live_view
7+
8+
alias Plausible.Repo
9+
alias Plausible.Teams
10+
11+
@spec enabled?(Plausible.Site.t() | nil) :: boolean()
12+
def enabled?(nil), do: false
13+
14+
def enabled?(site) do
15+
FunWithFlags.enabled?(:live_dashboard, for: site)
16+
end
17+
18+
def mount(_params, %{"domain" => domain, "url" => url}, socket) do
19+
user_prefs = get_connect_params(socket)["user_prefs"] || %{}
20+
21+
# As domain is passed via session, the associated site has already passed
22+
# validation logic on plug level.
23+
site =
24+
Repo.get_by!(Plausible.Site, domain: domain)
25+
|> Repo.preload([
26+
:owners,
27+
:completed_imports,
28+
team: [:owners, subscription: Teams.last_subscription_query()]
29+
])
30+
31+
socket =
32+
socket
33+
|> assign(:site, site)
34+
|> assign(:user_prefs, user_prefs)
35+
|> assign(:params, %{})
36+
37+
{:noreply, socket} = handle_params_internal(%{}, url, socket)
38+
39+
{:ok, socket}
40+
end
41+
42+
def handle_params_internal(_params, _url, socket) do
43+
{:noreply, socket}
44+
end
45+
46+
def render(assigns) do
47+
~H"""
48+
<div id="live-dashboard-container"></div>
49+
"""
50+
end
51+
52+
def handle_event("handle_dashboard_params", %{"url" => url}, socket) do
53+
query =
54+
url
55+
|> URI.parse()
56+
|> Map.fetch!(:query)
57+
58+
params = URI.decode_query(query || "")
59+
60+
handle_params_internal(params, url, socket)
61+
end
62+
end

lib/plausible_web/router.ex

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -706,7 +706,10 @@ defmodule PlausibleWeb.Router do
706706
put "/:domain/settings", SiteController, :update_settings
707707

708708
get "/:domain/export", StatsController, :csv_export
709-
get "/:domain/*path", StatsController, :stats
709+
710+
scope assigns: %{live_socket_disable_push_state: true} do
711+
get "/:domain/*path", StatsController, :stats
712+
end
710713
end
711714
end
712715
end

lib/plausible_web/templates/layout/app.html.heex

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@
1212
<meta name="csrf-token" content={Plug.CSRFProtection.get_csrf_token()} />
1313
<meta name="websocket-url" content={websocket_url()} />
1414
<% end %>
15+
<%= if PlausibleWeb.Live.Dashboard.enabled?(assigns[:site]) do %>
16+
<%= if assigns[:live_socket_disable_push_state] do %>
17+
<meta name="live-socket-disable-push-state" content="true" />
18+
<% end %>
19+
<meta name="dashboard-domain" content={@site.domain} />
20+
<% end %>
1521
<meta name="robots" content={@conn.private.robots} />
1622

1723
<PlausibleWeb.Components.Layout.favicon conn={@conn} />

lib/plausible_web/templates/stats/stats.html.heex

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,15 @@
5454
data-team-identifier={@team_identifier}
5555
>
5656
</div>
57+
<%= if PlausibleWeb.Live.Dashboard.enabled?(@site) do %>
58+
{live_render(@conn, PlausibleWeb.Live.Dashboard,
59+
id: "live-dashboard-lv",
60+
session: %{
61+
"domain" => @site.domain,
62+
"url" => Plug.Conn.request_url(@conn)
63+
}
64+
)}
65+
<% end %>
5766
<div id="modal_root"></div>
5867
<%= if !@conn.assigns[:current_user] && @conn.assigns[:demo] do %>
5968
<div class="py-12 lg:py-16 lg:flex lg:items-center lg:justify-between">

priv/repo/seeds.exs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ long_random_urls =
5555
"https://dummy.site#{path}"
5656
end
5757

58+
FunWithFlags.enable(:live_dashboard)
59+
5860
site =
5961
new_site(
6062
domain: "dummy.site",

test/test_helper.exs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ Mox.defmock(Plausible.DnsLookup.Mock,
99
for: Plausible.DnsLookupInterface
1010
)
1111

12+
FunWithFlags.enable(:live_dashboard)
13+
1214
Application.ensure_all_started(:double)
1315

1416
Ecto.Adapters.SQL.Sandbox.mode(Plausible.Repo, :manual)

0 commit comments

Comments
 (0)