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
*.json
*.config.js
js/types/query-api.d.ts

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +1,10 @@
import "./polyfills/closest"
import './polyfills/closest'
import 'abortcontroller-polyfill/dist/polyfill-patch-fetch'
import Alpine from 'alpinejs'
import "./liveview/live_socket"
import comboBox from "./liveview/combo-box"
import dropdown from "./liveview/dropdown"
import "./liveview/phx_events"
import './liveview/live_socket'
import comboBox from './liveview/combo-box'
import dropdown from './liveview/dropdown'
import './liveview/phx_events'
Alpine.data('dropdown', dropdown)
Alpine.data('comboBox', comboBox)
@ -61,7 +61,9 @@ const changelogNotification = document.getElementById('changelog-notification')
if (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) => {
localStorage.lastChangelogUpdate = new Date(res).getTime()
@ -89,7 +91,9 @@ function showChangelogNotification(el) {
const link = el.getElementsByTagName('a')[0]
link.addEventListener('click', function () {
localStorage.lastChangelogClick = Date.now()
setTimeout(() => { link.remove() }, 100)
setTimeout(() => {
link.remove()
}, 100)
})
}
}
@ -116,7 +120,8 @@ if (embedButton) {
<script async src="${baseUrl}/js/embed.host.js"></script>`
} catch (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 { createRoot } from 'react-dom/client'
import 'url-search-params-polyfill'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,27 +1,57 @@
import React from "react"
import { sectionTitles } from "../stats/behaviours"
import React from 'react'
import { sectionTitles } from '../stats/behaviours'
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 sectionTitle = sectionTitles[feature]
const requestHideSection = () => {
if (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 } })
if (
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())
.catch((error) => {if (!(error instanceof api.ApiError)) {throw error}})
.catch((error) => {
if (!(error instanceof api.ApiError)) {
throw error
}
})
}
}
function renderCallToAction() {
return (
<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>
<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" />
<p className="flex flex-col justify-center text-xs sm:text-sm">
{callToAction.action}
</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>
</a>
)
@ -31,7 +61,8 @@ export function FeatureSetupNotice({ feature, title, info, callToAction, onHideA
return (
<button
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
</button>
)

View File

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

View File

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

View File

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

View File

@ -1,5 +1,3 @@
/** @format */
import classNames from 'classnames'
import React, { ReactNode } from 'react'
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
// the function does not run on the initial render.
@ -22,15 +22,20 @@ export function useDebounce(fn, delay = DEBOUNCE_DELAY) {
useEffect(() => {
return () => {
if (timerRef.current) { clearTimeout(timerRef.current) }
if (timerRef.current) {
clearTimeout(timerRef.current)
}
}
}, [])
return useCallback((...args) => {
return useCallback(
(...args) => {
clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => {
fn(...args)
}, delay)
}, [fn, delay])
},
[fn, delay]
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -2,7 +2,7 @@ export default function FunnelTooltip(palette, funnel) {
return (context) => {
const tooltipModel = context.tooltip
const dataIndex = tooltipModel.dataPoints[0].dataIndex
const offset = document.getElementById("funnel").getBoundingClientRect()
const offset = document.getElementById('funnel').getBoundingClientRect()
let tooltipEl = document.getElementById('chartjs-tooltip')
if (!tooltipEl) {
@ -14,7 +14,8 @@ export default function FunnelTooltip(palette, funnel) {
}
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.right = null
tooltipEl.style.opacity = 1
@ -25,15 +26,14 @@ export default function FunnelTooltip(palette, funnel) {
return
}
if (tooltipModel.body) {
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 = `
<aside class="text-gray-100 flex flex-col">
<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>
${tooltipModel.title}
</span>
@ -45,7 +45,7 @@ export default function FunnelTooltip(palette, funnel) {
<span class="flex items-center mr-4">
<div class="w-3 h-3 mr-1 rounded-full ${palette.visitorsLegendClass}"></div>
<span>
${dataIndex == 0 ? "Entered the funnel" : "Visitors"}
${dataIndex == 0 ? 'Entered the funnel' : 'Visitors'}
</span>
</span>
</th>
@ -65,7 +65,7 @@ export default function FunnelTooltip(palette, funnel) {
<span class="flex items-center">
<div class="w-3 h-3 mr-1 rounded-full ${palette.dropoffLegendClass}"></div>
<span>
${dataIndex == 0 ? "Never entered the funnel" : "Dropoff"}
${dataIndex == 0 ? 'Never entered the funnel' : 'Dropoff'}
</span>
</span>
</th>

View File

@ -1,22 +1,21 @@
import React, { useEffect, useState, useRef } from 'react';
import FlipMove from 'react-flip-move';
import Chart from 'chart.js/auto';
import FunnelTooltip from './funnel-tooltip';
import ChartDataLabels from 'chartjs-plugin-datalabels';
import { numberShortFormatter } from '../util/number-formatter';
import Bar from '../stats/bar';
import React, { useEffect, useState, useRef } from 'react'
import FlipMove from 'react-flip-move'
import Chart from 'chart.js/auto'
import FunnelTooltip from './funnel-tooltip'
import ChartDataLabels from 'chartjs-plugin-datalabels'
import { numberShortFormatter } from '../util/number-formatter'
import Bar from '../stats/bar'
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 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'
export default function Funnel({ funnelName, tabs }) {
const site = useSiteContext();
const { query } = useQueryContext();
const site = useSiteContext()
const { query } = useQueryContext()
const [loading, setLoading] = useState(true)
const [visible, setVisible] = useState(false)
const [error, setError] = useState(undefined)
@ -60,11 +59,11 @@ export default function Funnel({ funnelName, tabs }) {
const mediaQuery = window.matchMedia('(max-width: 768px)')
setSmallScreen(mediaQuery.matches)
const handleScreenChange = (e) => {
setSmallScreen(e.matches);
setSmallScreen(e.matches)
}
mediaQuery.addEventListener("change", handleScreenChange);
mediaQuery.addEventListener('change', handleScreenChange)
return () => {
mediaQuery.removeEventListener("change", handleScreenChange)
mediaQuery.removeEventListener('change', handleScreenChange)
}
}, [])
@ -110,7 +109,9 @@ export default function Funnel({ funnelName, tabs }) {
}
const calcOffset = (ctx) => {
const conversionRate = parseFloat(funnel.steps[ctx.dataIndex].conversion_rate)
const conversionRate = parseFloat(
funnel.steps[ctx.dataIndex].conversion_rate
)
if (conversionRate > 90) {
return -64
} else if (conversionRate > 20) {
@ -129,7 +130,10 @@ export default function Funnel({ funnelName, tabs }) {
if (typeof funnelMeta === 'undefined') {
throw new Error('Could not fetch the funnel. Perhaps it was deleted?')
} 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.strokeStyle = color2
c.fillRect(0, 0, shape.width, shape.height);
c.fillRect(0, 0, shape.width, shape.height)
c.beginPath()
c.moveTo(2, 0)
@ -168,7 +172,7 @@ export default function Funnel({ funnelName, tabs }) {
const stepData = funnel.steps.map((step) => step.visitors)
const dropOffData = funnel.steps.map((step) => step.dropoff)
const ctx = canvasRef.current.getContext("2d")
const ctx = canvasRef.current.getContext('2d')
const calcBarThickness = (ctx) => {
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
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);
gradient.addColorStop(1, palette.dropoffBackground);
gradient.addColorStop(0, palette.visitorsBackground);
var gradient = ctx.createLinearGradient(900, 0, 900, 900)
gradient.addColorStop(1, palette.dropoffBackground)
gradient.addColorStop(0, palette.visitorsBackground)
const data = {
labels: labels,
@ -194,17 +199,20 @@ export default function Funnel({ funnelName, tabs }) {
backgroundColor: gradient,
hoverBackgroundColor: gradient,
borderRadius: 4,
stack: 'Stack 0',
stack: 'Stack 0'
},
{
label: 'Dropoff',
data: dropOffData,
backgroundColor: createDiagonalPattern(palette.dropoffBackground, palette.dropoffStripes),
backgroundColor: createDiagonalPattern(
palette.dropoffBackground,
palette.dropoffStripes
),
hoverBackgroundColor: palette.dropoffBackground,
borderRadius: 4,
stack: 'Stack 0',
},
],
stack: 'Stack 0'
}
]
}
const config = {
@ -216,7 +224,7 @@ export default function Funnel({ funnelName, tabs }) {
barThickness: calcBarThickness,
plugins: {
legend: {
display: false,
display: false
},
tooltip: {
enabled: false,
@ -234,10 +242,15 @@ export default function Funnel({ funnelName, tabs }) {
color: palette.dataLabelTextColor,
borderRadius: 4,
clip: true,
font: { size: 12, weight: 'normal', lineHeight: 1.6, family: fontFamily },
textAlign: 'center',
padding: { top: 8, bottom: 8, right: 8, left: 8 },
font: {
size: 12,
weight: 'normal',
lineHeight: 1.6,
family: fontFamily
},
textAlign: 'center',
padding: { top: 8, bottom: 8, right: 8, left: 8 }
}
},
scales: {
y: { display: false },
@ -250,10 +263,10 @@ export default function Funnel({ funnelName, tabs }) {
padding: 8,
font: { weight: 'bold', family: fontFamily, size: 14 },
color: palette.stepNameLegendColor
},
},
},
},
}
}
}
}
}
chartRef.current = new Chart(ctx, config)
@ -274,7 +287,9 @@ export default function Funnel({ funnelName, tabs }) {
return (
<>
{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 {
@ -284,8 +299,12 @@ export default function Funnel({ funnelName, tabs }) {
<div className="text-center text-gray-900 dark:text-gray-100 mt-16">
<RocketIcon />
<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-xs mt-8">Please try refreshing your browser or selecting the funnel again.</div>
<div className="text-lg">
{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>
</>
)
@ -294,16 +313,24 @@ export default function Funnel({ funnelName, tabs }) {
const renderInner = () => {
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) {
return renderError()
} else if (funnel) {
const conversionRate = funnel.steps[funnel.steps.length - 1].conversion_rate
const conversionRate =
funnel.steps[funnel.steps.length - 1].conversion_rate
return (
<div className="mb-8">
{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>}
</div>
)
@ -320,16 +347,18 @@ export default function Funnel({ funnelName, tabs }) {
count={step.visitors}
all={funnel.steps}
bg={palette.smallBarClass}
maxWidthDeduction={"5rem"}
maxWidthDeduction={'5rem'}
plot={'visitors'}
>
<span className="flex px-2 py-1.5 group dark:text-gray-100 relative z-9 break-all">
{step.label}
</span>
</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)}
</span>
</div>
@ -346,9 +375,7 @@ export default function Funnel({ funnelName, tabs }) {
<span className="inline-block w-20">Visitors</span>
</span>
</div>
<FlipMove>
{funnel.steps.map(renderBar)}
</FlipMove>
<FlipMove>{funnel.steps.map(renderBar)}</FlipMove>
</>
)
}
@ -358,7 +385,9 @@ export default function Funnel({ funnelName, tabs }) {
<LazyLoader onVisible={() => setVisible(true)}>
{renderInner()}
</LazyLoader>
{!isSmallScreen && <canvas className="" id="funnel" ref={canvasRef}></canvas>}
{!isSmallScreen && (
<canvas className="" id="funnel" ref={canvasRef}></canvas>
)}
</div>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,3 @@
/** @format */
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Metric } from '../stats/reports/metrics'
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 {
const ref = useRef<T | undefined>(undefined);
const ref = useRef<T | undefined>(undefined)
useEffect(() => {
// Update the ref with the current value after render
ref.current = value;
}, [value]);
ref.current = value
}, [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 VisitorGraph from './stats/graph/visitor-graph'
import Sources from './stats/sources'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,3 @@
/** @format */
import React, { createContext, ReactNode, useContext } from 'react'
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) {
let maxVal = all[0][plot];
let maxVal = all[0][plot]
for (const val of all) {
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 style = maxWidthDeduction ? {maxWidth: `calc(100% - ${maxWidthDeduction})`} : {}
const style = maxWidthDeduction
? { maxWidth: `calc(100% - ${maxWidthDeduction})` }
: {}
return (
<div
className="w-full h-full relative"
style={style}
>
<div className="w-full h-full relative" style={style}>
<div
className={`absolute top-0 left-0 h-full ${bg || ''}`}
style={{ width: `${width}%` }}
>
</div>
></div>
{children}
</div>
)

View File

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

View File

@ -1,21 +1,28 @@
import React from "react"
import React from 'react'
import Conversions from './conversions'
import ListReport from "../reports/list"
import ListReport from '../reports/list'
import * as metrics from '../reports/metrics'
import * as url from "../../util/url"
import * as api from "../../api"
import { EVENT_PROPS_PREFIX, getGoalFilter, FILTER_OPERATIONS } from "../../util/filters"
import { useSiteContext } from "../../site-context"
import { useQueryContext } from "../../query-context"
import { customPropsRoute } from "../../router"
import * as url from '../../util/url'
import * as api from '../../api'
import {
EVENT_PROPS_PREFIX,
getGoalFilter,
FILTER_OPERATIONS
} from '../../util/filters'
import { useSiteContext } from '../../site-context'
import { useQueryContext } from '../../query-context'
import { customPropsRoute } from '../../router'
export const SPECIAL_GOALS = {
'404': { title: '404 Pages', prop: 'path' },
404: { title: '404 Pages', prop: 'path' },
'Outbound Link: Click': { title: 'Outbound Links', prop: 'url' },
'Cloaked Link: Click': { title: 'Cloaked Links', prop: 'url' },
'File Download': { title: 'File Downloads', prop: 'url' },
'WP Search Queries': { title: 'WordPress Search Queries', prop: 'search_query' },
'WP Form Completions': { title: 'WordPress Form Completions', prop: 'path' },
'WP Search Queries': {
title: 'WordPress Search Queries',
prop: 'search_query'
},
'WP Form Completions': { title: 'WordPress Form Completions', prop: 'path' }
}
function getSpecialGoal(query) {
@ -28,7 +35,6 @@ function getSpecialGoal(query) {
return SPECIAL_GOALS[clauses[0]] || null
}
return null
}
export function specialTitleWhenGoalFilter(query, defaultTitle) {
@ -36,8 +42,8 @@ export function specialTitleWhenGoalFilter(query, defaultTitle) {
}
function SpecialPropBreakdown({ prop, afterFetchData }) {
const site = useSiteContext();
const { query } = useQueryContext();
const site = useSiteContext()
const { query } = useQueryContext()
function fetchData() {
return api.get(url.apiPath(site, `/custom-prop-values/${prop}`), query)
@ -54,16 +60,22 @@ function SpecialPropBreakdown({ prop, afterFetchData }) {
function getFilterInfo(listItem) {
return {
prefix: EVENT_PROPS_PREFIX,
filter: ["is", `${EVENT_PROPS_PREFIX}${prop}`, [listItem['name']]]
filter: ['is', `${EVENT_PROPS_PREFIX}${prop}`, [listItem['name']]]
}
}
function chooseMetrics() {
return [
metrics.createVisitors({ renderLabel: (_query) => "Visitors", meta: { plot: true } }),
metrics.createEvents({ renderLabel: (_query) => "Events", meta: { hiddenOnMobile: true } }),
metrics.createVisitors({
renderLabel: (_query) => 'Visitors',
meta: { plot: true }
}),
metrics.createEvents({
renderLabel: (_query) => 'Events',
meta: { hiddenOnMobile: true }
}),
metrics.createConversionRate()
].filter(metric => !!metric)
].filter((metric) => !!metric)
}
return (
@ -73,7 +85,11 @@ function SpecialPropBreakdown({ prop, afterFetchData }) {
getFilterInfo={getFilterInfo}
keyLabel={prop}
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()}
maybeHideDetails={true}
color="bg-red-50"
@ -87,8 +103,18 @@ export default function GoalConversions({ afterFetchData, onGoalFilterClick }) {
const specialGoal = getSpecialGoal(query)
if (specialGoal) {
return <SpecialPropBreakdown prop={specialGoal.prop} afterFetchData={afterFetchData} />
return (
<SpecialPropBreakdown
prop={specialGoal.prop}
afterFetchData={afterFetchData}
/>
)
} 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 { ChevronDownIcon } from '@heroicons/react/20/solid'
import classNames from 'classnames'
import * as storage from '../../util/storage'
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 { FeatureSetupNotice } from '../../components/notice'
import { hasConversionGoalFilter } from '../../util/filters'
@ -26,7 +35,8 @@ function maybeRequire() {
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'
export const CONVERSIONS = 'conversions'
@ -40,21 +50,29 @@ export const sectionTitles = {
}
export default function Behaviours({ importedDataInView }) {
const { query } = useQueryContext();
const site = useSiteContext();
const user = useUserContext();
const buttonRef = useRef();
const adminAccess = ['owner', 'admin', 'editor', 'super_admin'].includes(user.role)
const { query } = useQueryContext()
const site = useSiteContext()
const user = useUserContext()
const buttonRef = useRef()
const adminAccess = ['owner', 'admin', 'editor', 'super_admin'].includes(
user.role
)
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 [mode, setMode] = useState(defaultMode())
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 [showingPropsForGoalFilter, setShowingPropsForGoalFilter] = useState(false)
const [showingPropsForGoalFilter, setShowingPropsForGoalFilter] =
useState(false)
const [skipImportedReason, setSkipImportedReason] = useState(null)
@ -63,7 +81,12 @@ export default function Behaviours({ importedDataInView }) {
const isSpecialGoal = Object.keys(SPECIAL_GOALS).includes(goalName)
const isPageviewGoal = goalName.startsWith('Visit ')
if (!isSpecialGoal && !isPageviewGoal && enabledModes.includes(PROPS) && site.hasProps) {
if (
!isSpecialGoal &&
!isPageviewGoal &&
enabledModes.includes(PROPS) &&
site.hasProps
) {
setShowingPropsForGoalFilter(true)
setMode(PROPS)
}
@ -87,7 +110,11 @@ export default function Behaviours({ importedDataInView }) {
useEffect(() => setLoading(true), [query, mode])
function disableMode(mode) {
setEnabledModes(enabledModes.filter((m) => { return m !== mode }))
setEnabledModes(
enabledModes.filter((m) => {
return m !== mode
})
)
}
function setFunnel(selectedFunnel) {
@ -118,12 +145,21 @@ export default function Behaviours({ importedDataInView }) {
}
function tabFunnelPicker() {
return <Menu as="div" className="relative inline-block text-left">
return (
<Menu as="div" className="relative inline-block text-left">
<BlurMenuButtonOnEscape targetRef={buttonRef} />
<div>
<Menu.Button ref={buttonRef} 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
ref={buttonRef}
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>
</div>
@ -145,9 +181,13 @@ export default function Behaviours({ importedDataInView }) {
<span
onClick={setFunnel(funnelName)}
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',
(mode === FUNNELS && selectedFunnel === funnelName) ? 'font-bold text-gray-500' : ''
mode === FUNNELS && selectedFunnel === funnelName
? 'font-bold text-gray-500'
: ''
)}
>
{funnelName}
@ -160,10 +200,14 @@ export default function Behaviours({ importedDataInView }) {
</Menu.Items>
</Transition>
</Menu>
)
}
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 = () => {
storage.setItem(tabKey, 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">
{isEnabled(CONVERSIONS) && tabSwitcher(CONVERSIONS, 'Goals')}
{isEnabled(PROPS) && tabSwitcher(PROPS, 'Properties')}
{isEnabled(FUNNELS) && Funnel && (hasFunnels() ? tabFunnelPicker() : tabSwitcher(FUNNELS, 'Funnels'))}
{isEnabled(FUNNELS) &&
Funnel &&
(hasFunnels() ? tabFunnelPicker() : tabSwitcher(FUNNELS, 'Funnels'))}
</div>
)
}
@ -193,37 +239,45 @@ export default function Behaviours({ importedDataInView }) {
function renderConversions() {
if (site.hasGoals) {
return <GoalConversions onGoalFilterClick={onGoalFilterClick} afterFetchData={afterFetchData} />
}
else if (adminAccess) {
return (
<GoalConversions
onGoalFilterClick={onGoalFilterClick}
afterFetchData={afterFetchData}
/>
)
} else if (adminAccess) {
return (
<FeatureSetupNotice
feature={CONVERSIONS}
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={{
action: "Set up goals",
action: 'Set up goals',
link: `/${encodeURIComponent(site.domain)}/settings/goals`
}}
onHideAction={onHideAction(CONVERSIONS)}
/>
)
} else {
return noDataYet()
}
else { return noDataYet() }
}
function renderFunnels() {
if (Funnel === null) {
return featureUnavailable()
}
else if (Funnel && selectedFunnel && site.funnelsAvailable) {
} else if (Funnel && selectedFunnel && site.funnelsAvailable) {
return <Funnel funnelName={selectedFunnel} />
}
else if (Funnel && adminAccess) {
} else if (Funnel && adminAccess) {
let callToAction
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 {
callToAction = { action: 'Upgrade', link: '/billing/choose-plan' }
}
@ -232,13 +286,16 @@ export default function Behaviours({ importedDataInView }) {
<FeatureSetupNotice
feature={FUNNELS}
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}
onHideAction={onHideAction(FUNNELS)}
/>
)
} else {
return noDataYet()
}
else { return noDataYet() }
}
function renderProps() {
@ -248,7 +305,10 @@ export default function Behaviours({ importedDataInView }) {
let callToAction
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 {
callToAction = { action: 'Upgrade', link: '/billing/choose-plan' }
}
@ -257,12 +317,16 @@ export default function Behaviours({ importedDataInView }) {
<FeatureSetupNotice
feature={PROPS}
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}
onHideAction={onHideAction(PROPS)}
/>
)
} else { return noDataYet() }
} else {
return noDataYet()
}
}
function noDataYet() {
@ -282,7 +346,9 @@ export default function Behaviours({ importedDataInView }) {
}
function onHideAction(mode) {
return () => { disableMode(mode) }
return () => {
disableMode(mode)
}
}
function renderContent() {
@ -297,13 +363,21 @@ export default function Behaviours({ importedDataInView }) {
}
function defaultMode() {
if (enabledModes.length === 0) { return null }
if (enabledModes.length === 0) {
return null
}
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(PROPS)) { return PROPS }
if (enabledModes.includes(CONVERSIONS)) {
return CONVERSIONS
}
if (enabledModes.includes(PROPS)) {
return PROPS
}
return FUNNELS
}
@ -345,11 +419,27 @@ export default function Behaviours({ importedDataInView }) {
function renderImportedQueryUnsupportedWarning() {
if (mode === CONVERSIONS) {
return <ImportedQueryUnsupportedWarning loading={loading} skipImportedReason={skipImportedReason} />
return (
<ImportedQueryUnsupportedWarning
loading={loading}
skipImportedReason={skipImportedReason}
/>
)
} 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 {
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 ListReport, { MIN_HEIGHT } from "../reports/list";
import Combobox from '../../components/combobox';
import * as metrics from '../reports/metrics';
import * as api from '../../api';
import * as url from '../../util/url';
import * as storage from "../../util/storage";
import { EVENT_PROPS_PREFIX, getGoalFilter, FILTER_OPERATIONS, hasConversionGoalFilter } from "../../util/filters";
import classNames from "classnames";
import { useQueryContext } from "../../query-context";
import { useSiteContext } from "../../site-context";
import { customPropsRoute } from "../../router";
import React, { useCallback, useEffect, useState } from 'react'
import ListReport, { MIN_HEIGHT } from '../reports/list'
import Combobox from '../../components/combobox'
import * as metrics from '../reports/metrics'
import * as api from '../../api'
import * as url from '../../util/url'
import * as storage from '../../util/storage'
import {
EVENT_PROPS_PREFIX,
getGoalFilter,
FILTER_OPERATIONS,
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 }) {
const { query } = useQueryContext();
const site = useSiteContext();
const { query } = useQueryContext()
const site = useSiteContext()
const propKeyStorageName = `prop_key__${site.domain}`
const propKeyStorageNameForGoal = () => {
@ -39,8 +43,8 @@ export default function Properties({ afterFetchData }) {
setPropKeyLoading(true)
setPropKey(null)
fetchPropKeyOptions()("").then((propKeys) => {
const propKeyValues = propKeys.map(entry => entry.value)
fetchPropKeyOptions()('').then((propKeys) => {
const propKeyValues = propKeys.map((entry) => entry.value)
if (propKeyValues.length > 0) {
const storedPropKey = getPropKeyFromStorage()
@ -60,29 +64,39 @@ export default function Properties({ afterFetchData }) {
function getPropKeyFromStorage() {
if (singleGoalFilterApplied()) {
const storedForGoal = storage.getItem(propKeyStorageNameForGoal())
if (storedForGoal) { return storedForGoal }
if (storedForGoal) {
return storedForGoal
}
}
return storage.getItem(propKeyStorageName)
}
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(() => {
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
}, [query])
function onPropKeySelect() {
return (selectedOptions) => {
const newPropKey = selectedOptions.length === 0 ? null : selectedOptions[0].value
const newPropKey =
selectedOptions.length === 0 ? null : selectedOptions[0].value
if (newPropKey) {
const storageName = singleGoalFilterApplied() ? propKeyStorageNameForGoal() : propKeyStorageName
const storageName = singleGoalFilterApplied()
? propKeyStorageNameForGoal()
: propKeyStorageName
storage.setItem(storageName, newPropKey)
}
@ -93,13 +107,21 @@ export default function Properties({ afterFetchData }) {
/*global BUILD_EXTRA*/
function chooseMetrics() {
return [
metrics.createVisitors({ renderLabel: (_query) => "Visitors", meta: { plot: true } }),
metrics.createEvents({ renderLabel: (_query) => "Events", meta: { hiddenOnMobile: true } }),
metrics.createVisitors({
renderLabel: (_query) => 'Visitors',
meta: { plot: true }
}),
metrics.createEvents({
renderLabel: (_query) => 'Events',
meta: { hiddenOnMobile: true }
}),
hasConversionGoalFilter(query) && metrics.createConversionRate(),
!hasConversionGoalFilter(query) && metrics.createPercentage(),
BUILD_EXTRA && metrics.createTotalRevenue({ meta: { hiddenOnMobile: true } }),
BUILD_EXTRA && metrics.createAverageRevenue({ meta: { hiddenOnMobile: true } })
].filter(metric => !!metric)
BUILD_EXTRA &&
metrics.createTotalRevenue({ meta: { hiddenOnMobile: true } }),
BUILD_EXTRA &&
metrics.createAverageRevenue({ meta: { hiddenOnMobile: true } })
].filter((metric) => !!metric)
}
function renderBreakdown() {
@ -110,7 +132,11 @@ export default function Properties({ afterFetchData }) {
getFilterInfo={getFilterInfo}
keyLabel={propKey}
metrics={chooseMetrics()}
detailsLinkProps={{ path: customPropsRoute.path, params: { propKey }, search: (search) => search }}
detailsLinkProps={{
path: customPropsRoute.path,
params: { propKey },
search: (search) => search
}}
maybeHideDetails={true}
color="bg-red-50"
colMinWidth={90}
@ -120,22 +146,38 @@ export default function Properties({ afterFetchData }) {
const getFilterInfo = (listItem) => ({
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 comboboxPlaceholder = comboboxDisabled ? 'No custom properties found' : ''
const comboboxPlaceholder = comboboxDisabled
? 'No custom properties found'
: ''
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
})
}
)
const COMBOBOX_HEIGHT = 40
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` }}>
<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>
{propKey && renderBreakdown()}
</div>

View File

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

View File

@ -1,32 +1,36 @@
import React, { useEffect, useState } from 'react';
import * as storage from '../../util/storage';
import { getFiltersByKeyPrefix, hasConversionGoalFilter, isFilteringOnFixedValue } from '../../util/filters';
import ListReport from '../reports/list';
import * as metrics from '../reports/metrics';
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 React, { useEffect, useState } from 'react'
import * as storage from '../../util/storage'
import {
getFiltersByKeyPrefix,
hasConversionGoalFilter,
isFilteringOnFixedValue
} from '../../util/filters'
import ListReport from '../reports/list'
import * as metrics from '../reports/metrics'
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 {
browsersRoute,
browserVersionsRoute,
operatingSystemsRoute,
operatingSystemVersionsRoute,
screenSizesRoute
} from '../../router';
} from '../../router'
// Icons copied from https://github.com/alrra/browser-logos
const BROWSER_ICONS = {
'Chrome': 'chrome.svg',
'curl': 'curl.svg',
'Safari': 'safari.png',
'Firefox': 'firefox.svg',
Chrome: 'chrome.svg',
curl: 'curl.svg',
Safari: 'safari.png',
Firefox: 'firefox.svg',
'Microsoft Edge': 'edge.svg',
'Vivaldi': 'vivaldi.svg',
'Opera': 'opera.svg',
Vivaldi: 'vivaldi.svg',
Opera: 'opera.svg',
'Samsung Browser': 'samsung-internet.svg',
'Chromium': 'chromium.svg',
Chromium: 'chromium.svg',
'UC Browser': 'uc.svg',
'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.
@ -34,7 +38,7 @@ const BROWSER_ICONS = {
'MIUI Browser': 'miui.webp',
'Huawei Browser Mobile': 'huawei.png',
'QQ Browser': 'qq.png',
'Ecosia': 'ecosia.png',
Ecosia: 'ecosia.png',
'vivo Browser': 'vivo.png'
}
@ -51,8 +55,8 @@ export function browserIconFor(browser) {
}
function Browsers({ afterFetchData }) {
const site = useSiteContext();
const { query } = useQueryContext();
const site = useSiteContext()
const { query } = useQueryContext()
function fetchData() {
return api.get(url.apiPath(site, '/browsers'), query)
}
@ -60,7 +64,7 @@ function Browsers({ afterFetchData }) {
function getFilterInfo(listItem) {
return {
prefix: 'browser',
filter: ["is", "browser", [listItem['name']]]
filter: ['is', 'browser', [listItem['name']]]
}
}
@ -73,7 +77,7 @@ function Browsers({ afterFetchData }) {
metrics.createVisitors({ meta: { plot: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate(),
!hasConversionGoalFilter(query) && metrics.createPercentage()
].filter(metric => !!metric)
].filter((metric) => !!metric)
}
return (
@ -84,14 +88,17 @@ function Browsers({ afterFetchData }) {
keyLabel="Browser"
metrics={chooseMetrics()}
renderIcon={renderIcon}
detailsLinkProps={{ path: browsersRoute.path, search: (search) => search }}
detailsLinkProps={{
path: browsersRoute.path,
search: (search) => search
}}
/>
)
}
function BrowserVersions({ afterFetchData }) {
const { query } = useQueryContext();
const site = useSiteContext();
const { query } = useQueryContext()
const site = useSiteContext()
function fetchData() {
return api.get(url.apiPath(site, '/browser-versions'), query)
}
@ -101,12 +108,12 @@ function BrowserVersions({ afterFetchData }) {
}
function getFilterInfo(listItem) {
if (getSingleFilter(query, "browser") == '(not set)') {
if (getSingleFilter(query, 'browser') == '(not set)') {
return null
}
return {
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 } }),
hasConversionGoalFilter(query) && metrics.createConversionRate(),
!hasConversionGoalFilter(query) && metrics.createPercentage()
].filter(metric => !!metric)
].filter((metric) => !!metric)
}
return (
@ -126,46 +133,45 @@ function BrowserVersions({ afterFetchData }) {
keyLabel="Browser version"
metrics={chooseMetrics()}
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
const OS_ICONS = {
'iOS': 'ios.png',
'Mac': 'mac.png',
'Windows': 'windows.png',
iOS: 'ios.png',
Mac: 'mac.png',
Windows: 'windows.png',
'Windows Phone': 'windows.png',
'Android': 'android.png',
Android: 'android.png',
'GNU/Linux': 'gnu_linux.png',
'Ubuntu': 'ubuntu.png',
Ubuntu: 'ubuntu.png',
'Chrome OS': 'chrome_os.png',
'iPadOS': 'ipad_os.png',
iPadOS: 'ipad_os.png',
'Fire OS': 'fire_os.png',
'HarmonyOS': 'harmony_os.png',
'Tizen': 'tizen.png',
'PlayStation': 'playstation.png',
'KaiOS': 'kai_os.png',
'Fedora': 'fedora.png',
'FreeBSD': 'freebsd.png',
HarmonyOS: 'harmony_os.png',
Tizen: 'tizen.png',
PlayStation: 'playstation.png',
KaiOS: 'kai_os.png',
Fedora: 'fedora.png',
FreeBSD: 'freebsd.png'
}
export function osIconFor(os) {
const filename = OS_ICONS[os] || 'fallback.svg'
return (
<img
alt=""
src={`/images/icon/os/${filename}`}
className="w-4 h-4 mr-2"
/>
<img alt="" src={`/images/icon/os/${filename}`} className="w-4 h-4 mr-2" />
)
}
function OperatingSystems({ afterFetchData }) {
const { query } = useQueryContext();
const site = useSiteContext();
const { query } = useQueryContext()
const site = useSiteContext()
function fetchData() {
return api.get(url.apiPath(site, '/operating-systems'), query)
}
@ -173,7 +179,7 @@ function OperatingSystems({ afterFetchData }) {
function getFilterInfo(listItem) {
return {
prefix: 'os',
filter: ["is", "os", [listItem['name']]]
filter: ['is', 'os', [listItem['name']]]
}
}
@ -181,8 +187,9 @@ function OperatingSystems({ afterFetchData }) {
return [
metrics.createVisitors({ meta: { plot: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate(),
!hasConversionGoalFilter(query) && metrics.createPercentage({ meta: { hiddenonMobile: true } })
].filter(metric => !!metric)
!hasConversionGoalFilter(query) &&
metrics.createPercentage({ meta: { hiddenonMobile: true } })
].filter((metric) => !!metric)
}
function renderIcon(listItem) {
@ -197,14 +204,17 @@ function OperatingSystems({ afterFetchData }) {
renderIcon={renderIcon}
keyLabel="Operating system"
metrics={chooseMetrics()}
detailsLinkProps={{ path: operatingSystemsRoute.path, search: (search) => search }}
detailsLinkProps={{
path: operatingSystemsRoute.path,
search: (search) => search
}}
/>
)
}
function OperatingSystemVersions({ afterFetchData }) {
const { query } = useQueryContext();
const site = useSiteContext();
const { query } = useQueryContext()
const site = useSiteContext()
function fetchData() {
return api.get(url.apiPath(site, '/operating-system-versions'), query)
@ -215,12 +225,12 @@ function OperatingSystemVersions({ afterFetchData }) {
}
function getFilterInfo(listItem) {
if (getSingleFilter(query, "os") == '(not set)') {
if (getSingleFilter(query, 'os') == '(not set)') {
return null
}
return {
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 } }),
hasConversionGoalFilter(query) && metrics.createConversionRate(),
!hasConversionGoalFilter(query) && metrics.createPercentage()
].filter(metric => !!metric)
].filter((metric) => !!metric)
}
return (
@ -240,15 +250,17 @@ function OperatingSystemVersions({ afterFetchData }) {
getFilterInfo={getFilterInfo}
keyLabel="Operating System Version"
metrics={chooseMetrics()}
detailsLinkProps={{ path: operatingSystemVersionsRoute.path, search: (search) => search }}
detailsLinkProps={{
path: operatingSystemVersionsRoute.path,
search: (search) => search
}}
/>
)
}
function ScreenSizes({ afterFetchData }) {
const { query } = useQueryContext();
const site = useSiteContext();
const { query } = useQueryContext()
const site = useSiteContext()
function fetchData() {
return api.get(url.apiPath(site, '/screen-sizes'), query)
@ -261,7 +273,7 @@ function ScreenSizes({ afterFetchData }) {
function getFilterInfo(listItem) {
return {
prefix: 'screen',
filter: ["is", "screen", [listItem['name']]]
filter: ['is', 'screen', [listItem['name']]]
}
}
@ -270,7 +282,7 @@ function ScreenSizes({ afterFetchData }) {
metrics.createVisitors({ meta: { plot: true } }),
hasConversionGoalFilter(query) && metrics.createConversionRate(),
!hasConversionGoalFilter(query) && metrics.createPercentage()
].filter(metric => !!metric)
].filter((metric) => !!metric)
}
return (
@ -281,7 +293,10 @@ function ScreenSizes({ afterFetchData }) {
keyLabel="Screen size"
metrics={chooseMetrics()}
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
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') {
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') {
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') {
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>
}
export default function Devices() {
const { query } = useQueryContext();
const site = useSiteContext();
const { query } = useQueryContext()
const site = useSiteContext()
const tabKey = `deviceTab__${site.domain}`
const storedTab = storage.getItem(tabKey)
@ -347,9 +435,7 @@ export default function Devices() {
if (isActive) {
return (
<button
className="inline-block h-5 font-bold text-indigo-700 active-prop-heading dark:text-indigo-500"
>
<button className="inline-block h-5 font-bold text-indigo-700 active-prop-heading dark:text-indigo-500">
{name}
</button>
)
@ -370,7 +456,10 @@ export default function Devices() {
<div className="flex justify-between w-full">
<div className="flex gap-x-1">
<h3 className="font-bold dark:text-gray-100">Devices</h3>
<ImportedQueryUnsupportedWarning loading={loading} skipImportedReason={skipImportedReason} />
<ImportedQueryUnsupportedWarning
loading={loading}
skipImportedReason={skipImportedReason}
/>
</div>
<div className="flex text-xs font-medium text-gray-500 dark:text-gray-400 space-x-2">
{renderPill('Browser', 'browser')}

View File

@ -1,6 +1,8 @@
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 () {
return browserDateFormat.resolvedOptions().hour12
@ -19,7 +21,9 @@ const monthIntervalFormatter = {
const weekIntervalFormatter = {
long(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) {
return formatDayShort(parseUTCDate(isoDate), options.shouldShowYear)
@ -98,9 +102,20 @@ const factory = {
* @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.
*/
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 options = { period: period, interval: interval, isBucketPartial: !isPeriodFull, shouldShowYear }
const options = {
period: period,
interval: interval,
isBucketPartial: !isPeriodFull,
shouldShowYear
}
return function (isoDate, _index, _ticks) {
return factory[interval][displayMode](isoDate, options)
}

View File

@ -5,22 +5,36 @@ import { METRIC_LABELS } from './graph-util'
import { MetricFormatterShort } from '../reports/metric-formatter'
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]
if (comparison) isPeriodFull = true
const formattedLabel = dateFormatter({
interval: graphData.interval, longForm: true, period: query.period, isPeriodFull,
interval: graphData.interval,
longForm: true,
period: query.period,
isPeriodFull
})(label)
if (query.period === 'realtime') {
return dateFormatter({
interval: graphData.interval, longForm: true, period: query.period,
interval: graphData.interval,
longForm: true,
period: query.period
})(label)
}
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}`
}
@ -33,36 +47,59 @@ const calculatePercentageDifference = function(oldValue, newValue) {
} else if (oldValue == 0 && newValue == 0) {
return 0
} else {
return Math.round((newValue - oldValue) / oldValue * 100)
return Math.round(((newValue - oldValue) / oldValue) * 100)
}
}
const buildTooltipData = function (query, graphData, metric, tooltipModel) {
const data = tooltipModel.dataPoints.find((dataPoint) => dataPoint.dataset.yAxisID == "y")
const comparisonData = tooltipModel.dataPoints.find((dataPoint) => dataPoint.dataset.yAxisID == "yComparison")
const data = tooltipModel.dataPoints.find(
(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 comparisonLabel = comparisonData && renderBucketLabel(query, graphData, graphData.comparison_labels[comparisonData.dataIndex], true)
const label =
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 formatter = MetricFormatterShort[metric]
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 formattedComparisonValue = comparisonData && formatter(comparisonValue)
return { label, formattedValue, comparisonLabel, formattedComparisonValue, comparisonDifference }
return {
label,
formattedValue,
comparisonLabel,
formattedComparisonValue,
comparisonDifference
}
}
let tooltipRoot
export default function GraphTooltip(graphData, metric, query) {
return (context) => {
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')
if (!tooltipEl) {
@ -75,7 +112,8 @@ export default function GraphTooltip(graphData, metric, query) {
}
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.right = null
tooltipEl.style.opacity = 1
@ -87,26 +125,42 @@ export default function GraphTooltip(graphData, metric, query) {
}
if (tooltipModel.body) {
const tooltipData = buildTooltipData(query, graphData, metric, tooltipModel)
const tooltipData = buildTooltipData(
query,
graphData,
metric,
tooltipModel
)
tooltipRoot.render(
<aside className="text-gray-100 flex flex-col">
<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 ? (
<div className="inline-flex items-center space-x-1">
<ChangeArrow metric={metric} change={tooltipData.comparisonDifference} />
</div>) : null}
<ChangeArrow
metric={metric}
change={tooltipData.comparisonDifference}
/>
</div>
) : null}
</div>
{tooltipData.label ? (
<div className="flex flex-col">
<div className="flex flex-row justify-between items-center">
<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>
<span className="text-base font-bold">{tooltipData.formattedValue}</span>
<span className="text-base font-bold">
{tooltipData.formattedValue}
</span>
</div>
{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>
<span>{tooltipData.comparisonLabel}</span>
</span>
<span className="text-base font-bold">{tooltipData.formattedComparisonValue}</span>
</div>) : null}
<span className="text-base font-bold">
{tooltipData.formattedComparisonValue}
</span>
</div>
) : null}
</div>
) : null}
{graphData.interval === "month" ? (<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}
{graphData.interval === 'month' ? (
<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>
)
}

View File

@ -1,17 +1,17 @@
export const METRIC_LABELS = {
'visitors': 'Visitors',
'pageviews': 'Pageviews',
'events': 'Total Conversions',
'views_per_visit': 'Views per Visit',
'visits': 'Visits',
'bounce_rate': 'Bounce Rate',
'visit_duration': 'Visit Duration',
'conversions': 'Converted Visitors',
'conversion_rate': 'Conversion Rate',
'average_revenue': 'Average Revenue',
'total_revenue': 'Total Revenue',
'scroll_depth': 'Scroll Depth',
'time_on_page': 'Time on Page',
visitors: 'Visitors',
pageviews: 'Pageviews',
events: 'Total Conversions',
views_per_visit: 'Views per Visit',
visits: 'Visits',
bounce_rate: 'Bounce Rate',
visit_duration: 'Visit Duration',
conversions: 'Converted Visitors',
conversion_rate: 'Conversion Rate',
average_revenue: 'Average Revenue',
total_revenue: 'Total Revenue',
scroll_depth: 'Scroll Depth',
time_on_page: 'Time on Page'
}
function plottable(dataArray) {
@ -28,51 +28,70 @@ function plottable(dataArray) {
const buildComparisonDataset = function (comparisonPlot) {
if (!comparisonPlot) return []
return [{
return [
{
data: plottable(comparisonPlot),
borderColor: 'rgba(60,70,110,0.2)',
pointBackgroundColor: 'rgba(60,70,110,0.2)',
pointHoverBackgroundColor: 'rgba(60, 70, 110)',
yAxisID: 'yComparison',
}]
yAxisID: 'yComparison'
}
]
}
const buildDashedDataset = function (plot, presentIndex) {
if (!presentIndex) return []
const dashedPart = plot.slice(presentIndex - 1, presentIndex + 1);
const dashedPlot = (new Array(presentIndex - 1)).concat(dashedPart)
const dashedPart = plot.slice(presentIndex - 1, presentIndex + 1)
const dashedPlot = new Array(presentIndex - 1).concat(dashedPart)
return [{
return [
{
data: plottable(dashedPlot),
borderDash: [3, 3],
borderColor: 'rgba(101,116,205)',
pointHoverBackgroundColor: 'rgba(71, 87, 193)',
yAxisID: 'y',
}]
yAxisID: 'y'
}
]
}
const buildMainPlotDataset = function (plot, presentIndex) {
const data = presentIndex ? plot.slice(0, presentIndex) : plot
return [{
return [
{
data: plottable(data),
borderColor: 'rgba(101,116,205)',
pointBackgroundColor: 'rgba(101,116,205)',
pointHoverBackgroundColor: 'rgba(71, 87, 193)',
yAxisID: 'y',
}]
yAxisID: 'y'
}
]
}
export const buildDataSet = (plot, comparisonPlot, present_index, ctx, label) => {
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)');
export const buildDataSet = (
plot,
comparisonPlot,
present_index,
ctx,
label
) => {
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 = [
...buildMainPlotDataset(plot, present_index),

View File

@ -1,21 +1,26 @@
import React, { Fragment, useRef } from 'react';
import { Menu, Transition } from '@headlessui/react';
import { ChevronDownIcon } from '@heroicons/react/20/solid';
import classNames from 'classnames';
import * as storage from '../../util/storage';
import { BlurMenuButtonOnEscape, isModifierPressed, isTyping, Keybind } 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';
import React, { Fragment, useRef } from 'react'
import { Menu, Transition } from '@headlessui/react'
import { ChevronDownIcon } from '@heroicons/react/20/solid'
import classNames from 'classnames'
import * as storage from '../../util/storage'
import {
BlurMenuButtonOnEscape,
isModifierPressed,
isTyping,
Keybind
} 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 = {
'minute': 'Minutes',
'hour': 'Hours',
'day': 'Days',
'week': 'Weeks',
'month': 'Months'
minute: 'Minutes',
hour: 'Hours',
day: 'Days',
week: 'Weeks',
month: 'Months'
}
function validIntervals(site, query) {
@ -36,11 +41,11 @@ function validIntervals(site, query) {
function getDefaultInterval(query, validIntervals) {
const defaultByPeriod = {
'day': 'hour',
day: 'hour',
'7d': 'day',
'6mo': 'month',
'12mo': 'month',
'year': 'month'
year: 'month'
}
if (query.period === 'custom') {
@ -89,8 +94,8 @@ export const getCurrentInterval = function(site, query) {
export function IntervalPicker({ onIntervalUpdate }) {
const menuElement = useRef(null)
const {query} = useQueryContext();
const site = useSiteContext();
const { query } = useQueryContext()
const site = useSiteContext()
const dashboardRouteMatch = useMatch(rootRoute.path)
if (query.period == 'realtime') return null
@ -98,7 +103,6 @@ export function IntervalPicker({ onIntervalUpdate }) {
const options = validIntervals(site, query)
const currentInterval = getCurrentInterval(site, query)
function updateInterval(interval) {
storeInterval(query.period, site.domain, interval)
onIntervalUpdate(interval)
@ -106,13 +110,23 @@ export function IntervalPicker({ onIntervalUpdate }) {
function renderDropdownItem(option) {
return (
<Menu.Item onClick={() => updateInterval(option)} key={option} disabled={option == currentInterval}>
<Menu.Item
onClick={() => updateInterval(option)}
key={option}
disabled={option == currentInterval}
>
{({ active }) => (
<span className={classNames({
'bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-200 cursor-pointer': active,
<span
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,
'font-bold cursor-none select-none': option == currentInterval,
}, 'block px-4 py-2 text-sm')}>
'font-bold cursor-none select-none': option == currentInterval
},
'block px-4 py-2 text-sm'
)}
>
{INTERVAL_LABELS[option]}
</span>
)}
@ -144,11 +158,7 @@ export function IntervalPicker({ onIntervalUpdate }) {
<ChevronDownIcon className="ml-1 h-4 w-4" aria-hidden="true" />
</Menu.Button>
<Transition
as={Fragment}
show={open}
{...popover.transition.props}
>
<Transition as={Fragment} show={open} {...popover.transition.props}>
<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"
static

View File

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

View File

@ -1,13 +1,27 @@
import React from "react"
import React from 'react'
export default function SamplingNotice({ topStatData }) {
const samplePercent = topStatData?.samplePercent
if (samplePercent && samplePercent < 100) {
return (
<div tooltip={`Stats based on a ${samplePercent}% sample of all visitors`} 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" />
<div
tooltip={`Stats based on a ${samplePercent}% sample of all visitors`}
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>
</div>
)

View File

@ -1,17 +1,17 @@
import React, { useState } from "react";
import * as api from '../../api';
import { getCurrentInterval } from "./interval-picker";
import { useSiteContext } from "../../site-context";
import { useQueryContext } from "../../query-context";
import React, { useState } from 'react'
import * as api from '../../api'
import { getCurrentInterval } from './interval-picker'
import { useSiteContext } from '../../site-context'
import { useQueryContext } from '../../query-context'
export default function StatsExport() {
const site = useSiteContext();
const { query } = useQueryContext();
const site = useSiteContext()
const { query } = useQueryContext()
const [exporting, setExporting] = useState(false)
function startExport() {
setExporting(true)
document.cookie = "exporting="
document.cookie = 'exporting='
pollExportReady()
}
@ -25,21 +25,48 @@ export default function StatsExport() {
function renderLoading() {
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">
<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
className="animate-spin h-4 w-4 text-indigo-500"
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>
)
}
function renderExportLink() {
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}`
return (
<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>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>

View File

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

View File

@ -1,18 +1,18 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, { useState, useEffect, useRef, useCallback } from 'react';
import * as api from '../../api';
import * as storage from '../../util/storage';
import TopStats from './top-stats';
import { IntervalPicker, getCurrentInterval } from './interval-picker';
import StatsExport from './stats-export';
import WithImportedSwitch from './with-imported-switch';
import SamplingNotice from './sampling-notice';
import FadeIn from '../../fade-in';
import * as url from '../../util/url';
import { isComparisonEnabled } from '../../query-time-periods';
import LineGraphWithRouter from './line-graph';
import { useQueryContext } from '../../query-context';
import { useSiteContext } from '../../site-context';
import React, { useState, useEffect, useRef, useCallback } from 'react'
import * as api from '../../api'
import * as storage from '../../util/storage'
import TopStats from './top-stats'
import { IntervalPicker, getCurrentInterval } from './interval-picker'
import StatsExport from './stats-export'
import WithImportedSwitch from './with-imported-switch'
import SamplingNotice from './sampling-notice'
import FadeIn from '../../fade-in'
import * as url from '../../util/url'
import { isComparisonEnabled } from '../../query-time-periods'
import LineGraphWithRouter from './line-graph'
import { useQueryContext } from '../../query-context'
import { useSiteContext } from '../../site-context'
import { ExclamationCircleIcon } from '@heroicons/react/24/outline'
function fetchTopStats(site, query) {
@ -31,11 +31,12 @@ function fetchMainGraph(site, query, metric, interval) {
}
export default function VisitorGraph({ updateImportedDataInView }) {
const { query } = useQueryContext();
const site = useSiteContext();
const { query } = useQueryContext()
const site = useSiteContext()
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)
@ -50,18 +51,23 @@ export default function VisitorGraph({ updateImportedDataInView }) {
// long as new graph data is fetched.
const [graphRefreshing, setGraphRefreshing] = useState(false)
const onIntervalUpdate = useCallback((newInterval) => {
const onIntervalUpdate = useCallback(
(newInterval) => {
setGraphData(null)
setGraphRefreshing(true)
fetchGraphData(getStoredMetric(), newInterval)
}, [query])
},
[query]
)
const onMetricUpdate = useCallback((newMetric) => {
const onMetricUpdate = useCallback(
(newMetric) => {
setGraphData(null)
setGraphRefreshing(true)
fetchGraphData(newMetric, getCurrentInterval(site, query))
}, [query])
},
[query]
)
useEffect(() => {
setTopStatData(null)
@ -80,7 +86,9 @@ export default function VisitorGraph({ updateImportedDataInView }) {
}, [query])
useEffect(() => {
if (topStatData) { storeTopStatsContainerHeight() }
if (topStatData) {
storeTopStatsContainerHeight()
}
}, [topStatData])
async function fetchTopStatsAndGraphData() {
@ -107,8 +115,7 @@ export default function VisitorGraph({ updateImportedDataInView }) {
}
function fetchGraphData(metric, interval) {
fetchMainGraph(site, query, metric, interval)
.then((res) => {
fetchMainGraph(site, query, metric, interval).then((res) => {
setGraphData(res)
setGraphLoading(false)
setGraphRefreshing(false)
@ -120,7 +127,10 @@ export default function VisitorGraph({ updateImportedDataInView }) {
}
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
@ -136,16 +146,25 @@ export default function VisitorGraph({ updateImportedDataInView }) {
}
function importedSwitchVisible() {
return !!topStatData?.with_imported_switch && topStatData?.with_imported_switch.visible
return (
!!topStatData?.with_imported_switch &&
topStatData?.with_imported_switch.visible
)
}
function renderImportedIntervalUnsupportedWarning() {
const unsupportedInterval = ['hour', 'minute'].includes(getCurrentInterval(site, query))
const showingImported = importedSwitchVisible() && query.with_imported === true
const unsupportedInterval = ['hour', 'minute'].includes(
getCurrentInterval(site, query)
)
const showingImported =
importedSwitchVisible() && query.with_imported === true
return (
<FadeIn show={showingImported && unsupportedInterval} className="h-6 mr-1">
<span tooltip={"Interval is too short to graph imported data"}>
<FadeIn
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" />
</span>
</FadeIn>
@ -153,10 +172,19 @@ export default function VisitorGraph({ updateImportedDataInView }) {
}
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()}
<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
graphableMetrics={topStatData?.graphable_metrics || []}
data={topStatData}
@ -170,16 +198,19 @@ export default function VisitorGraph({ updateImportedDataInView }) {
{renderImportedIntervalUnsupportedWarning()}
{!isRealtime && <StatsExport />}
<SamplingNotice samplePercent={topStatData} />
{importedSwitchVisible() &&
{importedSwitchVisible() && (
<WithImportedSwitch
tooltipMessage={topStatData.with_imported_switch.tooltip_msg}
disabled={!topStatData.with_imported_switch.togglable}
/>
}
)}
<IntervalPicker onIntervalUpdate={onIntervalUpdate} />
</div>
<LineGraphWithRouter
graphData={{...graphData, interval: getCurrentInterval(site, query)}}
graphData={{
...graphData,
interval: getCurrentInterval(site, query)
}}
darkTheme={isDarkTheme}
/>
</div>

View File

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

View File

@ -1,12 +1,22 @@
import React from "react";
import React from 'react'
import { ExclamationCircleIcon } from '@heroicons/react/24/outline'
import FadeIn from "../fade-in";
import { useQueryContext } from "../query-context";
import FadeIn from '../fade-in'
import { useQueryContext } from '../query-context'
export default function ImportedQueryUnsupportedWarning({ loading, skipImportedReason, 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'
export default function ImportedQueryUnsupportedWarning({
loading,
skipImportedReason,
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) {
return (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,3 @@
/** @format */
import React, { ReactNode } from 'react'
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 BreakdownModal from "./breakdown-modal";
import * as metrics from "../reports/metrics";
import * as url from '../../util/url';
import { useSiteContext } from "../../site-context";
import { addFilter } from "../../query";
import BreakdownModal from './breakdown-modal'
import * as metrics from '../reports/metrics'
import * as url from '../../util/url'
import { useSiteContext } from '../../site-context'
import { addFilter } from '../../query'
/*global BUILD_EXTRA*/
function ConversionsModal() {
const [showRevenue, setShowRevenue] = useState(false)
const site = useSiteContext();
const site = useSiteContext()
const reportInfo = {
title: 'Goal Conversions',
dimension: 'goal',
endpoint: url.apiPath(site, '/conversions'),
dimensionLabel: "Goal"
dimensionLabel: 'Goal'
}
const getFilterInfo = useCallback((listItem) => {
const getFilterInfo = useCallback(
(listItem) => {
return {
prefix: reportInfo.dimension,
filter: ["is", reportInfo.dimension, [listItem.name]]
filter: ['is', reportInfo.dimension, [listItem.name]]
}
}, [reportInfo.dimension])
},
[reportInfo.dimension]
)
const addSearchFilter = useCallback((query, searchString) => {
return addFilter(query, ['contains', reportInfo.dimension, [searchString], { case_sensitive: false }])
}, [reportInfo.dimension])
const addSearchFilter = useCallback(
(query, searchString) => {
return addFilter(query, [
'contains',
reportInfo.dimension,
[searchString],
{ case_sensitive: false }
])
},
[reportInfo.dimension]
)
function chooseMetrics() {
return [
metrics.createVisitors({ renderLabel: (_query) => "Uniques" }),
metrics.createEvents({ renderLabel: (_query) => "Total" }),
metrics.createVisitors({ renderLabel: (_query) => 'Uniques' }),
metrics.createEvents({ renderLabel: (_query) => 'Total' }),
metrics.createConversionRate(),
showRevenue && metrics.createAverageRevenue(),
showRevenue && metrics.createTotalRevenue(),
].filter(metric => !!metric)
showRevenue && metrics.createTotalRevenue()
].filter((metric) => !!metric)
}
// 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
// `false` as revenue metrics might exist in previously loaded data.
const afterFetchNextPage = useCallback((res) => {
if (!showRevenue && revenueInResponse(res)) { setShowRevenue(true) }
}, [showRevenue])
const afterFetchNextPage = useCallback(
(res) => {
if (!showRevenue && revenueInResponse(res)) {
setShowRevenue(true)
}
},
[showRevenue]
)
function revenueInResponse(apiResponse) {
return apiResponse.results.some((item) => item.total_revenue)

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