Skip to content

Commit 82c7b0e

Browse files
authored
Refactor FE API v2 interface + current visitors bugfix (#6351)
* more specific type to event:props:* dimension * rename use-order-by.ts -> use-metric-order-by.ts * SortDirection: use string union type from query-api-schema instead of enum * use query-api-schema pagination type too * add proper OrderBy type (with dimensions) and separate MetricOrderBy * use NonTimeDimension type in v2 breakdown components * also create a Dimension type (incl time dims) * create use-query-api.ts - usePaginatedQueryApi -> useSearchAndPaginateQueryApi (include addSearchFilter logic in the hook) - enforce dimensions on ReportParams type and make it a part of queryKey - define explicit StatsReportQueryKey type * add a useQueryApi hook and use it in IndexBreakdown The new hook also includes automatic realtime re-fetch logic. * Add CurrentVisitorsContext - fixes a bug: current visitors in realtime top stats, in shared links limited to segment, are fetched from /query api which enforces segment filter -> 400 Bad Request - uses Tanstack useQuery against the old GET endpoint - The value is now also cached for 30s (CACHE_TTL_REALTIME) - Do not fetch current visitors at all when not needed (i.e. not realtime and dashboard has filters applied). - The same context value is now used by both top-stats.js and current-visitors.js * top stats and main graph to useQueryApi * DetailsBreakdown: addSearchFilter -> searchEnabled * extractIntervalFromDimensions utility * create new statsQuery with spread * remove redundant useMemo * remove redundant as type casts * move and improve comment
1 parent f428d38 commit 82c7b0e

38 files changed

Lines changed: 834 additions & 790 deletions

assets/js/dashboard/api.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { StatsQuery } from './stats-query'
55
import { formatISO } from './util/date'
66
import { serializeApiFilters } from './util/filters'
77
import * as url from './util/url'
8+
import { MainGraphResponse } from './stats/graph/fetch-main-graph'
89

910
let abortController = new AbortController()
1011
let SHARED_LINK_AUTH: null | string = null
@@ -37,10 +38,21 @@ export type QueryResultRow = {
3738
comparison?: { metrics: Array<number>; change: Array<number> }
3839
}
3940

41+
// Added client-side in the queryFn before storing to TanStack cache.
42+
// Needed to make sure that the time/metric labels we're constructing
43+
// in stats reports are in sync with the dashboardState that was used
44+
// to make that query. Otherwise, relying on current dashboardState
45+
// while rendering previous (placeholder) data, it'd be out of sync.
46+
export type ExtraContext = {
47+
isRealtime: boolean
48+
hasConversionGoalFilter: boolean
49+
}
50+
4051
export type QueryApiResponse = {
4152
query: QueryResultQuery
4253
meta: QueryResultMeta
4354
results: QueryResultRow[]
55+
extraContext: ExtraContext
4456
}
4557

4658
export class ApiError extends Error {
@@ -141,7 +153,9 @@ function getSharedLinkSearchParams(): Record<string, string> {
141153
return SHARED_LINK_AUTH ? { auth: SHARED_LINK_AUTH } : {}
142154
}
143155

144-
export async function stats(site: PlausibleSite, statsQuery: StatsQuery) {
156+
export async function stats<
157+
TResponse extends QueryApiResponse | MainGraphResponse
158+
>(site: PlausibleSite, statsQuery: StatsQuery) {
145159
const sharedLinkParams = getSharedLinkSearchParams()
146160
const queryString = sharedLinkParams.auth
147161
? new URLSearchParams(sharedLinkParams).toString()
@@ -158,7 +172,7 @@ export async function stats(site: PlausibleSite, statsQuery: StatsQuery) {
158172
body: JSON.stringify(statsQuery)
159173
})
160174

161-
return handleApiResponse(response)
175+
return (await handleApiResponse(response)) as TResponse
162176
}
163177

164178
export async function get(

assets/js/dashboard/components/sort-button.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React, { ReactNode } from 'react'
2-
import { cycleSortDirection, SortDirection } from '../hooks/use-order-by-legacy'
2+
import { cycleSortDirection } from '../hooks/use-order-by-legacy'
3+
import { SortDirection } from '../../types/query-api'
34
import classNames from 'classnames'
45

56
export const SortButton = ({
@@ -29,8 +30,8 @@ export const SortButton = ({
2930
'rounded inline-block size-4',
3031
'ml-1',
3132
{
32-
[SortDirection.asc]: 'rotate-180',
33-
[SortDirection.desc]: 'rotate-0'
33+
asc: 'rotate-180',
34+
desc: 'rotate-0'
3435
}[sortDirection ?? next.direction],
3536
!sortDirection && 'opacity-0',
3637
!sortDirection && 'group-hover:opacity-100',

assets/js/dashboard/components/table-legacy.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import classNames from 'classnames'
22
import React, { ReactNode } from 'react'
3-
import { SortDirection } from '../hooks/use-order-by-legacy'
3+
import { SortDirection } from '../../types/query-api'
44
import { SortButton } from './sort-button'
55
import { Tooltip } from '../util/tooltip'
66

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import React, { createContext, useContext, useEffect } from 'react'
2+
import { useQuery, useQueryClient } from '@tanstack/react-query'
3+
import { get } from './api'
4+
import { useSiteContext } from './site-context'
5+
import { useDashboardStateContext } from './dashboard-state-context'
6+
import { CACHE_TTL_REALTIME } from './hooks/api-client'
7+
import { isRealTimeDashboard } from './util/filters'
8+
9+
const CurrentVisitorsContext = createContext<number | null>(null)
10+
11+
export function CurrentVisitorsProvider({
12+
children
13+
}: {
14+
children: React.ReactNode
15+
}) {
16+
const site = useSiteContext()
17+
const { dashboardState } = useDashboardStateContext()
18+
const queryClient = useQueryClient()
19+
20+
const isEnabled =
21+
isRealTimeDashboard(dashboardState) || dashboardState.filters.length === 0
22+
23+
const { data } = useQuery<number>({
24+
queryKey: ['current-visitors'],
25+
queryFn: () =>
26+
get(`/api/stats/${encodeURIComponent(site.domain)}/current-visitors`),
27+
staleTime: CACHE_TTL_REALTIME,
28+
enabled: isEnabled
29+
})
30+
31+
useEffect(() => {
32+
const onTick = () => {
33+
queryClient.invalidateQueries({ queryKey: ['current-visitors'] })
34+
}
35+
document.addEventListener('tick', onTick)
36+
return () => document.removeEventListener('tick', onTick)
37+
}, [queryClient])
38+
39+
return (
40+
<CurrentVisitorsContext.Provider value={isEnabled ? (data ?? null) : null}>
41+
{children}
42+
</CurrentVisitorsContext.Provider>
43+
)
44+
}
45+
46+
export const useCurrentVisitorsContext = () =>
47+
useContext(CurrentVisitorsContext)

assets/js/dashboard/hooks/api-client.ts

Lines changed: 1 addition & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@ import {
1313
} from '../dashboard-time-periods'
1414
import { REALTIME_UPDATE_TIME_MS } from '../util/realtime-update-timer'
1515
import { Interval, validIntervals } from '../stats/graph/intervals'
16-
import { PlausibleSite } from '../site-context'
17-
import { StatsQuery } from '../stats-query'
1816

1917
// define (in ms) when query API responses should become stale
2018
export const CACHE_TTL_REALTIME = REALTIME_UPDATE_TIME_MS
@@ -23,7 +21,7 @@ export const CACHE_TTL_LONG_ONGOING = 60 * 60 * 1000 // 1 hour
2321
export const CACHE_TTL_HISTORICAL = 12 * 60 * 60 * 1000 // 12 hours
2422

2523
// how many items per page for breakdown modals
26-
const PAGINATION_LIMIT = 100
24+
export const PAGINATION_LIMIT = 100
2725

2826
/** full endpoint URL */
2927
type Endpoint = string
@@ -34,54 +32,6 @@ type GetRequestParams<TKey extends PaginatedQueryKeyBase> = (
3432
k: TKey
3533
) => [DashboardState, Record<string, unknown>]
3634

37-
/**
38-
* Hook for paginated POST /api/stats/:domain/query requests (i.e. Details views).
39-
*/
40-
export function usePaginatedQueryAPI({
41-
site,
42-
dashboardState,
43-
statsQuery
44-
}: {
45-
site: PlausibleSite
46-
dashboardState: DashboardState
47-
statsQuery: StatsQuery
48-
}) {
49-
const queryClient = useQueryClient()
50-
const dimensionKey = statsQuery.dimensions.join(',')
51-
52-
useEffect(() => {
53-
return () => {
54-
const tanstackQueryFilters: QueryFilters = {
55-
predicate: ({ queryKey }) => queryKey[0] === dimensionKey
56-
}
57-
queryClient.setQueriesData(tanstackQueryFilters, cleanToPageOne)
58-
}
59-
}, [queryClient, dimensionKey])
60-
61-
return useInfiniteQuery({
62-
queryKey: [dimensionKey, statsQuery],
63-
queryFn: async ({ pageParam }): Promise<api.QueryApiResponse> => {
64-
return api.stats(site, {
65-
...statsQuery,
66-
pagination: { limit: PAGINATION_LIMIT, offset: pageParam as number }
67-
})
68-
},
69-
getNextPageParam: (lastPage, _, lastPageParam) => {
70-
return lastPage.results.length === PAGINATION_LIMIT
71-
? (lastPageParam as number) + PAGINATION_LIMIT
72-
: null
73-
},
74-
staleTime: () =>
75-
getStaleTime({
76-
siteTimezoneOffset: site.offset,
77-
siteStatsBegin: site.statsBegin,
78-
...dashboardState
79-
}),
80-
initialPageParam: 0,
81-
placeholderData: (previousData) => previousData
82-
})
83-
}
84-
8535
/**
8636
* Hook that fetches the first page from the defined GET endpoint on mount,
8737
* then subsequent pages when component calls fetchNextPage.

assets/js/dashboard/hooks/use-order-by.test.ts renamed to assets/js/dashboard/hooks/use-metric-order-by.test.ts

Lines changed: 18 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,38 @@
11
import { Metric } from '../stats/metrics'
22
import {
3-
OrderBy,
4-
SortDirection,
3+
MetricOrderBy,
54
cycleSortDirection,
65
getOrderByStorageKey,
76
getStoredOrderBy,
87
maybeStoreOrderBy,
98
rearrangeOrderBy,
109
validateOrderBy
11-
} from './use-order-by'
10+
} from './use-metric-order-by'
1211

1312
describe(`${cycleSortDirection.name}`, () => {
1413
test.each([
1514
[
1615
null,
1716
{
18-
direction: SortDirection.desc,
17+
direction: 'desc',
1918
hint: 'Press to sort column in descending order'
2019
}
2120
],
2221
[
23-
SortDirection.desc,
22+
'desc',
2423
{
25-
direction: SortDirection.asc,
24+
direction: 'asc',
2625
hint: 'Press to sort column in ascending order'
2726
}
2827
],
2928
[
30-
SortDirection.asc,
29+
'asc',
3130
{
32-
direction: SortDirection.desc,
31+
direction: 'desc',
3332
hint: 'Press to sort column in descending order'
3433
}
3534
]
36-
])(
35+
] as const)(
3736
'for current direction %p returns %p',
3837
(currentDirection, expectedOutput) => {
3938
expect(cycleSortDirection(currentDirection)).toEqual(expectedOutput)
@@ -42,22 +41,10 @@ describe(`${cycleSortDirection.name}`, () => {
4241
})
4342

4443
describe(`${rearrangeOrderBy.name}`, () => {
45-
const cases: [Metric, OrderBy, OrderBy][] = [
46-
[
47-
'visitors',
48-
[['visitors', SortDirection.asc]],
49-
[['visitors', SortDirection.desc]]
50-
],
51-
[
52-
'visitors',
53-
[['visitors', SortDirection.desc]],
54-
[['visitors', SortDirection.asc]]
55-
],
56-
[
57-
'visit_duration',
58-
[['visitors', SortDirection.asc]],
59-
[['visit_duration', SortDirection.desc]]
60-
]
44+
const cases: [Metric, MetricOrderBy, MetricOrderBy][] = [
45+
['visitors', [['visitors', 'asc']], [['visitors', 'desc']]],
46+
['visitors', [['visitors', 'desc']], [['visitors', 'asc']]],
47+
['visit_duration', [['visitors', 'asc']], [['visit_duration', 'desc']]]
6148
]
6249
it.each(cases)(
6350
`[%#] clicking on %p yields expected order`,
@@ -96,7 +83,7 @@ describe(`storing detailed report preferred order`, () => {
9683

9784
it('does not store invalid value', () => {
9885
maybeStoreOrderBy({
99-
orderBy: [['total_visitors', SortDirection.desc]],
86+
orderBy: [['total_visitors', 'desc']],
10087
domain,
10188
dimensionLabel,
10289
metrics: ['total_visitors']
@@ -108,7 +95,7 @@ describe(`storing detailed report preferred order`, () => {
10895

10996
it('falls back to fallbackValue if metric has become unsortable between storing and retrieving', () => {
11097
maybeStoreOrderBy({
111-
orderBy: [['visitors', SortDirection.desc]],
98+
orderBy: [['visitors', 'desc']],
11299
domain,
113100
dimensionLabel,
114101
metrics: ['visitors']
@@ -122,13 +109,13 @@ describe(`storing detailed report preferred order`, () => {
122109
domain,
123110
dimensionLabel,
124111
metrics: ['total_visitors'],
125-
fallbackValue: [['visitors', SortDirection.desc]]
112+
fallbackValue: [['visitors', 'desc']]
126113
})
127-
).toEqual([['visitors', SortDirection.desc]])
114+
).toEqual([['visitors', 'desc']])
128115
})
129116

130117
it('retrieves stored value correctly', () => {
131-
const input: OrderBy = [['visitors', SortDirection.asc]]
118+
const input: MetricOrderBy = [['visitors', 'asc']]
132119
localStorage.setItem(
133120
getOrderByStorageKey(domain, dimensionLabel),
134121
JSON.stringify(input)
@@ -138,7 +125,7 @@ describe(`storing detailed report preferred order`, () => {
138125
domain,
139126
dimensionLabel,
140127
metrics: ['visitors'],
141-
fallbackValue: [['visitors', SortDirection.desc]]
128+
fallbackValue: [['visitors', 'desc']]
142129
})
143130
).toEqual(input)
144131
})

0 commit comments

Comments
 (0)