mirror of
https://github.com/plausible/analytics.git
synced 2024-11-29 05:57:19 +03:00
Improve sorting indicators, left-align tables, set column width by metric
This commit is contained in:
parent
57037fdb82
commit
bd80e910fc
@ -1,25 +1,48 @@
|
|||||||
/** @format */
|
/** @format */
|
||||||
|
|
||||||
import React, { ReactNode } from 'react'
|
import React, { ReactNode } from 'react'
|
||||||
import { getSortDirectionIndicator, SortDirection } from '../hooks/use-order-by'
|
import { SortDirection } from '../hooks/use-order-by'
|
||||||
|
import classNames from 'classnames'
|
||||||
|
|
||||||
export const SortButton = ({
|
export const SortButton = ({
|
||||||
children,
|
children,
|
||||||
toggleSort,
|
toggleSort,
|
||||||
hint,
|
hint,
|
||||||
sortDirection
|
sortDirection,
|
||||||
|
nextSortDirection
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
toggleSort: () => void
|
toggleSort: () => void
|
||||||
hint: string
|
hint: string
|
||||||
sortDirection: SortDirection | null
|
sortDirection: SortDirection | null
|
||||||
|
nextSortDirection: SortDirection
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<button onClick={toggleSort} title={hint} className="hover:underline">
|
<button
|
||||||
{children}
|
onClick={toggleSort}
|
||||||
{sortDirection !== null && (
|
title={hint}
|
||||||
<span> {getSortDirectionIndicator(sortDirection)}</span>
|
className={classNames(
|
||||||
|
'group',
|
||||||
|
'hover:underline',
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<span
|
||||||
|
className={classNames(
|
||||||
|
'rounded inline-block h-4 w-4',
|
||||||
|
'ml-1',
|
||||||
|
{
|
||||||
|
[SortDirection.asc]: 'rotate-180',
|
||||||
|
[SortDirection.desc]: 'rotate-0'
|
||||||
|
}[sortDirection ?? nextSortDirection],
|
||||||
|
!sortDirection && 'opacity-0',
|
||||||
|
!sortDirection && 'group-hover:opacity-100',
|
||||||
|
sortDirection && 'group-hover:bg-gray-100 dark:group-hover:bg-gray-900',
|
||||||
|
'transition',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
77
assets/js/dashboard/components/table.tsx
Normal file
77
assets/js/dashboard/components/table.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
/** @format */
|
||||||
|
|
||||||
|
import classNames from 'classnames'
|
||||||
|
import React, { ReactNode } from 'react'
|
||||||
|
|
||||||
|
export const TableHeaderCell = ({
|
||||||
|
children,
|
||||||
|
className
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
className: string
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
className={classNames(
|
||||||
|
'px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</th>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TableCell = ({
|
||||||
|
children,
|
||||||
|
className
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
className: string
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
className={classNames(
|
||||||
|
'px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Table = <T extends Record<string, string | number | ReactNode>>({
|
||||||
|
data,
|
||||||
|
columns
|
||||||
|
}: {
|
||||||
|
columns: { accessor: keyof T; width: string; label: string }[]
|
||||||
|
data: T[]
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full bg-white border border-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
{columns.map((column, index) => (
|
||||||
|
<TableHeaderCell key={index} className={column.width}>
|
||||||
|
{column.label}
|
||||||
|
</TableHeaderCell>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{data.map((item, itemIndex) => (
|
||||||
|
<tr key={itemIndex}>
|
||||||
|
{columns.map(({ accessor, width }, colIndex) => (
|
||||||
|
<TableCell key={colIndex} className={width}>
|
||||||
|
{item[accessor]}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -319,7 +319,7 @@ export default function Funnel({ funnelName, tabs }) {
|
|||||||
<Bar
|
<Bar
|
||||||
count={step.visitors}
|
count={step.visitors}
|
||||||
all={funnel.steps}
|
all={funnel.steps}
|
||||||
bg={palette.smallBarClass}
|
className={palette.smallBarClass}
|
||||||
maxWidthDeduction={"5rem"}
|
maxWidthDeduction={"5rem"}
|
||||||
plot={'visitors'}
|
plot={'visitors'}
|
||||||
>
|
>
|
||||||
|
@ -1,84 +0,0 @@
|
|||||||
import { useEffect } from "react"
|
|
||||||
import { useQueryClient, useInfiniteQuery } from "@tanstack/react-query"
|
|
||||||
import * as api from "../api"
|
|
||||||
|
|
||||||
const LIMIT = 10 // FOR DEBUGGING
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A wrapper for the React Query library. Constructs the necessary options
|
|
||||||
* (including pagination config) to pass into the `useInfiniteQuery` hook.
|
|
||||||
*
|
|
||||||
* ### Required props
|
|
||||||
*
|
|
||||||
* @param {Array} key - The key under which the global "query" instance will live.
|
|
||||||
* Should be passed as a list of two elements - `[endpoint, { query }]`. The object
|
|
||||||
* can also contain additional values (such as `search`) to be used by:
|
|
||||||
* 1) React Query, to determine the uniqueness of the query instance
|
|
||||||
* 2) the `getRequestParams` function to build the request params.
|
|
||||||
*
|
|
||||||
* @param {Function} getRequestParams - A function that takes the `key` prop as an
|
|
||||||
* argument, and returns `[query, params]` which will be used by `queryFn` that
|
|
||||||
* actually calls the API.
|
|
||||||
*
|
|
||||||
* ### Optional props
|
|
||||||
*
|
|
||||||
* @param {Function} [afterFetchData] - A function to call after data has been fetched.
|
|
||||||
* Receives the API response as an argument.
|
|
||||||
*
|
|
||||||
* @param {Function} [afterFetchNextPage] - A function to call after the next page has
|
|
||||||
* been fetched. Receives the API response as an argument.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export function useAPIClient(props) {
|
|
||||||
const {key, getRequestParams, afterFetchData, afterFetchNextPage} = props
|
|
||||||
const [endpoint] = key
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
const queryFn = async ({ pageParam, queryKey }) => {
|
|
||||||
const [query, params] = getRequestParams(queryKey)
|
|
||||||
params.limit = LIMIT
|
|
||||||
params.page = pageParam
|
|
||||||
|
|
||||||
const response = await api.get(endpoint, query, params)
|
|
||||||
|
|
||||||
if (pageParam === 1 && typeof afterFetchData === 'function') {
|
|
||||||
afterFetchData(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pageParam > 1 && typeof afterFetchNextPage === 'function') {
|
|
||||||
afterFetchNextPage(response)
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.results
|
|
||||||
}
|
|
||||||
|
|
||||||
// During the cleanup phase, make sure only the first page of results
|
|
||||||
// is cached under any `queryKey` containing this endpoint.
|
|
||||||
useEffect(() => {
|
|
||||||
const key = [endpoint]
|
|
||||||
return () => {
|
|
||||||
queryClient.setQueriesData(key, (data) => {
|
|
||||||
if (data?.pages?.length) {
|
|
||||||
return {
|
|
||||||
pages: data.pages.slice(0, 1),
|
|
||||||
pageParams: data.pageParams.slice(0, 1),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [queryClient, endpoint])
|
|
||||||
|
|
||||||
const getNextPageParam = (lastPageResults, _, lastPageIndex) => {
|
|
||||||
return lastPageResults.length === LIMIT ? lastPageIndex + 1 : null
|
|
||||||
}
|
|
||||||
const defaultInitialPageParam = 1
|
|
||||||
const initialPageParam = props.initialPageParam === undefined ? defaultInitialPageParam : props.initialPageParam
|
|
||||||
|
|
||||||
return useInfiniteQuery({
|
|
||||||
queryKey: key,
|
|
||||||
queryFn,
|
|
||||||
getNextPageParam,
|
|
||||||
initialPageParam,
|
|
||||||
placeholderData: (previousData) => previousData,
|
|
||||||
})
|
|
||||||
}
|
|
102
assets/js/dashboard/hooks/api-client.ts
Normal file
102
assets/js/dashboard/hooks/api-client.ts
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
/** @format */
|
||||||
|
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
useQueryClient,
|
||||||
|
useInfiniteQuery,
|
||||||
|
QueryFilters,
|
||||||
|
} from '@tanstack/react-query'
|
||||||
|
import * as api from '../api'
|
||||||
|
import { DashboardQuery } from '../query';
|
||||||
|
|
||||||
|
const LIMIT = 10 // FOR DEBUGGING
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A wrapper for the React Query library. Constructs the necessary options
|
||||||
|
* (including pagination config) to pass into the `useInfiniteQuery` hook.
|
||||||
|
*
|
||||||
|
* ### Required props
|
||||||
|
*
|
||||||
|
* @param {Array} key - The key under which the global "query" instance will live.
|
||||||
|
* Should be passed as a list of two elements - `[endpoint, { query }]`. The object
|
||||||
|
* can also contain additional values (such as `search`) to be used by:
|
||||||
|
* 1) React Query, to determine the uniqueness of the query instance
|
||||||
|
* 2) the `getRequestParams` function to build the request params.
|
||||||
|
*
|
||||||
|
* @param {Function} getRequestParams - A function that takes the `key` prop as an
|
||||||
|
* argument, and returns `[query, params]` which will be used by `queryFn` that
|
||||||
|
* actually calls the API.
|
||||||
|
*
|
||||||
|
* ### Optional props
|
||||||
|
*
|
||||||
|
* @param {Function} [afterFetchData] - A function to call after data has been fetched.
|
||||||
|
* Receives the API response as an argument.
|
||||||
|
*
|
||||||
|
* @param {Function} [afterFetchNextPage] - A function to call after the next page has
|
||||||
|
* been fetched. Receives the API response as an argument.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
type Endpoint = string;
|
||||||
|
|
||||||
|
type InfiniteQueryKey = [Endpoint, {query: DashboardQuery}]
|
||||||
|
|
||||||
|
export function useAPIClient<TResponse, TKey extends InfiniteQueryKey = InfiniteQueryKey>(props: {
|
||||||
|
initialPageParam?: number
|
||||||
|
key: TKey
|
||||||
|
getRequestParams: (key: TKey) => [Record<string, unknown>, Record<string, unknown>]
|
||||||
|
afterFetchData: (response: TResponse) => void
|
||||||
|
afterFetchNextPage: (response: TResponse) => void
|
||||||
|
}) {
|
||||||
|
const { key, getRequestParams, afterFetchData, afterFetchNextPage } = props
|
||||||
|
const [endpoint] = key
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
// During the cleanup phase, make sure only the first page of results
|
||||||
|
// is cached under any `queryKey` containing this endpoint.
|
||||||
|
useEffect(() => {
|
||||||
|
const queryKeyToClean = [endpoint] as QueryFilters
|
||||||
|
return () => {
|
||||||
|
queryClient.setQueriesData<{pages: TResponse[], pageParams: unknown[]}>(queryKeyToClean, (data) => {
|
||||||
|
if (data?.pages?.length) {
|
||||||
|
return {
|
||||||
|
pages: data.pages.slice(0, 1),
|
||||||
|
pageParams: data.pageParams.slice(0, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [queryClient, endpoint])
|
||||||
|
|
||||||
|
const defaultInitialPageParam = 1
|
||||||
|
const initialPageParam =
|
||||||
|
props.initialPageParam === undefined
|
||||||
|
? defaultInitialPageParam
|
||||||
|
: props.initialPageParam
|
||||||
|
|
||||||
|
return useInfiniteQuery({
|
||||||
|
queryKey: key,
|
||||||
|
queryFn: async ({ pageParam, queryKey }) => {
|
||||||
|
const [query, params] = getRequestParams(queryKey)
|
||||||
|
params.limit = LIMIT
|
||||||
|
params.page = pageParam
|
||||||
|
|
||||||
|
const response = await api.get(endpoint, query, params)
|
||||||
|
|
||||||
|
if (pageParam === 1 && typeof afterFetchData === 'function') {
|
||||||
|
afterFetchData(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageParam > 1 && typeof afterFetchNextPage === 'function') {
|
||||||
|
afterFetchNextPage(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.results
|
||||||
|
},
|
||||||
|
getNextPageParam: (lastPageResults, _, lastPageIndex) => {
|
||||||
|
return lastPageResults.length === LIMIT ? lastPageIndex + 1 : null
|
||||||
|
},
|
||||||
|
initialPageParam,
|
||||||
|
placeholderData: (previousData) => previousData
|
||||||
|
})
|
||||||
|
}
|
@ -8,26 +8,30 @@ export enum SortDirection {
|
|||||||
desc = 'desc'
|
desc = 'desc'
|
||||||
}
|
}
|
||||||
|
|
||||||
type Order = [Metric['key'], SortDirection]
|
export type Order = [Metric['key'], SortDirection]
|
||||||
|
|
||||||
export type OrderBy = Order[]
|
export type OrderBy = Order[]
|
||||||
|
|
||||||
export const getSortDirectionIndicator = (
|
|
||||||
sortDirection: SortDirection
|
|
||||||
): string =>
|
|
||||||
({ [SortDirection.asc]: '↑', [SortDirection.desc]: '↓' })[sortDirection]
|
|
||||||
|
|
||||||
export const getSortDirectionLabel = (sortDirection: SortDirection): string =>
|
export const getSortDirectionLabel = (sortDirection: SortDirection): string =>
|
||||||
({
|
({
|
||||||
[SortDirection.asc]: 'Sorted in ascending order',
|
[SortDirection.asc]: 'Sorted in ascending order',
|
||||||
[SortDirection.desc]: 'Sorted in descending order'
|
[SortDirection.desc]: 'Sorted in descending order'
|
||||||
})[sortDirection]
|
})[sortDirection]
|
||||||
|
|
||||||
export function useOrderBy({ metrics }: { metrics: Metric[] }) {
|
export function useOrderBy({
|
||||||
|
metrics,
|
||||||
|
defaultOrderBy
|
||||||
|
}: {
|
||||||
|
metrics: Metric[]
|
||||||
|
defaultOrderBy: OrderBy
|
||||||
|
}) {
|
||||||
const [orderBy, setOrderBy] = useState<OrderBy>([])
|
const [orderBy, setOrderBy] = useState<OrderBy>([])
|
||||||
const orderByDictionary = useMemo(
|
const orderByDictionary = useMemo(
|
||||||
() => Object.fromEntries(orderBy),
|
() =>
|
||||||
[orderBy]
|
orderBy.length
|
||||||
|
? Object.fromEntries(orderBy)
|
||||||
|
: Object.fromEntries(defaultOrderBy),
|
||||||
|
[orderBy, defaultOrderBy]
|
||||||
)
|
)
|
||||||
|
|
||||||
const toggleSortByMetric = useCallback(
|
const toggleSortByMetric = useCallback(
|
||||||
@ -35,19 +39,24 @@ export function useOrderBy({ metrics }: { metrics: Metric[] }) {
|
|||||||
if (!metrics.find(({ key }) => key === metric.key)) {
|
if (!metrics.find(({ key }) => key === metric.key)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setOrderBy((currentOrderBy) => rearrangeOrderBy(currentOrderBy, metric))
|
setOrderBy((currentOrderBy) => rearrangeOrderBy(currentOrderBy.length ? currentOrderBy : defaultOrderBy, metric))
|
||||||
},
|
},
|
||||||
[metrics]
|
[metrics, defaultOrderBy]
|
||||||
)
|
)
|
||||||
|
|
||||||
return { orderBy, orderByDictionary, toggleSortByMetric }
|
return {
|
||||||
|
orderBy: orderBy.length ? orderBy : defaultOrderBy,
|
||||||
|
orderByDictionary,
|
||||||
|
toggleSortByMetric
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cycleSortDirection(
|
export function cycleSortDirection(
|
||||||
currentSortDirection: SortDirection | null
|
currentSortDirection: SortDirection | null
|
||||||
): { direction: SortDirection | null; hint: string } {
|
): { direction: SortDirection; hint: string } {
|
||||||
switch (currentSortDirection) {
|
switch (currentSortDirection) {
|
||||||
case null:
|
case null:
|
||||||
|
case SortDirection.asc:
|
||||||
return {
|
return {
|
||||||
direction: SortDirection.desc,
|
direction: SortDirection.desc,
|
||||||
hint: 'Press to sort column in descending order'
|
hint: 'Press to sort column in descending order'
|
||||||
@ -57,8 +66,8 @@ export function cycleSortDirection(
|
|||||||
direction: SortDirection.asc,
|
direction: SortDirection.asc,
|
||||||
hint: 'Press to sort column in ascending order'
|
hint: 'Press to sort column in ascending order'
|
||||||
}
|
}
|
||||||
case SortDirection.asc:
|
// case SortDirection.asc:
|
||||||
return { direction: null, hint: 'Press to remove sorting from column' }
|
// return { direction: SortDirection, hint: 'Press to remove sorting from column' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,14 +1,22 @@
|
|||||||
/** @format */
|
/** @format */
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef } from 'react'
|
import React, { useState, useEffect, useRef, ReactNode } from 'react'
|
||||||
|
|
||||||
import { FilterLink } from '../reports/list'
|
import { FilterLink } from '../reports/list'
|
||||||
import { useQueryContext } from '../../query-context'
|
import { useQueryContext } from '../../query-context'
|
||||||
import { useDebounce } from '../../custom-hooks'
|
import { useDebounce } from '../../custom-hooks'
|
||||||
import { useAPIClient } from '../../hooks/api-client'
|
import { useAPIClient } from '../../hooks/api-client'
|
||||||
import { rootRoute } from '../../router'
|
import { rootRoute } from '../../router'
|
||||||
import { cycleSortDirection, useOrderBy } from '../../hooks/use-order-by'
|
import {
|
||||||
|
cycleSortDirection,
|
||||||
|
Order,
|
||||||
|
OrderBy,
|
||||||
|
useOrderBy
|
||||||
|
} from '../../hooks/use-order-by'
|
||||||
import { SortButton } from '../../components/sort-button'
|
import { SortButton } from '../../components/sort-button'
|
||||||
|
import { Metric } from '../reports/metrics'
|
||||||
|
import { DashboardQuery } from '../../query'
|
||||||
|
import classNames from 'classnames'
|
||||||
|
|
||||||
export const MIN_HEIGHT_PX = 500
|
export const MIN_HEIGHT_PX = 500
|
||||||
|
|
||||||
@ -76,7 +84,7 @@ export const MIN_HEIGHT_PX = 500
|
|||||||
|
|
||||||
// * `afterFetchNextPage` - a function with the same behaviour as `afterFetchData`,
|
// * `afterFetchNextPage` - a function with the same behaviour as `afterFetchData`,
|
||||||
// but will be called after a successful next page load in `fetchNextPage`.
|
// but will be called after a successful next page load in `fetchNextPage`.
|
||||||
export default function BreakdownModal({
|
export default function BreakdownModal<TListItem extends {name: string}>({
|
||||||
reportInfo,
|
reportInfo,
|
||||||
metrics,
|
metrics,
|
||||||
renderIcon,
|
renderIcon,
|
||||||
@ -86,13 +94,29 @@ export default function BreakdownModal({
|
|||||||
afterFetchNextPage,
|
afterFetchNextPage,
|
||||||
addSearchFilter,
|
addSearchFilter,
|
||||||
getFilterInfo
|
getFilterInfo
|
||||||
|
}: {
|
||||||
|
reportInfo: {
|
||||||
|
title: string
|
||||||
|
endpoint: string
|
||||||
|
dimensionLabel: string
|
||||||
|
defaultOrderBy: Order
|
||||||
|
}
|
||||||
|
metrics: Metric[]
|
||||||
|
renderIcon: (listItem: TListItem) => ReactNode
|
||||||
|
getExternalLinkURL: (listItem: TListItem) => string
|
||||||
|
searchEnabled?: boolean
|
||||||
|
afterFetchData: () => void
|
||||||
|
afterFetchNextPage: () => void
|
||||||
|
addSearchFilter: (q: DashboardQuery, search: string) => Record<string, unknown>
|
||||||
|
getFilterInfo: (listItem: TListItem) => void
|
||||||
}) {
|
}) {
|
||||||
const searchBoxRef = useRef(null)
|
const searchBoxRef = useRef<HTMLInputElement>(null)
|
||||||
const { query } = useQueryContext()
|
const { query } = useQueryContext()
|
||||||
|
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const { orderBy, orderByDictionary, toggleSortByMetric } = useOrderBy({
|
const { orderBy, orderByDictionary, toggleSortByMetric } = useOrderBy({
|
||||||
metrics
|
metrics,
|
||||||
|
defaultOrderBy: [reportInfo.defaultOrderBy]
|
||||||
})
|
})
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -102,12 +126,15 @@ export default function BreakdownModal({
|
|||||||
isFetchingNextPage,
|
isFetchingNextPage,
|
||||||
isFetching,
|
isFetching,
|
||||||
isPending
|
isPending
|
||||||
} = useAPIClient({
|
} = useAPIClient<
|
||||||
|
never,
|
||||||
|
[string, { query: DashboardQuery; search: string; orderBy: OrderBy }]
|
||||||
|
>({
|
||||||
key: [reportInfo.endpoint, { query, search, orderBy }],
|
key: [reportInfo.endpoint, { query, search, orderBy }],
|
||||||
getRequestParams: (key) => {
|
getRequestParams: (key) => {
|
||||||
const [_endpoint, { query, search }] = key
|
const [_endpoint, { query, search }] = key
|
||||||
|
|
||||||
let queryWithSearchFilter = { ...query }
|
let queryWithSearchFilter: Record<string, unknown> = { ...query }
|
||||||
|
|
||||||
if (searchEnabled && search !== '') {
|
if (searchEnabled && search !== '') {
|
||||||
queryWithSearchFilter = addSearchFilter(query, search)
|
queryWithSearchFilter = addSearchFilter(query, search)
|
||||||
@ -115,7 +142,10 @@ export default function BreakdownModal({
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
queryWithSearchFilter,
|
queryWithSearchFilter,
|
||||||
{ detailed: true, order_by: JSON.stringify(orderBy) }
|
{
|
||||||
|
detailed: true,
|
||||||
|
order_by: JSON.stringify(orderBy)
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
afterFetchData,
|
afterFetchData,
|
||||||
@ -123,33 +153,32 @@ export default function BreakdownModal({
|
|||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!searchEnabled) {
|
const searchBox = searchBoxRef.current
|
||||||
|
if (!searchEnabled && searchBox) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchBox = searchBoxRef.current
|
const handleKeyUp = (event: KeyboardEvent) => {
|
||||||
|
|
||||||
const handleKeyUp = (event) => {
|
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
event.target.blur()
|
;(event.target as HTMLElement | undefined)?.blur()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
searchBox.addEventListener('keyup', handleKeyUp)
|
searchBox?.addEventListener('keyup', handleKeyUp)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
searchBox.removeEventListener('keyup', handleKeyUp)
|
searchBox?.removeEventListener('keyup', handleKeyUp)
|
||||||
}
|
}
|
||||||
}, [searchEnabled])
|
}, [searchEnabled])
|
||||||
|
|
||||||
function maybeRenderIcon(item) {
|
function maybeRenderIcon(item: TListItem) {
|
||||||
if (typeof renderIcon === 'function') {
|
if (typeof renderIcon === 'function') {
|
||||||
return renderIcon(item)
|
return renderIcon(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function maybeRenderExternalLink(item) {
|
function maybeRenderExternalLink(item: TListItem) {
|
||||||
if (typeof getExternalLinkURL === 'function') {
|
if (typeof getExternalLinkURL === 'function') {
|
||||||
const linkUrl = getExternalLinkURL(item)
|
const linkUrl = getExternalLinkURL(item)
|
||||||
|
|
||||||
@ -177,20 +206,20 @@ export default function BreakdownModal({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderRow(item) {
|
function renderRow(item: TListItem) {
|
||||||
return (
|
return (
|
||||||
<tr className="text-sm dark:text-gray-200" key={item.name}>
|
<tr className="text-sm dark:text-gray-200" key={item.name}>
|
||||||
<td className="w-48 md:w-80 break-all p-2 flex items-center">
|
<td className="w-48 md:w-80 break-all p-2 flex items-center">
|
||||||
{maybeRenderIcon(item)}
|
{maybeRenderIcon(item)}
|
||||||
<FilterLink path={rootRoute.path} filterInfo={getFilterInfo(item)}>
|
<FilterLink path={rootRoute.path} filterInfo={getFilterInfo(item)} onClick={undefined} extraClass={undefined}>
|
||||||
{item.name}
|
{item.name}
|
||||||
</FilterLink>
|
</FilterLink>
|
||||||
{maybeRenderExternalLink(item)}
|
{maybeRenderExternalLink(item)}
|
||||||
</td>
|
</td>
|
||||||
{metrics.map((metric) => {
|
{metrics.map((metric) => {
|
||||||
return (
|
return (
|
||||||
<td key={metric.key} className="p-2 w-24 font-medium" align="right">
|
<td key={metric.key} className={classNames(metric.width, "p-2 font-medium")} align="left">
|
||||||
{metric.renderValue(item[metric.key])}
|
{metric.renderValue(item[metric.key as keyof TListItem])}
|
||||||
</td>
|
</td>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@ -226,7 +255,7 @@ export default function BreakdownModal({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-full my-4 items-center justify-center h-10">
|
<div className="flex flex-col w-full my-4 items-center justify-center h-10">
|
||||||
{!isFetching && (
|
{!isFetching && (
|
||||||
<button onClick={fetchNextPage} type="button" className="button">
|
<button onClick={() => fetchNextPage()} type="button" className="button">
|
||||||
Load more
|
Load more
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@ -235,8 +264,12 @@ export default function BreakdownModal({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleInputChange(e) {
|
function handleInputChange(e: Event) {
|
||||||
setSearch(e.target.value)
|
const element = e.target as HTMLInputElement | null;
|
||||||
|
if (!element) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSearch(element.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const debouncedHandleInputChange = useDebounce(handleInputChange)
|
const debouncedHandleInputChange = useDebounce(handleInputChange)
|
||||||
@ -261,7 +294,7 @@ export default function BreakdownModal({
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th
|
<th
|
||||||
className="p-2 w-48 md:w-80 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400"
|
className="p-2 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400"
|
||||||
align="left"
|
align="left"
|
||||||
>
|
>
|
||||||
{reportInfo.dimensionLabel}
|
{reportInfo.dimensionLabel}
|
||||||
@ -271,12 +304,15 @@ export default function BreakdownModal({
|
|||||||
return (
|
return (
|
||||||
<th
|
<th
|
||||||
key={metric.key}
|
key={metric.key}
|
||||||
className="p-2 w-24 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400"
|
className={classNames(metric.width, "p-2 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400")}
|
||||||
align="right"
|
align="left"
|
||||||
>
|
>
|
||||||
{metric.sortable ? (
|
{metric.sortable ? (
|
||||||
<SortButton
|
<SortButton
|
||||||
sortDirection={orderByDictionary[metric.key] ?? null}
|
sortDirection={orderByDictionary[metric.key] ?? null}
|
||||||
|
nextSortDirection={cycleSortDirection(
|
||||||
|
orderByDictionary[metric.key] ?? null
|
||||||
|
).direction!}
|
||||||
toggleSort={() => toggleSortByMetric(metric)}
|
toggleSort={() => toggleSortByMetric(metric)}
|
||||||
hint={
|
hint={
|
||||||
cycleSortDirection(
|
cycleSortDirection(
|
@ -16,7 +16,8 @@ function ConversionsModal() {
|
|||||||
title: 'Goal Conversions',
|
title: 'Goal Conversions',
|
||||||
dimension: 'goal',
|
dimension: 'goal',
|
||||||
endpoint: url.apiPath(site, '/conversions'),
|
endpoint: url.apiPath(site, '/conversions'),
|
||||||
dimensionLabel: "Goal"
|
dimensionLabel: "Goal",
|
||||||
|
defaultOrderBy: []
|
||||||
}
|
}
|
||||||
|
|
||||||
const getFilterInfo = useCallback((listItem) => {
|
const getFilterInfo = useCallback((listItem) => {
|
||||||
|
@ -7,6 +7,7 @@ import { useQueryContext } from "../../../query-context";
|
|||||||
import { useSiteContext } from "../../../site-context";
|
import { useSiteContext } from "../../../site-context";
|
||||||
import { browserIconFor } from "../../devices";
|
import { browserIconFor } from "../../devices";
|
||||||
import chooseMetrics from './choose-metrics';
|
import chooseMetrics from './choose-metrics';
|
||||||
|
import { SortDirection } from "../../../hooks/use-order-by";
|
||||||
|
|
||||||
function BrowserVersionsModal() {
|
function BrowserVersionsModal() {
|
||||||
const { query } = useQueryContext();
|
const { query } = useQueryContext();
|
||||||
@ -16,7 +17,8 @@ function BrowserVersionsModal() {
|
|||||||
title: 'Browser Versions',
|
title: 'Browser Versions',
|
||||||
dimension: 'browser_version',
|
dimension: 'browser_version',
|
||||||
endpoint: url.apiPath(site, '/browser-versions'),
|
endpoint: url.apiPath(site, '/browser-versions'),
|
||||||
dimensionLabel: 'Browser version'
|
dimensionLabel: 'Browser version',
|
||||||
|
defaultOrderBy: ["visitors", SortDirection.desc]
|
||||||
}
|
}
|
||||||
|
|
||||||
const getFilterInfo = useCallback((listItem) => {
|
const getFilterInfo = useCallback((listItem) => {
|
||||||
|
@ -7,6 +7,7 @@ import { useQueryContext } from "../../../query-context";
|
|||||||
import { useSiteContext } from "../../../site-context";
|
import { useSiteContext } from "../../../site-context";
|
||||||
import { browserIconFor } from "../../devices";
|
import { browserIconFor } from "../../devices";
|
||||||
import chooseMetrics from './choose-metrics';
|
import chooseMetrics from './choose-metrics';
|
||||||
|
import { SortDirection } from "../../../hooks/use-order-by";
|
||||||
|
|
||||||
function BrowsersModal() {
|
function BrowsersModal() {
|
||||||
const { query } = useQueryContext();
|
const { query } = useQueryContext();
|
||||||
@ -16,7 +17,8 @@ function BrowsersModal() {
|
|||||||
title: 'Browsers',
|
title: 'Browsers',
|
||||||
dimension: 'browser',
|
dimension: 'browser',
|
||||||
endpoint: url.apiPath(site, '/browsers'),
|
endpoint: url.apiPath(site, '/browsers'),
|
||||||
dimensionLabel: 'Browser'
|
dimensionLabel: 'Browser',
|
||||||
|
defaultOrderBy: ["visitors", SortDirection.desc]
|
||||||
}
|
}
|
||||||
|
|
||||||
const getFilterInfo = useCallback((listItem) => {
|
const getFilterInfo = useCallback((listItem) => {
|
||||||
|
@ -5,14 +5,14 @@ export default function chooseMetrics(query) {
|
|||||||
if (hasGoalFilter(query)) {
|
if (hasGoalFilter(query)) {
|
||||||
return [
|
return [
|
||||||
metrics.createTotalVisitors(),
|
metrics.createTotalVisitors(),
|
||||||
metrics.createVisitors({ renderLabel: (_query) => 'Conversions' }),
|
metrics.createVisitors({ renderLabel: (_query) => 'Conversions', width: 'w-32' }),
|
||||||
metrics.createConversionRate()
|
metrics.createConversionRate()
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isRealTimeDashboard(query)) {
|
if (isRealTimeDashboard(query)) {
|
||||||
return [
|
return [
|
||||||
metrics.createVisitors({ renderLabel: (_query) => 'Current visitors' }),
|
metrics.createVisitors({ renderLabel: (_query) => 'Current visitors', width: 'w-36' }),
|
||||||
metrics.createPercentage()
|
metrics.createPercentage()
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -7,6 +7,7 @@ import { useQueryContext } from "../../../query-context";
|
|||||||
import { useSiteContext } from "../../../site-context";
|
import { useSiteContext } from "../../../site-context";
|
||||||
import { osIconFor } from "../../devices";
|
import { osIconFor } from "../../devices";
|
||||||
import chooseMetrics from './choose-metrics';
|
import chooseMetrics from './choose-metrics';
|
||||||
|
import { SortDirection } from "../../../hooks/use-order-by";
|
||||||
|
|
||||||
function OperatingSystemVersionsModal() {
|
function OperatingSystemVersionsModal() {
|
||||||
const { query } = useQueryContext();
|
const { query } = useQueryContext();
|
||||||
@ -16,7 +17,8 @@ function OperatingSystemVersionsModal() {
|
|||||||
title: 'Operating System Versions',
|
title: 'Operating System Versions',
|
||||||
dimension: 'os_version',
|
dimension: 'os_version',
|
||||||
endpoint: url.apiPath(site, '/operating-system-versions'),
|
endpoint: url.apiPath(site, '/operating-system-versions'),
|
||||||
dimensionLabel: 'Operating system version'
|
dimensionLabel: 'Operating system version',
|
||||||
|
defaultOrderBy: ["visitors", SortDirection.desc]
|
||||||
}
|
}
|
||||||
|
|
||||||
const getFilterInfo = useCallback((listItem) => {
|
const getFilterInfo = useCallback((listItem) => {
|
||||||
|
@ -7,6 +7,7 @@ import { useQueryContext } from "../../../query-context";
|
|||||||
import { useSiteContext } from "../../../site-context";
|
import { useSiteContext } from "../../../site-context";
|
||||||
import { osIconFor } from "../../devices";
|
import { osIconFor } from "../../devices";
|
||||||
import chooseMetrics from './choose-metrics';
|
import chooseMetrics from './choose-metrics';
|
||||||
|
import { SortDirection } from "../../../hooks/use-order-by";
|
||||||
|
|
||||||
function OperatingSystemsModal() {
|
function OperatingSystemsModal() {
|
||||||
const { query } = useQueryContext();
|
const { query } = useQueryContext();
|
||||||
@ -16,7 +17,8 @@ function OperatingSystemsModal() {
|
|||||||
title: 'Operating Systems',
|
title: 'Operating Systems',
|
||||||
dimension: 'os',
|
dimension: 'os',
|
||||||
endpoint: url.apiPath(site, '/operating-systems'),
|
endpoint: url.apiPath(site, '/operating-systems'),
|
||||||
dimensionLabel: 'Operating system'
|
dimensionLabel: 'Operating system',
|
||||||
|
defaultOrderBy: ["visitors", SortDirection.desc]
|
||||||
}
|
}
|
||||||
|
|
||||||
const getFilterInfo = useCallback((listItem) => {
|
const getFilterInfo = useCallback((listItem) => {
|
||||||
|
@ -6,6 +6,7 @@ import { useQueryContext } from "../../../query-context";
|
|||||||
import { useSiteContext } from "../../../site-context";
|
import { useSiteContext } from "../../../site-context";
|
||||||
import { screenSizeIconFor } from "../../devices";
|
import { screenSizeIconFor } from "../../devices";
|
||||||
import chooseMetrics from './choose-metrics';
|
import chooseMetrics from './choose-metrics';
|
||||||
|
import { SortDirection } from "../../../hooks/use-order-by";
|
||||||
|
|
||||||
function ScreenSizesModal() {
|
function ScreenSizesModal() {
|
||||||
const { query } = useQueryContext();
|
const { query } = useQueryContext();
|
||||||
@ -15,7 +16,8 @@ function ScreenSizesModal() {
|
|||||||
title: 'Screen Sizes',
|
title: 'Screen Sizes',
|
||||||
dimension: 'screen',
|
dimension: 'screen',
|
||||||
endpoint: url.apiPath(site, '/screen-sizes'),
|
endpoint: url.apiPath(site, '/screen-sizes'),
|
||||||
dimensionLabel: 'Screen size'
|
dimensionLabel: 'Screen size',
|
||||||
|
defaultOrderBy: ["visitors", SortDirection.desc]
|
||||||
}
|
}
|
||||||
|
|
||||||
const getFilterInfo = useCallback((listItem) => {
|
const getFilterInfo = useCallback((listItem) => {
|
||||||
|
@ -7,6 +7,7 @@ import * as metrics from '../reports/metrics'
|
|||||||
import * as url from '../../util/url';
|
import * as url from '../../util/url';
|
||||||
import { useQueryContext } from "../../query-context";
|
import { useQueryContext } from "../../query-context";
|
||||||
import { useSiteContext } from "../../site-context";
|
import { useSiteContext } from "../../site-context";
|
||||||
|
import { SortDirection } from "../../hooks/use-order-by";
|
||||||
|
|
||||||
function EntryPagesModal() {
|
function EntryPagesModal() {
|
||||||
const { query } = useQueryContext();
|
const { query } = useQueryContext();
|
||||||
@ -16,7 +17,8 @@ function EntryPagesModal() {
|
|||||||
title: 'Entry Pages',
|
title: 'Entry Pages',
|
||||||
dimension: 'entry_page',
|
dimension: 'entry_page',
|
||||||
endpoint: url.apiPath(site, '/entry-pages'),
|
endpoint: url.apiPath(site, '/entry-pages'),
|
||||||
dimensionLabel: 'Entry page'
|
dimensionLabel: 'Entry page',
|
||||||
|
defaultOrderBy: ["visitors", SortDirection.desc]
|
||||||
}
|
}
|
||||||
|
|
||||||
const getFilterInfo = useCallback((listItem) => {
|
const getFilterInfo = useCallback((listItem) => {
|
||||||
@ -34,20 +36,20 @@ function EntryPagesModal() {
|
|||||||
if (hasGoalFilter(query)) {
|
if (hasGoalFilter(query)) {
|
||||||
return [
|
return [
|
||||||
metrics.createTotalVisitors(),
|
metrics.createTotalVisitors(),
|
||||||
metrics.createVisitors({ renderLabel: (_query) => 'Conversions' }),
|
metrics.createVisitors({ renderLabel: (_query) => 'Conversions', width: 'w-32' }),
|
||||||
metrics.createConversionRate()
|
metrics.createConversionRate()
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isRealTimeDashboard(query)) {
|
if (isRealTimeDashboard(query)) {
|
||||||
return [
|
return [
|
||||||
metrics.createVisitors({ renderLabel: (_query) => 'Current visitors' })
|
metrics.createVisitors({ renderLabel: (_query) => 'Current visitors', width: 'w-36' })
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
metrics.createVisitors({ renderLabel: (_query) => "Visitors" }),
|
metrics.createVisitors({ renderLabel: (_query) => "Visitors" }),
|
||||||
metrics.createVisits({ renderLabel: (_query) => "Total Entrances" }),
|
metrics.createVisits({ renderLabel: (_query) => "Total Entrances", width: 'w-36' }),
|
||||||
metrics.createVisitDuration()
|
metrics.createVisitDuration()
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -7,6 +7,7 @@ import * as metrics from '../reports/metrics'
|
|||||||
import * as url from '../../util/url';
|
import * as url from '../../util/url';
|
||||||
import { useQueryContext } from "../../query-context";
|
import { useQueryContext } from "../../query-context";
|
||||||
import { useSiteContext } from "../../site-context";
|
import { useSiteContext } from "../../site-context";
|
||||||
|
import { SortDirection } from "../../hooks/use-order-by";
|
||||||
|
|
||||||
function ExitPagesModal() {
|
function ExitPagesModal() {
|
||||||
const { query } = useQueryContext();
|
const { query } = useQueryContext();
|
||||||
@ -16,7 +17,8 @@ function ExitPagesModal() {
|
|||||||
title: 'Exit Pages',
|
title: 'Exit Pages',
|
||||||
dimension: 'exit_page',
|
dimension: 'exit_page',
|
||||||
endpoint: url.apiPath(site, '/exit-pages'),
|
endpoint: url.apiPath(site, '/exit-pages'),
|
||||||
dimensionLabel: 'Page url'
|
dimensionLabel: 'Page url',
|
||||||
|
defaultOrderBy: []
|
||||||
}
|
}
|
||||||
|
|
||||||
const getFilterInfo = useCallback((listItem) => {
|
const getFilterInfo = useCallback((listItem) => {
|
||||||
@ -34,14 +36,14 @@ function ExitPagesModal() {
|
|||||||
if (hasGoalFilter(query)) {
|
if (hasGoalFilter(query)) {
|
||||||
return [
|
return [
|
||||||
metrics.createTotalVisitors(),
|
metrics.createTotalVisitors(),
|
||||||
metrics.createVisitors({ renderLabel: (_query) => 'Conversions' }),
|
metrics.createVisitors({ renderLabel: (_query) => 'Conversions', width: 'w-32' }),
|
||||||
metrics.createConversionRate()
|
metrics.createConversionRate()
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.period === 'realtime') {
|
if (query.period === 'realtime') {
|
||||||
return [
|
return [
|
||||||
metrics.createVisitors({ renderLabel: (_query) => 'Current visitors' })
|
metrics.createVisitors({ renderLabel: (_query) => 'Current visitors', width: 'w-36' })
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -21,9 +21,9 @@ function GoogleKeywordsModal() {
|
|||||||
|
|
||||||
const metrics = [
|
const metrics = [
|
||||||
createVisitors({renderLabel: (_query) => 'Visitors'}),
|
createVisitors({renderLabel: (_query) => 'Visitors'}),
|
||||||
new Metric({key: 'impressions', renderLabel: (_query) => 'Impressions', renderValue: renderNumberWithTooltip}),
|
new Metric({width: 'w-28', key: 'impressions', renderLabel: (_query) => 'Impressions', renderValue: renderNumberWithTooltip, sortable: false}),
|
||||||
new Metric({key: 'ctr', renderLabel: (_query) => 'CTR', renderValue: percentageFormatter}),
|
new Metric({width: 'w-16', key: 'ctr', renderLabel: (_query) => 'CTR', renderValue: percentageFormatter, sortable: false}),
|
||||||
new Metric({key: 'position', renderLabel: (_query) => 'Position', renderValue: numberFormatter})
|
new Metric({width: 'w-28', key: 'position', renderLabel: (_query) => 'Position', renderValue: numberFormatter, sortable: false})
|
||||||
]
|
]
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
@ -8,11 +8,12 @@ import * as url from "../../util/url";
|
|||||||
import { useQueryContext } from "../../query-context";
|
import { useQueryContext } from "../../query-context";
|
||||||
import { useSiteContext } from "../../site-context";
|
import { useSiteContext } from "../../site-context";
|
||||||
import { addFilter } from "../../query";
|
import { addFilter } from "../../query";
|
||||||
|
import { SortDirection } from "../../hooks/use-order-by";
|
||||||
|
|
||||||
const VIEWS = {
|
const VIEWS = {
|
||||||
countries: { title: 'Top Countries', dimension: 'country', endpoint: '/countries', dimensionLabel: 'Country' },
|
countries: { title: 'Top Countries', dimension: 'country', endpoint: '/countries', dimensionLabel: 'Country', defaultOrderBy: ["visitors", SortDirection.desc] },
|
||||||
regions: { title: 'Top Regions', dimension: 'region', endpoint: '/regions', dimensionLabel: 'Region' },
|
regions: { title: 'Top Regions', dimension: 'region', endpoint: '/regions', dimensionLabel: 'Region', defaultOrderBy: ["visitors", SortDirection.desc] },
|
||||||
cities: { title: 'Top Cities', dimension: 'city', endpoint: '/cities', dimensionLabel: 'City' },
|
cities: { title: 'Top Cities', dimension: 'city', endpoint: '/cities', dimensionLabel: 'City', defaultOrderBy: ["visitors", SortDirection.desc] },
|
||||||
}
|
}
|
||||||
|
|
||||||
function LocationsModal({ currentView }) {
|
function LocationsModal({ currentView }) {
|
||||||
@ -38,14 +39,14 @@ function LocationsModal({ currentView }) {
|
|||||||
if (hasGoalFilter(query)) {
|
if (hasGoalFilter(query)) {
|
||||||
return [
|
return [
|
||||||
metrics.createTotalVisitors(),
|
metrics.createTotalVisitors(),
|
||||||
metrics.createVisitors({ renderLabel: (_query) => 'Conversions' }),
|
metrics.createVisitors({ renderLabel: (_query) => 'Conversions', width: 'w-32' }),
|
||||||
metrics.createConversionRate()
|
metrics.createConversionRate()
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query.period === 'realtime') {
|
if (query.period === 'realtime') {
|
||||||
return [
|
return [
|
||||||
metrics.createVisitors({ renderLabel: (_query) => 'Current visitors' })
|
metrics.createVisitors({ renderLabel: (_query) => 'Current visitors', width: 'w-36' })
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -7,6 +7,7 @@ import * as metrics from '../reports/metrics'
|
|||||||
import * as url from '../../util/url';
|
import * as url from '../../util/url';
|
||||||
import { useQueryContext } from "../../query-context";
|
import { useQueryContext } from "../../query-context";
|
||||||
import { useSiteContext } from "../../site-context";
|
import { useSiteContext } from "../../site-context";
|
||||||
|
import { SortDirection } from "../../hooks/use-order-by";
|
||||||
|
|
||||||
function PagesModal() {
|
function PagesModal() {
|
||||||
const { query } = useQueryContext();
|
const { query } = useQueryContext();
|
||||||
@ -16,7 +17,8 @@ function PagesModal() {
|
|||||||
title: 'Top Pages',
|
title: 'Top Pages',
|
||||||
dimension: 'page',
|
dimension: 'page',
|
||||||
endpoint: url.apiPath(site, '/pages'),
|
endpoint: url.apiPath(site, '/pages'),
|
||||||
dimensionLabel: 'Page url'
|
dimensionLabel: 'Page url',
|
||||||
|
defaultOrderBy: ["visitors", SortDirection.desc]
|
||||||
}
|
}
|
||||||
|
|
||||||
const getFilterInfo = useCallback((listItem) => {
|
const getFilterInfo = useCallback((listItem) => {
|
||||||
@ -34,14 +36,14 @@ function PagesModal() {
|
|||||||
if (hasGoalFilter(query)) {
|
if (hasGoalFilter(query)) {
|
||||||
return [
|
return [
|
||||||
metrics.createTotalVisitors(),
|
metrics.createTotalVisitors(),
|
||||||
metrics.createVisitors({renderLabel: (_query) => 'Conversions'}),
|
metrics.createVisitors({renderLabel: (_query) => 'Conversions', width: 'w-32'}),
|
||||||
metrics.createConversionRate()
|
metrics.createConversionRate()
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isRealTimeDashboard(query)) {
|
if (isRealTimeDashboard(query)) {
|
||||||
return [
|
return [
|
||||||
metrics.createVisitors({renderLabel: (_query) => 'Current visitors'})
|
metrics.createVisitors({renderLabel: (_query) => 'Current visitors', width: 'w-36'})
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -10,6 +10,7 @@ import * as metrics from "../reports/metrics";
|
|||||||
import * as url from "../../util/url";
|
import * as url from "../../util/url";
|
||||||
import { useQueryContext } from "../../query-context";
|
import { useQueryContext } from "../../query-context";
|
||||||
import { useSiteContext } from "../../site-context";
|
import { useSiteContext } from "../../site-context";
|
||||||
|
import { SortDirection } from "../../hooks/use-order-by";
|
||||||
|
|
||||||
function PropsModal() {
|
function PropsModal() {
|
||||||
const { query } = useQueryContext();
|
const { query } = useQueryContext();
|
||||||
@ -23,7 +24,8 @@ function PropsModal() {
|
|||||||
title: specialTitleWhenGoalFilter(query, 'Custom Property Breakdown'),
|
title: specialTitleWhenGoalFilter(query, 'Custom Property Breakdown'),
|
||||||
dimension: propKey,
|
dimension: propKey,
|
||||||
endpoint: url.apiPath(site, `/custom-prop-values/${propKey}`),
|
endpoint: url.apiPath(site, `/custom-prop-values/${propKey}`),
|
||||||
dimensionLabel: propKey
|
dimensionLabel: propKey,
|
||||||
|
defaultOrderBy: ["visitors", SortDirection.desc]
|
||||||
}
|
}
|
||||||
|
|
||||||
const getFilterInfo = useCallback((listItem) => {
|
const getFilterInfo = useCallback((listItem) => {
|
||||||
|
@ -9,6 +9,7 @@ import * as url from "../../util/url";
|
|||||||
import { addFilter } from "../../query";
|
import { addFilter } from "../../query";
|
||||||
import { useQueryContext } from "../../query-context";
|
import { useQueryContext } from "../../query-context";
|
||||||
import { useSiteContext } from "../../site-context";
|
import { useSiteContext } from "../../site-context";
|
||||||
|
import { SortDirection } from '../../hooks/use-order-by';
|
||||||
|
|
||||||
function ReferrerDrilldownModal() {
|
function ReferrerDrilldownModal() {
|
||||||
const { referrer } = useParams();
|
const { referrer } = useParams();
|
||||||
@ -19,7 +20,8 @@ function ReferrerDrilldownModal() {
|
|||||||
title: "Referrer Drilldown",
|
title: "Referrer Drilldown",
|
||||||
dimension: 'referrer',
|
dimension: 'referrer',
|
||||||
endpoint: url.apiPath(site, `/referrers/${referrer}`),
|
endpoint: url.apiPath(site, `/referrers/${referrer}`),
|
||||||
dimensionLabel: "Referrer"
|
dimensionLabel: "Referrer",
|
||||||
|
defaultOrderBy: ["visitors", SortDirection.desc]
|
||||||
}
|
}
|
||||||
|
|
||||||
const getFilterInfo = useCallback((listItem) => {
|
const getFilterInfo = useCallback((listItem) => {
|
||||||
@ -37,14 +39,14 @@ function ReferrerDrilldownModal() {
|
|||||||
if (hasGoalFilter(query)) {
|
if (hasGoalFilter(query)) {
|
||||||
return [
|
return [
|
||||||
metrics.createTotalVisitors(),
|
metrics.createTotalVisitors(),
|
||||||
metrics.createVisitors({ renderLabel: (_query) => 'Conversions' }),
|
metrics.createVisitors({ renderLabel: (_query) => 'Conversions', width: 'w-32' }),
|
||||||
metrics.createConversionRate()
|
metrics.createConversionRate()
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isRealTimeDashboard(query)) {
|
if (isRealTimeDashboard(query)) {
|
||||||
return [
|
return [
|
||||||
metrics.createVisitors({ renderLabel: (_query) => 'Current visitors' })
|
metrics.createVisitors({ renderLabel: (_query) => 'Current visitors', width: 'w-36' })
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -7,6 +7,7 @@ import * as url from "../../util/url";
|
|||||||
import { addFilter } from "../../query";
|
import { addFilter } from "../../query";
|
||||||
import { useQueryContext } from "../../query-context";
|
import { useQueryContext } from "../../query-context";
|
||||||
import { useSiteContext } from "../../site-context";
|
import { useSiteContext } from "../../site-context";
|
||||||
|
import { SortDirection } from "../../hooks/use-order-by";
|
||||||
|
|
||||||
const VIEWS = {
|
const VIEWS = {
|
||||||
sources: {
|
sources: {
|
||||||
@ -22,19 +23,19 @@ const VIEWS = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
utm_mediums: {
|
utm_mediums: {
|
||||||
info: { title: 'Top UTM Mediums', dimension: 'utm_medium', endpoint: '/utm_mediums', dimensionLabel: 'UTM Medium' }
|
info: { title: 'Top UTM Mediums', dimension: 'utm_medium', endpoint: '/utm_mediums', dimensionLabel: 'UTM Medium', defaultOrderBy: ["visitors", SortDirection.desc] }
|
||||||
},
|
},
|
||||||
utm_sources: {
|
utm_sources: {
|
||||||
info: { title: 'Top UTM Sources', dimension: 'utm_source', endpoint: '/utm_sources', dimensionLabel: 'UTM Source' }
|
info: { title: 'Top UTM Sources', dimension: 'utm_source', endpoint: '/utm_sources', dimensionLabel: 'UTM Source', defaultOrderBy: ["visitors", SortDirection.desc] }
|
||||||
},
|
},
|
||||||
utm_campaigns: {
|
utm_campaigns: {
|
||||||
info: { title: 'Top UTM Campaigns', dimension: 'utm_campaign', endpoint: '/utm_campaigns', dimensionLabel: 'UTM Campaign' }
|
info: { title: 'Top UTM Campaigns', dimension: 'utm_campaign', endpoint: '/utm_campaigns', dimensionLabel: 'UTM Campaign', defaultOrderBy: ["visitors", SortDirection.desc] }
|
||||||
},
|
},
|
||||||
utm_contents: {
|
utm_contents: {
|
||||||
info: { title: 'Top UTM Contents', dimension: 'utm_content', endpoint: '/utm_contents', dimensionLabel: 'UTM Content' }
|
info: { title: 'Top UTM Contents', dimension: 'utm_content', endpoint: '/utm_contents', dimensionLabel: 'UTM Content', defaultOrderBy: ["visitors", SortDirection.desc] }
|
||||||
},
|
},
|
||||||
utm_terms: {
|
utm_terms: {
|
||||||
info: { title: 'Top UTM Terms', dimension: 'utm_term', endpoint: '/utm_terms', dimensionLabel: 'UTM Term' }
|
info: { title: 'Top UTM Terms', dimension: 'utm_term', endpoint: '/utm_terms', dimensionLabel: 'UTM Term', defaultOrderBy: ["visitors", SortDirection.desc] }
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -60,14 +61,14 @@ function SourcesModal({ currentView }) {
|
|||||||
if (hasGoalFilter(query)) {
|
if (hasGoalFilter(query)) {
|
||||||
return [
|
return [
|
||||||
metrics.createTotalVisitors(),
|
metrics.createTotalVisitors(),
|
||||||
metrics.createVisitors({ renderLabel: (_query) => 'Conversions' }),
|
metrics.createVisitors({ renderLabel: (_query) => 'Conversions', width: 'w-32' }),
|
||||||
metrics.createConversionRate()
|
metrics.createConversionRate()
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isRealTimeDashboard(query)) {
|
if (isRealTimeDashboard(query)) {
|
||||||
return [
|
return [
|
||||||
metrics.createVisitors({ renderLabel: (_query) => 'Current visitors' })
|
metrics.createVisitors({ renderLabel: (_query) => 'Current visitors', width: 'w-36' })
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -31,7 +31,7 @@ function EntryPages({ afterFetchData }) {
|
|||||||
|
|
||||||
function chooseMetrics() {
|
function chooseMetrics() {
|
||||||
return [
|
return [
|
||||||
metrics.createVisitors({ defaultLabel: 'Unique Entrances', meta: { plot: true } }),
|
metrics.createVisitors({ defaultLabel: 'Unique Entrances', width: 'w-36', meta: { plot: true } }),
|
||||||
hasGoalFilter(query) && metrics.createConversionRate(),
|
hasGoalFilter(query) && metrics.createConversionRate(),
|
||||||
].filter(metric => !!metric)
|
].filter(metric => !!metric)
|
||||||
}
|
}
|
||||||
@ -70,7 +70,7 @@ function ExitPages({ afterFetchData }) {
|
|||||||
|
|
||||||
function chooseMetrics() {
|
function chooseMetrics() {
|
||||||
return [
|
return [
|
||||||
metrics.createVisitors({ defaultLabel: 'Unique Exits', meta: { plot: true } }),
|
metrics.createVisitors({ defaultLabel: 'Unique Exits', width: 'w-36', meta: { plot: true } }),
|
||||||
hasGoalFilter(query) && metrics.createConversionRate(),
|
hasGoalFilter(query) && metrics.createConversionRate(),
|
||||||
].filter(metric => !!metric)
|
].filter(metric => !!metric)
|
||||||
}
|
}
|
||||||
|
@ -191,7 +191,7 @@ export default function ListReport({ keyLabel, metrics, colMinWidth = COL_MIN_WI
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={metric.key}
|
key={metric.key}
|
||||||
className={`${metric.key} text-right ${hiddenOnMobileClass(metric)}`}
|
className={`${metric.key} text-left ${hiddenOnMobileClass(metric)}`}
|
||||||
style={{ minWidth: colMinWidth }}
|
style={{ minWidth: colMinWidth }}
|
||||||
>
|
>
|
||||||
{metric.renderLabel(query)}
|
{metric.renderLabel(query)}
|
||||||
@ -200,7 +200,7 @@ export default function ListReport({ keyLabel, metrics, colMinWidth = COL_MIN_WI
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pt-3 w-full text-xs font-bold tracking-wide text-gray-500 flex items-center dark:text-gray-400">
|
<div className="pt-3 w-full gap-x-4 text-xs font-bold tracking-wide text-gray-500 flex items-center dark:text-gray-400">
|
||||||
<span className="flex-grow truncate">{keyLabel}</span>
|
<span className="flex-grow truncate">{keyLabel}</span>
|
||||||
{metricLabels}
|
{metricLabels}
|
||||||
</div>
|
</div>
|
||||||
@ -218,7 +218,7 @@ export default function ListReport({ keyLabel, metrics, colMinWidth = COL_MIN_WI
|
|||||||
function renderRow(listItem) {
|
function renderRow(listItem) {
|
||||||
return (
|
return (
|
||||||
<div key={listItem.name} style={{ minHeight: ROW_HEIGHT }}>
|
<div key={listItem.name} style={{ minHeight: ROW_HEIGHT }}>
|
||||||
<div className="flex w-full" style={{ marginTop: ROW_GAP_HEIGHT }}>
|
<div className="flex w-full gap-x-4" style={{ marginTop: ROW_GAP_HEIGHT }}>
|
||||||
{renderBarFor(listItem)}
|
{renderBarFor(listItem)}
|
||||||
{renderMetricValuesFor(listItem)}
|
{renderMetricValuesFor(listItem)}
|
||||||
</div>
|
</div>
|
||||||
@ -268,10 +268,10 @@ export default function ListReport({ keyLabel, metrics, colMinWidth = COL_MIN_WI
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${listItem.name}__${metric.key}`}
|
key={`${listItem.name}__${metric.key}`}
|
||||||
className={`text-right ${hiddenOnMobileClass(metric)}`}
|
className={`text-left ${hiddenOnMobileClass(metric)}`}
|
||||||
style={{ width: colMinWidth, minWidth: colMinWidth }}
|
style={{ width: colMinWidth, minWidth: colMinWidth }}
|
||||||
>
|
>
|
||||||
<span className="font-medium text-sm dark:text-gray-200 text-right">
|
<span className="font-medium text-sm dark:text-gray-200 text-left">
|
||||||
{metric.renderValue(listItem[metric.key])}
|
{metric.renderValue(listItem[metric.key])}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
@ -52,7 +52,8 @@ export class Metric {
|
|||||||
this.renderValue = props.renderValue
|
this.renderValue = props.renderValue
|
||||||
this.renderLabel = props.renderLabel
|
this.renderLabel = props.renderLabel
|
||||||
this.meta = props.meta || {}
|
this.meta = props.meta || {}
|
||||||
this.sortable = props.sortable ?? true
|
this.sortable = props.sortable
|
||||||
|
this.width = props.width ?? 'w-24'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -88,77 +89,77 @@ export const createVisitors = (props) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Metric({...props, key: "visitors", renderValue, renderLabel})
|
return new Metric({width: 'w-24', sortable: true, ...props, key: "visitors", renderValue, renderLabel})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createConversionRate = (props) => {
|
export const createConversionRate = (props) => {
|
||||||
const renderValue = percentageFormatter
|
const renderValue = percentageFormatter
|
||||||
const renderLabel = (_query) => "CR"
|
const renderLabel = (_query) => "CR"
|
||||||
return new Metric({...props, key: "conversion_rate", renderLabel, renderValue, sortable: false})
|
return new Metric({width: 'w-16', ...props, key: "conversion_rate", renderLabel, renderValue, sortable: false})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createPercentage = (props) => {
|
export const createPercentage = (props) => {
|
||||||
const renderValue = (value) => value
|
const renderValue = (value) => value
|
||||||
const renderLabel = (_query) => "%"
|
const renderLabel = (_query) => "%"
|
||||||
return new Metric({...props, key: "percentage", renderLabel, renderValue})
|
return new Metric({width: 'w-16', ...props, key: "percentage", renderLabel, renderValue, sortable: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createEvents = (props) => {
|
export const createEvents = (props) => {
|
||||||
const renderValue = typeof props.renderValue === 'function' ? props.renderValue : renderNumberWithTooltip
|
const renderValue = typeof props.renderValue === 'function' ? props.renderValue : renderNumberWithTooltip
|
||||||
return new Metric({...props, key: "events", renderValue: renderValue})
|
return new Metric({width: 'w-24', ...props, key: "events", renderValue: renderValue, sortable: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createTotalRevenue = (props) => {
|
export const createTotalRevenue = (props) => {
|
||||||
const renderValue = (value) => <Money formatted={value} />
|
const renderValue = (value) => <Money formatted={value} />
|
||||||
const renderLabel = (_query) => "Revenue"
|
const renderLabel = (_query) => "Revenue"
|
||||||
return new Metric({...props, key: "total_revenue", renderValue, renderLabel})
|
return new Metric({width: 'w-16', ...props, key: "total_revenue", renderValue, renderLabel, sortable: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createAverageRevenue = (props) => {
|
export const createAverageRevenue = (props) => {
|
||||||
const renderValue = (value) => <Money formatted={value} />
|
const renderValue = (value) => <Money formatted={value} />
|
||||||
const renderLabel = (_query) => "Average"
|
const renderLabel = (_query) => "Average"
|
||||||
return new Metric({...props, key: "average_revenue", renderValue, renderLabel})
|
return new Metric({width: 'w-24', ...props, key: "average_revenue", renderValue, renderLabel, sortable: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createTotalVisitors = (props) => {
|
export const createTotalVisitors = (props) => {
|
||||||
const renderValue = renderNumberWithTooltip
|
const renderValue = renderNumberWithTooltip
|
||||||
const renderLabel = (_query) => "Total Visitors"
|
const renderLabel = (_query) => "Total Visitors"
|
||||||
return new Metric({...props, key: "total_visitors", renderValue, renderLabel, sortable: false })
|
return new Metric({width: 'w-32', ...props, key: "total_visitors", renderValue, renderLabel, sortable: false})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createVisits = (props) => {
|
export const createVisits = (props) => {
|
||||||
const renderValue = renderNumberWithTooltip
|
const renderValue = renderNumberWithTooltip
|
||||||
return new Metric({...props, key: "visits", renderValue})
|
return new Metric({width: 'w-24', sortable: true, ...props, key: "visits", renderValue })
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createVisitDuration = (props) => {
|
export const createVisitDuration = (props) => {
|
||||||
const renderValue = durationFormatter
|
const renderValue = durationFormatter
|
||||||
const renderLabel = (_query) => "Visit Duration"
|
const renderLabel = (_query) => "Visit Duration"
|
||||||
return new Metric({...props, key: "visit_duration", renderValue, renderLabel})
|
return new Metric({width: 'w-36', ...props, key: "visit_duration", renderValue, renderLabel, sortable: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createBounceRate = (props) => {
|
export const createBounceRate = (props) => {
|
||||||
const renderValue = (value) => `${value}%`
|
const renderValue = (value) => `${value}%`
|
||||||
const renderLabel = (_query) => "Bounce Rate"
|
const renderLabel = (_query) => "Bounce Rate"
|
||||||
return new Metric({...props, key: "bounce_rate", renderValue, renderLabel})
|
return new Metric({width: 'w-36', ...props, key: "bounce_rate", renderValue, renderLabel, sortable: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createPageviews = (props) => {
|
export const createPageviews = (props) => {
|
||||||
const renderValue = renderNumberWithTooltip
|
const renderValue = renderNumberWithTooltip
|
||||||
const renderLabel = (_query) => "Pageviews"
|
const renderLabel = (_query) => "Pageviews"
|
||||||
return new Metric({...props, key: "pageviews", renderValue, renderLabel})
|
return new Metric({width: 'w-28', ...props, key: "pageviews", renderValue, renderLabel, sortable: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createTimeOnPage = (props) => {
|
export const createTimeOnPage = (props) => {
|
||||||
const renderValue = durationFormatter
|
const renderValue = durationFormatter
|
||||||
const renderLabel = (_query) => "Time on Page"
|
const renderLabel = (_query) => "Time on Page"
|
||||||
return new Metric({...props, key: "time_on_page", renderValue, renderLabel, sortable: false})
|
return new Metric({width: 'w-32', ...props, key: "time_on_page", renderValue, renderLabel, sortable: false})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createExitRate = (props) => {
|
export const createExitRate = (props) => {
|
||||||
const renderValue = percentageFormatter
|
const renderValue = percentageFormatter
|
||||||
const renderLabel = (_query) => "Exit Rate"
|
const renderLabel = (_query) => "Exit Rate"
|
||||||
return new Metric({...props, key: "exit_rate", renderValue, renderLabel, sortable: false})
|
return new Metric({width: 'w-28', ...props, key: "exit_rate", renderValue, renderLabel, sortable: false})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderNumberWithTooltip(value) {
|
export function renderNumberWithTooltip(value) {
|
||||||
|
Loading…
Reference in New Issue
Block a user