Improve sorting indicators, left-align tables, set column width by metric

This commit is contained in:
Artur Pata 2024-09-04 11:04:23 +03:00
parent 57037fdb82
commit bd80e910fc
25 changed files with 379 additions and 192 deletions

View File

@ -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>
) )
} }

View 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>
)
}

View File

@ -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'}
> >

View File

@ -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,
})
}

View 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
})
}

View File

@ -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' }
} }
} }

View File

@ -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(

View File

@ -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) => {

View File

@ -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) => {

View File

@ -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) => {

View File

@ -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()
] ]
} }

View File

@ -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) => {

View File

@ -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) => {

View File

@ -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) => {

View File

@ -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()
] ]
} }

View File

@ -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' })
] ]
} }

View File

@ -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 {

View File

@ -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' })
] ]
} }

View File

@ -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'})
] ]
} }

View File

@ -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) => {

View File

@ -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' })
] ]
} }

View File

@ -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' })
] ]
} }

View File

@ -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)
} }

View File

@ -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>

View File

@ -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) {