Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions assets/js/dashboard/components/liveview-portal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Component used for embedding LiveView components inside React.
*
* The content of the portal is completely excluded from React re-renders with
* a hardwired `React.memo`.
*/

import React from 'react'
import classNames from 'classnames'

const MIN_HEIGHT = 380

type LiveViewPortalProps = {
id: string
className?: string
}

export const LiveViewPortal = React.memo(
function ({ id, className }: LiveViewPortalProps) {
return (
<div
id={id}
className={classNames('group', className)}
style={{ width: '100%', border: '0', minHeight: MIN_HEIGHT }}
>
<div
className="w-full flex flex-col justify-center group-has-[[data-phx-teleported]]:hidden"
style={{ minHeight: MIN_HEIGHT }}
>
<div className="mx-auto loading">
<div />
</div>
</div>
</div>
)
},
() => true
)
45 changes: 43 additions & 2 deletions assets/js/dashboard/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { useMemo, useState } from 'react'
import React, { useMemo, useState, useEffect, useCallback } from 'react'
import { LiveViewPortal } from './components/liveview-portal'
import VisitorGraph from './stats/graph/visitor-graph'
import Sources from './stats/sources'
import Pages from './stats/pages'
Expand All @@ -7,7 +8,10 @@
import { TopBar } from './nav-menu/top-bar'
import Behaviours from './stats/behaviours'
import { useQueryContext } from './query-context'
import { useSiteContext } from './site-context'
import { isRealTimeDashboard } from './util/filters'
import { useAppNavigate } from './navigation/use-app-navigate'
import { parseSearch } from './util/url-search-params'

function DashboardStats({
importedDataInView,
Expand All @@ -16,6 +20,36 @@
importedDataInView?: boolean
updateImportedDataInView?: (v: boolean) => void
}) {
const navigate = useAppNavigate()
const site = useSiteContext()

// Handler for navigation events delegated from LiveView dashboard.
// Necessary to emulate navigation events in LiveView with pushState
// manipulation disabled.
const onLiveNavigate = useCallback(
(e: CustomEvent) => {
navigate({
path: e.detail.path,
search: () => parseSearch(e.detail.search)
})
},
[navigate]
)

useEffect(() => {
window.addEventListener(
'dashboard:live-navigate',
onLiveNavigate as EventListener
)

return () => {
window.removeEventListener(
'dashboard:live-navigate',
onLiveNavigate as EventListener
)
}
}, [navigate])

Check warning on line 51 in assets/js/dashboard/index.tsx

View workflow job for this annotation

GitHub Actions / Build and test

React Hook useEffect has a missing dependency: 'onLiveNavigate'. Either include it or remove the dependency array

const statsBoxClass =
'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'

Expand All @@ -27,7 +61,14 @@
<Sources />
</div>
<div className={statsBoxClass}>
<Pages />
{site.flags.live_dashboard ? (
<LiveViewPortal
id="pages-breakdown-live"
className="w-full h-full border-0 overflow-hidden"
/>
) : (
<Pages />
)}
</div>
</div>

Expand Down
9 changes: 9 additions & 0 deletions assets/js/dashboard/navigation/use-app-navigate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,15 @@ export const useAppNavigate = () => {
search,
...options
}: AppNavigationTarget & NavigateOptions) => {
// Event dispatched for handling by LiveView dashboard via hook.
// Necessary to emulate navigation events in LiveView with pushState
// manipulation disabled.
window.dispatchEvent(
new CustomEvent('dashboard:live-navigate-back', {
detail: { search: window.location.search }
})
)

return _navigate(getToOptions({ path, params, search }), options)
},
[getToOptions, _navigate]
Expand Down
4 changes: 3 additions & 1 deletion assets/js/dashboard/site-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ export function parseSiteFromDataset(dataset: DOMStringMap): PlausibleSite {
}

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

export const siteContextDefaultValue = {
domain: '',
Expand Down
156 changes: 156 additions & 0 deletions assets/js/liveview/live_dashboard.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/**
* Hook used by LiveView dashboard.
*
* Defines various widgets to use by various dashboard specific components.
*/

const WIDGETS = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally I'm not a fan of introducing this new concept (widget). It took a bit of jumping around and reading the code to understand it's just a liveview hook. Why not call them what they are - hooks - and register them directly instead of via data-widget? This way anyone who's familiar with liveview (and the concept of hooks) understands immediately what's going on without having to learn and get used to an intermediary layer.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By the way - I do like the shared structure for adding/removing listeners and cleaning them up. In Prima I have the same problem where all the components duplicate some amount of this structure.

I wonder if this can be solved via some kind of mixin or inheritance instead. I haven't really experimented with sharing behaviour between hooks yet.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My worry was about having to add multitude of hooks, majority of them concerned with dashboard specific stuff. I'm not saying it's rational 😅 And you are right, it's not worth introducing cognitive load of a new concept I came up with "on the go". I'll see what can we do about structure sharing.

Copy link
Contributor

@ukutaht ukutaht Dec 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My worry was about having to add multitude of hooks, majority of them concerned with dashboard specific stuff. I'm not saying it's rational

Haha yeah I don't think there's a problem with having many hooks. Surely hooks is just a map in liveview that looks them up in the same way as WIDGETS[this.widget].

It is a bit annoying to have to manually import and register them all when initializing a liveSocket. But I think there's also value in being explicit rather than magical. For namespacing and organization perhaps we can make something like this:

// live_dashboard.js

import HookA from "./hooks/hook_a.js"
import HookB from "./hooks/hook_b.js"
import HookC from "./hooks/hook_c.js"

export default {
  HookA,
  HookB,
  HookC
}

// live_socket.js

import LiveDashboard from './live_dashboard.js

let Hooks = { Modal, Dropdown, LiveDashboard } // these are given to LiveSocket during initialization

// dashboard_component.ex

<div phx-hook="LiveDashboard.HookA">

There's also a new ColocatedHook feature in Phoenix. I haven't tried it, not sure if/how it would work if you want to import some shared helpers or dependencies in these co-located hooks for example.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WDYT about the approach taken in #5937 ?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good!

// Hook widget delegating navigation events to and from React.
// Necessary to emulate navigation events in LiveView with pushState
// manipulation disabled.
'dashboard-root': {
initialize: function () {
this.url = window.location.href

addListener.bind(this)('click', document.body, (e) => {
const type = e.target.dataset.type || null

if (type === 'dashboard-link') {
this.url = e.target.href
const uri = new URL(this.url)
// Domain is dropped from URL prefix, because that's what react-dom-router
// expects.
const path = '/' + uri.pathname.split('/').slice(2).join('/')
this.el.dispatchEvent(
new CustomEvent('dashboard:live-navigate', {
bubbles: true,
detail: { path: path, search: uri.search }
})
)

this.pushEvent('handle_dashboard_params', { url: this.url })

e.preventDefault()
}
})

// Browser back and forward navigation triggers that event.
addListener.bind(this)('popstate', window, () => {
if (this.url !== window.location.href) {
this.pushEvent('handle_dashboard_params', {
url: window.location.href
})
}
})

// Navigation events triggered from liveview are propagated via this
// handler.
addListener.bind(this)('dashboard:live-navigate-back', window, (e) => {
if (
typeof e.detail.search === 'string' &&
this.url !== window.location.href
) {
this.pushEvent('handle_dashboard_params', {
url: window.location.href
})
}
})
},
cleanup: function () {
removeListeners.bind(this)()
}
},
// Hook widget for optimistic loading of tabs and
// client-side persistence of selection using localStorage.
tabs: {
initialize: function () {
const domain = getDomain(window.location.href)

addListener.bind(this)('click', this.el, (e) => {
const button = e.target.closest('button')
const tab = button && button.dataset.tab

if (tab) {
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]')

title.innerText = label

this.el.querySelectorAll(`button[data-tab] span`).forEach((s) => {
s.className = inactiveClasses
})

button.querySelector('span').className = activeClasses

if (storageKey) {
localStorage.setItem(`${storageKey}__${domain}`, tab)
}
}
})
},
cleanup: function () {
removeListeners.bind(this)()
}
}
}

function getDomain(url) {
const uri = typeof url === 'object' ? url : new URL(url)
return uri.pathname.split('/')[1]
}

function addListener(eventName, listener, callback) {
this.listeners = this.listeners || []

listener.addEventListener(eventName, callback)

this.listeners.push({
element: listener,
event: eventName,
callback: callback
})
}

function removeListeners() {
if (this.listeners) {
this.listeners.forEach((l) => {
l.element.removeEventListener(l.event, l.callback)
})

this.listeners = null
}
}

export default {
mounted() {
this.widget = this.el.getAttribute('data-widget')

this.initialize()
},

updated() {
this.initialize()
},

reconnected() {
this.initialize()
},

destroyed() {
this.cleanup()
},

initialize() {
this.cleanup()
WIDGETS[this.widget].initialize.bind(this)()
},

cleanup() {
WIDGETS[this.widget].cleanup.bind(this)()
}
}
29 changes: 27 additions & 2 deletions assets/js/liveview/live_socket.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,26 @@
The modules below this comment block are resolved from '../deps' folder,
which does not exist when running the lint command in Github CI
*/

/* eslint-disable import/no-unresolved */
import 'phoenix_html'
import { Socket } from 'phoenix'
import { LiveSocket } from 'phoenix_live_view'
import { Modal, Dropdown } from 'prima'
import LiveDashboard from './live_dashboard'
import topbar from 'topbar'
/* eslint-enable import/no-unresolved */

import Alpine from 'alpinejs'

let csrfToken = document.querySelector("meta[name='csrf-token']")
let websocketUrl = document.querySelector("meta[name='websocket-url']")
let disablePushStateFlag = document.querySelector(
"meta[name='live-socket-disable-push-state']"
)
let domain = document.querySelector("meta[name='dashboard-domain']")
if (csrfToken && websocketUrl) {
let Hooks = { Modal, Dropdown }
let Hooks = { Modal, Dropdown, LiveDashboard }
Hooks.Metrics = {
mounted() {
this.handleEvent('send-metrics', ({ event_name }) => {
Expand Down Expand Up @@ -48,9 +54,14 @@ if (csrfToken && websocketUrl) {
let token = csrfToken.getAttribute('content')
let url = websocketUrl.getAttribute('content')
let liveUrl = url === '' ? '/live' : new URL('/live', url).href
let disablePushState =
!!disablePushStateFlag &&
disablePushStateFlag.getAttribute('content') === 'true'
let domainName = domain && domain.getAttribute('content')
let liveSocket = new LiveSocket(liveUrl, Socket, {
// For dashboard LV migration
disablePushState: disablePushState,
heartbeatIntervalMs: 10000,
params: { _csrf_token: token },
hooks: Hooks,
uploaders: Uploaders,
dom: {
Expand All @@ -60,6 +71,20 @@ if (csrfToken && websocketUrl) {
Alpine.clone(from, to)
}
}
},
params: () => {
if (domainName) {
return {
// The prefs are used by dashboard LiveView to persist
// user preferences across the reloads.
user_prefs: {
pages_tab: localStorage.getItem(`pageTab__${domainName}`)
},
_csrf_token: token
}
} else {
return { _csrf_token: token }
}
}
})

Expand Down
5 changes: 3 additions & 2 deletions lib/plausible_web/controllers/stats_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,8 @@ defmodule PlausibleWeb.StatsController do
hide_footer?: if(ce?() || demo, do: false, else: site_role != :public),
consolidated_view?: consolidated_view?,
consolidated_view_available?: consolidated_view_available?,
team_identifier: team_identifier
team_identifier: team_identifier,
connect_live_socket: PlausibleWeb.Live.Dashboard.enabled?(site)
)

!stats_start_date && can_see_stats? ->
Expand Down Expand Up @@ -447,7 +448,7 @@ defmodule PlausibleWeb.StatsController do

defp get_flags(user, site),
do:
[]
[:live_dashboard]
|> Enum.map(fn flag ->
{flag, FunWithFlags.enabled?(flag, for: user) || FunWithFlags.enabled?(flag, for: site)}
end)
Expand Down
Loading
Loading