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:
parent
007155ba60
commit
16f1eb3075
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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'
|
||||
|
|
@ -7,7 +8,10 @@ import Devices from './stats/devices'
|
|||
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,
|
||||
|
|
@ -16,6 +20,36 @@ function DashboardStats({
|
|||
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])
|
||||
|
||||
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'
|
||||
|
||||
|
|
@ -27,7 +61,14 @@ function DashboardStats({
|
|||
<Sources />
|
||||
</div>
|
||||
<div className={statsBoxClass}>
|
||||
{site.flags.live_dashboard ? (
|
||||
<LiveViewPortal
|
||||
id="pages-breakdown-live"
|
||||
className="w-full h-full border-0 overflow-hidden"
|
||||
/>
|
||||
) : (
|
||||
<Pages />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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: '',
|
||||
|
|
|
|||
|
|
@ -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)()
|
||||
}
|
||||
}
|
||||
|
|
@ -2,11 +2,13 @@
|
|||
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 */
|
||||
|
||||
|
|
@ -14,8 +16,12 @@ 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 }) => {
|
||||
|
|
@ -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: {
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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? ->
|
||||
|
|
@ -455,7 +456,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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -706,7 +706,10 @@ defmodule PlausibleWeb.Router do
|
|||
put "/:domain/settings", SiteController, :update_settings
|
||||
|
||||
get "/:domain/export", StatsController, :csv_export
|
||||
|
||||
scope assigns: %{live_socket_disable_push_state: true} do
|
||||
get "/:domain/*path", StatsController, :stats
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -12,6 +12,12 @@
|
|||
<meta name="csrf-token" content={Plug.CSRFProtection.get_csrf_token()} />
|
||||
<meta name="websocket-url" content={websocket_url()} />
|
||||
<% 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} />
|
||||
|
||||
<PlausibleWeb.Components.Layout.favicon conn={@conn} />
|
||||
|
|
|
|||
|
|
@ -54,6 +54,15 @@
|
|||
data-team-identifier={@team_identifier}
|
||||
>
|
||||
</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>
|
||||
<%= if !@conn.assigns[:current_user] && @conn.assigns[:demo] do %>
|
||||
<div class="py-12 lg:py-16 lg:flex lg:items-center lg:justify-between">
|
||||
|
|
|
|||
5
mix.exs
5
mix.exs
|
|
@ -111,7 +111,10 @@ defmodule Plausible.MixProject do
|
|||
{:phoenix_html, "~> 4.1"},
|
||||
{:phoenix_live_reload, "~> 1.2", only: [:dev, :ce_dev]},
|
||||
{: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"},
|
||||
{:plug, "~> 1.13", override: true},
|
||||
{:prima, "~> 0.2.1"},
|
||||
|
|
|
|||
2
mix.lock
2
mix.lock
|
|
@ -124,7 +124,7 @@
|
|||
"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_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_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"},
|
||||
|
|
|
|||
|
|
@ -55,6 +55,8 @@ long_random_urls =
|
|||
"https://dummy.site#{path}"
|
||||
end
|
||||
|
||||
FunWithFlags.enable(:live_dashboard)
|
||||
|
||||
site =
|
||||
new_site(
|
||||
domain: "dummy.site",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -9,6 +9,8 @@ Mox.defmock(Plausible.DnsLookup.Mock,
|
|||
for: Plausible.DnsLookupInterface
|
||||
)
|
||||
|
||||
FunWithFlags.enable(:live_dashboard)
|
||||
|
||||
Application.ensure_all_started(:double)
|
||||
|
||||
Ecto.Adapters.SQL.Sandbox.mode(Plausible.Repo, :manual)
|
||||
|
|
|
|||
Loading…
Reference in New Issue