diff --git a/assets/js/dashboard/components/liveview-portal.tsx b/assets/js/dashboard/components/liveview-portal.tsx
new file mode 100644
index 0000000000..23be924654
--- /dev/null
+++ b/assets/js/dashboard/components/liveview-portal.tsx
@@ -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 (
+
+type FeatureFlags = {
+ live_dashboard?: boolean
+}
export const siteContextDefaultValue = {
domain: '',
diff --git a/assets/js/liveview/live_dashboard.js b/assets/js/liveview/live_dashboard.js
new file mode 100644
index 0000000000..222ab1c0ed
--- /dev/null
+++ b/assets/js/liveview/live_dashboard.js
@@ -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)()
+ }
+}
diff --git a/assets/js/liveview/live_socket.js b/assets/js/liveview/live_socket.js
index c0f723f8cf..3ac14c51fc 100644
--- a/assets/js/liveview/live_socket.js
+++ b/assets/js/liveview/live_socket.js
@@ -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 }
+ }
}
})
diff --git a/lib/plausible_web/controllers/stats_controller.ex b/lib/plausible_web/controllers/stats_controller.ex
index a1c8bc5bc7..94f0c9a2e4 100644
--- a/lib/plausible_web/controllers/stats_controller.ex
+++ b/lib/plausible_web/controllers/stats_controller.ex
@@ -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)
diff --git a/lib/plausible_web/live/components/dashboard/base.ex b/lib/plausible_web/live/components/dashboard/base.ex
new file mode 100644
index 0000000000..8db5e45c7b
--- /dev/null
+++ b/lib/plausible_web/live/components/dashboard/base.ex
@@ -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)}
+
+ """
+ end
+end
diff --git a/lib/plausible_web/live/components/dashboard/tile.ex b/lib/plausible_web/live/components/dashboard/tile.ex
new file mode 100644
index 0000000000..426f9223da
--- /dev/null
+++ b/lib/plausible_web/live/components/dashboard/tile.ex
@@ -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"""
+
+
+
"-title"} class="flex gap-x-1" phx-update={@update_mode}>
+
{@title}
+
+
+
"-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)}
+
+
+
+ {render_slot(@inner_block)}
+
+ """
+ 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
+ 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"""
+
+ """
+ end
+end
diff --git a/lib/plausible_web/live/dashboard.ex b/lib/plausible_web/live/dashboard.ex
new file mode 100644
index 0000000000..58685e9524
--- /dev/null
+++ b/lib/plausible_web/live/dashboard.ex
@@ -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"""
+
+ <.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?}
+ />
+
+
+ """
+ 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"""
+ {render_slot(@inner_block)}
+ """
+ end
+ else
+ defp portal_wrapper(assigns) do
+ ~H"""
+ <.portal id={@id} target={@target}>{render_slot(@inner_block)}
+ """
+ end
+ end
+end
diff --git a/lib/plausible_web/live/dashboard/pages.ex b/lib/plausible_web/live/dashboard/pages.ex
new file mode 100644
index 0000000000..fafb89282d
--- /dev/null
+++ b/lib/plausible_web/live/dashboard/pages.ex
@@ -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"""
+
+
+ <:tabs>
+
+
+
+
+
+ Filter by source Direct / None
+
+
+
+
+ """
+ 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
diff --git a/lib/plausible_web/router.ex b/lib/plausible_web/router.ex
index a5046072e3..420f03ae7b 100644
--- a/lib/plausible_web/router.ex
+++ b/lib/plausible_web/router.ex
@@ -706,7 +706,10 @@ defmodule PlausibleWeb.Router do
put "/:domain/settings", SiteController, :update_settings
get "/:domain/export", StatsController, :csv_export
- get "/:domain/*path", StatsController, :stats
+
+ scope assigns: %{live_socket_disable_push_state: true} do
+ get "/:domain/*path", StatsController, :stats
+ end
end
end
end
diff --git a/lib/plausible_web/templates/layout/app.html.heex b/lib/plausible_web/templates/layout/app.html.heex
index dc217ba23f..3e121b0806 100644
--- a/lib/plausible_web/templates/layout/app.html.heex
+++ b/lib/plausible_web/templates/layout/app.html.heex
@@ -12,6 +12,12 @@
<% end %>
+ <%= if PlausibleWeb.Live.Dashboard.enabled?(assigns[:site]) do %>
+ <%= if assigns[:live_socket_disable_push_state] do %>
+
+ <% end %>
+
+ <% end %>
diff --git a/lib/plausible_web/templates/stats/stats.html.heex b/lib/plausible_web/templates/stats/stats.html.heex
index d473e6b5be..429a050590 100644
--- a/lib/plausible_web/templates/stats/stats.html.heex
+++ b/lib/plausible_web/templates/stats/stats.html.heex
@@ -54,6 +54,15 @@
data-team-identifier={@team_identifier}
>
+ <%= 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 %>
<%= if !@conn.assigns[:current_user] && @conn.assigns[:demo] do %>
diff --git a/mix.exs b/mix.exs
index 69d848f092..e6a609c6f5 100644
--- a/mix.exs
+++ b/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"},
diff --git a/mix.lock b/mix.lock
index 306a80b788..a436efdefe 100644
--- a/mix.lock
+++ b/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"},
diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs
index 1522defcfc..44df81d265 100644
--- a/priv/repo/seeds.exs
+++ b/priv/repo/seeds.exs
@@ -55,6 +55,8 @@ long_random_urls =
"https://dummy.site#{path}"
end
+FunWithFlags.enable(:live_dashboard)
+
site =
new_site(
domain: "dummy.site",
diff --git a/test/plausible_web/live/dashboard_test.exs b/test/plausible_web/live/dashboard_test.exs
new file mode 100644
index 0000000000..6fa050a0df
--- /dev/null
+++ b/test/plausible_web/live/dashboard_test.exs
@@ -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
diff --git a/test/test_helper.exs b/test/test_helper.exs
index e11ea3df95..9dbbed1018 100644
--- a/test/test_helper.exs
+++ b/test/test_helper.exs
@@ -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)