Remove format pragma, run prettier on /assets (#5237)

This commit is contained in:
Artur Pata 2025-03-25 12:16:54 +02:00 committed by GitHub
parent 9ca23f80ed
commit ce1df315d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
169 changed files with 2602 additions and 1667 deletions

View File

@ -4,3 +4,4 @@ static/images/
.*rc .*rc
*.json *.json
*.config.js *.config.js
js/types/query-api.d.ts

View File

@ -1,6 +1,5 @@
{ {
"singleQuote": true, "singleQuote": true,
"insertPragma": true,
"trailingComma": "none", "trailingComma": "none",
"semi": false "semi": false
} }

View File

@ -1,5 +1,3 @@
/* @format */
@import 'tailwindcss/base'; @import 'tailwindcss/base';
@import 'flatpickr/dist/flatpickr.min.css'; @import 'flatpickr/dist/flatpickr.min.css';
@import './modal.css'; @import './modal.css';

View File

@ -1,4 +1,3 @@
/* @format */
#chartjs-tooltip { #chartjs-tooltip {
background-color: rgb(25 30 56); background-color: rgb(25 30 56);
position: absolute; position: absolute;

View File

@ -1,4 +1,3 @@
/* @format */
/* stylelint-disable media-feature-range-notation */ /* stylelint-disable media-feature-range-notation */
/* stylelint-disable selector-class-pattern */ /* stylelint-disable selector-class-pattern */

View File

@ -1,4 +1,3 @@
/* @format */
.loading { .loading {
width: 50px; width: 50px;
height: 50px; height: 50px;

View File

@ -1,4 +1,3 @@
/* @format */
/* stylelint-disable selector-class-pattern */ /* stylelint-disable selector-class-pattern */
.modal { .modal {
display: none; display: none;

View File

@ -1,6 +1,6 @@
@import "tailwindcss/base"; @import 'tailwindcss/base';
@import "tailwindcss/components"; @import 'tailwindcss/components';
@import "tailwindcss/utilities"; @import 'tailwindcss/utilities';
/* /*
* Put your component styling within the Tailwind utilities layer. * Put your component styling within the Tailwind utilities layer.

View File

@ -1,4 +1,3 @@
/* @format */
[tooltip] { [tooltip] {
position: relative; position: relative;
display: inline-block; display: inline-block;

View File

@ -1,10 +1,10 @@
import "./polyfills/closest" import './polyfills/closest'
import 'abortcontroller-polyfill/dist/polyfill-patch-fetch' import 'abortcontroller-polyfill/dist/polyfill-patch-fetch'
import Alpine from 'alpinejs' import Alpine from 'alpinejs'
import "./liveview/live_socket" import './liveview/live_socket'
import comboBox from "./liveview/combo-box" import comboBox from './liveview/combo-box'
import dropdown from "./liveview/dropdown" import dropdown from './liveview/dropdown'
import "./liveview/phx_events" import './liveview/phx_events'
Alpine.data('dropdown', dropdown) Alpine.data('dropdown', dropdown)
Alpine.data('comboBox', comboBox) Alpine.data('comboBox', comboBox)
@ -30,14 +30,14 @@ if (document.querySelectorAll('[data-modal]').length > 0) {
const triggers = document.querySelectorAll('[data-dropdown-trigger]') const triggers = document.querySelectorAll('[data-dropdown-trigger]')
for (const trigger of triggers) { for (const trigger of triggers) {
trigger.addEventListener('click', function(e) { trigger.addEventListener('click', function (e) {
e.stopPropagation() e.stopPropagation()
e.currentTarget.nextElementSibling.classList.remove('hidden') e.currentTarget.nextElementSibling.classList.remove('hidden')
}) })
} }
if (triggers.length > 0) { if (triggers.length > 0) {
document.addEventListener('click', function(e) { document.addEventListener('click', function (e) {
const dropdown = e.target.closest('[data-dropdown]') const dropdown = e.target.closest('[data-dropdown]')
if (dropdown && e.target.tagName === 'A') { if (dropdown && e.target.tagName === 'A') {
@ -45,7 +45,7 @@ if (triggers.length > 0) {
} }
}) })
document.addEventListener('click', function(e) { document.addEventListener('click', function (e) {
const clickedInDropdown = e.target.closest('[data-dropdown]') const clickedInDropdown = e.target.closest('[data-dropdown]')
if (!clickedInDropdown) { if (!clickedInDropdown) {
@ -61,7 +61,9 @@ const changelogNotification = document.getElementById('changelog-notification')
if (changelogNotification) { if (changelogNotification) {
showChangelogNotification(changelogNotification) showChangelogNotification(changelogNotification)
fetch('https://plausible.io/changes.txt', { headers: { 'Content-Type': 'text/plain' } }) fetch('https://plausible.io/changes.txt', {
headers: { 'Content-Type': 'text/plain' }
})
.then((res) => res.text()) .then((res) => res.text())
.then((res) => { .then((res) => {
localStorage.lastChangelogUpdate = new Date(res).getTime() localStorage.lastChangelogUpdate = new Date(res).getTime()
@ -87,9 +89,11 @@ function showChangelogNotification(el) {
</a> </a>
` `
const link = el.getElementsByTagName('a')[0] const link = el.getElementsByTagName('a')[0]
link.addEventListener('click', function() { link.addEventListener('click', function () {
localStorage.lastChangelogClick = Date.now() localStorage.lastChangelogClick = Date.now()
setTimeout(() => { link.remove() }, 100) setTimeout(() => {
link.remove()
}, 100)
}) })
} }
} }
@ -97,7 +101,7 @@ function showChangelogNotification(el) {
const embedButton = document.getElementById('generate-embed') const embedButton = document.getElementById('generate-embed')
if (embedButton) { if (embedButton) {
embedButton.addEventListener('click', function(_e) { embedButton.addEventListener('click', function (_e) {
const baseUrl = document.getElementById('base-url').value const baseUrl = document.getElementById('base-url').value
const embedCode = document.getElementById('embed-code') const embedCode = document.getElementById('embed-code')
const theme = document.getElementById('theme').value.toLowerCase() const theme = document.getElementById('theme').value.toLowerCase()
@ -116,7 +120,8 @@ if (embedButton) {
<script async src="${baseUrl}/js/embed.host.js"></script>` <script async src="${baseUrl}/js/embed.host.js"></script>`
} catch (e) { } catch (e) {
console.error(e) console.error(e)
embedCode.value = 'ERROR: Please enter a valid URL in the shared link field' embedCode.value =
'ERROR: Please enter a valid URL in the shared link field'
} }
}) })
} }

View File

@ -1,5 +1,3 @@
/** @format */
import React, { ReactNode } from 'react' import React, { ReactNode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import 'url-search-params-polyfill' import 'url-search-params-polyfill'

View File

@ -1,4 +1,3 @@
/** @format */
import { DashboardQuery } from './query' import { DashboardQuery } from './query'
import { formatISO } from './util/date' import { formatISO } from './util/date'
import { serializeApiFilters } from './util/filters' import { serializeApiFilters } from './util/filters'

View File

@ -1,5 +1,3 @@
/** @format */
import React, { import React, {
Fragment, Fragment,
useState, useState,

View File

@ -1,5 +1,3 @@
/** @format */
import React from 'react' import React from 'react'
import { import {
AppNavigationLink, AppNavigationLink,

View File

@ -1,5 +1,3 @@
/** @format */
import React from 'react' import React from 'react'
import classNames from 'classnames' import classNames from 'classnames'
import { import {

View File

@ -1,5 +1,3 @@
/** @format */
import React, { Fragment, useRef } from 'react' import React, { Fragment, useRef } from 'react'
import { import {

View File

@ -4,7 +4,7 @@ import { useInView } from 'react-intersection-observer'
export default function LazyLoader(props) { export default function LazyLoader(props) {
const [hasBecomeVisibleYet, setHasBecomeVisibleYet] = useState(false) const [hasBecomeVisibleYet, setHasBecomeVisibleYet] = useState(false)
const { ref, inView } = useInView({ const { ref, inView } = useInView({
threshold: 0, threshold: 0
}) })
useEffect(() => { useEffect(() => {
@ -15,9 +15,5 @@ export default function LazyLoader(props) {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [inView]) }, [inView])
return ( return <div ref={ref}>{props.children}</div>
<div ref={ref}>
{props.children}
</div>
)
} }

View File

@ -1,27 +1,57 @@
import React from "react" import React from 'react'
import { sectionTitles } from "../stats/behaviours" import { sectionTitles } from '../stats/behaviours'
import * as api from '../api' import * as api from '../api'
import { useSiteContext } from "../site-context" import { useSiteContext } from '../site-context'
export function FeatureSetupNotice({ feature, title, info, callToAction, onHideAction }) { export function FeatureSetupNotice({
feature,
title,
info,
callToAction,
onHideAction
}) {
const site = useSiteContext() const site = useSiteContext()
const sectionTitle = sectionTitles[feature] const sectionTitle = sectionTitles[feature]
const requestHideSection = () => { const requestHideSection = () => {
if (window.confirm(`Are you sure you want to hide ${sectionTitle}? You can make it visible again in your site settings later.`)) { if (
api.mutation(`/api/${encodeURIComponent(site.domain)}/disable-feature`, { method: 'PUT', body: { feature: feature } }) window.confirm(
`Are you sure you want to hide ${sectionTitle}? You can make it visible again in your site settings later.`
)
) {
api
.mutation(`/api/${encodeURIComponent(site.domain)}/disable-feature`, {
method: 'PUT',
body: { feature: feature }
})
.then(() => onHideAction()) .then(() => onHideAction())
.catch((error) => {if (!(error instanceof api.ApiError)) {throw error}}) .catch((error) => {
if (!(error instanceof api.ApiError)) {
throw error
}
})
} }
} }
function renderCallToAction() { function renderCallToAction() {
return ( return (
<a href={callToAction.link} className="ml-2 sm:ml-4 button px-2 sm:px-4"> <a href={callToAction.link} className="ml-2 sm:ml-4 button px-2 sm:px-4">
<p className="flex flex-col justify-center text-xs sm:text-sm">{callToAction.action}</p> <p className="flex flex-col justify-center text-xs sm:text-sm">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="ml-2 w-5 h-5"> {callToAction.action}
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12h15m0 0l-6.75-6.75M19.5 12l-6.75 6.75" /> </p>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="ml-2 w-5 h-5"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 12h15m0 0l-6.75-6.75M19.5 12l-6.75 6.75"
/>
</svg> </svg>
</a> </a>
) )
@ -31,14 +61,15 @@ export function FeatureSetupNotice({ feature, title, info, callToAction, onHideA
return ( return (
<button <button
onClick={requestHideSection} onClick={requestHideSection}
className="inline-block px-2 sm:px-4 py-2 border border-gray-300 dark:border-gray-500 leading-5 rounded-md text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition ease-in-out duration-150"> className="inline-block px-2 sm:px-4 py-2 border border-gray-300 dark:border-gray-500 leading-5 rounded-md text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition ease-in-out duration-150"
>
Hide this report Hide this report
</button> </button>
) )
} }
return ( return (
<div className="sm:mx-32 mt-6 mb-3" > <div className="sm:mx-32 mt-6 mb-3">
<div className="py-3"> <div className="py-3">
<div className="text-center mt-2 text-gray-800 dark:text-gray-200"> <div className="text-center mt-2 text-gray-800 dark:text-gray-200">
{title} {title}

View File

@ -1,4 +1,3 @@
/** @format */
import { TransitionClasses } from '@headlessui/react' import { TransitionClasses } from '@headlessui/react'
import classNames from 'classnames' import classNames from 'classnames'

View File

@ -1,5 +1,3 @@
/** @format */
import React, { ChangeEventHandler, useCallback, useState, useRef } from 'react' import React, { ChangeEventHandler, useCallback, useState, useRef } from 'react'
import { isModifierPressed, Keybind } from '../keybinding' import { isModifierPressed, Keybind } from '../keybinding'
import { useDebounce } from '../custom-hooks' import { useDebounce } from '../custom-hooks'

View File

@ -1,5 +1,3 @@
/** @format */
import React, { ReactNode } from 'react' import React, { ReactNode } from 'react'
import { cycleSortDirection, SortDirection } from '../hooks/use-order-by' import { cycleSortDirection, SortDirection } from '../hooks/use-order-by'
import classNames from 'classnames' import classNames from 'classnames'

View File

@ -1,5 +1,3 @@
/** @format */
import classNames from 'classnames' import classNames from 'classnames'
import React, { ReactNode } from 'react' import React, { ReactNode } from 'react'
import { SortDirection } from '../hooks/use-order-by' import { SortDirection } from '../hooks/use-order-by'

View File

@ -1,4 +1,4 @@
import { useEffect, useRef, useCallback } from 'react'; import { useEffect, useRef, useCallback } from 'react'
// A custom hook that behaves like `useEffect`, but // A custom hook that behaves like `useEffect`, but
// the function does not run on the initial render. // the function does not run on the initial render.
@ -22,15 +22,20 @@ export function useDebounce(fn, delay = DEBOUNCE_DELAY) {
useEffect(() => { useEffect(() => {
return () => { return () => {
if (timerRef.current) { clearTimeout(timerRef.current) } if (timerRef.current) {
clearTimeout(timerRef.current)
}
} }
}, []) }, [])
return useCallback((...args) => { return useCallback(
(...args) => {
clearTimeout(timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => { timerRef.current = setTimeout(() => {
fn(...args) fn(...args)
}, delay) }, delay)
}, [fn, delay]) },
[fn, delay]
)
} }

View File

@ -1,4 +1,3 @@
/* @format */
import React from 'react' import React from 'react'
import { NavigateKeybind } from './keybinding' import { NavigateKeybind } from './keybinding'
import { useRoutelessModalsContext } from './navigation/routeless-modals-context' import { useRoutelessModalsContext } from './navigation/routeless-modals-context'

View File

@ -1,5 +1,3 @@
/** @format */
import React, { useState } from 'react' import React, { useState } from 'react'
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'

View File

@ -1,5 +1,3 @@
/** @format */
import React, { ReactNode, ReactElement } from 'react' import React, { ReactNode, ReactElement } from 'react'
type ErrorBoundaryProps = { type ErrorBoundaryProps = {

View File

@ -1,5 +1,3 @@
/** @format */
import React from 'react' import React from 'react'
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import { GoToSites, SomethingWentWrongMessage } from './something-went-wrong' import { GoToSites, SomethingWentWrongMessage } from './something-went-wrong'

View File

@ -1,5 +1,3 @@
/** @format */
import React, { ReactNode } from 'react' import React, { ReactNode } from 'react'
import RocketIcon from '../stats/modals/rocket-icon' import RocketIcon from '../stats/modals/rocket-icon'
import { useInRouterContext } from 'react-router-dom' import { useInRouterContext } from 'react-router-dom'

View File

@ -2,7 +2,7 @@ export default function FunnelTooltip(palette, funnel) {
return (context) => { return (context) => {
const tooltipModel = context.tooltip const tooltipModel = context.tooltip
const dataIndex = tooltipModel.dataPoints[0].dataIndex const dataIndex = tooltipModel.dataPoints[0].dataIndex
const offset = document.getElementById("funnel").getBoundingClientRect() const offset = document.getElementById('funnel').getBoundingClientRect()
let tooltipEl = document.getElementById('chartjs-tooltip') let tooltipEl = document.getElementById('chartjs-tooltip')
if (!tooltipEl) { if (!tooltipEl) {
@ -14,7 +14,8 @@ export default function FunnelTooltip(palette, funnel) {
} }
if (tooltipEl && offset && window.innerWidth < 768) { if (tooltipEl && offset && window.innerWidth < 768) {
tooltipEl.style.top = offset.y + offset.height + window.scrollY + 15 + 'px' tooltipEl.style.top =
offset.y + offset.height + window.scrollY + 15 + 'px'
tooltipEl.style.left = offset.x + 'px' tooltipEl.style.left = offset.x + 'px'
tooltipEl.style.right = null tooltipEl.style.right = null
tooltipEl.style.opacity = 1 tooltipEl.style.opacity = 1
@ -25,15 +26,14 @@ export default function FunnelTooltip(palette, funnel) {
return return
} }
if (tooltipModel.body) { if (tooltipModel.body) {
const currentStep = funnel.steps[dataIndex] const currentStep = funnel.steps[dataIndex]
const previousStep = (dataIndex > 0) ? funnel.steps[dataIndex - 1] : null const previousStep = dataIndex > 0 ? funnel.steps[dataIndex - 1] : null
tooltipEl.innerHTML = ` tooltipEl.innerHTML = `
<aside class="text-gray-100 flex flex-col"> <aside class="text-gray-100 flex flex-col">
<div class="flex justify-between items-center border-b-2 border-gray-700 pb-2"> <div class="flex justify-between items-center border-b-2 border-gray-700 pb-2">
<span class="font-semibold mr-4 text-lg">${previousStep ? `<span class="mr-2">${previousStep.label}</span>` : ""} <span class="font-semibold mr-4 text-lg">${previousStep ? `<span class="mr-2">${previousStep.label}</span>` : ''}
<span class="text-gray-500 mr-2"></span> <span class="text-gray-500 mr-2"></span>
${tooltipModel.title} ${tooltipModel.title}
</span> </span>
@ -45,7 +45,7 @@ export default function FunnelTooltip(palette, funnel) {
<span class="flex items-center mr-4"> <span class="flex items-center mr-4">
<div class="w-3 h-3 mr-1 rounded-full ${palette.visitorsLegendClass}"></div> <div class="w-3 h-3 mr-1 rounded-full ${palette.visitorsLegendClass}"></div>
<span> <span>
${dataIndex == 0 ? "Entered the funnel" : "Visitors"} ${dataIndex == 0 ? 'Entered the funnel' : 'Visitors'}
</span> </span>
</span> </span>
</th> </th>
@ -65,7 +65,7 @@ export default function FunnelTooltip(palette, funnel) {
<span class="flex items-center"> <span class="flex items-center">
<div class="w-3 h-3 mr-1 rounded-full ${palette.dropoffLegendClass}"></div> <div class="w-3 h-3 mr-1 rounded-full ${palette.dropoffLegendClass}"></div>
<span> <span>
${dataIndex == 0 ? "Never entered the funnel" : "Dropoff"} ${dataIndex == 0 ? 'Never entered the funnel' : 'Dropoff'}
</span> </span>
</span> </span>
</th> </th>

View File

@ -1,22 +1,21 @@
import React, { useEffect, useState, useRef } from 'react'; import React, { useEffect, useState, useRef } from 'react'
import FlipMove from 'react-flip-move'; import FlipMove from 'react-flip-move'
import Chart from 'chart.js/auto'; import Chart from 'chart.js/auto'
import FunnelTooltip from './funnel-tooltip'; import FunnelTooltip from './funnel-tooltip'
import ChartDataLabels from 'chartjs-plugin-datalabels'; import ChartDataLabels from 'chartjs-plugin-datalabels'
import { numberShortFormatter } from '../util/number-formatter'; import { numberShortFormatter } from '../util/number-formatter'
import Bar from '../stats/bar'; import Bar from '../stats/bar'
import RocketIcon from '../stats/modals/rocket-icon'; import RocketIcon from '../stats/modals/rocket-icon'
import * as api from '../api';
import LazyLoader from '../components/lazy-loader';
import { useQueryContext } from '../query-context';
import { useSiteContext } from '../site-context';
import * as api from '../api'
import LazyLoader from '../components/lazy-loader'
import { useQueryContext } from '../query-context'
import { useSiteContext } from '../site-context'
export default function Funnel({ funnelName, tabs }) { export default function Funnel({ funnelName, tabs }) {
const site = useSiteContext(); const site = useSiteContext()
const { query } = useQueryContext(); const { query } = useQueryContext()
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [visible, setVisible] = useState(false) const [visible, setVisible] = useState(false)
const [error, setError] = useState(undefined) const [error, setError] = useState(undefined)
@ -60,11 +59,11 @@ export default function Funnel({ funnelName, tabs }) {
const mediaQuery = window.matchMedia('(max-width: 768px)') const mediaQuery = window.matchMedia('(max-width: 768px)')
setSmallScreen(mediaQuery.matches) setSmallScreen(mediaQuery.matches)
const handleScreenChange = (e) => { const handleScreenChange = (e) => {
setSmallScreen(e.matches); setSmallScreen(e.matches)
} }
mediaQuery.addEventListener("change", handleScreenChange); mediaQuery.addEventListener('change', handleScreenChange)
return () => { return () => {
mediaQuery.removeEventListener("change", handleScreenChange) mediaQuery.removeEventListener('change', handleScreenChange)
} }
}, []) }, [])
@ -110,7 +109,9 @@ export default function Funnel({ funnelName, tabs }) {
} }
const calcOffset = (ctx) => { const calcOffset = (ctx) => {
const conversionRate = parseFloat(funnel.steps[ctx.dataIndex].conversion_rate) const conversionRate = parseFloat(
funnel.steps[ctx.dataIndex].conversion_rate
)
if (conversionRate > 90) { if (conversionRate > 90) {
return -64 return -64
} else if (conversionRate > 20) { } else if (conversionRate > 20) {
@ -129,7 +130,10 @@ export default function Funnel({ funnelName, tabs }) {
if (typeof funnelMeta === 'undefined') { if (typeof funnelMeta === 'undefined') {
throw new Error('Could not fetch the funnel. Perhaps it was deleted?') throw new Error('Could not fetch the funnel. Perhaps it was deleted?')
} else { } else {
return api.get(`/api/stats/${encodeURIComponent(site.domain)}/funnels/${funnelMeta.id}`, query) return api.get(
`/api/stats/${encodeURIComponent(site.domain)}/funnels/${funnelMeta.id}`,
query
)
} }
} }
@ -149,7 +153,7 @@ export default function Funnel({ funnelName, tabs }) {
c.fillStyle = color1 c.fillStyle = color1
c.strokeStyle = color2 c.strokeStyle = color2
c.fillRect(0, 0, shape.width, shape.height); c.fillRect(0, 0, shape.width, shape.height)
c.beginPath() c.beginPath()
c.moveTo(2, 0) c.moveTo(2, 0)
@ -168,7 +172,7 @@ export default function Funnel({ funnelName, tabs }) {
const stepData = funnel.steps.map((step) => step.visitors) const stepData = funnel.steps.map((step) => step.visitors)
const dropOffData = funnel.steps.map((step) => step.dropoff) const dropOffData = funnel.steps.map((step) => step.dropoff)
const ctx = canvasRef.current.getContext("2d") const ctx = canvasRef.current.getContext('2d')
const calcBarThickness = (ctx) => { const calcBarThickness = (ctx) => {
if (ctx.dataset.data.length <= 3) { if (ctx.dataset.data.length <= 3) {
@ -179,11 +183,12 @@ export default function Funnel({ funnelName, tabs }) {
} }
// passing those verbatim to make sure canvas rendering picks them up // passing those verbatim to make sure canvas rendering picks them up
var fontFamily = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"' var fontFamily =
'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"'
var gradient = ctx.createLinearGradient(900, 0, 900, 900); var gradient = ctx.createLinearGradient(900, 0, 900, 900)
gradient.addColorStop(1, palette.dropoffBackground); gradient.addColorStop(1, palette.dropoffBackground)
gradient.addColorStop(0, palette.visitorsBackground); gradient.addColorStop(0, palette.visitorsBackground)
const data = { const data = {
labels: labels, labels: labels,
@ -194,17 +199,20 @@ export default function Funnel({ funnelName, tabs }) {
backgroundColor: gradient, backgroundColor: gradient,
hoverBackgroundColor: gradient, hoverBackgroundColor: gradient,
borderRadius: 4, borderRadius: 4,
stack: 'Stack 0', stack: 'Stack 0'
}, },
{ {
label: 'Dropoff', label: 'Dropoff',
data: dropOffData, data: dropOffData,
backgroundColor: createDiagonalPattern(palette.dropoffBackground, palette.dropoffStripes), backgroundColor: createDiagonalPattern(
palette.dropoffBackground,
palette.dropoffStripes
),
hoverBackgroundColor: palette.dropoffBackground, hoverBackgroundColor: palette.dropoffBackground,
borderRadius: 4, borderRadius: 4,
stack: 'Stack 0', stack: 'Stack 0'
}, }
], ]
} }
const config = { const config = {
@ -216,7 +224,7 @@ export default function Funnel({ funnelName, tabs }) {
barThickness: calcBarThickness, barThickness: calcBarThickness,
plugins: { plugins: {
legend: { legend: {
display: false, display: false
}, },
tooltip: { tooltip: {
enabled: false, enabled: false,
@ -234,10 +242,15 @@ export default function Funnel({ funnelName, tabs }) {
color: palette.dataLabelTextColor, color: palette.dataLabelTextColor,
borderRadius: 4, borderRadius: 4,
clip: true, clip: true,
font: { size: 12, weight: 'normal', lineHeight: 1.6, family: fontFamily }, font: {
textAlign: 'center', size: 12,
padding: { top: 8, bottom: 8, right: 8, left: 8 }, weight: 'normal',
lineHeight: 1.6,
family: fontFamily
}, },
textAlign: 'center',
padding: { top: 8, bottom: 8, right: 8, left: 8 }
}
}, },
scales: { scales: {
y: { display: false }, y: { display: false },
@ -250,10 +263,10 @@ export default function Funnel({ funnelName, tabs }) {
padding: 8, padding: 8,
font: { weight: 'bold', family: fontFamily, size: 14 }, font: { weight: 'bold', family: fontFamily, size: 14 },
color: palette.stepNameLegendColor color: palette.stepNameLegendColor
}, }
}, }
}, }
}, }
} }
chartRef.current = new Chart(ctx, config) chartRef.current = new Chart(ctx, config)
@ -274,7 +287,9 @@ export default function Funnel({ funnelName, tabs }) {
return ( return (
<> <>
{header()} {header()}
<div className="font-medium text-center text-gray-500 mt-44 dark:text-gray-400">{error.message}</div> <div className="font-medium text-center text-gray-500 mt-44 dark:text-gray-400">
{error.message}
</div>
</> </>
) )
} else { } else {
@ -284,8 +299,12 @@ export default function Funnel({ funnelName, tabs }) {
<div className="text-center text-gray-900 dark:text-gray-100 mt-16"> <div className="text-center text-gray-900 dark:text-gray-100 mt-16">
<RocketIcon /> <RocketIcon />
<div className="text-lg font-bold">Oops! Something went wrong</div> <div className="text-lg font-bold">Oops! Something went wrong</div>
<div className="text-lg">{error.message ? error.message : 'Failed to render funnel'}</div> <div className="text-lg">
<div className="text-xs mt-8">Please try refreshing your browser or selecting the funnel again.</div> {error.message ? error.message : 'Failed to render funnel'}
</div>
<div className="text-xs mt-8">
Please try refreshing your browser or selecting the funnel again.
</div>
</div> </div>
</> </>
) )
@ -294,16 +313,24 @@ export default function Funnel({ funnelName, tabs }) {
const renderInner = () => { const renderInner = () => {
if (loading) { if (loading) {
return <div className="mx-auto loading pt-44"><div></div></div> return (
<div className="mx-auto loading pt-44">
<div></div>
</div>
)
} else if (error) { } else if (error) {
return renderError() return renderError()
} else if (funnel) { } else if (funnel) {
const conversionRate = funnel.steps[funnel.steps.length - 1].conversion_rate const conversionRate =
funnel.steps[funnel.steps.length - 1].conversion_rate
return ( return (
<div className="mb-8"> <div className="mb-8">
{header()} {header()}
<p className="mt-1 text-gray-500 text-sm">{funnel.steps.length}-step funnel {conversionRate}% conversion rate</p> <p className="mt-1 text-gray-500 text-sm">
{funnel.steps.length}-step funnel {conversionRate}% conversion
rate
</p>
{isSmallScreen && <div className="mt-4">{renderBars(funnel)}</div>} {isSmallScreen && <div className="mt-4">{renderBars(funnel)}</div>}
</div> </div>
) )
@ -320,16 +347,18 @@ export default function Funnel({ funnelName, tabs }) {
count={step.visitors} count={step.visitors}
all={funnel.steps} all={funnel.steps}
bg={palette.smallBarClass} bg={palette.smallBarClass}
maxWidthDeduction={"5rem"} maxWidthDeduction={'5rem'}
plot={'visitors'} plot={'visitors'}
> >
<span className="flex px-2 py-1.5 group dark:text-gray-100 relative z-9 break-all"> <span className="flex px-2 py-1.5 group dark:text-gray-100 relative z-9 break-all">
{step.label} {step.label}
</span> </span>
</Bar> </Bar>
<span className="font-medium dark:text-gray-200 w-20 text-right" tooltip={step.visitors.toLocaleString()}> <span
className="font-medium dark:text-gray-200 w-20 text-right"
tooltip={step.visitors.toLocaleString()}
>
{numberShortFormatter(step.visitors)} {numberShortFormatter(step.visitors)}
</span> </span>
</div> </div>
@ -346,9 +375,7 @@ export default function Funnel({ funnelName, tabs }) {
<span className="inline-block w-20">Visitors</span> <span className="inline-block w-20">Visitors</span>
</span> </span>
</div> </div>
<FlipMove> <FlipMove>{funnel.steps.map(renderBar)}</FlipMove>
{funnel.steps.map(renderBar)}
</FlipMove>
</> </>
) )
} }
@ -358,7 +385,9 @@ export default function Funnel({ funnelName, tabs }) {
<LazyLoader onVisible={() => setVisible(true)}> <LazyLoader onVisible={() => setVisible(true)}>
{renderInner()} {renderInner()}
</LazyLoader> </LazyLoader>
{!isSmallScreen && <canvas className="" id="funnel" ref={canvasRef}></canvas>} {!isSmallScreen && (
<canvas className="" id="funnel" ref={canvasRef}></canvas>
)}
</div> </div>
) )
} }

View File

@ -1,11 +1,11 @@
import React from 'react'; import React from 'react'
export default function FadeIn({className, show, children}) { export default function FadeIn({ className, show, children }) {
return ( return (
<div <div
className={`${className || ''} ${show ? "fade-enter-active" : "fade-enter"}`} className={`${className || ''} ${show ? 'fade-enter-active' : 'fade-enter'}`}
> >
{children} {children}
</div> </div>
) )
} }

View File

@ -1,5 +1,3 @@
/** @format */
import React from 'react' import React from 'react'
import { render, screen, fireEvent } from '@testing-library/react' import { render, screen, fireEvent } from '@testing-library/react'
import { SegmentsContextProvider, useSegmentsContext } from './segments-context' import { SegmentsContextProvider, useSegmentsContext } from './segments-context'

View File

@ -1,4 +1,3 @@
/** @format */
import React, { import React, {
createContext, createContext,
ReactNode, ReactNode,

View File

@ -1,5 +1,3 @@
/** @format */
import { remapToApiFilters } from '../util/filters' import { remapToApiFilters } from '../util/filters'
import { import {
formatSegmentIdAsLabelKey, formatSegmentIdAsLabelKey,

View File

@ -1,5 +1,3 @@
/** @format */
import { DashboardQuery, Filter } from '../query' import { DashboardQuery, Filter } from '../query'
import { cleanLabels, remapFromApiFilters } from '../util/filters' import { cleanLabels, remapFromApiFilters } from '../util/filters'
import { plainFilterText } from '../util/filter-text' import { plainFilterText } from '../util/filter-text'

View File

@ -1,5 +1,3 @@
/** @format */
import { useEffect } from 'react' import { useEffect } from 'react'
import { import {
useQueryClient, useQueryClient,

View File

@ -1,5 +1,3 @@
/** @format */
import { Metric } from '../stats/reports/metrics' import { Metric } from '../stats/reports/metrics'
import { import {
OrderBy, OrderBy,

View File

@ -1,5 +1,3 @@
/** @format */
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { Metric } from '../stats/reports/metrics' import { Metric } from '../stats/reports/metrics'
import { getDomainScopedStorageKey, getItem, setItem } from '../util/storage' import { getDomainScopedStorageKey, getItem, setItem } from '../util/storage'

View File

@ -1,15 +1,15 @@
import { useRef, useEffect } from 'react'; import { useRef, useEffect } from 'react'
function usePrevious<T>(value: T): T | undefined { function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T | undefined>(undefined); const ref = useRef<T | undefined>(undefined)
useEffect(() => { useEffect(() => {
// Update the ref with the current value after render // Update the ref with the current value after render
ref.current = value; ref.current = value
}, [value]); }, [value])
// Return the previous value // Return the previous value
return ref.current; return ref.current
} }
export default usePrevious; export default usePrevious

View File

@ -1,5 +1,3 @@
/** @format */
import React, { useMemo, useState } from 'react' import React, { useMemo, useState } from 'react'
import VisitorGraph from './stats/graph/visitor-graph' import VisitorGraph from './stats/graph/visitor-graph'
import Sources from './stats/sources' import Sources from './stats/sources'

View File

@ -1,4 +1,3 @@
/* @format */
import React, { ReactNode, RefObject, useCallback, useEffect } from 'react' import React, { ReactNode, RefObject, useCallback, useEffect } from 'react'
import { import {
AppNavigationTarget, AppNavigationTarget,

View File

@ -1,4 +1,3 @@
/* @format */
import React, { import React, {
createContext, createContext,
useEffect, useEffect,

View File

@ -1,5 +1,3 @@
/** @format */
import React, { useMemo, useRef } from 'react' import React, { useMemo, useRef } from 'react'
import { import {
FILTER_MODAL_TO_FILTER_GROUP, FILTER_MODAL_TO_FILTER_GROUP,

View File

@ -1,5 +1,3 @@
/** @format */
import React, { ReactNode } from 'react' import React, { ReactNode } from 'react'
import { import {
AppNavigationLink, AppNavigationLink,

View File

@ -1,5 +1,3 @@
/** @format */
import React, { DetailedHTMLProps, HTMLAttributes } from 'react' import React, { DetailedHTMLProps, HTMLAttributes } from 'react'
import { useQueryContext } from '../query-context' import { useQueryContext } from '../query-context'
import { FilterPill, FilterPillProps } from './filter-pill' import { FilterPill, FilterPillProps } from './filter-pill'

View File

@ -1,5 +1,3 @@
/** @format */
import React from 'react' import React from 'react'
import { render, screen } from '../../../test-utils' import { render, screen } from '../../../test-utils'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'

View File

@ -1,5 +1,3 @@
/** @format */
import { EllipsisHorizontalIcon } from '@heroicons/react/24/solid' import { EllipsisHorizontalIcon } from '@heroicons/react/24/solid'
import classNames from 'classnames' import classNames from 'classnames'
import React, { useRef, useState, useLayoutEffect, ReactNode } from 'react' import React, { useRef, useState, useLayoutEffect, ReactNode } from 'react'

View File

@ -1,5 +1,3 @@
/** @format */
import React from 'react' import React from 'react'
export const MenuSeparator = () => ( export const MenuSeparator = () => (

View File

@ -1,5 +1,3 @@
/** @format */
import React, { useRef } from 'react' import React, { useRef } from 'react'
import { clearedComparisonSearch } from '../../query' import { clearedComparisonSearch } from '../../query'
import classNames from 'classnames' import classNames from 'classnames'

View File

@ -1,5 +1,3 @@
/** @format */
import React from 'react' import React from 'react'
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import { DateRangeCalendar } from './date-range-calendar' import { DateRangeCalendar } from './date-range-calendar'

View File

@ -1,4 +1,3 @@
/* @format */
import React, { useLayoutEffect, useRef } from 'react' import React, { useLayoutEffect, useRef } from 'react'
import DatePicker from 'react-flatpickr' import DatePicker from 'react-flatpickr'

View File

@ -1,4 +1,3 @@
/* @format */
import React, { useMemo } from 'react' import React, { useMemo } from 'react'
import { shiftQueryPeriod, getDateForShiftedPeriod } from '../../query' import { shiftQueryPeriod, getDateForShiftedPeriod } from '../../query'
import classNames from 'classnames' import classNames from 'classnames'

View File

@ -1,5 +1,3 @@
/** @format */
import React from 'react' import React from 'react'
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'

View File

@ -1,5 +1,3 @@
/** @format */
import React, { useMemo, useRef } from 'react' import React, { useMemo, useRef } from 'react'
import classNames from 'classnames' import classNames from 'classnames'
import { useQueryContext } from '../../query-context' import { useQueryContext } from '../../query-context'

View File

@ -1,5 +1,3 @@
/** @format */
import React, { useRef } from 'react' import React, { useRef } from 'react'
import classNames from 'classnames' import classNames from 'classnames'
import { useQueryContext } from '../../query-context' import { useQueryContext } from '../../query-context'

View File

@ -1,5 +1,3 @@
/** @format */
import React, { ReactNode, RefObject } from 'react' import React, { ReactNode, RefObject } from 'react'
import classNames from 'classnames' import classNames from 'classnames'
import { popover } from '../../components/popover' import { popover } from '../../components/popover'

View File

@ -1,5 +1,3 @@
/** @format */
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { useQueryContext } from '../../query-context' import { useQueryContext } from '../../query-context'
import { useSiteContext } from '../../site-context' import { useSiteContext } from '../../site-context'

View File

@ -1,5 +1,3 @@
/** @format */
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import classNames from 'classnames' import classNames from 'classnames'
import { Popover, Transition } from '@headlessui/react' import { Popover, Transition } from '@headlessui/react'

View File

@ -1,5 +1,3 @@
/** @format */
import React from 'react' import React from 'react'
import { import {
render, render,

View File

@ -1,5 +1,3 @@
/** @format */
import React, { ReactNode, useRef } from 'react' import React, { ReactNode, useRef } from 'react'
import SiteSwitcher from '../site-switcher' import SiteSwitcher from '../site-switcher'
import { useSiteContext } from '../site-context' import { useSiteContext } from '../site-context'

View File

@ -1,4 +1,3 @@
/* @format */
import React, { createContext, ReactNode, useContext, useState } from 'react' import React, { createContext, ReactNode, useContext, useState } from 'react'
import { RoutelessSegmentModal } from '../segments/routeless-segment-modals' import { RoutelessSegmentModal } from '../segments/routeless-segment-modals'

View File

@ -1,4 +1,3 @@
/* @format */
import React, { forwardRef, useCallback } from 'react' import React, { forwardRef, useCallback } from 'react'
import { import {
Link, Link,

View File

@ -1,5 +1,3 @@
/** @format */
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { useAppNavigate } from './use-app-navigate' import { useAppNavigate } from './use-app-navigate'

View File

@ -1,4 +1,3 @@
/* @format */
import React, { createContext, useMemo, useContext, ReactNode } from 'react' import React, { createContext, useMemo, useContext, ReactNode } from 'react'
import { useLocation } from 'react-router' import { useLocation } from 'react-router'
import { useMountedEffect } from './custom-hooks' import { useMountedEffect } from './custom-hooks'

View File

@ -1,5 +1,3 @@
/** @format */
import { import {
ComparisonMode, ComparisonMode,
getDashboardTimeSettings, getDashboardTimeSettings,

View File

@ -1,4 +1,3 @@
/* @format */
import { useEffect } from 'react' import { useEffect } from 'react'
import { import {
clearedComparisonSearch, clearedComparisonSearch,

View File

@ -1,5 +1,3 @@
/** @format */
import { import {
nowForSite, nowForSite,
formatISO, formatISO,

View File

@ -1,4 +1,3 @@
/** @format */
import React from 'react' import React from 'react'
import { createBrowserRouter, Outlet, useRouteError } from 'react-router-dom' import { createBrowserRouter, Outlet, useRouteError } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

View File

@ -1,5 +1,3 @@
/** @format */
import React from 'react' import React from 'react'
import { import {
CreateSegmentModal, CreateSegmentModal,

View File

@ -1,5 +1,3 @@
/** @format */
import React from 'react' import React from 'react'
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import { SegmentAuthorship } from './segment-authorship' import { SegmentAuthorship } from './segment-authorship'

View File

@ -1,5 +1,3 @@
/** @format */
import React from 'react' import React from 'react'
import { SavedSegmentPublic, SavedSegment } from '../filtering/segments' import { SavedSegmentPublic, SavedSegment } from '../filtering/segments'
import { dateForSite, formatDayShort } from '../util/date' import { dateForSite, formatDayShort } from '../util/date'

View File

@ -1,5 +1,3 @@
/** @format */
import React from 'react' import React from 'react'
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import { SegmentModal } from './segment-modals' import { SegmentModal } from './segment-modals'

View File

@ -1,5 +1,3 @@
/** @format */
import React, { ReactNode, useState } from 'react' import React, { ReactNode, useState } from 'react'
import ModalWithRouting from '../stats/modals/modal' import ModalWithRouting from '../stats/modals/modal'
import { import {

View File

@ -1,4 +1,3 @@
/** @format */
import React, { HTMLAttributes } from 'react' import React, { HTMLAttributes } from 'react'
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import { parseSiteFromDataset, PlausibleSite } from './site-context' import { parseSiteFromDataset, PlausibleSite } from './site-context'

View File

@ -1,4 +1,3 @@
/** @format */
import React, { createContext, ReactNode, useContext } from 'react' import React, { createContext, ReactNode, useContext } from 'react'
export function parseSiteFromDataset(dataset: DOMStringMap): PlausibleSite { export function parseSiteFromDataset(dataset: DOMStringMap): PlausibleSite {

View File

@ -1,29 +1,34 @@
import React from 'react'; import React from 'react'
function barWidth(count, all, plot) { function barWidth(count, all, plot) {
let maxVal = all[0][plot]; let maxVal = all[0][plot]
for (const val of all) { for (const val of all) {
if (val > maxVal) maxVal = val[plot] if (val > maxVal) maxVal = val[plot]
} }
return count / maxVal * 100 return (count / maxVal) * 100
} }
export default function Bar({count, all, bg, maxWidthDeduction, children, plot = "visitors"}) { export default function Bar({
count,
all,
bg,
maxWidthDeduction,
children,
plot = 'visitors'
}) {
const width = barWidth(count, all, plot) const width = barWidth(count, all, plot)
const style = maxWidthDeduction ? {maxWidth: `calc(100% - ${maxWidthDeduction})`} : {} const style = maxWidthDeduction
? { maxWidth: `calc(100% - ${maxWidthDeduction})` }
: {}
return ( return (
<div <div className="w-full h-full relative" style={style}>
className="w-full h-full relative"
style={style}
>
<div <div
className={`absolute top-0 left-0 h-full ${bg || ''}`} className={`absolute top-0 left-0 h-full ${bg || ''}`}
style={{width: `${width}%`}} style={{ width: `${width}%` }}
> ></div>
</div>
{children} {children}
</div> </div>
) )

View File

@ -1,15 +1,15 @@
import React from 'react'; import React from 'react'
import * as api from '../../api'; import * as api from '../../api'
import * as url from '../../util/url'; import * as url from '../../util/url'
import * as metrics from '../reports/metrics'; import * as metrics from '../reports/metrics'
import ListReport from '../reports/list'; import ListReport from '../reports/list'
import { useSiteContext } from '../../site-context'; import { useSiteContext } from '../../site-context'
import { useQueryContext } from '../../query-context'; import { useQueryContext } from '../../query-context'
import { conversionsRoute } from '../../router'; import { conversionsRoute } from '../../router'
export default function Conversions({ afterFetchData, onGoalFilterClick }) { export default function Conversions({ afterFetchData, onGoalFilterClick }) {
const site = useSiteContext(); const site = useSiteContext()
const { query } = useQueryContext() const { query } = useQueryContext()
function fetchConversions() { function fetchConversions() {
@ -18,19 +18,27 @@ export default function Conversions({ afterFetchData, onGoalFilterClick }) {
function getFilterInfo(listItem) { function getFilterInfo(listItem) {
return { return {
prefix: "goal", prefix: 'goal',
filter: ["is", "goal", [listItem.name]], filter: ['is', 'goal', [listItem.name]]
} }
} }
function chooseMetrics() { function chooseMetrics() {
return [ return [
metrics.createVisitors({ renderLabel: (_query) => "Uniques", meta: { plot: true } }), metrics.createVisitors({
metrics.createEvents({ renderLabel: (_query) => "Total", meta: { hiddenOnMobile: true } }), renderLabel: (_query) => 'Uniques',
meta: { plot: true }
}),
metrics.createEvents({
renderLabel: (_query) => 'Total',
meta: { hiddenOnMobile: true }
}),
metrics.createConversionRate(), metrics.createConversionRate(),
BUILD_EXTRA && metrics.createTotalRevenue({ meta: { hiddenOnMobile: true } }), BUILD_EXTRA &&
BUILD_EXTRA && metrics.createAverageRevenue({ meta: { hiddenOnMobile: true } }) metrics.createTotalRevenue({ meta: { hiddenOnMobile: true } }),
].filter(metric => !!metric) BUILD_EXTRA &&
metrics.createAverageRevenue({ meta: { hiddenOnMobile: true } })
].filter((metric) => !!metric)
} }
/*global BUILD_EXTRA*/ /*global BUILD_EXTRA*/
@ -42,7 +50,10 @@ export default function Conversions({ afterFetchData, onGoalFilterClick }) {
keyLabel="Goal" keyLabel="Goal"
onClick={onGoalFilterClick} onClick={onGoalFilterClick}
metrics={chooseMetrics()} metrics={chooseMetrics()}
detailsLinkProps={{ path: conversionsRoute.path, search: (search) => search }} detailsLinkProps={{
path: conversionsRoute.path,
search: (search) => search
}}
color="bg-red-50" color="bg-red-50"
colMinWidth={90} colMinWidth={90}
/> />

View File

@ -1,21 +1,28 @@
import React from "react" import React from 'react'
import Conversions from './conversions' import Conversions from './conversions'
import ListReport from "../reports/list" import ListReport from '../reports/list'
import * as metrics from '../reports/metrics' import * as metrics from '../reports/metrics'
import * as url from "../../util/url" import * as url from '../../util/url'
import * as api from "../../api" import * as api from '../../api'
import { EVENT_PROPS_PREFIX, getGoalFilter, FILTER_OPERATIONS } from "../../util/filters" import {
import { useSiteContext } from "../../site-context" EVENT_PROPS_PREFIX,
import { useQueryContext } from "../../query-context" getGoalFilter,
import { customPropsRoute } from "../../router" FILTER_OPERATIONS
} from '../../util/filters'
import { useSiteContext } from '../../site-context'
import { useQueryContext } from '../../query-context'
import { customPropsRoute } from '../../router'
export const SPECIAL_GOALS = { export const SPECIAL_GOALS = {
'404': { title: '404 Pages', prop: 'path' }, 404: { title: '404 Pages', prop: 'path' },
'Outbound Link: Click': { title: 'Outbound Links', prop: 'url' }, 'Outbound Link: Click': { title: 'Outbound Links', prop: 'url' },
'Cloaked Link: Click': { title: 'Cloaked Links', prop: 'url' }, 'Cloaked Link: Click': { title: 'Cloaked Links', prop: 'url' },
'File Download': { title: 'File Downloads', prop: 'url' }, 'File Download': { title: 'File Downloads', prop: 'url' },
'WP Search Queries': { title: 'WordPress Search Queries', prop: 'search_query' }, 'WP Search Queries': {
'WP Form Completions': { title: 'WordPress Form Completions', prop: 'path' }, title: 'WordPress Search Queries',
prop: 'search_query'
},
'WP Form Completions': { title: 'WordPress Form Completions', prop: 'path' }
} }
function getSpecialGoal(query) { function getSpecialGoal(query) {
@ -28,7 +35,6 @@ function getSpecialGoal(query) {
return SPECIAL_GOALS[clauses[0]] || null return SPECIAL_GOALS[clauses[0]] || null
} }
return null return null
} }
export function specialTitleWhenGoalFilter(query, defaultTitle) { export function specialTitleWhenGoalFilter(query, defaultTitle) {
@ -36,8 +42,8 @@ export function specialTitleWhenGoalFilter(query, defaultTitle) {
} }
function SpecialPropBreakdown({ prop, afterFetchData }) { function SpecialPropBreakdown({ prop, afterFetchData }) {
const site = useSiteContext(); const site = useSiteContext()
const { query } = useQueryContext(); const { query } = useQueryContext()
function fetchData() { function fetchData() {
return api.get(url.apiPath(site, `/custom-prop-values/${prop}`), query) return api.get(url.apiPath(site, `/custom-prop-values/${prop}`), query)
@ -54,16 +60,22 @@ function SpecialPropBreakdown({ prop, afterFetchData }) {
function getFilterInfo(listItem) { function getFilterInfo(listItem) {
return { return {
prefix: EVENT_PROPS_PREFIX, prefix: EVENT_PROPS_PREFIX,
filter: ["is", `${EVENT_PROPS_PREFIX}${prop}`, [listItem['name']]] filter: ['is', `${EVENT_PROPS_PREFIX}${prop}`, [listItem['name']]]
} }
} }
function chooseMetrics() { function chooseMetrics() {
return [ return [
metrics.createVisitors({ renderLabel: (_query) => "Visitors", meta: { plot: true } }), metrics.createVisitors({
metrics.createEvents({ renderLabel: (_query) => "Events", meta: { hiddenOnMobile: true } }), renderLabel: (_query) => 'Visitors',
meta: { plot: true }
}),
metrics.createEvents({
renderLabel: (_query) => 'Events',
meta: { hiddenOnMobile: true }
}),
metrics.createConversionRate() metrics.createConversionRate()
].filter(metric => !!metric) ].filter((metric) => !!metric)
} }
return ( return (
@ -73,7 +85,11 @@ function SpecialPropBreakdown({ prop, afterFetchData }) {
getFilterInfo={getFilterInfo} getFilterInfo={getFilterInfo}
keyLabel={prop} keyLabel={prop}
metrics={chooseMetrics()} metrics={chooseMetrics()}
detailsLinkProps={{ path: customPropsRoute.path, params: {propKey: url.maybeEncodeRouteParam(prop)}, search: (search) => search }} detailsLinkProps={{
path: customPropsRoute.path,
params: { propKey: url.maybeEncodeRouteParam(prop) },
search: (search) => search
}}
getExternalLinkUrl={getExternalLinkUrlFactory()} getExternalLinkUrl={getExternalLinkUrlFactory()}
maybeHideDetails={true} maybeHideDetails={true}
color="bg-red-50" color="bg-red-50"
@ -87,8 +103,18 @@ export default function GoalConversions({ afterFetchData, onGoalFilterClick }) {
const specialGoal = getSpecialGoal(query) const specialGoal = getSpecialGoal(query)
if (specialGoal) { if (specialGoal) {
return <SpecialPropBreakdown prop={specialGoal.prop} afterFetchData={afterFetchData} /> return (
<SpecialPropBreakdown
prop={specialGoal.prop}
afterFetchData={afterFetchData}
/>
)
} else { } else {
return <Conversions onGoalFilterClick={onGoalFilterClick} afterFetchData={afterFetchData} /> return (
<Conversions
onGoalFilterClick={onGoalFilterClick}
afterFetchData={afterFetchData}
/>
)
} }
} }

View File

@ -1,10 +1,19 @@
import React, { Fragment, useState, useEffect, useCallback, useRef } from 'react' import React, {
Fragment,
useState,
useEffect,
useCallback,
useRef
} from 'react'
import { Menu, Transition } from '@headlessui/react' import { Menu, Transition } from '@headlessui/react'
import { ChevronDownIcon } from '@heroicons/react/20/solid' import { ChevronDownIcon } from '@heroicons/react/20/solid'
import classNames from 'classnames' import classNames from 'classnames'
import * as storage from '../../util/storage' import * as storage from '../../util/storage'
import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning' import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning'
import GoalConversions, { specialTitleWhenGoalFilter, SPECIAL_GOALS } from './goal-conversions' import GoalConversions, {
specialTitleWhenGoalFilter,
SPECIAL_GOALS
} from './goal-conversions'
import Properties from './props' import Properties from './props'
import { FeatureSetupNotice } from '../../components/notice' import { FeatureSetupNotice } from '../../components/notice'
import { hasConversionGoalFilter } from '../../util/filters' import { hasConversionGoalFilter } from '../../util/filters'
@ -26,7 +35,8 @@ function maybeRequire() {
const Funnel = maybeRequire().default const Funnel = maybeRequire().default
const ACTIVE_CLASS = 'inline-block h-5 text-indigo-700 dark:text-indigo-500 font-bold active-prop-heading truncate text-left' const ACTIVE_CLASS =
'inline-block h-5 text-indigo-700 dark:text-indigo-500 font-bold active-prop-heading truncate text-left'
const DEFAULT_CLASS = 'hover:text-indigo-600 cursor-pointer truncate text-left' const DEFAULT_CLASS = 'hover:text-indigo-600 cursor-pointer truncate text-left'
export const CONVERSIONS = 'conversions' export const CONVERSIONS = 'conversions'
@ -40,21 +50,29 @@ export const sectionTitles = {
} }
export default function Behaviours({ importedDataInView }) { export default function Behaviours({ importedDataInView }) {
const { query } = useQueryContext(); const { query } = useQueryContext()
const site = useSiteContext(); const site = useSiteContext()
const user = useUserContext(); const user = useUserContext()
const buttonRef = useRef(); const buttonRef = useRef()
const adminAccess = ['owner', 'admin', 'editor', 'super_admin'].includes(user.role) const adminAccess = ['owner', 'admin', 'editor', 'super_admin'].includes(
user.role
)
const tabKey = storage.getDomainScopedStorageKey('behavioursTab', site.domain) const tabKey = storage.getDomainScopedStorageKey('behavioursTab', site.domain)
const funnelKey = storage.getDomainScopedStorageKey('behavioursTabFunnel', site.domain) const funnelKey = storage.getDomainScopedStorageKey(
'behavioursTabFunnel',
site.domain
)
const [enabledModes, setEnabledModes] = useState(getEnabledModes()) const [enabledModes, setEnabledModes] = useState(getEnabledModes())
const [mode, setMode] = useState(defaultMode()) const [mode, setMode] = useState(defaultMode())
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [funnelNames, _setFunnelNames] = useState(site.funnels.map(({ name }) => name)) const [funnelNames, _setFunnelNames] = useState(
site.funnels.map(({ name }) => name)
)
const [selectedFunnel, setSelectedFunnel] = useState(defaultSelectedFunnel()) const [selectedFunnel, setSelectedFunnel] = useState(defaultSelectedFunnel())
const [showingPropsForGoalFilter, setShowingPropsForGoalFilter] = useState(false) const [showingPropsForGoalFilter, setShowingPropsForGoalFilter] =
useState(false)
const [skipImportedReason, setSkipImportedReason] = useState(null) const [skipImportedReason, setSkipImportedReason] = useState(null)
@ -63,7 +81,12 @@ export default function Behaviours({ importedDataInView }) {
const isSpecialGoal = Object.keys(SPECIAL_GOALS).includes(goalName) const isSpecialGoal = Object.keys(SPECIAL_GOALS).includes(goalName)
const isPageviewGoal = goalName.startsWith('Visit ') const isPageviewGoal = goalName.startsWith('Visit ')
if (!isSpecialGoal && !isPageviewGoal && enabledModes.includes(PROPS) && site.hasProps) { if (
!isSpecialGoal &&
!isPageviewGoal &&
enabledModes.includes(PROPS) &&
site.hasProps
) {
setShowingPropsForGoalFilter(true) setShowingPropsForGoalFilter(true)
setMode(PROPS) setMode(PROPS)
} }
@ -87,7 +110,11 @@ export default function Behaviours({ importedDataInView }) {
useEffect(() => setLoading(true), [query, mode]) useEffect(() => setLoading(true), [query, mode])
function disableMode(mode) { function disableMode(mode) {
setEnabledModes(enabledModes.filter((m) => { return m !== mode })) setEnabledModes(
enabledModes.filter((m) => {
return m !== mode
})
)
} }
function setFunnel(selectedFunnel) { function setFunnel(selectedFunnel) {
@ -118,12 +145,21 @@ export default function Behaviours({ importedDataInView }) {
} }
function tabFunnelPicker() { function tabFunnelPicker() {
return <Menu as="div" className="relative inline-block text-left"> return (
<BlurMenuButtonOnEscape targetRef={buttonRef}/> <Menu as="div" className="relative inline-block text-left">
<BlurMenuButtonOnEscape targetRef={buttonRef} />
<div> <div>
<Menu.Button ref={buttonRef} className="inline-flex justify-between focus:outline-none"> <Menu.Button
<span className={(mode == FUNNELS) ? ACTIVE_CLASS : DEFAULT_CLASS}>Funnels</span> ref={buttonRef}
<ChevronDownIcon className="-mr-1 ml-1 h-4 w-4" aria-hidden="true" /> className="inline-flex justify-between focus:outline-none"
>
<span className={mode == FUNNELS ? ACTIVE_CLASS : DEFAULT_CLASS}>
Funnels
</span>
<ChevronDownIcon
className="-mr-1 ml-1 h-4 w-4"
aria-hidden="true"
/>
</Menu.Button> </Menu.Button>
</div> </div>
@ -145,9 +181,13 @@ export default function Behaviours({ importedDataInView }) {
<span <span
onClick={setFunnel(funnelName)} onClick={setFunnel(funnelName)}
className={classNames( className={classNames(
active ? 'bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-200 cursor-pointer' : 'text-gray-700 dark:text-gray-200', active
? 'bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-200 cursor-pointer'
: 'text-gray-700 dark:text-gray-200',
'block px-4 py-2 text-sm', 'block px-4 py-2 text-sm',
(mode === FUNNELS && selectedFunnel === funnelName) ? 'font-bold text-gray-500' : '' mode === FUNNELS && selectedFunnel === funnelName
? 'font-bold text-gray-500'
: ''
)} )}
> >
{funnelName} {funnelName}
@ -160,10 +200,14 @@ export default function Behaviours({ importedDataInView }) {
</Menu.Items> </Menu.Items>
</Transition> </Transition>
</Menu> </Menu>
)
} }
function tabSwitcher(toMode, displayName) { function tabSwitcher(toMode, displayName) {
const className = classNames({ [ACTIVE_CLASS]: mode == toMode, [DEFAULT_CLASS]: mode !== toMode }) const className = classNames({
[ACTIVE_CLASS]: mode == toMode,
[DEFAULT_CLASS]: mode !== toMode
})
const setTab = () => { const setTab = () => {
storage.setItem(tabKey, toMode) storage.setItem(tabKey, toMode)
setMode(toMode) setMode(toMode)
@ -181,7 +225,9 @@ export default function Behaviours({ importedDataInView }) {
<div className="flex text-xs font-medium text-gray-500 dark:text-gray-400 space-x-2"> <div className="flex text-xs font-medium text-gray-500 dark:text-gray-400 space-x-2">
{isEnabled(CONVERSIONS) && tabSwitcher(CONVERSIONS, 'Goals')} {isEnabled(CONVERSIONS) && tabSwitcher(CONVERSIONS, 'Goals')}
{isEnabled(PROPS) && tabSwitcher(PROPS, 'Properties')} {isEnabled(PROPS) && tabSwitcher(PROPS, 'Properties')}
{isEnabled(FUNNELS) && Funnel && (hasFunnels() ? tabFunnelPicker() : tabSwitcher(FUNNELS, 'Funnels'))} {isEnabled(FUNNELS) &&
Funnel &&
(hasFunnels() ? tabFunnelPicker() : tabSwitcher(FUNNELS, 'Funnels'))}
</div> </div>
) )
} }
@ -193,37 +239,45 @@ export default function Behaviours({ importedDataInView }) {
function renderConversions() { function renderConversions() {
if (site.hasGoals) { if (site.hasGoals) {
return <GoalConversions onGoalFilterClick={onGoalFilterClick} afterFetchData={afterFetchData} /> return (
} <GoalConversions
else if (adminAccess) { onGoalFilterClick={onGoalFilterClick}
afterFetchData={afterFetchData}
/>
)
} else if (adminAccess) {
return ( return (
<FeatureSetupNotice <FeatureSetupNotice
feature={CONVERSIONS} feature={CONVERSIONS}
title={'Measure how often visitors complete specific actions'} title={'Measure how often visitors complete specific actions'}
info={'Goals allow you to track registrations, button clicks, form completions, external link clicks, file downloads, 404 error pages and more.'} info={
'Goals allow you to track registrations, button clicks, form completions, external link clicks, file downloads, 404 error pages and more.'
}
callToAction={{ callToAction={{
action: "Set up goals", action: 'Set up goals',
link: `/${encodeURIComponent(site.domain)}/settings/goals` link: `/${encodeURIComponent(site.domain)}/settings/goals`
}} }}
onHideAction={onHideAction(CONVERSIONS)} onHideAction={onHideAction(CONVERSIONS)}
/> />
) )
} else {
return noDataYet()
} }
else { return noDataYet() }
} }
function renderFunnels() { function renderFunnels() {
if (Funnel === null) { if (Funnel === null) {
return featureUnavailable() return featureUnavailable()
} } else if (Funnel && selectedFunnel && site.funnelsAvailable) {
else if (Funnel && selectedFunnel && site.funnelsAvailable) {
return <Funnel funnelName={selectedFunnel} /> return <Funnel funnelName={selectedFunnel} />
} } else if (Funnel && adminAccess) {
else if (Funnel && adminAccess) {
let callToAction let callToAction
if (site.funnelsAvailable) { if (site.funnelsAvailable) {
callToAction = { action: 'Set up funnels', link: `/${encodeURIComponent(site.domain)}/settings/funnels` } callToAction = {
action: 'Set up funnels',
link: `/${encodeURIComponent(site.domain)}/settings/funnels`
}
} else { } else {
callToAction = { action: 'Upgrade', link: '/billing/choose-plan' } callToAction = { action: 'Upgrade', link: '/billing/choose-plan' }
} }
@ -232,13 +286,16 @@ export default function Behaviours({ importedDataInView }) {
<FeatureSetupNotice <FeatureSetupNotice
feature={FUNNELS} feature={FUNNELS}
title={'Follow the visitor journey from entry to conversion'} title={'Follow the visitor journey from entry to conversion'}
info={'Funnels allow you to analyze the user flow through your website, uncover possible issues, optimize your site and increase the conversion rate.'} info={
'Funnels allow you to analyze the user flow through your website, uncover possible issues, optimize your site and increase the conversion rate.'
}
callToAction={callToAction} callToAction={callToAction}
onHideAction={onHideAction(FUNNELS)} onHideAction={onHideAction(FUNNELS)}
/> />
) )
} else {
return noDataYet()
} }
else { return noDataYet() }
} }
function renderProps() { function renderProps() {
@ -248,7 +305,10 @@ export default function Behaviours({ importedDataInView }) {
let callToAction let callToAction
if (site.propsAvailable) { if (site.propsAvailable) {
callToAction = { action: 'Set up props', link: `/${encodeURIComponent(site.domain)}/settings/properties` } callToAction = {
action: 'Set up props',
link: `/${encodeURIComponent(site.domain)}/settings/properties`
}
} else { } else {
callToAction = { action: 'Upgrade', link: '/billing/choose-plan' } callToAction = { action: 'Upgrade', link: '/billing/choose-plan' }
} }
@ -257,12 +317,16 @@ export default function Behaviours({ importedDataInView }) {
<FeatureSetupNotice <FeatureSetupNotice
feature={PROPS} feature={PROPS}
title={'Send custom data to create your own metrics'} title={'Send custom data to create your own metrics'}
info={'You can attach custom properties when sending a pageview or event. This allows you to create custom metrics and analyze stats we don\'t track automatically.'} info={
"You can attach custom properties when sending a pageview or event. This allows you to create custom metrics and analyze stats we don't track automatically."
}
callToAction={callToAction} callToAction={callToAction}
onHideAction={onHideAction(PROPS)} onHideAction={onHideAction(PROPS)}
/> />
) )
} else { return noDataYet() } } else {
return noDataYet()
}
} }
function noDataYet() { function noDataYet() {
@ -282,7 +346,9 @@ export default function Behaviours({ importedDataInView }) {
} }
function onHideAction(mode) { function onHideAction(mode) {
return () => { disableMode(mode) } return () => {
disableMode(mode)
}
} }
function renderContent() { function renderContent() {
@ -297,13 +363,21 @@ export default function Behaviours({ importedDataInView }) {
} }
function defaultMode() { function defaultMode() {
if (enabledModes.length === 0) { return null } if (enabledModes.length === 0) {
return null
}
const storedMode = storage.getItem(tabKey) const storedMode = storage.getItem(tabKey)
if (storedMode && enabledModes.includes(storedMode)) { return storedMode } if (storedMode && enabledModes.includes(storedMode)) {
return storedMode
}
if (enabledModes.includes(CONVERSIONS)) { return CONVERSIONS } if (enabledModes.includes(CONVERSIONS)) {
if (enabledModes.includes(PROPS)) { return PROPS } return CONVERSIONS
}
if (enabledModes.includes(PROPS)) {
return PROPS
}
return FUNNELS return FUNNELS
} }
@ -345,11 +419,27 @@ export default function Behaviours({ importedDataInView }) {
function renderImportedQueryUnsupportedWarning() { function renderImportedQueryUnsupportedWarning() {
if (mode === CONVERSIONS) { if (mode === CONVERSIONS) {
return <ImportedQueryUnsupportedWarning loading={loading} skipImportedReason={skipImportedReason} /> return (
<ImportedQueryUnsupportedWarning
loading={loading}
skipImportedReason={skipImportedReason}
/>
)
} else if (mode === PROPS) { } else if (mode === PROPS) {
return <ImportedQueryUnsupportedWarning loading={loading} skipImportedReason={skipImportedReason} message="Imported data is unavailable in this view" /> return (
<ImportedQueryUnsupportedWarning
loading={loading}
skipImportedReason={skipImportedReason}
message="Imported data is unavailable in this view"
/>
)
} else { } else {
return <ImportedQueryUnsupportedWarning altCondition={importedDataInView} message="Imported data is unavailable in this view" /> return (
<ImportedQueryUnsupportedWarning
altCondition={importedDataInView}
message="Imported data is unavailable in this view"
/>
)
} }
} }

View File

@ -1,20 +1,24 @@
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from 'react'
import ListReport, { MIN_HEIGHT } from "../reports/list"; import ListReport, { MIN_HEIGHT } from '../reports/list'
import Combobox from '../../components/combobox'; import Combobox from '../../components/combobox'
import * as metrics from '../reports/metrics'; import * as metrics from '../reports/metrics'
import * as api from '../../api'; import * as api from '../../api'
import * as url from '../../util/url'; import * as url from '../../util/url'
import * as storage from "../../util/storage"; import * as storage from '../../util/storage'
import { EVENT_PROPS_PREFIX, getGoalFilter, FILTER_OPERATIONS, hasConversionGoalFilter } from "../../util/filters"; import {
import classNames from "classnames"; EVENT_PROPS_PREFIX,
import { useQueryContext } from "../../query-context"; getGoalFilter,
import { useSiteContext } from "../../site-context"; FILTER_OPERATIONS,
import { customPropsRoute } from "../../router"; hasConversionGoalFilter
} from '../../util/filters'
import classNames from 'classnames'
import { useQueryContext } from '../../query-context'
import { useSiteContext } from '../../site-context'
import { customPropsRoute } from '../../router'
export default function Properties({ afterFetchData }) { export default function Properties({ afterFetchData }) {
const { query } = useQueryContext(); const { query } = useQueryContext()
const site = useSiteContext(); const site = useSiteContext()
const propKeyStorageName = `prop_key__${site.domain}` const propKeyStorageName = `prop_key__${site.domain}`
const propKeyStorageNameForGoal = () => { const propKeyStorageNameForGoal = () => {
@ -39,8 +43,8 @@ export default function Properties({ afterFetchData }) {
setPropKeyLoading(true) setPropKeyLoading(true)
setPropKey(null) setPropKey(null)
fetchPropKeyOptions()("").then((propKeys) => { fetchPropKeyOptions()('').then((propKeys) => {
const propKeyValues = propKeys.map(entry => entry.value) const propKeyValues = propKeys.map((entry) => entry.value)
if (propKeyValues.length > 0) { if (propKeyValues.length > 0) {
const storedPropKey = getPropKeyFromStorage() const storedPropKey = getPropKeyFromStorage()
@ -60,29 +64,39 @@ export default function Properties({ afterFetchData }) {
function getPropKeyFromStorage() { function getPropKeyFromStorage() {
if (singleGoalFilterApplied()) { if (singleGoalFilterApplied()) {
const storedForGoal = storage.getItem(propKeyStorageNameForGoal()) const storedForGoal = storage.getItem(propKeyStorageNameForGoal())
if (storedForGoal) { return storedForGoal } if (storedForGoal) {
return storedForGoal
}
} }
return storage.getItem(propKeyStorageName) return storage.getItem(propKeyStorageName)
} }
function fetchProps() { function fetchProps() {
return api.get(url.apiPath(site, `/custom-prop-values/${encodeURIComponent(propKey)}`), query) return api.get(
url.apiPath(site, `/custom-prop-values/${encodeURIComponent(propKey)}`),
query
)
} }
const fetchPropKeyOptions = useCallback(() => { const fetchPropKeyOptions = useCallback(() => {
return (input) => { return (input) => {
return api.get(url.apiPath(site, "/suggestions/prop_key"), query, { q: input.trim() }) return api.get(url.apiPath(site, '/suggestions/prop_key'), query, {
q: input.trim()
})
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [query]) }, [query])
function onPropKeySelect() { function onPropKeySelect() {
return (selectedOptions) => { return (selectedOptions) => {
const newPropKey = selectedOptions.length === 0 ? null : selectedOptions[0].value const newPropKey =
selectedOptions.length === 0 ? null : selectedOptions[0].value
if (newPropKey) { if (newPropKey) {
const storageName = singleGoalFilterApplied() ? propKeyStorageNameForGoal() : propKeyStorageName const storageName = singleGoalFilterApplied()
? propKeyStorageNameForGoal()
: propKeyStorageName
storage.setItem(storageName, newPropKey) storage.setItem(storageName, newPropKey)
} }
@ -93,13 +107,21 @@ export default function Properties({ afterFetchData }) {
/*global BUILD_EXTRA*/ /*global BUILD_EXTRA*/
function chooseMetrics() { function chooseMetrics() {
return [ return [
metrics.createVisitors({ renderLabel: (_query) => "Visitors", meta: { plot: true } }), metrics.createVisitors({
metrics.createEvents({ renderLabel: (_query) => "Events", meta: { hiddenOnMobile: true } }), renderLabel: (_query) => 'Visitors',
meta: { plot: true }
}),
metrics.createEvents({
renderLabel: (_query) => 'Events',
meta: { hiddenOnMobile: true }
}),
hasConversionGoalFilter(query) && metrics.createConversionRate(), hasConversionGoalFilter(query) && metrics.createConversionRate(),
!hasConversionGoalFilter(query) && metrics.createPercentage(), !hasConversionGoalFilter(query) && metrics.createPercentage(),
BUILD_EXTRA && metrics.createTotalRevenue({ meta: { hiddenOnMobile: true } }), BUILD_EXTRA &&
BUILD_EXTRA && metrics.createAverageRevenue({ meta: { hiddenOnMobile: true } }) metrics.createTotalRevenue({ meta: { hiddenOnMobile: true } }),
].filter(metric => !!metric) BUILD_EXTRA &&
metrics.createAverageRevenue({ meta: { hiddenOnMobile: true } })
].filter((metric) => !!metric)
} }
function renderBreakdown() { function renderBreakdown() {
@ -110,7 +132,11 @@ export default function Properties({ afterFetchData }) {
getFilterInfo={getFilterInfo} getFilterInfo={getFilterInfo}
keyLabel={propKey} keyLabel={propKey}
metrics={chooseMetrics()} metrics={chooseMetrics()}
detailsLinkProps={{ path: customPropsRoute.path, params: { propKey }, search: (search) => search }} detailsLinkProps={{
path: customPropsRoute.path,
params: { propKey },
search: (search) => search
}}
maybeHideDetails={true} maybeHideDetails={true}
color="bg-red-50" color="bg-red-50"
colMinWidth={90} colMinWidth={90}
@ -120,22 +146,38 @@ export default function Properties({ afterFetchData }) {
const getFilterInfo = (listItem) => ({ const getFilterInfo = (listItem) => ({
prefix: `${EVENT_PROPS_PREFIX}${propKey}`, prefix: `${EVENT_PROPS_PREFIX}${propKey}`,
filter: ["is", `${EVENT_PROPS_PREFIX}${propKey}`, [listItem.name]] filter: ['is', `${EVENT_PROPS_PREFIX}${propKey}`, [listItem.name]]
}) })
const comboboxDisabled = !propKeyLoading && !propKey const comboboxDisabled = !propKeyLoading && !propKey
const comboboxPlaceholder = comboboxDisabled ? 'No custom properties found' : '' const comboboxPlaceholder = comboboxDisabled
? 'No custom properties found'
: ''
const comboboxValues = propKey ? [{ value: propKey, label: propKey }] : [] const comboboxValues = propKey ? [{ value: propKey, label: propKey }] : []
const boxClass = classNames('pl-2 pr-8 py-1 bg-transparent dark:text-gray-300 rounded-md shadow-sm border border-gray-300 dark:border-gray-500', { const boxClass = classNames(
'pl-2 pr-8 py-1 bg-transparent dark:text-gray-300 rounded-md shadow-sm border border-gray-300 dark:border-gray-500',
{
'pointer-events-none': comboboxDisabled 'pointer-events-none': comboboxDisabled
}) }
)
const COMBOBOX_HEIGHT = 40 const COMBOBOX_HEIGHT = 40
return ( return (
<div className="w-full mt-4" style={{ minHeight: `${COMBOBOX_HEIGHT + MIN_HEIGHT}px` }}> <div
className="w-full mt-4"
style={{ minHeight: `${COMBOBOX_HEIGHT + MIN_HEIGHT}px` }}
>
<div style={{ minHeight: `${COMBOBOX_HEIGHT}px` }}> <div style={{ minHeight: `${COMBOBOX_HEIGHT}px` }}>
<Combobox boxClass={boxClass} forceLoading={propKeyLoading} fetchOptions={fetchPropKeyOptions()} singleOption={true} values={comboboxValues} onSelect={onPropKeySelect()} placeholder={comboboxPlaceholder} /> <Combobox
boxClass={boxClass}
forceLoading={propKeyLoading}
fetchOptions={fetchPropKeyOptions()}
singleOption={true}
values={comboboxValues}
onSelect={onPropKeySelect()}
placeholder={comboboxPlaceholder}
/>
</div> </div>
{propKey && renderBreakdown()} {propKey && renderBreakdown()}
</div> </div>

View File

@ -1,5 +1,3 @@
/** @format */
import React, { useCallback, useEffect, useState } from 'react' import React, { useCallback, useEffect, useState } from 'react'
import { AppNavigationLink } from '../navigation/use-app-navigate' import { AppNavigationLink } from '../navigation/use-app-navigate'
import * as api from '../api' import * as api from '../api'

View File

@ -1,32 +1,36 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react'
import * as storage from '../../util/storage'; import * as storage from '../../util/storage'
import { getFiltersByKeyPrefix, hasConversionGoalFilter, isFilteringOnFixedValue } from '../../util/filters'; import {
import ListReport from '../reports/list'; getFiltersByKeyPrefix,
import * as metrics from '../reports/metrics'; hasConversionGoalFilter,
import * as api from '../../api'; isFilteringOnFixedValue
import * as url from '../../util/url'; } from '../../util/filters'
import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning'; import ListReport from '../reports/list'
import { useQueryContext } from '../../query-context'; import * as metrics from '../reports/metrics'
import { useSiteContext } from '../../site-context'; import * as api from '../../api'
import * as url from '../../util/url'
import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning'
import { useQueryContext } from '../../query-context'
import { useSiteContext } from '../../site-context'
import { import {
browsersRoute, browsersRoute,
browserVersionsRoute, browserVersionsRoute,
operatingSystemsRoute, operatingSystemsRoute,
operatingSystemVersionsRoute, operatingSystemVersionsRoute,
screenSizesRoute screenSizesRoute
} from '../../router'; } from '../../router'
// Icons copied from https://github.com/alrra/browser-logos // Icons copied from https://github.com/alrra/browser-logos
const BROWSER_ICONS = { const BROWSER_ICONS = {
'Chrome': 'chrome.svg', Chrome: 'chrome.svg',
'curl': 'curl.svg', curl: 'curl.svg',
'Safari': 'safari.png', Safari: 'safari.png',
'Firefox': 'firefox.svg', Firefox: 'firefox.svg',
'Microsoft Edge': 'edge.svg', 'Microsoft Edge': 'edge.svg',
'Vivaldi': 'vivaldi.svg', Vivaldi: 'vivaldi.svg',
'Opera': 'opera.svg', Opera: 'opera.svg',
'Samsung Browser': 'samsung-internet.svg', 'Samsung Browser': 'samsung-internet.svg',
'Chromium': 'chromium.svg', Chromium: 'chromium.svg',
'UC Browser': 'uc.svg', 'UC Browser': 'uc.svg',
'Yandex Browser': 'yandex.png', // Only PNG available in browser-logos 'Yandex Browser': 'yandex.png', // Only PNG available in browser-logos
// Logos underneath this line are not available in browser-logos. Grabbed from random places on the internets. // Logos underneath this line are not available in browser-logos. Grabbed from random places on the internets.
@ -34,7 +38,7 @@ const BROWSER_ICONS = {
'MIUI Browser': 'miui.webp', 'MIUI Browser': 'miui.webp',
'Huawei Browser Mobile': 'huawei.png', 'Huawei Browser Mobile': 'huawei.png',
'QQ Browser': 'qq.png', 'QQ Browser': 'qq.png',
'Ecosia': 'ecosia.png', Ecosia: 'ecosia.png',
'vivo Browser': 'vivo.png' 'vivo Browser': 'vivo.png'
} }
@ -51,8 +55,8 @@ export function browserIconFor(browser) {
} }
function Browsers({ afterFetchData }) { function Browsers({ afterFetchData }) {
const site = useSiteContext(); const site = useSiteContext()
const { query } = useQueryContext(); const { query } = useQueryContext()
function fetchData() { function fetchData() {
return api.get(url.apiPath(site, '/browsers'), query) return api.get(url.apiPath(site, '/browsers'), query)
} }
@ -60,7 +64,7 @@ function Browsers({ afterFetchData }) {
function getFilterInfo(listItem) { function getFilterInfo(listItem) {
return { return {
prefix: 'browser', prefix: 'browser',
filter: ["is", "browser", [listItem['name']]] filter: ['is', 'browser', [listItem['name']]]
} }
} }
@ -73,7 +77,7 @@ function Browsers({ afterFetchData }) {
metrics.createVisitors({ meta: { plot: true } }), metrics.createVisitors({ meta: { plot: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate(), hasConversionGoalFilter(query) && metrics.createConversionRate(),
!hasConversionGoalFilter(query) && metrics.createPercentage() !hasConversionGoalFilter(query) && metrics.createPercentage()
].filter(metric => !!metric) ].filter((metric) => !!metric)
} }
return ( return (
@ -84,14 +88,17 @@ function Browsers({ afterFetchData }) {
keyLabel="Browser" keyLabel="Browser"
metrics={chooseMetrics()} metrics={chooseMetrics()}
renderIcon={renderIcon} renderIcon={renderIcon}
detailsLinkProps={{ path: browsersRoute.path, search: (search) => search }} detailsLinkProps={{
path: browsersRoute.path,
search: (search) => search
}}
/> />
) )
} }
function BrowserVersions({ afterFetchData }) { function BrowserVersions({ afterFetchData }) {
const { query } = useQueryContext(); const { query } = useQueryContext()
const site = useSiteContext(); const site = useSiteContext()
function fetchData() { function fetchData() {
return api.get(url.apiPath(site, '/browser-versions'), query) return api.get(url.apiPath(site, '/browser-versions'), query)
} }
@ -101,12 +108,12 @@ function BrowserVersions({ afterFetchData }) {
} }
function getFilterInfo(listItem) { function getFilterInfo(listItem) {
if (getSingleFilter(query, "browser") == '(not set)') { if (getSingleFilter(query, 'browser') == '(not set)') {
return null return null
} }
return { return {
prefix: 'browser_version', prefix: 'browser_version',
filter: ["is", "browser_version", [listItem.version]] filter: ['is', 'browser_version', [listItem.version]]
} }
} }
@ -115,7 +122,7 @@ function BrowserVersions({ afterFetchData }) {
metrics.createVisitors({ meta: { plot: true } }), metrics.createVisitors({ meta: { plot: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate(), hasConversionGoalFilter(query) && metrics.createConversionRate(),
!hasConversionGoalFilter(query) && metrics.createPercentage() !hasConversionGoalFilter(query) && metrics.createPercentage()
].filter(metric => !!metric) ].filter((metric) => !!metric)
} }
return ( return (
@ -126,46 +133,45 @@ function BrowserVersions({ afterFetchData }) {
keyLabel="Browser version" keyLabel="Browser version"
metrics={chooseMetrics()} metrics={chooseMetrics()}
renderIcon={renderIcon} renderIcon={renderIcon}
detailsLinkProps={{ path: browserVersionsRoute.path, search: (search) => search }} detailsLinkProps={{
path: browserVersionsRoute.path,
search: (search) => search
}}
/> />
) )
} }
// Icons copied from https://github.com/ngeenx/operating-system-logos // Icons copied from https://github.com/ngeenx/operating-system-logos
const OS_ICONS = { const OS_ICONS = {
'iOS': 'ios.png', iOS: 'ios.png',
'Mac': 'mac.png', Mac: 'mac.png',
'Windows': 'windows.png', Windows: 'windows.png',
'Windows Phone': 'windows.png', 'Windows Phone': 'windows.png',
'Android': 'android.png', Android: 'android.png',
'GNU/Linux': 'gnu_linux.png', 'GNU/Linux': 'gnu_linux.png',
'Ubuntu': 'ubuntu.png', Ubuntu: 'ubuntu.png',
'Chrome OS': 'chrome_os.png', 'Chrome OS': 'chrome_os.png',
'iPadOS': 'ipad_os.png', iPadOS: 'ipad_os.png',
'Fire OS': 'fire_os.png', 'Fire OS': 'fire_os.png',
'HarmonyOS': 'harmony_os.png', HarmonyOS: 'harmony_os.png',
'Tizen': 'tizen.png', Tizen: 'tizen.png',
'PlayStation': 'playstation.png', PlayStation: 'playstation.png',
'KaiOS': 'kai_os.png', KaiOS: 'kai_os.png',
'Fedora': 'fedora.png', Fedora: 'fedora.png',
'FreeBSD': 'freebsd.png', FreeBSD: 'freebsd.png'
} }
export function osIconFor(os) { export function osIconFor(os) {
const filename = OS_ICONS[os] || 'fallback.svg' const filename = OS_ICONS[os] || 'fallback.svg'
return ( return (
<img <img alt="" src={`/images/icon/os/${filename}`} className="w-4 h-4 mr-2" />
alt=""
src={`/images/icon/os/${filename}`}
className="w-4 h-4 mr-2"
/>
) )
} }
function OperatingSystems({ afterFetchData }) { function OperatingSystems({ afterFetchData }) {
const { query } = useQueryContext(); const { query } = useQueryContext()
const site = useSiteContext(); const site = useSiteContext()
function fetchData() { function fetchData() {
return api.get(url.apiPath(site, '/operating-systems'), query) return api.get(url.apiPath(site, '/operating-systems'), query)
} }
@ -173,7 +179,7 @@ function OperatingSystems({ afterFetchData }) {
function getFilterInfo(listItem) { function getFilterInfo(listItem) {
return { return {
prefix: 'os', prefix: 'os',
filter: ["is", "os", [listItem['name']]] filter: ['is', 'os', [listItem['name']]]
} }
} }
@ -181,8 +187,9 @@ function OperatingSystems({ afterFetchData }) {
return [ return [
metrics.createVisitors({ meta: { plot: true } }), metrics.createVisitors({ meta: { plot: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate(), hasConversionGoalFilter(query) && metrics.createConversionRate(),
!hasConversionGoalFilter(query) && metrics.createPercentage({ meta: { hiddenonMobile: true } }) !hasConversionGoalFilter(query) &&
].filter(metric => !!metric) metrics.createPercentage({ meta: { hiddenonMobile: true } })
].filter((metric) => !!metric)
} }
function renderIcon(listItem) { function renderIcon(listItem) {
@ -197,14 +204,17 @@ function OperatingSystems({ afterFetchData }) {
renderIcon={renderIcon} renderIcon={renderIcon}
keyLabel="Operating system" keyLabel="Operating system"
metrics={chooseMetrics()} metrics={chooseMetrics()}
detailsLinkProps={{ path: operatingSystemsRoute.path, search: (search) => search }} detailsLinkProps={{
path: operatingSystemsRoute.path,
search: (search) => search
}}
/> />
) )
} }
function OperatingSystemVersions({ afterFetchData }) { function OperatingSystemVersions({ afterFetchData }) {
const { query } = useQueryContext(); const { query } = useQueryContext()
const site = useSiteContext(); const site = useSiteContext()
function fetchData() { function fetchData() {
return api.get(url.apiPath(site, '/operating-system-versions'), query) return api.get(url.apiPath(site, '/operating-system-versions'), query)
@ -215,12 +225,12 @@ function OperatingSystemVersions({ afterFetchData }) {
} }
function getFilterInfo(listItem) { function getFilterInfo(listItem) {
if (getSingleFilter(query, "os") == '(not set)') { if (getSingleFilter(query, 'os') == '(not set)') {
return null return null
} }
return { return {
prefix: 'os_version', prefix: 'os_version',
filter: ["is", "os_version", [listItem.version]] filter: ['is', 'os_version', [listItem.version]]
} }
} }
@ -229,7 +239,7 @@ function OperatingSystemVersions({ afterFetchData }) {
metrics.createVisitors({ meta: { plot: true } }), metrics.createVisitors({ meta: { plot: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate(), hasConversionGoalFilter(query) && metrics.createConversionRate(),
!hasConversionGoalFilter(query) && metrics.createPercentage() !hasConversionGoalFilter(query) && metrics.createPercentage()
].filter(metric => !!metric) ].filter((metric) => !!metric)
} }
return ( return (
@ -240,15 +250,17 @@ function OperatingSystemVersions({ afterFetchData }) {
getFilterInfo={getFilterInfo} getFilterInfo={getFilterInfo}
keyLabel="Operating System Version" keyLabel="Operating System Version"
metrics={chooseMetrics()} metrics={chooseMetrics()}
detailsLinkProps={{ path: operatingSystemVersionsRoute.path, search: (search) => search }} detailsLinkProps={{
path: operatingSystemVersionsRoute.path,
search: (search) => search
}}
/> />
) )
} }
function ScreenSizes({ afterFetchData }) { function ScreenSizes({ afterFetchData }) {
const { query } = useQueryContext(); const { query } = useQueryContext()
const site = useSiteContext(); const site = useSiteContext()
function fetchData() { function fetchData() {
return api.get(url.apiPath(site, '/screen-sizes'), query) return api.get(url.apiPath(site, '/screen-sizes'), query)
@ -261,7 +273,7 @@ function ScreenSizes({ afterFetchData }) {
function getFilterInfo(listItem) { function getFilterInfo(listItem) {
return { return {
prefix: 'screen', prefix: 'screen',
filter: ["is", "screen", [listItem['name']]] filter: ['is', 'screen', [listItem['name']]]
} }
} }
@ -270,7 +282,7 @@ function ScreenSizes({ afterFetchData }) {
metrics.createVisitors({ meta: { plot: true } }), metrics.createVisitors({ meta: { plot: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate(), hasConversionGoalFilter(query) && metrics.createConversionRate(),
!hasConversionGoalFilter(query) && metrics.createPercentage() !hasConversionGoalFilter(query) && metrics.createPercentage()
].filter(metric => !!metric) ].filter((metric) => !!metric)
} }
return ( return (
@ -281,7 +293,10 @@ function ScreenSizes({ afterFetchData }) {
keyLabel="Screen size" keyLabel="Screen size"
metrics={chooseMetrics()} metrics={chooseMetrics()}
renderIcon={renderIcon} renderIcon={renderIcon}
detailsLinkProps={{ path: screenSizesRoute.path, search: (search) => search }} detailsLinkProps={{
path: screenSizesRoute.path,
search: (search) => search
}}
/> />
) )
} }
@ -290,21 +305,94 @@ export function screenSizeIconFor(screenSize) {
let svg = null let svg = null
if (screenSize === 'Mobile') { if (screenSize === 'Mobile') {
svg = <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="-mt-px feather"><rect x="5" y="2" width="14" height="20" rx="2" ry="2" /><line x1="12" y1="18" x2="12" y2="18" /></svg> svg = (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="-mt-px feather"
>
<rect x="5" y="2" width="14" height="20" rx="2" ry="2" />
<line x1="12" y1="18" x2="12" y2="18" />
</svg>
)
} else if (screenSize === 'Tablet') { } else if (screenSize === 'Tablet') {
svg = <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="-mt-px feather"><rect x="4" y="2" width="16" height="20" rx="2" ry="2" transform="rotate(180 12 12)" /><line x1="12" y1="18" x2="12" y2="18" /></svg> svg = (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="-mt-px feather"
>
<rect
x="4"
y="2"
width="16"
height="20"
rx="2"
ry="2"
transform="rotate(180 12 12)"
/>
<line x1="12" y1="18" x2="12" y2="18" />
</svg>
)
} else if (screenSize === 'Laptop') { } else if (screenSize === 'Laptop') {
svg = <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="-mt-px feather"><rect x="2" y="3" width="20" height="14" rx="2" ry="2" /><line x1="2" y1="20" x2="22" y2="20" /></svg> svg = (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="-mt-px feather"
>
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
<line x1="2" y1="20" x2="22" y2="20" />
</svg>
)
} else if (screenSize === 'Desktop') { } else if (screenSize === 'Desktop') {
svg = <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="-mt-px feather"><rect x="2" y="3" width="20" height="14" rx="2" ry="2" /><line x1="8" y1="21" x2="16" y2="21" /><line x1="12" y1="17" x2="12" y2="21" /></svg> svg = (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="-mt-px feather"
>
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
)
} }
return <span className="mr-1.5">{svg}</span> return <span className="mr-1.5">{svg}</span>
} }
export default function Devices() { export default function Devices() {
const { query } = useQueryContext(); const { query } = useQueryContext()
const site = useSiteContext(); const site = useSiteContext()
const tabKey = `deviceTab__${site.domain}` const tabKey = `deviceTab__${site.domain}`
const storedTab = storage.getItem(tabKey) const storedTab = storage.getItem(tabKey)
@ -347,9 +435,7 @@ export default function Devices() {
if (isActive) { if (isActive) {
return ( return (
<button <button className="inline-block h-5 font-bold text-indigo-700 active-prop-heading dark:text-indigo-500">
className="inline-block h-5 font-bold text-indigo-700 active-prop-heading dark:text-indigo-500"
>
{name} {name}
</button> </button>
) )
@ -370,7 +456,10 @@ export default function Devices() {
<div className="flex justify-between w-full"> <div className="flex justify-between w-full">
<div className="flex gap-x-1"> <div className="flex gap-x-1">
<h3 className="font-bold dark:text-gray-100">Devices</h3> <h3 className="font-bold dark:text-gray-100">Devices</h3>
<ImportedQueryUnsupportedWarning loading={loading} skipImportedReason={skipImportedReason} /> <ImportedQueryUnsupportedWarning
loading={loading}
skipImportedReason={skipImportedReason}
/>
</div> </div>
<div className="flex text-xs font-medium text-gray-500 dark:text-gray-400 space-x-2"> <div className="flex text-xs font-medium text-gray-500 dark:text-gray-400 space-x-2">
{renderPill('Browser', 'browser')} {renderPill('Browser', 'browser')}

View File

@ -1,6 +1,8 @@
import {parseUTCDate, formatMonthYYYY, formatDayShort} from '../../util/date' import { parseUTCDate, formatMonthYYYY, formatDayShort } from '../../util/date'
const browserDateFormat = Intl.DateTimeFormat(navigator.language, { hour: 'numeric' }) const browserDateFormat = Intl.DateTimeFormat(navigator.language, {
hour: 'numeric'
})
const is12HourClock = function () { const is12HourClock = function () {
return browserDateFormat.resolvedOptions().hour12 return browserDateFormat.resolvedOptions().hour12
@ -19,7 +21,9 @@ const monthIntervalFormatter = {
const weekIntervalFormatter = { const weekIntervalFormatter = {
long(isoDate, options) { long(isoDate, options) {
const formatted = this.short(isoDate, options) const formatted = this.short(isoDate, options)
return options.isBucketPartial ? `Partial week of ${formatted}` : `Week of ${formatted}` return options.isBucketPartial
? `Partial week of ${formatted}`
: `Week of ${formatted}`
}, },
short(isoDate, options) { short(isoDate, options) {
return formatDayShort(parseUTCDate(isoDate), options.shouldShowYear) return formatDayShort(parseUTCDate(isoDate), options.shouldShowYear)
@ -98,9 +102,20 @@ const factory = {
* @param {boolean} config.shouldShowYear - Should the year be appended to the date? * @param {boolean} config.shouldShowYear - Should the year be appended to the date?
* Defaults to false. Rendering year string is a newer opt-in feature to be enabled where needed. * Defaults to false. Rendering year string is a newer opt-in feature to be enabled where needed.
*/ */
export default function dateFormatter({ interval, longForm, period, isPeriodFull, shouldShowYear = false }) { export default function dateFormatter({
interval,
longForm,
period,
isPeriodFull,
shouldShowYear = false
}) {
const displayMode = longForm ? 'long' : 'short' const displayMode = longForm ? 'long' : 'short'
const options = { period: period, interval: interval, isBucketPartial: !isPeriodFull, shouldShowYear } const options = {
period: period,
interval: interval,
isBucketPartial: !isPeriodFull,
shouldShowYear
}
return function (isoDate, _index, _ticks) { return function (isoDate, _index, _ticks) {
return factory[interval][displayMode](isoDate, options) return factory[interval][displayMode](isoDate, options)
} }

View File

@ -5,64 +5,101 @@ import { METRIC_LABELS } from './graph-util'
import { MetricFormatterShort } from '../reports/metric-formatter' import { MetricFormatterShort } from '../reports/metric-formatter'
import { ChangeArrow } from '../reports/change-arrow' import { ChangeArrow } from '../reports/change-arrow'
const renderBucketLabel = function(query, graphData, label, comparison = false) { const renderBucketLabel = function (
query,
graphData,
label,
comparison = false
) {
let isPeriodFull = graphData.full_intervals?.[label] let isPeriodFull = graphData.full_intervals?.[label]
if (comparison) isPeriodFull = true if (comparison) isPeriodFull = true
const formattedLabel = dateFormatter({ const formattedLabel = dateFormatter({
interval: graphData.interval, longForm: true, period: query.period, isPeriodFull, interval: graphData.interval,
longForm: true,
period: query.period,
isPeriodFull
})(label) })(label)
if (query.period === 'realtime') { if (query.period === 'realtime') {
return dateFormatter({ return dateFormatter({
interval: graphData.interval, longForm: true, period: query.period, interval: graphData.interval,
longForm: true,
period: query.period
})(label) })(label)
} }
if (graphData.interval === 'hour' || graphData.interval == 'minute') { if (graphData.interval === 'hour' || graphData.interval == 'minute') {
const date = dateFormatter({ interval: "day", longForm: true, period: query.period })(label) const date = dateFormatter({
interval: 'day',
longForm: true,
period: query.period
})(label)
return `${date}, ${formattedLabel}` return `${date}, ${formattedLabel}`
} }
return formattedLabel return formattedLabel
} }
const calculatePercentageDifference = function(oldValue, newValue) { const calculatePercentageDifference = function (oldValue, newValue) {
if (oldValue == 0 && newValue > 0) { if (oldValue == 0 && newValue > 0) {
return 100 return 100
} else if (oldValue == 0 && newValue == 0) { } else if (oldValue == 0 && newValue == 0) {
return 0 return 0
} else { } else {
return Math.round((newValue - oldValue) / oldValue * 100) return Math.round(((newValue - oldValue) / oldValue) * 100)
} }
} }
const buildTooltipData = function(query, graphData, metric, tooltipModel) { const buildTooltipData = function (query, graphData, metric, tooltipModel) {
const data = tooltipModel.dataPoints.find((dataPoint) => dataPoint.dataset.yAxisID == "y") const data = tooltipModel.dataPoints.find(
const comparisonData = tooltipModel.dataPoints.find((dataPoint) => dataPoint.dataset.yAxisID == "yComparison") (dataPoint) => dataPoint.dataset.yAxisID == 'y'
)
const comparisonData = tooltipModel.dataPoints.find(
(dataPoint) => dataPoint.dataset.yAxisID == 'yComparison'
)
const label = data && renderBucketLabel(query, graphData, graphData.labels[data.dataIndex]) const label =
const comparisonLabel = comparisonData && renderBucketLabel(query, graphData, graphData.comparison_labels[comparisonData.dataIndex], true) data &&
renderBucketLabel(query, graphData, graphData.labels[data.dataIndex])
const comparisonLabel =
comparisonData &&
renderBucketLabel(
query,
graphData,
graphData.comparison_labels[comparisonData.dataIndex],
true
)
const value = graphData.plot[data.dataIndex] const value = graphData.plot[data.dataIndex]
const formatter = MetricFormatterShort[metric] const formatter = MetricFormatterShort[metric]
const comparisonValue = graphData.comparison_plot?.[comparisonData.dataIndex] const comparisonValue = graphData.comparison_plot?.[comparisonData.dataIndex]
const comparisonDifference = label && comparisonData && calculatePercentageDifference(comparisonValue, value) const comparisonDifference =
label &&
comparisonData &&
calculatePercentageDifference(comparisonValue, value)
const formattedValue = formatter(value) const formattedValue = formatter(value)
const formattedComparisonValue = comparisonData && formatter(comparisonValue) const formattedComparisonValue = comparisonData && formatter(comparisonValue)
return { label, formattedValue, comparisonLabel, formattedComparisonValue, comparisonDifference } return {
label,
formattedValue,
comparisonLabel,
formattedComparisonValue,
comparisonDifference
}
} }
let tooltipRoot let tooltipRoot
export default function GraphTooltip(graphData, metric, query) { export default function GraphTooltip(graphData, metric, query) {
return (context) => { return (context) => {
const tooltipModel = context.tooltip const tooltipModel = context.tooltip
const offset = document.getElementById("main-graph-canvas").getBoundingClientRect() const offset = document
.getElementById('main-graph-canvas')
.getBoundingClientRect()
let tooltipEl = document.getElementById('chartjs-tooltip') let tooltipEl = document.getElementById('chartjs-tooltip')
if (!tooltipEl) { if (!tooltipEl) {
@ -75,7 +112,8 @@ export default function GraphTooltip(graphData, metric, query) {
} }
if (tooltipEl && offset && window.innerWidth < 768) { if (tooltipEl && offset && window.innerWidth < 768) {
tooltipEl.style.top = offset.y + offset.height + window.scrollY + 15 + 'px' tooltipEl.style.top =
offset.y + offset.height + window.scrollY + 15 + 'px'
tooltipEl.style.left = offset.x + 'px' tooltipEl.style.left = offset.x + 'px'
tooltipEl.style.right = null tooltipEl.style.right = null
tooltipEl.style.opacity = 1 tooltipEl.style.opacity = 1
@ -87,26 +125,42 @@ export default function GraphTooltip(graphData, metric, query) {
} }
if (tooltipModel.body) { if (tooltipModel.body) {
const tooltipData = buildTooltipData(query, graphData, metric, tooltipModel) const tooltipData = buildTooltipData(
query,
graphData,
metric,
tooltipModel
)
tooltipRoot.render( tooltipRoot.render(
<aside className="text-gray-100 flex flex-col"> <aside className="text-gray-100 flex flex-col">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="font-semibold mr-4 text-lg">{METRIC_LABELS[metric]}</span> <span className="font-semibold mr-4 text-lg">
{METRIC_LABELS[metric]}
</span>
{tooltipData.comparisonDifference ? ( {tooltipData.comparisonDifference ? (
<div className="inline-flex items-center space-x-1"> <div className="inline-flex items-center space-x-1">
<ChangeArrow metric={metric} change={tooltipData.comparisonDifference} /> <ChangeArrow
</div>) : null} metric={metric}
change={tooltipData.comparisonDifference}
/>
</div>
) : null}
</div> </div>
{tooltipData.label ? ( {tooltipData.label ? (
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex flex-row justify-between items-center"> <div className="flex flex-row justify-between items-center">
<span className="flex items-center mr-4"> <span className="flex items-center mr-4">
<div className="w-3 h-3 mr-1 rounded-full" style={{ backgroundColor: "rgba(101,116,205)" }}></div> <div
className="w-3 h-3 mr-1 rounded-full"
style={{ backgroundColor: 'rgba(101,116,205)' }}
></div>
<span>{tooltipData.label}</span> <span>{tooltipData.label}</span>
</span> </span>
<span className="text-base font-bold">{tooltipData.formattedValue}</span> <span className="text-base font-bold">
{tooltipData.formattedValue}
</span>
</div> </div>
{tooltipData.comparisonLabel ? ( {tooltipData.comparisonLabel ? (
@ -115,13 +169,20 @@ export default function GraphTooltip(graphData, metric, query) {
<div className="w-3 h-3 mr-1 rounded-full bg-gray-500"></div> <div className="w-3 h-3 mr-1 rounded-full bg-gray-500"></div>
<span>{tooltipData.comparisonLabel}</span> <span>{tooltipData.comparisonLabel}</span>
</span> </span>
<span className="text-base font-bold">{tooltipData.formattedComparisonValue}</span> <span className="text-base font-bold">
</div>) : null} {tooltipData.formattedComparisonValue}
</span>
</div>
) : null}
</div> </div>
) : null} ) : null}
{graphData.interval === "month" ? (<span className="font-semibold italic">Click to view month</span>) : null} {graphData.interval === 'month' ? (
{graphData.interval === "day" ? (<span className="font-semibold italic">Click to view day</span>) : null} <span className="font-semibold italic">Click to view month</span>
) : null}
{graphData.interval === 'day' ? (
<span className="font-semibold italic">Click to view day</span>
) : null}
</aside> </aside>
) )
} }

View File

@ -1,17 +1,17 @@
export const METRIC_LABELS = { export const METRIC_LABELS = {
'visitors': 'Visitors', visitors: 'Visitors',
'pageviews': 'Pageviews', pageviews: 'Pageviews',
'events': 'Total Conversions', events: 'Total Conversions',
'views_per_visit': 'Views per Visit', views_per_visit: 'Views per Visit',
'visits': 'Visits', visits: 'Visits',
'bounce_rate': 'Bounce Rate', bounce_rate: 'Bounce Rate',
'visit_duration': 'Visit Duration', visit_duration: 'Visit Duration',
'conversions': 'Converted Visitors', conversions: 'Converted Visitors',
'conversion_rate': 'Conversion Rate', conversion_rate: 'Conversion Rate',
'average_revenue': 'Average Revenue', average_revenue: 'Average Revenue',
'total_revenue': 'Total Revenue', total_revenue: 'Total Revenue',
'scroll_depth': 'Scroll Depth', scroll_depth: 'Scroll Depth',
'time_on_page': 'Time on Page', time_on_page: 'Time on Page'
} }
function plottable(dataArray) { function plottable(dataArray) {
@ -25,54 +25,73 @@ function plottable(dataArray) {
}) })
} }
const buildComparisonDataset = function(comparisonPlot) { const buildComparisonDataset = function (comparisonPlot) {
if (!comparisonPlot) return [] if (!comparisonPlot) return []
return [{ return [
{
data: plottable(comparisonPlot), data: plottable(comparisonPlot),
borderColor: 'rgba(60,70,110,0.2)', borderColor: 'rgba(60,70,110,0.2)',
pointBackgroundColor: 'rgba(60,70,110,0.2)', pointBackgroundColor: 'rgba(60,70,110,0.2)',
pointHoverBackgroundColor: 'rgba(60, 70, 110)', pointHoverBackgroundColor: 'rgba(60, 70, 110)',
yAxisID: 'yComparison', yAxisID: 'yComparison'
}] }
]
} }
const buildDashedDataset = function(plot, presentIndex) { const buildDashedDataset = function (plot, presentIndex) {
if (!presentIndex) return [] if (!presentIndex) return []
const dashedPart = plot.slice(presentIndex - 1, presentIndex + 1); const dashedPart = plot.slice(presentIndex - 1, presentIndex + 1)
const dashedPlot = (new Array(presentIndex - 1)).concat(dashedPart) const dashedPlot = new Array(presentIndex - 1).concat(dashedPart)
return [{ return [
{
data: plottable(dashedPlot), data: plottable(dashedPlot),
borderDash: [3, 3], borderDash: [3, 3],
borderColor: 'rgba(101,116,205)', borderColor: 'rgba(101,116,205)',
pointHoverBackgroundColor: 'rgba(71, 87, 193)', pointHoverBackgroundColor: 'rgba(71, 87, 193)',
yAxisID: 'y', yAxisID: 'y'
}] }
]
} }
const buildMainPlotDataset = function(plot, presentIndex) { const buildMainPlotDataset = function (plot, presentIndex) {
const data = presentIndex ? plot.slice(0, presentIndex) : plot const data = presentIndex ? plot.slice(0, presentIndex) : plot
return [{ return [
{
data: plottable(data), data: plottable(data),
borderColor: 'rgba(101,116,205)', borderColor: 'rgba(101,116,205)',
pointBackgroundColor: 'rgba(101,116,205)', pointBackgroundColor: 'rgba(101,116,205)',
pointHoverBackgroundColor: 'rgba(71, 87, 193)', pointHoverBackgroundColor: 'rgba(71, 87, 193)',
yAxisID: 'y', yAxisID: 'y'
}] }
]
} }
export const buildDataSet = (plot, comparisonPlot, present_index, ctx, label) => { export const buildDataSet = (
var gradient = ctx.createLinearGradient(0, 0, 0, 300); plot,
var prev_gradient = ctx.createLinearGradient(0, 0, 0, 300); comparisonPlot,
gradient.addColorStop(0, 'rgba(101,116,205, 0.2)'); present_index,
gradient.addColorStop(1, 'rgba(101,116,205, 0)'); ctx,
prev_gradient.addColorStop(0, 'rgba(101,116,205, 0.075)'); label
prev_gradient.addColorStop(1, 'rgba(101,116,205, 0)'); ) => {
var gradient = ctx.createLinearGradient(0, 0, 0, 300)
var prev_gradient = ctx.createLinearGradient(0, 0, 0, 300)
gradient.addColorStop(0, 'rgba(101,116,205, 0.2)')
gradient.addColorStop(1, 'rgba(101,116,205, 0)')
prev_gradient.addColorStop(0, 'rgba(101,116,205, 0.075)')
prev_gradient.addColorStop(1, 'rgba(101,116,205, 0)')
const defaultOptions = { label, borderWidth: 3, pointBorderColor: "transparent", pointHoverRadius: 4, backgroundColor: gradient, fill: true } const defaultOptions = {
label,
borderWidth: 3,
pointBorderColor: 'transparent',
pointHoverRadius: 4,
backgroundColor: gradient,
fill: true
}
const dataset = [ const dataset = [
...buildMainPlotDataset(plot, present_index), ...buildMainPlotDataset(plot, present_index),

View File

@ -1,21 +1,26 @@
import React, { Fragment, useRef } from 'react'; import React, { Fragment, useRef } from 'react'
import { Menu, Transition } from '@headlessui/react'; import { Menu, Transition } from '@headlessui/react'
import { ChevronDownIcon } from '@heroicons/react/20/solid'; import { ChevronDownIcon } from '@heroicons/react/20/solid'
import classNames from 'classnames'; import classNames from 'classnames'
import * as storage from '../../util/storage'; import * as storage from '../../util/storage'
import { BlurMenuButtonOnEscape, isModifierPressed, isTyping, Keybind } from '../../keybinding'; import {
import { useQueryContext } from '../../query-context'; BlurMenuButtonOnEscape,
import { useSiteContext } from '../../site-context'; isModifierPressed,
import { useMatch } from 'react-router-dom'; isTyping,
import { rootRoute } from '../../router'; Keybind
import { popover } from '../../components/popover'; } from '../../keybinding'
import { useQueryContext } from '../../query-context'
import { useSiteContext } from '../../site-context'
import { useMatch } from 'react-router-dom'
import { rootRoute } from '../../router'
import { popover } from '../../components/popover'
const INTERVAL_LABELS = { const INTERVAL_LABELS = {
'minute': 'Minutes', minute: 'Minutes',
'hour': 'Hours', hour: 'Hours',
'day': 'Days', day: 'Days',
'week': 'Weeks', week: 'Weeks',
'month': 'Months' month: 'Months'
} }
function validIntervals(site, query) { function validIntervals(site, query) {
@ -36,11 +41,11 @@ function validIntervals(site, query) {
function getDefaultInterval(query, validIntervals) { function getDefaultInterval(query, validIntervals) {
const defaultByPeriod = { const defaultByPeriod = {
'day': 'hour', day: 'hour',
'7d': 'day', '7d': 'day',
'6mo': 'month', '6mo': 'month',
'12mo': 'month', '12mo': 'month',
'year': 'month' year: 'month'
} }
if (query.period === 'custom') { if (query.period === 'custom') {
@ -74,7 +79,7 @@ function storeInterval(period, domain, interval) {
storage.setItem(`interval__${period}__${domain}`, interval) storage.setItem(`interval__${period}__${domain}`, interval)
} }
export const getCurrentInterval = function(site, query) { export const getCurrentInterval = function (site, query) {
const options = validIntervals(site, query) const options = validIntervals(site, query)
const storedInterval = getStoredInterval(query.period, site.domain) const storedInterval = getStoredInterval(query.period, site.domain)
@ -89,8 +94,8 @@ export const getCurrentInterval = function(site, query) {
export function IntervalPicker({ onIntervalUpdate }) { export function IntervalPicker({ onIntervalUpdate }) {
const menuElement = useRef(null) const menuElement = useRef(null)
const {query} = useQueryContext(); const { query } = useQueryContext()
const site = useSiteContext(); const site = useSiteContext()
const dashboardRouteMatch = useMatch(rootRoute.path) const dashboardRouteMatch = useMatch(rootRoute.path)
if (query.period == 'realtime') return null if (query.period == 'realtime') return null
@ -98,7 +103,6 @@ export function IntervalPicker({ onIntervalUpdate }) {
const options = validIntervals(site, query) const options = validIntervals(site, query)
const currentInterval = getCurrentInterval(site, query) const currentInterval = getCurrentInterval(site, query)
function updateInterval(interval) { function updateInterval(interval) {
storeInterval(query.period, site.domain, interval) storeInterval(query.period, site.domain, interval)
onIntervalUpdate(interval) onIntervalUpdate(interval)
@ -106,13 +110,23 @@ export function IntervalPicker({ onIntervalUpdate }) {
function renderDropdownItem(option) { function renderDropdownItem(option) {
return ( return (
<Menu.Item onClick={() => updateInterval(option)} key={option} disabled={option == currentInterval}> <Menu.Item
onClick={() => updateInterval(option)}
key={option}
disabled={option == currentInterval}
>
{({ active }) => ( {({ active }) => (
<span className={classNames({ <span
'bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-200 cursor-pointer': active, className={classNames(
{
'bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-200 cursor-pointer':
active,
'text-gray-700 dark:text-gray-200': !active, 'text-gray-700 dark:text-gray-200': !active,
'font-bold cursor-none select-none': option == currentInterval, 'font-bold cursor-none select-none': option == currentInterval
}, 'block px-4 py-2 text-sm')}> },
'block px-4 py-2 text-sm'
)}
>
{INTERVAL_LABELS[option]} {INTERVAL_LABELS[option]}
</span> </span>
)} )}
@ -144,11 +158,7 @@ export function IntervalPicker({ onIntervalUpdate }) {
<ChevronDownIcon className="ml-1 h-4 w-4" aria-hidden="true" /> <ChevronDownIcon className="ml-1 h-4 w-4" aria-hidden="true" />
</Menu.Button> </Menu.Button>
<Transition <Transition as={Fragment} show={open} {...popover.transition.props}>
as={Fragment}
show={open}
{...popover.transition.props}
>
<Menu.Items <Menu.Items
className="py-1 text-left origin-top-right absolute right-0 mt-2 w-56 rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 focus:outline-none z-10" className="py-1 text-left origin-top-right absolute right-0 mt-2 w-56 rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 focus:outline-none z-10"
static static

View File

@ -1,16 +1,16 @@
import React from 'react'; import React from 'react'
import { useAppNavigate } from '../../navigation/use-app-navigate'; import { useAppNavigate } from '../../navigation/use-app-navigate'
import { useQueryContext } from '../../query-context'; import { useQueryContext } from '../../query-context'
import Chart from 'chart.js/auto'; import Chart from 'chart.js/auto'
import GraphTooltip from './graph-tooltip' import GraphTooltip from './graph-tooltip'
import { buildDataSet, METRIC_LABELS } from './graph-util' import { buildDataSet, METRIC_LABELS } from './graph-util'
import dateFormatter from './date-formatter'; import dateFormatter from './date-formatter'
import FadeIn from '../../fade-in'; import FadeIn from '../../fade-in'
import classNames from 'classnames'; import classNames from 'classnames'
import { hasConversionGoalFilter } from '../../util/filters'; import { hasConversionGoalFilter } from '../../util/filters'
import { MetricFormatterShort } from '../reports/metric-formatter' import { MetricFormatterShort } from '../reports/metric-formatter'
const calculateMaximumY = function(dataset) { const calculateMaximumY = function (dataset) {
const yAxisValues = dataset const yAxisValues = dataset
.flatMap((item) => item.data) .flatMap((item) => item.data)
.map((item) => item || 0) .map((item) => item || 0)
@ -24,9 +24,9 @@ const calculateMaximumY = function(dataset) {
class LineGraph extends React.Component { class LineGraph extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props)
this.regenerateChart = this.regenerateChart.bind(this); this.regenerateChart = this.regenerateChart.bind(this)
this.updateWindowDimensions = this.updateWindowDimensions.bind(this); this.updateWindowDimensions = this.updateWindowDimensions.bind(this)
} }
getGraphMetric() { getGraphMetric() {
@ -42,9 +42,15 @@ class LineGraph extends React.Component {
regenerateChart() { regenerateChart() {
const { graphData, query } = this.props const { graphData, query } = this.props
const metric = this.getGraphMetric() const metric = this.getGraphMetric()
const graphEl = document.getElementById("main-graph-canvas") const graphEl = document.getElementById('main-graph-canvas')
this.ctx = graphEl.getContext('2d'); this.ctx = graphEl.getContext('2d')
const dataSet = buildDataSet(graphData.plot, graphData.comparison_plot, graphData.present_index, this.ctx, METRIC_LABELS[metric]) const dataSet = buildDataSet(
graphData.plot,
graphData.comparison_plot,
graphData.present_index,
this.ctx,
METRIC_LABELS[metric]
)
return new Chart(this.ctx, { return new Chart(this.ctx, {
type: 'line', type: 'line',
@ -62,7 +68,7 @@ class LineGraph extends React.Component {
intersect: false, intersect: false,
position: 'average', position: 'average',
external: GraphTooltip(graphData, metric, query) external: GraphTooltip(graphData, metric, query)
}, }
}, },
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
@ -82,41 +88,42 @@ class LineGraph extends React.Component {
}, },
grid: { grid: {
zeroLineColor: 'transparent', zeroLineColor: 'transparent',
drawBorder: false, drawBorder: false
} }
}, },
yComparison: { yComparison: {
min: 0, min: 0,
suggestedMax: calculateMaximumY(dataSet), suggestedMax: calculateMaximumY(dataSet),
display: false, display: false,
grid: { display: false }, grid: { display: false }
}, },
x: { x: {
grid: { display: false }, grid: { display: false },
ticks: { ticks: {
callback: function(val, _index, _ticks) { callback: function (val, _index, _ticks) {
if (this.getLabelForValue(val) == "__blank__") return "" if (this.getLabelForValue(val) == '__blank__') return ''
const hasMultipleYears = const hasMultipleYears =
graphData.labels graphData.labels
.filter((date) => typeof date === 'string') .filter((date) => typeof date === 'string')
.map(date => date.split('-')[0]) .map((date) => date.split('-')[0])
.filter((value, index, list) => list.indexOf(value) === index) .filter(
.length > 1 (value, index, list) => list.indexOf(value) === index
).length > 1
if (graphData.interval === 'hour' && query.period !== 'day') { if (graphData.interval === 'hour' && query.period !== 'day') {
const date = dateFormatter({ const date = dateFormatter({
interval: "day", interval: 'day',
longForm: false, longForm: false,
period: query.period, period: query.period,
shouldShowYear: hasMultipleYears, shouldShowYear: hasMultipleYears
})(this.getLabelForValue(val)) })(this.getLabelForValue(val))
const hour = dateFormatter({ const hour = dateFormatter({
interval: graphData.interval, interval: graphData.interval,
longForm: false, longForm: false,
period: query.period, period: query.period,
shouldShowYear: hasMultipleYears, shouldShowYear: hasMultipleYears
})(this.getLabelForValue(val)) })(this.getLabelForValue(val))
// Returns a combination of date and hour. This is because // Returns a combination of date and hour. This is because
@ -125,14 +132,22 @@ class LineGraph extends React.Component {
return `${date}, ${hour}` return `${date}, ${hour}`
} }
if (graphData.interval === 'minute' && query.period !== 'realtime') { if (
graphData.interval === 'minute' &&
query.period !== 'realtime'
) {
return dateFormatter({ return dateFormatter({
interval: "hour", longForm: false, period: query.period, interval: 'hour',
longForm: false,
period: query.period
})(this.getLabelForValue(val)) })(this.getLabelForValue(val))
} }
return dateFormatter({ return dateFormatter({
interval: graphData.interval, longForm: false, period: query.period, shouldShowYear: hasMultipleYears, interval: graphData.interval,
longForm: false,
period: query.period,
shouldShowYear: hasMultipleYears
})(this.getLabelForValue(val)) })(this.getLabelForValue(val))
}, },
color: this.props.darkTheme ? 'rgb(243, 244, 246)' : undefined color: this.props.darkTheme ? 'rgb(243, 244, 246)' : undefined
@ -141,73 +156,73 @@ class LineGraph extends React.Component {
}, },
interaction: { interaction: {
mode: 'index', mode: 'index',
intersect: false, intersect: false
} }
} }
}); })
} }
repositionTooltip(e) { repositionTooltip(e) {
const tooltipEl = document.getElementById('chartjs-tooltip'); const tooltipEl = document.getElementById('chartjs-tooltip')
if (tooltipEl && window.innerWidth >= 768) { if (tooltipEl && window.innerWidth >= 768) {
if (e.clientX > 0.66 * window.innerWidth) { if (e.clientX > 0.66 * window.innerWidth) {
tooltipEl.style.right = (window.innerWidth - e.clientX) + window.pageXOffset + 'px' tooltipEl.style.right =
tooltipEl.style.left = null; window.innerWidth - e.clientX + window.pageXOffset + 'px'
tooltipEl.style.left = null
} else { } else {
tooltipEl.style.right = null; tooltipEl.style.right = null
tooltipEl.style.left = e.clientX + window.pageXOffset + 'px' tooltipEl.style.left = e.clientX + window.pageXOffset + 'px'
} }
tooltipEl.style.top = e.clientY + window.pageYOffset + 'px' tooltipEl.style.top = e.clientY + window.pageYOffset + 'px'
tooltipEl.style.opacity = 1; tooltipEl.style.opacity = 1
} }
} }
componentDidMount() { componentDidMount() {
if (this.props.graphData) { if (this.props.graphData) {
this.chart = this.regenerateChart(); this.chart = this.regenerateChart()
} }
window.addEventListener('mousemove', this.repositionTooltip); window.addEventListener('mousemove', this.repositionTooltip)
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
const { graphData, darkTheme } = this.props; const { graphData, darkTheme } = this.props
const tooltip = document.getElementById('chartjs-tooltip'); const tooltip = document.getElementById('chartjs-tooltip')
if ( if (
graphData !== prevProps.graphData || graphData !== prevProps.graphData ||
darkTheme !== prevProps.darkTheme darkTheme !== prevProps.darkTheme
) { ) {
if (graphData) { if (graphData) {
if (this.chart) { if (this.chart) {
this.chart.destroy(); this.chart.destroy()
} }
this.chart = this.regenerateChart(); this.chart = this.regenerateChart()
this.chart.update(); this.chart.update()
} }
if (tooltip) { if (tooltip) {
tooltip.style.display = 'none'; tooltip.style.display = 'none'
} }
} }
if (!graphData) { if (!graphData) {
if (this.chart) { if (this.chart) {
this.chart.destroy(); this.chart.destroy()
} }
if (tooltip) { if (tooltip) {
tooltip.style.display = 'none'; tooltip.style.display = 'none'
} }
} }
} }
componentWillUnmount() { componentWillUnmount() {
// Ensure that the tooltip doesn't hang around when we are loading more data // Ensure that the tooltip doesn't hang around when we are loading more data
const tooltip = document.getElementById('chartjs-tooltip'); const tooltip = document.getElementById('chartjs-tooltip')
if (tooltip) { if (tooltip) {
tooltip.style.opacity = 0; tooltip.style.opacity = 0
tooltip.style.display = 'none'; tooltip.style.display = 'none'
} }
window.removeEventListener('mousemove', this.repositionTooltip) window.removeEventListener('mousemove', this.repositionTooltip)
} }
@ -222,8 +237,12 @@ class LineGraph extends React.Component {
} }
maybeHopToHoveredPeriod(e) { maybeHopToHoveredPeriod(e) {
const element = this.chart.getElementsAtEventForMode(e, 'index', { intersect: false })[0] const element = this.chart.getElementsAtEventForMode(e, 'index', {
const date = this.props.graphData.labels[element.index] || this.props.graphData.comparison_labels[element.index] intersect: false
})[0]
const date =
this.props.graphData.labels[element.index] ||
this.props.graphData.comparison_labels[element.index]
if (this.props.graphData.interval === 'month') { if (this.props.graphData.interval === 'month') {
this.props.navigate({ this.props.navigate({
@ -238,7 +257,9 @@ class LineGraph extends React.Component {
render() { render() {
const { graphData } = this.props const { graphData } = this.props
const canvasClass = classNames('mt-4 select-none', { 'cursor-pointer': !['minute', 'hour'].includes(graphData?.interval) }) const canvasClass = classNames('mt-4 select-none', {
'cursor-pointer': !['minute', 'hour'].includes(graphData?.interval)
})
return ( return (
<FadeIn show={graphData}> <FadeIn show={graphData}>

View File

@ -1,13 +1,27 @@
import React from "react" import React from 'react'
export default function SamplingNotice({topStatData}) { export default function SamplingNotice({ topStatData }) {
const samplePercent = topStatData?.samplePercent const samplePercent = topStatData?.samplePercent
if (samplePercent && samplePercent < 100) { if (samplePercent && samplePercent < 100) {
return ( return (
<div tooltip={`Stats based on a ${samplePercent}% sample of all visitors`} className="cursor-pointer w-4 h-4 mx-2"> <div
<svg className="absolute w-4 h-4 dark:text-gray-300 text-gray-700" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"> tooltip={`Stats based on a ${samplePercent}% sample of all visitors`}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> className="cursor-pointer w-4 h-4 mx-2"
>
<svg
className="absolute w-4 h-4 dark:text-gray-300 text-gray-700"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg> </svg>
</div> </div>
) )

View File

@ -1,17 +1,17 @@
import React, { useState } from "react"; import React, { useState } from 'react'
import * as api from '../../api'; import * as api from '../../api'
import { getCurrentInterval } from "./interval-picker"; import { getCurrentInterval } from './interval-picker'
import { useSiteContext } from "../../site-context"; import { useSiteContext } from '../../site-context'
import { useQueryContext } from "../../query-context"; import { useQueryContext } from '../../query-context'
export default function StatsExport() { export default function StatsExport() {
const site = useSiteContext(); const site = useSiteContext()
const { query } = useQueryContext(); const { query } = useQueryContext()
const [exporting, setExporting] = useState(false) const [exporting, setExporting] = useState(false)
function startExport() { function startExport() {
setExporting(true) setExporting(true)
document.cookie = "exporting=" document.cookie = 'exporting='
pollExportReady() pollExportReady()
} }
@ -25,21 +25,48 @@ export default function StatsExport() {
function renderLoading() { function renderLoading() {
return ( return (
<svg className="animate-spin h-4 w-4 text-indigo-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> <svg
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> className="animate-spin h-4 w-4 text-indigo-500"
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg> </svg>
) )
} }
function renderExportLink() { function renderExportLink() {
const interval = getCurrentInterval(site, query) const interval = getCurrentInterval(site, query)
const queryParams = api.queryToSearchParams(query, [{ interval, comparison: undefined }]) const queryParams = api.queryToSearchParams(query, [
{ interval, comparison: undefined }
])
const endpoint = `/${encodeURIComponent(site.domain)}/export?${queryParams}` const endpoint = `/${encodeURIComponent(site.domain)}/export?${queryParams}`
return ( return (
<a href={endpoint} download onClick={startExport}> <a href={endpoint} download onClick={startExport}>
<svg className="absolute text-gray-700 feather dark:text-gray-300 hover:text-indigo-600 dark:hover:text-indigo-600" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg
className="absolute text-gray-700 feather dark:text-gray-300 hover:text-indigo-600 dark:hover:text-indigo-600"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline> <polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line> <line x1="12" y1="15" x2="12" y2="3"></line>

View File

@ -1,5 +1,3 @@
/** @format */
import React from 'react' import React from 'react'
import { Tooltip } from '../../util/tooltip' import { Tooltip } from '../../util/tooltip'
import { SecondsSinceLastLoad } from '../../util/seconds-since-last-load' import { SecondsSinceLastLoad } from '../../util/seconds-since-last-load'

View File

@ -1,18 +1,18 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react'
import * as api from '../../api'; import * as api from '../../api'
import * as storage from '../../util/storage'; import * as storage from '../../util/storage'
import TopStats from './top-stats'; import TopStats from './top-stats'
import { IntervalPicker, getCurrentInterval } from './interval-picker'; import { IntervalPicker, getCurrentInterval } from './interval-picker'
import StatsExport from './stats-export'; import StatsExport from './stats-export'
import WithImportedSwitch from './with-imported-switch'; import WithImportedSwitch from './with-imported-switch'
import SamplingNotice from './sampling-notice'; import SamplingNotice from './sampling-notice'
import FadeIn from '../../fade-in'; import FadeIn from '../../fade-in'
import * as url from '../../util/url'; import * as url from '../../util/url'
import { isComparisonEnabled } from '../../query-time-periods'; import { isComparisonEnabled } from '../../query-time-periods'
import LineGraphWithRouter from './line-graph'; import LineGraphWithRouter from './line-graph'
import { useQueryContext } from '../../query-context'; import { useQueryContext } from '../../query-context'
import { useSiteContext } from '../../site-context'; import { useSiteContext } from '../../site-context'
import { ExclamationCircleIcon } from '@heroicons/react/24/outline' import { ExclamationCircleIcon } from '@heroicons/react/24/outline'
function fetchTopStats(site, query) { function fetchTopStats(site, query) {
@ -31,11 +31,12 @@ function fetchMainGraph(site, query, metric, interval) {
} }
export default function VisitorGraph({ updateImportedDataInView }) { export default function VisitorGraph({ updateImportedDataInView }) {
const { query } = useQueryContext(); const { query } = useQueryContext()
const site = useSiteContext(); const site = useSiteContext()
const isRealtime = query.period === 'realtime' const isRealtime = query.period === 'realtime'
const isDarkTheme = document.querySelector('html').classList.contains('dark') || false const isDarkTheme =
document.querySelector('html').classList.contains('dark') || false
const topStatsBoundary = useRef(null) const topStatsBoundary = useRef(null)
@ -50,18 +51,23 @@ export default function VisitorGraph({ updateImportedDataInView }) {
// long as new graph data is fetched. // long as new graph data is fetched.
const [graphRefreshing, setGraphRefreshing] = useState(false) const [graphRefreshing, setGraphRefreshing] = useState(false)
const onIntervalUpdate = useCallback(
const onIntervalUpdate = useCallback((newInterval) => { (newInterval) => {
setGraphData(null) setGraphData(null)
setGraphRefreshing(true) setGraphRefreshing(true)
fetchGraphData(getStoredMetric(), newInterval) fetchGraphData(getStoredMetric(), newInterval)
}, [query]) },
[query]
)
const onMetricUpdate = useCallback((newMetric) => { const onMetricUpdate = useCallback(
(newMetric) => {
setGraphData(null) setGraphData(null)
setGraphRefreshing(true) setGraphRefreshing(true)
fetchGraphData(newMetric, getCurrentInterval(site, query)) fetchGraphData(newMetric, getCurrentInterval(site, query))
}, [query]) },
[query]
)
useEffect(() => { useEffect(() => {
setTopStatData(null) setTopStatData(null)
@ -80,7 +86,9 @@ export default function VisitorGraph({ updateImportedDataInView }) {
}, [query]) }, [query])
useEffect(() => { useEffect(() => {
if (topStatData) { storeTopStatsContainerHeight() } if (topStatData) {
storeTopStatsContainerHeight()
}
}, [topStatData]) }, [topStatData])
async function fetchTopStatsAndGraphData() { async function fetchTopStatsAndGraphData() {
@ -107,8 +115,7 @@ export default function VisitorGraph({ updateImportedDataInView }) {
} }
function fetchGraphData(metric, interval) { function fetchGraphData(metric, interval) {
fetchMainGraph(site, query, metric, interval) fetchMainGraph(site, query, metric, interval).then((res) => {
.then((res) => {
setGraphData(res) setGraphData(res)
setGraphLoading(false) setGraphLoading(false)
setGraphRefreshing(false) setGraphRefreshing(false)
@ -120,7 +127,10 @@ export default function VisitorGraph({ updateImportedDataInView }) {
} }
function storeTopStatsContainerHeight() { function storeTopStatsContainerHeight() {
storage.setItem(`topStatsHeight__${site.domain}`, document.getElementById('top-stats-container').clientHeight) storage.setItem(
`topStatsHeight__${site.domain}`,
document.getElementById('top-stats-container').clientHeight
)
} }
// This function is used for maintaining the main-graph/top-stats container height in the // This function is used for maintaining the main-graph/top-stats container height in the
@ -136,16 +146,25 @@ export default function VisitorGraph({ updateImportedDataInView }) {
} }
function importedSwitchVisible() { function importedSwitchVisible() {
return !!topStatData?.with_imported_switch && topStatData?.with_imported_switch.visible return (
!!topStatData?.with_imported_switch &&
topStatData?.with_imported_switch.visible
)
} }
function renderImportedIntervalUnsupportedWarning() { function renderImportedIntervalUnsupportedWarning() {
const unsupportedInterval = ['hour', 'minute'].includes(getCurrentInterval(site, query)) const unsupportedInterval = ['hour', 'minute'].includes(
const showingImported = importedSwitchVisible() && query.with_imported === true getCurrentInterval(site, query)
)
const showingImported =
importedSwitchVisible() && query.with_imported === true
return ( return (
<FadeIn show={showingImported && unsupportedInterval} className="h-6 mr-1"> <FadeIn
<span tooltip={"Interval is too short to graph imported data"}> show={showingImported && unsupportedInterval}
className="h-6 mr-1"
>
<span tooltip={'Interval is too short to graph imported data'}>
<ExclamationCircleIcon className="w-6 h-6 text-gray-700 dark:text-gray-300" /> <ExclamationCircleIcon className="w-6 h-6 text-gray-700 dark:text-gray-300" />
</span> </span>
</FadeIn> </FadeIn>
@ -153,10 +172,19 @@ export default function VisitorGraph({ updateImportedDataInView }) {
} }
return ( return (
<div className={"relative w-full mt-2 bg-white rounded shadow-xl dark:bg-gray-825"}> <div
className={
'relative w-full mt-2 bg-white rounded shadow-xl dark:bg-gray-825'
}
>
{(topStatsLoading || graphLoading) && renderLoader()} {(topStatsLoading || graphLoading) && renderLoader()}
<FadeIn show={!(topStatsLoading || graphLoading)}> <FadeIn show={!(topStatsLoading || graphLoading)}>
<div id="top-stats-container" className="flex flex-wrap" ref={topStatsBoundary} style={{ height: getTopStatsHeight() }}> <div
id="top-stats-container"
className="flex flex-wrap"
ref={topStatsBoundary}
style={{ height: getTopStatsHeight() }}
>
<TopStats <TopStats
graphableMetrics={topStatData?.graphable_metrics || []} graphableMetrics={topStatData?.graphable_metrics || []}
data={topStatData} data={topStatData}
@ -170,16 +198,19 @@ export default function VisitorGraph({ updateImportedDataInView }) {
{renderImportedIntervalUnsupportedWarning()} {renderImportedIntervalUnsupportedWarning()}
{!isRealtime && <StatsExport />} {!isRealtime && <StatsExport />}
<SamplingNotice samplePercent={topStatData} /> <SamplingNotice samplePercent={topStatData} />
{importedSwitchVisible() && {importedSwitchVisible() && (
<WithImportedSwitch <WithImportedSwitch
tooltipMessage={topStatData.with_imported_switch.tooltip_msg} tooltipMessage={topStatData.with_imported_switch.tooltip_msg}
disabled={!topStatData.with_imported_switch.togglable} disabled={!topStatData.with_imported_switch.togglable}
/> />
} )}
<IntervalPicker onIntervalUpdate={onIntervalUpdate} /> <IntervalPicker onIntervalUpdate={onIntervalUpdate} />
</div> </div>
<LineGraphWithRouter <LineGraphWithRouter
graphData={{...graphData, interval: getCurrentInterval(site, query)}} graphData={{
...graphData,
interval: getCurrentInterval(site, query)
}}
darkTheme={isDarkTheme} darkTheme={isDarkTheme}
/> />
</div> </div>

View File

@ -1,21 +1,27 @@
import React from "react" import React from 'react'
import { BarsArrowUpIcon } from '@heroicons/react/20/solid' import { BarsArrowUpIcon } from '@heroicons/react/20/solid'
import classNames from "classnames" import classNames from 'classnames'
import { useQueryContext } from "../../query-context" import { useQueryContext } from '../../query-context'
import { AppNavigationLink } from "../../navigation/use-app-navigate" import { AppNavigationLink } from '../../navigation/use-app-navigate'
export default function WithImportedSwitch({ tooltipMessage, disabled }) { export default function WithImportedSwitch({ tooltipMessage, disabled }) {
const { query } = useQueryContext(); const { query } = useQueryContext()
const importsSwitchedOn = query.with_imported; const importsSwitchedOn = query.with_imported
const iconClass = classNames("mt-0.5", { const iconClass = classNames('mt-0.5', {
"dark:text-gray-300 text-gray-700": importsSwitchedOn, 'dark:text-gray-300 text-gray-700': importsSwitchedOn,
"dark:text-gray-500 text-gray-400": !importsSwitchedOn, 'dark:text-gray-500 text-gray-400': !importsSwitchedOn
}) })
return ( return (
<div tooltip={tooltipMessage} className="w-4 h-4 mx-2"> <div tooltip={tooltipMessage} className="w-4 h-4 mx-2">
<AppNavigationLink search={disabled ? (search) => search : (search) => ({...search, with_imported: !importsSwitchedOn}) }> <AppNavigationLink
search={
disabled
? (search) => search
: (search) => ({ ...search, with_imported: !importsSwitchedOn })
}
>
<BarsArrowUpIcon className={iconClass} /> <BarsArrowUpIcon className={iconClass} />
</AppNavigationLink> </AppNavigationLink>
</div> </div>

View File

@ -1,12 +1,22 @@
import React from "react"; import React from 'react'
import { ExclamationCircleIcon } from '@heroicons/react/24/outline' import { ExclamationCircleIcon } from '@heroicons/react/24/outline'
import FadeIn from "../fade-in"; import FadeIn from '../fade-in'
import { useQueryContext } from "../query-context"; import { useQueryContext } from '../query-context'
export default function ImportedQueryUnsupportedWarning({ loading, skipImportedReason, altCondition, message }) { export default function ImportedQueryUnsupportedWarning({
const { query } = useQueryContext(); loading,
const tooltipMessage = message || "Imported data is excluded due to applied filters" skipImportedReason,
const show = query && query.with_imported && skipImportedReason === "unsupported_query" && query.period !== 'realtime' altCondition,
message
}) {
const { query } = useQueryContext()
const tooltipMessage =
message || 'Imported data is excluded due to applied filters'
const show =
query &&
query.with_imported &&
skipImportedReason === 'unsupported_query' &&
query.period !== 'realtime'
if (show || altCondition) { if (show || altCondition) {
return ( return (

View File

@ -1,4 +1,3 @@
/* @format */
import React from 'react' import React from 'react'
export const GeolocationNotice = () => { export const GeolocationNotice = () => {

View File

@ -1,17 +1,20 @@
import React from 'react'; import React from 'react'
import * as storage from '../../util/storage'; import * as storage from '../../util/storage'
import CountriesMap from './map'; import CountriesMap from './map'
import * as api from '../../api'; import * as api from '../../api'
import { apiPath } from '../../util/url'; import { apiPath } from '../../util/url'
import ListReport from '../reports/list'; import ListReport from '../reports/list'
import * as metrics from '../reports/metrics'; import * as metrics from '../reports/metrics'
import { hasConversionGoalFilter, getFiltersByKeyPrefix } from '../../util/filters'; import {
import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning'; hasConversionGoalFilter,
import { citiesRoute, countriesRoute, regionsRoute } from '../../router'; getFiltersByKeyPrefix
import { useQueryContext } from '../../query-context'; } from '../../util/filters'
import { useSiteContext } from '../../site-context'; import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning'
import { citiesRoute, countriesRoute, regionsRoute } from '../../router'
import { useQueryContext } from '../../query-context'
import { useSiteContext } from '../../site-context'
function Countries({ query, site, onClick, afterFetchData }) { function Countries({ query, site, onClick, afterFetchData }) {
function fetchData() { function fetchData() {
@ -24,17 +27,17 @@ function Countries({ query, site, onClick, afterFetchData }) {
function getFilterInfo(listItem) { function getFilterInfo(listItem) {
return { return {
prefix: "country", prefix: 'country',
filter: ["is", "country", [listItem['code']]], filter: ['is', 'country', [listItem['code']]],
labels: { [listItem['code']]: listItem['name'] } labels: { [listItem['code']]: listItem['name'] }
} }
} }
function chooseMetrics() { function chooseMetrics() {
return [ return [
metrics.createVisitors({ meta: {plot: true}}), metrics.createVisitors({ meta: { plot: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate(), hasConversionGoalFilter(query) && metrics.createConversionRate()
].filter(metric => !!metric) ].filter((metric) => !!metric)
} }
return ( return (
@ -45,7 +48,10 @@ function Countries({ query, site, onClick, afterFetchData }) {
onClick={onClick} onClick={onClick}
keyLabel="Country" keyLabel="Country"
metrics={chooseMetrics()} metrics={chooseMetrics()}
detailsLinkProps={{ path: countriesRoute.path, search: (search) => search }} detailsLinkProps={{
path: countriesRoute.path,
search: (search) => search
}}
renderIcon={renderIcon} renderIcon={renderIcon}
color="bg-orange-50" color="bg-orange-50"
/> />
@ -63,17 +69,17 @@ function Regions({ query, site, onClick, afterFetchData }) {
function getFilterInfo(listItem) { function getFilterInfo(listItem) {
return { return {
prefix: "region", prefix: 'region',
filter: ["is", "region", [listItem['code']]], filter: ['is', 'region', [listItem['code']]],
labels: { [listItem['code']]: listItem['name'] } labels: { [listItem['code']]: listItem['name'] }
} }
} }
function chooseMetrics() { function chooseMetrics() {
return [ return [
metrics.createVisitors({ meta: {plot: true}}), metrics.createVisitors({ meta: { plot: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate(), hasConversionGoalFilter(query) && metrics.createConversionRate()
].filter(metric => !!metric) ].filter((metric) => !!metric)
} }
return ( return (
@ -102,17 +108,17 @@ function Cities({ query, site, afterFetchData }) {
function getFilterInfo(listItem) { function getFilterInfo(listItem) {
return { return {
prefix: "city", prefix: 'city',
filter: ["is", "city", [listItem['code']]], filter: ['is', 'city', [listItem['code']]],
labels: { [listItem['code']]: listItem['name'] } labels: { [listItem['code']]: listItem['name'] }
} }
} }
function chooseMetrics() { function chooseMetrics() {
return [ return [
metrics.createVisitors({ meta: {plot: true}}), metrics.createVisitors({ meta: { plot: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate(), hasConversionGoalFilter(query) && metrics.createConversionRate()
].filter(metric => !!metric) ].filter((metric) => !!metric)
} }
return ( return (
@ -129,11 +135,10 @@ function Cities({ query, site, afterFetchData }) {
) )
} }
const labelFor = { const labelFor = {
'countries': 'Countries', countries: 'Countries',
'regions': 'Regions', regions: 'Regions',
'cities': 'Cities', cities: 'Cities'
} }
class Locations extends React.Component { class Locations extends React.Component {
@ -153,8 +158,10 @@ class Locations extends React.Component {
componentDidUpdate(prevProps, prevState) { componentDidUpdate(prevProps, prevState) {
const isRemovingFilter = (filterName) => { const isRemovingFilter = (filterName) => {
return getFiltersByKeyPrefix(prevProps.query, filterName).length > 0 && return (
getFiltersByKeyPrefix(prevProps.query, filterName).length > 0 &&
getFiltersByKeyPrefix(this.props.query, filterName).length == 0 getFiltersByKeyPrefix(this.props.query, filterName).length == 0
)
} }
if (this.state.mode === 'cities' && isRemovingFilter('region')) { if (this.state.mode === 'cities' && isRemovingFilter('region')) {
@ -165,7 +172,10 @@ class Locations extends React.Component {
this.setMode(this.countriesRestoreMode || 'countries')() this.setMode(this.countriesRestoreMode || 'countries')()
} }
if (this.props.query !== prevProps.query || this.state.mode !== prevState.mode) { if (
this.props.query !== prevProps.query ||
this.state.mode !== prevState.mode
) {
this.setState({ loading: true }) this.setState({ loading: true })
} }
} }
@ -189,20 +199,48 @@ class Locations extends React.Component {
} }
afterFetchData(apiResponse) { afterFetchData(apiResponse) {
this.setState({ loading: false, skipImportedReason: apiResponse.skip_imported_reason }) this.setState({
loading: false,
skipImportedReason: apiResponse.skip_imported_reason
})
} }
renderContent() { renderContent() {
switch (this.state.mode) { switch (this.state.mode) {
case "cities": case 'cities':
return <Cities site={this.props.site} query={this.props.query} afterFetchData={this.afterFetchData} /> return (
case "regions": <Cities
return <Regions onClick={this.onRegionFilter} site={this.props.site} query={this.props.query} afterFetchData={this.afterFetchData} /> site={this.props.site}
case "countries": query={this.props.query}
return <Countries onClick={this.onCountryFilter('countries')} site={this.props.site} query={this.props.query} afterFetchData={this.afterFetchData} /> afterFetchData={this.afterFetchData}
case "map": />
)
case 'regions':
return (
<Regions
onClick={this.onRegionFilter}
site={this.props.site}
query={this.props.query}
afterFetchData={this.afterFetchData}
/>
)
case 'countries':
return (
<Countries
onClick={this.onCountryFilter('countries')}
site={this.props.site}
query={this.props.query}
afterFetchData={this.afterFetchData}
/>
)
case 'map':
default: default:
return <CountriesMap onCountrySelect={this.onCountryFilter('map')} afterFetchData={this.afterFetchData} /> return (
<CountriesMap
onCountrySelect={this.onCountryFilter('map')}
afterFetchData={this.afterFetchData}
/>
)
} }
} }
@ -211,9 +249,7 @@ class Locations extends React.Component {
if (isActive) { if (isActive) {
return ( return (
<button <button className="inline-block h-5 text-indigo-700 dark:text-indigo-500 font-bold active-prop-heading">
className="inline-block h-5 text-indigo-700 dark:text-indigo-500 font-bold active-prop-heading"
>
{name} {name}
</button> </button>
) )
@ -237,7 +273,10 @@ class Locations extends React.Component {
<h3 className="font-bold dark:text-gray-100"> <h3 className="font-bold dark:text-gray-100">
{labelFor[this.state.mode] || 'Locations'} {labelFor[this.state.mode] || 'Locations'}
</h3> </h3>
<ImportedQueryUnsupportedWarning loading={this.state.loading} skipImportedReason={this.state.skipImportedReason} /> <ImportedQueryUnsupportedWarning
loading={this.state.loading}
skipImportedReason={this.state.skipImportedReason}
/>
</div> </div>
<div className="flex text-xs font-medium text-gray-500 dark:text-gray-400 space-x-2"> <div className="flex text-xs font-medium text-gray-500 dark:text-gray-400 space-x-2">
{this.renderPill('Map', 'map')} {this.renderPill('Map', 'map')}
@ -253,8 +292,8 @@ class Locations extends React.Component {
} }
function LocationsWithContext() { function LocationsWithContext() {
const { query } = useQueryContext(); const { query } = useQueryContext()
const site = useSiteContext(); const site = useSiteContext()
return <Locations site={site} query={query} /> return <Locations site={site} query={query} />
} }
export default LocationsWithContext export default LocationsWithContext

View File

@ -1,4 +1,3 @@
/* @format */
import classNames from 'classnames' import classNames from 'classnames'
import React from 'react' import React from 'react'

View File

@ -1,4 +1,3 @@
/* @format */
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import * as d3 from 'd3' import * as d3 from 'd3'
import classNames from 'classnames' import classNames from 'classnames'

View File

@ -1,5 +1,3 @@
/** @format */
import React, { useState, ReactNode, useMemo } from 'react' import React, { useState, ReactNode, useMemo } from 'react'
import { useQueryContext } from '../../query-context' import { useQueryContext } from '../../query-context'

View File

@ -1,5 +1,3 @@
/** @format */
import React, { ReactNode } from 'react' import React, { ReactNode } from 'react'
import { SearchInput } from '../../components/search-input' import { SearchInput } from '../../components/search-input'

View File

@ -1,43 +1,54 @@
import React, { useCallback, useState } from "react"; import React, { useCallback, useState } from 'react'
import Modal from './modal' import Modal from './modal'
import BreakdownModal from "./breakdown-modal"; import BreakdownModal from './breakdown-modal'
import * as metrics from "../reports/metrics"; import * as metrics from '../reports/metrics'
import * as url from '../../util/url'; import * as url from '../../util/url'
import { useSiteContext } from "../../site-context"; import { useSiteContext } from '../../site-context'
import { addFilter } from "../../query"; import { addFilter } from '../../query'
/*global BUILD_EXTRA*/ /*global BUILD_EXTRA*/
function ConversionsModal() { function ConversionsModal() {
const [showRevenue, setShowRevenue] = useState(false) const [showRevenue, setShowRevenue] = useState(false)
const site = useSiteContext(); const site = useSiteContext()
const reportInfo = { const reportInfo = {
title: 'Goal Conversions', title: 'Goal Conversions',
dimension: 'goal', dimension: 'goal',
endpoint: url.apiPath(site, '/conversions'), endpoint: url.apiPath(site, '/conversions'),
dimensionLabel: "Goal" dimensionLabel: 'Goal'
} }
const getFilterInfo = useCallback((listItem) => { const getFilterInfo = useCallback(
(listItem) => {
return { return {
prefix: reportInfo.dimension, prefix: reportInfo.dimension,
filter: ["is", reportInfo.dimension, [listItem.name]] filter: ['is', reportInfo.dimension, [listItem.name]]
} }
}, [reportInfo.dimension]) },
[reportInfo.dimension]
)
const addSearchFilter = useCallback((query, searchString) => { const addSearchFilter = useCallback(
return addFilter(query, ['contains', reportInfo.dimension, [searchString], { case_sensitive: false }]) (query, searchString) => {
}, [reportInfo.dimension]) return addFilter(query, [
'contains',
reportInfo.dimension,
[searchString],
{ case_sensitive: false }
])
},
[reportInfo.dimension]
)
function chooseMetrics() { function chooseMetrics() {
return [ return [
metrics.createVisitors({ renderLabel: (_query) => "Uniques" }), metrics.createVisitors({ renderLabel: (_query) => 'Uniques' }),
metrics.createEvents({ renderLabel: (_query) => "Total" }), metrics.createEvents({ renderLabel: (_query) => 'Total' }),
metrics.createConversionRate(), metrics.createConversionRate(),
showRevenue && metrics.createAverageRevenue(), showRevenue && metrics.createAverageRevenue(),
showRevenue && metrics.createTotalRevenue(), showRevenue && metrics.createTotalRevenue()
].filter(metric => !!metric) ].filter((metric) => !!metric)
} }
// After a successful API response, we want to scan the rows of the // After a successful API response, we want to scan the rows of the
@ -49,9 +60,14 @@ function ConversionsModal() {
// After fetching the next page, we never want to set `showRevenue` to // After fetching the next page, we never want to set `showRevenue` to
// `false` as revenue metrics might exist in previously loaded data. // `false` as revenue metrics might exist in previously loaded data.
const afterFetchNextPage = useCallback((res) => { const afterFetchNextPage = useCallback(
if (!showRevenue && revenueInResponse(res)) { setShowRevenue(true) } (res) => {
}, [showRevenue]) if (!showRevenue && revenueInResponse(res)) {
setShowRevenue(true)
}
},
[showRevenue]
)
function revenueInResponse(apiResponse) { function revenueInResponse(apiResponse) {
return apiResponse.results.some((item) => item.total_revenue) return apiResponse.results.some((item) => item.total_revenue)

Some files were not shown because too many files have changed in this diff Show More