Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions assets/js/dashboard/components/graph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,8 @@ function InnerGraph<T extends GraphYValues>({
svg,
series,
x: point.x,
y: point.values[seriesIndex]
y: point.values[seriesIndex],
bucketIndex: i
})
points[i] = {
...point,
Expand Down Expand Up @@ -800,6 +801,7 @@ function drawLine<T extends GraphYValues>({
svg
.append('path')
.attr('class', classNames(className))
.attr('data-testid', 'graph-line')
.datum(datum)
.attr('d', line)
}
Expand All @@ -808,12 +810,14 @@ function drawDot({
svg,
series,
x,
y
y,
bucketIndex
}: {
svg: SelectedSVG
series: SeriesConfig
x: number
y: number | null
bucketIndex: number
}): SelectedGroup {
const group = svg.append('g').attr('class', 'group')
if (series.dot && y !== null) {
Expand All @@ -822,6 +826,7 @@ function drawDot({
.attr('r', 2.5)
.attr('class', series.dot.dotClassName)
.attr('transform', `translate(${x},${y})`)
.attr('data-testid', `graph-dot-group-${bucketIndex}`)
}
return group
}
Expand Down
2 changes: 1 addition & 1 deletion assets/js/dashboard/custom-hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export function useMountedEffect(fn, deps) {
}, deps)
}

const DEBOUNCE_DELAY = 300
export const DEBOUNCE_DELAY = 300

export function useDebounce(fn, delay = DEBOUNCE_DELAY) {
const timerRef = useRef(null)
Expand Down
9 changes: 3 additions & 6 deletions assets/js/dashboard/stats/graph/fetch-main-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { useDashboardStateContext } from '../../dashboard-state-context'
import { UseQueryResult } from '@tanstack/react-query'

export function useMainGraphQuery(
metric: Metric | null,
metric: Metric,
interval: Interval
): {
apiState: UseQueryResult<MainGraphResponse>
Expand All @@ -28,7 +28,7 @@ export function useMainGraphQuery(
{
dashboardState,
reportParams: {
metrics: [metricToQuery!],
metrics: [metricToQuery],
dimensions: [`time:${interval}`],
include: {
time_labels: true,
Expand All @@ -43,10 +43,7 @@ export function useMainGraphQuery(
const { apiState, isRealtimeSilentUpdate } = useQueryApi<MainGraphResponse>(
site,
mainGraphQueryKey,
{
getStatsQuery: getMainGraphQuery,
enabled: !!metric
}
{ getStatsQuery: getMainGraphQuery }
)

return { apiState, isRealtimeSilentUpdate }
Expand Down
25 changes: 20 additions & 5 deletions assets/js/dashboard/stats/graph/main-graph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -463,9 +463,15 @@ const MainGraphTooltip = ({
typeof onClick !== 'function' && 'pointer-events-none'
)}
>
<aside className="text-sm font-normal text-gray-100 flex flex-col gap-1.5">
<aside
data-testid="graph-tooltip"
className="text-sm font-normal text-gray-100 flex flex-col gap-1.5"
>
<div className="flex justify-between items-center rounded-sm">
<div className="font-semibold mr-4 text-xs uppercase whitespace-nowrap">
<div
data-testid="metric-label"
className="font-semibold mr-4 text-xs uppercase whitespace-nowrap"
>
{metricLabel}
</div>
{comparison.isDefined && typeof change === 'number' && (
Expand All @@ -481,7 +487,10 @@ const MainGraphTooltip = ({
<div className="flex flex-row justify-between items-center">
<div className="flex items-center mr-4">
<div className="size-2 flex-none mr-2 rounded-full bg-indigo-400" />
<div className="whitespace-nowrap">
<div
data-testid="main-time-label"
className="whitespace-nowrap"
>
{getFullBucketLabel(main.timeLabel, {
isRealtime,
interval,
Expand All @@ -493,7 +502,10 @@ const MainGraphTooltip = ({
})}
</div>
</div>
<div className="font-bold whitespace-nowrap">
<div
data-testid="main-value"
className="font-bold whitespace-nowrap"
>
{getFormattedValue(main.value)}
</div>
</div>
Expand All @@ -515,7 +527,10 @@ const MainGraphTooltip = ({
})}
</div>
</div>
<div className="font-bold whitespace-nowrap">
<div
data-testid="comparison-value"
className="font-bold whitespace-nowrap"
>
{' '}
{getFormattedValue(comparison.value)}
</div>
Expand Down
162 changes: 162 additions & 0 deletions assets/js/dashboard/stats/graph/visitor-graph.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import React from 'react'
import { render, waitForElementToBeRemoved } from '@testing-library/react'
import { TestContextProviders } from '../../../../test-utils/app-context-providers'
import VisitorGraph from './visitor-graph'
import { MockAPI } from '../../../../test-utils/mock-api'

// jsdom doesn't implement ResizeObserver (used by useMainGraphWidth and useGuessTopStatsHeight)
global.ResizeObserver = class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}

const LOADING_SPINNER = '[data-testid="loading-spinner"]'

const domain = 'dummy.site'
const queryPath = `/api/stats/${domain}/query/`
const metricStorageKey = `metric__${domain}`

// Default metrics shown in the top stats bar without any filter active
const DEFAULT_TOP_STATS_METRICS = [
'visitors',
'visits',
'pageviews',
'views_per_visit',
'bounce_rate',
'visit_duration'
]

function buildTopStatsResponse(metrics = DEFAULT_TOP_STATS_METRICS) {
return {
query: {
metrics,
dimensions: [],
date_range: ['2024-01-01', '2024-01-28']
},
meta: {},
results: [{ dimensions: [], metrics: metrics.map(() => 0) }]
}
}

function buildMainGraphResponse(metric: string) {
return {
query: {
metrics: [metric],
dimensions: ['time:day'],
date_range: ['2024-01-01', '2024-01-28']
},
meta: {
time_labels: [],
time_label_result_indices: [],
partial_time_labels: null,
comparison_partial_time_labels: null,
empty_metrics: [0],
present_index: 0
},
results: [],
comparison_results: []
}
}

let mockAPI: MockAPI

beforeAll(() => {
mockAPI = new MockAPI().start()
})

afterAll(() => {
mockAPI.stop()
})

beforeEach(() => {
mockAPI.clear()
localStorage.clear()
})

describe('main graph metric selection', () => {
// sets up a single handler that returns either a top stats response (when
// requested dimensions = []) or a graph response. Collects metrics that
// have been requested by the graph into an array and returns it.
function setupQueryHandler(topStatsMetrics = DEFAULT_TOP_STATS_METRICS) {
const mainGraphCalledWithMetrics: string[] = []

mockAPI.post(
queryPath,
async (_input: RequestInfo | URL, init?: RequestInit) => {
const body = JSON.parse(init!.body as string) as {
metrics: string[]
dimensions: string[]
}

if (body.dimensions.length === 0) {
return {
status: 200,
ok: true,
json: async () => buildTopStatsResponse(topStatsMetrics)
} as Response
}

const metric = body.metrics[0]
mainGraphCalledWithMetrics.push(metric)
return {
status: 200,
ok: true,
json: async () => buildMainGraphResponse(metric)
} as Response
}
)

return { mainGraphCalledWithMetrics }
}

test('no stored metric → defaults to visitors, single graph request', async () => {
const { mainGraphCalledWithMetrics } = setupQueryHandler()

render(
<TestContextProviders siteOptions={{ domain }}>
<VisitorGraph />
</TestContextProviders>
)

await waitForElementToBeRemoved(() =>
document.querySelector(LOADING_SPINNER)
)

expect(mainGraphCalledWithMetrics).toEqual(['visitors'])
})

test('valid stored metric -> initial metric from storage, single graph request', async () => {
localStorage.setItem(metricStorageKey, 'pageviews')
const { mainGraphCalledWithMetrics } = setupQueryHandler()

render(
<TestContextProviders siteOptions={{ domain }}>
<VisitorGraph />
</TestContextProviders>
)

await waitForElementToBeRemoved(() =>
document.querySelector(LOADING_SPINNER)
)

expect(mainGraphCalledWithMetrics).toEqual(['pageviews'])
})

test('invalid stored metric -> initial fetch with stored metric, corrected to default after top stats load', async () => {
localStorage.setItem(metricStorageKey, 'scroll_depth')
const { mainGraphCalledWithMetrics } = setupQueryHandler()

render(
<TestContextProviders siteOptions={{ domain }}>
<VisitorGraph />
</TestContextProviders>
)

await waitForElementToBeRemoved(() =>
document.querySelector(LOADING_SPINNER)
)

expect(mainGraphCalledWithMetrics).toEqual(['scroll_depth', 'visitors'])
})
})
21 changes: 10 additions & 11 deletions assets/js/dashboard/stats/graph/visitor-graph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { useSetImportsIncluded } from './imports-included-context'

// height of at least one row of top stats
const DEFAULT_TOP_STATS_LOADING_HEIGHT_PX = 85
const DEFAULT_GRAPH_METRIC = 'visitors'

export default function VisitorGraph({
updateImportedDataInView
Expand All @@ -24,14 +25,8 @@ export default function VisitorGraph({

const { selectedInterval } = useGraphIntervalContext()

// Possible future improvement -- currently, if there's no stored metric,
// the graph fetch doesn't run until Top Stats are loaded. That's because
// Top Stats tell us which metrics are available for the graph. However,
// as things stand today, the `visitors` metric is always available and
// could become the default selectedMetric, making it possible to fetch
// the graph instantly.
const [selectedMetric, setSelectedMetric] = useState<Metric | null>(
getStoredMetric(site)
const [selectedMetric, setSelectedMetric] = useState<Metric>(
getStoredMetric(site) || DEFAULT_GRAPH_METRIC
)
const onMetricClick = useCallback(
(metric: Metric) => {
Expand All @@ -51,7 +46,8 @@ export default function VisitorGraph({
isRealtimeSilentUpdate: isMainGraphRealtimeSilentUpdate
} = useMainGraphQuery(selectedMetric, selectedInterval)

// update metric to one that exists
// Fall back to default graph metric if the stored metric
// does not exist in the returned Top Stats
useEffect(() => {
if (topStatsApiState.data) {
const availableMetrics = topStatsApiState.data.query.metrics
Expand All @@ -63,7 +59,7 @@ export default function VisitorGraph({
) {
return currentlySelectedMetric
} else {
return availableMetrics[0]
return DEFAULT_GRAPH_METRIC
}
})
}
Expand Down Expand Up @@ -159,7 +155,10 @@ export default function VisitorGraph({

const Loader = () => {
return (
<div className="absolute inset-0 bg-white rounded-md dark:bg-gray-900 flex items-center justify-center">
<div
data-testid="loading-spinner"
className="absolute inset-0 bg-white rounded-md dark:bg-gray-900 flex items-center justify-center"
>
<div className="loading">
<div></div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion assets/js/dashboard/stats/modals/breakdown-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ const InitialLoadingSpinner = () => (
)

const SmallLoadingSpinner = () => (
<div className="loading sm">
<div data-testid="small-loading-spinner" className="loading sm">
<div />
</div>
)
Expand Down
Loading
Loading