analytics/assets/js/dashboard/stats/graph/visitor-graph.js

229 lines
6.8 KiB
JavaScript

/* 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 { getSamplingNotice, NoticesIcon } from './notices'
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'
function fetchTopStats(site, query) {
const q = { ...query }
if (!isComparisonEnabled(q.comparison) && query.period !== 'realtime') {
q.comparison = 'previous_period'
}
return api.get(url.apiPath(site, '/top-stats'), q)
}
function fetchMainGraph(site, query, metric, interval) {
const params = { metric, interval }
return api.get(url.apiPath(site, '/main-graph'), query, params)
}
export default function VisitorGraph({ updateImportedDataInView }) {
const { query } = useQueryContext()
const site = useSiteContext()
const isRealtime = query.period === 'realtime'
const isDarkTheme =
document.querySelector('html').classList.contains('dark') || false
const topStatsBoundary = useRef(null)
const [topStatData, setTopStatData] = useState(null)
const [topStatsLoading, setTopStatsLoading] = useState(true)
const [graphData, setGraphData] = useState(null)
const [graphLoading, setGraphLoading] = useState(true)
// This state is explicitly meant for the situation where either graph interval
// or graph metric is changed. That results in behaviour where Top Stats stay
// intact, but the graph container alone will display a loading spinner for as
// long as new graph data is fetched.
const [graphRefreshing, setGraphRefreshing] = useState(false)
const onIntervalUpdate = useCallback(
(newInterval) => {
setGraphData(null)
setGraphRefreshing(true)
fetchGraphData(getStoredMetric(), newInterval)
},
[query]
)
const onMetricUpdate = useCallback(
(newMetric) => {
setGraphData(null)
setGraphRefreshing(true)
fetchGraphData(newMetric, getCurrentInterval(site, query))
},
[query]
)
useEffect(() => {
setTopStatData(null)
setTopStatsLoading(true)
setGraphData(null)
setGraphLoading(true)
fetchTopStatsAndGraphData()
if (isRealtime) {
document.addEventListener('tick', fetchTopStatsAndGraphData)
}
return () => {
document.removeEventListener('tick', fetchTopStatsAndGraphData)
}
}, [query])
useEffect(() => {
if (topStatData) {
storeTopStatsContainerHeight()
}
}, [topStatData])
async function fetchTopStatsAndGraphData() {
const response = await fetchTopStats(site, query)
let metric = getStoredMetric()
const availableMetrics = response.graphable_metrics
if (!availableMetrics.includes(metric)) {
metric = availableMetrics[0]
storage.setItem(`metric__${site.domain}`, metric)
}
const interval = getCurrentInterval(site, query)
if (response.updateImportedDataInView) {
updateImportedDataInView(response.includes_imported)
}
setTopStatData(response)
setTopStatsLoading(false)
fetchGraphData(metric, interval)
}
function fetchGraphData(metric, interval) {
fetchMainGraph(site, query, metric, interval).then((res) => {
setGraphData(res)
setGraphLoading(false)
setGraphRefreshing(false)
})
}
function getStoredMetric() {
return storage.getItem(`metric__${site.domain}`)
}
function storeTopStatsContainerHeight() {
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
// loading process. The container height depends on how many top stat metrics are returned
// from the API, but in the loading state, we don't know that yet. We can use localStorage
// to keep track of the Top Stats container height.
function getTopStatsHeight() {
if (topStatData) {
return 'auto'
} else {
return `${storage.getItem(`topStatsHeight__${site.domain}`) || 89}px`
}
}
function importedSwitchVisible() {
return (
!!topStatData?.with_imported_switch &&
topStatData?.with_imported_switch.visible
)
}
function getImportedIntervalUnsupportedNotice() {
const unsupportedInterval = ['hour', 'minute'].includes(
getCurrentInterval(site, query)
)
const showingImported =
importedSwitchVisible() && query.with_imported === true
if (showingImported && unsupportedInterval) {
return 'Interval is too short to graph imported data'
}
return null
}
return (
<div
className={
'relative w-full mt-2 bg-white rounded-md shadow dark:bg-gray-900'
}
>
{(topStatsLoading || graphLoading) && renderLoader()}
<FadeIn show={!(topStatsLoading || graphLoading)}>
<div
id="top-stats-container"
className="flex flex-wrap"
ref={topStatsBoundary}
style={{ height: getTopStatsHeight() }}
>
<TopStats
graphableMetrics={topStatData?.graphable_metrics || []}
data={topStatData}
onMetricUpdate={onMetricUpdate}
tooltipBoundary={topStatsBoundary.current}
/>
</div>
<div className="relative px-2">
{graphRefreshing && renderLoader()}
<div className="absolute right-4 -top-8 py-1 flex items-center gap-x-4">
<NoticesIcon
notices={[
getImportedIntervalUnsupportedNotice(),
getSamplingNotice(topStatData)
].filter((n) => !!n)}
/>
{!isRealtime && <StatsExport />}
{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)
}}
darkTheme={isDarkTheme}
/>
</div>
</FadeIn>
</div>
)
}
function renderLoader() {
return (
<div className="absolute h-full w-full flex items-center justify-center">
<div className="loading">
<div></div>
</div>
</div>
)
}