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`
This commit is contained in:
Adrian Gruntkowski 2025-12-08 12:46:56 +01:00 committed by GitHub
parent 007155ba60
commit 16f1eb3075
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 627 additions and 10 deletions

View File

@ -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
)

View File

@ -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 VisitorGraph from './stats/graph/visitor-graph'
import Sources from './stats/sources' import Sources from './stats/sources'
import Pages from './stats/pages' import Pages from './stats/pages'
@ -7,7 +8,10 @@ import Devices from './stats/devices'
import { TopBar } from './nav-menu/top-bar' import { TopBar } from './nav-menu/top-bar'
import Behaviours from './stats/behaviours' import Behaviours from './stats/behaviours'
import { useQueryContext } from './query-context' import { useQueryContext } from './query-context'
import { useSiteContext } from './site-context'
import { isRealTimeDashboard } from './util/filters' import { isRealTimeDashboard } from './util/filters'
import { useAppNavigate } from './navigation/use-app-navigate'
import { parseSearch } from './util/url-search-params'
function DashboardStats({ function DashboardStats({
importedDataInView, importedDataInView,
@ -16,6 +20,36 @@ function DashboardStats({
importedDataInView?: boolean importedDataInView?: boolean
updateImportedDataInView?: (v: boolean) => void 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])
const statsBoxClass = 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' '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'
@ -27,7 +61,14 @@ function DashboardStats({
<Sources /> <Sources />
</div> </div>
<div className={statsBoxClass}> <div className={statsBoxClass}>
{site.flags.live_dashboard ? (
<LiveViewPortal
id="pages-breakdown-live"
className="w-full h-full border-0 overflow-hidden"
/>
) : (
<Pages /> <Pages />
)}
</div> </div>
</div> </div>

View File

@ -63,6 +63,15 @@ export const useAppNavigate = () => {
search, search,
...options ...options
}: AppNavigationTarget & NavigateOptions) => { }: 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) return _navigate(getToOptions({ path, params, search }), options)
}, },
[getToOptions, _navigate] [getToOptions, _navigate]

View File

@ -27,7 +27,9 @@ export function parseSiteFromDataset(dataset: DOMStringMap): PlausibleSite {
} }
// Update this object when new feature flags are added to the frontend. // 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 = { export const siteContextDefaultValue = {
domain: '', domain: '',

View File

@ -0,0 +1,156 @@
/**
* 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)()
}
}

View File

@ -2,11 +2,13 @@
The modules below this comment block are resolved from '../deps' folder, The modules below this comment block are resolved from '../deps' folder,
which does not exist when running the lint command in Github CI which does not exist when running the lint command in Github CI
*/ */
/* eslint-disable import/no-unresolved */ /* eslint-disable import/no-unresolved */
import 'phoenix_html' import 'phoenix_html'
import { Socket } from 'phoenix' import { Socket } from 'phoenix'
import { LiveSocket } from 'phoenix_live_view' import { LiveSocket } from 'phoenix_live_view'
import { Modal, Dropdown } from 'prima' import { Modal, Dropdown } from 'prima'
import LiveDashboard from './live_dashboard'
import topbar from 'topbar' import topbar from 'topbar'
/* eslint-enable import/no-unresolved */ /* eslint-enable import/no-unresolved */
@ -14,8 +16,12 @@ import Alpine from 'alpinejs'
let csrfToken = document.querySelector("meta[name='csrf-token']") let csrfToken = document.querySelector("meta[name='csrf-token']")
let websocketUrl = document.querySelector("meta[name='websocket-url']") 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) { if (csrfToken && websocketUrl) {
let Hooks = { Modal, Dropdown } let Hooks = { Modal, Dropdown, LiveDashboard }
Hooks.Metrics = { Hooks.Metrics = {
mounted() { mounted() {
this.handleEvent('send-metrics', ({ event_name }) => { this.handleEvent('send-metrics', ({ event_name }) => {
@ -48,9 +54,14 @@ if (csrfToken && websocketUrl) {
let token = csrfToken.getAttribute('content') let token = csrfToken.getAttribute('content')
let url = websocketUrl.getAttribute('content') let url = websocketUrl.getAttribute('content')
let liveUrl = url === '' ? '/live' : new URL('/live', url).href 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, { let liveSocket = new LiveSocket(liveUrl, Socket, {
// For dashboard LV migration
disablePushState: disablePushState,
heartbeatIntervalMs: 10000, heartbeatIntervalMs: 10000,
params: { _csrf_token: token },
hooks: Hooks, hooks: Hooks,
uploaders: Uploaders, uploaders: Uploaders,
dom: { dom: {
@ -60,6 +71,20 @@ if (csrfToken && websocketUrl) {
Alpine.clone(from, to) 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 }
}
} }
}) })

View File

@ -100,7 +100,8 @@ defmodule PlausibleWeb.StatsController do
hide_footer?: if(ce?() || demo, do: false, else: site_role != :public), hide_footer?: if(ce?() || demo, do: false, else: site_role != :public),
consolidated_view?: consolidated_view?, consolidated_view?: consolidated_view?,
consolidated_view_available?: consolidated_view_available?, 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? -> !stats_start_date && can_see_stats? ->
@ -455,7 +456,7 @@ defmodule PlausibleWeb.StatsController do
defp get_flags(user, site), defp get_flags(user, site),
do: do:
[] [:live_dashboard]
|> Enum.map(fn flag -> |> Enum.map(fn flag ->
{flag, FunWithFlags.enabled?(flag, for: user) || FunWithFlags.enabled?(flag, for: site)} {flag, FunWithFlags.enabled?(flag, for: user) || FunWithFlags.enabled?(flag, for: site)}
end) end)

View File

@ -0,0 +1,29 @@
defmodule PlausibleWeb.Components.Dashboard.Base do
@moduledoc """
Common components for dasbhaord.
"""
use PlausibleWeb, :component
attr :href, :string, required: true
attr :site, Plausible.Site, required: true
attr :class, :string, default: ""
attr :rest, :global
slot :inner_block, required: true
def dashboard_link(assigns) do
url = "/" <> assigns.site.domain <> assigns.href
assigns = assign(assigns, :url, url)
~H"""
<.link
data-type="dashboard-link"
href={@url}
{@rest}
>
{render_slot(@inner_block)}
</.link>
"""
end
end

View File

@ -0,0 +1,92 @@
defmodule PlausibleWeb.Components.Dashboard.Tile do
@moduledoc """
Components for rendering dashboard tile contents.
"""
use PlausibleWeb, :component
attr :id, :string, required: true
attr :title, :string, required: true
# Optimistic rendering requires preventing LV patching of
# title and tabs. The update of those is handled by `tab`
# widget hook.
attr :connected?, :boolean, required: true
slot :tabs
slot :inner_block, required: true
def tile(assigns) do
assigns = assign(assigns, :update_mode, if(assigns.connected?, do: "ignore", else: "replace"))
~H"""
<div data-tile id={@id}>
<div data-tile class="w-full flex justify-between h-full">
<div id={@id <> "-title"} class="flex gap-x-1" phx-update={@update_mode}>
<h3 data-title class="font-bold dark:text-gray-100">{@title}</h3>
</div>
<div
:if={@tabs != []}
id={@id <> "-tabs"}
phx-update={@update_mode}
phx-hook="LiveDashboard"
data-widget="tabs"
class="flex text-xs font-medium text-gray-500 dark:text-gray-400 space-x-2 items-baseline"
>
{render_slot(@tabs)}
</div>
</div>
{render_slot(@inner_block)}
</div>
"""
end
attr :id, :string, required: true
slot :inner_block, required: true
def tabs(assigns) do
~H"""
<div
id={@id}
phx-hook="LiveDashboard"
data-widget="tabs"
class="flex text-xs font-medium text-gray-500 dark:text-gray-400 space-x-2 items-baseline"
>
{render_slot(@inner_block)}
</div>
"""
end
attr :label, :string, required: true
attr :value, :string, required: true
attr :active, :string, required: true
attr :target, :any, required: true
def tab(assigns) do
assigns =
assign(assigns,
active_classes:
"text-indigo-600 dark:text-indigo-500 font-bold underline decoration-2 decoration-indigo-600 dark:decoration-indigo-500",
inactive_classes: "hover:text-indigo-700 dark:hover:text-indigo-400 cursor-pointer"
)
~H"""
<button
class="rounded-sm truncate text-left transition-colors duration-150"
data-tab={@value}
data-label={@label}
data-storage-key="pageTab"
data-active-classes={@active_classes}
data-inactive-classes={@inactive_classes}
phx-click="set-tab"
phx-value-tab={@value}
phx-target={@target}
>
<span class={if(@value == @active, do: @active_classes, else: @inactive_classes)}>
{@label}
</span>
</button>
"""
end
end

View File

@ -0,0 +1,93 @@
defmodule PlausibleWeb.Live.Dashboard do
@moduledoc """
LiveView for site dashboard.
"""
use PlausibleWeb, :live_view
alias Plausible.Repo
alias Plausible.Teams
@spec enabled?(Plausible.Site.t() | nil) :: boolean()
def enabled?(nil), do: false
def enabled?(site) do
FunWithFlags.enabled?(:live_dashboard, for: site)
end
def mount(_params, %{"domain" => domain, "url" => url}, socket) do
user_prefs = get_connect_params(socket)["user_prefs"] || %{}
# As domain is passed via session, the associated site has already passed
# validation logic on plug level.
site =
Plausible.Site
|> Repo.get_by!(domain: domain)
|> Repo.preload([
:owners,
:completed_imports,
team: [:owners, subscription: Teams.last_subscription_query()]
])
socket =
socket
|> assign(:connected?, connected?(socket))
|> assign(:site, site)
|> assign(:user_prefs, user_prefs)
|> assign(:params, %{})
{:noreply, socket} = handle_params_internal(%{}, url, socket)
{:ok, socket}
end
def handle_params_internal(_params, _url, socket) do
{:noreply, socket}
end
def render(assigns) do
~H"""
<div id="live-dashboard-container" phx-hook="LiveDashboard" data-widget="dashboard-root">
<.portal_wrapper id="pages-breakdown-live-container" target="#pages-breakdown-live">
<.live_component
module={PlausibleWeb.Live.Dashboard.Pages}
id="pages-breakdown-component"
site={@site}
user_prefs={@user_prefs}
connected?={@connected?}
/>
</.portal_wrapper>
</div>
"""
end
def handle_event("handle_dashboard_params", %{"url" => url}, socket) do
query =
url
|> URI.parse()
|> Map.fetch!(:query)
params = URI.decode_query(query || "")
handle_params_internal(params, url, socket)
end
attr :id, :string, required: true
attr :target, :string, required: true
slot :inner_block
if Mix.env() in [:test, :ce_test] do
defp portal_wrapper(assigns) do
~H"""
<div id={@id}>{render_slot(@inner_block)}</div>
"""
end
else
defp portal_wrapper(assigns) do
~H"""
<.portal id={@id} target={@target}>{render_slot(@inner_block)}</.portal>
"""
end
end
end

View File

@ -0,0 +1,67 @@
defmodule PlausibleWeb.Live.Dashboard.Pages do
@moduledoc """
Pages breakdown component.
"""
use PlausibleWeb, :live_component
alias PlausibleWeb.Components.Dashboard.Base
alias PlausibleWeb.Components.Dashboard.Tile
@tabs [
{"pages", "Top Pages"},
{"entry-pages", "Entry Pages"},
{"exit-pages", "Exit Pages"}
]
@tab_labels Map.new(@tabs)
def update(assigns, socket) do
active_tab = assigns.user_prefs["pages_tab"] || "pages"
socket =
assign(socket,
site: assigns.site,
tabs: @tabs,
tab_labels: @tab_labels,
active_tab: active_tab,
connected?: assigns.connected?
)
{:ok, socket}
end
def render(assigns) do
~H"""
<div>
<Tile.tile id="breakdown-tile-pages" title={@tab_labels[@active_tab]} connected?={@connected?}>
<:tabs>
<Tile.tab
:for={{value, label} <- @tabs}
label={label}
value={value}
active={@active_tab}
target={@myself}
/>
</:tabs>
<div class="mx-auto font-medium text-gray-500 dark:text-gray-400">
<Base.dashboard_link site={@site} href="?f=is,source,Direct / None">
Filter by source Direct / None
</Base.dashboard_link>
</div>
</Tile.tile>
</div>
"""
end
def handle_event("set-tab", %{"tab" => tab}, socket) do
if tab != socket.assigns.active_tab do
socket = assign(socket, :active_tab, tab)
{:noreply, socket}
else
{:noreply, socket}
end
end
end

View File

@ -706,7 +706,10 @@ defmodule PlausibleWeb.Router do
put "/:domain/settings", SiteController, :update_settings put "/:domain/settings", SiteController, :update_settings
get "/:domain/export", StatsController, :csv_export get "/:domain/export", StatsController, :csv_export
scope assigns: %{live_socket_disable_push_state: true} do
get "/:domain/*path", StatsController, :stats get "/:domain/*path", StatsController, :stats
end end
end end
end
end end

View File

@ -12,6 +12,12 @@
<meta name="csrf-token" content={Plug.CSRFProtection.get_csrf_token()} /> <meta name="csrf-token" content={Plug.CSRFProtection.get_csrf_token()} />
<meta name="websocket-url" content={websocket_url()} /> <meta name="websocket-url" content={websocket_url()} />
<% end %> <% end %>
<%= if PlausibleWeb.Live.Dashboard.enabled?(assigns[:site]) do %>
<%= if assigns[:live_socket_disable_push_state] do %>
<meta name="live-socket-disable-push-state" content="true" />
<% end %>
<meta name="dashboard-domain" content={@site.domain} />
<% end %>
<meta name="robots" content={@conn.private.robots} /> <meta name="robots" content={@conn.private.robots} />
<PlausibleWeb.Components.Layout.favicon conn={@conn} /> <PlausibleWeb.Components.Layout.favicon conn={@conn} />

View File

@ -54,6 +54,15 @@
data-team-identifier={@team_identifier} data-team-identifier={@team_identifier}
> >
</div> </div>
<%= if PlausibleWeb.Live.Dashboard.enabled?(@site) do %>
{live_render(@conn, PlausibleWeb.Live.Dashboard,
id: "live-dashboard-lv",
session: %{
"domain" => @site.domain,
"url" => Plug.Conn.request_url(@conn)
}
)}
<% end %>
<div id="modal_root"></div> <div id="modal_root"></div>
<%= if !@conn.assigns[:current_user] && @conn.assigns[:demo] do %> <%= if !@conn.assigns[:current_user] && @conn.assigns[:demo] do %>
<div class="py-12 lg:py-16 lg:flex lg:items-center lg:justify-between"> <div class="py-12 lg:py-16 lg:flex lg:items-center lg:justify-between">

View File

@ -111,7 +111,10 @@ defmodule Plausible.MixProject do
{:phoenix_html, "~> 4.1"}, {:phoenix_html, "~> 4.1"},
{:phoenix_live_reload, "~> 1.2", only: [:dev, :ce_dev]}, {:phoenix_live_reload, "~> 1.2", only: [:dev, :ce_dev]},
{:phoenix_pubsub, "~> 2.0"}, {:phoenix_pubsub, "~> 2.0"},
{:phoenix_live_view, "~> 1.1.17"}, {:phoenix_live_view,
git: "https://github.com/plausible/phoenix_live_view.git",
branch: "disable-push-state-v-1-1-18",
override: true},
{:php_serializer, "~> 2.0"}, {:php_serializer, "~> 2.0"},
{:plug, "~> 1.13", override: true}, {:plug, "~> 1.13", override: true},
{:prima, "~> 0.2.1"}, {:prima, "~> 0.2.1"},

View File

@ -124,7 +124,7 @@
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"}, "phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
"phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"}, "phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.1", "05df733a09887a005ed0d69a7fc619d376aea2730bf64ce52ac51ce716cc1ef0", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "74273843d5a6e4fef0bbc17599f33e3ec63f08e69215623a0cd91eea4288e5a0"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.1", "05df733a09887a005ed0d69a7fc619d376aea2730bf64ce52ac51ce716cc1ef0", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "74273843d5a6e4fef0bbc17599f33e3ec63f08e69215623a0cd91eea4288e5a0"},
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.18", "b5410017b3d4edf261d9c98ebc334e0637d7189457c730720cfc13e206443d43", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f189b759595feff0420e9a1d544396397f9cf9e2d5a8cb98ba5b6cab01927da0"}, "phoenix_live_view": {:git, "https://github.com/plausible/phoenix_live_view.git", "2c59a7da2f254ce789ee5924b34907870624ca3b", [branch: "disable-push-state-v-1-1-18"]},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
"phoenix_storybook": {:hex, :phoenix_storybook, "0.9.3", "4f94e731d4c40d4dd7d1eddf7d5c6914366da7d78552dc565b222e4036d0d76f", [:mix], [{:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:jason, "~> 1.3", [hex: :jason, repo: "hexpm", optional: true]}, {:makeup_eex, "~> 2.0.2", [hex: :makeup_eex, repo: "hexpm", optional: false]}, {:makeup_html, "~> 0.2.0", [hex: :makeup_html, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.8.1", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "4c8658b756fd8238f7e8e4343a0f12bdb91d4eba592b1c4e8118b37b6fd43e4b"}, "phoenix_storybook": {:hex, :phoenix_storybook, "0.9.3", "4f94e731d4c40d4dd7d1eddf7d5c6914366da7d78552dc565b222e4036d0d76f", [:mix], [{:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:jason, "~> 1.3", [hex: :jason, repo: "hexpm", optional: true]}, {:makeup_eex, "~> 2.0.2", [hex: :makeup_eex, repo: "hexpm", optional: false]}, {:makeup_html, "~> 0.2.0", [hex: :makeup_html, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.8.1", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "4c8658b756fd8238f7e8e4343a0f12bdb91d4eba592b1c4e8118b37b6fd43e4b"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},

View File

@ -55,6 +55,8 @@ long_random_urls =
"https://dummy.site#{path}" "https://dummy.site#{path}"
end end
FunWithFlags.enable(:live_dashboard)
site = site =
new_site( new_site(
domain: "dummy.site", domain: "dummy.site",

View File

@ -0,0 +1,39 @@
defmodule PlausibleWeb.Live.DashboardTest do
use PlausibleWeb.ConnCase, async: false
import Phoenix.LiveViewTest
setup [:create_user, :log_in, :create_site]
setup %{site: site} do
populate_stats(site, [build(:pageview)])
:ok
end
describe "GET /:domain" do
test "renders live dashboard container", %{conn: conn, site: site} do
conn = get(conn, "/#{site.domain}")
html = html_response(conn, 200)
assert element_exists?(html, "#live-dashboard-container")
assert element_exists?(html, "#pages-breakdown-live-container")
end
end
describe "Live.Dashboard" do
test "it works", %{conn: conn, site: site} do
{lv, _html} = get_liveview(conn, site)
assert has_element?(lv, "#pages-breakdown-live-container")
assert has_element?(lv, "#breakdown-tile-pages")
assert has_element?(lv, "#breakdown-tile-pages-title")
assert has_element?(lv, "#breakdown-tile-pages-tabs")
end
end
defp get_liveview(conn, site) do
conn = assign(conn, :live_module, PlausibleWeb.Live.Dashboard)
{:ok, lv, html} = live(conn, "/#{site.domain}")
{lv, html}
end
end

View File

@ -9,6 +9,8 @@ Mox.defmock(Plausible.DnsLookup.Mock,
for: Plausible.DnsLookupInterface for: Plausible.DnsLookupInterface
) )
FunWithFlags.enable(:live_dashboard)
Application.ensure_all_started(:double) Application.ensure_all_started(:double)
Ecto.Adapters.SQL.Sandbox.mode(Plausible.Repo, :manual) Ecto.Adapters.SQL.Sandbox.mode(Plausible.Repo, :manual)