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 ( +
+
+
+
+
+
+
+ ) + }, + () => true +) diff --git a/assets/js/dashboard/index.tsx b/assets/js/dashboard/index.tsx index 24f467a842..81b46a180e 100644 --- a/assets/js/dashboard/index.tsx +++ b/assets/js/dashboard/index.tsx @@ -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({
- + {site.flags.live_dashboard ? ( + + ) : ( + + )}
diff --git a/assets/js/dashboard/navigation/use-app-navigate.tsx b/assets/js/dashboard/navigation/use-app-navigate.tsx index fa2fe9257f..c8342c9d6c 100644 --- a/assets/js/dashboard/navigation/use-app-navigate.tsx +++ b/assets/js/dashboard/navigation/use-app-navigate.tsx @@ -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] diff --git a/assets/js/dashboard/site-context.tsx b/assets/js/dashboard/site-context.tsx index ac6213b21c..7dc79fb9e8 100644 --- a/assets/js/dashboard/site-context.tsx +++ b/assets/js/dashboard/site-context.tsx @@ -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 +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)