From 3af2e1a5e3fd12e2915665be391ada8245b1ec9e Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Mon, 8 Dec 2025 23:05:52 +0100 Subject: [PATCH 1/6] Remove redundant data-tile attribute --- lib/plausible_web/live/components/dashboard/tile.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/plausible_web/live/components/dashboard/tile.ex b/lib/plausible_web/live/components/dashboard/tile.ex index 426f9223da4e..eb3b4a73306a 100644 --- a/lib/plausible_web/live/components/dashboard/tile.ex +++ b/lib/plausible_web/live/components/dashboard/tile.ex @@ -20,7 +20,7 @@ defmodule PlausibleWeb.Components.Dashboard.Tile do ~H"""
-
+
"-title"} class="flex gap-x-1" phx-update={@update_mode}>

{@title}

From 6aa6ad23c30bc1bb1a7a0ff28fae848b99b126d6 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Mon, 8 Dec 2025 23:11:49 +0100 Subject: [PATCH 2/6] Remove unused component --- .../live/components/dashboard/tile.ex | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/lib/plausible_web/live/components/dashboard/tile.ex b/lib/plausible_web/live/components/dashboard/tile.ex index eb3b4a73306a..24a3a780c7fd 100644 --- a/lib/plausible_web/live/components/dashboard/tile.ex +++ b/lib/plausible_web/live/components/dashboard/tile.ex @@ -42,22 +42,6 @@ defmodule PlausibleWeb.Components.Dashboard.Tile do """ end - attr :id, :string, required: true - slot :inner_block, required: true - - def tabs(assigns) do - ~H""" -
- {render_slot(@inner_block)} -
- """ - end - attr :label, :string, required: true attr :value, :string, required: true attr :active, :string, required: true From f272ca5b9e3c1d4c6acee14dc6d2fc9692a9d7d5 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Mon, 8 Dec 2025 23:17:44 +0100 Subject: [PATCH 3/6] Set always to ignore for optimistic loading --- lib/plausible_web/live/components/dashboard/tile.ex | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/plausible_web/live/components/dashboard/tile.ex b/lib/plausible_web/live/components/dashboard/tile.ex index 24a3a780c7fd..6aa17a7a90c2 100644 --- a/lib/plausible_web/live/components/dashboard/tile.ex +++ b/lib/plausible_web/live/components/dashboard/tile.ex @@ -16,19 +16,17 @@ defmodule PlausibleWeb.Components.Dashboard.Tile do slot :inner_block, required: true def tile(assigns) do - assigns = assign(assigns, :update_mode, if(assigns.connected?, do: "ignore", else: "replace")) - ~H"""
-
"-title"} class="flex gap-x-1" phx-update={@update_mode}> +
"-title"} class="flex gap-x-1" phx-update="ignore">

{@title}

"-tabs"} - phx-update={@update_mode} + phx-update="ignore" phx-hook="LiveDashboard" data-widget="tabs" class="flex text-xs font-medium text-gray-500 dark:text-gray-400 space-x-2 items-baseline" From 73465b3eba3b35085e1e4d4d90ef02e39ac28e08 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Mon, 8 Dec 2025 23:23:29 +0100 Subject: [PATCH 4/6] Use `patch` instead of `href` in `dashboard_link` --- lib/plausible_web/live/components/dashboard/base.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/plausible_web/live/components/dashboard/base.ex b/lib/plausible_web/live/components/dashboard/base.ex index 8db5e45c7bfd..041cac215d92 100644 --- a/lib/plausible_web/live/components/dashboard/base.ex +++ b/lib/plausible_web/live/components/dashboard/base.ex @@ -19,7 +19,7 @@ defmodule PlausibleWeb.Components.Dashboard.Base do ~H""" <.link data-type="dashboard-link" - href={@url} + patch={@url} {@rest} > {render_slot(@inner_block)} From 2d0d28ed203abe7a6977a33c2f26c851c59bc7b8 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Tue, 9 Dec 2025 01:07:31 +0100 Subject: [PATCH 5/6] Replace "widgets" with first-class hooks --- assets/js/liveview/dashboard_root.js | 57 +++++++ assets/js/liveview/dashboard_tabs.js | 44 +++++ assets/js/liveview/hook_builder.js | 53 ++++++ assets/js/liveview/live_dashboard.js | 156 ------------------ assets/js/liveview/live_socket.js | 5 +- .../live/components/dashboard/tile.ex | 3 +- lib/plausible_web/live/dashboard.ex | 2 +- 7 files changed, 159 insertions(+), 161 deletions(-) create mode 100644 assets/js/liveview/dashboard_root.js create mode 100644 assets/js/liveview/dashboard_tabs.js create mode 100644 assets/js/liveview/hook_builder.js delete mode 100644 assets/js/liveview/live_dashboard.js diff --git a/assets/js/liveview/dashboard_root.js b/assets/js/liveview/dashboard_root.js new file mode 100644 index 000000000000..42af690f103c --- /dev/null +++ b/assets/js/liveview/dashboard_root.js @@ -0,0 +1,57 @@ +/** + * Hook widget delegating navigation events to and from React. + * Necessary to emulate navigation events in LiveView with pushState + * manipulation disabled. + */ + +import { buildHook } from './hook_builder' + +export default buildHook({ + initialize() { + this.url = window.location.href + + this.addListener('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. + this.addListener('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. + this.addListener('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 + }) + } + }) + } +}) diff --git a/assets/js/liveview/dashboard_tabs.js b/assets/js/liveview/dashboard_tabs.js new file mode 100644 index 000000000000..0f645a7f0134 --- /dev/null +++ b/assets/js/liveview/dashboard_tabs.js @@ -0,0 +1,44 @@ +/** + * Hook widget for optimistic loading of tabs and + * client-side persistence of selection using localStorage. + */ + +import { buildHook } from './hook_builder' + +function getDomain(url) { + const uri = typeof url === 'object' ? url : new URL(url) + return uri.pathname.split('/')[1] +} + +export default buildHook({ + initialize() { + const domain = getDomain(window.location.href) + + this.addListener('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) + } + } + }) + } +}) diff --git a/assets/js/liveview/hook_builder.js b/assets/js/liveview/hook_builder.js new file mode 100644 index 000000000000..48c998d7bfff --- /dev/null +++ b/assets/js/liveview/hook_builder.js @@ -0,0 +1,53 @@ +export function buildHook({ initialize, cleanup }) { + cleanup = cleanup || function () {} + + return { + mounted() { + this.initialize() + }, + + updated() { + this.initialize() + }, + + reconnected() { + this.initialize() + }, + + destroyed() { + this.cleanup() + }, + + initialize() { + this.cleanup() + initialize.bind(this)() + }, + + cleanup() { + this.removeListeners() + cleanup.bind(this)() + }, + + addListener(eventName, listener, callback) { + this.listeners = this.listeners || [] + + listener.addEventListener(eventName, callback) + + this.listeners.push({ + element: listener, + event: eventName, + callback: callback + }) + }, + + removeListeners() { + if (this.listeners) { + this.listeners.forEach((l) => { + l.element.removeEventListener(l.event, l.callback) + }) + + this.listeners = null + } + } + } +} diff --git a/assets/js/liveview/live_dashboard.js b/assets/js/liveview/live_dashboard.js deleted file mode 100644 index 222ab1c0ed2c..000000000000 --- a/assets/js/liveview/live_dashboard.js +++ /dev/null @@ -1,156 +0,0 @@ -/** - * Hook used by LiveView dashboard. - * - * Defines various widgets to use by various dashboard specific components. - */ - -const WIDGETS = { - // 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)() - } -} diff --git a/assets/js/liveview/live_socket.js b/assets/js/liveview/live_socket.js index 3ac14c51fcd2..627eaff7a44e 100644 --- a/assets/js/liveview/live_socket.js +++ b/assets/js/liveview/live_socket.js @@ -8,7 +8,8 @@ import 'phoenix_html' import { Socket } from 'phoenix' import { LiveSocket } from 'phoenix_live_view' import { Modal, Dropdown } from 'prima' -import LiveDashboard from './live_dashboard' +import DashboardRoot from './dashboard_root' +import DashboardTabs from './dashboard_tabs.js' import topbar from 'topbar' /* eslint-enable import/no-unresolved */ @@ -21,7 +22,7 @@ let disablePushStateFlag = document.querySelector( ) let domain = document.querySelector("meta[name='dashboard-domain']") if (csrfToken && websocketUrl) { - let Hooks = { Modal, Dropdown, LiveDashboard } + let Hooks = { Modal, Dropdown, DashboardRoot, DashboardTabs } Hooks.Metrics = { mounted() { this.handleEvent('send-metrics', ({ event_name }) => { diff --git a/lib/plausible_web/live/components/dashboard/tile.ex b/lib/plausible_web/live/components/dashboard/tile.ex index 6aa17a7a90c2..b09ef681d7fd 100644 --- a/lib/plausible_web/live/components/dashboard/tile.ex +++ b/lib/plausible_web/live/components/dashboard/tile.ex @@ -27,8 +27,7 @@ defmodule PlausibleWeb.Components.Dashboard.Tile do :if={@tabs != []} id={@id <> "-tabs"} phx-update="ignore" - phx-hook="LiveDashboard" - data-widget="tabs" + phx-hook="DashboardTabs" class="flex text-xs font-medium text-gray-500 dark:text-gray-400 space-x-2 items-baseline" > {render_slot(@tabs)} diff --git a/lib/plausible_web/live/dashboard.ex b/lib/plausible_web/live/dashboard.ex index 58685e9524ea..a1729f7355e2 100644 --- a/lib/plausible_web/live/dashboard.ex +++ b/lib/plausible_web/live/dashboard.ex @@ -47,7 +47,7 @@ defmodule PlausibleWeb.Live.Dashboard do def render(assigns) do ~H""" -
+
<.portal_wrapper id="pages-breakdown-live-container" target="#pages-breakdown-live"> <.live_component module={PlausibleWeb.Live.Dashboard.Pages} From 68132690a030df54683b6db6d5f1b46ce74665be Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Tue, 9 Dec 2025 01:18:54 +0100 Subject: [PATCH 6/6] Fix `useEffect` React dependency --- assets/js/dashboard/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/js/dashboard/index.tsx b/assets/js/dashboard/index.tsx index 81b46a180e22..d0c6fc0a36ad 100644 --- a/assets/js/dashboard/index.tsx +++ b/assets/js/dashboard/index.tsx @@ -48,7 +48,7 @@ function DashboardStats({ onLiveNavigate as EventListener ) } - }, [navigate]) + }, [onLiveNavigate]) 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'