Skip to content
Merged
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
18 changes: 16 additions & 2 deletions assets/js/dashboard/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { StatsQuery } from './stats-query'
import { formatISO } from './util/date'
import { serializeApiFilters } from './util/filters'
import * as url from './util/url'
import { MainGraphResponse } from './stats/graph/fetch-main-graph'

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

// Added client-side in the queryFn before storing to TanStack cache.
// Needed to make sure that the time/metric labels we're constructing
// in stats reports are in sync with the dashboardState that was used
// to make that query. Otherwise, relying on current dashboardState
// while rendering previous (placeholder) data, it'd be out of sync.
export type ExtraContext = {
isRealtime: boolean
hasConversionGoalFilter: boolean
}

export type QueryApiResponse = {
query: QueryResultQuery
meta: QueryResultMeta
results: QueryResultRow[]
extraContext: ExtraContext
}
Comment on lines +41 to 56
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue, non-blocking: I think useQuery has API for this so we don't actually need to hand roll it. Check the description for meta at https://tanstack.com/query/latest/docs/framework/react/reference/useQuery. I didn't try it yet locally, but I think we should

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think that solves the problem with rendering previousData. As soon as queryKey changes, meta does too. In other words, it will always represent the dashboardState of the current (new) query, rather than placeholderData.


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

export async function stats(site: PlausibleSite, statsQuery: StatsQuery) {
export async function stats<
TResponse extends QueryApiResponse | MainGraphResponse
>(site: PlausibleSite, statsQuery: StatsQuery) {
const sharedLinkParams = getSharedLinkSearchParams()
const queryString = sharedLinkParams.auth
? new URLSearchParams(sharedLinkParams).toString()
Expand All @@ -158,7 +172,7 @@ export async function stats(site: PlausibleSite, statsQuery: StatsQuery) {
body: JSON.stringify(statsQuery)
})

return handleApiResponse(response)
return (await handleApiResponse(response)) as TResponse
}

export async function get(
Expand Down
7 changes: 4 additions & 3 deletions assets/js/dashboard/components/sort-button.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { ReactNode } from 'react'
import { cycleSortDirection, SortDirection } from '../hooks/use-order-by-legacy'
import { cycleSortDirection } from '../hooks/use-order-by-legacy'
import { SortDirection } from '../../types/query-api'
import classNames from 'classnames'

export const SortButton = ({
Expand Down Expand Up @@ -29,8 +30,8 @@ export const SortButton = ({
'rounded inline-block size-4',
'ml-1',
{
[SortDirection.asc]: 'rotate-180',
[SortDirection.desc]: 'rotate-0'
asc: 'rotate-180',
desc: 'rotate-0'
}[sortDirection ?? next.direction],
!sortDirection && 'opacity-0',
!sortDirection && 'group-hover:opacity-100',
Expand Down
2 changes: 1 addition & 1 deletion assets/js/dashboard/components/table-legacy.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import classNames from 'classnames'
import React, { ReactNode } from 'react'
import { SortDirection } from '../hooks/use-order-by-legacy'
import { SortDirection } from '../../types/query-api'
import { SortButton } from './sort-button'
import { Tooltip } from '../util/tooltip'

Expand Down
47 changes: 47 additions & 0 deletions assets/js/dashboard/current-visitors-context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import React, { createContext, useContext, useEffect } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { get } from './api'
import { useSiteContext } from './site-context'
import { useDashboardStateContext } from './dashboard-state-context'
import { CACHE_TTL_REALTIME } from './hooks/api-client'
import { isRealTimeDashboard } from './util/filters'

const CurrentVisitorsContext = createContext<number | null>(null)

export function CurrentVisitorsProvider({
children
}: {
children: React.ReactNode
}) {
const site = useSiteContext()
const { dashboardState } = useDashboardStateContext()
const queryClient = useQueryClient()

const isEnabled =
isRealTimeDashboard(dashboardState) || dashboardState.filters.length === 0

const { data } = useQuery<number>({
queryKey: ['current-visitors'],
queryFn: () =>
get(`/api/stats/${encodeURIComponent(site.domain)}/current-visitors`),
staleTime: CACHE_TTL_REALTIME,
enabled: isEnabled
})

useEffect(() => {
const onTick = () => {
queryClient.invalidateQueries({ queryKey: ['current-visitors'] })
}
document.addEventListener('tick', onTick)
return () => document.removeEventListener('tick', onTick)
}, [queryClient])

return (
<CurrentVisitorsContext.Provider value={isEnabled ? (data ?? null) : null}>
{children}
</CurrentVisitorsContext.Provider>
)
}

export const useCurrentVisitorsContext = () =>
useContext(CurrentVisitorsContext)
52 changes: 1 addition & 51 deletions assets/js/dashboard/hooks/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ import {
} from '../dashboard-time-periods'
import { REALTIME_UPDATE_TIME_MS } from '../util/realtime-update-timer'
import { Interval, validIntervals } from '../stats/graph/intervals'
import { PlausibleSite } from '../site-context'
import { StatsQuery } from '../stats-query'

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

// how many items per page for breakdown modals
const PAGINATION_LIMIT = 100
export const PAGINATION_LIMIT = 100

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

/**
* Hook for paginated POST /api/stats/:domain/query requests (i.e. Details views).
*/
export function usePaginatedQueryAPI({
site,
dashboardState,
statsQuery
}: {
site: PlausibleSite
dashboardState: DashboardState
statsQuery: StatsQuery
}) {
const queryClient = useQueryClient()
const dimensionKey = statsQuery.dimensions.join(',')

useEffect(() => {
return () => {
const tanstackQueryFilters: QueryFilters = {
predicate: ({ queryKey }) => queryKey[0] === dimensionKey
}
queryClient.setQueriesData(tanstackQueryFilters, cleanToPageOne)
}
}, [queryClient, dimensionKey])

return useInfiniteQuery({
queryKey: [dimensionKey, statsQuery],
queryFn: async ({ pageParam }): Promise<api.QueryApiResponse> => {
return api.stats(site, {
...statsQuery,
pagination: { limit: PAGINATION_LIMIT, offset: pageParam as number }
})
},
getNextPageParam: (lastPage, _, lastPageParam) => {
return lastPage.results.length === PAGINATION_LIMIT
? (lastPageParam as number) + PAGINATION_LIMIT
: null
},
staleTime: () =>
getStaleTime({
siteTimezoneOffset: site.offset,
siteStatsBegin: site.statsBegin,
...dashboardState
}),
initialPageParam: 0,
placeholderData: (previousData) => previousData
})
}

/**
* Hook that fetches the first page from the defined GET endpoint on mount,
* then subsequent pages when component calls fetchNextPage.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,39 +1,38 @@
import { Metric } from '../stats/metrics'
import {
OrderBy,
SortDirection,
MetricOrderBy,
cycleSortDirection,
getOrderByStorageKey,
getStoredOrderBy,
maybeStoreOrderBy,
rearrangeOrderBy,
validateOrderBy
} from './use-order-by'
} from './use-metric-order-by'

describe(`${cycleSortDirection.name}`, () => {
test.each([
[
null,
{
direction: SortDirection.desc,
direction: 'desc',
hint: 'Press to sort column in descending order'
}
],
[
SortDirection.desc,
'desc',
{
direction: SortDirection.asc,
direction: 'asc',
hint: 'Press to sort column in ascending order'
}
],
[
SortDirection.asc,
'asc',
{
direction: SortDirection.desc,
direction: 'desc',
hint: 'Press to sort column in descending order'
}
]
])(
] as const)(
'for current direction %p returns %p',
(currentDirection, expectedOutput) => {
expect(cycleSortDirection(currentDirection)).toEqual(expectedOutput)
Expand All @@ -42,22 +41,10 @@ describe(`${cycleSortDirection.name}`, () => {
})

describe(`${rearrangeOrderBy.name}`, () => {
const cases: [Metric, OrderBy, OrderBy][] = [
[
'visitors',
[['visitors', SortDirection.asc]],
[['visitors', SortDirection.desc]]
],
[
'visitors',
[['visitors', SortDirection.desc]],
[['visitors', SortDirection.asc]]
],
[
'visit_duration',
[['visitors', SortDirection.asc]],
[['visit_duration', SortDirection.desc]]
]
const cases: [Metric, MetricOrderBy, MetricOrderBy][] = [
['visitors', [['visitors', 'asc']], [['visitors', 'desc']]],
['visitors', [['visitors', 'desc']], [['visitors', 'asc']]],
['visit_duration', [['visitors', 'asc']], [['visit_duration', 'desc']]]
]
it.each(cases)(
`[%#] clicking on %p yields expected order`,
Expand Down Expand Up @@ -96,7 +83,7 @@ describe(`storing detailed report preferred order`, () => {

it('does not store invalid value', () => {
maybeStoreOrderBy({
orderBy: [['total_visitors', SortDirection.desc]],
orderBy: [['total_visitors', 'desc']],
domain,
dimensionLabel,
metrics: ['total_visitors']
Expand All @@ -108,7 +95,7 @@ describe(`storing detailed report preferred order`, () => {

it('falls back to fallbackValue if metric has become unsortable between storing and retrieving', () => {
maybeStoreOrderBy({
orderBy: [['visitors', SortDirection.desc]],
orderBy: [['visitors', 'desc']],
domain,
dimensionLabel,
metrics: ['visitors']
Expand All @@ -122,13 +109,13 @@ describe(`storing detailed report preferred order`, () => {
domain,
dimensionLabel,
metrics: ['total_visitors'],
fallbackValue: [['visitors', SortDirection.desc]]
fallbackValue: [['visitors', 'desc']]
})
).toEqual([['visitors', SortDirection.desc]])
).toEqual([['visitors', 'desc']])
})

it('retrieves stored value correctly', () => {
const input: OrderBy = [['visitors', SortDirection.asc]]
const input: MetricOrderBy = [['visitors', 'asc']]
localStorage.setItem(
getOrderByStorageKey(domain, dimensionLabel),
JSON.stringify(input)
Expand All @@ -138,7 +125,7 @@ describe(`storing detailed report preferred order`, () => {
domain,
dimensionLabel,
metrics: ['visitors'],
fallbackValue: [['visitors', SortDirection.desc]]
fallbackValue: [['visitors', 'desc']]
})
).toEqual(input)
})
Expand Down
Loading
Loading