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 */
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 = ({
children,
toggleSort,
hint,
sortDirection
sortDirection,
nextSortDirection
}: {
children: ReactNode
toggleSort: () => void
hint: string
sortDirection: SortDirection | null
nextSortDirection: SortDirection
}) => {
return (
<button onClick={toggleSort} title={hint} className="hover:underline">
{children}
{sortDirection !== null && (
<span> {getSortDirectionIndicator(sortDirection)}</span>
<button
onClick={toggleSort}
title={hint}
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>
)
}

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
count={step.visitors}
all={funnel.steps}
bg={palette.smallBarClass}
className={palette.smallBarClass}
maxWidthDeduction={"5rem"}
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'
}
type Order = [Metric['key'], SortDirection]
export type Order = [Metric['key'], SortDirection]
export type OrderBy = Order[]
export const getSortDirectionIndicator = (
sortDirection: SortDirection
): string =>
({ [SortDirection.asc]: '↑', [SortDirection.desc]: '↓' })[sortDirection]
export const getSortDirectionLabel = (sortDirection: SortDirection): string =>
({
[SortDirection.asc]: 'Sorted in ascending order',
[SortDirection.desc]: 'Sorted in descending order'
})[sortDirection]
export function useOrderBy({ metrics }: { metrics: Metric[] }) {
export function useOrderBy({
metrics,
defaultOrderBy
}: {
metrics: Metric[]
defaultOrderBy: OrderBy
}) {
const [orderBy, setOrderBy] = useState<OrderBy>([])
const orderByDictionary = useMemo(
() => Object.fromEntries(orderBy),
[orderBy]
() =>
orderBy.length
? Object.fromEntries(orderBy)
: Object.fromEntries(defaultOrderBy),
[orderBy, defaultOrderBy]
)
const toggleSortByMetric = useCallback(
@ -35,19 +39,24 @@ export function useOrderBy({ metrics }: { metrics: Metric[] }) {
if (!metrics.find(({ key }) => key === metric.key)) {
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(
currentSortDirection: SortDirection | null
): { direction: SortDirection | null; hint: string } {
): { direction: SortDirection; hint: string } {
switch (currentSortDirection) {
case null:
case SortDirection.asc:
return {
direction: SortDirection.desc,
hint: 'Press to sort column in descending order'
@ -57,8 +66,8 @@ export function cycleSortDirection(
direction: SortDirection.asc,
hint: 'Press to sort column in ascending order'
}
case SortDirection.asc:
return { direction: null, hint: 'Press to remove sorting from column' }
// case SortDirection.asc:
// return { direction: SortDirection, hint: 'Press to remove sorting from column' }
}
}

View File

@ -1,14 +1,22 @@
/** @format */
import React, { useState, useEffect, useRef } from 'react'
import React, { useState, useEffect, useRef, ReactNode } from 'react'
import { FilterLink } from '../reports/list'
import { useQueryContext } from '../../query-context'
import { useDebounce } from '../../custom-hooks'
import { useAPIClient } from '../../hooks/api-client'
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 { Metric } from '../reports/metrics'
import { DashboardQuery } from '../../query'
import classNames from 'classnames'
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`,
// 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,
metrics,
renderIcon,
@ -86,13 +94,29 @@ export default function BreakdownModal({
afterFetchNextPage,
addSearchFilter,
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 [search, setSearch] = useState('')
const { orderBy, orderByDictionary, toggleSortByMetric } = useOrderBy({
metrics
metrics,
defaultOrderBy: [reportInfo.defaultOrderBy]
})
const {
@ -102,12 +126,15 @@ export default function BreakdownModal({
isFetchingNextPage,
isFetching,
isPending
} = useAPIClient({
} = useAPIClient<
never,
[string, { query: DashboardQuery; search: string; orderBy: OrderBy }]
>({
key: [reportInfo.endpoint, { query, search, orderBy }],
getRequestParams: (key) => {
const [_endpoint, { query, search }] = key
let queryWithSearchFilter = { ...query }
let queryWithSearchFilter: Record<string, unknown> = { ...query }
if (searchEnabled && search !== '') {
queryWithSearchFilter = addSearchFilter(query, search)
@ -115,7 +142,10 @@ export default function BreakdownModal({
return [
queryWithSearchFilter,
{ detailed: true, order_by: JSON.stringify(orderBy) }
{
detailed: true,
order_by: JSON.stringify(orderBy)
}
]
},
afterFetchData,
@ -123,33 +153,32 @@ export default function BreakdownModal({
})
useEffect(() => {
if (!searchEnabled) {
const searchBox = searchBoxRef.current
if (!searchEnabled && searchBox) {
return
}
const searchBox = searchBoxRef.current
const handleKeyUp = (event) => {
const handleKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.target.blur()
;(event.target as HTMLElement | undefined)?.blur()
event.stopPropagation()
}
}
searchBox.addEventListener('keyup', handleKeyUp)
searchBox?.addEventListener('keyup', handleKeyUp)
return () => {
searchBox.removeEventListener('keyup', handleKeyUp)
searchBox?.removeEventListener('keyup', handleKeyUp)
}
}, [searchEnabled])
function maybeRenderIcon(item) {
function maybeRenderIcon(item: TListItem) {
if (typeof renderIcon === 'function') {
return renderIcon(item)
}
}
function maybeRenderExternalLink(item) {
function maybeRenderExternalLink(item: TListItem) {
if (typeof getExternalLinkURL === 'function') {
const linkUrl = getExternalLinkURL(item)
@ -177,20 +206,20 @@ export default function BreakdownModal({
}
}
function renderRow(item) {
function renderRow(item: TListItem) {
return (
<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">
{maybeRenderIcon(item)}
<FilterLink path={rootRoute.path} filterInfo={getFilterInfo(item)}>
<FilterLink path={rootRoute.path} filterInfo={getFilterInfo(item)} onClick={undefined} extraClass={undefined}>
{item.name}
</FilterLink>
{maybeRenderExternalLink(item)}
</td>
{metrics.map((metric) => {
return (
<td key={metric.key} className="p-2 w-24 font-medium" align="right">
{metric.renderValue(item[metric.key])}
<td key={metric.key} className={classNames(metric.width, "p-2 font-medium")} align="left">
{metric.renderValue(item[metric.key as keyof TListItem])}
</td>
)
})}
@ -226,7 +255,7 @@ export default function BreakdownModal({
return (
<div className="flex flex-col w-full my-4 items-center justify-center h-10">
{!isFetching && (
<button onClick={fetchNextPage} type="button" className="button">
<button onClick={() => fetchNextPage()} type="button" className="button">
Load more
</button>
)}
@ -235,8 +264,12 @@ export default function BreakdownModal({
)
}
function handleInputChange(e) {
setSearch(e.target.value)
function handleInputChange(e: Event) {
const element = e.target as HTMLInputElement | null;
if (!element) {
return
}
setSearch(element.value)
}
const debouncedHandleInputChange = useDebounce(handleInputChange)
@ -261,7 +294,7 @@ export default function BreakdownModal({
<thead>
<tr>
<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"
>
{reportInfo.dimensionLabel}
@ -271,12 +304,15 @@ export default function BreakdownModal({
return (
<th
key={metric.key}
className="p-2 w-24 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400"
align="right"
className={classNames(metric.width, "p-2 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400")}
align="left"
>
{metric.sortable ? (
<SortButton
sortDirection={orderByDictionary[metric.key] ?? null}
nextSortDirection={cycleSortDirection(
orderByDictionary[metric.key] ?? null
).direction!}
toggleSort={() => toggleSortByMetric(metric)}
hint={
cycleSortDirection(

View File

@ -16,7 +16,8 @@ function ConversionsModal() {
title: 'Goal Conversions',
dimension: 'goal',
endpoint: url.apiPath(site, '/conversions'),
dimensionLabel: "Goal"
dimensionLabel: "Goal",
defaultOrderBy: []
}
const getFilterInfo = useCallback((listItem) => {

View File

@ -7,6 +7,7 @@ import { useQueryContext } from "../../../query-context";
import { useSiteContext } from "../../../site-context";
import { browserIconFor } from "../../devices";
import chooseMetrics from './choose-metrics';
import { SortDirection } from "../../../hooks/use-order-by";
function BrowserVersionsModal() {
const { query } = useQueryContext();
@ -16,7 +17,8 @@ function BrowserVersionsModal() {
title: 'Browser Versions',
dimension: 'browser_version',
endpoint: url.apiPath(site, '/browser-versions'),
dimensionLabel: 'Browser version'
dimensionLabel: 'Browser version',
defaultOrderBy: ["visitors", SortDirection.desc]
}
const getFilterInfo = useCallback((listItem) => {

View File

@ -7,6 +7,7 @@ import { useQueryContext } from "../../../query-context";
import { useSiteContext } from "../../../site-context";
import { browserIconFor } from "../../devices";
import chooseMetrics from './choose-metrics';
import { SortDirection } from "../../../hooks/use-order-by";
function BrowsersModal() {
const { query } = useQueryContext();
@ -16,7 +17,8 @@ function BrowsersModal() {
title: 'Browsers',
dimension: 'browser',
endpoint: url.apiPath(site, '/browsers'),
dimensionLabel: 'Browser'
dimensionLabel: 'Browser',
defaultOrderBy: ["visitors", SortDirection.desc]
}
const getFilterInfo = useCallback((listItem) => {

View File

@ -5,14 +5,14 @@ export default function chooseMetrics(query) {
if (hasGoalFilter(query)) {
return [
metrics.createTotalVisitors(),
metrics.createVisitors({ renderLabel: (_query) => 'Conversions' }),
metrics.createVisitors({ renderLabel: (_query) => 'Conversions', width: 'w-32' }),
metrics.createConversionRate()
]
}
if (isRealTimeDashboard(query)) {
return [
metrics.createVisitors({ renderLabel: (_query) => 'Current visitors' }),
metrics.createVisitors({ renderLabel: (_query) => 'Current visitors', width: 'w-36' }),
metrics.createPercentage()
]
}

View File

@ -7,6 +7,7 @@ import { useQueryContext } from "../../../query-context";
import { useSiteContext } from "../../../site-context";
import { osIconFor } from "../../devices";
import chooseMetrics from './choose-metrics';
import { SortDirection } from "../../../hooks/use-order-by";
function OperatingSystemVersionsModal() {
const { query } = useQueryContext();
@ -16,7 +17,8 @@ function OperatingSystemVersionsModal() {
title: 'Operating System Versions',
dimension: 'os_version',
endpoint: url.apiPath(site, '/operating-system-versions'),
dimensionLabel: 'Operating system version'
dimensionLabel: 'Operating system version',
defaultOrderBy: ["visitors", SortDirection.desc]
}
const getFilterInfo = useCallback((listItem) => {

View File

@ -7,6 +7,7 @@ import { useQueryContext } from "../../../query-context";
import { useSiteContext } from "../../../site-context";
import { osIconFor } from "../../devices";
import chooseMetrics from './choose-metrics';
import { SortDirection } from "../../../hooks/use-order-by";
function OperatingSystemsModal() {
const { query } = useQueryContext();
@ -16,7 +17,8 @@ function OperatingSystemsModal() {
title: 'Operating Systems',
dimension: 'os',
endpoint: url.apiPath(site, '/operating-systems'),
dimensionLabel: 'Operating system'
dimensionLabel: 'Operating system',
defaultOrderBy: ["visitors", SortDirection.desc]
}
const getFilterInfo = useCallback((listItem) => {

View File

@ -6,6 +6,7 @@ import { useQueryContext } from "../../../query-context";
import { useSiteContext } from "../../../site-context";
import { screenSizeIconFor } from "../../devices";
import chooseMetrics from './choose-metrics';
import { SortDirection } from "../../../hooks/use-order-by";
function ScreenSizesModal() {
const { query } = useQueryContext();
@ -15,7 +16,8 @@ function ScreenSizesModal() {
title: 'Screen Sizes',
dimension: 'screen',
endpoint: url.apiPath(site, '/screen-sizes'),
dimensionLabel: 'Screen size'
dimensionLabel: 'Screen size',
defaultOrderBy: ["visitors", SortDirection.desc]
}
const getFilterInfo = useCallback((listItem) => {

View File

@ -7,6 +7,7 @@ import * as metrics from '../reports/metrics'
import * as url from '../../util/url';
import { useQueryContext } from "../../query-context";
import { useSiteContext } from "../../site-context";
import { SortDirection } from "../../hooks/use-order-by";
function EntryPagesModal() {
const { query } = useQueryContext();
@ -16,7 +17,8 @@ function EntryPagesModal() {
title: 'Entry Pages',
dimension: 'entry_page',
endpoint: url.apiPath(site, '/entry-pages'),
dimensionLabel: 'Entry page'
dimensionLabel: 'Entry page',
defaultOrderBy: ["visitors", SortDirection.desc]
}
const getFilterInfo = useCallback((listItem) => {
@ -34,20 +36,20 @@ function EntryPagesModal() {
if (hasGoalFilter(query)) {
return [
metrics.createTotalVisitors(),
metrics.createVisitors({ renderLabel: (_query) => 'Conversions' }),
metrics.createVisitors({ renderLabel: (_query) => 'Conversions', width: 'w-32' }),
metrics.createConversionRate()
]
}
if (isRealTimeDashboard(query)) {
return [
metrics.createVisitors({ renderLabel: (_query) => 'Current visitors' })
metrics.createVisitors({ renderLabel: (_query) => 'Current visitors', width: 'w-36' })
]
}
return [
metrics.createVisitors({ renderLabel: (_query) => "Visitors" }),
metrics.createVisits({ renderLabel: (_query) => "Total Entrances" }),
metrics.createVisits({ renderLabel: (_query) => "Total Entrances", width: 'w-36' }),
metrics.createVisitDuration()
]
}

View File

@ -7,6 +7,7 @@ import * as metrics from '../reports/metrics'
import * as url from '../../util/url';
import { useQueryContext } from "../../query-context";
import { useSiteContext } from "../../site-context";
import { SortDirection } from "../../hooks/use-order-by";
function ExitPagesModal() {
const { query } = useQueryContext();
@ -16,7 +17,8 @@ function ExitPagesModal() {
title: 'Exit Pages',
dimension: 'exit_page',
endpoint: url.apiPath(site, '/exit-pages'),
dimensionLabel: 'Page url'
dimensionLabel: 'Page url',
defaultOrderBy: []
}
const getFilterInfo = useCallback((listItem) => {
@ -34,14 +36,14 @@ function ExitPagesModal() {
if (hasGoalFilter(query)) {
return [
metrics.createTotalVisitors(),
metrics.createVisitors({ renderLabel: (_query) => 'Conversions' }),
metrics.createVisitors({ renderLabel: (_query) => 'Conversions', width: 'w-32' }),
metrics.createConversionRate()
]
}
if (query.period === 'realtime') {
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 = [
createVisitors({renderLabel: (_query) => 'Visitors'}),
new Metric({key: 'impressions', renderLabel: (_query) => 'Impressions', renderValue: renderNumberWithTooltip}),
new Metric({key: 'ctr', renderLabel: (_query) => 'CTR', renderValue: percentageFormatter}),
new Metric({key: 'position', renderLabel: (_query) => 'Position', renderValue: numberFormatter})
new Metric({width: 'w-28', key: 'impressions', renderLabel: (_query) => 'Impressions', renderValue: renderNumberWithTooltip, sortable: false}),
new Metric({width: 'w-16', key: 'ctr', renderLabel: (_query) => 'CTR', renderValue: percentageFormatter, sortable: false}),
new Metric({width: 'w-28', key: 'position', renderLabel: (_query) => 'Position', renderValue: numberFormatter, sortable: false})
]
const {

View File

@ -8,11 +8,12 @@ import * as url from "../../util/url";
import { useQueryContext } from "../../query-context";
import { useSiteContext } from "../../site-context";
import { addFilter } from "../../query";
import { SortDirection } from "../../hooks/use-order-by";
const VIEWS = {
countries: { title: 'Top Countries', dimension: 'country', endpoint: '/countries', dimensionLabel: 'Country' },
regions: { title: 'Top Regions', dimension: 'region', endpoint: '/regions', dimensionLabel: 'Region' },
cities: { title: 'Top Cities', dimension: 'city', endpoint: '/cities', dimensionLabel: 'City' },
countries: { title: 'Top Countries', dimension: 'country', endpoint: '/countries', dimensionLabel: 'Country', defaultOrderBy: ["visitors", SortDirection.desc] },
regions: { title: 'Top Regions', dimension: 'region', endpoint: '/regions', dimensionLabel: 'Region', defaultOrderBy: ["visitors", SortDirection.desc] },
cities: { title: 'Top Cities', dimension: 'city', endpoint: '/cities', dimensionLabel: 'City', defaultOrderBy: ["visitors", SortDirection.desc] },
}
function LocationsModal({ currentView }) {
@ -38,14 +39,14 @@ function LocationsModal({ currentView }) {
if (hasGoalFilter(query)) {
return [
metrics.createTotalVisitors(),
metrics.createVisitors({ renderLabel: (_query) => 'Conversions' }),
metrics.createVisitors({ renderLabel: (_query) => 'Conversions', width: 'w-32' }),
metrics.createConversionRate()
]
}
if (query.period === 'realtime') {
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 { useQueryContext } from "../../query-context";
import { useSiteContext } from "../../site-context";
import { SortDirection } from "../../hooks/use-order-by";
function PagesModal() {
const { query } = useQueryContext();
@ -16,7 +17,8 @@ function PagesModal() {
title: 'Top Pages',
dimension: 'page',
endpoint: url.apiPath(site, '/pages'),
dimensionLabel: 'Page url'
dimensionLabel: 'Page url',
defaultOrderBy: ["visitors", SortDirection.desc]
}
const getFilterInfo = useCallback((listItem) => {
@ -34,14 +36,14 @@ function PagesModal() {
if (hasGoalFilter(query)) {
return [
metrics.createTotalVisitors(),
metrics.createVisitors({renderLabel: (_query) => 'Conversions'}),
metrics.createVisitors({renderLabel: (_query) => 'Conversions', width: 'w-32'}),
metrics.createConversionRate()
]
}
if (isRealTimeDashboard(query)) {
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 { useQueryContext } from "../../query-context";
import { useSiteContext } from "../../site-context";
import { SortDirection } from "../../hooks/use-order-by";
function PropsModal() {
const { query } = useQueryContext();
@ -23,7 +24,8 @@ function PropsModal() {
title: specialTitleWhenGoalFilter(query, 'Custom Property Breakdown'),
dimension: propKey,
endpoint: url.apiPath(site, `/custom-prop-values/${propKey}`),
dimensionLabel: propKey
dimensionLabel: propKey,
defaultOrderBy: ["visitors", SortDirection.desc]
}
const getFilterInfo = useCallback((listItem) => {

View File

@ -9,6 +9,7 @@ import * as url from "../../util/url";
import { addFilter } from "../../query";
import { useQueryContext } from "../../query-context";
import { useSiteContext } from "../../site-context";
import { SortDirection } from '../../hooks/use-order-by';
function ReferrerDrilldownModal() {
const { referrer } = useParams();
@ -19,7 +20,8 @@ function ReferrerDrilldownModal() {
title: "Referrer Drilldown",
dimension: 'referrer',
endpoint: url.apiPath(site, `/referrers/${referrer}`),
dimensionLabel: "Referrer"
dimensionLabel: "Referrer",
defaultOrderBy: ["visitors", SortDirection.desc]
}
const getFilterInfo = useCallback((listItem) => {
@ -37,14 +39,14 @@ function ReferrerDrilldownModal() {
if (hasGoalFilter(query)) {
return [
metrics.createTotalVisitors(),
metrics.createVisitors({ renderLabel: (_query) => 'Conversions' }),
metrics.createVisitors({ renderLabel: (_query) => 'Conversions', width: 'w-32' }),
metrics.createConversionRate()
]
}
if (isRealTimeDashboard(query)) {
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 { useQueryContext } from "../../query-context";
import { useSiteContext } from "../../site-context";
import { SortDirection } from "../../hooks/use-order-by";
const VIEWS = {
sources: {
@ -22,19 +23,19 @@ const VIEWS = {
}
},
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: {
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: {
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: {
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: {
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)) {
return [
metrics.createTotalVisitors(),
metrics.createVisitors({ renderLabel: (_query) => 'Conversions' }),
metrics.createVisitors({ renderLabel: (_query) => 'Conversions', width: 'w-32' }),
metrics.createConversionRate()
]
}
if (isRealTimeDashboard(query)) {
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() {
return [
metrics.createVisitors({ defaultLabel: 'Unique Entrances', meta: { plot: true } }),
metrics.createVisitors({ defaultLabel: 'Unique Entrances', width: 'w-36', meta: { plot: true } }),
hasGoalFilter(query) && metrics.createConversionRate(),
].filter(metric => !!metric)
}
@ -70,7 +70,7 @@ function ExitPages({ afterFetchData }) {
function chooseMetrics() {
return [
metrics.createVisitors({ defaultLabel: 'Unique Exits', meta: { plot: true } }),
metrics.createVisitors({ defaultLabel: 'Unique Exits', width: 'w-36', meta: { plot: true } }),
hasGoalFilter(query) && metrics.createConversionRate(),
].filter(metric => !!metric)
}

View File

@ -170,7 +170,7 @@ export default function ListReport({ keyLabel, metrics, colMinWidth = COL_MIN_WI
function renderReport() {
if (state.list && state.list.length > 0) {
return (
<div className="h-full flex flex-col">
<div className="h-full flex flex-col ">
<div style={{ height: ROW_HEIGHT }}>
{renderReportHeader()}
</div>
@ -191,7 +191,7 @@ export default function ListReport({ keyLabel, metrics, colMinWidth = COL_MIN_WI
return (
<div
key={metric.key}
className={`${metric.key} text-right ${hiddenOnMobileClass(metric)}`}
className={`${metric.key} text-left ${hiddenOnMobileClass(metric)}`}
style={{ minWidth: colMinWidth }}
>
{metric.renderLabel(query)}
@ -200,7 +200,7 @@ export default function ListReport({ keyLabel, metrics, colMinWidth = COL_MIN_WI
})
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>
{metricLabels}
</div>
@ -218,7 +218,7 @@ export default function ListReport({ keyLabel, metrics, colMinWidth = COL_MIN_WI
function renderRow(listItem) {
return (
<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)}
{renderMetricValuesFor(listItem)}
</div>
@ -268,10 +268,10 @@ export default function ListReport({ keyLabel, metrics, colMinWidth = COL_MIN_WI
return (
<div
key={`${listItem.name}__${metric.key}`}
className={`text-right ${hiddenOnMobileClass(metric)}`}
className={`text-left ${hiddenOnMobileClass(metric)}`}
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])}
</span>
</div>

View File

@ -52,7 +52,8 @@ export class Metric {
this.renderValue = props.renderValue
this.renderLabel = props.renderLabel
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) => {
const renderValue = percentageFormatter
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) => {
const renderValue = (value) => value
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) => {
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) => {
const renderValue = (value) => <Money formatted={value} />
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) => {
const renderValue = (value) => <Money formatted={value} />
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) => {
const renderValue = renderNumberWithTooltip
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) => {
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) => {
const renderValue = durationFormatter
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) => {
const renderValue = (value) => `${value}%`
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) => {
const renderValue = renderNumberWithTooltip
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) => {
const renderValue = durationFormatter
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) => {
const renderValue = percentageFormatter
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) {