diff --git a/assets/js/dashboard/api.ts b/assets/js/dashboard/api.ts index d51aa2ae8035..a77f88ecfc10 100644 --- a/assets/js/dashboard/api.ts +++ b/assets/js/dashboard/api.ts @@ -1,4 +1,4 @@ -import { Metric } from '../types/query-api' +import { Metric } from './stats/metrics' import { DashboardState } from './dashboard-state' import { PlausibleSite } from './site-context' import { StatsQuery } from './stats-query' @@ -9,18 +9,37 @@ import * as url from './util/url' let abortController = new AbortController() let SHARED_LINK_AUTH: null | string = null +export type RevenueMetricValue = { + short: string + value: number + long: string + currency: string +} + +export type MetricValue = null | number | RevenueMetricValue + +export type QueryResultQuery = { + metrics: Metric[] + date_range: [string, string] + comparison_date_range?: [string, string] | null +} + +export type QueryResultMeta = { + metric_warnings?: Record> + imports_included?: boolean + imports_skip_reason?: string +} + +export type QueryResultRow = { + metrics: Array + dimensions: Array + comparison?: { metrics: Array; change: Array } +} + export type QueryApiResponse = { - query: { - metrics: Metric[] - date_range: [string, string] - comparison_date_range: [string, string] - } - meta: Record - results: { - metrics: Array - dimensions: Array - comparison: { metrics: Array; change: Array } - }[] + query: QueryResultQuery + meta: QueryResultMeta + results: QueryResultRow[] } export class ApiError extends Error { diff --git a/assets/js/dashboard/components/sort-button.tsx b/assets/js/dashboard/components/sort-button.tsx index 3ca98b298c4e..3c1e2c4952e7 100644 --- a/assets/js/dashboard/components/sort-button.tsx +++ b/assets/js/dashboard/components/sort-button.tsx @@ -1,5 +1,5 @@ import React, { ReactNode } from 'react' -import { cycleSortDirection, SortDirection } from '../hooks/use-order-by' +import { cycleSortDirection, SortDirection } from '../hooks/use-order-by-legacy' import classNames from 'classnames' export const SortButton = ({ diff --git a/assets/js/dashboard/components/table-legacy.tsx b/assets/js/dashboard/components/table-legacy.tsx new file mode 100644 index 000000000000..7460fb26a2d1 --- /dev/null +++ b/assets/js/dashboard/components/table-legacy.tsx @@ -0,0 +1,216 @@ +import classNames from 'classnames' +import React, { ReactNode } from 'react' +import { SortDirection } from '../hooks/use-order-by-legacy' +import { SortButton } from './sort-button' +import { Tooltip } from '../util/tooltip' + +export type ColumnConfiguraton> = { + /** Unique column ID, used for sorting purposes and to get the value of the cell using rowItem[key] */ + key: keyof T + /** Column title */ + label: string + /** If defined, the column is considered sortable. @see SortButton */ + onSort?: () => void + sortDirection?: SortDirection + /** CSS class string. @example "w-24 md:w-32" */ + width: string + /** Aligns column content. */ + align?: 'left' | 'right' + /** A warning to be rendered as a tooltip for the column header */ + metricWarning?: string + /** + * Function used to transform the value found at item[key] for the cell. Superseded by renderItem if present. @example 1120 => "1.1k" + */ + renderValue?: (item: T, isRowHovered?: boolean) => ReactNode + /** Function used to create richer cells */ + renderItem?: (item: T) => ReactNode +} + +export const TableHeaderCell = ({ + children, + className, + align +}: { + children: ReactNode + className: string + align?: 'left' | 'right' +}) => { + return ( + + {children} + + ) +} + +export const TableCell = ({ + children, + className, + align +}: { + children: ReactNode + className: string + align?: 'left' | 'right' +}) => { + return ( + + {children} + + ) +} + +export const ItemRow = >({ + rowIndex, + pageIndex, + item, + columns, + tappedRowName, + onRowTap +}: { + rowIndex: number + pageIndex?: number + item: T + columns: ColumnConfiguraton[] + tappedRowName?: string | null + onRowTap?: (rowName: string | null) => void +}) => { + const [isHovered, setIsHovered] = React.useState(false) + + const rowName = (item as unknown as { name: string }).name + const isTapped = tappedRowName === rowName + const isRowActive = isHovered || isTapped + + const handleRowClick = (e: React.MouseEvent) => { + if (window.innerWidth < 768 && !(e.target as HTMLElement).closest('a')) { + if (onRowTap) { + if (isTapped) { + onRowTap(null) + } else { + onRowTap(rowName) + } + } + } + } + + return ( + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onClick={handleRowClick} + > + {columns.map(({ key, width, align, renderValue, renderItem }) => ( + + {renderItem + ? renderItem(item) + : renderValue + ? renderValue(item, isRowActive) + : (item[key] ?? '')} + + ))} + + ) +} + +export const Table = >({ + data, + columns +}: { + columns: ColumnConfiguraton[] + data: T[] | { pages: T[][] } +}) => { + const [tappedRowName, setTappedRowName] = React.useState(null) + + const renderColumnLabel = (column: ColumnConfiguraton) => { + if (column.metricWarning) { + return ( + + {column.label + ' *'} + + ) + } else { + return column.label + } + } + + const warningSpan = (warning: string) => { + return ( + + {'* ' + warning} + + ) + } + + return ( + + + + {columns.map((column) => ( + + {column.onSort ? ( + + {renderColumnLabel(column)} + + ) : ( + renderColumnLabel(column) + )} + + ))} + + + + {Array.isArray(data) + ? data.map((item, rowIndex) => ( + + )) + : data.pages.map((page, pageIndex) => + page.map((item, rowIndex) => ( + + )) + )} + +
+ ) +} diff --git a/assets/js/dashboard/components/table.tsx b/assets/js/dashboard/components/table.tsx index ba9116193e0b..2fde7a256570 100644 --- a/assets/js/dashboard/components/table.tsx +++ b/assets/js/dashboard/components/table.tsx @@ -1,106 +1,27 @@ import classNames from 'classnames' -import React, { ReactNode } from 'react' -import { SortDirection } from '../hooks/use-order-by' -import { SortButton } from './sort-button' -import { Tooltip } from '../util/tooltip' +import React, { useState } from 'react' +import { ColumnConfiguration } from '../stats/breakdowns' -export type ColumnConfiguraton> = { - /** Unique column ID, used for sorting purposes and to get the value of the cell using rowItem[key] */ - key: keyof T - /** Column title */ - label: string - /** If defined, the column is considered sortable. @see SortButton */ - onSort?: () => void - sortDirection?: SortDirection - /** CSS class string. @example "w-24 md:w-32" */ - width: string - /** Aligns column content. */ - align?: 'left' | 'right' - /** A warning to be rendered as a tooltip for the column header */ - metricWarning?: string - /** - * Function used to transform the value found at item[key] for the cell. Superseded by renderItem if present. @example 1120 => "1.1k" - */ - renderValue?: (item: T, isRowHovered?: boolean) => ReactNode - /** Function used to create richer cells */ - renderItem?: (item: T) => ReactNode -} - -export const TableHeaderCell = ({ - children, - className, - align -}: { - children: ReactNode - className: string - align?: 'left' | 'right' -}) => { - return ( - - {children} - - ) -} - -export const TableCell = ({ - children, - className, - align -}: { - children: ReactNode - className: string - align?: 'left' | 'right' -}) => { - return ( - - {children} - - ) -} - -export const ItemRow = >({ - rowIndex, - pageIndex, - item, +function Row({ + row, columns, - tappedRowName, - onRowTap + rowKey, + tappedKey, + onTap }: { - rowIndex: number - pageIndex?: number - item: T - columns: ColumnConfiguraton[] - tappedRowName?: string | null - onRowTap?: (rowName: string | null) => void -}) => { - const [isHovered, setIsHovered] = React.useState(false) + row: T + columns: ColumnConfiguration[] + rowKey: string + tappedKey: string | null + onTap: (key: string | null) => void +}) { + const [isHovered, setIsHovered] = useState(false) + const isTapped = tappedKey === rowKey + const isActive = isHovered || isTapped - const rowName = (item as unknown as { name: string }).name - const isTapped = tappedRowName === rowName - const isRowActive = isHovered || isTapped - - const handleRowClick = (e: React.MouseEvent) => { + const handleClick = (e: React.MouseEvent) => { if (window.innerWidth < 768 && !(e.target as HTMLElement).closest('a')) { - if (onRowTap) { - if (isTapped) { - onRowTap(null) - } else { - onRowTap(rowName) - } - } + onTap(isTapped ? null : rowKey) } } @@ -110,106 +31,67 @@ export const ItemRow = >({ className="group text-sm dark:text-gray-200 md:cursor-default cursor-pointer" onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} - onClick={handleRowClick} + onClick={handleClick} > - {columns.map(({ key, width, align, renderValue, renderItem }) => ( - ( + - {renderItem - ? renderItem(item) - : renderValue - ? renderValue(item, isRowActive) - : (item[key] ?? '')} - + {col.renderCell(row, isActive)} + ))} ) } -export const Table = >({ +export function Table({ data, - columns + columns, + getRowKey }: { - columns: ColumnConfiguraton[] - data: T[] | { pages: T[][] } -}) => { - const [tappedRowName, setTappedRowName] = React.useState(null) - - const renderColumnLabel = (column: ColumnConfiguraton) => { - if (column.metricWarning) { - return ( - - {column.label + ' *'} - - ) - } else { - return column.label - } - } - - const warningSpan = (warning: string) => { - return ( - - {'* ' + warning} - - ) - } + data: { pages: T[][] } + columns: ColumnConfiguration[] + getRowKey: (row: T) => string +}) { + const [tappedKey, setTappedKey] = useState(null) return ( - {columns.map((column) => ( - ( + ))} - {Array.isArray(data) - ? data.map((item, rowIndex) => ( - + page.map((row) => { + const rowKey = getRowKey(row) + return ( + - )) - : data.pages.map((page, pageIndex) => - page.map((item, rowIndex) => ( - - )) - )} + ) + }) + )}
- {column.onSort ? ( - - {renderColumnLabel(column)} - - ) : ( - renderColumnLabel(column) - )} - + {col.renderLabel()} +
) diff --git a/assets/js/dashboard/hooks/api-client.ts b/assets/js/dashboard/hooks/api-client.ts index 3d01462ddbd5..ae82abec8b98 100644 --- a/assets/js/dashboard/hooks/api-client.ts +++ b/assets/js/dashboard/hooks/api-client.ts @@ -9,6 +9,8 @@ import { DashboardState } from '../dashboard-state' import { DashboardPeriod } from '../dashboard-time-periods' import { Dayjs } from 'dayjs' import { REALTIME_UPDATE_TIME_MS } from '../util/realtime-update-timer' +import { PlausibleSite } from '../site-context' +import { StatsQuery } from '../stats-query' // defines when queries that don't include the current time should be refetched const HISTORICAL_RESPONSES_STALE_TIME_MS = 12 * 60 * 60 * 1000 @@ -25,6 +27,79 @@ type GetRequestParams = ( k: TKey ) => [DashboardState, Record] +/** + * Hook for paginated POST /api/stats/:domain/query requests. + * Pass pageSize to limit results (e.g. 9 for index views, default 100 for modals). + * Set enabled=false to defer fetching (e.g. until the component is visible). + */ +export function usePaginatedQueryAPI({ + site, + statsQuery, + afterFetchData, + afterFetchNextPage, + pageSize = PAGINATION_LIMIT, + enabled = true +}: { + site: PlausibleSite + statsQuery: StatsQuery + afterFetchData?: (response: api.QueryApiResponse) => void + afterFetchNextPage?: (response: api.QueryApiResponse) => void + pageSize?: number + enabled?: boolean +}) { + const queryClient = useQueryClient() + const dimensionKey = statsQuery.dimensions.join(',') + + useEffect(() => { + return () => { + const tanstackQueryFilters: QueryFilters = { + predicate: (query) => { + const key = query.queryKey[0] + return ( + typeof key === 'object' && + key !== null && + 'dimensions' in key && + (key as StatsQuery).dimensions.join(',') === dimensionKey + ) + } + } + queryClient.setQueriesData(tanstackQueryFilters, cleanToPageOne) + } + }, [queryClient, dimensionKey]) + + return useInfiniteQuery({ + queryKey: [statsQuery], + enabled, + queryFn: async ({ + pageParam + }): Promise => { + const response: api.QueryApiResponse = await api.stats(site, { + ...statsQuery, + pagination: { limit: pageSize, offset: pageParam as number } + } as StatsQuery) + + if (pageParam === 0 && typeof afterFetchData === 'function') { + afterFetchData(response) + } + if ( + (pageParam as number) > 0 && + typeof afterFetchNextPage === 'function' + ) { + afterFetchNextPage(response) + } + + return response.results + }, + getNextPageParam: (lastPageResults, _, lastPageParam) => { + return lastPageResults.length === pageSize + ? (lastPageParam as number) + pageSize + : null + }, + 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. diff --git a/assets/js/dashboard/hooks/use-order-by-legacy.test.ts b/assets/js/dashboard/hooks/use-order-by-legacy.test.ts new file mode 100644 index 000000000000..8e5a1a8cbfc1 --- /dev/null +++ b/assets/js/dashboard/hooks/use-order-by-legacy.test.ts @@ -0,0 +1,163 @@ +import { Metric } from '../stats/reports/metrics' +import { + OrderBy, + SortDirection, + cycleSortDirection, + findOrderIndex, + getOrderByStorageKey, + getStoredOrderBy, + maybeStoreOrderBy, + rearrangeOrderBy, + validateOrderBy +} from './use-order-by-legacy' + +describe(`${findOrderIndex.name}`, () => { + /* prettier-ignore */ + const cases: [OrderBy, Pick, number][] = [ + [[], { key: 'anything' }, -1], + [[['visitors', SortDirection.asc]], { key: 'anything' }, -1], + [[['bounce_rate', SortDirection.desc], ['visitors', SortDirection.asc]], {key: 'bounce_rate'}, 0], + [[['bounce_rate', SortDirection.desc], ['visitors', SortDirection.asc]], {key: 'visitors'}, 1] + ] + + test.each(cases)( + `[%#] in order by %p, the index of metric %p is %p`, + (orderBy, metric, expectedIndex) => { + expect(findOrderIndex(orderBy, metric)).toEqual(expectedIndex) + } + ) +}) + +describe(`${cycleSortDirection.name}`, () => { + test.each([ + [ + null, + { + direction: SortDirection.desc, + hint: 'Press to sort column in descending order' + } + ], + [ + SortDirection.desc, + { + direction: SortDirection.asc, + hint: 'Press to sort column in ascending order' + } + ], + [ + SortDirection.asc, + { + direction: SortDirection.desc, + hint: 'Press to sort column in descending order' + } + ] + ])( + 'for current direction %p returns %p', + (currentDirection, expectedOutput) => { + expect(cycleSortDirection(currentDirection)).toEqual(expectedOutput) + } + ) +}) + +describe(`${rearrangeOrderBy.name}`, () => { + const cases: [Pick, OrderBy, OrderBy][] = [ + [ + { key: 'visitors' }, + [['visitors', SortDirection.asc]], + [['visitors', SortDirection.desc]] + ], + [ + { key: 'visitors' }, + [['visitors', SortDirection.desc]], + [['visitors', SortDirection.asc]] + ], + [ + { key: 'visit_duration' }, + [['visitors', SortDirection.asc]], + [['visit_duration', SortDirection.desc]] + ] + ] + it.each(cases)( + `[%#] clicking on %p yields expected order`, + (metric, currentOrderBy, expectedOrderBy) => { + expect(rearrangeOrderBy(currentOrderBy, metric)).toEqual(expectedOrderBy) + } + ) +}) + +describe(`${validateOrderBy.name}`, () => { + test.each([ + [false, '', []], + [false, [], []], + [false, [['a']], [{ key: 'a' }]], + [false, [['a', 'b']], [{ key: 'a' }]], + [ + false, + [ + ['a', 'desc'], + ['a', 'asc'] + ], + [{ key: 'a' }] + ], + [true, [['a', 'desc']], [{ key: 'a' }]] + ])( + '[%#] returns %p given input %p and sortable metrics %p', + (expected, input, sortableMetrics) => { + expect(validateOrderBy(input, sortableMetrics)).toBe(expected) + } + ) +}) + +describe(`storing detailed report preferred order`, () => { + const domain = 'any-domain' + const reportInfo = { dimensionLabel: 'Goal' } + + it('does not store invalid value', () => { + maybeStoreOrderBy({ + orderBy: [['foo', SortDirection.desc]], + domain, + reportInfo, + metrics: [{ key: 'foo', sortable: false }] + }) + expect(localStorage.getItem(getOrderByStorageKey(domain, reportInfo))).toBe( + null + ) + }) + + it('falls back to fallbackValue if metric has become unsortable between storing and retrieving', () => { + maybeStoreOrderBy({ + orderBy: [['c', SortDirection.desc]], + domain, + reportInfo, + metrics: [{ key: 'c', sortable: true }] + }) + const inStorage = localStorage.getItem( + getOrderByStorageKey(domain, reportInfo) + ) + expect(inStorage).toBe('[["c","desc"]]') + expect( + getStoredOrderBy({ + domain, + reportInfo, + metrics: [{ key: 'c', sortable: false }], + fallbackValue: [['visitors', SortDirection.desc]] + }) + ).toEqual([['visitors', SortDirection.desc]]) + }) + + it('retrieves stored value correctly', () => { + const input = [['any-column', SortDirection.asc]] + localStorage.setItem( + getOrderByStorageKey(domain, reportInfo), + JSON.stringify(input) + ) + expect( + getStoredOrderBy({ + domain, + reportInfo, + metrics: [{ key: 'any-column', sortable: true }], + fallbackValue: [['visitors', SortDirection.desc]] + }) + ).toEqual(input) + }) +}) diff --git a/assets/js/dashboard/hooks/use-order-by-legacy.ts b/assets/js/dashboard/hooks/use-order-by-legacy.ts new file mode 100644 index 000000000000..a8908f4fca9d --- /dev/null +++ b/assets/js/dashboard/hooks/use-order-by-legacy.ts @@ -0,0 +1,200 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Metric } from '../stats/reports/metrics' +import { getDomainScopedStorageKey, getItem, setItem } from '../util/storage' +import { useSiteContext } from '../site-context' +import { ReportInfo } from '../stats/modals/breakdown-modal-legacy' + +export enum SortDirection { + asc = 'asc', + desc = 'desc' +} + +export type Order = [Metric['key'], SortDirection] + +export type OrderBy = Order[] + +export const getSortDirectionLabel = (sortDirection: SortDirection): string => + ({ + [SortDirection.asc]: 'Sorted in ascending order', + [SortDirection.desc]: 'Sorted in descending order' + })[sortDirection] + +export function useOrderBy({ + metrics, + defaultOrderBy +}: { + metrics: Pick[] + defaultOrderBy: OrderBy +}) { + const [orderBy, setOrderBy] = useState([]) + const orderByDictionary: Record = useMemo( + () => + orderBy.length + ? Object.fromEntries(orderBy) + : Object.fromEntries(defaultOrderBy), + [orderBy, defaultOrderBy] + ) + + const toggleSortByMetric = useCallback( + (metric: Pick) => { + if (!metrics.find(({ key }) => key === metric.key)) { + return + } + setOrderBy((currentOrderBy) => + rearrangeOrderBy( + currentOrderBy.length ? currentOrderBy : defaultOrderBy, + metric + ) + ) + }, + [metrics, defaultOrderBy] + ) + + return { + orderBy: orderBy.length ? orderBy : defaultOrderBy, + orderByDictionary, + toggleSortByMetric + } +} + +export function cycleSortDirection( + currentSortDirection: SortDirection | null +): { direction: SortDirection; hint: string } { + if (currentSortDirection === SortDirection.desc) { + return { + direction: SortDirection.asc, + hint: 'Press to sort column in ascending order' + } + } + + return { + direction: SortDirection.desc, + hint: 'Press to sort column in descending order' + } +} + +export function findOrderIndex(orderBy: OrderBy, metric: Pick) { + return orderBy.findIndex(([metricKey]) => metricKey === metric.key) +} + +export function rearrangeOrderBy( + currentOrderBy: OrderBy, + metric: Pick +): OrderBy { + const orderIndex = findOrderIndex(currentOrderBy, metric) + if (orderIndex < 0) { + const sortDirection = cycleSortDirection(null).direction as SortDirection + return [[metric.key, sortDirection]] + } + const previousOrder = currentOrderBy[orderIndex] + const sortDirection = cycleSortDirection(previousOrder[1]).direction + if (sortDirection === null) { + return [] + } + return [[metric.key, sortDirection]] +} + +export function getOrderByStorageKey( + domain: string, + reportInfo: Pick +) { + const storageKey = getDomainScopedStorageKey( + `order_${reportInfo.dimensionLabel}_by`, + domain + ) + return storageKey +} + +export function validateOrderBy( + orderBy: unknown, + metrics: Pick[] +): orderBy is OrderBy { + if (!Array.isArray(orderBy)) { + return false + } + if (orderBy.length !== 1) { + return false + } + if (!Array.isArray(orderBy[0])) { + return false + } + if ( + orderBy[0].length === 2 && + metrics.findIndex((m) => m.key === orderBy[0][0]) > -1 && + [SortDirection.asc, SortDirection.desc].includes(orderBy[0][1]) + ) { + return true + } + return false +} + +export function getStoredOrderBy({ + domain, + reportInfo, + metrics, + fallbackValue +}: { + domain: string + reportInfo: Pick + metrics: Pick[] + fallbackValue: OrderBy +}): OrderBy { + try { + const storedItem = getItem(getOrderByStorageKey(domain, reportInfo)) + const parsed = JSON.parse(storedItem) + if ( + validateOrderBy( + parsed, + metrics.filter((m) => m.sortable) + ) + ) { + return parsed + } else { + throw new Error('Invalid stored order_by value') + } + } catch (_e) { + return fallbackValue + } +} + +export function maybeStoreOrderBy({ + domain, + reportInfo, + metrics, + orderBy +}: { + domain: string + reportInfo: Pick + metrics: Pick[] + orderBy: OrderBy +}) { + if ( + validateOrderBy( + orderBy, + metrics.filter((m) => m.sortable) + ) + ) { + setItem(getOrderByStorageKey(domain, reportInfo), JSON.stringify(orderBy)) + } +} + +export function useRememberOrderBy({ + effectiveOrderBy, + metrics, + reportInfo +}: { + effectiveOrderBy: OrderBy + metrics: Pick[] + reportInfo: Pick +}) { + const site = useSiteContext() + + useEffect(() => { + maybeStoreOrderBy({ + domain: site.domain, + metrics, + reportInfo, + orderBy: effectiveOrderBy + }) + }, [site, reportInfo, effectiveOrderBy, metrics]) +} diff --git a/assets/js/dashboard/hooks/use-order-by.test.ts b/assets/js/dashboard/hooks/use-order-by.test.ts index 40e63fcf42f7..f555abe058b6 100644 --- a/assets/js/dashboard/hooks/use-order-by.test.ts +++ b/assets/js/dashboard/hooks/use-order-by.test.ts @@ -1,4 +1,4 @@ -import { Metric } from '../stats/reports/metrics' +import { Metric } from '../stats/metrics' import { OrderBy, SortDirection, @@ -13,11 +13,11 @@ import { describe(`${findOrderIndex.name}`, () => { /* prettier-ignore */ - const cases: [OrderBy, Pick, number][] = [ - [[], { key: 'anything' }, -1], - [[['visitors', SortDirection.asc]], { key: 'anything' }, -1], - [[['bounce_rate', SortDirection.desc], ['visitors', SortDirection.asc]], {key: 'bounce_rate'}, 0], - [[['bounce_rate', SortDirection.desc], ['visitors', SortDirection.asc]], {key: 'visitors'}, 1] + const cases: [OrderBy, Metric, number][] = [ + [[], 'visitors', -1], + [[['visitors', SortDirection.asc]], 'bounce_rate', -1], + [[['bounce_rate', SortDirection.desc], ['visitors', SortDirection.asc]], 'bounce_rate', 0], + [[['bounce_rate', SortDirection.desc], ['visitors', SortDirection.asc]], 'visitors', 1] ] test.each(cases)( @@ -60,19 +60,19 @@ describe(`${cycleSortDirection.name}`, () => { }) describe(`${rearrangeOrderBy.name}`, () => { - const cases: [Pick, OrderBy, OrderBy][] = [ + const cases: [Metric, OrderBy, OrderBy][] = [ [ - { key: 'visitors' }, + 'visitors', [['visitors', SortDirection.asc]], [['visitors', SortDirection.desc]] ], [ - { key: 'visitors' }, + 'visitors', [['visitors', SortDirection.desc]], [['visitors', SortDirection.asc]] ], [ - { key: 'visit_duration' }, + 'visit_duration', [['visitors', SortDirection.asc]], [['visit_duration', SortDirection.desc]] ] @@ -89,21 +89,21 @@ describe(`${validateOrderBy.name}`, () => { test.each([ [false, '', []], [false, [], []], - [false, [['a']], [{ key: 'a' }]], - [false, [['a', 'b']], [{ key: 'a' }]], + [false, [['visitors']], ['visitors']], + [false, [['visitors', 'b']], ['visitors']], [ false, [ - ['a', 'desc'], - ['a', 'asc'] + ['visitors', 'desc'], + ['visitors', 'asc'] ], - [{ key: 'a' }] + ['visitors'] ], - [true, [['a', 'desc']], [{ key: 'a' }]] + [true, [['visitors', 'desc']], ['visitors']] ])( '[%#] returns %p given input %p and sortable metrics %p', (expected, input, sortableMetrics) => { - expect(validateOrderBy(input, sortableMetrics)).toBe(expected) + expect(validateOrderBy(input, sortableMetrics as Metric[])).toBe(expected) } ) }) @@ -114,10 +114,10 @@ describe(`storing detailed report preferred order`, () => { it('does not store invalid value', () => { maybeStoreOrderBy({ - orderBy: [['foo', SortDirection.desc]], + orderBy: [['total_visitors', SortDirection.desc]], domain, reportInfo, - metrics: [{ key: 'foo', sortable: false }] + metrics: ['total_visitors'] }) expect(localStorage.getItem(getOrderByStorageKey(domain, reportInfo))).toBe( null @@ -126,27 +126,27 @@ describe(`storing detailed report preferred order`, () => { it('falls back to fallbackValue if metric has become unsortable between storing and retrieving', () => { maybeStoreOrderBy({ - orderBy: [['c', SortDirection.desc]], + orderBy: [['visitors', SortDirection.desc]], domain, reportInfo, - metrics: [{ key: 'c', sortable: true }] + metrics: ['visitors'] }) const inStorage = localStorage.getItem( getOrderByStorageKey(domain, reportInfo) ) - expect(inStorage).toBe('[["c","desc"]]') + expect(inStorage).toBe('[["visitors","desc"]]') expect( getStoredOrderBy({ domain, reportInfo, - metrics: [{ key: 'c', sortable: false }], + metrics: ['total_visitors'], fallbackValue: [['visitors', SortDirection.desc]] }) ).toEqual([['visitors', SortDirection.desc]]) }) it('retrieves stored value correctly', () => { - const input = [['any-column', SortDirection.asc]] + const input: OrderBy = [['visitors', SortDirection.asc]] localStorage.setItem( getOrderByStorageKey(domain, reportInfo), JSON.stringify(input) @@ -155,7 +155,7 @@ describe(`storing detailed report preferred order`, () => { getStoredOrderBy({ domain, reportInfo, - metrics: [{ key: 'any-column', sortable: true }], + metrics: ['visitors'], fallbackValue: [['visitors', SortDirection.desc]] }) ).toEqual(input) diff --git a/assets/js/dashboard/hooks/use-order-by.ts b/assets/js/dashboard/hooks/use-order-by.ts index 28bde044af92..a3448e29615d 100644 --- a/assets/js/dashboard/hooks/use-order-by.ts +++ b/assets/js/dashboard/hooks/use-order-by.ts @@ -1,15 +1,15 @@ import { useCallback, useEffect, useMemo, useState } from 'react' -import { Metric } from '../stats/reports/metrics' +import { isSortable, Metric } from '../stats/metrics' import { getDomainScopedStorageKey, getItem, setItem } from '../util/storage' import { useSiteContext } from '../site-context' -import { ReportInfo } from '../stats/modals/breakdown-modal' +import { ReportInfo } from '../stats/modals/breakdown-modal-legacy' export enum SortDirection { asc = 'asc', desc = 'desc' } -export type Order = [Metric['key'], SortDirection] +export type Order = [Metric, SortDirection] export type OrderBy = Order[] @@ -23,21 +23,21 @@ export function useOrderBy({ metrics, defaultOrderBy }: { - metrics: Pick[] + metrics: Metric[] defaultOrderBy: OrderBy }) { const [orderBy, setOrderBy] = useState([]) - const orderByDictionary: Record = useMemo( + const orderByDictionary = useMemo( () => - orderBy.length + (orderBy.length ? Object.fromEntries(orderBy) - : Object.fromEntries(defaultOrderBy), + : Object.fromEntries(defaultOrderBy)) as Record, [orderBy, defaultOrderBy] ) const toggleSortByMetric = useCallback( - (metric: Pick) => { - if (!metrics.find(({ key }) => key === metric.key)) { + (metric: Metric) => { + if (!metrics.find((m) => m === metric)) { return } setOrderBy((currentOrderBy) => @@ -73,25 +73,25 @@ export function cycleSortDirection( } } -export function findOrderIndex(orderBy: OrderBy, metric: Pick) { - return orderBy.findIndex(([metricKey]) => metricKey === metric.key) +export function findOrderIndex(orderBy: OrderBy, metric: Metric) { + return orderBy.findIndex(([m]) => m === metric) } export function rearrangeOrderBy( currentOrderBy: OrderBy, - metric: Pick + metric: Metric ): OrderBy { const orderIndex = findOrderIndex(currentOrderBy, metric) if (orderIndex < 0) { const sortDirection = cycleSortDirection(null).direction as SortDirection - return [[metric.key, sortDirection]] + return [[metric, sortDirection]] } const previousOrder = currentOrderBy[orderIndex] const sortDirection = cycleSortDirection(previousOrder[1]).direction if (sortDirection === null) { return [] } - return [[metric.key, sortDirection]] + return [[metric, sortDirection]] } export function getOrderByStorageKey( @@ -107,7 +107,7 @@ export function getOrderByStorageKey( export function validateOrderBy( orderBy: unknown, - metrics: Pick[] + metrics: Metric[] ): orderBy is OrderBy { if (!Array.isArray(orderBy)) { return false @@ -120,7 +120,7 @@ export function validateOrderBy( } if ( orderBy[0].length === 2 && - metrics.findIndex((m) => m.key === orderBy[0][0]) > -1 && + metrics.findIndex((m) => m === orderBy[0][0]) > -1 && [SortDirection.asc, SortDirection.desc].includes(orderBy[0][1]) ) { return true @@ -136,7 +136,7 @@ export function getStoredOrderBy({ }: { domain: string reportInfo: Pick - metrics: Pick[] + metrics: Metric[] fallbackValue: OrderBy }): OrderBy { try { @@ -145,7 +145,7 @@ export function getStoredOrderBy({ if ( validateOrderBy( parsed, - metrics.filter((m) => m.sortable) + metrics.filter((m) => isSortable(m)) ) ) { return parsed @@ -165,13 +165,13 @@ export function maybeStoreOrderBy({ }: { domain: string reportInfo: Pick - metrics: Pick[] + metrics: Metric[] orderBy: OrderBy }) { if ( validateOrderBy( orderBy, - metrics.filter((m) => m.sortable) + metrics.filter((m) => isSortable(m)) ) ) { setItem(getOrderByStorageKey(domain, reportInfo), JSON.stringify(orderBy)) @@ -184,7 +184,7 @@ export function useRememberOrderBy({ reportInfo }: { effectiveOrderBy: OrderBy - metrics: Pick[] + metrics: Metric[] reportInfo: Pick }) { const site = useSiteContext() diff --git a/assets/js/dashboard/router.tsx b/assets/js/dashboard/router.tsx index df0d7078510a..355bee142f32 100644 --- a/assets/js/dashboard/router.tsx +++ b/assets/js/dashboard/router.tsx @@ -11,9 +11,9 @@ import Dashboard from './index' import SourcesModal from './stats/modals/sources' import ReferrersDrilldownModal from './stats/modals/referrer-drilldown' import GoogleKeywordsModal from './stats/modals/google-keywords' -import PagesModal from './stats/modals/pages' -import EntryPagesModal from './stats/modals/entry-pages' -import ExitPagesModal from './stats/modals/exit-pages' +import { PagesDetails } from './stats/pages/pages' +import { EntryPagesDetails } from './stats/pages/entry-pages' +import { ExitPagesDetails } from './stats/pages/exit-pages' import LocationsModal from './stats/modals/locations-modal' import BrowsersModal from './stats/modals/devices/browsers-modal' import BrowserVersionsModal from './stats/modals/devices/browser-versions-modal' @@ -101,17 +101,17 @@ export const referrersGoogleRoute = { export const topPagesRoute = { path: 'pages', - element: + element: } export const entryPagesRoute = { path: 'entry-pages', - element: + element: } export const exitPagesRoute = { path: 'exit-pages', - element: + element: } export const countriesRoute = { diff --git a/assets/js/dashboard/stats-query.ts b/assets/js/dashboard/stats-query.ts index 0f356fdf5625..853f9f4baf33 100644 --- a/assets/js/dashboard/stats-query.ts +++ b/assets/js/dashboard/stats-query.ts @@ -1,9 +1,21 @@ -import { Metric } from '../types/query-api' -import { DashboardState, Filter } from './dashboard-state' +import { Metric } from './stats/metrics' +import { + DashboardState, + FilterOperator, + FilterKey, + FilterClause +} from './dashboard-state' +import { OrderByEntry } from '../types/query-api' import { ComparisonMode, DashboardPeriod } from './dashboard-time-periods' import { formatISO } from './util/date' import { remapToApiFilters } from './util/filters' +export type FilterModifiers = { case_sensitive?: boolean } + +export type ApiFilter = + | [FilterOperator, FilterKey, FilterClause[]] + | [FilterOperator, FilterKey, FilterClause[], FilterModifiers] + type DateRange = DashboardPeriod | [string, string] type IncludeCompare = | ComparisonMode.previous_period @@ -26,15 +38,24 @@ export type ReportParams = { metrics: Metric[] dimensions?: string[] include?: Partial + order_by?: OrderByEntry[] } export type StatsQuery = { date_range: DateRange relative_date: string | null - filters: Filter[] + filters: ApiFilter[] dimensions: string[] metrics: Metric[] include: QueryInclude + order_by?: OrderByEntry[] | null +} + +export function addFilter( + statsQuery: StatsQuery, + filter: ApiFilter +): StatsQuery { + return { ...statsQuery, filters: [...statsQuery.filters, filter] } } export function createStatsQuery( @@ -47,6 +68,7 @@ export function createStatsQuery( dimensions: reportParams.dimensions || [], metrics: reportParams.metrics, filters: remapToApiFilters(dashboardState.filters), + order_by: reportParams.order_by || null, include: { imports: dashboardState.with_imported, imports_meta: reportParams.include?.imports_meta || false, diff --git a/assets/js/dashboard/stats/behaviours/conversions.js b/assets/js/dashboard/stats/behaviours/conversions.js index aa5ff34dc11a..a99841f99c4d 100644 --- a/assets/js/dashboard/stats/behaviours/conversions.js +++ b/assets/js/dashboard/stats/behaviours/conversions.js @@ -3,7 +3,7 @@ import * as api from '../../api' import * as url from '../../util/url' import * as metrics from '../reports/metrics' -import ListReport from '../reports/list' +import ListReport from '../reports/list-legacy' import { useSiteContext } from '../../site-context' import { useDashboardStateContext } from '../../dashboard-state-context' diff --git a/assets/js/dashboard/stats/behaviours/props.js b/assets/js/dashboard/stats/behaviours/props.js index 30b23312194c..3eae65e58fae 100644 --- a/assets/js/dashboard/stats/behaviours/props.js +++ b/assets/js/dashboard/stats/behaviours/props.js @@ -1,5 +1,5 @@ import React from 'react' -import ListReport, { MIN_HEIGHT } from '../reports/list' +import ListReport, { MIN_HEIGHT } from '../reports/list-legacy' import * as metrics from '../reports/metrics' import * as api from '../../api' import * as url from '../../util/url' diff --git a/assets/js/dashboard/stats/behaviours/special-goal-prop-breakdown.js b/assets/js/dashboard/stats/behaviours/special-goal-prop-breakdown.js index 700b92042512..b82ef8368f65 100644 --- a/assets/js/dashboard/stats/behaviours/special-goal-prop-breakdown.js +++ b/assets/js/dashboard/stats/behaviours/special-goal-prop-breakdown.js @@ -1,5 +1,5 @@ import React from 'react' -import ListReport from '../reports/list' +import ListReport from '../reports/list-legacy' import * as metrics from '../reports/metrics' import * as url from '../../util/url' import * as api from '../../api' diff --git a/assets/js/dashboard/stats/breakdowns.tsx b/assets/js/dashboard/stats/breakdowns.tsx new file mode 100644 index 000000000000..503fee250749 --- /dev/null +++ b/assets/js/dashboard/stats/breakdowns.tsx @@ -0,0 +1,207 @@ +import React, { ReactNode, useEffect, useRef } from 'react' +import { SortDirection } from '../hooks/use-order-by' +import type { QueryResultRow, QueryResultQuery, QueryApiResponse } from '../api' +import { Metric } from './metrics' +import { FilterInfo } from '../components/drilldown-link' +import { ChangeArrow } from './reports/change-arrow' +import { MetricFormatterLong, ValueType } from './reports/metric-formatter' +import dayjs from 'dayjs' +import { addFilter, ApiFilter, StatsQuery } from '../stats-query' + +export type SharedBreakdownReportProps = { + dimensionLabel: string + dimensions: string[] + metrics: Metric[] + getFilterInfo: (row: QueryResultRow) => FilterInfo | null + getExternalLinkUrl?: (row: QueryResultRow) => string | null + afterFetchData?: (response: QueryApiResponse) => void +} + +export type ColumnConfiguration = { + /** Unique column ID, used for sorting purposes and as a React key */ + key: string + /** Column title */ + renderLabel: () => ReactNode + /** Renders any cell in this column — name cells, metric cells, etc. */ + renderCell: (item: T, isActive?: boolean) => ReactNode + /** If defined, the column is considered sortable. @see SortButton */ + onSort?: () => void + sortDirection?: SortDirection + /** CSS class string. @example "w-24 md:w-32" */ + width?: string + /** Aligns column content. */ + align?: 'left' | 'right' +} + +export function MetricValueTooltipContent({ + value, + comparison, + metric, + metricLabel, + dateRangeLabel, + comparisonDateRangeLabel +}: { + value: ValueType + comparison: { value: ValueType; change: number } | null + metric: Metric + metricLabel: string + dateRangeLabel: string + comparisonDateRangeLabel: string | null +}) { + const longFormatter = MetricFormatterLong[metric] + const label = metricLabel.length >= 3 ? ` ${metricLabel.toLowerCase()}` : '' + + if (comparison && comparisonDateRangeLabel) { + return ( +
+
+
+
+ + {longFormatter(value)} + {label} + +
+ {dateRangeLabel} +
+
+ +
+
+
+
+
+ {longFormatter(comparison.value)} + {label} +
+
+ {comparisonDateRangeLabel} +
+
+
+ ) + } + + return
{longFormatter(value)}
+} + +export function formatDateRangeLabel([from, to]: [string, string]): string { + const fromDay = dayjs(from.slice(0, 19)) + const toDay = dayjs(to.slice(0, 19)) + if (fromDay.isSame(toDay, 'day')) return fromDay.format('D MMM YYYY') + if (fromDay.isSame(toDay, 'year')) + return `${fromDay.format('D MMM')} – ${toDay.format('D MMM YYYY')}` + return `${fromDay.format('D MMM YY')} – ${toDay.format('D MMM YY')}` +} + +export function useBodyPortalRef() { + const portalRef = useRef(null) + useEffect(() => { + if (typeof document !== 'undefined') { + portalRef.current = document.body + } + }, []) + return portalRef +} + +export function ExternalLinkIcon() { + return ( + + + + ) +} + +export function extractMetricValue( + row: QueryResultRow, + query: QueryResultQuery, + metricKey: string +): { + metricIndex: number + value: ValueType + comparison: { value: ValueType; change: number } | null +} { + const metricIndex = query.metrics.indexOf(metricKey as Metric) + const value: ValueType = + metricIndex >= 0 ? (row.metrics[metricIndex] ?? null) : null + const comparison = + row.comparison && query.comparison_date_range + ? { + value: row.comparison.metrics[metricIndex] ?? null, + change: row.comparison.change[metricIndex] + } + : null + return { metricIndex, value, comparison } +} + +const DEFAULT_DETAILED_METRICS = [ + 'visitors', + 'percentage', + 'bounce_rate', + 'visit_duration' +] as Metric[] + +export const getBreakdownMetrics = ({ + hasConversionGoalFilter, + isRealtime, + isDetailed = false, + isRevenueAvailable = false, + detailedMetrics = DEFAULT_DETAILED_METRICS +}: { + hasConversionGoalFilter: boolean + isRealtime: boolean + isDetailed?: boolean + isRevenueAvailable?: boolean + detailedMetrics?: Metric[] +}): Metric[] => { + if (hasConversionGoalFilter && isDetailed && isRevenueAvailable) { + return [ + 'total_visitors', + 'visitors', + 'group_conversion_rate', + 'total_revenue', + 'average_revenue' + ] + } + if (hasConversionGoalFilter && isDetailed) { + return ['total_visitors', 'visitors', 'group_conversion_rate'] + } + if (hasConversionGoalFilter) { + return ['visitors', 'group_conversion_rate'] + } + if (isRealtime) { + return ['visitors', 'percentage'] + } + if (isDetailed) { + return detailedMetrics + } + return ['visitors', 'percentage'] +} + +export function addDimensionSearchFilter( + statsQuery: StatsQuery, + dimension: string, + search: string +) { + return addFilter(statsQuery, [ + 'contains', + dimension, + [search], + { case_sensitive: false } + ] as ApiFilter) +} diff --git a/assets/js/dashboard/stats/devices/index.js b/assets/js/dashboard/stats/devices/index.js index f383821c2f00..88757a15c346 100644 --- a/assets/js/dashboard/stats/devices/index.js +++ b/assets/js/dashboard/stats/devices/index.js @@ -5,7 +5,7 @@ import { hasConversionGoalFilter, isFilteringOnFixedValue } from '../../util/filters' -import ListReport from '../reports/list' +import ListReport from '../reports/list-legacy' import * as metrics from '../reports/metrics' import * as api from '../../api' import * as url from '../../util/url' diff --git a/assets/js/dashboard/stats/graph/fetch-main-graph.ts b/assets/js/dashboard/stats/graph/fetch-main-graph.ts index b944f6ed272c..be2ac6883457 100644 --- a/assets/js/dashboard/stats/graph/fetch-main-graph.ts +++ b/assets/js/dashboard/stats/graph/fetch-main-graph.ts @@ -1,9 +1,10 @@ -import { Metric } from '../../../types/query-api' +import { Metric } from '../metrics' import { DashboardState } from '../../dashboard-state' import { DashboardPeriod } from '../../dashboard-time-periods' import { PlausibleSite } from '../../site-context' import { createStatsQuery, ReportParams } from '../../stats-query' import { isRealTimeDashboard } from '../../util/filters' +import { MetricValue } from '../../api' import * as api from '../../api' export function fetchMainGraph( @@ -35,20 +36,11 @@ export function fetchMainGraph( return api.stats(site, statsQuery) } -export type RevenueMetricValue = { - short: string - value: number - long: string - currency: string -} - export type ResultItem = { dimensions: [string] // one item metrics: MetricValues } -export type MetricValue = null | number | RevenueMetricValue - export type MetricValues = [MetricValue] // one item export type MainGraphResponse = { diff --git a/assets/js/dashboard/stats/graph/fetch-top-stats.test.ts b/assets/js/dashboard/stats/graph/fetch-top-stats.test.ts index 05d1d86386ef..4f046cf2ce1e 100644 --- a/assets/js/dashboard/stats/graph/fetch-top-stats.test.ts +++ b/assets/js/dashboard/stats/graph/fetch-top-stats.test.ts @@ -1,4 +1,4 @@ -import { Metric } from '../../../types/query-api' +import { Metric } from '../metrics' import { DashboardState, dashboardStateDefaultValue, diff --git a/assets/js/dashboard/stats/graph/fetch-top-stats.ts b/assets/js/dashboard/stats/graph/fetch-top-stats.ts index 4d2e9364ceee..ccd2a75b906d 100644 --- a/assets/js/dashboard/stats/graph/fetch-top-stats.ts +++ b/assets/js/dashboard/stats/graph/fetch-top-stats.ts @@ -1,7 +1,6 @@ -import { Metric } from '../../../types/query-api' import * as api from '../../api' import { DashboardState } from '../../dashboard-state' -import { getMetricLabel } from '../metrics' +import { Metric, getMetricLabel } from '../metrics' import { ComparisonMode, DashboardPeriod, @@ -141,7 +140,7 @@ function constructTopStatsQuery( type TopStatItem = { metric: Metric - value: number + value: api.MetricValue name: string graphable: boolean change?: number diff --git a/assets/js/dashboard/stats/graph/main-graph-data.ts b/assets/js/dashboard/stats/graph/main-graph-data.ts index 9303d83ee35d..3f34a9f73ab8 100644 --- a/assets/js/dashboard/stats/graph/main-graph-data.ts +++ b/assets/js/dashboard/stats/graph/main-graph-data.ts @@ -1,4 +1,5 @@ -import { MainGraphResponse, MetricValue, ResultItem } from './fetch-main-graph' +import { MainGraphResponse, ResultItem } from './fetch-main-graph' +import { MetricValue } from '../../api' /** * Fills gaps in @see MainGraphResponse the series of `results` and `comparisonResults`. diff --git a/assets/js/dashboard/stats/graph/main-graph.tsx b/assets/js/dashboard/stats/graph/main-graph.tsx index 5e73b8246313..8829ad817714 100644 --- a/assets/js/dashboard/stats/graph/main-graph.tsx +++ b/assets/js/dashboard/stats/graph/main-graph.tsx @@ -19,16 +19,12 @@ import { } from '../../util/date' import classNames from 'classnames' import { ChangeArrow } from '../reports/change-arrow' -import { Metric } from '../../../types/query-api' import { useAppNavigate } from '../../navigation/use-app-navigate' import { Graph, PointerHandler, SeriesConfig } from '../../components/graph' import { useSiteContext, PlausibleSite } from '../../site-context' import { GraphTooltipWrapper } from '../../components/graph-tooltip' -import { - MainGraphResponse, - MetricValue, - RevenueMetricValue -} from './fetch-main-graph' +import { MetricValue, RevenueMetricValue } from '../../api' +import { MainGraphResponse } from './fetch-main-graph' import { remapAndFillData, getLineSegments, @@ -40,7 +36,7 @@ import { getFirstAndLastTimeLabels, MainGraphSeriesName } from './main-graph-data' -import { getMetricLabel } from '../metrics' +import { Metric, getMetricLabel } from '../metrics' import { useDashboardStateContext } from '../../dashboard-state-context' import { hasConversionGoalFilter } from '../../util/filters' import { Interval } from './intervals' diff --git a/assets/js/dashboard/stats/graph/visitor-graph.tsx b/assets/js/dashboard/stats/graph/visitor-graph.tsx index 7b21320ea6a9..c399927ba438 100644 --- a/assets/js/dashboard/stats/graph/visitor-graph.tsx +++ b/assets/js/dashboard/stats/graph/visitor-graph.tsx @@ -10,7 +10,7 @@ import { NoticesIcon } from './notices' import { useDashboardStateContext } from '../../dashboard-state-context' import { PlausibleSite, useSiteContext } from '../../site-context' import { useQuery, useQueryClient } from '@tanstack/react-query' -import { Metric } from '../../../types/query-api' +import { Metric } from '../metrics' import { DashboardPeriod } from '../../dashboard-time-periods' import { DashboardState } from '../../dashboard-state' import { nowForSite } from '../../util/date' diff --git a/assets/js/dashboard/stats/locations/index.js b/assets/js/dashboard/stats/locations/index.js index 1421f4d42198..b083a2da1625 100644 --- a/assets/js/dashboard/stats/locations/index.js +++ b/assets/js/dashboard/stats/locations/index.js @@ -5,7 +5,7 @@ import CountriesMap from './map' import * as api from '../../api' import { apiPath } from '../../util/url' -import ListReport from '../reports/list' +import ListReport from '../reports/list-legacy' import * as metrics from '../reports/metrics' import { hasConversionGoalFilter, diff --git a/assets/js/dashboard/stats/locations/map.tsx b/assets/js/dashboard/stats/locations/map.tsx index 33dd356aace8..60b0bdfddaf2 100644 --- a/assets/js/dashboard/stats/locations/map.tsx +++ b/assets/js/dashboard/stats/locations/map.tsx @@ -17,7 +17,7 @@ import { useDashboardStateContext } from '../../dashboard-state-context' import worldJson from 'visionscarto-world-atlas/world/110m.json' import { UIMode, useTheme } from '../../theme-context' import { apiPath } from '../../util/url' -import { MIN_HEIGHT } from '../reports/list' +import { MIN_HEIGHT } from '../reports/list-legacy' import { MapTooltip } from './map-tooltip' import { GeolocationNotice } from './geolocation-notice' import { DashboardState } from '../../dashboard-state' diff --git a/assets/js/dashboard/stats/metrics.test.ts b/assets/js/dashboard/stats/metrics.test.ts new file mode 100644 index 000000000000..4a17980b87f0 --- /dev/null +++ b/assets/js/dashboard/stats/metrics.test.ts @@ -0,0 +1,236 @@ +import { isSortable, getMetricLabel, getBreakdownMetricLabel } from './metrics' + +describe('isSortable', () => { + it('returns false for total_visitors', () => { + expect(isSortable('total_visitors')).toBe(false) + }) + + it.each(['visitors', 'bounce_rate', 'visit_duration', 'conversion_rate'])( + 'returns true for %s', + (metric) => { + expect(isSortable(metric as Parameters[0])).toBe(true) + } + ) +}) + +describe('getMetricLabel', () => { + it.each([ + ['visitors', false, 'Unique visitors'], + ['visitors', true, 'Unique conversions'], + ['events', false, 'Total events'], + ['events', true, 'Total conversions'], + ['visits', false, 'Total visits'], + ['pageviews', false, 'Total pageviews'], + ['views_per_visit', false, 'Views per visit'], + ['bounce_rate', false, 'Bounce rate'], + ['visit_duration', false, 'Visit duration'], + ['time_on_page', false, 'Time on page'], + ['scroll_depth', false, 'Scroll depth'], + ['conversion_rate', false, 'Conversion rate'], + ['total_revenue', false, 'Total revenue'], + ['average_revenue', false, 'Average revenue'], + ['percentage', false, 'Percentage'], + ['group_conversion_rate', false, 'Conversion rate'], + ['total_visitors', false, 'Total visitors'], + ['exit_rate', false, 'Exit rate'] + ] as const)( + '%s (hasConversionGoalFilter=%s) -> %s', + (metric, hasConversionGoalFilter, expected) => { + expect(getMetricLabel(metric, { hasConversionGoalFilter })).toBe(expected) + } + ) +}) + +describe('getBreakdownMetricLabel', () => { + const defaults = { hasConversionGoalFilter: false, isRealtime: false } + + describe('entry page dimension', () => { + const dimension = 'visit:entry_page' + + it('returns Unique entrances for visitors (no goal, not realtime)', () => { + expect( + getBreakdownMetricLabel('visitors', { + ...defaults, + dimensions: [dimension] + }) + ).toBe('Unique entrances') + }) + + it('returns Total entrances for visits (no goal, not realtime)', () => { + expect( + getBreakdownMetricLabel('visits', { + ...defaults, + dimensions: [dimension] + }) + ).toBe('Total entrances') + }) + + it('falls back to default label for visitors with conversion goal', () => { + expect( + getBreakdownMetricLabel('visitors', { + hasConversionGoalFilter: true, + isRealtime: false, + dimensions: [dimension] + }) + ).toBe('Conversions') + }) + + it('falls back to default label for visitors in realtime', () => { + expect( + getBreakdownMetricLabel('visitors', { + hasConversionGoalFilter: false, + isRealtime: true, + dimensions: [dimension] + }) + ).toBe('Current visitors') + }) + + it('falls back to default label for other metrics', () => { + expect( + getBreakdownMetricLabel('bounce_rate', { + ...defaults, + dimensions: [dimension] + }) + ).toBe('Bounce rate') + }) + }) + + describe('exit page dimension', () => { + const dimension = 'visit:exit_page' + + it('returns Unique exits for visitors (no goal, not realtime)', () => { + expect( + getBreakdownMetricLabel('visitors', { + ...defaults, + dimensions: [dimension] + }) + ).toBe('Unique exits') + }) + + it('returns Total exits for visits (no goal, not realtime)', () => { + expect( + getBreakdownMetricLabel('visits', { + ...defaults, + dimensions: [dimension] + }) + ).toBe('Total exits') + }) + + it('falls back to default label for visitors with conversion goal', () => { + expect( + getBreakdownMetricLabel('visitors', { + hasConversionGoalFilter: true, + isRealtime: false, + dimensions: [dimension] + }) + ).toBe('Conversions') + }) + + it('falls back to default label for visitors in realtime', () => { + expect( + getBreakdownMetricLabel('visitors', { + hasConversionGoalFilter: false, + isRealtime: true, + dimensions: [dimension] + }) + ).toBe('Current visitors') + }) + + it('falls back to default label for other metrics', () => { + expect( + getBreakdownMetricLabel('exit_rate', { + ...defaults, + dimensions: [dimension] + }) + ).toBe('Exit rate') + }) + }) + + describe('goal dimension', () => { + const dimension = 'event:goal' + + it('returns Uniques for visitors', () => { + expect( + getBreakdownMetricLabel('visitors', { + ...defaults, + dimensions: [dimension] + }) + ).toBe('Uniques') + }) + + it('returns Total for events', () => { + expect( + getBreakdownMetricLabel('events', { + ...defaults, + dimensions: [dimension] + }) + ).toBe('Total') + }) + + it('returns CR for conversion_rate', () => { + expect( + getBreakdownMetricLabel('conversion_rate', { + ...defaults, + dimensions: [dimension] + }) + ).toBe('CR') + }) + + it('falls back to default label for other metrics', () => { + expect( + getBreakdownMetricLabel('bounce_rate', { + ...defaults, + dimensions: [dimension] + }) + ).toBe('Bounce rate') + }) + }) + + describe('any other session dimension', () => { + const dimensions = ['visit:source'] + + it('returns Visitors for visitors (no goal, not realtime)', () => { + expect( + getBreakdownMetricLabel('visitors', { ...defaults, dimensions }) + ).toBe('Visitors') + }) + + it('returns Conversions for visitors with conversion goal', () => { + expect( + getBreakdownMetricLabel('visitors', { + hasConversionGoalFilter: true, + isRealtime: false, + dimensions + }) + ).toBe('Conversions') + }) + + it('returns Current visitors for visitors in realtime', () => { + expect( + getBreakdownMetricLabel('visitors', { + hasConversionGoalFilter: false, + isRealtime: true, + dimensions + }) + ).toBe('Current visitors') + }) + + it.each([ + ['group_conversion_rate', 'CR'], + ['conversion_rate', 'CR'], + ['pageviews', 'Pageviews'], + ['average_revenue', 'Average'], + ['total_revenue', 'Revenue'] + ] as const)('%s -> %s', (metric, expected) => { + expect(getBreakdownMetricLabel(metric, { ...defaults, dimensions })).toBe( + expected + ) + }) + + it('delegates to getMetricLabel for other metrics', () => { + expect( + getBreakdownMetricLabel('bounce_rate', { ...defaults, dimensions }) + ).toBe('Bounce rate') + }) + }) +}) diff --git a/assets/js/dashboard/stats/metrics.ts b/assets/js/dashboard/stats/metrics.ts index 2a66eff70e74..98341bda5006 100644 --- a/assets/js/dashboard/stats/metrics.ts +++ b/assets/js/dashboard/stats/metrics.ts @@ -1,4 +1,12 @@ -import { Metric } from '../../types/query-api' +import { Metric as PublicApiMetric } from '../../types/query-api' + +export type Metric = PublicApiMetric | 'total_visitors' | 'exit_rate' + +const NOT_SORTABLE = ['total_visitors'] + +export const isSortable = (metric: Metric): boolean => { + return !NOT_SORTABLE.includes(metric) +} export const getMetricLabel = ( metric: Metric, @@ -33,5 +41,125 @@ export const getMetricLabel = ( return 'Percentage' case 'group_conversion_rate': return 'Conversion rate' + case 'total_visitors': + return 'Total visitors' + case 'exit_rate': + return 'Exit rate' + } +} + +export const getBreakdownMetricLabel = ( + metric: Metric, + { + hasConversionGoalFilter, + isRealtime, + dimensions + }: { + hasConversionGoalFilter: boolean + isRealtime: boolean + dimensions: string[] + } +): string => { + switch (dimensions[0]) { + case 'visit:entry_page': + return getEntryPagesBreakdownMetricLabel(metric, { + hasConversionGoalFilter, + isRealtime + }) + case 'visit:exit_page': + return getExitPagesBreakdownMetricLabel(metric, { + hasConversionGoalFilter, + isRealtime + }) + case 'event:goal': + return getConversionsBreakdownMetricLabel(metric) + default: + return getDefaultBreakdownMetricLabel(metric, { + hasConversionGoalFilter, + isRealtime + }) + } +} + +const getEntryPagesBreakdownMetricLabel = ( + metric: Metric, + { + hasConversionGoalFilter, + isRealtime + }: { hasConversionGoalFilter: boolean; isRealtime: boolean } +): string => { + if (metric === 'visitors' && !hasConversionGoalFilter && !isRealtime) { + return 'Unique entrances' + } + if (metric === 'visits' && !hasConversionGoalFilter && !isRealtime) { + return 'Total entrances' + } + + return getDefaultBreakdownMetricLabel(metric, { + hasConversionGoalFilter, + isRealtime + }) +} + +const getExitPagesBreakdownMetricLabel = ( + metric: Metric, + { + hasConversionGoalFilter, + isRealtime + }: { hasConversionGoalFilter: boolean; isRealtime: boolean } +): string => { + if (metric === 'visitors' && !hasConversionGoalFilter && !isRealtime) { + return 'Unique exits' + } + if (metric === 'visits' && !hasConversionGoalFilter && !isRealtime) { + return 'Total exits' + } + + return getDefaultBreakdownMetricLabel(metric, { + hasConversionGoalFilter, + isRealtime + }) +} + +const getConversionsBreakdownMetricLabel = (metric: Metric): string => { + switch (metric) { + case 'visitors': + return 'Uniques' + case 'events': + return 'Total' + default: + return getDefaultBreakdownMetricLabel(metric, { + hasConversionGoalFilter: false, + isRealtime: false + }) + } +} + +const getDefaultBreakdownMetricLabel = ( + metric: Metric, + { + hasConversionGoalFilter, + isRealtime + }: { hasConversionGoalFilter: boolean; isRealtime: boolean } +): string => { + switch (metric) { + case 'visitors': + return hasConversionGoalFilter + ? 'Conversions' + : isRealtime + ? 'Current visitors' + : 'Visitors' + case 'group_conversion_rate': + return 'CR' + case 'conversion_rate': + return 'CR' + case 'average_revenue': + return 'Average' + case 'total_revenue': + return 'Revenue' + case 'pageviews': + return 'Pageviews' + default: + return getMetricLabel(metric, { hasConversionGoalFilter }) } } diff --git a/assets/js/dashboard/stats/modals/breakdown-modal.tsx b/assets/js/dashboard/stats/modals/breakdown-modal-legacy.tsx similarity index 97% rename from assets/js/dashboard/stats/modals/breakdown-modal.tsx rename to assets/js/dashboard/stats/modals/breakdown-modal-legacy.tsx index b2ab4f6cae15..8427e1bba3dd 100644 --- a/assets/js/dashboard/stats/modals/breakdown-modal.tsx +++ b/assets/js/dashboard/stats/modals/breakdown-modal-legacy.tsx @@ -9,15 +9,15 @@ import { OrderBy, useOrderBy, useRememberOrderBy -} from '../../hooks/use-order-by' +} from '../../hooks/use-order-by-legacy' import { Metric } from '../reports/metrics' import * as metricsModule from '../reports/metrics' import { BreakdownResultMeta, DashboardState } from '../../dashboard-state' -import { ColumnConfiguraton } from '../../components/table' -import { BreakdownTable } from './breakdown-table' +import { ColumnConfiguraton } from '../../components/table-legacy' +import { BreakdownTable } from './breakdown-table-legacy' import { useSiteContext } from '../../site-context' import { DrilldownLink, FilterInfo } from '../../components/drilldown-link' -import { SharedReportProps } from '../reports/list' +import { SharedReportProps } from '../reports/list-legacy' import { hasConversionGoalFilter } from '../../util/filters' export type ReportInfo = { diff --git a/assets/js/dashboard/stats/modals/breakdown-table-legacy.tsx b/assets/js/dashboard/stats/modals/breakdown-table-legacy.tsx new file mode 100644 index 000000000000..0944f3afb203 --- /dev/null +++ b/assets/js/dashboard/stats/modals/breakdown-table-legacy.tsx @@ -0,0 +1,134 @@ +import React, { ReactNode, useRef } from 'react' +import { XMarkIcon } from '@heroicons/react/20/solid' + +import { SearchInput } from '../../components/search-input' +import { ColumnConfiguraton, Table } from '../../components/table-legacy' +import RocketIcon from './rocket-icon' +import { QueryStatus } from '@tanstack/react-query' +import { useAppNavigate } from '../../navigation/use-app-navigate' +import { rootRoute } from '../../router' + +export const BreakdownTable = ({ + title, + isPending, + isFetching, + onSearch, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + columns, + data, + status, + error, + displayError, + onClose +}: { + title: ReactNode + onSearch?: (input: string) => void + isPending: boolean + isFetching: boolean + hasNextPage: boolean + isFetchingNextPage: boolean + fetchNextPage: () => void + columns: ColumnConfiguraton[] + data?: { pages: TListItem[][] } + status?: QueryStatus + error?: Error | null + /** Controls whether the component displays API request errors or ignores them. */ + displayError?: boolean + onClose?: () => void +}) => { + const searchRef = useRef(null) + const navigate = useAppNavigate() + const handleClose = + onClose ?? (() => navigate({ path: rootRoute.path, search: (s) => s })) + + return ( + <> +
+
+

+ {title} +

+ {!!onSearch && ( + + )} + {!isPending && isFetching && } +
+ +
+
+
+ {displayError && status === 'error' && } + {isPending && } + {data && data={data} columns={columns} />} + {!isPending && !isFetching && hasNextPage && ( + fetchNextPage()} + isFetchingNextPage={isFetchingNextPage} + /> + )} +
+ + ) +} + +const InitialLoadingSpinner = () => ( +
+
+
+
+
+) + +const SmallLoadingSpinner = () => ( +
+
+
+) + +const ErrorMessage = ({ error }: { error?: unknown }) => ( +
+
+ +
+
+ {error + ? (error as { message: string }).message + : 'Error loading data. Refresh the page to try again'} +
+
+) + +const LoadMore = ({ + onClick, + isFetchingNextPage +}: { + onClick: () => void + isFetchingNextPage: boolean +}) => ( +
+ {isFetchingNextPage ? ( + + ) : ( + + )} +
+) diff --git a/assets/js/dashboard/stats/modals/breakdown-table.tsx b/assets/js/dashboard/stats/modals/breakdown-table.tsx index 954a29991b2c..232396a35ce7 100644 --- a/assets/js/dashboard/stats/modals/breakdown-table.tsx +++ b/assets/js/dashboard/stats/modals/breakdown-table.tsx @@ -2,13 +2,14 @@ import React, { ReactNode, useRef } from 'react' import { XMarkIcon } from '@heroicons/react/20/solid' import { SearchInput } from '../../components/search-input' -import { ColumnConfiguraton, Table } from '../../components/table' +import { Table } from '../../components/table' +import { ColumnConfiguration } from '../breakdowns' import RocketIcon from './rocket-icon' import { QueryStatus } from '@tanstack/react-query' import { useAppNavigate } from '../../navigation/use-app-navigate' import { rootRoute } from '../../router' -export const BreakdownTable = ({ +export function BreakdownTable({ title, isPending, isFetching, @@ -20,8 +21,9 @@ export const BreakdownTable = ({ data, status, error, - displayError, - onClose + displayError = true, + onClose, + getRowKey }: { title: ReactNode onSearch?: (input: string) => void @@ -30,14 +32,15 @@ export const BreakdownTable = ({ hasNextPage: boolean isFetchingNextPage: boolean fetchNextPage: () => void - columns: ColumnConfiguraton[] - data?: { pages: TListItem[][] } + columns: ColumnConfiguration[] | null + data?: { pages: T[][] } status?: QueryStatus error?: Error | null /** Controls whether the component displays API request errors or ignores them. */ displayError?: boolean onClose?: () => void -}) => { + getRowKey: (row: T) => string +}) { const searchRef = useRef(null) const navigate = useAppNavigate() const handleClose = @@ -50,7 +53,7 @@ export const BreakdownTable = ({

{title}

- {!!onSearch && ( + {typeof onSearch === 'function' && ( ({
{displayError && status === 'error' && } {isPending && } - {data && data={data} columns={columns} />} + {columns && data && ( + + )} {!isPending && !isFetching && hasNextPage && ( fetchNextPage()} diff --git a/assets/js/dashboard/stats/modals/conversions.js b/assets/js/dashboard/stats/modals/conversions.js index 92d6834f1b18..b5e3e523eca1 100644 --- a/assets/js/dashboard/stats/modals/conversions.js +++ b/assets/js/dashboard/stats/modals/conversions.js @@ -1,7 +1,7 @@ import React, { useCallback, useState } from 'react' import Modal from './modal' -import BreakdownModal from './breakdown-modal' +import BreakdownModal from './breakdown-modal-legacy' import * as metrics from '../reports/metrics' import * as url from '../../util/url' import { useSiteContext } from '../../site-context' diff --git a/assets/js/dashboard/stats/modals/details-breakdown.tsx b/assets/js/dashboard/stats/modals/details-breakdown.tsx new file mode 100644 index 000000000000..0b59ddef814d --- /dev/null +++ b/assets/js/dashboard/stats/modals/details-breakdown.tsx @@ -0,0 +1,502 @@ +import React, { ReactNode, useCallback, useMemo, useState } from 'react' +import { useDashboardStateContext } from '../../dashboard-state-context' +import { usePaginatedQueryAPI } from '../../hooks/api-client' +import { rootRoute } from '../../router' +import { + getStoredOrderBy, + Order, + OrderBy, + SortDirection, + useOrderBy, + useRememberOrderBy +} from '../../hooks/use-order-by' +import { Metric, getBreakdownMetricLabel, isSortable } from '../metrics' +import { BreakdownTable } from './breakdown-table' +import { createStatsQuery, StatsQuery } from '../../stats-query' +import { useSiteContext } from '../../site-context' +import { DrilldownLink, FilterInfo } from '../../components/drilldown-link' +import { + ColumnConfiguration, + ExternalLinkIcon, + MetricValueTooltipContent, + SharedBreakdownReportProps, + formatDateRangeLabel, + useBodyPortalRef, + extractMetricValue +} from '../breakdowns' +import { + QueryResultRow, + QueryResultMeta, + QueryResultQuery, + QueryApiResponse +} from '../../api' +import classNames from 'classnames' +import { Tooltip } from '../../util/tooltip' +import { ChangeArrow } from '../reports/change-arrow' +import { + MetricFormatterShort, + MetricFormatterLong +} from '../reports/metric-formatter' +import { + hasConversionGoalFilter, + isRealTimeDashboard +} from '../../util/filters' +import { SortButton } from '../../components/sort-button' + +type DetailsBreakdownProps = SharedBreakdownReportProps & { + title: ReactNode + defaultOrderBy?: OrderBy + addSearchFilter?: (statsQuery: StatsQuery, search: string) => StatsQuery + afterFetchNextPage?: (response: QueryApiResponse) => void +} + +const getMetricCellWidthClass = ( + metric: Metric, + metricLabel: string +): string => { + if (['average_revenue', 'total_revenue'].includes(metric)) { + return 'w-32 min-w-32' + } + + if (metricLabel.length < 3) { + return 'w-28 min-w-28 md:w-24 md:min-w-24' + } + + if (metricLabel.length < 15) { + return 'w-28 min-w-28' + } + + return 'w-32 min-w-32' +} + +export function DetailsBreakdown({ + title, + dimensionLabel, + dimensions, + metrics, + defaultOrderBy = [] as OrderBy, + getFilterInfo, + getExternalLinkUrl, + addSearchFilter, + afterFetchData, + afterFetchNextPage +}: DetailsBreakdownProps) { + const site = useSiteContext() + const { dashboardState } = useDashboardStateContext() + const [search, setSearch] = useState('') + const [meta, setMeta] = useState(null) + const [query, setQuery] = useState(null) + + const storedOrderBy = getStoredOrderBy({ + domain: site.domain, + reportInfo: { dimensionLabel }, + metrics, + fallbackValue: defaultOrderBy + }) + + const { orderBy, orderByDictionary, toggleSortByMetric } = useOrderBy({ + metrics, + defaultOrderBy: storedOrderBy + }) + + useRememberOrderBy({ + effectiveOrderBy: orderBy, + metrics, + reportInfo: { dimensionLabel } + }) + + const effectiveOrderBy = orderBy.length ? orderBy : storedOrderBy + + const baseStatsQuery: StatsQuery = useMemo( + () => + createStatsQuery(dashboardState, { + metrics: metrics, + dimensions, + order_by: effectiveOrderBy as Order[] + }), + [dashboardState, metrics, dimensions, effectiveOrderBy] + ) + + const statsQuery: StatsQuery = useMemo(() => { + if (search && addSearchFilter) { + return addSearchFilter(baseStatsQuery, search) + } + return baseStatsQuery + }, [baseStatsQuery, search, addSearchFilter]) + + const handleAfterFetchData = useCallback( + (response: QueryApiResponse) => { + setMeta(response.meta) + setQuery(response.query) + afterFetchData?.(response) + }, + [afterFetchData] + ) + + const apiState = usePaginatedQueryAPI({ + site, + statsQuery, + afterFetchData: handleAfterFetchData, + afterFetchNextPage + }) + + const metricLabelFor = useCallback( + (metric: Metric): string => { + return getBreakdownMetricLabel(metric, { + hasConversionGoalFilter: hasConversionGoalFilter(dashboardState), + isRealtime: isRealTimeDashboard(dashboardState), + dimensions: dimensions + }) + }, + [dashboardState, dimensions] + ) + + const columns: ColumnConfiguration[] | null = useMemo(() => { + if (!query) return null + + const hasPercentage = query.metrics.includes('percentage') + const isVisitorsWithPercentageCell = (m: Metric) => + hasPercentage && m === 'visitors' + + return [ + { + key: 'dimension', + renderLabel: () => dimensionLabel, + renderCell: (row, isActive) => ( + + ), + align: 'left' + }, + ...query.metrics + // Percentage is not its own column — shown inline in the visitors cell + .filter((metric) => metric !== 'percentage') + .map( + (metric): ColumnConfiguration => ({ + key: metric, + renderLabel: () => ( + toggleSortByMetric(metric)} + sortDirection={orderByDictionary[metric] ?? null} + /> + ), + renderCell: (row, isActive) => { + if (isVisitorsWithPercentageCell(metric)) { + return ( + + ) + } else { + return ( + + ) + } + }, + onSort: isSortable(metric) + ? () => toggleSortByMetric(metric) + : undefined, + sortDirection: orderByDictionary[metric], + width: isVisitorsWithPercentageCell(metric) + ? 'w-36' + : getMetricCellWidthClass(metric, metricLabelFor(metric)), + align: 'right' + }) + ) + ] + }, [ + dimensionLabel, + query, + meta, + getFilterInfo, + getExternalLinkUrl, + orderByDictionary, + toggleSortByMetric, + metricLabelFor + ]) + + return ( + + title={title} + {...apiState} + columns={columns} + onSearch={addSearchFilter ? setSearch : undefined} + getRowKey={(row) => row.dimensions[0]} + /> + ) +} + +function VisitorsWithPercentageCell({ + row, + query, + isActive +}: { + row: QueryResultRow + query: QueryResultQuery + isActive?: boolean +}) { + const portalRef = useBodyPortalRef() + + const { value: visitorsValue, comparison: visitorsComparison } = + extractMetricValue(row, query, 'visitors') + const { value: percentageValue } = extractMetricValue( + row, + query, + 'percentage' + ) + + const visitorsShortFormatter = MetricFormatterShort['visitors'] + const visitorsLongFormatter = MetricFormatterLong['visitors'] + const percentageFormatter = MetricFormatterShort['percentage'] + + const showTooltip = !!visitorsComparison + + const dateRangeLabel = formatDateRangeLabel(query.date_range) + const comparisonDateRangeLabel = query.comparison_date_range + ? formatDateRangeLabel(query.comparison_date_range) + : null + + const percentageCell = ( + + {percentageFormatter(percentageValue)} + + ) + + const visitorsCell = ( + + {isActive + ? visitorsLongFormatter(visitorsValue) + : visitorsShortFormatter(visitorsValue)} + {visitorsComparison && ( + + )} + + ) + + const visitorsWithTooltip = showTooltip ? ( + } + info={ + + } + > + {visitorsCell} + + ) : ( + visitorsCell + ) + + return ( +
+ {percentageCell} + {visitorsWithTooltip} +
+ ) +} + +function MetricValueCell({ + row, + metric, + metricLabel, + query, + isActive +}: { + row: QueryResultRow + metric: Metric + metricLabel: string + query: QueryResultQuery + isActive?: boolean +}) { + const portalRef = useBodyPortalRef() + + const { value, comparison } = extractMetricValue(row, query, metric) + + const shortFormatter = MetricFormatterShort[metric] + const longFormatter = MetricFormatterLong[metric] + + // Show long format when the row is active (hovered on desktop, tapped on mobile) + const displayFormatter = isActive ? longFormatter : shortFormatter + + // Tooltip is used for comparison mode only + const showTooltip = !!comparison + + const valueContent = ( + + {displayFormatter(value)} + {comparison && ( + + )} + + ) + + if (!showTooltip) return valueContent + + const dateRangeLabel = formatDateRangeLabel(query.date_range) + const comparisonDateRangeLabel = query.comparison_date_range + ? formatDateRangeLabel(query.comparison_date_range) + : null + + return ( + } + info={ + + } + > + {valueContent} + + ) +} + +function getMetricWarning( + metricKey: string, + meta: QueryResultMeta | null +): string | null { + const warnings = meta?.metric_warnings + if (!warnings || !warnings[metricKey]) return null + const { code, message } = warnings[metricKey] + if (metricKey === 'bounce_rate' && code === 'no_imported_bounce_rate') { + return 'Does not include imported data' + } + if (metricKey === 'scroll_depth' && code === 'no_imported_scroll_depth') { + return 'Does not include imported data' + } + if (metricKey === 'time_on_page' && code) { + return message + } + return null +} + +function MetricLabel({ + label, + warning, + sortable, + toggleSort, + sortDirection +}: { + label: string + warning: string | null + sortable: boolean + toggleSort: () => void + sortDirection: SortDirection | null +}) { + const labelText = label + (warning ? ' *' : '') + const inner = sortable ? ( + + {labelText} + + ) : ( + labelText + ) + if (warning) { + return ( + + {'* ' + warning} + + } + className="inline-block" + > + {inner} + + ) + } else { + return <>{inner} + } +} + +function DimensionCell({ + row, + getFilterInfo, + getExternalLinkUrl, + isActive +}: { + row: QueryResultRow + getFilterInfo: (row: QueryResultRow) => FilterInfo | null + getExternalLinkUrl?: (row: QueryResultRow) => string | null + isActive?: boolean +}) { + return ( +
+ + {row.dimensions[0]} + + {typeof getExternalLinkUrl === 'function' && ( + + )} +
+ ) +} + +function ExternalLink({ + href, + isActive +}: { + href: string | null + isActive?: boolean +}) { + return ( +
+ {href && ( + + + + )} +
+ ) +} diff --git a/assets/js/dashboard/stats/modals/devices/browser-versions-modal.js b/assets/js/dashboard/stats/modals/devices/browser-versions-modal.js index 460a04aaca76..2197ca03be0d 100644 --- a/assets/js/dashboard/stats/modals/devices/browser-versions-modal.js +++ b/assets/js/dashboard/stats/modals/devices/browser-versions-modal.js @@ -1,13 +1,13 @@ import React, { useCallback } from 'react' import Modal from './../modal' import { addFilter } from '../../../dashboard-state' -import BreakdownModal from './../breakdown-modal' +import BreakdownModal from '../breakdown-modal-legacy' import * as url from '../../../util/url' import { useDashboardStateContext } from '../../../dashboard-state-context' import { useSiteContext } from '../../../site-context' import { browserIconFor } from '../../devices' import chooseMetrics from './choose-metrics' -import { SortDirection } from '../../../hooks/use-order-by' +import { SortDirection } from '../../../hooks/use-order-by-legacy' function BrowserVersionsModal() { const { dashboardState } = useDashboardStateContext() diff --git a/assets/js/dashboard/stats/modals/devices/browsers-modal.js b/assets/js/dashboard/stats/modals/devices/browsers-modal.js index 7cbc87d7dc26..f6a14ce1bbf7 100644 --- a/assets/js/dashboard/stats/modals/devices/browsers-modal.js +++ b/assets/js/dashboard/stats/modals/devices/browsers-modal.js @@ -1,13 +1,13 @@ import React, { useCallback } from 'react' import Modal from './../modal' import { addFilter } from '../../../dashboard-state' -import BreakdownModal from './../breakdown-modal' +import BreakdownModal from '../breakdown-modal-legacy' import * as url from '../../../util/url' import { useDashboardStateContext } from '../../../dashboard-state-context' import { useSiteContext } from '../../../site-context' import { browserIconFor } from '../../devices' import chooseMetrics from './choose-metrics' -import { SortDirection } from '../../../hooks/use-order-by' +import { SortDirection } from '../../../hooks/use-order-by-legacy' function BrowsersModal() { const { dashboardState } = useDashboardStateContext() diff --git a/assets/js/dashboard/stats/modals/devices/operating-system-versions-modal.js b/assets/js/dashboard/stats/modals/devices/operating-system-versions-modal.js index ca3ae1919333..50b20d45679c 100644 --- a/assets/js/dashboard/stats/modals/devices/operating-system-versions-modal.js +++ b/assets/js/dashboard/stats/modals/devices/operating-system-versions-modal.js @@ -1,13 +1,13 @@ import React, { useCallback } from 'react' import Modal from './../modal' import { addFilter } from '../../../dashboard-state' -import BreakdownModal from './../breakdown-modal' +import BreakdownModal from '../breakdown-modal-legacy' import * as url from '../../../util/url' import { useDashboardStateContext } from '../../../dashboard-state-context' import { useSiteContext } from '../../../site-context' import { osIconFor } from '../../devices' import chooseMetrics from './choose-metrics' -import { SortDirection } from '../../../hooks/use-order-by' +import { SortDirection } from '../../../hooks/use-order-by-legacy' function OperatingSystemVersionsModal() { const { dashboardState } = useDashboardStateContext() diff --git a/assets/js/dashboard/stats/modals/devices/operating-systems-modal.js b/assets/js/dashboard/stats/modals/devices/operating-systems-modal.js index d5f36b2f8100..8685c2158abe 100644 --- a/assets/js/dashboard/stats/modals/devices/operating-systems-modal.js +++ b/assets/js/dashboard/stats/modals/devices/operating-systems-modal.js @@ -1,13 +1,13 @@ import React, { useCallback } from 'react' import Modal from './../modal' import { addFilter } from '../../../dashboard-state' -import BreakdownModal from './../breakdown-modal' +import BreakdownModal from '../breakdown-modal-legacy' import * as url from '../../../util/url' import { useDashboardStateContext } from '../../../dashboard-state-context' import { useSiteContext } from '../../../site-context' import { osIconFor } from '../../devices' import chooseMetrics from './choose-metrics' -import { SortDirection } from '../../../hooks/use-order-by' +import { SortDirection } from '../../../hooks/use-order-by-legacy' function OperatingSystemsModal() { const { dashboardState } = useDashboardStateContext() diff --git a/assets/js/dashboard/stats/modals/devices/screen-sizes.js b/assets/js/dashboard/stats/modals/devices/screen-sizes.js index 79c3f5da6d54..15828f1fc013 100644 --- a/assets/js/dashboard/stats/modals/devices/screen-sizes.js +++ b/assets/js/dashboard/stats/modals/devices/screen-sizes.js @@ -1,12 +1,12 @@ import React, { useCallback } from 'react' import Modal from './../modal' -import BreakdownModal from './../breakdown-modal' +import BreakdownModal from '../breakdown-modal-legacy' import * as url from '../../../util/url' import { useDashboardStateContext } from '../../../dashboard-state-context' import { useSiteContext } from '../../../site-context' import { screenSizeIconFor } from '../../devices' import chooseMetrics from './choose-metrics' -import { SortDirection } from '../../../hooks/use-order-by' +import { SortDirection } from '../../../hooks/use-order-by-legacy' function ScreenSizesModal() { const { dashboardState } = useDashboardStateContext() diff --git a/assets/js/dashboard/stats/modals/entry-pages.js b/assets/js/dashboard/stats/modals/entry-pages.js deleted file mode 100644 index a443248ca9da..000000000000 --- a/assets/js/dashboard/stats/modals/entry-pages.js +++ /dev/null @@ -1,102 +0,0 @@ -import React, { useCallback } from 'react' -import Modal from './modal' -import { - hasConversionGoalFilter, - isRealTimeDashboard -} from '../../util/filters' -import { addFilter, revenueAvailable } from '../../dashboard-state' -import BreakdownModal from './breakdown-modal' -import * as metrics from '../reports/metrics' -import * as url from '../../util/url' -import { useDashboardStateContext } from '../../dashboard-state-context' -import { useSiteContext } from '../../site-context' -import { SortDirection } from '../../hooks/use-order-by' - -function EntryPagesModal() { - const { dashboardState } = useDashboardStateContext() - const site = useSiteContext() - - /*global BUILD_EXTRA*/ - const showRevenueMetrics = - BUILD_EXTRA && revenueAvailable(dashboardState, site) - - const reportInfo = { - title: 'Entry pages', - dimension: 'entry_page', - endpoint: url.apiPath(site, '/entry-pages'), - dimensionLabel: 'Entry page', - defaultOrder: ['visitors', SortDirection.desc] - } - - const getFilterInfo = useCallback( - (listItem) => { - return { - prefix: reportInfo.dimension, - filter: ['is', reportInfo.dimension, [listItem.name]] - } - }, - [reportInfo.dimension] - ) - - const addSearchFilter = useCallback( - (dashboardState, searchString) => { - return addFilter(dashboardState, [ - 'contains', - reportInfo.dimension, - [searchString], - { case_sensitive: false } - ]) - }, - [reportInfo.dimension] - ) - - function chooseMetrics() { - if (hasConversionGoalFilter(dashboardState)) { - return [ - metrics.createTotalVisitors(), - metrics.createVisitors({ - renderLabel: (_dashboardState) => 'Conversions', - width: 'w-28' - }), - metrics.createConversionRate(), - showRevenueMetrics && metrics.createTotalRevenue(), - showRevenueMetrics && metrics.createAverageRevenue() - ].filter((metric) => !!metric) - } - - if ( - isRealTimeDashboard(dashboardState) && - !hasConversionGoalFilter(dashboardState) - ) { - return [ - metrics.createVisitors({ - renderLabel: (_dashboardState) => 'Current visitors', - width: 'w-32' - }) - ] - } - - return [ - metrics.createVisitors({ renderLabel: (_dashboardState) => 'Visitors' }), - metrics.createVisits({ - renderLabel: (_dashboardState) => 'Total entrances', - width: 'w-32' - }), - metrics.createBounceRate(), - metrics.createVisitDuration() - ] - } - - return ( - - - - ) -} - -export default EntryPagesModal diff --git a/assets/js/dashboard/stats/modals/exit-pages.js b/assets/js/dashboard/stats/modals/exit-pages.js deleted file mode 100644 index 84a5b6f59b8f..000000000000 --- a/assets/js/dashboard/stats/modals/exit-pages.js +++ /dev/null @@ -1,105 +0,0 @@ -import React, { useCallback } from 'react' -import Modal from './modal' -import { - hasConversionGoalFilter, - isRealTimeDashboard -} from '../../util/filters' -import { addFilter, revenueAvailable } from '../../dashboard-state' -import BreakdownModal from './breakdown-modal' -import * as metrics from '../reports/metrics' -import * as url from '../../util/url' -import { useDashboardStateContext } from '../../dashboard-state-context' -import { useSiteContext } from '../../site-context' -import { SortDirection } from '../../hooks/use-order-by' - -function ExitPagesModal() { - const { dashboardState } = useDashboardStateContext() - const site = useSiteContext() - - /*global BUILD_EXTRA*/ - const showRevenueMetrics = - BUILD_EXTRA && revenueAvailable(dashboardState, site) - - const reportInfo = { - title: 'Exit pages', - dimension: 'exit_page', - endpoint: url.apiPath(site, '/exit-pages'), - dimensionLabel: 'Page url', - defaultOrder: ['visitors', SortDirection.desc] - } - - const getFilterInfo = useCallback( - (listItem) => { - return { - prefix: reportInfo.dimension, - filter: ['is', reportInfo.dimension, [listItem.name]] - } - }, - [reportInfo.dimension] - ) - - const addSearchFilter = useCallback( - (dashboardState, searchString) => { - return addFilter(dashboardState, [ - 'contains', - reportInfo.dimension, - [searchString], - { case_sensitive: false } - ]) - }, - [reportInfo.dimension] - ) - - function chooseMetrics() { - if (hasConversionGoalFilter(dashboardState)) { - return [ - metrics.createTotalVisitors(), - metrics.createVisitors({ - renderLabel: (_dashboardState) => 'Conversions', - width: 'w-28' - }), - metrics.createConversionRate(), - showRevenueMetrics && metrics.createTotalRevenue(), - showRevenueMetrics && metrics.createAverageRevenue() - ].filter((metric) => !!metric) - } - - if ( - isRealTimeDashboard(dashboardState) && - !hasConversionGoalFilter(dashboardState) - ) { - return [ - metrics.createVisitors({ - renderLabel: (_dashboardState) => 'Current visitors', - width: 'w-32' - }) - ] - } - - return [ - metrics.createVisitors({ - renderLabel: (_dashboardState) => 'Visitors', - sortable: true - }), - metrics.createVisits({ - renderLabel: (_dashboardState) => 'Total exits', - width: 'w-32', - sortable: true - }), - metrics.createExitRate() - ] - } - - return ( - - - - ) -} - -export default ExitPagesModal diff --git a/assets/js/dashboard/stats/modals/google-keywords.tsx b/assets/js/dashboard/stats/modals/google-keywords.tsx index d2e89378932f..2c10cf004a7f 100644 --- a/assets/js/dashboard/stats/modals/google-keywords.tsx +++ b/assets/js/dashboard/stats/modals/google-keywords.tsx @@ -11,8 +11,8 @@ import { } from '../../util/number-formatter' import { apiPath } from '../../util/url' import { DashboardState } from '../../dashboard-state' -import { ColumnConfiguraton } from '../../components/table' -import { BreakdownTable } from './breakdown-table' +import { ColumnConfiguraton } from '../../components/table-legacy' +import { BreakdownTable } from './breakdown-table-legacy' type GoogleKeywordItem = { visitors: string diff --git a/assets/js/dashboard/stats/modals/locations-modal.js b/assets/js/dashboard/stats/modals/locations-modal.js index ec3a5c5548cf..fbfdd97c9203 100644 --- a/assets/js/dashboard/stats/modals/locations-modal.js +++ b/assets/js/dashboard/stats/modals/locations-modal.js @@ -5,13 +5,13 @@ import { hasConversionGoalFilter, isRealTimeDashboard } from '../../util/filters' -import BreakdownModal from './breakdown-modal' +import BreakdownModal from './breakdown-modal-legacy' import * as metrics from '../reports/metrics' import * as url from '../../util/url' import { useDashboardStateContext } from '../../dashboard-state-context' import { useSiteContext } from '../../site-context' import { addFilter, revenueAvailable } from '../../dashboard-state' -import { SortDirection } from '../../hooks/use-order-by' +import { SortDirection } from '../../hooks/use-order-by-legacy' const VIEWS = { countries: { diff --git a/assets/js/dashboard/stats/modals/pages.js b/assets/js/dashboard/stats/modals/pages.js index b93ab5869e16..f374c6dfe4c3 100644 --- a/assets/js/dashboard/stats/modals/pages.js +++ b/assets/js/dashboard/stats/modals/pages.js @@ -5,12 +5,12 @@ import { isRealTimeDashboard } from '../../util/filters' import { addFilter, revenueAvailable } from '../../dashboard-state' -import BreakdownModal from './breakdown-modal' +import BreakdownModal from './breakdown-modal-legacy' import * as metrics from '../reports/metrics' import * as url from '../../util/url' import { useDashboardStateContext } from '../../dashboard-state-context' import { useSiteContext } from '../../site-context' -import { SortDirection } from '../../hooks/use-order-by' +import { SortDirection } from '../../hooks/use-order-by-legacy' function PagesModal() { const { dashboardState } = useDashboardStateContext() diff --git a/assets/js/dashboard/stats/modals/props.js b/assets/js/dashboard/stats/modals/props.js index 8dcc8e6e0b27..eae9c6344c2d 100644 --- a/assets/js/dashboard/stats/modals/props.js +++ b/assets/js/dashboard/stats/modals/props.js @@ -9,12 +9,12 @@ import { getGoalFilter, hasConversionGoalFilter } from '../../util/filters' -import BreakdownModal from './breakdown-modal' +import BreakdownModal from './breakdown-modal-legacy' import * as metrics from '../reports/metrics' import * as url from '../../util/url' import { useDashboardStateContext } from '../../dashboard-state-context' import { useSiteContext } from '../../site-context' -import { SortDirection } from '../../hooks/use-order-by' +import { SortDirection } from '../../hooks/use-order-by-legacy' function PropsModal() { const { dashboardState } = useDashboardStateContext() diff --git a/assets/js/dashboard/stats/modals/referrer-drilldown.js b/assets/js/dashboard/stats/modals/referrer-drilldown.js index 8266a6fc5466..6ee599a4ac29 100644 --- a/assets/js/dashboard/stats/modals/referrer-drilldown.js +++ b/assets/js/dashboard/stats/modals/referrer-drilldown.js @@ -6,13 +6,13 @@ import { hasConversionGoalFilter, isRealTimeDashboard } from '../../util/filters' -import BreakdownModal from './breakdown-modal' +import BreakdownModal from './breakdown-modal-legacy' import * as metrics from '../reports/metrics' import * as url from '../../util/url' import { addFilter, revenueAvailable } from '../../dashboard-state' import { useDashboardStateContext } from '../../dashboard-state-context' import { useSiteContext } from '../../site-context' -import { SortDirection } from '../../hooks/use-order-by' +import { SortDirection } from '../../hooks/use-order-by-legacy' import { SourceFavicon } from '../sources/source-favicon' function ReferrerDrilldownModal() { diff --git a/assets/js/dashboard/stats/modals/sources.js b/assets/js/dashboard/stats/modals/sources.js index b52b8e20e2ab..7d06fb8cde3e 100644 --- a/assets/js/dashboard/stats/modals/sources.js +++ b/assets/js/dashboard/stats/modals/sources.js @@ -4,13 +4,13 @@ import { hasConversionGoalFilter, isRealTimeDashboard } from '../../util/filters' -import BreakdownModal from './breakdown-modal' +import BreakdownModal from './breakdown-modal-legacy' import * as metrics from '../reports/metrics' import * as url from '../../util/url' import { addFilter, revenueAvailable } from '../../dashboard-state' import { useDashboardStateContext } from '../../dashboard-state-context' import { useSiteContext } from '../../site-context' -import { SortDirection } from '../../hooks/use-order-by' +import { SortDirection } from '../../hooks/use-order-by-legacy' import { SourceFavicon } from '../sources/source-favicon' const VIEWS = { diff --git a/assets/js/dashboard/stats/pages/entry-pages.tsx b/assets/js/dashboard/stats/pages/entry-pages.tsx new file mode 100644 index 000000000000..abc828ed7bbc --- /dev/null +++ b/assets/js/dashboard/stats/pages/entry-pages.tsx @@ -0,0 +1,106 @@ +import React, { useCallback } from 'react' +import Modal from '../modals/modal' +import { Metric } from '../metrics' +import * as url from '../../util/url' +import { StatsQuery } from '../../stats-query' +import { IndexBreakdown } from '../reports/index-breakdown' +import { DetailsBreakdown } from '../modals/details-breakdown' +import { useDashboardStateContext } from '../../dashboard-state-context' +import { useSiteContext } from '../../site-context' +import { + hasConversionGoalFilter, + isRealTimeDashboard +} from '../../util/filters' +import { revenueAvailable, Filter } from '../../dashboard-state' +import { QueryApiResponse, QueryResultRow } from '../../api' +import { SortDirection } from '../../hooks/use-order-by' +import { addDimensionSearchFilter, getBreakdownMetrics } from '../breakdowns' +import { PAGES_BAR_COLOR } from './pages' + +const DIMENSION = 'visit:entry_page' +const DETAILED_METRICS: Metric[] = [ + 'visitors', + 'percentage', + 'visits', + 'bounce_rate', + 'visit_duration' +] + +function getFilterInfo(row: QueryResultRow) { + return { + prefix: 'entry_page', + filter: ['is', 'entry_page', [row.dimensions[0]]] as Filter + } +} + +function addSearchFilter(statsQuery: StatsQuery, search: string) { + return addDimensionSearchFilter(statsQuery, DIMENSION, search) +} + +export function EntryPagesIndex({ + afterFetchData +}: { + afterFetchData?: (response: QueryApiResponse) => void +}) { + const { dashboardState } = useDashboardStateContext() + const site = useSiteContext() + + const metrics = getBreakdownMetrics({ + hasConversionGoalFilter: hasConversionGoalFilter(dashboardState), + isRealtime: isRealTimeDashboard(dashboardState) + }) + + const getExternalLinkUrl = useCallback( + (row: QueryResultRow) => url.externalLinkForPage(site, row.dimensions[0]), + [site] + ) + + return ( + + ) +} + +export function EntryPagesDetails() { + const { dashboardState } = useDashboardStateContext() + const site = useSiteContext() + + /*global BUILD_EXTRA*/ + const isRevenueAvailable = + BUILD_EXTRA && revenueAvailable(dashboardState, site) + + const metrics = getBreakdownMetrics({ + hasConversionGoalFilter: hasConversionGoalFilter(dashboardState), + isRealtime: isRealTimeDashboard(dashboardState), + isDetailed: true, + isRevenueAvailable: isRevenueAvailable, + detailedMetrics: DETAILED_METRICS + }) + + const getExternalLinkUrl = useCallback( + (row: QueryResultRow) => url.externalLinkForPage(site, row.dimensions[0]), + [site] + ) + + return ( + + + + ) +} diff --git a/assets/js/dashboard/stats/pages/exit-pages.tsx b/assets/js/dashboard/stats/pages/exit-pages.tsx new file mode 100644 index 000000000000..ad50af996fd9 --- /dev/null +++ b/assets/js/dashboard/stats/pages/exit-pages.tsx @@ -0,0 +1,105 @@ +import React, { useCallback } from 'react' +import Modal from '../modals/modal' +import { Metric } from '../metrics' +import * as url from '../../util/url' +import { StatsQuery } from '../../stats-query' +import { IndexBreakdown } from '../reports/index-breakdown' +import { DetailsBreakdown } from '../modals/details-breakdown' +import { useDashboardStateContext } from '../../dashboard-state-context' +import { useSiteContext } from '../../site-context' +import { + hasConversionGoalFilter, + isRealTimeDashboard +} from '../../util/filters' +import { revenueAvailable, Filter } from '../../dashboard-state' +import { QueryApiResponse, QueryResultRow } from '../../api' +import { SortDirection } from '../../hooks/use-order-by' +import { addDimensionSearchFilter, getBreakdownMetrics } from '../breakdowns' +import { PAGES_BAR_COLOR } from './pages' + +const DIMENSION = 'visit:exit_page' +const DETAILED_METRICS: Metric[] = [ + 'visitors', + 'percentage', + 'visits', + 'exit_rate' +] + +function getFilterInfo(row: QueryResultRow) { + return { + prefix: 'exit_page', + filter: ['is', 'exit_page', [row.dimensions[0]]] as Filter + } +} + +function addSearchFilter(statsQuery: StatsQuery, search: string) { + return addDimensionSearchFilter(statsQuery, DIMENSION, search) +} + +export function ExitPagesIndex({ + afterFetchData +}: { + afterFetchData?: (response: QueryApiResponse) => void +}) { + const { dashboardState } = useDashboardStateContext() + const site = useSiteContext() + + const metrics = getBreakdownMetrics({ + hasConversionGoalFilter: hasConversionGoalFilter(dashboardState), + isRealtime: isRealTimeDashboard(dashboardState) + }) + + const getExternalLinkUrl = useCallback( + (row: QueryResultRow) => url.externalLinkForPage(site, row.dimensions[0]), + [site] + ) + + return ( + + ) +} + +export function ExitPagesDetails() { + const { dashboardState } = useDashboardStateContext() + const site = useSiteContext() + + /*global BUILD_EXTRA*/ + const isRevenueAvailable = + BUILD_EXTRA && revenueAvailable(dashboardState, site) + + const metrics = getBreakdownMetrics({ + hasConversionGoalFilter: hasConversionGoalFilter(dashboardState), + isRealtime: isRealTimeDashboard(dashboardState), + isDetailed: true, + isRevenueAvailable: isRevenueAvailable, + detailedMetrics: [...DETAILED_METRICS] + }) + + const getExternalLinkUrl = useCallback( + (row: QueryResultRow) => url.externalLinkForPage(site, row.dimensions[0]), + [site] + ) + + return ( + + + + ) +} diff --git a/assets/js/dashboard/stats/pages/index.js b/assets/js/dashboard/stats/pages/index.js index 962cb935a5ae..23682d253874 100644 --- a/assets/js/dashboard/stats/pages/index.js +++ b/assets/js/dashboard/stats/pages/index.js @@ -1,10 +1,6 @@ import React, { useEffect, useState } from 'react' import * as storage from '../../util/storage' -import * as url from '../../util/url' -import * as api from '../../api' -import ListReport from './../reports/list' -import * as metrics from './../reports/metrics' import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning' import { hasConversionGoalFilter } from '../../util/filters' import { useDashboardStateContext } from '../../dashboard-state-context' @@ -15,138 +11,9 @@ import { ReportHeader } from '../reports/report-header' import { TabButton, TabWrapper } from '../../components/tabs' import MoreLink from '../more-link' import { MoreLinkState } from '../more-link-state' - -function EntryPages({ afterFetchData }) { - const { dashboardState } = useDashboardStateContext() - const site = useSiteContext() - function fetchData() { - return api.get(url.apiPath(site, '/entry-pages'), dashboardState, { - limit: 9 - }) - } - - function getExternalLinkUrl(page) { - return url.externalLinkForPage(site, page.name) - } - - function getFilterInfo(listItem) { - return { - prefix: 'entry_page', - filter: ['is', 'entry_page', [listItem['name']]] - } - } - - function chooseMetrics() { - return [ - metrics.createVisitors({ - defaultLabel: 'Unique entrances', - width: 'w-36', - meta: { plot: true } - }), - !hasConversionGoalFilter(dashboardState) && - metrics.createPercentage({ meta: { showOnHover: true } }), - hasConversionGoalFilter(dashboardState) && metrics.createConversionRate() - ].filter((metric) => !!metric) - } - - return ( - - ) -} - -function ExitPages({ afterFetchData }) { - const site = useSiteContext() - const { dashboardState } = useDashboardStateContext() - function fetchData() { - return api.get(url.apiPath(site, '/exit-pages'), dashboardState, { - limit: 9 - }) - } - - function getExternalLinkUrl(page) { - return url.externalLinkForPage(site, page.name) - } - - function getFilterInfo(listItem) { - return { - prefix: 'exit_page', - filter: ['is', 'exit_page', [listItem['name']]] - } - } - - function chooseMetrics() { - return [ - metrics.createVisitors({ - defaultLabel: 'Unique exits', - width: 'w-36', - meta: { plot: true } - }), - !hasConversionGoalFilter(dashboardState) && - metrics.createPercentage({ meta: { showOnHover: true } }), - hasConversionGoalFilter(dashboardState) && metrics.createConversionRate() - ].filter((metric) => !!metric) - } - - return ( - - ) -} - -function TopPages({ afterFetchData }) { - const { dashboardState } = useDashboardStateContext() - const site = useSiteContext() - function fetchData() { - return api.get(url.apiPath(site, '/pages'), dashboardState, { limit: 9 }) - } - - function getExternalLinkUrl(page) { - return url.externalLinkForPage(site, page.name) - } - - function getFilterInfo(listItem) { - return { - prefix: 'page', - filter: ['is', 'page', [listItem['name']]] - } - } - - function chooseMetrics() { - return [ - metrics.createVisitors({ meta: { plot: true } }), - !hasConversionGoalFilter(dashboardState) && - metrics.createPercentage({ meta: { showOnHover: true } }), - hasConversionGoalFilter(dashboardState) && metrics.createConversionRate() - ].filter((metric) => !!metric) - } - - return ( - - ) -} +import { PagesIndex } from './pages' +import { EntryPagesIndex } from './entry-pages' +import { ExitPagesIndex } from './exit-pages' export default function Pages() { const { dashboardState } = useDashboardStateContext() @@ -203,12 +70,12 @@ export default function Pages() { function renderContent() { switch (mode) { case 'entry-pages': - return + return case 'exit-pages': - return + return case 'pages': default: - return + return } } diff --git a/assets/js/dashboard/stats/pages/pages.tsx b/assets/js/dashboard/stats/pages/pages.tsx new file mode 100644 index 000000000000..d75c1805e9e5 --- /dev/null +++ b/assets/js/dashboard/stats/pages/pages.tsx @@ -0,0 +1,108 @@ +import React, { useCallback } from 'react' +import Modal from '../modals/modal' +import { Metric } from '../metrics' +import * as url from '../../util/url' +import { StatsQuery } from '../../stats-query' +import { IndexBreakdown } from '../reports/index-breakdown' +import { DetailsBreakdown } from '../modals/details-breakdown' +import { useDashboardStateContext } from '../../dashboard-state-context' +import { useSiteContext } from '../../site-context' +import { + hasConversionGoalFilter, + isRealTimeDashboard +} from '../../util/filters' +import { revenueAvailable, Filter } from '../../dashboard-state' +import { QueryApiResponse, QueryResultRow } from '../../api' +import { SortDirection } from '../../hooks/use-order-by' +import { addDimensionSearchFilter, getBreakdownMetrics } from '../breakdowns' + +export const PAGES_BAR_COLOR = 'bg-orange-50 group-hover/row:bg-orange-100' + +const DIMENSION = 'event:page' +const DETAILED_METRICS: Metric[] = [ + 'visitors', + 'percentage', + 'pageviews', + 'bounce_rate', + 'time_on_page', + 'scroll_depth' +] + +function getFilterInfo(row: QueryResultRow) { + return { + prefix: 'page', + filter: ['is', 'page', [row.dimensions[0]]] as Filter + } +} + +function addSearchFilter(statsQuery: StatsQuery, search: string) { + return addDimensionSearchFilter(statsQuery, DIMENSION, search) +} + +export function PagesIndex({ + afterFetchData +}: { + afterFetchData?: (response: QueryApiResponse) => void +}) { + const { dashboardState } = useDashboardStateContext() + const site = useSiteContext() + + const metrics = getBreakdownMetrics({ + hasConversionGoalFilter: hasConversionGoalFilter(dashboardState), + isRealtime: isRealTimeDashboard(dashboardState) + }) + + const getExternalLinkUrl = useCallback( + (row: QueryResultRow) => url.externalLinkForPage(site, row.dimensions[0]), + [site] + ) + + return ( + + ) +} + +export function PagesDetails() { + const { dashboardState } = useDashboardStateContext() + const site = useSiteContext() + + /*global BUILD_EXTRA*/ + const isRevenueAvailable = + BUILD_EXTRA && revenueAvailable(dashboardState, site) + + const metrics = getBreakdownMetrics({ + hasConversionGoalFilter: hasConversionGoalFilter(dashboardState), + isRealtime: isRealTimeDashboard(dashboardState), + isDetailed: true, + isRevenueAvailable: isRevenueAvailable, + detailedMetrics: DETAILED_METRICS + }) + + const getExternalLinkUrl = useCallback( + (row: QueryResultRow) => url.externalLinkForPage(site, row.dimensions[0]), + [site] + ) + + return ( + + + + ) +} diff --git a/assets/js/dashboard/stats/reports/change-arrow.tsx b/assets/js/dashboard/stats/reports/change-arrow.tsx index 3156ca670940..ea63d234d466 100644 --- a/assets/js/dashboard/stats/reports/change-arrow.tsx +++ b/assets/js/dashboard/stats/reports/change-arrow.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Metric } from '../../../types/query-api' +import { Metric } from '../metrics' import { numberShortFormatter } from '../../util/number-formatter' import { ArrowDownRightIcon, ArrowUpRightIcon } from '@heroicons/react/24/solid' import classNames from 'classnames' diff --git a/assets/js/dashboard/stats/reports/index-breakdown.tsx b/assets/js/dashboard/stats/reports/index-breakdown.tsx new file mode 100644 index 000000000000..2e1b61bd19d9 --- /dev/null +++ b/assets/js/dashboard/stats/reports/index-breakdown.tsx @@ -0,0 +1,544 @@ +import React, { useCallback, useMemo, useState } from 'react' +import FlipMove from 'react-flip-move' +import FadeIn from '../../fade-in' +import LazyLoader from '../../components/lazy-loader' +import { trimURL } from '../../util/url' +import { useDashboardStateContext } from '../../dashboard-state-context' +import { useSiteContext } from '../../site-context' +import { usePaginatedQueryAPI } from '../../hooks/api-client' +import { createStatsQuery } from '../../stats-query' +import type { StatsQuery } from '../../stats-query' +import { Metric, getBreakdownMetricLabel } from '../metrics' +import { + ColumnConfiguration, + ExternalLinkIcon, + MetricValueTooltipContent, + SharedBreakdownReportProps, + formatDateRangeLabel, + useBodyPortalRef, + extractMetricValue +} from '../breakdowns' +import { DrilldownLink, FilterInfo } from '../../components/drilldown-link' +import * as api from '../../api' +import type { QueryResultRow, QueryResultQuery } from '../../api' +import classNames from 'classnames' +import { Tooltip } from '../../util/tooltip' +import { ChangeArrow } from './change-arrow' +import { MetricFormatterShort, MetricFormatterLong } from './metric-formatter' +import { + hasConversionGoalFilter, + isRealTimeDashboard +} from '../../util/filters' + +const MAX_ITEMS = 9 +export const MIN_HEIGHT = 356 +const ROW_HEIGHT = 32 +const ROW_GAP_HEIGHT = 4 +const DATA_CONTAINER_HEIGHT = + (ROW_HEIGHT + ROW_GAP_HEIGHT) * (MAX_ITEMS - 1) + ROW_HEIGHT + +const MAX_DIMENSION_LENGTH = 70 + +const DEFAULT_METRIC_COLUMN_WIDTH = 'w-16 min-w-16' +const VISITORS_WITH_PERCENTAGE_COLUMN_WIDTH = 'w-32 min-w-32' + +const BAR_METRIC = 'visitors' + +export function IndexBreakdown({ + metrics, + dimensions, + color, + getFilterInfo, + getExternalLinkUrl, + dimensionLabel, + afterFetchData, + metricColumnWidth = DEFAULT_METRIC_COLUMN_WIDTH +}: SharedBreakdownReportProps & { color: string; metricColumnWidth?: string }) { + const site = useSiteContext() + const { dashboardState } = useDashboardStateContext() + const [visible, setVisible] = useState(false) + const [query, setQuery] = useState(null) + + const statsQuery: StatsQuery = useMemo( + () => createStatsQuery(dashboardState, { metrics: metrics, dimensions }), + [dashboardState, metrics, dimensions] + ) + + const handleAfterFetchData = useCallback( + (response: api.QueryApiResponse) => { + setQuery(response.query) + afterFetchData?.(response) + }, + [afterFetchData] + ) + + const apiState = usePaginatedQueryAPI({ + site, + statsQuery, + afterFetchData: handleAfterFetchData, + pageSize: MAX_ITEMS, + enabled: visible + }) + + const barMetricIndex = query + ? query.metrics.findIndex((m) => m === BAR_METRIC) + : null + + const barMaxValue = useMemo(() => { + const rows = apiState.data?.pages?.[0] ?? [] + return barMetricIndex === null + ? null + : Math.max(...rows.map((r) => r.metrics[barMetricIndex] as number)) + }, [apiState.data, barMetricIndex]) + + const metricLabelFor = useCallback( + (metric: Metric): string => { + return getBreakdownMetricLabel(metric, { + hasConversionGoalFilter: hasConversionGoalFilter(dashboardState), + isRealtime: isRealTimeDashboard(dashboardState), + dimensions: dimensions + }) + }, + [dashboardState, dimensions] + ) + + const columns = useMemo((): ColumnConfiguration[] | null => { + if (!query || barMetricIndex === null || barMaxValue === null) return null + + // Only render columns for metrics the API actually returned. Also, + // percentage is not its own column —- it's shown inline in the + // visitors cell instead. + const filteredMetrics = query.metrics.filter((m) => m !== 'percentage') + + const hasPercentage = query.metrics.includes('percentage') + const isVisitorsWithPercentageCell = (m: Metric) => + hasPercentage && m === 'visitors' + + return [ + { + key: 'dimension', + renderLabel: () => dimensionLabel, + renderCell: (row, isActive) => ( + + ), + align: 'left' + }, + ...filteredMetrics.map( + (metric): ColumnConfiguration => ({ + key: metric, + renderLabel: () => metricLabelFor(metric), + renderCell: (row, isActive) => { + if (isVisitorsWithPercentageCell(metric)) { + return ( + + ) + } else { + return ( + + ) + } + }, + width: isVisitorsWithPercentageCell(metric) + ? VISITORS_WITH_PERCENTAGE_COLUMN_WIDTH + : metricColumnWidth, + align: 'right' + }) + ) + ] + }, [ + dimensionLabel, + color, + barMetricIndex, + metricLabelFor, + barMaxValue, + query, + getFilterInfo, + getExternalLinkUrl, + metricColumnWidth + ]) + + return ( + setVisible(true)}> + + + ) +} + +function DimensionCell({ + row, + color, + barWidthPercent, + getFilterInfo, + getExternalLinkUrl, + isActive +}: { + row: QueryResultRow + color: string + barWidthPercent: number + getFilterInfo: (row: QueryResultRow) => FilterInfo | null + getExternalLinkUrl?: (row: QueryResultRow) => string | null + isActive?: boolean +}) { + const externalUrl = getExternalLinkUrl?.(row) + return ( +
+
+
+ + + {trimURL(row.dimensions[0], MAX_DIMENSION_LENGTH)} + + + {externalUrl && } +
+
+ ) +} + +function VisitorsWithPercentageCell({ + row, + query, + isActive +}: { + row: QueryResultRow + query: QueryResultQuery + isActive?: boolean +}) { + const portalRef = useBodyPortalRef() + + const { value: visitorsValue, comparison: visitorsComparison } = + extractMetricValue(row, query, 'visitors') + const { value: percentageValue, comparison: percentageComparison } = + extractMetricValue(row, query, 'percentage') + + const visitorsShortFormatter = MetricFormatterShort['visitors'] + const visitorsLongFormatter = MetricFormatterLong['visitors'] + const percentageFormatter = MetricFormatterShort['percentage'] + + const isVisitorsAbbreviated = + visitorsValue !== null && + visitorsShortFormatter(visitorsValue) !== + visitorsLongFormatter(visitorsValue) + + const showVisitorsTooltip = !!visitorsComparison || isVisitorsAbbreviated + const showPercentageTooltip = !!percentageComparison + + const dateRangeLabel = formatDateRangeLabel(query.date_range) + const comparisonDateRangeLabel = query.comparison_date_range + ? formatDateRangeLabel(query.comparison_date_range) + : null + + const percentageCell = ( + + {percentageFormatter(percentageValue)} + {percentageComparison && ( + + )} + + ) + + const percentageWithTooltip = showPercentageTooltip ? ( + } + info={ + + } + > + {percentageCell} + + ) : ( + percentageCell + ) + + const visitorsCell = ( + + {visitorsShortFormatter(visitorsValue)} + {visitorsComparison && ( + + )} + + ) + + const visitorsWithTooltip = showVisitorsTooltip ? ( + } + info={ + + } + > + {visitorsCell} + + ) : ( + visitorsCell + ) + + return ( +
+
{visitorsWithTooltip}
+
{percentageWithTooltip}
+
+ ) +} + +function MetricValueCell({ + row, + metric, + metricLabel, + query +}: { + row: QueryResultRow + metric: Metric + metricLabel: string + query: QueryResultQuery +}) { + const portalRef = useBodyPortalRef() + + const { value, comparison } = extractMetricValue(row, query, metric) + + const shortFormatter = MetricFormatterShort[metric] + const longFormatter = MetricFormatterLong[metric] + + const isAbbreviated = + value !== null && shortFormatter(value) !== longFormatter(value) + const showTooltip = !!comparison || isAbbreviated + + const valueContent = ( + + {shortFormatter(value)} + {comparison && ( + + )} + + ) + + if (!showTooltip) return valueContent + + const dateRangeLabel = formatDateRangeLabel(query.date_range) + const comparisonDateRangeLabel = query.comparison_date_range + ? formatDateRangeLabel(query.comparison_date_range) + : null + + return ( + } + info={ + + } + > + {valueContent} + + ) +} + +export function IndexBreakdownRenderer({ + apiState, + columns +}: { + apiState: ReturnType + columns: ColumnConfiguration[] | null +}) { + const [tappedRow, setTappedRow] = useState(null) + + if (!columns) return null + const rows = apiState.data?.pages?.[0]?.slice(0, MAX_ITEMS) ?? [] + + if (apiState.isPending) { + return ( +
+
+
+
+
+ ) + } + + if (rows.length === 0) { + return ( +
+
+ No data yet +
+
+ ) + } + + return ( + +
+
+ {columns.map((col) => ( +
+ {col.renderLabel()} +
+ ))} +
+
+ + {rows.map((row) => { + const dimension = row.dimensions[0] + const isActive = tappedRow === dimension + + const handleClick = (e: React.MouseEvent) => { + if ( + window.innerWidth < 768 && + !(e.target as HTMLElement).closest('a') + ) { + setTappedRow(isActive ? null : dimension) + } + } + + return ( +
+
+ {columns.map((col) => ( +
+ {col.renderCell(row, isActive)} +
+ ))} +
+
+ ) + })} +
+
+
+
+ ) +} + +function ExternalLink({ + href, + isActive +}: { + href: string + isActive?: boolean +}) { + return ( + + + + ) +} diff --git a/assets/js/dashboard/stats/reports/list.tsx b/assets/js/dashboard/stats/reports/list-legacy.tsx similarity index 100% rename from assets/js/dashboard/stats/reports/list.tsx rename to assets/js/dashboard/stats/reports/list-legacy.tsx diff --git a/assets/js/dashboard/stats/reports/metric-formatter.ts b/assets/js/dashboard/stats/reports/metric-formatter.ts index 6367d2b39649..604f1cf3274e 100644 --- a/assets/js/dashboard/stats/reports/metric-formatter.ts +++ b/assets/js/dashboard/stats/reports/metric-formatter.ts @@ -1,4 +1,4 @@ -import { Metric } from '../../../types/query-api' +import { Metric } from '../metrics' import { formatMoneyShort, formatMoneyLong } from '../../util/money' import { numberShortFormatter, @@ -8,12 +8,7 @@ import { nullable } from '../../util/number-formatter' -export type FormattableMetric = - | Metric - | 'total_visitors' - | 'current_visitors' - | 'exit_rate' - | 'conversions' +export type FormattableMetric = Metric | 'current_visitors' | 'conversions' // eslint-disable-next-line @typescript-eslint/no-explicit-any export type ValueType = any diff --git a/assets/js/dashboard/stats/sources/index.js b/assets/js/dashboard/stats/sources/index.js index 48b857425540..8a63182bfeb2 100644 --- a/assets/js/dashboard/stats/sources/index.js +++ b/assets/js/dashboard/stats/sources/index.js @@ -4,7 +4,7 @@ import * as storage from '../../util/storage' import * as url from '../../util/url' import * as api from '../../api' import usePrevious from '../../hooks/use-previous' -import ListReport from '../reports/list' +import ListReport from '../reports/list-legacy' import * as metrics from '../reports/metrics' import { getFiltersByKeyPrefix, diff --git a/assets/js/dashboard/stats/sources/referrer-list.js b/assets/js/dashboard/stats/sources/referrer-list.js index f6b6c76280c9..99b8a5a48f9f 100644 --- a/assets/js/dashboard/stats/sources/referrer-list.js +++ b/assets/js/dashboard/stats/sources/referrer-list.js @@ -3,7 +3,7 @@ import * as api from '../../api' import * as url from '../../util/url' import * as metrics from '../reports/metrics' import { hasConversionGoalFilter } from '../../util/filters' -import ListReport from '../reports/list' +import ListReport from '../reports/list-legacy' import { useDashboardStateContext } from '../../dashboard-state-context' import { useSiteContext } from '../../site-context' import { SourceFavicon } from './source-favicon' diff --git a/assets/js/types/globals.d.ts b/assets/js/types/globals.d.ts new file mode 100644 index 000000000000..95a940397658 --- /dev/null +++ b/assets/js/types/globals.d.ts @@ -0,0 +1 @@ +declare const BUILD_EXTRA: boolean diff --git a/lib/plausible/stats/api_query_parser.ex b/lib/plausible/stats/api_query_parser.ex index 53af31245ce8..35837f1eba5e 100644 --- a/lib/plausible/stats/api_query_parser.ex +++ b/lib/plausible/stats/api_query_parser.ex @@ -325,11 +325,11 @@ defmodule Plausible.Stats.ApiQueryParser do defp parse_include_entry(key, _value), do: {:error, %QueryError{code: :invalid_include, message: "Invalid include key'#{i(key)}'."}} - defp parse_pagination(pagination) when is_map(pagination) do + def parse_pagination(pagination) when is_map(pagination) do {:ok, Map.merge(@default_pagination, atomize_keys(pagination))} end - defp parse_pagination(nil), do: {:ok, @default_pagination} + def parse_pagination(nil), do: {:ok, @default_pagination} defp atomize_keys(map) when is_map(map) do Map.new(map, fn {key, value} -> diff --git a/lib/plausible/stats/dashboard/query_parser.ex b/lib/plausible/stats/dashboard/query_parser.ex index d15f4f8de055..ebf4ca4591ba 100644 --- a/lib/plausible/stats/dashboard/query_parser.ex +++ b/lib/plausible/stats/dashboard/query_parser.ex @@ -20,6 +20,8 @@ defmodule Plausible.Stats.Dashboard.QueryParser do {:ok, dimensions} <- ApiQueryParser.parse_dimensions(params["dimensions"]), {:ok, filters} <- ApiQueryParser.parse_filters(params["filters"]), {:ok, metrics} <- parse_metrics(params), + {:ok, order_by} <- ApiQueryParser.parse_order_by(params["order_by"]), + {:ok, pagination} <- ApiQueryParser.parse_pagination(params["pagination"]), {:ok, include} <- parse_include(params) do {:ok, ParsedQueryParams.new!(%{ @@ -28,6 +30,8 @@ defmodule Plausible.Stats.Dashboard.QueryParser do dimensions: dimensions, filters: filters, metrics: metrics, + order_by: order_by, + pagination: pagination, include: include, skip_goal_existence_check: true, now: Keyword.get(opts, :now) diff --git a/lib/plausible/stats/metrics.ex b/lib/plausible/stats/metrics.ex index fc603b706e21..191b3a363421 100644 --- a/lib/plausible/stats/metrics.ex +++ b/lib/plausible/stats/metrics.ex @@ -19,6 +19,7 @@ defmodule Plausible.Stats.Metrics do :visit_duration, :events, :conversion_rate, + :total_visitors, :group_conversion_rate, :time_on_page, :percentage, diff --git a/test/plausible/stats/query/query_parse_and_build_test.exs b/test/plausible/stats/query/query_parse_and_build_test.exs index 33add7a73963..0ba5271e7c73 100644 --- a/test/plausible/stats/query/query_parse_and_build_test.exs +++ b/test/plausible/stats/query/query_parse_and_build_test.exs @@ -92,6 +92,19 @@ defmodule Plausible.Stats.Query.QueryParseAndBuildTest do assert error =~ "Invalid metric" end + test "public API does not recognize total_visitors metric", %{site: site} do + params = %{ + "site_id" => site.domain, + "metrics" => ["total_visitors"], + "date_range" => "all" + } + + assert {:error, %QueryError{message: error}} = + Query.parse_and_build(site, params, now: @now) + + assert error =~ "Invalid metric" + end + test "valid metrics passed", %{site: site} do params = %{ "site_id" => site.domain, diff --git a/test/plausible_web/controllers/api/stats_controller/pages_test.exs b/test/plausible_web/controllers/api/stats_controller/pages_test.exs index 5220a1b93915..7120a2f80a41 100644 --- a/test/plausible_web/controllers/api/stats_controller/pages_test.exs +++ b/test/plausible_web/controllers/api/stats_controller/pages_test.exs @@ -3,6 +3,34 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do @user_id Enum.random(1000..9999) + @default_metrics ["visitors", "percentage"] + @detailed_metrics [ + "visitors", + "pageviews", + "bounce_rate", + "time_on_page", + "scroll_depth", + "percentage" + ] + @goal_filter_metrics ["visitors", "group_conversion_rate", "total_visitors"] + + defp query_pages(conn, site, opts) do + params = %{ + "dimensions" => Keyword.get(opts, :dimensions, ["event:page"]), + "date_range" => Keyword.get(opts, :date_range, "all"), + "relative_date" => Keyword.get(opts, :relative_date, nil), + "filters" => Keyword.get(opts, :filters, []), + "metrics" => Keyword.get(opts, :metrics, @default_metrics), + "include" => Keyword.get(opts, :include, nil), + "pagination" => Keyword.get(opts, :pagination, nil), + "order_by" => Keyword.get(opts, :order_by, nil) + } + + conn + |> post("/api/stats/#{site.domain}/query", params) + |> json_response(200) + end + describe "GET /api/stats/:domain/pages" do setup [ :create_user, @@ -21,16 +49,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do build(:pageview, pathname: "/contact") ]) - conn = get(conn, "/api/stats/#{site.domain}/pages?period=day") + response = query_pages(conn, site, date_range: "day") - assert json_response(conn, 200)["results"] == [ - %{"visitors" => 3, "name" => "/", "percentage" => 50.0}, - %{"visitors" => 2, "name" => "/register", "percentage" => 33.33}, - %{"visitors" => 1, "name" => "/contact", "percentage" => 16.67} + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [3, 50.0]}, + %{"dimensions" => ["/register"], "metrics" => [2, 33.33]}, + %{"dimensions" => ["/contact"], "metrics" => [1, 16.67]} ] end - test "returns top pages by visitors by hostname", %{conn: conn1, site: site} do + test "returns top pages by visitors by hostname", %{conn: conn, site: site} do populate_stats(site, [ build(:pageview, pathname: "/", hostname: "a.example.com"), build(:pageview, pathname: "/", hostname: "b.example.com"), @@ -42,22 +70,29 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do build(:pageview, pathname: "/contact", hostname: "e.example.com") ]) - filters = Jason.encode!([[:contains, "event:hostname", [".example.com"]]]) - conn = get(conn1, "/api/stats/#{site.domain}/pages?period=day&filters=#{filters}") + response = + query_pages(conn, site, + date_range: "day", + filters: [["contains", "event:hostname", [".example.com"]]], + order_by: [["visitors", "desc"], ["event:page", "asc"]] + ) - assert json_response(conn, 200)["results"] == [ - %{"visitors" => 3, "name" => "/", "percentage" => 50.0}, - %{"visitors" => 2, "name" => "/register", "percentage" => 33.33}, - %{"visitors" => 1, "name" => "/contact", "percentage" => 16.67}, - %{"visitors" => 1, "name" => "/landing", "percentage" => 16.67} + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [3, 50.0]}, + %{"dimensions" => ["/register"], "metrics" => [2, 33.33]}, + %{"dimensions" => ["/contact"], "metrics" => [1, 16.67]}, + %{"dimensions" => ["/landing"], "metrics" => [1, 16.67]} ] - filters = Jason.encode!([[:is, "event:hostname", ["d.example.com"]]]) - conn = get(conn1, "/api/stats/#{site.domain}/pages?period=day&filters=#{filters}") + response = + query_pages(conn, site, + date_range: "day", + filters: [["is", "event:hostname", ["d.example.com"]]] + ) - assert json_response(conn, 200)["results"] == [ - %{"visitors" => 2, "name" => "/register", "percentage" => 66.67}, - %{"visitors" => 1, "name" => "/", "percentage" => 33.33} + assert response["results"] == [ + %{"dimensions" => ["/register"], "metrics" => [2, 66.67]}, + %{"dimensions" => ["/"], "metrics" => [1, 33.33]} ] end @@ -76,11 +111,14 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do build(:pageview, user_id: 123, pathname: "/") ]) - filters = Jason.encode!([[:is, "event:props:author", ["John Doe"]]]) - conn = get(conn, "/api/stats/#{site.domain}/pages?period=day&filters=#{filters}") + response = + query_pages(conn, site, + date_range: "day", + filters: [["is", "event:props:author", ["John Doe"]]] + ) - assert json_response(conn, 200)["results"] == [ - %{"visitors" => 1, "name" => "/blog/john-1", "percentage" => 100.0} + assert response["results"] == [ + %{"dimensions" => ["/blog/john-1"], "metrics" => [1, 100.0]} ] end @@ -102,12 +140,15 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do build(:pageview, pathname: "/") ]) - filters = Jason.encode!([[:is_not, "event:props:author", ["John Doe"]]]) - conn = get(conn, "/api/stats/#{site.domain}/pages?period=day&filters=#{filters}") + response = + query_pages(conn, site, + date_range: "day", + filters: [["is_not", "event:props:author", ["John Doe"]]] + ) - assert json_response(conn, 200)["results"] == [ - %{"visitors" => 1, "name" => "/", "percentage" => 50.0}, - %{"visitors" => 1, "name" => "/blog/other-post", "percentage" => 50.0} + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [1, 50.0]}, + %{"dimensions" => ["/blog/other-post"], "metrics" => [1, 50.0]} ] end @@ -139,12 +180,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do build(:pageview, pathname: "/5") ]) - filters = Jason.encode!([[:contains, "event:props:prop", ["bar"]]]) - conn = get(conn, "/api/stats/#{site.domain}/pages?period=day&filters=#{filters}") + response = + query_pages(conn, site, + date_range: "day", + filters: [["contains", "event:props:prop", ["bar"]]], + order_by: [["visitors", "desc"], ["event:page", "asc"]] + ) - assert json_response(conn, 200)["results"] == [ - %{"visitors" => 1, "name" => "/1", "percentage" => 50.0}, - %{"visitors" => 1, "name" => "/2", "percentage" => 50.0} + assert response["results"] == [ + %{"dimensions" => ["/1"], "metrics" => [1, 50.0]}, + %{"dimensions" => ["/2"], "metrics" => [1, 50.0]} ] end @@ -181,13 +226,17 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - filters = Jason.encode!([[:contains, "event:props:prop", ["bar", "nea"]]]) - conn = get(conn, "/api/stats/#{site.domain}/pages?period=day&filters=#{filters}") + response = + query_pages(conn, site, + date_range: "day", + filters: [["contains", "event:props:prop", ["bar", "nea"]]], + order_by: [["visitors", "desc"], ["event:page", "asc"]] + ) - assert json_response(conn, 200)["results"] == [ - %{"visitors" => 1, "name" => "/1", "percentage" => 33.33}, - %{"visitors" => 1, "name" => "/2", "percentage" => 33.33}, - %{"visitors" => 1, "name" => "/6", "percentage" => 33.33} + assert response["results"] == [ + %{"dimensions" => ["/1"], "metrics" => [1, 33.33]}, + %{"dimensions" => ["/2"], "metrics" => [1, 33.33]}, + %{"dimensions" => ["/6"], "metrics" => [1, 33.33]} ] end @@ -219,16 +268,17 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do build(:pageview, pathname: "/5") ]) - filters = - Jason.encode!([ - [:is, "event:props:prop", ["bar"]], - [:is, "event:props:number", ["1"]] - ]) - - conn = get(conn, "/api/stats/#{site.domain}/pages?period=day&filters=#{filters}") + response = + query_pages(conn, site, + date_range: "day", + filters: [ + ["is", "event:props:prop", ["bar"]], + ["is", "event:props:number", ["1"]] + ] + ) - assert json_response(conn, 200)["results"] == [ - %{"visitors" => 1, "name" => "/1", "percentage" => 100.0} + assert response["results"] == [ + %{"dimensions" => ["/1"], "metrics" => [1, 100.0]} ] end @@ -304,33 +354,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - filters = Jason.encode!([[:is, "event:props:author", ["John Doe"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&filters=#{filters}&detailed=true" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is", "event:props:author", ["John Doe"]]], + metrics: @detailed_metrics ) - assert json_response(conn, 200)["results"] == [ - %{ - "name" => "/blog/john-2", - "visitors" => 2, - "pageviews" => 2, - "bounce_rate" => 0, - "time_on_page" => 315, - "scroll_depth" => 0, - "percentage" => 100.0 - }, - %{ - "name" => "/blog/john-1", - "visitors" => 1, - "pageviews" => 1, - "bounce_rate" => 0, - "time_on_page" => 60, - "scroll_depth" => 0, - "percentage" => 50.0 - } + assert response["results"] == [ + %{"dimensions" => ["/blog/john-2"], "metrics" => [2, 2, 0, 315, 0, 100.0]}, + %{"dimensions" => ["/blog/john-1"], "metrics" => [1, 1, 0, 60, 0, 50.0]} ] end @@ -406,33 +439,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - filters = Jason.encode!([[:is_not, "event:props:author", ["John Doe"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&filters=#{filters}&detailed=true" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is_not", "event:props:author", ["John Doe"]]], + metrics: @detailed_metrics ) - assert json_response(conn, 200)["results"] == [ - %{ - "name" => "/blog", - "visitors" => 2, - "pageviews" => 2, - "bounce_rate" => 0, - "time_on_page" => 120, - "scroll_depth" => 0, - "percentage" => 100.0 - }, - %{ - "name" => "/blog/other-post", - "visitors" => 1, - "pageviews" => 1, - "bounce_rate" => 0, - "time_on_page" => 30, - "scroll_depth" => 0, - "percentage" => 50.0 - } + assert response["results"] == [ + %{"dimensions" => ["/blog"], "metrics" => [2, 2, 0, 120, 0, 100.0]}, + %{"dimensions" => ["/blog/other-post"], "metrics" => [1, 1, 0, 30, 0, 50.0]} ] end @@ -489,33 +505,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - filters = Jason.encode!([[:is, "event:props:author", ["(none)"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&filters=#{filters}&detailed=true" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is", "event:props:author", ["(none)"]]], + metrics: @detailed_metrics ) - assert json_response(conn, 200)["results"] == [ - %{ - "name" => "/blog", - "visitors" => 2, - "pageviews" => 2, - "bounce_rate" => 50, - "time_on_page" => 45, - "scroll_depth" => 0, - "percentage" => 100.0 - }, - %{ - "name" => "/blog/other-post", - "visitors" => 1, - "pageviews" => 1, - "bounce_rate" => 0, - "time_on_page" => 30, - "scroll_depth" => 0, - "percentage" => 50.0 - } + assert response["results"] == [ + %{"dimensions" => ["/blog"], "metrics" => [2, 2, 50, 45, 0, 100.0]}, + %{"dimensions" => ["/blog/other-post"], "metrics" => [1, 1, 0, 30, 0, 50.0]} ] end @@ -580,33 +579,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - filters = Jason.encode!([[:is_not, "event:props:author", ["(none)"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&filters=#{filters}&detailed=true" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is_not", "event:props:author", ["(none)"]]], + metrics: @detailed_metrics ) - assert json_response(conn, 200)["results"] == [ - %{ - "name" => "/blog/other-post", - "visitors" => 2, - "pageviews" => 2, - "bounce_rate" => 100, - "time_on_page" => 30, - "scroll_depth" => 0, - "percentage" => 100.0 - }, - %{ - "name" => "/blog/john-1", - "visitors" => 1, - "pageviews" => 1, - "bounce_rate" => 0, - "time_on_page" => 60, - "scroll_depth" => 0, - "percentage" => 50.0 - } + assert response["results"] == [ + %{"dimensions" => ["/blog/other-post"], "metrics" => [2, 2, 100, 30, 0, 100.0]}, + %{"dimensions" => ["/blog/john-1"], "metrics" => [1, 1, 0, 60, 0, 50.0]} ] end @@ -645,17 +627,14 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - filters = Jason.encode!([[:is_not, "event:props:browser", ["Chrome", "Safari"]]]) - - conn = - get(conn, "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&filters=#{filters}") + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is_not", "event:props:browser", ["Chrome", "Safari"]]] + ) - assert json_response(conn, 200)["results"] == [ - %{ - "name" => "/firefox", - "visitors" => 2, - "percentage" => 100.0 - } + assert response["results"] == [ + %{"dimensions" => ["/firefox"], "metrics" => [2, 100.0]} ] assert json_response(conn, 200)["meta"] == %{"date_range_label" => "1 Jan 2021"} @@ -688,17 +667,14 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - filters = Jason.encode!([[:is_not, "event:props:browser", ["Chrome", "(none)"]]]) - - conn = - get(conn, "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&filters=#{filters}") + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is_not", "event:props:browser", ["Chrome", "(none)"]]] + ) - assert json_response(conn, 200)["results"] == [ - %{ - "name" => "/safari", - "visitors" => 1, - "percentage" => 100.0 - } + assert response["results"] == [ + %{"dimensions" => ["/safari"], "metrics" => [1, 100.0]} ] end @@ -755,24 +731,15 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - filters = Jason.encode!([[:is, "event:page", ["/"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&filters=#{filters}&detailed=true" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is", "event:page", ["/"]]], + metrics: @detailed_metrics ) - assert json_response(conn, 200)["results"] == [ - %{ - "name" => "/", - "visitors" => 2, - "pageviews" => 3, - "bounce_rate" => 50, - "time_on_page" => 90, - "scroll_depth" => 0, - "percentage" => 100.0 - } + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [2, 3, 50, 90, 0, 100.0]} ] end @@ -831,31 +798,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/pages?period=day&date=2020-01-01&detailed=true&order_by=#{Jason.encode!([["scroll_depth", "asc"]])}" + response = + query_pages(conn, site, + date_range: ["2020-01-01", "2020-01-01"], + metrics: @detailed_metrics, + order_by: [["scroll_depth", "asc"]] ) - assert json_response(conn, 200)["results"] == [ - %{ - "name" => "/another", - "visitors" => 2, - "pageviews" => 2, - "bounce_rate" => 0, - "time_on_page" => 60, - "scroll_depth" => 25, - "percentage" => 66.67 - }, - %{ - "name" => "/blog", - "visitors" => 3, - "pageviews" => 4, - "bounce_rate" => 33, - "time_on_page" => 80, - "scroll_depth" => 60, - "percentage" => 100.0 - } + assert response["results"] == [ + %{"dimensions" => ["/another"], "metrics" => [2, 2, 0, 60, 25, 66.67]}, + %{"dimensions" => ["/blog"], "metrics" => [3, 4, 33, 80, 60, 100.0]} ] end @@ -895,22 +847,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do build(:imported_visitors, date: ~D[2020-01-01]) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/pages?period=day&date=2020-01-01&detailed=true&with_imported=true&order_by=#{Jason.encode!([["scroll_depth", "desc"]])}" + response = + query_pages(conn, site, + date_range: ["2020-01-01", "2020-01-01"], + metrics: @detailed_metrics, + include: %{"imports" => true}, + order_by: [["scroll_depth", "desc"]] ) - assert json_response(conn, 200)["results"] == [ - %{ - "name" => "/blog", - "visitors" => 4, - "pageviews" => 4, - "bounce_rate" => 100, - "time_on_page" => 28, - "scroll_depth" => 50, - "percentage" => 100.0 - } + assert response["results"] == [ + %{"dimensions" => ["/blog"], "metrics" => [4, 4, 100, 28, 50, 100.0]} ] end @@ -972,40 +918,18 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do for(_ <- 1..24, do: build(:imported_visitors, date: ~D[2020-01-01])) ) - conn = - get( - conn, - "/api/stats/#{site.domain}/pages?period=day&date=2020-01-01&detailed=true&with_imported=true&order_by=#{Jason.encode!([["scroll_depth", "desc"]])}" + response = + query_pages(conn, site, + date_range: ["2020-01-01", "2020-01-01"], + metrics: @detailed_metrics, + include: %{"imports" => true}, + order_by: [["scroll_depth", "desc"]] ) - assert json_response(conn, 200)["results"] == [ - %{ - "name" => "/native-and-imported", - "visitors" => 5, - "pageviews" => 5, - "bounce_rate" => 0, - "time_on_page" => 48, - "scroll_depth" => 50, - "percentage" => 20.0 - }, - %{ - "name" => "/native-only", - "visitors" => 1, - "pageviews" => 1, - "bounce_rate" => 0, - "time_on_page" => 60, - "scroll_depth" => 40, - "percentage" => 4.0 - }, - %{ - "name" => "/imported-only", - "visitors" => 20, - "pageviews" => 30, - "bounce_rate" => 0, - "time_on_page" => 30, - "scroll_depth" => 10, - "percentage" => 80.0 - } + assert response["results"] == [ + %{"dimensions" => ["/native-and-imported"], "metrics" => [5, 5, 0, 48, 50, 20.0]}, + %{"dimensions" => ["/native-only"], "metrics" => [1, 1, 0, 60, 40, 4.0]}, + %{"dimensions" => ["/imported-only"], "metrics" => [20, 30, 0, 30, 10, 80.0]} ] end @@ -1037,22 +961,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/pages?period=7d&date=2020-01-02&detailed=true&with_imported=true" + response = + query_pages(conn, site, + date_range: "7d", + relative_date: "2020-01-02", + metrics: @detailed_metrics, + include: %{"imports" => true} ) - assert json_response(conn, 200)["results"] == [ - %{ - "name" => "/blog", - "visitors" => 110, - "pageviews" => 160, - "bounce_rate" => 0, - "time_on_page" => 60, - "scroll_depth" => 10, - "percentage" => nil - } + assert response["results"] == [ + %{"dimensions" => ["/blog"], "metrics" => [110, 160, 0, 60, 10, nil]} ] end @@ -1116,33 +1034,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - filters = Jason.encode!([[:is, "event:page", ["/", "/about"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&filters=#{filters}&detailed=true" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is", "event:page", ["/", "/about"]]], + metrics: @detailed_metrics ) - assert json_response(conn, 200)["results"] == [ - %{ - "name" => "/", - "visitors" => 2, - "pageviews" => 3, - "bounce_rate" => 50, - "time_on_page" => 75, - "scroll_depth" => 0, - "percentage" => 66.67 - }, - %{ - "name" => "/about", - "visitors" => 1, - "pageviews" => 1, - "bounce_rate" => 100, - "time_on_page" => 30, - "scroll_depth" => 0, - "percentage" => 33.33 - } + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [2, 3, 50, 75, 0, 66.67]}, + %{"dimensions" => ["/about"], "metrics" => [1, 1, 100, 30, 0, 33.33]} ] end @@ -1206,24 +1107,15 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - filters = Jason.encode!([[:is_not, "event:page", ["/irrelevant", "/about"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&filters=#{filters}&detailed=true" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is_not", "event:page", ["/irrelevant", "/about"]]], + metrics: @detailed_metrics ) - assert json_response(conn, 200)["results"] == [ - %{ - "name" => "/", - "visitors" => 2, - "pageviews" => 3, - "bounce_rate" => 50, - "time_on_page" => 75, - "scroll_depth" => 0, - "percentage" => 100.0 - } + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [2, 3, 50, 75, 0, 100.0]} ] end @@ -1287,42 +1179,18 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - filters = Jason.encode!([[:contains, "event:page", ["/blog/", "/articles/"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&filters=#{filters}&detailed=true" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["contains", "event:page", ["/blog/", "/articles/"]]], + metrics: @detailed_metrics, + order_by: [["visitors", "desc"], ["event:page", "asc"]] ) - assert json_response(conn, 200)["results"] == [ - %{ - "name" => "/articles/post-1", - "visitors" => 2, - "pageviews" => 2, - "bounce_rate" => 100, - "time_on_page" => 30, - "scroll_depth" => 0, - "percentage" => 66.67 - }, - %{ - "name" => "/blog/post-1", - "visitors" => 1, - "pageviews" => 1, - "bounce_rate" => 0, - "time_on_page" => 60, - "scroll_depth" => 0, - "percentage" => 33.33 - }, - %{ - "name" => "/blog/post-2", - "visitors" => 1, - "pageviews" => 1, - "bounce_rate" => 0, - "time_on_page" => 30, - "scroll_depth" => 0, - "percentage" => 33.33 - } + assert response["results"] == [ + %{"dimensions" => ["/articles/post-1"], "metrics" => [2, 2, 100, 30, 0, 66.67]}, + %{"dimensions" => ["/blog/post-1"], "metrics" => [1, 1, 0, 60, 0, 33.33]}, + %{"dimensions" => ["/blog/post-2"], "metrics" => [1, 1, 0, 30, 0, 33.33]} ] end @@ -1364,33 +1232,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - filters = Jason.encode!([[:contains, "event:page", ["/blog/(/", "/blog/)/"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&filters=#{filters}&detailed=true" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["contains", "event:page", ["/blog/(/", "/blog/)/"]]], + metrics: @detailed_metrics ) - assert json_response(conn, 200)["results"] == [ - %{ - "name" => "/blog/(/post-1", - "visitors" => 1, - "pageviews" => 1, - "bounce_rate" => 0, - "time_on_page" => 60, - "scroll_depth" => 0, - "percentage" => 100.0 - }, - %{ - "name" => "/blog/(/post-2", - "visitors" => 1, - "pageviews" => 1, - "bounce_rate" => 0, - "time_on_page" => 30, - "scroll_depth" => 0, - "percentage" => 100.0 - } + assert response["results"] == [ + %{"dimensions" => ["/blog/(/post-1"], "metrics" => [1, 1, 0, 60, 0, 100.0]}, + %{"dimensions" => ["/blog/(/post-2"], "metrics" => [1, 1, 0, 30, 0, 100.0]} ] end @@ -1454,33 +1305,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - filters = Jason.encode!([[:contains_not, "event:page", ["/blog/", "/articles/"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&filters=#{filters}&detailed=true" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["contains_not", "event:page", ["/blog/", "/articles/"]]], + metrics: @detailed_metrics ) - assert json_response(conn, 200)["results"] == [ - %{ - "name" => "/", - "visitors" => 2, - "pageviews" => 2, - "bounce_rate" => 50, - "time_on_page" => 600, - "scroll_depth" => 0, - "percentage" => 100.0 - }, - %{ - "name" => "/about", - "visitors" => 1, - "pageviews" => 1, - "bounce_rate" => 0, - "time_on_page" => 30, - "scroll_depth" => 0, - "percentage" => 50.0 - } + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [2, 2, 50, 600, 0, 100.0]}, + %{"dimensions" => ["/about"], "metrics" => [1, 1, 0, 30, 0, 50.0]} ] end @@ -1496,28 +1330,30 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do build(:pageview, pathname: "/contact") ]) - conn1 = get(conn, "/api/stats/#{site.domain}/pages?period=day") + response = query_pages(conn, site, date_range: "day") - assert json_response(conn1, 200)["results"] == [ - %{"visitors" => 3, "name" => "/", "percentage" => 50.0}, - %{"visitors" => 2, "name" => "/register", "percentage" => 33.33}, - %{"visitors" => 1, "name" => "/contact", "percentage" => 16.67} + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [3, 50.0]}, + %{"dimensions" => ["/register"], "metrics" => [2, 33.33]}, + %{"dimensions" => ["/contact"], "metrics" => [1, 16.67]} ] - conn2 = get(conn, "/api/stats/#{site.domain}/pages?period=day&with_imported=true") + response = query_pages(conn, site, date_range: "day", include: %{"imports" => true}) - assert json_response(conn2, 200)["results"] == [ - %{"visitors" => 4, "name" => "/", "percentage" => 66.67}, - %{"visitors" => 3, "name" => "/register", "percentage" => 50.0}, - %{"visitors" => 1, "name" => "/contact", "percentage" => 16.67} + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [4, 66.67]}, + %{"dimensions" => ["/register"], "metrics" => [3, 50.0]}, + %{"dimensions" => ["/contact"], "metrics" => [1, 16.67]} ] end test "returns scroll depth warning code", %{conn: conn, site: site} do - conn = - get(conn, "/api/stats/#{site.domain}/pages?period=day&detailed=true&with_imported=true") - - response = json_response(conn, 200) + response = + query_pages(conn, site, + date_range: "day", + metrics: @detailed_metrics, + include: %{"imports" => true} + ) assert response["meta"]["metric_warnings"]["scroll_depth"]["code"] == "no_imported_scroll_depth" @@ -1533,23 +1369,17 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do build(:imported_visitors, visitors: 4) ]) - filters = Jason.encode!([[:is, "event:goal", ["Visit /blog**"]]]) - q = "?period=day&filters=#{filters}&with_imported=true" - conn = get(conn, "/api/stats/#{site.domain}/pages#{q}") + response = + query_pages(conn, site, + date_range: "day", + filters: [["is", "event:goal", ["Visit /blog**"]]], + metrics: @goal_filter_metrics, + include: %{"imports" => true} + ) - assert json_response(conn, 200)["results"] == [ - %{ - "visitors" => 2, - "name" => "/blog/post-1", - "conversion_rate" => 100.0, - "total_visitors" => 2 - }, - %{ - "visitors" => 1, - "name" => "/blog", - "conversion_rate" => 100.0, - "total_visitors" => 1 - } + assert response["results"] == [ + %{"dimensions" => ["/blog/post-1"], "metrics" => [2, 100.0, 2]}, + %{"dimensions" => ["/blog"], "metrics" => [1, 100.0, 1]} ] end @@ -1590,31 +1420,15 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&detailed=true" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + metrics: @detailed_metrics ) - assert json_response(conn, 200)["results"] == [ - %{ - "bounce_rate" => 50.0, - "time_on_page" => 465.0, - "visitors" => 2, - "pageviews" => 2, - "name" => "/", - "scroll_depth" => 0, - "percentage" => 100.0 - }, - %{ - "bounce_rate" => 0, - "time_on_page" => 30, - "visitors" => 1, - "pageviews" => 1, - "name" => "/some-other-page", - "scroll_depth" => 0, - "percentage" => 50.0 - } + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [2, 2, 50.0, 465.0, 0, 100.0]}, + %{"dimensions" => ["/some-other-page"], "metrics" => [1, 1, 0, 30, 0, 50.0]} ] end @@ -1639,24 +1453,15 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - filters = Jason.encode!([[:is, "event:hostname", ["blog.example.com"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&detailed=true&filters=#{filters}" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is", "event:hostname", ["blog.example.com"]]], + metrics: @detailed_metrics ) - assert json_response(conn, 200)["results"] == [ - %{ - "bounce_rate" => 50, - "name" => "/about", - "pageviews" => 2, - "time_on_page" => nil, - "visitors" => 2, - "scroll_depth" => nil, - "percentage" => 100.0 - } + assert response["results"] == [ + %{"dimensions" => ["/about"], "metrics" => [2, 2, 50, nil, nil, 100.0]} ] end @@ -1776,33 +1581,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - filters = Jason.encode!([[:is, "event:hostname", ["blog.example.com"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&detailed=true&filters=#{filters}" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is", "event:hostname", ["blog.example.com"]]], + metrics: @detailed_metrics ) - assert json_response(conn, 200)["results"] == [ - %{ - "bounce_rate" => 50, - "name" => "/about-blog", - "pageviews" => 3, - "time_on_page" => 435, - "visitors" => 2, - "scroll_depth" => 0, - "percentage" => 100.0 - }, - %{ - "bounce_rate" => 0, - "name" => "/exit-blog", - "pageviews" => 1, - "time_on_page" => 120, - "visitors" => 1, - "scroll_depth" => 0, - "percentage" => 50.0 - } + assert response["results"] == [ + %{"dimensions" => ["/about-blog"], "metrics" => [2, 3, 50, 435, 0, 100.0]}, + %{"dimensions" => ["/exit-blog"], "metrics" => [1, 1, 0, 120, 0, 50.0]} ] end @@ -1871,31 +1659,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do build(:imported_visitors, date: ~D[2021-01-01]) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&detailed=true&with_imported=true" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + metrics: @detailed_metrics, + include: %{"imports" => true} ) - assert json_response(conn, 200)["results"] == [ - %{ - "bounce_rate" => 40.0, - "time_on_page" => 500, - "visitors" => 3, - "pageviews" => 3, - "scroll_depth" => 0, - "name" => "/", - "percentage" => 60.0 - }, - %{ - "bounce_rate" => 0, - "time_on_page" => 45, - "visitors" => 2, - "pageviews" => 2, - "scroll_depth" => 0, - "name" => "/some-other-page", - "percentage" => 40.0 - } + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [3, 3, 40.0, 500, 0, 60.0]}, + %{"dimensions" => ["/some-other-page"], "metrics" => [2, 2, 0, 45, 0, 40.0]} ] end @@ -1906,11 +1679,11 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do build(:pageview, pathname: "/page1") ]) - conn = get(conn, "/api/stats/#{site.domain}/pages?period=realtime") + response = query_pages(conn, site, date_range: "realtime") - assert json_response(conn, 200)["results"] == [ - %{"visitors" => 2, "name" => "/page1", "percentage" => 66.67}, - %{"visitors" => 1, "name" => "/page2", "percentage" => 33.33} + assert response["results"] == [ + %{"dimensions" => ["/page1"], "metrics" => [2, 66.67]}, + %{"dimensions" => ["/page2"], "metrics" => [1, 33.33]} ] end @@ -1923,17 +1696,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ]) insert(:goal, site: site, event_name: "Signup") - filters = Jason.encode!([[:is, "event:goal", ["Signup"]]]) - conn = get(conn, "/api/stats/#{site.domain}/pages?period=day&filters=#{filters}") + response = + query_pages(conn, site, + date_range: "day", + filters: [["is", "event:goal", ["Signup"]]], + metrics: @goal_filter_metrics + ) - assert json_response(conn, 200)["results"] == [ - %{ - "total_visitors" => 3, - "visitors" => 1, - "name" => "/", - "conversion_rate" => 33.33 - } + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [1, 33.33, 3]} ] end @@ -1972,21 +1744,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do build(:imported_pages, page: "/ignored", visitors: 10, date: ~D[2021-01-01]) ]) - filters = Jason.encode!([[:is, "event:page", ["/"]]]) - q = "?period=day&date=2021-01-01&filters=#{filters}&detailed=true&with_imported=true" - - conn = get(conn, "/api/stats/#{site.domain}/pages#{q}") + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is", "event:page", ["/"]]], + metrics: @detailed_metrics, + include: %{"imports" => true} + ) - assert json_response(conn, 200)["results"] == [ - %{ - "bounce_rate" => 50, - "name" => "/", - "pageviews" => 4, - "time_on_page" => 90.0, - "visitors" => 4, - "scroll_depth" => 0, - "percentage" => 100.0 - } + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [4, 4, 50, 90.0, 0, 100.0]} ] end @@ -2036,30 +1803,17 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do build(:imported_pages, page: "/ignored", visitors: 10, date: ~D[2021-01-01]) ]) - filters = Jason.encode!([[:is, "event:page", ["/", "/a"]]]) - q = "?period=day&date=2021-01-01&filters=#{filters}&detailed=true&with_imported=true" - - conn = get(conn, "/api/stats/#{site.domain}/pages#{q}") + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is", "event:page", ["/", "/a"]]], + metrics: @detailed_metrics, + include: %{"imports" => true} + ) - assert json_response(conn, 200)["results"] == [ - %{ - "bounce_rate" => 50, - "name" => "/", - "pageviews" => 4, - "time_on_page" => 90.0, - "visitors" => 4, - "scroll_depth" => 0, - "percentage" => 80.0 - }, - %{ - "bounce_rate" => 100, - "name" => "/a", - "pageviews" => 1, - "time_on_page" => 10.0, - "visitors" => 1, - "scroll_depth" => nil, - "percentage" => 20.0 - } + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [4, 4, 50, 90.0, 0, 80.0]}, + %{"dimensions" => ["/a"], "metrics" => [1, 1, 100, 10.0, nil, 20.0]} ] end @@ -2109,30 +1863,17 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do build(:imported_pages, page: "/ignored", visitors: 10, date: ~D[2021-01-01]) ]) - filters = Jason.encode!([[:contains, "event:page", ["/a"]]]) - q = "?period=day&date=2021-01-01&filters=#{filters}&detailed=true&with_imported=true" - - conn = get(conn, "/api/stats/#{site.domain}/pages#{q}") + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["contains", "event:page", ["/a"]]], + metrics: @detailed_metrics, + include: %{"imports" => true} + ) - assert json_response(conn, 200)["results"] == [ - %{ - "bounce_rate" => 50.0, - "name" => "/aaa", - "pageviews" => 4, - "time_on_page" => 90, - "visitors" => 4, - "scroll_depth" => 0, - "percentage" => 80.0 - }, - %{ - "bounce_rate" => 100.0, - "name" => "/a", - "pageviews" => 1, - "time_on_page" => 10, - "visitors" => 1, - "scroll_depth" => nil, - "percentage" => 20.0 - } + assert response["results"] == [ + %{"dimensions" => ["/aaa"], "metrics" => [4, 4, 50.0, 90, 0, 80.0]}, + %{"dimensions" => ["/a"], "metrics" => [1, 1, 100.0, 10, nil, 20.0]} ] end @@ -2156,61 +1897,30 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/pages?period=day&date=2021-01-02&comparison=previous_period&detailed=true" + response = + query_pages(conn, site, + date_range: ["2021-01-02", "2021-01-02"], + metrics: @detailed_metrics, + include: %{"compare" => "previous_period"} ) - assert json_response(conn, 200)["results"] == [ + assert response["results"] == [ %{ - "bounce_rate" => 100, + "dimensions" => ["/page2"], + "metrics" => [2, 2, 100, nil, nil, 66.67], "comparison" => %{ - "bounce_rate" => 0.0, - "pageviews" => 0, - "time_on_page" => nil, - "visitors" => 0, - "scroll_depth" => nil, - "percentage" => 0.0, - "change" => %{ - "bounce_rate" => nil, - "pageviews" => 100, - "time_on_page" => nil, - "visitors" => 100, - "scroll_depth" => nil, - "percentage" => 100 - } - }, - "name" => "/page2", - "pageviews" => 2, - "time_on_page" => nil, - "visitors" => 2, - "scroll_depth" => nil, - "percentage" => 66.67 + "dimensions" => ["/page2"], + "metrics" => [0, 0, 0.0, nil, nil, 0.0], + "change" => [100, 100, nil, nil, nil, 100] + } }, %{ - "bounce_rate" => 100, - "name" => "/page1", - "pageviews" => 1, - "time_on_page" => nil, - "visitors" => 1, - "scroll_depth" => nil, - "percentage" => 33.33, + "dimensions" => ["/page1"], + "metrics" => [1, 1, 100, nil, nil, 33.33], "comparison" => %{ - "bounce_rate" => 100, - "pageviews" => 1, - "time_on_page" => nil, - "visitors" => 1, - "scroll_depth" => nil, - "percentage" => 100.0, - "change" => %{ - "bounce_rate" => 0, - "pageviews" => 0, - "time_on_page" => nil, - "visitors" => 0, - "scroll_depth" => nil, - "percentage" => -67 - } + "dimensions" => ["/page1"], + "metrics" => [1, 1, 100, nil, nil, 100.0], + "change" => [0, 0, 0, nil, nil, -67] } } ] @@ -2238,40 +1948,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do build(:pageview, pathname: "/b1", timestamp: ~N[2021-01-01 00:00:00]) ]) - conn = - get( - conn, - "/api/stats/#{cv.domain}/pages?period=day&date=2021-01-01&detailed=true" + response = + query_pages(conn, cv, + date_range: ["2021-01-01", "2021-01-01"], + metrics: @detailed_metrics ) - assert json_response(conn, 200)["results"] == [ - %{ - "bounce_rate" => 100, - "name" => "/b1", - "pageviews" => 3, - "time_on_page" => nil, - "visitors" => 3, - "scroll_depth" => nil, - "percentage" => 50.0 - }, - %{ - "bounce_rate" => 100, - "name" => "/a2", - "pageviews" => 2, - "time_on_page" => nil, - "visitors" => 2, - "scroll_depth" => nil, - "percentage" => 33.33 - }, - %{ - "bounce_rate" => 100, - "name" => "/a1", - "pageviews" => 1, - "time_on_page" => nil, - "visitors" => 1, - "scroll_depth" => nil, - "percentage" => 16.67 - } + assert response["results"] == [ + %{"dimensions" => ["/b1"], "metrics" => [3, 3, 100, nil, nil, 50.0]}, + %{"dimensions" => ["/a2"], "metrics" => [2, 2, 100, nil, nil, 33.33]}, + %{"dimensions" => ["/a1"], "metrics" => [1, 1, 100, nil, nil, 16.67]} ] end end @@ -2310,25 +1996,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - conn = get(conn, "/api/stats/#{site.domain}/entry-pages?period=day&date=2021-01-01") + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:entry_page"], + metrics: ["visitors", "visits", "bounce_rate", "visit_duration", "percentage"] + ) - assert json_response(conn, 200)["results"] == [ - %{ - "visitors" => 2, - "visits" => 2, - "name" => "/page1", - "visit_duration" => 0, - "bounce_rate" => 100, - "percentage" => 66.67 - }, - %{ - "visitors" => 1, - "visits" => 2, - "name" => "/page2", - "visit_duration" => 450, - "bounce_rate" => 50, - "percentage" => 33.33 - } + assert response["results"] == [ + %{"dimensions" => ["/page1"], "metrics" => [2, 2, 100, 0, 66.67]}, + %{"dimensions" => ["/page2"], "metrics" => [1, 2, 50, 450, 33.33]} ] end @@ -2364,31 +2041,17 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - filters = Jason.encode!([[:is, "event:props:author", ["John Doe"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/entry-pages?period=day&date=2021-01-01&filters=#{filters}" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:entry_page"], + filters: [["is", "event:props:author", ["John Doe"]]], + metrics: ["visitors", "visits", "bounce_rate", "visit_duration", "percentage"] ) - assert json_response(conn, 200)["results"] == [ - %{ - "visitors" => 1, - "visits" => 1, - "name" => "/blog", - "visit_duration" => 60, - "bounce_rate" => 0, - "percentage" => 50.0 - }, - %{ - "visitors" => 1, - "visits" => 1, - "name" => "/blog/john-2", - "visit_duration" => 0, - "bounce_rate" => 100, - "percentage" => 50.0 - } + assert response["results"] == [ + %{"dimensions" => ["/blog"], "metrics" => [1, 1, 0, 60, 50.0]}, + %{"dimensions" => ["/blog/john-2"], "metrics" => [1, 1, 100, 0, 50.0]} ] end @@ -2420,6 +2083,10 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ]) populate_stats(site, [ + build(:imported_visitors, + date: ~D[2021-01-01], + visitors: 2 + ), build(:imported_entry_pages, entry_page: "/page2", date: ~D[2021-01-01], @@ -2429,50 +2096,29 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - conn1 = get(conn, "/api/stats/#{site.domain}/entry-pages?period=day&date=2021-01-01") + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:entry_page"], + metrics: ["visitors", "visits", "bounce_rate", "visit_duration", "percentage"] + ) - assert json_response(conn1, 200)["results"] == [ - %{ - "visitors" => 2, - "visits" => 2, - "name" => "/page1", - "visit_duration" => 0, - "bounce_rate" => 100, - "percentage" => 66.67 - }, - %{ - "visitors" => 1, - "visits" => 2, - "name" => "/page2", - "visit_duration" => 450, - "bounce_rate" => 50, - "percentage" => 33.33 - } + assert response["results"] == [ + %{"dimensions" => ["/page1"], "metrics" => [2, 2, 100, 0, 66.67]}, + %{"dimensions" => ["/page2"], "metrics" => [1, 2, 50, 450, 33.33]} ] - conn2 = - get( - conn, - "/api/stats/#{site.domain}/entry-pages?period=day&date=2021-01-01&with_imported=true" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:entry_page"], + metrics: ["visitors", "visits", "bounce_rate", "visit_duration", "percentage"], + include: %{"imports" => true} ) - assert json_response(conn2, 200)["results"] == [ - %{ - "visitors" => 3, - "visits" => 5, - "name" => "/page2", - "visit_duration" => 240.0, - "bounce_rate" => 20.0, - "percentage" => 60.0 - }, - %{ - "visitors" => 2, - "visits" => 2, - "name" => "/page1", - "visit_duration" => 0.0, - "bounce_rate" => 100.0, - "percentage" => 40.0 - } + assert response["results"] == [ + %{"dimensions" => ["/page2"], "metrics" => [3, 5, 20.0, 240.0, 60.0]}, + %{"dimensions" => ["/page1"], "metrics" => [2, 2, 100.0, 0.0, 40.0]} ] end @@ -2514,32 +2160,19 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - filters = Jason.encode!([[:is, "event:hostname", ["es.example.com"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/entry-pages?period=day&date=2021-01-01&filters=#{filters}" - ) - # We're going to only join sessions where the exit hostname matches the filter - assert json_response(conn, 200)["results"] == [ - %{ - "name" => "/page1", - "visit_duration" => 0, - "visitors" => 1, - "visits" => 1, - "bounce_rate" => 100, - "percentage" => 50.0 - }, - %{ - "name" => "/page2", - "visit_duration" => 0, - "visitors" => 1, - "visits" => 1, - "bounce_rate" => 100, - "percentage" => 50.0 - } + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:entry_page"], + filters: [["is", "event:hostname", ["es.example.com"]]], + metrics: ["visitors", "visits", "bounce_rate", "visit_duration", "percentage"], + order_by: [["visitors", "desc"], ["visit:entry_page", "asc"]] + ) + + assert response["results"] == [ + %{"dimensions" => ["/page1"], "metrics" => [1, 1, 100, 0, 50.0]}, + %{"dimensions" => ["/page2"], "metrics" => [1, 1, 100, 0, 50.0]} ] end @@ -2559,31 +2192,32 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do insert(:goal, site: site, event_name: "Signup") request = fn conn, opts -> - page = Keyword.fetch!(opts, :page) - limit = Keyword.fetch!(opts, :limit) - filters = Jason.encode!([[:is, "event:goal", ["Signup"]]]) - conn - |> get( - "/api/stats/#{site.domain}/pages?date=2021-01-01&period=day&filters=#{filters}&limit=#{limit}&page=#{page}" + |> query_pages(site, + metrics: ["visitors"], + pagination: %{ + "offset" => Keyword.fetch!(opts, :offset), + "limit" => Keyword.fetch!(opts, :limit) + }, + filters: [["is", "event:goal", ["Signup"]]], + order_by: [["event:page", "asc"]] ) - |> json_response(200) |> Map.get("results") - |> Enum.map(fn %{"name" => "/signup/" <> seq} -> + |> Enum.map(fn %{"dimensions" => ["/signup/" <> seq]} -> seq end) end - assert List.first(request.(conn, page: 1, limit: 100)) == "01" - assert List.last(request.(conn, page: 1, limit: 100)) == "30" - assert List.last(request.(conn, page: 1, limit: 29)) == "29" - assert ["01", "02"] = request.(conn, page: 1, limit: 2) - assert ["03", "04"] = request.(conn, page: 2, limit: 2) - assert ["01", "02", "03", "04", "05"] = request.(conn, page: 1, limit: 5) - assert ["06", "07", "08", "09", "10"] = request.(conn, page: 2, limit: 5) - assert ["11", "12", "13", "14", "15"] = request.(conn, page: 3, limit: 5) - assert ["20"] = request.(conn, page: 20, limit: 1) - assert [] = request.(conn, page: 31, limit: 1) + assert List.first(request.(conn, offset: 0, limit: 100)) == "01" + assert List.last(request.(conn, offset: 0, limit: 100)) == "30" + assert List.last(request.(conn, offset: 0, limit: 29)) == "29" + assert ["01", "02"] = request.(conn, offset: 0, limit: 2) + assert ["03", "04"] = request.(conn, offset: 2, limit: 2) + assert ["01", "02", "03", "04", "05"] = request.(conn, offset: 0, limit: 5) + assert ["06", "07", "08", "09", "10"] = request.(conn, offset: 5, limit: 5) + assert ["11", "12", "13", "14", "15"] = request.(conn, offset: 10, limit: 5) + assert ["20"] = request.(conn, offset: 19, limit: 1) + assert [] = request.(conn, offset: 30, limit: 1) end test "calculates conversion_rate when filtering for goal", %{conn: conn, site: site} do @@ -2621,31 +2255,23 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ]) insert(:goal, site: site, event_name: "Signup") - filters = Jason.encode!([[:is, "event:goal", ["Signup"]]]) - conn = - get( - conn, - "/api/stats/#{site.domain}/entry-pages?period=day&date=2021-01-01&filters=#{filters}" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:entry_page"], + filters: [["is", "event:goal", ["Signup"]]], + metrics: ["visitors", "group_conversion_rate", "total_visitors"], + order_by: [["visitors", "desc"], ["visit:entry_page", "asc"]] ) - assert json_response(conn, 200)["results"] == [ - %{ - "total_visitors" => 2, - "visitors" => 1, - "name" => "/page1", - "conversion_rate" => 50.0 - }, - %{ - "total_visitors" => 1, - "visitors" => 1, - "name" => "/page2", - "conversion_rate" => 100.0 - } + assert response["results"] == [ + %{"dimensions" => ["/page1"], "metrics" => [1, 50.0, 2]}, + %{"dimensions" => ["/page2"], "metrics" => [1, 100.0, 1]} ] end - test "ignores entry pages from sessions with only custom events", %{conn: conn, site: site} do + test "can filter out empty entry pages (sessions with only custom events)", %{conn: conn, site: site} do populate_stats(site, [ build(:event, name: "Signup", @@ -2654,13 +2280,15 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/entry-pages?period=day&date=2021-01-01" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:entry_page"], + filters: [["is_not", "visit:entry_page", [""]]], + metrics: ["visitors", "visits", "bounce_rate", "visit_duration", "percentage"] ) - assert json_response(conn, 200)["results"] == [] + assert response["results"] == [] end test "filter by :matches_member entry_page with imported data", %{conn: conn, site: site} do @@ -2686,36 +2314,19 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - filters = Jason.encode!([[:contains, "visit:entry_page", ["/a", "/b"]]]) - q = "?period=day&date=2021-01-01&filters=#{filters}&detailed=true&with_imported=true" - - conn = get(conn, "/api/stats/#{site.domain}/entry-pages#{q}") + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:entry_page"], + filters: [["contains", "visit:entry_page", ["/a", "/b"]]], + metrics: ["visitors", "visits", "bounce_rate", "visit_duration", "percentage"], + include: %{"imports" => true} + ) - assert json_response(conn, 200)["results"] == [ - %{ - "visit_duration" => 100.0, - "name" => "/a", - "visits" => 10, - "visitors" => 6, - "bounce_rate" => 10.0, - "percentage" => 66.67 - }, - %{ - "visit_duration" => 50.0, - "name" => "/bbb", - "visits" => 2, - "visitors" => 2, - "bounce_rate" => 0.0, - "percentage" => 22.22 - }, - %{ - "visit_duration" => 0, - "name" => "/aaa", - "visits" => 1, - "visitors" => 1, - "bounce_rate" => 100.0, - "percentage" => 11.11 - } + assert response["results"] == [ + %{"dimensions" => ["/a"], "metrics" => [6, 10, 10.0, 100.0, 66.67]}, + %{"dimensions" => ["/bbb"], "metrics" => [2, 2, 0.0, 50.0, 22.22]}, + %{"dimensions" => ["/aaa"], "metrics" => [1, 1, 100.0, 0, 11.11]} ] end end @@ -2745,23 +2356,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - conn = get(conn, "/api/stats/#{site.domain}/exit-pages?period=day&date=2021-01-01") + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:exit_page"], + metrics: ["visitors", "visits", "exit_rate", "percentage"] + ) - assert json_response(conn, 200)["results"] == [ - %{ - "name" => "/page1", - "visitors" => 2, - "visits" => 2, - "exit_rate" => 66.7, - "percentage" => 66.67 - }, - %{ - "name" => "/page2", - "visitors" => 1, - "visits" => 1, - "exit_rate" => 100, - "percentage" => 33.33 - } + assert response["results"] == [ + %{"dimensions" => ["/page1"], "metrics" => [2, 2, 66.7, 66.67]}, + %{"dimensions" => ["/page2"], "metrics" => [1, 1, 100, 33.33]} ] end @@ -2787,27 +2391,17 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/exit-pages?period=day&date=2021-01-01&order_by=#{Jason.encode!([["visits", "asc"]])}" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:exit_page"], + metrics: ["visitors", "visits", "exit_rate", "percentage"], + order_by: [["visits", "asc"]] ) - assert json_response(conn, 200)["results"] == [ - %{ - "name" => "/page2", - "visitors" => 1, - "visits" => 1, - "exit_rate" => 100.0, - "percentage" => 33.33 - }, - %{ - "name" => "/page1", - "visitors" => 2, - "visits" => 2, - "exit_rate" => 66.7, - "percentage" => 66.67 - } + assert response["results"] == [ + %{"dimensions" => ["/page2"], "metrics" => [1, 1, 100.0, 33.33]}, + %{"dimensions" => ["/page1"], "metrics" => [2, 2, 66.7, 66.67]} ] end @@ -2844,17 +2438,18 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - filters = Jason.encode!([[:is, "event:hostname", ["es.example.com"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/exit-pages?period=day&date=2021-01-01&filters=#{filters}" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:exit_page"], + filters: [["is", "event:hostname", ["es.example.com"]]], + metrics: ["visitors", "visits", "percentage"] ) # We're going to only join sessions where the entry hostname matches the filter - assert json_response(conn, 200)["results"] == - [%{"name" => "/page1", "visitors" => 1, "visits" => 1, "percentage" => 100.0}] + assert response["results"] == [ + %{"dimensions" => ["/page1"], "metrics" => [1, 1, 100.0]} + ] end test "returns top exit pages filtered by custom pageview props", %{conn: conn, site: site} do @@ -2883,16 +2478,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - filters = Jason.encode!([[:is, "event:props:author", ["John Doe"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/exit-pages?period=day&date=2021-01-01&filters=#{filters}" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:exit_page"], + filters: [["is", "event:props:author", ["John Doe"]]], + metrics: ["visitors", "visits", "percentage"] ) - assert json_response(conn, 200)["results"] == [ - %{"name" => "/", "visitors" => 1, "visits" => 1, "percentage" => 100.0} + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [1, 1, 100.0]} ] end @@ -2933,50 +2528,35 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - conn1 = get(conn, "/api/stats/#{site.domain}/exit-pages?period=day&date=2021-01-01") + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:exit_page"], + filters: [["is_not", "visit:exit_page", [""]]], + metrics: ["visitors", "visits", "exit_rate", "percentage"] + ) - assert json_response(conn1, 200)["results"] == [ - %{ - "name" => "/page1", - "visitors" => 2, - "visits" => 2, - "exit_rate" => 66.7, - "percentage" => 66.67 - }, - %{ - "name" => "/page2", - "visitors" => 1, - "visits" => 1, - "exit_rate" => 100.0, - "percentage" => 33.33 - } + assert response["results"] == [ + %{"dimensions" => ["/page1"], "metrics" => [2, 2, 66.7, 66.67]}, + %{"dimensions" => ["/page2"], "metrics" => [1, 1, 100.0, 33.33]} ] - conn2 = - get( - conn, - "/api/stats/#{site.domain}/exit-pages?period=day&date=2021-01-01&with_imported=true" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:exit_page"], + filters: [["is_not", "visit:exit_page", [""]]], + metrics: ["visitors", "visits", "exit_rate", "percentage"], + include: %{"imports" => true} ) - assert json_response(conn2, 200)["results"] == [ - %{ - "name" => "/page2", - "visitors" => 3, - "visits" => 4, - "exit_rate" => 80.0, - "percentage" => 60.0 - }, - %{ - "name" => "/page1", - "visitors" => 2, - "visits" => 2, - "exit_rate" => 66.7, - "percentage" => 40.0 - } + assert response["results"] == [ + %{"dimensions" => ["/page2"], "metrics" => [3, 4, 80.0, 60.0]}, + %{"dimensions" => ["/page1"], "metrics" => [2, 2, 66.7, 40.0]} ] end - test "calculates correct exit rate and conversion_rate when filtering for goal", %{ + test "returns top exit pages when filtering for goal", %{ conn: conn, site: site } do @@ -3009,31 +2589,23 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ]) insert(:goal, site: site, event_name: "Signup") - filters = Jason.encode!([[:is, "event:goal", ["Signup"]]]) - conn = - get( - conn, - "/api/stats/#{site.domain}/exit-pages?period=day&date=2021-01-01&filters=#{filters}" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:exit_page"], + filters: [["is", "event:goal", ["Signup"]]], + metrics: ["visitors", "group_conversion_rate", "total_visitors"], + order_by: [["visitors", "desc"], ["visit:exit_page", "asc"]] ) - assert json_response(conn, 200)["results"] == [ - %{ - "name" => "/exit1", - "visitors" => 1, - "total_visitors" => 1, - "conversion_rate" => 100.0 - }, - %{ - "name" => "/exit2", - "visitors" => 1, - "total_visitors" => 1, - "conversion_rate" => 100.0 - } + assert response["results"] == [ + %{"dimensions" => ["/exit1"], "metrics" => [1, 100.0, 1]}, + %{"dimensions" => ["/exit2"], "metrics" => [1, 100.0, 1]} ] end - test "calculates correct exit rate when filtering for page", %{conn: conn, site: site} do + test "returns top exit pages when filtering for page", %{conn: conn, site: site} do populate_stats(site, [ build(:pageview, user_id: 1, @@ -3062,21 +2634,21 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - filters = Jason.encode!([[:is, "event:page", ["/exit1"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/exit-pages?period=day&date=2021-01-01&filters=#{filters}" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:exit_page"], + filters: [["is", "event:page", ["/exit1"]]], + metrics: ["visitors", "visits", "percentage"] ) - assert json_response(conn, 200)["results"] == [ - %{"name" => "/exit1", "visitors" => 1, "visits" => 1, "percentage" => 50.0}, - %{"name" => "/exit2", "visitors" => 1, "visits" => 1, "percentage" => 50.0} + assert response["results"] == [ + %{"dimensions" => ["/exit1"], "metrics" => [1, 1, 50.0]}, + %{"dimensions" => ["/exit2"], "metrics" => [1, 1, 50.0]} ] end - test "ignores exit pages from sessions with only custom events", %{conn: conn, site: site} do + test "can filter out empty exit pages (sessions with only custom events)", %{conn: conn, site: site} do populate_stats(site, [ build(:event, name: "Signup", @@ -3085,13 +2657,15 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/exit-pages?period=day&date=2021-01-01" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:exit_page"], + filters: [["is_not", "visit:exit_page", [""]]], + metrics: ["visitors", "visits", "exit_rate", "percentage"] ) - assert json_response(conn, 200)["results"] == [] + assert response["results"] == [] end test "filter by :is_not exit_page with imported data", %{conn: conn, site: site} do @@ -3119,33 +2693,19 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do build(:imported_pages, page: "/bbb", pageviews: 2, date: ~D[2021-01-01]) ]) - filters = Jason.encode!([[:is_not, "visit:exit_page", ["/ignored"]]]) - q = "?period=day&date=2021-01-01&filters=#{filters}&detailed=true&with_imported=true" - - conn = get(conn, "/api/stats/#{site.domain}/exit-pages#{q}") + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:exit_page"], + filters: [["is_not", "visit:exit_page", ["/ignored"]]], + metrics: ["visitors", "visits", "exit_rate", "percentage"], + include: %{"imports" => true} + ) - assert json_response(conn, 200)["results"] == [ - %{ - "exit_rate" => 50.0, - "name" => "/a", - "visits" => 10, - "visitors" => 6, - "percentage" => 66.67 - }, - %{ - "exit_rate" => 100.0, - "name" => "/bbb", - "visits" => 2, - "visitors" => 2, - "percentage" => 22.22 - }, - %{ - "exit_rate" => 100.0, - "name" => "/aaa", - "visits" => 1, - "visitors" => 1, - "percentage" => 11.11 - } + assert response["results"] == [ + %{"dimensions" => ["/a"], "metrics" => [6, 10, 50.0, 66.67]}, + %{"dimensions" => ["/bbb"], "metrics" => [2, 2, 100.0, 22.22]}, + %{"dimensions" => ["/aaa"], "metrics" => [1, 1, 100.0, 11.11]} ] end @@ -3192,71 +2752,81 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do insert(:goal, %{site: site, event_name: "Payment", currency: :USD}) - filters = Jason.encode!([[:is, "event:goal", ["Payment"]]]) - order_by = Jason.encode!([["visitors", "desc"]]) - - q = "?filters=#{filters}&order_by=#{order_by}&detailed=true&period=day&page=1&limit=100" - - conn = - get( - conn, - "/api/stats/#{site.domain}/entry-pages#{q}" - ) - - assert json_response(conn, 200)["results"] == [ - %{ - "average_revenue" => %{ - "currency" => "USD", - "long" => "$1,500.00", - "short" => "$1.5K", - "value" => 1500.0 - }, - "conversion_rate" => 100.0, - "name" => "/first", - "total_revenue" => %{ - "currency" => "USD", - "long" => "$3,000.00", - "short" => "$3.0K", - "value" => 3000.0 - }, - "total_visitors" => 2, - "visitors" => 2 + response = + query_pages(conn, site, + date_range: "day", + dimensions: ["visit:entry_page"], + filters: [["is", "event:goal", ["Payment"]], ["is_not", "visit:entry_page", [""]]], + metrics: [ + "visitors", + "group_conversion_rate", + "total_visitors", + "average_revenue", + "total_revenue" + ], + order_by: [["visitors", "desc"], ["visit:entry_page", "asc"]] + ) + + assert response["results"] == [ + %{ + "dimensions" => ["/first"], + "metrics" => [ + 2, + 100.0, + 2, + %{ + "currency" => "USD", + "long" => "$1,500.00", + "short" => "$1.5K", + "value" => 1500.0 + }, + %{ + "currency" => "USD", + "long" => "$3,000.00", + "short" => "$3.0K", + "value" => 3000.0 + } + ] }, %{ - "average_revenue" => %{ - "currency" => "USD", - "long" => "$3,500.00", - "short" => "$3.5K", - "value" => 3500.0 - }, - "conversion_rate" => 100.0, - "name" => "/second", - "total_revenue" => %{ - "currency" => "USD", - "long" => "$7,000.00", - "short" => "$7.0K", - "value" => 7000.0 - }, - "total_visitors" => 1, - "visitors" => 1 + "dimensions" => ["/second"], + "metrics" => [ + 1, + 100.0, + 1, + %{ + "currency" => "USD", + "long" => "$3,500.00", + "short" => "$3.5K", + "value" => 3500.0 + }, + %{ + "currency" => "USD", + "long" => "$7,000.00", + "short" => "$7.0K", + "value" => 7000.0 + } + ] }, %{ - "average_revenue" => %{ - "currency" => "USD", - "long" => "$2,500.00", - "short" => "$2.5K", - "value" => 2500.0 - }, - "conversion_rate" => 100.0, - "name" => "/third", - "total_revenue" => %{ - "currency" => "USD", - "long" => "$2,500.00", - "short" => "$2.5K", - "value" => 2500.0 - }, - "total_visitors" => 1, - "visitors" => 1 + "dimensions" => ["/third"], + "metrics" => [ + 1, + 100.0, + 1, + %{ + "currency" => "USD", + "long" => "$2,500.00", + "short" => "$2.5K", + "value" => 2500.0 + }, + %{ + "currency" => "USD", + "long" => "$2,500.00", + "short" => "$2.5K", + "value" => 2500.0 + } + ] } ] end @@ -3307,71 +2877,81 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do insert(:goal, %{site: site, event_name: "Payment", currency: :USD}) - filters = Jason.encode!([[:is, "event:goal", ["Payment"]]]) - order_by = Jason.encode!([["visitors", "desc"]]) - - q = "?filters=#{filters}&order_by=#{order_by}&detailed=true&period=day&page=1&limit=100" - - conn = - get( - conn, - "/api/stats/#{site.domain}/exit-pages#{q}" - ) - - assert json_response(conn, 200)["results"] == [ - %{ - "average_revenue" => %{ - "currency" => "USD", - "long" => "$1,500.00", - "short" => "$1.5K", - "value" => 1500.0 - }, - "conversion_rate" => 100.0, - "name" => "/exit_first", - "total_revenue" => %{ - "currency" => "USD", - "long" => "$3,000.00", - "short" => "$3.0K", - "value" => 3000.0 - }, - "total_visitors" => 2, - "visitors" => 2 + response = + query_pages(conn, site, + date_range: "day", + dimensions: ["visit:exit_page"], + filters: [["is", "event:goal", ["Payment"]], ["is_not", "visit:exit_page", [""]]], + metrics: [ + "visitors", + "group_conversion_rate", + "total_visitors", + "average_revenue", + "total_revenue" + ], + order_by: [["visitors", "desc"], ["visit:exit_page", "asc"]] + ) + + assert response["results"] == [ + %{ + "dimensions" => ["/exit_first"], + "metrics" => [ + 2, + 100.0, + 2, + %{ + "currency" => "USD", + "long" => "$1,500.00", + "short" => "$1.5K", + "value" => 1500.0 + }, + %{ + "currency" => "USD", + "long" => "$3,000.00", + "short" => "$3.0K", + "value" => 3000.0 + } + ] }, %{ - "average_revenue" => %{ - "currency" => "USD", - "long" => "$3,500.00", - "short" => "$3.5K", - "value" => 3500.0 - }, - "conversion_rate" => 100.0, - "name" => "/exit_second", - "total_revenue" => %{ - "currency" => "USD", - "long" => "$7,000.00", - "short" => "$7.0K", - "value" => 7000.0 - }, - "total_visitors" => 1, - "visitors" => 1 + "dimensions" => ["/exit_second"], + "metrics" => [ + 1, + 100.0, + 1, + %{ + "currency" => "USD", + "long" => "$3,500.00", + "short" => "$3.5K", + "value" => 3500.0 + }, + %{ + "currency" => "USD", + "long" => "$7,000.00", + "short" => "$7.0K", + "value" => 7000.0 + } + ] }, %{ - "average_revenue" => %{ - "currency" => "USD", - "long" => "$2,500.00", - "short" => "$2.5K", - "value" => 2500.0 - }, - "conversion_rate" => 100.0, - "name" => "/third", - "total_revenue" => %{ - "currency" => "USD", - "long" => "$2,500.00", - "short" => "$2.5K", - "value" => 2500.0 - }, - "total_visitors" => 1, - "visitors" => 1 + "dimensions" => ["/third"], + "metrics" => [ + 1, + 100.0, + 1, + %{ + "currency" => "USD", + "long" => "$2,500.00", + "short" => "$2.5K", + "value" => 2500.0 + }, + %{ + "currency" => "USD", + "long" => "$2,500.00", + "short" => "$2.5K", + "value" => 2500.0 + } + ] } ] end @@ -3428,89 +3008,89 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do insert(:goal, %{site: site, event_name: "Payment", currency: :USD}) - filters = Jason.encode!([[:is, "event:goal", ["Payment"]]]) - order_by = Jason.encode!([["visitors", "desc"]]) - - q = "?filters=#{filters}&order_by=#{order_by}&detailed=true&period=day&page=1&limit=100" - - conn = - get( - conn, - "/api/stats/#{site.domain}/pages#{q}" - ) - - assert json_response(conn, 200)["results"] == [ - %{ - "average_revenue" => %{ - "currency" => "USD", - "long" => "$0.00", - "short" => "$0.0", - "value" => 0.0 - }, - "conversion_rate" => 100.0, - "name" => "/nopay", - "total_revenue" => %{ - "currency" => "USD", - "long" => "$0.00", - "short" => "$0.0", - "value" => 0.0 - }, - "total_visitors" => 3, - "visitors" => 3 + response = + query_pages(conn, site, + date_range: "day", + filters: [["is", "event:goal", ["Payment"]]], + metrics: [ + "visitors", + "group_conversion_rate", + "total_visitors", + "average_revenue", + "total_revenue" + ] + ) + + assert response["results"] == [ + %{ + "dimensions" => ["/nopay"], + "metrics" => [ + 3, + 100.0, + 3, + %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, + %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0} + ] }, %{ - "average_revenue" => %{ - "currency" => "USD", - "long" => "$1,500.00", - "short" => "$1.5K", - "value" => 1500.0 - }, - "conversion_rate" => 100.0, - "name" => "/purchase/first", - "total_revenue" => %{ - "currency" => "USD", - "long" => "$3,000.00", - "short" => "$3.0K", - "value" => 3000.0 - }, - "total_visitors" => 2, - "visitors" => 2 + "dimensions" => ["/purchase/first"], + "metrics" => [ + 2, + 100.0, + 2, + %{ + "currency" => "USD", + "long" => "$1,500.00", + "short" => "$1.5K", + "value" => 1500.0 + }, + %{ + "currency" => "USD", + "long" => "$3,000.00", + "short" => "$3.0K", + "value" => 3000.0 + } + ] }, %{ - "average_revenue" => %{ - "currency" => "USD", - "long" => "$3,500.00", - "short" => "$3.5K", - "value" => 3500.0 - }, - "conversion_rate" => 100.0, - "name" => "/purchase/second", - "total_revenue" => %{ - "currency" => "USD", - "long" => "$7,000.00", - "short" => "$7.0K", - "value" => 7000.0 - }, - "total_visitors" => 1, - "visitors" => 1 + "dimensions" => ["/purchase/second"], + "metrics" => [ + 1, + 100.0, + 1, + %{ + "currency" => "USD", + "long" => "$3,500.00", + "short" => "$3.5K", + "value" => 3500.0 + }, + %{ + "currency" => "USD", + "long" => "$7,000.00", + "short" => "$7.0K", + "value" => 7000.0 + } + ] }, %{ - "average_revenue" => %{ - "currency" => "USD", - "long" => "$2,500.00", - "short" => "$2.5K", - "value" => 2500.0 - }, - "conversion_rate" => 100.0, - "name" => "/purchase/third", - "total_revenue" => %{ - "currency" => "USD", - "long" => "$2,500.00", - "short" => "$2.5K", - "value" => 2500.0 - }, - "total_visitors" => 1, - "visitors" => 1 + "dimensions" => ["/purchase/third"], + "metrics" => [ + 1, + 100.0, + 1, + %{ + "currency" => "USD", + "long" => "$2,500.00", + "short" => "$2.5K", + "value" => 2500.0 + }, + %{ + "currency" => "USD", + "long" => "$2,500.00", + "short" => "$2.5K", + "value" => 2500.0 + } + ] } ] end