Clean up all uploaded files
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- assets/js/dashboard/stats/more-link-state.js +0 -5
- assets/js/dashboard/stats/more-link.js +0 -59
- assets/js/dashboard/stats/pages/details.tsx +0 -146
- assets/js/dashboard/stats/pages/external-link.tsx +0 -50
- assets/js/dashboard/stats/pages/index.tsx +0 -321
- assets/js/dashboard/stats/reports/change-arrow.test.tsx +0 -81
- assets/js/dashboard/stats/reports/change-arrow.tsx +0 -46
- assets/js/dashboard/stats/reports/index-breakdown.tsx +0 -594
- assets/js/dashboard/stats/reports/metric-formatter.ts +0 -70
- assets/js/dashboard/stats/reports/report-header.js +0 -9
- assets/js/dashboard/stats/reports/report-layout.tsx +0 -25
- assets/js/dashboard/stats/reports/reports-config.ts +0 -454
- assets/js/dashboard/stats/sources/details.tsx +0 -123
- assets/js/dashboard/stats/sources/fetch-search-terms.tsx +0 -133
- assets/js/dashboard/stats/sources/index.tsx +0 -360
- assets/js/dashboard/stats/sources/search-terms.tsx +0 -151
- assets/js/dashboard/stats/sources/source-favicon.tsx +0 -26
- assets/js/dashboard/theme-context.tsx +0 -61
- assets/js/dashboard/user-context.tsx +0 -50
- assets/js/dashboard/util/date.js +0 -169
- assets/js/dashboard/util/date.test.ts +0 -210
- assets/js/dashboard/util/fetch-suggestions.ts +0 -35
- assets/js/dashboard/util/filter-text.test.tsx +0 -42
- assets/js/dashboard/util/filter-text.tsx +0 -78
- assets/js/dashboard/util/filters.js +0 -311
- assets/js/dashboard/util/filters.test.ts +0 -82
- assets/js/dashboard/util/goals.ts +0 -34
- assets/js/dashboard/util/money.ts +0 -23
- assets/js/dashboard/util/number-formatter.test.ts +0 -33
- assets/js/dashboard/util/number-formatter.ts +0 -84
- assets/js/dashboard/util/realtime-update-timer.js +0 -8
- assets/js/dashboard/util/seconds-since-last-load.js +0 -15
- assets/js/dashboard/util/storage.js +0 -37
- assets/js/dashboard/util/tooltip.tsx +0 -109
- assets/js/dashboard/util/url-search-params-v1.ts +0 -121
- assets/js/dashboard/util/url-search-params-v2.test.ts +0 -204
- assets/js/dashboard/util/url-search-params-v2.ts +0 -83
- assets/js/dashboard/util/url-search-params.test.ts +0 -331
- assets/js/dashboard/util/url-search-params.ts +0 -376
- assets/js/dashboard/util/url.test.ts +0 -105
- assets/js/dashboard/util/url.ts +0 -86
- assets/js/embed.content.js +0 -16
- assets/js/embed.host.js +0 -24
- assets/js/liveview/combo-box.js +0 -98
- assets/js/liveview/dropdown.js +0 -20
- assets/js/liveview/live_socket.js +0 -78
- assets/js/liveview/phx_events.js +0 -20
- assets/js/polyfills/closest.js +0 -14
- assets/js/types/globals.d.ts +0 -1
- assets/js/types/query-api.d.ts +0 -216
assets/js/dashboard/stats/more-link-state.js
DELETED
|
@@ -1,5 +0,0 @@
|
|
| 1 |
-
export const MoreLinkState = {
|
| 2 |
-
HIDDEN: 'hidden',
|
| 3 |
-
LOADING: 'loading',
|
| 4 |
-
READY: 'ready'
|
| 5 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/dashboard/stats/more-link.js
DELETED
|
@@ -1,59 +0,0 @@
|
|
| 1 |
-
import React, { useRef, useEffect } from 'react'
|
| 2 |
-
import { AppNavigationLink } from '../navigation/use-app-navigate'
|
| 3 |
-
import { Tooltip } from '../util/tooltip'
|
| 4 |
-
import { MoreLinkState } from './more-link-state'
|
| 5 |
-
|
| 6 |
-
function detailsIcon() {
|
| 7 |
-
return (
|
| 8 |
-
<svg
|
| 9 |
-
className="feather"
|
| 10 |
-
xmlns="http://www.w3.org/2000/svg"
|
| 11 |
-
viewBox="0 0 24 24"
|
| 12 |
-
fill="none"
|
| 13 |
-
stroke="currentColor"
|
| 14 |
-
strokeWidth="2"
|
| 15 |
-
strokeLinecap="round"
|
| 16 |
-
strokeLinejoin="round"
|
| 17 |
-
>
|
| 18 |
-
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3" />
|
| 19 |
-
</svg>
|
| 20 |
-
)
|
| 21 |
-
}
|
| 22 |
-
|
| 23 |
-
export default function MoreLink({ linkProps, state }) {
|
| 24 |
-
const portalRef = useRef(null)
|
| 25 |
-
|
| 26 |
-
useEffect(() => {
|
| 27 |
-
if (typeof document !== 'undefined') {
|
| 28 |
-
portalRef.current = document.body
|
| 29 |
-
}
|
| 30 |
-
}, [])
|
| 31 |
-
|
| 32 |
-
const baseClassName =
|
| 33 |
-
'relative flex mt-px rounded text-gray-500 dark:text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors duration-150 before:absolute before:inset-[-8px] before:content-[" "]'
|
| 34 |
-
const icon = detailsIcon()
|
| 35 |
-
|
| 36 |
-
if (state === MoreLinkState.HIDDEN) {
|
| 37 |
-
return null
|
| 38 |
-
}
|
| 39 |
-
|
| 40 |
-
if (state === MoreLinkState.LOADING || !linkProps) {
|
| 41 |
-
return (
|
| 42 |
-
<Tooltip info="View details" containerRef={portalRef}>
|
| 43 |
-
<div className={baseClassName}>{icon}</div>
|
| 44 |
-
</Tooltip>
|
| 45 |
-
)
|
| 46 |
-
}
|
| 47 |
-
|
| 48 |
-
return (
|
| 49 |
-
<Tooltip info="View details" containerRef={portalRef}>
|
| 50 |
-
<AppNavigationLink
|
| 51 |
-
{...linkProps}
|
| 52 |
-
aria-label="View details"
|
| 53 |
-
className={baseClassName}
|
| 54 |
-
>
|
| 55 |
-
{icon}
|
| 56 |
-
</AppNavigationLink>
|
| 57 |
-
</Tooltip>
|
| 58 |
-
)
|
| 59 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/dashboard/stats/pages/details.tsx
DELETED
|
@@ -1,146 +0,0 @@
|
|
| 1 |
-
import React from 'react'
|
| 2 |
-
import { revenueAvailable, Filter, DashboardState } from '../../dashboard-state'
|
| 3 |
-
import { useDashboardStateContext } from '../../dashboard-state-context'
|
| 4 |
-
import { useSiteContext } from '../../site-context'
|
| 5 |
-
import {
|
| 6 |
-
hasConversionGoalFilter,
|
| 7 |
-
hasEventFilters,
|
| 8 |
-
isRealTimeDashboard
|
| 9 |
-
} from '../../util/filters'
|
| 10 |
-
import { defaultGetFilterInfo, GetFilterInfo } from '../breakdowns'
|
| 11 |
-
import {
|
| 12 |
-
BREAKDOWN_REPORTS,
|
| 13 |
-
BreakdownReportKey
|
| 14 |
-
} from '../reports/reports-config'
|
| 15 |
-
import {
|
| 16 |
-
DetailsBreakdown,
|
| 17 |
-
DimensionCell,
|
| 18 |
-
DimensionCellProps
|
| 19 |
-
} from '../modals/details-breakdown'
|
| 20 |
-
import Modal from '../modals/modal'
|
| 21 |
-
import { DetailsExternalLink } from './external-link'
|
| 22 |
-
import { externalLinkForPage, trimURL } from '../../util/url'
|
| 23 |
-
|
| 24 |
-
const MAX_DIMENSION_LENGTH = 70
|
| 25 |
-
|
| 26 |
-
function makeHostnameDimensionElement(pageFilterKey: string) {
|
| 27 |
-
const getFilterInfo: GetFilterInfo = (_dim, row) => ({
|
| 28 |
-
prefix: 'hostname',
|
| 29 |
-
filter: ['is', 'hostname', [row.dimensions[0]]] as Filter,
|
| 30 |
-
extraFilters: [
|
| 31 |
-
{
|
| 32 |
-
prefix: pageFilterKey,
|
| 33 |
-
filter: ['is', pageFilterKey, [row.dimensions[1]]] as Filter
|
| 34 |
-
}
|
| 35 |
-
]
|
| 36 |
-
})
|
| 37 |
-
return function HostnameDimensionElement(props: DimensionCellProps) {
|
| 38 |
-
const site = useSiteContext()
|
| 39 |
-
const hostname = props.row.dimensions[0]
|
| 40 |
-
const path = props.row.dimensions[1]
|
| 41 |
-
|
| 42 |
-
const displayValue = trimURL(
|
| 43 |
-
`https://${hostname}${path}`,
|
| 44 |
-
MAX_DIMENSION_LENGTH + 8
|
| 45 |
-
).replace(/^https:\/\//, '')
|
| 46 |
-
|
| 47 |
-
return (
|
| 48 |
-
<DimensionCell
|
| 49 |
-
text={displayValue}
|
| 50 |
-
externalLink={
|
| 51 |
-
<DetailsExternalLink
|
| 52 |
-
href={externalLinkForPage(site, path, hostname)}
|
| 53 |
-
isActive={props.isActive}
|
| 54 |
-
/>
|
| 55 |
-
}
|
| 56 |
-
getFilterInfo={getFilterInfo}
|
| 57 |
-
{...props}
|
| 58 |
-
/>
|
| 59 |
-
)
|
| 60 |
-
}
|
| 61 |
-
}
|
| 62 |
-
|
| 63 |
-
const HOSTNAME_DIMENSION_ELEMENTS: Partial<
|
| 64 |
-
Record<BreakdownReportKey, (props: DimensionCellProps) => React.ReactNode>
|
| 65 |
-
> = {
|
| 66 |
-
[BreakdownReportKey.pagesWithHostname]: makeHostnameDimensionElement('page'),
|
| 67 |
-
[BreakdownReportKey.entryPagesWithHostname]:
|
| 68 |
-
makeHostnameDimensionElement('entry_page'),
|
| 69 |
-
[BreakdownReportKey.exitPagesWithHostname]:
|
| 70 |
-
makeHostnameDimensionElement('exit_page')
|
| 71 |
-
}
|
| 72 |
-
|
| 73 |
-
export function PagesDetails({
|
| 74 |
-
breakdownReportKey
|
| 75 |
-
}: {
|
| 76 |
-
breakdownReportKey: BreakdownReportKey
|
| 77 |
-
}) {
|
| 78 |
-
const { dashboardState } = useDashboardStateContext()
|
| 79 |
-
const site = useSiteContext()
|
| 80 |
-
|
| 81 |
-
const reportConfig = BREAKDOWN_REPORTS[breakdownReportKey]
|
| 82 |
-
|
| 83 |
-
/*global BUILD_EXTRA*/
|
| 84 |
-
const isRevenueAvailable =
|
| 85 |
-
BUILD_EXTRA && revenueAvailable(dashboardState, site)
|
| 86 |
-
|
| 87 |
-
const metrics = reportConfig.getMetrics({
|
| 88 |
-
hasConversionGoalFilter: hasConversionGoalFilter(dashboardState),
|
| 89 |
-
isRealtime: isRealTimeDashboard(dashboardState),
|
| 90 |
-
isDetailed: true,
|
| 91 |
-
isRevenueAvailable: isRevenueAvailable,
|
| 92 |
-
hasEventFilters: hasEventFilters(dashboardState)
|
| 93 |
-
})
|
| 94 |
-
|
| 95 |
-
const DimensionElement =
|
| 96 |
-
HOSTNAME_DIMENSION_ELEMENTS[breakdownReportKey] ?? PathDimensionElement
|
| 97 |
-
|
| 98 |
-
return (
|
| 99 |
-
<Modal>
|
| 100 |
-
<DetailsBreakdown
|
| 101 |
-
title={pagesDetailsTitle(breakdownReportKey, dashboardState)}
|
| 102 |
-
dimensionLabel={reportConfig.dimensionLabel}
|
| 103 |
-
dimensions={reportConfig.dimensions}
|
| 104 |
-
metrics={metrics}
|
| 105 |
-
alwaysOnFilters={reportConfig.alwaysOnFilters}
|
| 106 |
-
defaultOrderBy={[['visitors', 'desc']]}
|
| 107 |
-
searchDimension={reportConfig.searchDimension}
|
| 108 |
-
DimensionElement={DimensionElement}
|
| 109 |
-
/>
|
| 110 |
-
</Modal>
|
| 111 |
-
)
|
| 112 |
-
}
|
| 113 |
-
|
| 114 |
-
const PathDimensionElement = (props: DimensionCellProps) => {
|
| 115 |
-
const site = useSiteContext()
|
| 116 |
-
const path = props.row.dimensions[0]
|
| 117 |
-
|
| 118 |
-
return (
|
| 119 |
-
<DimensionCell
|
| 120 |
-
text={path}
|
| 121 |
-
externalLink={
|
| 122 |
-
<DetailsExternalLink
|
| 123 |
-
href={externalLinkForPage(site, path)}
|
| 124 |
-
isActive={props.isActive}
|
| 125 |
-
/>
|
| 126 |
-
}
|
| 127 |
-
getFilterInfo={defaultGetFilterInfo}
|
| 128 |
-
{...props}
|
| 129 |
-
/>
|
| 130 |
-
)
|
| 131 |
-
}
|
| 132 |
-
|
| 133 |
-
export function pagesDetailsTitle(
|
| 134 |
-
reportKey: BreakdownReportKey,
|
| 135 |
-
dashboardState: DashboardState
|
| 136 |
-
) {
|
| 137 |
-
if (
|
| 138 |
-
[BreakdownReportKey.pages, BreakdownReportKey.pagesWithHostname].includes(
|
| 139 |
-
reportKey
|
| 140 |
-
) &&
|
| 141 |
-
hasConversionGoalFilter(dashboardState)
|
| 142 |
-
) {
|
| 143 |
-
return 'Conversion pages'
|
| 144 |
-
}
|
| 145 |
-
return BREAKDOWN_REPORTS[reportKey].detailsTitle
|
| 146 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/dashboard/stats/pages/external-link.tsx
DELETED
|
@@ -1,50 +0,0 @@
|
|
| 1 |
-
import React from 'react'
|
| 2 |
-
import { ExternalLinkIcon } from '../breakdowns'
|
| 3 |
-
|
| 4 |
-
export function DetailsExternalLink({
|
| 5 |
-
href,
|
| 6 |
-
isActive
|
| 7 |
-
}: {
|
| 8 |
-
href: string | null
|
| 9 |
-
isActive?: boolean
|
| 10 |
-
}) {
|
| 11 |
-
return (
|
| 12 |
-
<div className="w-4 min-w-4 self-stretch flex flex-col justify-center">
|
| 13 |
-
{href && (
|
| 14 |
-
<a
|
| 15 |
-
target="_blank"
|
| 16 |
-
rel="noreferrer"
|
| 17 |
-
href={href}
|
| 18 |
-
aria-label="Open page in a new tab"
|
| 19 |
-
className={isActive ? 'block' : 'hidden'}
|
| 20 |
-
>
|
| 21 |
-
<ExternalLinkIcon />
|
| 22 |
-
</a>
|
| 23 |
-
)}
|
| 24 |
-
</div>
|
| 25 |
-
)
|
| 26 |
-
}
|
| 27 |
-
|
| 28 |
-
export function IndexExternalLink({
|
| 29 |
-
href,
|
| 30 |
-
isActive
|
| 31 |
-
}: {
|
| 32 |
-
href: string
|
| 33 |
-
isActive?: boolean
|
| 34 |
-
}) {
|
| 35 |
-
return (
|
| 36 |
-
<a
|
| 37 |
-
target="_blank"
|
| 38 |
-
rel="noreferrer"
|
| 39 |
-
href={href}
|
| 40 |
-
aria-label="Open page in a new tab"
|
| 41 |
-
className={
|
| 42 |
-
isActive
|
| 43 |
-
? 'visible md:invisible md:group-hover/row:visible'
|
| 44 |
-
: 'invisible md:group-hover/row:visible'
|
| 45 |
-
}
|
| 46 |
-
>
|
| 47 |
-
<ExternalLinkIcon />
|
| 48 |
-
</a>
|
| 49 |
-
)
|
| 50 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/dashboard/stats/pages/index.tsx
DELETED
|
@@ -1,321 +0,0 @@
|
|
| 1 |
-
import React, { useRef, useState } from 'react'
|
| 2 |
-
import { Popover, Transition } from '@headlessui/react'
|
| 3 |
-
import { EllipsisVerticalIcon } from '@heroicons/react/24/outline'
|
| 4 |
-
import classNames from 'classnames'
|
| 5 |
-
|
| 6 |
-
import * as storage from '../../util/storage'
|
| 7 |
-
import {
|
| 8 |
-
hasConversionGoalFilter,
|
| 9 |
-
isRealTimeDashboard
|
| 10 |
-
} from '../../util/filters'
|
| 11 |
-
import { useDashboardStateContext } from '../../dashboard-state-context'
|
| 12 |
-
import { useSiteContext } from '../../site-context'
|
| 13 |
-
import { ReportLayout } from '../reports/report-layout'
|
| 14 |
-
import { ReportHeader } from '../reports/report-header'
|
| 15 |
-
import { TabButton, TabWrapper } from '../../components/tabs'
|
| 16 |
-
import MoreLink from '../more-link'
|
| 17 |
-
import { MoreLinkState } from '../more-link-state'
|
| 18 |
-
import { QueryApiResponse } from '../../api'
|
| 19 |
-
import ImportedWarningBubble from '../imported-warning-bubble'
|
| 20 |
-
import {
|
| 21 |
-
BREAKDOWN_REPORTS,
|
| 22 |
-
BreakdownReportKey
|
| 23 |
-
} from '../reports/reports-config'
|
| 24 |
-
import {
|
| 25 |
-
DimensionCellWithBar,
|
| 26 |
-
IndexBreakdown,
|
| 27 |
-
DimensionCellWithBarProps
|
| 28 |
-
} from '../reports/index-breakdown'
|
| 29 |
-
import { defaultGetFilterInfo, GetFilterInfo } from '../breakdowns'
|
| 30 |
-
import { Filter } from '../../dashboard-state'
|
| 31 |
-
import { externalLinkForPage, trimURL } from '../../util/url'
|
| 32 |
-
import { IndexExternalLink } from './external-link'
|
| 33 |
-
import {
|
| 34 |
-
popover,
|
| 35 |
-
BlurMenuButtonOnEscape,
|
| 36 |
-
SelectedCheckmark
|
| 37 |
-
} from '../../components/popover'
|
| 38 |
-
import { pagesDetailsTitle } from './details'
|
| 39 |
-
|
| 40 |
-
const BAR_COLOR = 'bg-orange-50 group-hover/row:bg-orange-100'
|
| 41 |
-
const MAX_DIMENSION_LENGTH = 70
|
| 42 |
-
|
| 43 |
-
type BreakdownMode = 'path' | 'hostname'
|
| 44 |
-
type TabKey =
|
| 45 |
-
| BreakdownReportKey.pages
|
| 46 |
-
| BreakdownReportKey.entryPages
|
| 47 |
-
| BreakdownReportKey.exitPages
|
| 48 |
-
|
| 49 |
-
const BREAKDOWN_MODE_OPTIONS: Array<{ value: BreakdownMode; label: string }> = [
|
| 50 |
-
{ value: 'path', label: 'Path' },
|
| 51 |
-
{ value: 'hostname', label: 'URL' } // URL is more suitable for user-facing label
|
| 52 |
-
]
|
| 53 |
-
|
| 54 |
-
function getReportKey(tab: TabKey, mode: BreakdownMode): BreakdownReportKey {
|
| 55 |
-
if (mode === 'hostname') {
|
| 56 |
-
switch (tab) {
|
| 57 |
-
case BreakdownReportKey.pages:
|
| 58 |
-
return BreakdownReportKey.pagesWithHostname
|
| 59 |
-
case BreakdownReportKey.entryPages:
|
| 60 |
-
return BreakdownReportKey.entryPagesWithHostname
|
| 61 |
-
case BreakdownReportKey.exitPages:
|
| 62 |
-
return BreakdownReportKey.exitPagesWithHostname
|
| 63 |
-
}
|
| 64 |
-
}
|
| 65 |
-
return tab
|
| 66 |
-
}
|
| 67 |
-
|
| 68 |
-
export default function Pages() {
|
| 69 |
-
const { dashboardState } = useDashboardStateContext()
|
| 70 |
-
const site = useSiteContext()
|
| 71 |
-
|
| 72 |
-
const tabStorageKey = `pageTab__${site.domain}`
|
| 73 |
-
const modeStorageKey = `pageBreakdownMode__${site.domain}`
|
| 74 |
-
const [tab, setTab] = useState<TabKey>(
|
| 75 |
-
initTab(storage.getItem(tabStorageKey))
|
| 76 |
-
)
|
| 77 |
-
const [breakdownMode, setBreakdownMode] = useState<BreakdownMode>(() =>
|
| 78 |
-
storage.getItem(modeStorageKey) === 'hostname' ? 'hostname' : 'path'
|
| 79 |
-
)
|
| 80 |
-
const [currentData, setCurrentData] = useState<QueryApiResponse | null>(null)
|
| 81 |
-
|
| 82 |
-
const reportKey = getReportKey(tab, breakdownMode)
|
| 83 |
-
const reportConfig = BREAKDOWN_REPORTS[reportKey]
|
| 84 |
-
|
| 85 |
-
const metrics = reportConfig.getMetrics({
|
| 86 |
-
isRealtime: isRealTimeDashboard(dashboardState),
|
| 87 |
-
hasConversionGoalFilter: hasConversionGoalFilter(dashboardState)
|
| 88 |
-
})
|
| 89 |
-
|
| 90 |
-
function switchTab(newTab: TabKey) {
|
| 91 |
-
storage.setItem(tabStorageKey, newTab)
|
| 92 |
-
setTab(newTab)
|
| 93 |
-
}
|
| 94 |
-
|
| 95 |
-
function selectBreakdownMode(mode: BreakdownMode) {
|
| 96 |
-
storage.setItem(modeStorageKey, mode)
|
| 97 |
-
setBreakdownMode(mode)
|
| 98 |
-
}
|
| 99 |
-
|
| 100 |
-
const moreLinkState = currentData
|
| 101 |
-
? currentData.results.length > 0
|
| 102 |
-
? MoreLinkState.READY
|
| 103 |
-
: MoreLinkState.HIDDEN
|
| 104 |
-
: MoreLinkState.LOADING
|
| 105 |
-
|
| 106 |
-
const DimensionElement =
|
| 107 |
-
breakdownMode === 'hostname'
|
| 108 |
-
? HOSTNAME_DIMENSION_CELLS[tab]
|
| 109 |
-
: PathDimensionCell
|
| 110 |
-
|
| 111 |
-
return (
|
| 112 |
-
<ReportLayout testId="report-pages" className="overflow-x-hidden">
|
| 113 |
-
<ReportHeader>
|
| 114 |
-
<div className="flex gap-x-3">
|
| 115 |
-
<TabWrapper>
|
| 116 |
-
{(
|
| 117 |
-
[
|
| 118 |
-
BreakdownReportKey.pages,
|
| 119 |
-
BreakdownReportKey.entryPages,
|
| 120 |
-
BreakdownReportKey.exitPages
|
| 121 |
-
] as const
|
| 122 |
-
).map((reportKey) => (
|
| 123 |
-
<TabButton
|
| 124 |
-
key={reportKey}
|
| 125 |
-
active={tab === reportKey}
|
| 126 |
-
onClick={() => switchTab(reportKey)}
|
| 127 |
-
>
|
| 128 |
-
{pagesDetailsTitle(reportKey, dashboardState)}
|
| 129 |
-
</TabButton>
|
| 130 |
-
))}
|
| 131 |
-
</TabWrapper>
|
| 132 |
-
<ImportedWarningBubble
|
| 133 |
-
queryApiResponse={currentData}
|
| 134 |
-
message={
|
| 135 |
-
breakdownMode === 'hostname' && tab !== BreakdownReportKey.pages
|
| 136 |
-
? 'Imported data is unavailable in this view'
|
| 137 |
-
: undefined
|
| 138 |
-
}
|
| 139 |
-
/>
|
| 140 |
-
</div>
|
| 141 |
-
<div className="flex items-start gap-x-3">
|
| 142 |
-
<MoreLink
|
| 143 |
-
state={moreLinkState}
|
| 144 |
-
linkProps={{
|
| 145 |
-
path: reportConfig.detailsPath,
|
| 146 |
-
search: (search: string) => search
|
| 147 |
-
}}
|
| 148 |
-
/>
|
| 149 |
-
<PagesBreakdownMenu
|
| 150 |
-
breakdownMode={breakdownMode}
|
| 151 |
-
onSelect={selectBreakdownMode}
|
| 152 |
-
/>
|
| 153 |
-
</div>
|
| 154 |
-
</ReportHeader>
|
| 155 |
-
<IndexBreakdown
|
| 156 |
-
key={reportKey}
|
| 157 |
-
metrics={metrics}
|
| 158 |
-
dimensions={reportConfig.dimensions}
|
| 159 |
-
dimensionLabel={reportConfig.dimensionLabel}
|
| 160 |
-
alwaysOnFilters={reportConfig.alwaysOnFilters}
|
| 161 |
-
DimensionElement={DimensionElement}
|
| 162 |
-
onDataReady={setCurrentData}
|
| 163 |
-
/>
|
| 164 |
-
</ReportLayout>
|
| 165 |
-
)
|
| 166 |
-
}
|
| 167 |
-
|
| 168 |
-
function PagesBreakdownMenu({
|
| 169 |
-
breakdownMode,
|
| 170 |
-
onSelect
|
| 171 |
-
}: {
|
| 172 |
-
breakdownMode: BreakdownMode
|
| 173 |
-
onSelect: (mode: BreakdownMode) => void
|
| 174 |
-
}) {
|
| 175 |
-
const buttonRef = useRef<HTMLButtonElement>(null)
|
| 176 |
-
|
| 177 |
-
return (
|
| 178 |
-
<Popover className="relative">
|
| 179 |
-
{({ close }) => (
|
| 180 |
-
<>
|
| 181 |
-
<BlurMenuButtonOnEscape targetRef={buttonRef} />
|
| 182 |
-
<Popover.Button
|
| 183 |
-
ref={buttonRef}
|
| 184 |
-
className={classNames(
|
| 185 |
-
'relative flex rounded !text-gray-500 dark:!text-gray-400 hover:!text-gray-600 dark:hover:!text-gray-300 transition-colors duration-150 before:absolute before:inset-[-8px]',
|
| 186 |
-
popover.toggleButton.classNames.linkLike,
|
| 187 |
-
'justify-center'
|
| 188 |
-
)}
|
| 189 |
-
title="Breakdown options"
|
| 190 |
-
aria-label="Breakdown options"
|
| 191 |
-
>
|
| 192 |
-
<EllipsisVerticalIcon className="size-4.5" />
|
| 193 |
-
</Popover.Button>
|
| 194 |
-
<Transition
|
| 195 |
-
as="div"
|
| 196 |
-
{...popover.transition.props}
|
| 197 |
-
className={classNames(
|
| 198 |
-
popover.transition.classNames.right,
|
| 199 |
-
'mt-2 min-w-48'
|
| 200 |
-
)}
|
| 201 |
-
>
|
| 202 |
-
<Popover.Panel className={popover.panel.classNames.roundedSheet}>
|
| 203 |
-
<p className="uppercase text-xs font-medium text-gray-500 dark:text-gray-400 px-4 py-2.5 whitespace-nowrap">
|
| 204 |
-
Break down by
|
| 205 |
-
</p>
|
| 206 |
-
<div
|
| 207 |
-
data-testid="dropdown-items"
|
| 208 |
-
className="flex flex-col gap-y-0.5"
|
| 209 |
-
>
|
| 210 |
-
{BREAKDOWN_MODE_OPTIONS.map(({ value, label }) => (
|
| 211 |
-
<button
|
| 212 |
-
key={value}
|
| 213 |
-
onClick={() => {
|
| 214 |
-
onSelect(value)
|
| 215 |
-
close()
|
| 216 |
-
}}
|
| 217 |
-
data-selected={value === breakdownMode}
|
| 218 |
-
className={classNames(
|
| 219 |
-
popover.items.classNames.navigationLink,
|
| 220 |
-
popover.items.classNames.selectedOption,
|
| 221 |
-
popover.items.classNames.hoverLink
|
| 222 |
-
)}
|
| 223 |
-
>
|
| 224 |
-
{label}
|
| 225 |
-
<SelectedCheckmark selected={value === breakdownMode} />
|
| 226 |
-
</button>
|
| 227 |
-
))}
|
| 228 |
-
</div>
|
| 229 |
-
</Popover.Panel>
|
| 230 |
-
</Transition>
|
| 231 |
-
</>
|
| 232 |
-
)}
|
| 233 |
-
</Popover>
|
| 234 |
-
)
|
| 235 |
-
}
|
| 236 |
-
|
| 237 |
-
function PathDimensionCell(props: DimensionCellWithBarProps) {
|
| 238 |
-
const site = useSiteContext()
|
| 239 |
-
const path = props.row.dimensions[0]
|
| 240 |
-
const externalUrl = externalLinkForPage(site, path)
|
| 241 |
-
|
| 242 |
-
return (
|
| 243 |
-
<DimensionCellWithBar
|
| 244 |
-
getFilterInfo={defaultGetFilterInfo}
|
| 245 |
-
text={trimURL(path, MAX_DIMENSION_LENGTH)}
|
| 246 |
-
barClassName={BAR_COLOR}
|
| 247 |
-
externalLink={
|
| 248 |
-
externalUrl && (
|
| 249 |
-
<IndexExternalLink href={externalUrl} isActive={props.isActive} />
|
| 250 |
-
)
|
| 251 |
-
}
|
| 252 |
-
{...props}
|
| 253 |
-
/>
|
| 254 |
-
)
|
| 255 |
-
}
|
| 256 |
-
|
| 257 |
-
function makeHostnameDimensionCell(pageFilterKey: string) {
|
| 258 |
-
const getFilterInfo: GetFilterInfo = (_dim, row) => ({
|
| 259 |
-
prefix: 'hostname',
|
| 260 |
-
filter: ['is', 'hostname', [row.dimensions[0]]] as Filter,
|
| 261 |
-
extraFilters: [
|
| 262 |
-
{
|
| 263 |
-
prefix: pageFilterKey,
|
| 264 |
-
filter: ['is', pageFilterKey, [row.dimensions[1]]] as Filter
|
| 265 |
-
}
|
| 266 |
-
]
|
| 267 |
-
})
|
| 268 |
-
return function HostnameDimensionCell(props: DimensionCellWithBarProps) {
|
| 269 |
-
const site = useSiteContext()
|
| 270 |
-
const hostname = props.row.dimensions[0]
|
| 271 |
-
const path = props.row.dimensions[1]
|
| 272 |
-
const externalUrl = externalLinkForPage(site, path, hostname)
|
| 273 |
-
|
| 274 |
-
const displayValue = trimURL(
|
| 275 |
-
`https://${hostname}${path}`,
|
| 276 |
-
MAX_DIMENSION_LENGTH + 8
|
| 277 |
-
).replace(/^https:\/\//, '')
|
| 278 |
-
|
| 279 |
-
return (
|
| 280 |
-
<DimensionCellWithBar
|
| 281 |
-
getFilterInfo={getFilterInfo}
|
| 282 |
-
text={displayValue}
|
| 283 |
-
barClassName={BAR_COLOR}
|
| 284 |
-
externalLink={
|
| 285 |
-
externalUrl && (
|
| 286 |
-
<IndexExternalLink href={externalUrl} isActive={props.isActive} />
|
| 287 |
-
)
|
| 288 |
-
}
|
| 289 |
-
{...props}
|
| 290 |
-
/>
|
| 291 |
-
)
|
| 292 |
-
}
|
| 293 |
-
}
|
| 294 |
-
|
| 295 |
-
const HOSTNAME_DIMENSION_CELLS: Record<
|
| 296 |
-
TabKey,
|
| 297 |
-
(props: DimensionCellWithBarProps) => React.ReactNode
|
| 298 |
-
> = {
|
| 299 |
-
[BreakdownReportKey.pages]: makeHostnameDimensionCell('page'),
|
| 300 |
-
[BreakdownReportKey.entryPages]: makeHostnameDimensionCell('entry_page'),
|
| 301 |
-
[BreakdownReportKey.exitPages]: makeHostnameDimensionCell('exit_page')
|
| 302 |
-
}
|
| 303 |
-
|
| 304 |
-
const initTab = (storedTab: string): TabKey => {
|
| 305 |
-
switch (storedTab) {
|
| 306 |
-
case LegacyTabKey.entryPages:
|
| 307 |
-
case BreakdownReportKey.entryPages:
|
| 308 |
-
return BreakdownReportKey.entryPages
|
| 309 |
-
case LegacyTabKey.exitPages:
|
| 310 |
-
case BreakdownReportKey.exitPages:
|
| 311 |
-
return BreakdownReportKey.exitPages
|
| 312 |
-
case BreakdownReportKey.pages:
|
| 313 |
-
default:
|
| 314 |
-
return BreakdownReportKey.pages
|
| 315 |
-
}
|
| 316 |
-
}
|
| 317 |
-
|
| 318 |
-
enum LegacyTabKey {
|
| 319 |
-
entryPages = 'entry-pages',
|
| 320 |
-
exitPages = 'exit-pages'
|
| 321 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/dashboard/stats/reports/change-arrow.test.tsx
DELETED
|
@@ -1,81 +0,0 @@
|
|
| 1 |
-
import React from 'react'
|
| 2 |
-
import { render, screen } from '@testing-library/react'
|
| 3 |
-
import { ChangeArrow } from './change-arrow'
|
| 4 |
-
|
| 5 |
-
jest.mock('@heroicons/react/24/solid', () => ({
|
| 6 |
-
ArrowUpRightIcon: ({ className, ...props }: { className: string }) => (
|
| 7 |
-
<span className={className} {...props}>
|
| 8 |
-
↑
|
| 9 |
-
</span>
|
| 10 |
-
),
|
| 11 |
-
ArrowDownRightIcon: ({ className, ...props }: { className: string }) => (
|
| 12 |
-
<span className={className} {...props}>
|
| 13 |
-
↓
|
| 14 |
-
</span>
|
| 15 |
-
)
|
| 16 |
-
}))
|
| 17 |
-
|
| 18 |
-
it('renders up arrow for positive change', () => {
|
| 19 |
-
render(<ChangeArrow change={1} className="text-xs" metric="visitors" />)
|
| 20 |
-
|
| 21 |
-
const arrowElement = screen.getByTestId('change-arrow')
|
| 22 |
-
|
| 23 |
-
expect(arrowElement).toHaveTextContent('↑ 1%')
|
| 24 |
-
expect(arrowElement.children[0]).toHaveClass('text-green-500')
|
| 25 |
-
})
|
| 26 |
-
|
| 27 |
-
it('renders down arrow for negative change', () => {
|
| 28 |
-
render(<ChangeArrow change={-10} className="text-xs" metric="visitors" />)
|
| 29 |
-
|
| 30 |
-
const arrowElement = screen.getByTestId('change-arrow')
|
| 31 |
-
|
| 32 |
-
expect(arrowElement).toHaveTextContent('↓ 10%')
|
| 33 |
-
expect(arrowElement.children[0]).toHaveClass('text-red-500')
|
| 34 |
-
})
|
| 35 |
-
|
| 36 |
-
it('renders no arrow for no change', () => {
|
| 37 |
-
render(<ChangeArrow change={0} className="text-xs" metric="visitors" />)
|
| 38 |
-
|
| 39 |
-
const arrowElement = screen.getByTestId('change-arrow')
|
| 40 |
-
|
| 41 |
-
expect(arrowElement).toHaveTextContent('0%')
|
| 42 |
-
expect(arrowElement.children).toHaveLength(0)
|
| 43 |
-
})
|
| 44 |
-
|
| 45 |
-
it('inverts arrow direction for positive bounce_rate change', () => {
|
| 46 |
-
render(<ChangeArrow change={15} className="text-xs" metric="bounce_rate" />)
|
| 47 |
-
|
| 48 |
-
const arrowElement = screen.getByTestId('change-arrow')
|
| 49 |
-
|
| 50 |
-
expect(arrowElement).toHaveTextContent('↑ 15%')
|
| 51 |
-
expect(arrowElement.children[0]).toHaveClass('text-red-500')
|
| 52 |
-
})
|
| 53 |
-
|
| 54 |
-
it('inverts arrow direction for negative bounce_rate change', () => {
|
| 55 |
-
render(<ChangeArrow change={-3} className="text-xs" metric="bounce_rate" />)
|
| 56 |
-
|
| 57 |
-
const arrowElement = screen.getByTestId('change-arrow')
|
| 58 |
-
|
| 59 |
-
expect(arrowElement).toHaveTextContent('↓ 3%')
|
| 60 |
-
expect(arrowElement.children[0]).toHaveClass('text-green-500')
|
| 61 |
-
})
|
| 62 |
-
|
| 63 |
-
it('renders with text hidden', () => {
|
| 64 |
-
render(
|
| 65 |
-
<ChangeArrow change={-3} className="text-xs" metric="visitors" hideNumber />
|
| 66 |
-
)
|
| 67 |
-
|
| 68 |
-
const arrowElement = screen.getByTestId('change-arrow')
|
| 69 |
-
|
| 70 |
-
expect(arrowElement).toHaveTextContent('↓')
|
| 71 |
-
expect(arrowElement.children[0]).toHaveClass('text-red-500')
|
| 72 |
-
})
|
| 73 |
-
|
| 74 |
-
it('renders no content with text hidden and 0 change', () => {
|
| 75 |
-
render(
|
| 76 |
-
<ChangeArrow change={0} className="text-xs" metric="visitors" hideNumber />
|
| 77 |
-
)
|
| 78 |
-
|
| 79 |
-
const arrowElement = screen.getByTestId('change-arrow')
|
| 80 |
-
expect(arrowElement).toHaveTextContent('')
|
| 81 |
-
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/dashboard/stats/reports/change-arrow.tsx
DELETED
|
@@ -1,46 +0,0 @@
|
|
| 1 |
-
import React from 'react'
|
| 2 |
-
import { Metric } from '../metrics'
|
| 3 |
-
import { numberShortFormatter } from '../../util/number-formatter'
|
| 4 |
-
import { ArrowDownRightIcon, ArrowUpRightIcon } from '@heroicons/react/24/solid'
|
| 5 |
-
import classNames from 'classnames'
|
| 6 |
-
|
| 7 |
-
export function ChangeArrow({
|
| 8 |
-
change,
|
| 9 |
-
metric,
|
| 10 |
-
className,
|
| 11 |
-
hideNumber
|
| 12 |
-
}: {
|
| 13 |
-
change: number
|
| 14 |
-
metric: Metric
|
| 15 |
-
className: string
|
| 16 |
-
hideNumber?: boolean
|
| 17 |
-
}) {
|
| 18 |
-
let icon = null
|
| 19 |
-
const arrowClassName = classNames(
|
| 20 |
-
color(change, metric),
|
| 21 |
-
'mb-0.5 inline-block size-2.5 stroke-[1.5px] stroke-current'
|
| 22 |
-
)
|
| 23 |
-
|
| 24 |
-
if (change > 0) {
|
| 25 |
-
icon = <ArrowUpRightIcon className={arrowClassName} />
|
| 26 |
-
} else if (change < 0) {
|
| 27 |
-
icon = <ArrowDownRightIcon className={arrowClassName} />
|
| 28 |
-
}
|
| 29 |
-
|
| 30 |
-
const formattedChange = hideNumber
|
| 31 |
-
? null
|
| 32 |
-
: `${icon ? ' ' : ''}${numberShortFormatter(Math.abs(change))}%`
|
| 33 |
-
|
| 34 |
-
return (
|
| 35 |
-
<span className={className} data-testid="change-arrow">
|
| 36 |
-
{icon}
|
| 37 |
-
{formattedChange}
|
| 38 |
-
</span>
|
| 39 |
-
)
|
| 40 |
-
}
|
| 41 |
-
|
| 42 |
-
function color(change: number, metric: Metric) {
|
| 43 |
-
const invert = metric === 'bounce_rate'
|
| 44 |
-
|
| 45 |
-
return change > 0 != invert ? 'text-green-500' : 'text-red-500'
|
| 46 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/dashboard/stats/reports/index-breakdown.tsx
DELETED
|
@@ -1,594 +0,0 @@
|
|
| 1 |
-
import React, {
|
| 2 |
-
useCallback,
|
| 3 |
-
useMemo,
|
| 4 |
-
useState,
|
| 5 |
-
useEffect,
|
| 6 |
-
ReactNode
|
| 7 |
-
} from 'react'
|
| 8 |
-
import FlipMove from 'react-flip-move'
|
| 9 |
-
import LazyLoader from '../../components/lazy-loader'
|
| 10 |
-
import { useDashboardStateContext } from '../../dashboard-state-context'
|
| 11 |
-
import { useSiteContext } from '../../site-context'
|
| 12 |
-
import { NonTimeDimension } from '../../stats-query'
|
| 13 |
-
import { Metric, getBreakdownMetricLabel } from '../metrics'
|
| 14 |
-
import {
|
| 15 |
-
ColumnConfiguration,
|
| 16 |
-
MetricValueTooltipContent,
|
| 17 |
-
SharedBreakdownReportProps,
|
| 18 |
-
formatDateRangeLabel,
|
| 19 |
-
useBodyPortalRef,
|
| 20 |
-
extractMetricValue,
|
| 21 |
-
MetricValueWrapper,
|
| 22 |
-
GetFilterInfo,
|
| 23 |
-
useColumnsHiddenForAllNull,
|
| 24 |
-
dimensionOrderBy
|
| 25 |
-
} from '../breakdowns'
|
| 26 |
-
import { DrilldownLink } from '../../components/drilldown-link'
|
| 27 |
-
import { QueryResultRow, QueryResultQuery, QueryApiResponse } from '../../api'
|
| 28 |
-
import classNames from 'classnames'
|
| 29 |
-
import { Tooltip } from '../../util/tooltip'
|
| 30 |
-
import { ChangeArrow } from './change-arrow'
|
| 31 |
-
import { MetricFormatterShort, MetricFormatterLong } from './metric-formatter'
|
| 32 |
-
import {
|
| 33 |
-
hasConversionGoalFilter,
|
| 34 |
-
isRealTimeDashboard
|
| 35 |
-
} from '../../util/filters'
|
| 36 |
-
import {
|
| 37 |
-
StatsReportId,
|
| 38 |
-
StatsReportQueryKey,
|
| 39 |
-
useQueryApi
|
| 40 |
-
} from '../../hooks/use-query-api'
|
| 41 |
-
|
| 42 |
-
const MAX_ITEMS = 9
|
| 43 |
-
export const MIN_HEIGHT = 356
|
| 44 |
-
export const FADE_IN_CLASSNAME =
|
| 45 |
-
'opacity-100 transition-opacity duration-300 starting:opacity-0'
|
| 46 |
-
const ROW_HEIGHT = 32
|
| 47 |
-
const ROW_GAP_HEIGHT = 4
|
| 48 |
-
const DATA_CONTAINER_HEIGHT =
|
| 49 |
-
(ROW_HEIGHT + ROW_GAP_HEIGHT) * (MAX_ITEMS - 1) + ROW_HEIGHT
|
| 50 |
-
|
| 51 |
-
export const DEFAULT_METRIC_COLUMN_WIDTH = 'w-16 min-w-16'
|
| 52 |
-
const VISITORS_WITH_PERCENTAGE_COLUMN_WIDTH = 'w-32 min-w-32'
|
| 53 |
-
|
| 54 |
-
const BAR_METRIC = 'visitors'
|
| 55 |
-
|
| 56 |
-
type IndexBreakdownProps = SharedBreakdownReportProps & {
|
| 57 |
-
metricColumnWidth?: string
|
| 58 |
-
DimensionElement: (props: DimensionCellWithBarProps) => ReactNode
|
| 59 |
-
onDataReady?: (data: QueryApiResponse) => void
|
| 60 |
-
hideMetricsOnMobile?: Metric[]
|
| 61 |
-
}
|
| 62 |
-
|
| 63 |
-
export function IndexBreakdown({
|
| 64 |
-
metrics,
|
| 65 |
-
dimensions,
|
| 66 |
-
DimensionElement,
|
| 67 |
-
dimensionLabel,
|
| 68 |
-
alwaysOnFilters,
|
| 69 |
-
onDataReady,
|
| 70 |
-
metricColumnWidth = DEFAULT_METRIC_COLUMN_WIDTH,
|
| 71 |
-
bundlePercentageWithVisitors = true,
|
| 72 |
-
hideMetricsIfAllNull,
|
| 73 |
-
hideMetricsOnMobile,
|
| 74 |
-
getStatsQuery
|
| 75 |
-
}: IndexBreakdownProps) {
|
| 76 |
-
const site = useSiteContext()
|
| 77 |
-
const { dashboardState } = useDashboardStateContext()
|
| 78 |
-
const [visible, setVisible] = useState(false)
|
| 79 |
-
|
| 80 |
-
const statsReportQueryKey: StatsReportQueryKey = [
|
| 81 |
-
dimensions.join(',') as StatsReportId,
|
| 82 |
-
{
|
| 83 |
-
dashboardState,
|
| 84 |
-
reportParams: {
|
| 85 |
-
metrics,
|
| 86 |
-
dimensions,
|
| 87 |
-
order_by: [['visitors', 'desc'], ...dimensionOrderBy(dimensions)],
|
| 88 |
-
alwaysOnFilters,
|
| 89 |
-
pagination: { limit: MAX_ITEMS, offset: 0 }
|
| 90 |
-
}
|
| 91 |
-
}
|
| 92 |
-
]
|
| 93 |
-
|
| 94 |
-
const { apiState, isRealtimeSilentUpdate } = useQueryApi(
|
| 95 |
-
site,
|
| 96 |
-
statsReportQueryKey,
|
| 97 |
-
{ enabled: visible, getStatsQuery }
|
| 98 |
-
)
|
| 99 |
-
|
| 100 |
-
useEffect(() => {
|
| 101 |
-
if (apiState.data && typeof onDataReady === 'function') {
|
| 102 |
-
onDataReady(apiState.data)
|
| 103 |
-
}
|
| 104 |
-
}, [apiState.data, onDataReady])
|
| 105 |
-
|
| 106 |
-
const query: QueryResultQuery | null = apiState.data?.query ?? null
|
| 107 |
-
|
| 108 |
-
const barMetricIndex = query
|
| 109 |
-
? query.metrics.findIndex((m) => m === BAR_METRIC)
|
| 110 |
-
: null
|
| 111 |
-
|
| 112 |
-
const barMaxValue = useMemo(() => {
|
| 113 |
-
const rows = apiState.data?.results ?? []
|
| 114 |
-
return barMetricIndex === null
|
| 115 |
-
? null
|
| 116 |
-
: Math.max(...rows.map((r) => r.metrics[barMetricIndex] as number))
|
| 117 |
-
}, [apiState.data, barMetricIndex])
|
| 118 |
-
|
| 119 |
-
const metricLabelFor = useCallback(
|
| 120 |
-
(metric: Metric): string => {
|
| 121 |
-
return getBreakdownMetricLabel(metric, {
|
| 122 |
-
hasConversionGoalFilter: hasConversionGoalFilter(dashboardState),
|
| 123 |
-
isRealtime: isRealTimeDashboard(dashboardState),
|
| 124 |
-
dimensions: dimensions
|
| 125 |
-
})
|
| 126 |
-
},
|
| 127 |
-
[dashboardState, dimensions]
|
| 128 |
-
)
|
| 129 |
-
|
| 130 |
-
const columnsHiddenForAllNull = useColumnsHiddenForAllNull(
|
| 131 |
-
apiState.data?.results,
|
| 132 |
-
query,
|
| 133 |
-
hideMetricsIfAllNull
|
| 134 |
-
)
|
| 135 |
-
|
| 136 |
-
const columns = useMemo((): ColumnConfiguration<QueryResultRow>[] | null => {
|
| 137 |
-
if (!query || barMetricIndex === null || barMaxValue === null) return null
|
| 138 |
-
|
| 139 |
-
// Only render columns for metrics the API actually returned. When
|
| 140 |
-
// bundlePercentageWithVisitors is on (default), `percentage` is shown
|
| 141 |
-
// inline in the Visitors cell rather than as its own column.
|
| 142 |
-
const filteredMetrics = query.metrics.filter((m) => {
|
| 143 |
-
if (columnsHiddenForAllNull.has(m)) return false
|
| 144 |
-
return !(bundlePercentageWithVisitors && m === 'percentage')
|
| 145 |
-
})
|
| 146 |
-
|
| 147 |
-
const filterDimension = query.dimensions[0] as NonTimeDimension
|
| 148 |
-
|
| 149 |
-
const hasPercentage = query.metrics.includes('percentage')
|
| 150 |
-
const isVisitorsWithPercentageCell = (m: Metric) =>
|
| 151 |
-
bundlePercentageWithVisitors && hasPercentage && m === 'visitors'
|
| 152 |
-
|
| 153 |
-
return [
|
| 154 |
-
{
|
| 155 |
-
key: 'dimension',
|
| 156 |
-
renderLabel: () => dimensionLabel,
|
| 157 |
-
renderCell: (row, isActive) => (
|
| 158 |
-
<DimensionElement
|
| 159 |
-
filterDimension={filterDimension}
|
| 160 |
-
row={row}
|
| 161 |
-
barWidthPercent={
|
| 162 |
-
((row.metrics[barMetricIndex] as number) / barMaxValue) * 100
|
| 163 |
-
}
|
| 164 |
-
isActive={isActive}
|
| 165 |
-
/>
|
| 166 |
-
),
|
| 167 |
-
align: 'left'
|
| 168 |
-
},
|
| 169 |
-
...filteredMetrics.map(
|
| 170 |
-
(metric): ColumnConfiguration<QueryResultRow> => ({
|
| 171 |
-
key: metric,
|
| 172 |
-
renderLabel: () => metricLabelFor(metric),
|
| 173 |
-
hideOnMobile: hideMetricsOnMobile?.includes(metric),
|
| 174 |
-
renderCell: (row, isActive) => {
|
| 175 |
-
if (isVisitorsWithPercentageCell(metric)) {
|
| 176 |
-
return (
|
| 177 |
-
<VisitorsWithPercentageCell
|
| 178 |
-
row={row}
|
| 179 |
-
query={query}
|
| 180 |
-
isActive={isActive}
|
| 181 |
-
/>
|
| 182 |
-
)
|
| 183 |
-
} else {
|
| 184 |
-
return (
|
| 185 |
-
<MetricValueCell
|
| 186 |
-
row={row}
|
| 187 |
-
metric={metric}
|
| 188 |
-
metricLabel={metricLabelFor(metric)}
|
| 189 |
-
query={query}
|
| 190 |
-
/>
|
| 191 |
-
)
|
| 192 |
-
}
|
| 193 |
-
},
|
| 194 |
-
width: isVisitorsWithPercentageCell(metric)
|
| 195 |
-
? VISITORS_WITH_PERCENTAGE_COLUMN_WIDTH
|
| 196 |
-
: metricColumnWidth,
|
| 197 |
-
align: 'right'
|
| 198 |
-
})
|
| 199 |
-
)
|
| 200 |
-
]
|
| 201 |
-
}, [
|
| 202 |
-
dimensionLabel,
|
| 203 |
-
DimensionElement,
|
| 204 |
-
barMetricIndex,
|
| 205 |
-
metricLabelFor,
|
| 206 |
-
barMaxValue,
|
| 207 |
-
query,
|
| 208 |
-
metricColumnWidth,
|
| 209 |
-
bundlePercentageWithVisitors,
|
| 210 |
-
columnsHiddenForAllNull,
|
| 211 |
-
hideMetricsOnMobile
|
| 212 |
-
])
|
| 213 |
-
|
| 214 |
-
return (
|
| 215 |
-
<LazyLoader onVisible={() => setVisible(true)}>
|
| 216 |
-
<IndexBreakdownRenderer<QueryResultRow>
|
| 217 |
-
{...apiState}
|
| 218 |
-
rows={apiState.data?.results?.slice(0, MAX_ITEMS) ?? []}
|
| 219 |
-
getDimensionValue={(row) => row.dimensions.join(',')}
|
| 220 |
-
isRealtimeSilentUpdate={isRealtimeSilentUpdate}
|
| 221 |
-
columns={columns}
|
| 222 |
-
/>
|
| 223 |
-
</LazyLoader>
|
| 224 |
-
)
|
| 225 |
-
}
|
| 226 |
-
|
| 227 |
-
export type DimensionCellWithBarProps = {
|
| 228 |
-
filterDimension: NonTimeDimension
|
| 229 |
-
row: QueryResultRow
|
| 230 |
-
barWidthPercent: number
|
| 231 |
-
isActive?: boolean
|
| 232 |
-
}
|
| 233 |
-
|
| 234 |
-
export const DimensionCellWithBar = ({
|
| 235 |
-
text,
|
| 236 |
-
icon,
|
| 237 |
-
onClick,
|
| 238 |
-
externalLink,
|
| 239 |
-
getFilterInfo,
|
| 240 |
-
filterDimension,
|
| 241 |
-
barWidthPercent,
|
| 242 |
-
barClassName,
|
| 243 |
-
row
|
| 244 |
-
}: {
|
| 245 |
-
text: string
|
| 246 |
-
icon?: ReactNode
|
| 247 |
-
onClick?: () => void
|
| 248 |
-
externalLink?: ReactNode
|
| 249 |
-
getFilterInfo: GetFilterInfo
|
| 250 |
-
barClassName: string
|
| 251 |
-
} & DimensionCellWithBarProps) => (
|
| 252 |
-
<Bar barWidthPercent={barWidthPercent} className={barClassName}>
|
| 253 |
-
<div className="flex justify-start items-center gap-x-1.5 w-full">
|
| 254 |
-
<DrilldownLink
|
| 255 |
-
onClick={onClick}
|
| 256 |
-
filterInfo={getFilterInfo(filterDimension, row)}
|
| 257 |
-
className="max-w-max w-full flex items-center md:overflow-hidden"
|
| 258 |
-
icon={icon}
|
| 259 |
-
textClassName="w-full md:truncate"
|
| 260 |
-
>
|
| 261 |
-
{text}
|
| 262 |
-
</DrilldownLink>
|
| 263 |
-
{externalLink}
|
| 264 |
-
</div>
|
| 265 |
-
</Bar>
|
| 266 |
-
)
|
| 267 |
-
|
| 268 |
-
function VisitorsWithPercentageCell({
|
| 269 |
-
row,
|
| 270 |
-
query,
|
| 271 |
-
isActive
|
| 272 |
-
}: {
|
| 273 |
-
row: QueryResultRow
|
| 274 |
-
query: QueryResultQuery
|
| 275 |
-
isActive?: boolean
|
| 276 |
-
}) {
|
| 277 |
-
const portalRef = useBodyPortalRef()
|
| 278 |
-
|
| 279 |
-
const { value: visitorsValue, comparison: visitorsComparison } =
|
| 280 |
-
extractMetricValue(row, query, 'visitors')
|
| 281 |
-
const { value: percentageValue, comparison: percentageComparison } =
|
| 282 |
-
extractMetricValue(row, query, 'percentage')
|
| 283 |
-
|
| 284 |
-
const visitorsShortFormatter = MetricFormatterShort['visitors']
|
| 285 |
-
const visitorsLongFormatter = MetricFormatterLong['visitors']
|
| 286 |
-
const percentageFormatter = MetricFormatterShort['percentage']
|
| 287 |
-
|
| 288 |
-
const isVisitorsAbbreviated =
|
| 289 |
-
visitorsValue !== null &&
|
| 290 |
-
visitorsShortFormatter(visitorsValue) !==
|
| 291 |
-
visitorsLongFormatter(visitorsValue)
|
| 292 |
-
|
| 293 |
-
const showVisitorsTooltip = !!visitorsComparison || isVisitorsAbbreviated
|
| 294 |
-
const showPercentageTooltip = !!percentageComparison
|
| 295 |
-
|
| 296 |
-
const dateRangeLabel = formatDateRangeLabel(query.date_range)
|
| 297 |
-
const comparisonDateRangeLabel = query.comparison_date_range
|
| 298 |
-
? formatDateRangeLabel(query.comparison_date_range)
|
| 299 |
-
: null
|
| 300 |
-
|
| 301 |
-
const percentageCell = (
|
| 302 |
-
<span
|
| 303 |
-
data-testid="metric-value"
|
| 304 |
-
className={classNames('block w-full text-gray-500 dark:text-gray-400', {
|
| 305 |
-
'translate-x-0 opacity-100 transition-all duration-150': isActive,
|
| 306 |
-
'translate-x-[100%] opacity-0 transition-all duration-150 md:group-hover/report:translate-x-0 md:group-hover/report:opacity-100':
|
| 307 |
-
!isActive
|
| 308 |
-
})}
|
| 309 |
-
>
|
| 310 |
-
{percentageFormatter(percentageValue)}
|
| 311 |
-
{percentageComparison && (
|
| 312 |
-
<ChangeArrow
|
| 313 |
-
change={percentageComparison.change}
|
| 314 |
-
metric={'percentage' as Metric}
|
| 315 |
-
className="inline-block pl-1 w-4"
|
| 316 |
-
hideNumber
|
| 317 |
-
/>
|
| 318 |
-
)}
|
| 319 |
-
</span>
|
| 320 |
-
)
|
| 321 |
-
|
| 322 |
-
const percentageWithTooltip = showPercentageTooltip ? (
|
| 323 |
-
<Tooltip
|
| 324 |
-
containerRef={portalRef as React.RefObject<HTMLElement>}
|
| 325 |
-
info={
|
| 326 |
-
<MetricValueTooltipContent
|
| 327 |
-
value={percentageValue}
|
| 328 |
-
comparison={percentageComparison}
|
| 329 |
-
metric={'percentage'}
|
| 330 |
-
metricLabel="Percentage"
|
| 331 |
-
dateRangeLabel={dateRangeLabel}
|
| 332 |
-
comparisonDateRangeLabel={comparisonDateRangeLabel}
|
| 333 |
-
/>
|
| 334 |
-
}
|
| 335 |
-
>
|
| 336 |
-
{percentageCell}
|
| 337 |
-
</Tooltip>
|
| 338 |
-
) : (
|
| 339 |
-
percentageCell
|
| 340 |
-
)
|
| 341 |
-
|
| 342 |
-
const visitorsCell = (
|
| 343 |
-
<span
|
| 344 |
-
className={classNames('block w-full', {
|
| 345 |
-
'transition-transform duration-150 translate-x-0': isActive,
|
| 346 |
-
'transition-transform duration-150 translate-x-[100%] md:group-hover/report:translate-x-0':
|
| 347 |
-
!isActive
|
| 348 |
-
})}
|
| 349 |
-
data-testid="metric-value"
|
| 350 |
-
>
|
| 351 |
-
{visitorsShortFormatter(visitorsValue)}
|
| 352 |
-
{visitorsComparison && (
|
| 353 |
-
<ChangeArrow
|
| 354 |
-
change={visitorsComparison.change}
|
| 355 |
-
metric={'visitors'}
|
| 356 |
-
className="inline-block pl-1 w-4"
|
| 357 |
-
hideNumber
|
| 358 |
-
/>
|
| 359 |
-
)}
|
| 360 |
-
</span>
|
| 361 |
-
)
|
| 362 |
-
|
| 363 |
-
const visitorsWithTooltip = showVisitorsTooltip ? (
|
| 364 |
-
<Tooltip
|
| 365 |
-
containerRef={portalRef as React.RefObject<HTMLElement>}
|
| 366 |
-
info={
|
| 367 |
-
<MetricValueTooltipContent
|
| 368 |
-
value={visitorsValue}
|
| 369 |
-
comparison={visitorsComparison}
|
| 370 |
-
metric={'visitors'}
|
| 371 |
-
metricLabel="Visitors"
|
| 372 |
-
dateRangeLabel={dateRangeLabel}
|
| 373 |
-
comparisonDateRangeLabel={comparisonDateRangeLabel}
|
| 374 |
-
/>
|
| 375 |
-
}
|
| 376 |
-
>
|
| 377 |
-
{visitorsCell}
|
| 378 |
-
</Tooltip>
|
| 379 |
-
) : (
|
| 380 |
-
visitorsCell
|
| 381 |
-
)
|
| 382 |
-
|
| 383 |
-
return (
|
| 384 |
-
<div
|
| 385 |
-
className={
|
| 386 |
-
'flex w-full font-medium text-sm block text-gray-800 dark:text-gray-200'
|
| 387 |
-
}
|
| 388 |
-
>
|
| 389 |
-
<div className="w-1/2">{visitorsWithTooltip}</div>
|
| 390 |
-
<div className="w-1/2">{percentageWithTooltip}</div>
|
| 391 |
-
</div>
|
| 392 |
-
)
|
| 393 |
-
}
|
| 394 |
-
|
| 395 |
-
function MetricValueCell({
|
| 396 |
-
row,
|
| 397 |
-
metric,
|
| 398 |
-
metricLabel,
|
| 399 |
-
query
|
| 400 |
-
}: {
|
| 401 |
-
row: QueryResultRow
|
| 402 |
-
metric: Metric
|
| 403 |
-
metricLabel: string
|
| 404 |
-
query: QueryResultQuery
|
| 405 |
-
}) {
|
| 406 |
-
const portalRef = useBodyPortalRef()
|
| 407 |
-
|
| 408 |
-
const { value, comparison } = extractMetricValue(row, query, metric)
|
| 409 |
-
|
| 410 |
-
const shortFormatter = MetricFormatterShort[metric]
|
| 411 |
-
const longFormatter = MetricFormatterLong[metric]
|
| 412 |
-
|
| 413 |
-
const isAbbreviated =
|
| 414 |
-
value !== null && shortFormatter(value) !== longFormatter(value)
|
| 415 |
-
const showTooltip = !!comparison || isAbbreviated
|
| 416 |
-
|
| 417 |
-
const valueContent = (
|
| 418 |
-
<MetricValueWrapper className={showTooltip ? 'cursor-default' : undefined}>
|
| 419 |
-
{shortFormatter(value)}
|
| 420 |
-
{comparison && (
|
| 421 |
-
<ChangeArrow
|
| 422 |
-
change={comparison.change}
|
| 423 |
-
metric={metric}
|
| 424 |
-
className="inline-block pl-1 w-4"
|
| 425 |
-
hideNumber
|
| 426 |
-
/>
|
| 427 |
-
)}
|
| 428 |
-
</MetricValueWrapper>
|
| 429 |
-
)
|
| 430 |
-
|
| 431 |
-
if (!showTooltip) return valueContent
|
| 432 |
-
|
| 433 |
-
const dateRangeLabel = formatDateRangeLabel(query.date_range)
|
| 434 |
-
const comparisonDateRangeLabel = query.comparison_date_range
|
| 435 |
-
? formatDateRangeLabel(query.comparison_date_range)
|
| 436 |
-
: null
|
| 437 |
-
|
| 438 |
-
return (
|
| 439 |
-
<Tooltip
|
| 440 |
-
containerRef={portalRef as React.RefObject<HTMLElement>}
|
| 441 |
-
info={
|
| 442 |
-
<MetricValueTooltipContent
|
| 443 |
-
value={value}
|
| 444 |
-
comparison={comparison}
|
| 445 |
-
metric={metric}
|
| 446 |
-
metricLabel={metricLabel}
|
| 447 |
-
dateRangeLabel={dateRangeLabel}
|
| 448 |
-
comparisonDateRangeLabel={comparisonDateRangeLabel}
|
| 449 |
-
/>
|
| 450 |
-
}
|
| 451 |
-
>
|
| 452 |
-
{valueContent}
|
| 453 |
-
</Tooltip>
|
| 454 |
-
)
|
| 455 |
-
}
|
| 456 |
-
|
| 457 |
-
export function IndexBreakdownRenderer<TRow>({
|
| 458 |
-
rows,
|
| 459 |
-
getDimensionValue,
|
| 460 |
-
isPending,
|
| 461 |
-
isPlaceholderData,
|
| 462 |
-
isRealtimeSilentUpdate,
|
| 463 |
-
columns
|
| 464 |
-
}: {
|
| 465 |
-
rows: TRow[]
|
| 466 |
-
getDimensionValue: (row: TRow) => string
|
| 467 |
-
isPending: boolean
|
| 468 |
-
isPlaceholderData: boolean
|
| 469 |
-
isRealtimeSilentUpdate: boolean
|
| 470 |
-
columns: ColumnConfiguration<TRow>[] | null
|
| 471 |
-
}) {
|
| 472 |
-
const [tappedRow, setTappedRow] = useState<string | null>(null)
|
| 473 |
-
|
| 474 |
-
if (!columns || isPending || (isPlaceholderData && !isRealtimeSilentUpdate)) {
|
| 475 |
-
return (
|
| 476 |
-
<div
|
| 477 |
-
key="loading"
|
| 478 |
-
className="w-full flex flex-col justify-center"
|
| 479 |
-
style={{ minHeight: `${MIN_HEIGHT}px` }}
|
| 480 |
-
>
|
| 481 |
-
<div className="mx-auto loading">
|
| 482 |
-
<div />
|
| 483 |
-
</div>
|
| 484 |
-
</div>
|
| 485 |
-
)
|
| 486 |
-
}
|
| 487 |
-
|
| 488 |
-
if (rows.length === 0) {
|
| 489 |
-
return (
|
| 490 |
-
<div
|
| 491 |
-
key="empty"
|
| 492 |
-
className="w-full h-full flex flex-col justify-center"
|
| 493 |
-
style={{ minHeight: `${MIN_HEIGHT}px` }}
|
| 494 |
-
>
|
| 495 |
-
<div className="mx-auto font-medium text-gray-500 dark:text-gray-400">
|
| 496 |
-
No data yet
|
| 497 |
-
</div>
|
| 498 |
-
</div>
|
| 499 |
-
)
|
| 500 |
-
}
|
| 501 |
-
|
| 502 |
-
return (
|
| 503 |
-
<div
|
| 504 |
-
key="data"
|
| 505 |
-
className={classNames('h-full flex flex-col', FADE_IN_CLASSNAME)}
|
| 506 |
-
>
|
| 507 |
-
<div
|
| 508 |
-
style={{ height: ROW_HEIGHT }}
|
| 509 |
-
className="pt-3 w-full text-xs font-medium text-gray-500 dark:text-gray-400 flex items-center"
|
| 510 |
-
>
|
| 511 |
-
{columns.map((col) => (
|
| 512 |
-
<div
|
| 513 |
-
key={col.key}
|
| 514 |
-
data-testid="report-header"
|
| 515 |
-
className={classNames(
|
| 516 |
-
col.width ?? 'grow w-full',
|
| 517 |
-
col.align === 'right' ? 'text-right' : 'truncate',
|
| 518 |
-
col.hideOnMobile && 'hidden md:block'
|
| 519 |
-
)}
|
| 520 |
-
>
|
| 521 |
-
{col.renderLabel()}
|
| 522 |
-
</div>
|
| 523 |
-
))}
|
| 524 |
-
</div>
|
| 525 |
-
<div
|
| 526 |
-
className="group/report"
|
| 527 |
-
style={{ minHeight: DATA_CONTAINER_HEIGHT }}
|
| 528 |
-
>
|
| 529 |
-
<FlipMove disableAllAnimations={!isRealtimeSilentUpdate}>
|
| 530 |
-
{rows.map((row) => {
|
| 531 |
-
const dimensionValue = getDimensionValue(row)
|
| 532 |
-
const isActive = tappedRow === dimensionValue
|
| 533 |
-
|
| 534 |
-
const handleClick = (e: React.MouseEvent) => {
|
| 535 |
-
if (
|
| 536 |
-
window.innerWidth < 768 &&
|
| 537 |
-
!(e.target as HTMLElement).closest('a')
|
| 538 |
-
) {
|
| 539 |
-
setTappedRow(isActive ? null : dimensionValue)
|
| 540 |
-
}
|
| 541 |
-
}
|
| 542 |
-
|
| 543 |
-
return (
|
| 544 |
-
<div key={dimensionValue} style={{ minHeight: ROW_HEIGHT }}>
|
| 545 |
-
<div
|
| 546 |
-
data-testid="report-row"
|
| 547 |
-
className="group/row flex w-full items-center hover:bg-gray-100/60 dark:hover:bg-gray-850 rounded-sm md:cursor-default cursor-pointer"
|
| 548 |
-
style={{ marginTop: ROW_GAP_HEIGHT }}
|
| 549 |
-
onClick={handleClick}
|
| 550 |
-
>
|
| 551 |
-
{columns.map((col) => (
|
| 552 |
-
<div
|
| 553 |
-
key={col.key}
|
| 554 |
-
className={classNames(
|
| 555 |
-
col.width ?? 'grow w-full',
|
| 556 |
-
col.align === 'right' ? 'text-right' : 'md:truncate',
|
| 557 |
-
col.hideOnMobile && 'hidden md:block'
|
| 558 |
-
)}
|
| 559 |
-
>
|
| 560 |
-
{col.renderCell(row, isActive)}
|
| 561 |
-
</div>
|
| 562 |
-
))}
|
| 563 |
-
</div>
|
| 564 |
-
</div>
|
| 565 |
-
)
|
| 566 |
-
})}
|
| 567 |
-
</FlipMove>
|
| 568 |
-
</div>
|
| 569 |
-
</div>
|
| 570 |
-
)
|
| 571 |
-
}
|
| 572 |
-
|
| 573 |
-
export const Bar = ({
|
| 574 |
-
barWidthPercent,
|
| 575 |
-
className,
|
| 576 |
-
children
|
| 577 |
-
}: {
|
| 578 |
-
barWidthPercent: number
|
| 579 |
-
className: string
|
| 580 |
-
children: ReactNode
|
| 581 |
-
}) => (
|
| 582 |
-
<div className="w-full h-full relative">
|
| 583 |
-
<div
|
| 584 |
-
className={classNames(
|
| 585 |
-
`absolute top-0 left-0 h-full rounded-sm dark:bg-gray-500/15 dark:group-hover/row:bg-gray-500/30`,
|
| 586 |
-
className
|
| 587 |
-
)}
|
| 588 |
-
style={{ width: `${barWidthPercent}%` }}
|
| 589 |
-
></div>
|
| 590 |
-
<div className="px-2 py-1.5 text-sm dark:text-gray-300 relative z-9 break-all">
|
| 591 |
-
{children}
|
| 592 |
-
</div>
|
| 593 |
-
</div>
|
| 594 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/dashboard/stats/reports/metric-formatter.ts
DELETED
|
@@ -1,70 +0,0 @@
|
|
| 1 |
-
import { Metric } from '../metrics'
|
| 2 |
-
import { formatMoneyShort, formatMoneyLong } from '../../util/money'
|
| 3 |
-
import {
|
| 4 |
-
numberShortFormatter,
|
| 5 |
-
durationFormatter,
|
| 6 |
-
percentageFormatter,
|
| 7 |
-
numberLongFormatter,
|
| 8 |
-
nullable
|
| 9 |
-
} from '../../util/number-formatter'
|
| 10 |
-
|
| 11 |
-
export type FormattableMetric = Metric | 'current_visitors' | 'conversions'
|
| 12 |
-
|
| 13 |
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
| 14 |
-
export type ValueType = any
|
| 15 |
-
|
| 16 |
-
export const MetricFormatterShort: Record<
|
| 17 |
-
FormattableMetric,
|
| 18 |
-
(value: ValueType) => string
|
| 19 |
-
> = {
|
| 20 |
-
events: nullable(numberShortFormatter),
|
| 21 |
-
pageviews: numberShortFormatter,
|
| 22 |
-
total_visitors: numberShortFormatter,
|
| 23 |
-
current_visitors: numberShortFormatter,
|
| 24 |
-
views_per_visit: numberShortFormatter,
|
| 25 |
-
visitors: numberShortFormatter,
|
| 26 |
-
visits: numberShortFormatter,
|
| 27 |
-
|
| 28 |
-
conversions: numberShortFormatter,
|
| 29 |
-
|
| 30 |
-
time_on_page: nullable(durationFormatter),
|
| 31 |
-
visit_duration: nullable(durationFormatter),
|
| 32 |
-
|
| 33 |
-
bounce_rate: percentageFormatter,
|
| 34 |
-
conversion_rate: percentageFormatter,
|
| 35 |
-
scroll_depth: percentageFormatter,
|
| 36 |
-
exit_rate: percentageFormatter,
|
| 37 |
-
group_conversion_rate: percentageFormatter,
|
| 38 |
-
percentage: percentageFormatter,
|
| 39 |
-
|
| 40 |
-
average_revenue: formatMoneyShort,
|
| 41 |
-
total_revenue: formatMoneyShort
|
| 42 |
-
}
|
| 43 |
-
|
| 44 |
-
export const MetricFormatterLong: Record<
|
| 45 |
-
FormattableMetric,
|
| 46 |
-
(value: ValueType) => string
|
| 47 |
-
> = {
|
| 48 |
-
events: nullable(numberLongFormatter),
|
| 49 |
-
pageviews: numberLongFormatter,
|
| 50 |
-
total_visitors: numberLongFormatter,
|
| 51 |
-
current_visitors: numberShortFormatter,
|
| 52 |
-
views_per_visit: numberLongFormatter,
|
| 53 |
-
visitors: numberLongFormatter,
|
| 54 |
-
visits: numberLongFormatter,
|
| 55 |
-
|
| 56 |
-
conversions: numberLongFormatter,
|
| 57 |
-
|
| 58 |
-
time_on_page: nullable(durationFormatter),
|
| 59 |
-
visit_duration: nullable(durationFormatter),
|
| 60 |
-
|
| 61 |
-
bounce_rate: percentageFormatter,
|
| 62 |
-
conversion_rate: percentageFormatter,
|
| 63 |
-
scroll_depth: percentageFormatter,
|
| 64 |
-
exit_rate: percentageFormatter,
|
| 65 |
-
group_conversion_rate: percentageFormatter,
|
| 66 |
-
percentage: percentageFormatter,
|
| 67 |
-
|
| 68 |
-
average_revenue: formatMoneyLong,
|
| 69 |
-
total_revenue: formatMoneyLong
|
| 70 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/dashboard/stats/reports/report-header.js
DELETED
|
@@ -1,9 +0,0 @@
|
|
| 1 |
-
import React from 'react'
|
| 2 |
-
|
| 3 |
-
export function ReportHeader({ children }) {
|
| 4 |
-
return (
|
| 5 |
-
<div className="w-full flex justify-between border-b border-gray-200 dark:border-gray-750">
|
| 6 |
-
{children}
|
| 7 |
-
</div>
|
| 8 |
-
)
|
| 9 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/dashboard/stats/reports/report-layout.tsx
DELETED
|
@@ -1,25 +0,0 @@
|
|
| 1 |
-
import React from 'react'
|
| 2 |
-
import classNames from 'classnames'
|
| 3 |
-
|
| 4 |
-
export function ReportLayout({
|
| 5 |
-
children,
|
| 6 |
-
testId,
|
| 7 |
-
className
|
| 8 |
-
}: {
|
| 9 |
-
children: React.ReactNode
|
| 10 |
-
testId?: string
|
| 11 |
-
className?: string
|
| 12 |
-
}) {
|
| 13 |
-
return (
|
| 14 |
-
<div
|
| 15 |
-
data-testid={testId}
|
| 16 |
-
className={classNames(
|
| 17 |
-
'relative min-h-[430px] w-full p-5 flex flex-col bg-white dark:bg-gray-900 shadow-sm rounded-md md:min-h-initial md:h-27.25rem',
|
| 18 |
-
className
|
| 19 |
-
)}
|
| 20 |
-
>
|
| 21 |
-
{children}
|
| 22 |
-
<div data-testid="report-end"></div>
|
| 23 |
-
</div>
|
| 24 |
-
)
|
| 25 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/dashboard/stats/reports/reports-config.ts
DELETED
|
@@ -1,454 +0,0 @@
|
|
| 1 |
-
import { ApiFilter, NonTimeDimension } from '../../stats-query'
|
| 2 |
-
import { Metric } from '../metrics'
|
| 3 |
-
|
| 4 |
-
export type MetricContext = {
|
| 5 |
-
hasConversionGoalFilter?: boolean
|
| 6 |
-
isRealtime?: boolean
|
| 7 |
-
isCsv?: boolean
|
| 8 |
-
isDetailed?: boolean
|
| 9 |
-
isRevenueAvailable?: boolean
|
| 10 |
-
hasEventFilters?: boolean
|
| 11 |
-
}
|
| 12 |
-
|
| 13 |
-
export type MetricsByContext = {
|
| 14 |
-
realtimeMetrics: Metric[]
|
| 15 |
-
defaultIndexMetrics: Metric[]
|
| 16 |
-
defaultDetailedMetrics: Metric[]
|
| 17 |
-
defaultCsvMetrics: Metric[]
|
| 18 |
-
goalFilterIndexMetrics: Metric[]
|
| 19 |
-
goalFilterDetailedMetrics: Metric[]
|
| 20 |
-
goalFilterCsvMetrics: Metric[]
|
| 21 |
-
}
|
| 22 |
-
|
| 23 |
-
export type BreakdownReportConfig = {
|
| 24 |
-
dimensions: [NonTimeDimension, ...NonTimeDimension[]]
|
| 25 |
-
getMetrics: (context: MetricContext) => Metric[]
|
| 26 |
-
detailsTitle: string
|
| 27 |
-
detailsPath: string
|
| 28 |
-
dimensionLabel: string
|
| 29 |
-
alwaysOnFilters?: ApiFilter[]
|
| 30 |
-
searchDimension?: NonTimeDimension
|
| 31 |
-
}
|
| 32 |
-
|
| 33 |
-
export const COMMON_BREAKDOWN_METRICS_BY_CONTEXT: MetricsByContext = {
|
| 34 |
-
realtimeMetrics: ['visitors', 'percentage'],
|
| 35 |
-
defaultIndexMetrics: ['visitors', 'percentage'],
|
| 36 |
-
defaultDetailedMetrics: [
|
| 37 |
-
'visitors',
|
| 38 |
-
'percentage',
|
| 39 |
-
'bounce_rate',
|
| 40 |
-
'visit_duration'
|
| 41 |
-
],
|
| 42 |
-
defaultCsvMetrics: ['visitors', 'bounce_rate', 'visit_duration'],
|
| 43 |
-
goalFilterIndexMetrics: ['visitors', 'group_conversion_rate'],
|
| 44 |
-
goalFilterDetailedMetrics: [
|
| 45 |
-
'total_visitors',
|
| 46 |
-
'visitors',
|
| 47 |
-
'group_conversion_rate'
|
| 48 |
-
],
|
| 49 |
-
goalFilterCsvMetrics: ['visitors', 'group_conversion_rate']
|
| 50 |
-
}
|
| 51 |
-
|
| 52 |
-
function chooseMetrics(mbc: MetricsByContext, ctx: MetricContext): Metric[] {
|
| 53 |
-
const {
|
| 54 |
-
isRealtime,
|
| 55 |
-
isCsv,
|
| 56 |
-
isDetailed,
|
| 57 |
-
hasConversionGoalFilter,
|
| 58 |
-
isRevenueAvailable
|
| 59 |
-
} = ctx
|
| 60 |
-
if (isCsv && hasConversionGoalFilter) {
|
| 61 |
-
return mbc.goalFilterCsvMetrics
|
| 62 |
-
}
|
| 63 |
-
if (isCsv) {
|
| 64 |
-
return mbc.defaultCsvMetrics
|
| 65 |
-
}
|
| 66 |
-
if (hasConversionGoalFilter && isDetailed && isRevenueAvailable) {
|
| 67 |
-
return [
|
| 68 |
-
...mbc.goalFilterDetailedMetrics,
|
| 69 |
-
'total_revenue',
|
| 70 |
-
'average_revenue'
|
| 71 |
-
]
|
| 72 |
-
}
|
| 73 |
-
if (hasConversionGoalFilter && isDetailed) {
|
| 74 |
-
return mbc.goalFilterDetailedMetrics
|
| 75 |
-
}
|
| 76 |
-
if (hasConversionGoalFilter) {
|
| 77 |
-
return mbc.goalFilterIndexMetrics
|
| 78 |
-
}
|
| 79 |
-
if (isRealtime) {
|
| 80 |
-
return mbc.realtimeMetrics
|
| 81 |
-
}
|
| 82 |
-
if (isDetailed) {
|
| 83 |
-
return mbc.defaultDetailedMetrics
|
| 84 |
-
}
|
| 85 |
-
return mbc.defaultIndexMetrics
|
| 86 |
-
}
|
| 87 |
-
|
| 88 |
-
function createGetMetricsFn(
|
| 89 |
-
mbc: MetricsByContext
|
| 90 |
-
): (context: MetricContext) => Metric[] {
|
| 91 |
-
return (ctx) => chooseMetrics(mbc, ctx)
|
| 92 |
-
}
|
| 93 |
-
|
| 94 |
-
export enum BreakdownReportKey {
|
| 95 |
-
'pages' = 'pages',
|
| 96 |
-
'pagesWithHostname' = 'pagesWithHostname',
|
| 97 |
-
'entryPages' = 'entryPages',
|
| 98 |
-
'entryPagesWithHostname' = 'entryPagesWithHostname',
|
| 99 |
-
'exitPages' = 'exitPages',
|
| 100 |
-
'exitPagesWithHostname' = 'exitPagesWithHostname',
|
| 101 |
-
'browsers' = 'browsers',
|
| 102 |
-
'browserVersions' = 'browserVersions',
|
| 103 |
-
'operatingSystems' = 'operatingSystems',
|
| 104 |
-
'operatingSystemVersions' = 'operatingSystemVersions',
|
| 105 |
-
'screenSizes' = 'screenSizes',
|
| 106 |
-
'channels' = 'channels',
|
| 107 |
-
'sources' = 'sources',
|
| 108 |
-
'referrers' = 'referrers',
|
| 109 |
-
'utmMediums' = 'utmMediums',
|
| 110 |
-
'utmSources' = 'utmSources',
|
| 111 |
-
'utmCampaigns' = 'utmCampaigns',
|
| 112 |
-
'utmContents' = 'utmContents',
|
| 113 |
-
'utmTerms' = 'utmTerms',
|
| 114 |
-
'countries' = 'countries',
|
| 115 |
-
'regions' = 'regions',
|
| 116 |
-
'cities' = 'cities',
|
| 117 |
-
'goals' = 'goals'
|
| 118 |
-
}
|
| 119 |
-
|
| 120 |
-
export const BREAKDOWN_REPORTS: Record<
|
| 121 |
-
BreakdownReportKey,
|
| 122 |
-
BreakdownReportConfig
|
| 123 |
-
> = {
|
| 124 |
-
[BreakdownReportKey.pages]: {
|
| 125 |
-
dimensions: ['event:page'],
|
| 126 |
-
getMetrics: createGetMetricsFn({
|
| 127 |
-
...COMMON_BREAKDOWN_METRICS_BY_CONTEXT,
|
| 128 |
-
defaultDetailedMetrics: [
|
| 129 |
-
'visitors',
|
| 130 |
-
'percentage',
|
| 131 |
-
'pageviews',
|
| 132 |
-
'bounce_rate',
|
| 133 |
-
'time_on_page',
|
| 134 |
-
'scroll_depth'
|
| 135 |
-
],
|
| 136 |
-
defaultCsvMetrics: [
|
| 137 |
-
'visitors',
|
| 138 |
-
'pageviews',
|
| 139 |
-
'bounce_rate',
|
| 140 |
-
'time_on_page',
|
| 141 |
-
'scroll_depth'
|
| 142 |
-
]
|
| 143 |
-
}),
|
| 144 |
-
detailsTitle: 'Top pages',
|
| 145 |
-
detailsPath: 'pages',
|
| 146 |
-
dimensionLabel: 'Page'
|
| 147 |
-
},
|
| 148 |
-
[BreakdownReportKey.pagesWithHostname]: {
|
| 149 |
-
dimensions: ['event:hostname', 'event:page'],
|
| 150 |
-
getMetrics: createGetMetricsFn({
|
| 151 |
-
...COMMON_BREAKDOWN_METRICS_BY_CONTEXT,
|
| 152 |
-
defaultDetailedMetrics: [
|
| 153 |
-
'visitors',
|
| 154 |
-
'percentage',
|
| 155 |
-
'pageviews',
|
| 156 |
-
'bounce_rate',
|
| 157 |
-
'time_on_page',
|
| 158 |
-
'scroll_depth'
|
| 159 |
-
],
|
| 160 |
-
defaultCsvMetrics: [
|
| 161 |
-
'visitors',
|
| 162 |
-
'pageviews',
|
| 163 |
-
'bounce_rate',
|
| 164 |
-
'time_on_page',
|
| 165 |
-
'scroll_depth'
|
| 166 |
-
]
|
| 167 |
-
}),
|
| 168 |
-
detailsTitle: 'Top pages',
|
| 169 |
-
detailsPath: 'pages-with-hostname',
|
| 170 |
-
dimensionLabel: 'URL',
|
| 171 |
-
searchDimension: 'event:page'
|
| 172 |
-
},
|
| 173 |
-
[BreakdownReportKey.entryPages]: {
|
| 174 |
-
dimensions: ['visit:entry_page'],
|
| 175 |
-
getMetrics: createGetMetricsFn({
|
| 176 |
-
...COMMON_BREAKDOWN_METRICS_BY_CONTEXT,
|
| 177 |
-
defaultDetailedMetrics: [
|
| 178 |
-
'visitors',
|
| 179 |
-
'percentage',
|
| 180 |
-
'visits',
|
| 181 |
-
'bounce_rate',
|
| 182 |
-
'visit_duration'
|
| 183 |
-
],
|
| 184 |
-
defaultCsvMetrics: ['visitors', 'visits', 'bounce_rate', 'visit_duration']
|
| 185 |
-
}),
|
| 186 |
-
detailsTitle: 'Entry pages',
|
| 187 |
-
detailsPath: 'entry-pages',
|
| 188 |
-
dimensionLabel: 'Entry page',
|
| 189 |
-
alwaysOnFilters: [['is_not', 'visit:entry_page', ['']]]
|
| 190 |
-
},
|
| 191 |
-
[BreakdownReportKey.entryPagesWithHostname]: {
|
| 192 |
-
dimensions: ['visit:entry_page_hostname', 'visit:entry_page'],
|
| 193 |
-
getMetrics: createGetMetricsFn({
|
| 194 |
-
...COMMON_BREAKDOWN_METRICS_BY_CONTEXT,
|
| 195 |
-
defaultDetailedMetrics: [
|
| 196 |
-
'visitors',
|
| 197 |
-
'percentage',
|
| 198 |
-
'visits',
|
| 199 |
-
'bounce_rate',
|
| 200 |
-
'visit_duration'
|
| 201 |
-
],
|
| 202 |
-
defaultCsvMetrics: ['visitors', 'visits', 'bounce_rate', 'visit_duration']
|
| 203 |
-
}),
|
| 204 |
-
detailsTitle: 'Entry pages',
|
| 205 |
-
detailsPath: 'entry-pages-with-hostname',
|
| 206 |
-
dimensionLabel: 'URL',
|
| 207 |
-
alwaysOnFilters: [['is_not', 'visit:entry_page', ['']]],
|
| 208 |
-
searchDimension: 'visit:entry_page'
|
| 209 |
-
},
|
| 210 |
-
[BreakdownReportKey.exitPages]: {
|
| 211 |
-
dimensions: ['visit:exit_page'],
|
| 212 |
-
getMetrics: (ctx) => {
|
| 213 |
-
const base = chooseMetrics(
|
| 214 |
-
{
|
| 215 |
-
...COMMON_BREAKDOWN_METRICS_BY_CONTEXT,
|
| 216 |
-
defaultDetailedMetrics: [
|
| 217 |
-
'visitors',
|
| 218 |
-
'percentage',
|
| 219 |
-
'visits',
|
| 220 |
-
'exit_rate'
|
| 221 |
-
],
|
| 222 |
-
defaultCsvMetrics: ['visitors', 'visits', 'exit_rate']
|
| 223 |
-
},
|
| 224 |
-
ctx
|
| 225 |
-
)
|
| 226 |
-
|
| 227 |
-
return ctx.hasEventFilters ? base.filter((m) => m !== 'exit_rate') : base
|
| 228 |
-
},
|
| 229 |
-
detailsTitle: 'Exit pages',
|
| 230 |
-
detailsPath: 'exit-pages',
|
| 231 |
-
dimensionLabel: 'Exit page',
|
| 232 |
-
alwaysOnFilters: [['is_not', 'visit:exit_page', ['']]]
|
| 233 |
-
},
|
| 234 |
-
[BreakdownReportKey.exitPagesWithHostname]: {
|
| 235 |
-
dimensions: ['visit:exit_page_hostname', 'visit:exit_page'],
|
| 236 |
-
getMetrics: (ctx) => {
|
| 237 |
-
const base = chooseMetrics(
|
| 238 |
-
{
|
| 239 |
-
...COMMON_BREAKDOWN_METRICS_BY_CONTEXT,
|
| 240 |
-
defaultDetailedMetrics: [
|
| 241 |
-
'visitors',
|
| 242 |
-
'percentage',
|
| 243 |
-
'visits',
|
| 244 |
-
'exit_rate'
|
| 245 |
-
],
|
| 246 |
-
defaultCsvMetrics: ['visitors', 'visits', 'exit_rate']
|
| 247 |
-
},
|
| 248 |
-
ctx
|
| 249 |
-
)
|
| 250 |
-
|
| 251 |
-
return ctx.hasEventFilters ? base.filter((m) => m !== 'exit_rate') : base
|
| 252 |
-
},
|
| 253 |
-
detailsTitle: 'Exit pages',
|
| 254 |
-
detailsPath: 'exit-pages-with-hostname',
|
| 255 |
-
dimensionLabel: 'URL',
|
| 256 |
-
alwaysOnFilters: [['is_not', 'visit:exit_page', ['']]],
|
| 257 |
-
searchDimension: 'visit:exit_page'
|
| 258 |
-
},
|
| 259 |
-
[BreakdownReportKey.browsers]: {
|
| 260 |
-
dimensions: ['visit:browser'],
|
| 261 |
-
getMetrics: createGetMetricsFn({
|
| 262 |
-
...COMMON_BREAKDOWN_METRICS_BY_CONTEXT,
|
| 263 |
-
defaultCsvMetrics: ['visitors']
|
| 264 |
-
}),
|
| 265 |
-
detailsTitle: 'Browsers',
|
| 266 |
-
detailsPath: 'browsers',
|
| 267 |
-
dimensionLabel: 'Browser'
|
| 268 |
-
},
|
| 269 |
-
[BreakdownReportKey.browserVersions]: {
|
| 270 |
-
dimensions: ['visit:browser_version', 'visit:browser'],
|
| 271 |
-
getMetrics: createGetMetricsFn({
|
| 272 |
-
...COMMON_BREAKDOWN_METRICS_BY_CONTEXT,
|
| 273 |
-
defaultCsvMetrics: ['visitors']
|
| 274 |
-
}),
|
| 275 |
-
detailsTitle: 'Browser versions',
|
| 276 |
-
detailsPath: 'browser-versions',
|
| 277 |
-
dimensionLabel: 'Browser version'
|
| 278 |
-
},
|
| 279 |
-
[BreakdownReportKey.operatingSystems]: {
|
| 280 |
-
dimensions: ['visit:os'],
|
| 281 |
-
getMetrics: createGetMetricsFn({
|
| 282 |
-
...COMMON_BREAKDOWN_METRICS_BY_CONTEXT,
|
| 283 |
-
defaultCsvMetrics: ['visitors']
|
| 284 |
-
}),
|
| 285 |
-
detailsTitle: 'Operating systems',
|
| 286 |
-
detailsPath: 'operating-systems',
|
| 287 |
-
dimensionLabel: 'Operating system'
|
| 288 |
-
},
|
| 289 |
-
[BreakdownReportKey.operatingSystemVersions]: {
|
| 290 |
-
dimensions: ['visit:os_version', 'visit:os'],
|
| 291 |
-
getMetrics: createGetMetricsFn({
|
| 292 |
-
...COMMON_BREAKDOWN_METRICS_BY_CONTEXT,
|
| 293 |
-
defaultCsvMetrics: ['visitors']
|
| 294 |
-
}),
|
| 295 |
-
detailsTitle: 'Operating system versions',
|
| 296 |
-
detailsPath: 'operating-system-versions',
|
| 297 |
-
dimensionLabel: 'Operating system version'
|
| 298 |
-
},
|
| 299 |
-
[BreakdownReportKey.screenSizes]: {
|
| 300 |
-
dimensions: ['visit:device'],
|
| 301 |
-
getMetrics: createGetMetricsFn({
|
| 302 |
-
...COMMON_BREAKDOWN_METRICS_BY_CONTEXT,
|
| 303 |
-
defaultCsvMetrics: ['visitors']
|
| 304 |
-
}),
|
| 305 |
-
detailsTitle: 'Devices',
|
| 306 |
-
detailsPath: 'screen-sizes',
|
| 307 |
-
dimensionLabel: 'Device'
|
| 308 |
-
},
|
| 309 |
-
[BreakdownReportKey.channels]: {
|
| 310 |
-
dimensions: ['visit:channel'],
|
| 311 |
-
getMetrics: createGetMetricsFn(COMMON_BREAKDOWN_METRICS_BY_CONTEXT),
|
| 312 |
-
detailsTitle: 'Acquisition channels',
|
| 313 |
-
detailsPath: 'channels',
|
| 314 |
-
dimensionLabel: 'Channel'
|
| 315 |
-
},
|
| 316 |
-
[BreakdownReportKey.sources]: {
|
| 317 |
-
dimensions: ['visit:source'],
|
| 318 |
-
getMetrics: createGetMetricsFn(COMMON_BREAKDOWN_METRICS_BY_CONTEXT),
|
| 319 |
-
detailsTitle: 'Sources',
|
| 320 |
-
detailsPath: 'sources',
|
| 321 |
-
dimensionLabel: 'Source'
|
| 322 |
-
},
|
| 323 |
-
[BreakdownReportKey.referrers]: {
|
| 324 |
-
dimensions: ['visit:referrer'],
|
| 325 |
-
getMetrics: createGetMetricsFn(COMMON_BREAKDOWN_METRICS_BY_CONTEXT),
|
| 326 |
-
detailsTitle: 'Referrer drilldown',
|
| 327 |
-
detailsPath: 'referrers/:referrer',
|
| 328 |
-
dimensionLabel: 'Referrer'
|
| 329 |
-
},
|
| 330 |
-
[BreakdownReportKey.utmMediums]: {
|
| 331 |
-
dimensions: ['visit:utm_medium'],
|
| 332 |
-
getMetrics: createGetMetricsFn(COMMON_BREAKDOWN_METRICS_BY_CONTEXT),
|
| 333 |
-
detailsTitle: 'UTM mediums',
|
| 334 |
-
detailsPath: 'utm_mediums',
|
| 335 |
-
dimensionLabel: 'UTM medium',
|
| 336 |
-
alwaysOnFilters: [['is_not', 'visit:utm_medium', ['']]]
|
| 337 |
-
},
|
| 338 |
-
[BreakdownReportKey.utmSources]: {
|
| 339 |
-
dimensions: ['visit:utm_source'],
|
| 340 |
-
getMetrics: createGetMetricsFn(COMMON_BREAKDOWN_METRICS_BY_CONTEXT),
|
| 341 |
-
detailsTitle: 'UTM sources',
|
| 342 |
-
detailsPath: 'utm_sources',
|
| 343 |
-
dimensionLabel: 'UTM source',
|
| 344 |
-
alwaysOnFilters: [['is_not', 'visit:utm_source', ['']]]
|
| 345 |
-
},
|
| 346 |
-
[BreakdownReportKey.utmCampaigns]: {
|
| 347 |
-
dimensions: ['visit:utm_campaign'],
|
| 348 |
-
getMetrics: createGetMetricsFn(COMMON_BREAKDOWN_METRICS_BY_CONTEXT),
|
| 349 |
-
detailsTitle: 'UTM campaigns',
|
| 350 |
-
detailsPath: 'utm_campaigns',
|
| 351 |
-
dimensionLabel: 'UTM campaign',
|
| 352 |
-
alwaysOnFilters: [['is_not', 'visit:utm_campaign', ['']]]
|
| 353 |
-
},
|
| 354 |
-
[BreakdownReportKey.utmContents]: {
|
| 355 |
-
dimensions: ['visit:utm_content'],
|
| 356 |
-
getMetrics: createGetMetricsFn(COMMON_BREAKDOWN_METRICS_BY_CONTEXT),
|
| 357 |
-
detailsTitle: 'UTM contents',
|
| 358 |
-
detailsPath: 'utm_contents',
|
| 359 |
-
dimensionLabel: 'UTM content',
|
| 360 |
-
alwaysOnFilters: [['is_not', 'visit:utm_content', ['']]]
|
| 361 |
-
},
|
| 362 |
-
[BreakdownReportKey.utmTerms]: {
|
| 363 |
-
dimensions: ['visit:utm_term'],
|
| 364 |
-
getMetrics: createGetMetricsFn(COMMON_BREAKDOWN_METRICS_BY_CONTEXT),
|
| 365 |
-
detailsTitle: 'UTM terms',
|
| 366 |
-
detailsPath: 'utm_terms',
|
| 367 |
-
dimensionLabel: 'UTM term',
|
| 368 |
-
alwaysOnFilters: [['is_not', 'visit:utm_term', ['']]]
|
| 369 |
-
},
|
| 370 |
-
[BreakdownReportKey.countries]: {
|
| 371 |
-
dimensions: ['visit:country_name', 'visit:country'],
|
| 372 |
-
getMetrics: createGetMetricsFn({
|
| 373 |
-
...COMMON_BREAKDOWN_METRICS_BY_CONTEXT,
|
| 374 |
-
defaultCsvMetrics: ['visitors']
|
| 375 |
-
}),
|
| 376 |
-
detailsTitle: 'Countries',
|
| 377 |
-
detailsPath: 'countries',
|
| 378 |
-
dimensionLabel: 'Country',
|
| 379 |
-
alwaysOnFilters: [['is_not', 'visit:country', ['\0\0', 'ZZ']]]
|
| 380 |
-
},
|
| 381 |
-
[BreakdownReportKey.regions]: {
|
| 382 |
-
// the 3rd dimension "visit:country" is needed to render the country flag
|
| 383 |
-
dimensions: ['visit:region_name', 'visit:region', 'visit:country'],
|
| 384 |
-
getMetrics: createGetMetricsFn({
|
| 385 |
-
...COMMON_BREAKDOWN_METRICS_BY_CONTEXT,
|
| 386 |
-
defaultCsvMetrics: ['visitors']
|
| 387 |
-
}),
|
| 388 |
-
detailsTitle: 'Regions',
|
| 389 |
-
detailsPath: 'regions',
|
| 390 |
-
dimensionLabel: 'Region',
|
| 391 |
-
alwaysOnFilters: [['is_not', 'visit:region', ['']]]
|
| 392 |
-
},
|
| 393 |
-
[BreakdownReportKey.cities]: {
|
| 394 |
-
// the 3rd dimension "visit:country" is needed to render the country flag
|
| 395 |
-
dimensions: ['visit:city_name', 'visit:city', 'visit:country'],
|
| 396 |
-
getMetrics: createGetMetricsFn({
|
| 397 |
-
...COMMON_BREAKDOWN_METRICS_BY_CONTEXT,
|
| 398 |
-
defaultCsvMetrics: ['visitors']
|
| 399 |
-
}),
|
| 400 |
-
detailsTitle: 'Cities',
|
| 401 |
-
detailsPath: 'cities',
|
| 402 |
-
dimensionLabel: 'City',
|
| 403 |
-
alwaysOnFilters: [['is_not', 'visit:city', [0]]]
|
| 404 |
-
},
|
| 405 |
-
[BreakdownReportKey.goals]: {
|
| 406 |
-
dimensions: ['event:goal'],
|
| 407 |
-
getMetrics: (ctx: MetricContext) => {
|
| 408 |
-
if (ctx.isCsv) {
|
| 409 |
-
return ['visitors', 'events']
|
| 410 |
-
}
|
| 411 |
-
if (ctx.isRevenueAvailable) {
|
| 412 |
-
return [
|
| 413 |
-
'visitors',
|
| 414 |
-
'events',
|
| 415 |
-
'conversion_rate',
|
| 416 |
-
'total_revenue',
|
| 417 |
-
'average_revenue'
|
| 418 |
-
]
|
| 419 |
-
}
|
| 420 |
-
return ['visitors', 'events', 'conversion_rate']
|
| 421 |
-
},
|
| 422 |
-
detailsTitle: 'Goal conversions',
|
| 423 |
-
detailsPath: 'conversions',
|
| 424 |
-
dimensionLabel: 'Goal'
|
| 425 |
-
}
|
| 426 |
-
}
|
| 427 |
-
|
| 428 |
-
export function customPropsReportConfig(
|
| 429 |
-
propKey: string
|
| 430 |
-
): BreakdownReportConfig {
|
| 431 |
-
return {
|
| 432 |
-
dimensions: [`event:props:${propKey}` as NonTimeDimension],
|
| 433 |
-
getMetrics: getCustomPropsMetrics,
|
| 434 |
-
detailsTitle: 'Custom property breakdown',
|
| 435 |
-
detailsPath: `custom-prop-values/${propKey}`,
|
| 436 |
-
dimensionLabel: propKey
|
| 437 |
-
}
|
| 438 |
-
}
|
| 439 |
-
|
| 440 |
-
export function getCustomPropsMetrics(ctx: MetricContext): Metric[] {
|
| 441 |
-
if (ctx.hasConversionGoalFilter && ctx.isRevenueAvailable) {
|
| 442 |
-
return [
|
| 443 |
-
'visitors',
|
| 444 |
-
'events',
|
| 445 |
-
'conversion_rate',
|
| 446 |
-
'total_revenue',
|
| 447 |
-
'average_revenue'
|
| 448 |
-
]
|
| 449 |
-
}
|
| 450 |
-
if (ctx.hasConversionGoalFilter) {
|
| 451 |
-
return ['visitors', 'events', 'conversion_rate']
|
| 452 |
-
}
|
| 453 |
-
return ['visitors', 'events', 'percentage']
|
| 454 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/dashboard/stats/sources/details.tsx
DELETED
|
@@ -1,123 +0,0 @@
|
|
| 1 |
-
import React from 'react'
|
| 2 |
-
import { revenueAvailable } from '../../dashboard-state'
|
| 3 |
-
import { useDashboardStateContext } from '../../dashboard-state-context'
|
| 4 |
-
import { useSiteContext } from '../../site-context'
|
| 5 |
-
import {
|
| 6 |
-
hasConversionGoalFilter,
|
| 7 |
-
isRealTimeDashboard
|
| 8 |
-
} from '../../util/filters'
|
| 9 |
-
import { defaultGetFilterInfo, getReferrerUrlFilterInfo } from '../breakdowns'
|
| 10 |
-
import {
|
| 11 |
-
BREAKDOWN_REPORTS,
|
| 12 |
-
BreakdownReportKey
|
| 13 |
-
} from '../reports/reports-config'
|
| 14 |
-
import {
|
| 15 |
-
DetailsBreakdown,
|
| 16 |
-
DimensionCell,
|
| 17 |
-
DimensionCellProps
|
| 18 |
-
} from '../modals/details-breakdown'
|
| 19 |
-
import Modal from '../modals/modal'
|
| 20 |
-
import { SourceFavicon } from './source-favicon'
|
| 21 |
-
import { DIRECT_NONE } from '.'
|
| 22 |
-
import { DetailsExternalLink } from '../pages/external-link'
|
| 23 |
-
|
| 24 |
-
type SourcesReportKey =
|
| 25 |
-
| BreakdownReportKey.channels
|
| 26 |
-
| BreakdownReportKey.sources
|
| 27 |
-
| BreakdownReportKey.referrers
|
| 28 |
-
| BreakdownReportKey.utmMediums
|
| 29 |
-
| BreakdownReportKey.utmSources
|
| 30 |
-
| BreakdownReportKey.utmCampaigns
|
| 31 |
-
| BreakdownReportKey.utmContents
|
| 32 |
-
| BreakdownReportKey.utmTerms
|
| 33 |
-
|
| 34 |
-
export function SourcesDetails({ reportKey }: { reportKey: SourcesReportKey }) {
|
| 35 |
-
const { dashboardState } = useDashboardStateContext()
|
| 36 |
-
const site = useSiteContext()
|
| 37 |
-
|
| 38 |
-
const reportConfig = BREAKDOWN_REPORTS[reportKey]
|
| 39 |
-
|
| 40 |
-
/*global BUILD_EXTRA*/
|
| 41 |
-
const isRevenueAvailable =
|
| 42 |
-
BUILD_EXTRA && revenueAvailable(dashboardState, site)
|
| 43 |
-
|
| 44 |
-
const metrics = reportConfig.getMetrics({
|
| 45 |
-
hasConversionGoalFilter: hasConversionGoalFilter(dashboardState),
|
| 46 |
-
isRealtime: isRealTimeDashboard(dashboardState),
|
| 47 |
-
isDetailed: true,
|
| 48 |
-
isRevenueAvailable: isRevenueAvailable
|
| 49 |
-
})
|
| 50 |
-
|
| 51 |
-
const DimensionElement =
|
| 52 |
-
reportKey === BreakdownReportKey.sources
|
| 53 |
-
? SourcesDimensionCell
|
| 54 |
-
: reportKey === BreakdownReportKey.referrers
|
| 55 |
-
? ReferrerUrlDimensionCell
|
| 56 |
-
: SimpleDimensionCell
|
| 57 |
-
|
| 58 |
-
return (
|
| 59 |
-
<Modal>
|
| 60 |
-
<DetailsBreakdown
|
| 61 |
-
title={reportConfig.detailsTitle}
|
| 62 |
-
dimensionLabel={reportConfig.dimensionLabel}
|
| 63 |
-
dimensions={reportConfig.dimensions}
|
| 64 |
-
metrics={metrics}
|
| 65 |
-
alwaysOnFilters={reportConfig.alwaysOnFilters}
|
| 66 |
-
defaultOrderBy={[['visitors', 'desc']]}
|
| 67 |
-
DimensionElement={DimensionElement}
|
| 68 |
-
/>
|
| 69 |
-
</Modal>
|
| 70 |
-
)
|
| 71 |
-
}
|
| 72 |
-
|
| 73 |
-
const SourcesDimensionCell = (props: DimensionCellProps) => {
|
| 74 |
-
return (
|
| 75 |
-
<DimensionCell
|
| 76 |
-
icon={
|
| 77 |
-
<SourceFavicon
|
| 78 |
-
name={props.row.dimensions[0]}
|
| 79 |
-
className="size-4 mr-2 align-middle inline"
|
| 80 |
-
/>
|
| 81 |
-
}
|
| 82 |
-
text={props.row.dimensions[0]}
|
| 83 |
-
getFilterInfo={defaultGetFilterInfo}
|
| 84 |
-
{...props}
|
| 85 |
-
/>
|
| 86 |
-
)
|
| 87 |
-
}
|
| 88 |
-
|
| 89 |
-
const ReferrerUrlDimensionCell = (props: DimensionCellProps) => {
|
| 90 |
-
const dimensionValue = props.row.dimensions[0]
|
| 91 |
-
|
| 92 |
-
const externalUrl =
|
| 93 |
-
dimensionValue === DIRECT_NONE ? null : `https://${dimensionValue}`
|
| 94 |
-
|
| 95 |
-
return (
|
| 96 |
-
<DimensionCell
|
| 97 |
-
text={dimensionValue}
|
| 98 |
-
icon={
|
| 99 |
-
<SourceFavicon
|
| 100 |
-
name={dimensionValue}
|
| 101 |
-
className="size-4 mr-2 align-middle inline"
|
| 102 |
-
/>
|
| 103 |
-
}
|
| 104 |
-
externalLink={
|
| 105 |
-
externalUrl && (
|
| 106 |
-
<DetailsExternalLink href={externalUrl} isActive={props.isActive} />
|
| 107 |
-
)
|
| 108 |
-
}
|
| 109 |
-
getFilterInfo={getReferrerUrlFilterInfo}
|
| 110 |
-
{...props}
|
| 111 |
-
/>
|
| 112 |
-
)
|
| 113 |
-
}
|
| 114 |
-
|
| 115 |
-
const SimpleDimensionCell = (props: DimensionCellProps) => {
|
| 116 |
-
return (
|
| 117 |
-
<DimensionCell
|
| 118 |
-
text={props.row.dimensions[0]}
|
| 119 |
-
getFilterInfo={defaultGetFilterInfo}
|
| 120 |
-
{...props}
|
| 121 |
-
/>
|
| 122 |
-
)
|
| 123 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/dashboard/stats/sources/fetch-search-terms.tsx
DELETED
|
@@ -1,133 +0,0 @@
|
|
| 1 |
-
import { InfiniteData, useInfiniteQuery, useQuery } from '@tanstack/react-query'
|
| 2 |
-
import { useDashboardStateContext } from '../../dashboard-state-context'
|
| 3 |
-
import { useSiteContext } from '../../site-context'
|
| 4 |
-
import { DashboardState } from '../../dashboard-state'
|
| 5 |
-
import * as api from '../../api'
|
| 6 |
-
import { ApiError } from '../../api'
|
| 7 |
-
import * as url from '../../util/url'
|
| 8 |
-
import { getStaleTime, PAGINATION_LIMIT } from '../../hooks/api-client'
|
| 9 |
-
|
| 10 |
-
const INDEX_SEARCH_TERMS_REPORT_ID = 'index-search-terms'
|
| 11 |
-
const DETAILED_SEARCH_TERMS_REPORT_ID = 'detailed-search-terms'
|
| 12 |
-
const SEARCH_TERMS_ENDPOINT = '/google-search-terms'
|
| 13 |
-
|
| 14 |
-
export const GOOGLE_SEARCH_TERMS_DETAILS_PATH = 'referrers/Google'
|
| 15 |
-
|
| 16 |
-
type IndexSearchTermsQueryKey = [
|
| 17 |
-
typeof INDEX_SEARCH_TERMS_REPORT_ID,
|
| 18 |
-
{ dashboardState: DashboardState }
|
| 19 |
-
]
|
| 20 |
-
|
| 21 |
-
type DetailedSearchTermsQueryKey = [
|
| 22 |
-
typeof DETAILED_SEARCH_TERMS_REPORT_ID,
|
| 23 |
-
{ dashboardState: DashboardState; search?: string }
|
| 24 |
-
]
|
| 25 |
-
|
| 26 |
-
export type SearchTermsResultItem = {
|
| 27 |
-
name: string
|
| 28 |
-
position: number
|
| 29 |
-
visitors: number
|
| 30 |
-
ctr: number
|
| 31 |
-
impressions: number
|
| 32 |
-
}
|
| 33 |
-
export type SearchTermsSuccessResponse = {
|
| 34 |
-
results: SearchTermsResultItem[]
|
| 35 |
-
}
|
| 36 |
-
|
| 37 |
-
export type SearchTermsErrorCode =
|
| 38 |
-
| 'not_configured'
|
| 39 |
-
| 'unsupported_filters'
|
| 40 |
-
| 'period_too_recent'
|
| 41 |
-
export type SearchTermsErrorPayload = {
|
| 42 |
-
error_code: SearchTermsErrorCode
|
| 43 |
-
is_admin: boolean
|
| 44 |
-
}
|
| 45 |
-
|
| 46 |
-
export function useIndexGoogleSearchTermsQuery({
|
| 47 |
-
enabled
|
| 48 |
-
}: {
|
| 49 |
-
enabled: boolean
|
| 50 |
-
}) {
|
| 51 |
-
const site = useSiteContext()
|
| 52 |
-
const { dashboardState } = useDashboardStateContext()
|
| 53 |
-
|
| 54 |
-
const searchTermsQueryKey: IndexSearchTermsQueryKey = [
|
| 55 |
-
INDEX_SEARCH_TERMS_REPORT_ID,
|
| 56 |
-
{ dashboardState }
|
| 57 |
-
]
|
| 58 |
-
|
| 59 |
-
return useQuery<
|
| 60 |
-
SearchTermsSuccessResponse,
|
| 61 |
-
ApiError,
|
| 62 |
-
SearchTermsSuccessResponse,
|
| 63 |
-
IndexSearchTermsQueryKey
|
| 64 |
-
>({
|
| 65 |
-
queryKey: searchTermsQueryKey,
|
| 66 |
-
enabled: enabled,
|
| 67 |
-
retry: false,
|
| 68 |
-
queryFn: ({ queryKey }) => {
|
| 69 |
-
return api.get(
|
| 70 |
-
url.apiPath(site, SEARCH_TERMS_ENDPOINT),
|
| 71 |
-
queryKey[1].dashboardState
|
| 72 |
-
)
|
| 73 |
-
},
|
| 74 |
-
placeholderData: (previousData) => previousData,
|
| 75 |
-
staleTime: ({ queryKey }) => {
|
| 76 |
-
return getStaleTime({
|
| 77 |
-
siteTimezoneOffset: site.offset,
|
| 78 |
-
siteStatsBegin: site.statsBegin,
|
| 79 |
-
...queryKey[1].dashboardState
|
| 80 |
-
})
|
| 81 |
-
}
|
| 82 |
-
})
|
| 83 |
-
}
|
| 84 |
-
|
| 85 |
-
export function useDetailedGoogleSearchTermsQuery({
|
| 86 |
-
search
|
| 87 |
-
}: {
|
| 88 |
-
search?: string
|
| 89 |
-
}) {
|
| 90 |
-
const site = useSiteContext()
|
| 91 |
-
const { dashboardState } = useDashboardStateContext()
|
| 92 |
-
|
| 93 |
-
const searchTermsQueryKey: DetailedSearchTermsQueryKey = [
|
| 94 |
-
DETAILED_SEARCH_TERMS_REPORT_ID,
|
| 95 |
-
{ dashboardState, search }
|
| 96 |
-
]
|
| 97 |
-
|
| 98 |
-
return useInfiniteQuery<
|
| 99 |
-
SearchTermsSuccessResponse,
|
| 100 |
-
ApiError,
|
| 101 |
-
InfiniteData<SearchTermsSuccessResponse>,
|
| 102 |
-
DetailedSearchTermsQueryKey,
|
| 103 |
-
number
|
| 104 |
-
>({
|
| 105 |
-
queryKey: searchTermsQueryKey,
|
| 106 |
-
retry: false,
|
| 107 |
-
queryFn: ({ pageParam, queryKey }) => {
|
| 108 |
-
const { dashboardState, search } = queryKey[1]
|
| 109 |
-
|
| 110 |
-
return api.get(url.apiPath(site, SEARCH_TERMS_ENDPOINT), dashboardState, {
|
| 111 |
-
detailed: true,
|
| 112 |
-
search: search,
|
| 113 |
-
limit: PAGINATION_LIMIT,
|
| 114 |
-
page: pageParam
|
| 115 |
-
})
|
| 116 |
-
},
|
| 117 |
-
placeholderData: (previousData) => previousData,
|
| 118 |
-
getNextPageParam: (lastPageResults, _, lastPageIndex) => {
|
| 119 |
-
return lastPageResults.results.length === PAGINATION_LIMIT
|
| 120 |
-
? lastPageIndex + 1
|
| 121 |
-
: null
|
| 122 |
-
},
|
| 123 |
-
initialPageParam: 0,
|
| 124 |
-
staleTime: ({ queryKey }) => {
|
| 125 |
-
const [_, opts] = queryKey
|
| 126 |
-
return getStaleTime({
|
| 127 |
-
siteTimezoneOffset: site.offset,
|
| 128 |
-
siteStatsBegin: site.statsBegin,
|
| 129 |
-
...opts.dashboardState
|
| 130 |
-
})
|
| 131 |
-
}
|
| 132 |
-
})
|
| 133 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/dashboard/stats/sources/index.tsx
DELETED
|
@@ -1,360 +0,0 @@
|
|
| 1 |
-
import React, { useCallback, useEffect, useState } from 'react'
|
| 2 |
-
|
| 3 |
-
import * as storage from '../../util/storage'
|
| 4 |
-
import * as url from '../../util/url'
|
| 5 |
-
import * as api from '../../api'
|
| 6 |
-
import usePrevious from '../../hooks/use-previous'
|
| 7 |
-
import {
|
| 8 |
-
getFiltersByKeyPrefix,
|
| 9 |
-
hasConversionGoalFilter,
|
| 10 |
-
isFilteringOnFixedValue,
|
| 11 |
-
isRealTimeDashboard
|
| 12 |
-
} from '../../util/filters'
|
| 13 |
-
import { useDashboardStateContext } from '../../dashboard-state-context'
|
| 14 |
-
import { useSiteContext } from '../../site-context'
|
| 15 |
-
import { SourceFavicon } from './source-favicon'
|
| 16 |
-
import { ReportLayout } from '../reports/report-layout'
|
| 17 |
-
import { ReportHeader } from '../reports/report-header'
|
| 18 |
-
import { DropdownTabButton, TabButton, TabWrapper } from '../../components/tabs'
|
| 19 |
-
import MoreLink from '../more-link'
|
| 20 |
-
import { MoreLinkState } from '../more-link-state'
|
| 21 |
-
import { DashboardState } from '../../dashboard-state'
|
| 22 |
-
import {
|
| 23 |
-
BREAKDOWN_REPORTS,
|
| 24 |
-
BreakdownReportKey
|
| 25 |
-
} from '../reports/reports-config'
|
| 26 |
-
import {
|
| 27 |
-
DimensionCellWithBar,
|
| 28 |
-
DimensionCellWithBarProps,
|
| 29 |
-
IndexBreakdown
|
| 30 |
-
} from '../reports/index-breakdown'
|
| 31 |
-
import { defaultGetFilterInfo } from '../breakdowns'
|
| 32 |
-
import ImportedWarningBubble from '../imported-warning-bubble'
|
| 33 |
-
import { IndexExternalLink } from '../pages/external-link'
|
| 34 |
-
import { SearchTerms } from './search-terms'
|
| 35 |
-
import {
|
| 36 |
-
GOOGLE_SEARCH_TERMS_DETAILS_PATH,
|
| 37 |
-
SearchTermsSuccessResponse
|
| 38 |
-
} from './fetch-search-terms'
|
| 39 |
-
|
| 40 |
-
const BAR_COLOR = 'bg-blue-50 group-hover/row:bg-blue-100'
|
| 41 |
-
const MAX_DIMENSION_LENGTH = 70
|
| 42 |
-
const SEARCH_TERMS_KEY = 'searchTerms'
|
| 43 |
-
export const DIRECT_NONE = 'Direct / None'
|
| 44 |
-
|
| 45 |
-
const UTM_TAB_KEYS = [
|
| 46 |
-
BreakdownReportKey.utmMediums,
|
| 47 |
-
BreakdownReportKey.utmSources,
|
| 48 |
-
BreakdownReportKey.utmCampaigns,
|
| 49 |
-
BreakdownReportKey.utmContents,
|
| 50 |
-
BreakdownReportKey.utmTerms
|
| 51 |
-
] as const
|
| 52 |
-
|
| 53 |
-
type TabKey =
|
| 54 |
-
| BreakdownReportKey.channels
|
| 55 |
-
| BreakdownReportKey.sources
|
| 56 |
-
| (typeof UTM_TAB_KEYS)[number]
|
| 57 |
-
|
| 58 |
-
type ReportKey = TabKey | BreakdownReportKey.referrers | typeof SEARCH_TERMS_KEY
|
| 59 |
-
|
| 60 |
-
const isGoogleSourceFilter = (dashboardState: DashboardState) => {
|
| 61 |
-
return isFilteringOnFixedValue(dashboardState, 'source', 'Google')
|
| 62 |
-
}
|
| 63 |
-
|
| 64 |
-
const isFixedSourceFilter = (dashboardState: DashboardState) => {
|
| 65 |
-
return isFilteringOnFixedValue(dashboardState, 'source')
|
| 66 |
-
}
|
| 67 |
-
|
| 68 |
-
const getFixedSourceFilterClause = (dashboardState: DashboardState) => {
|
| 69 |
-
if (isFixedSourceFilter(dashboardState)) {
|
| 70 |
-
const [[_operation, _filterKey, clauses]] = getFiltersByKeyPrefix(
|
| 71 |
-
dashboardState,
|
| 72 |
-
'source'
|
| 73 |
-
)
|
| 74 |
-
|
| 75 |
-
return clauses[0]
|
| 76 |
-
}
|
| 77 |
-
return null
|
| 78 |
-
}
|
| 79 |
-
|
| 80 |
-
const getCurrentReportKey = (
|
| 81 |
-
currentTab: TabKey,
|
| 82 |
-
dashboardState: DashboardState
|
| 83 |
-
): ReportKey => {
|
| 84 |
-
if (
|
| 85 |
-
currentTab === BreakdownReportKey.sources &&
|
| 86 |
-
isGoogleSourceFilter(dashboardState)
|
| 87 |
-
) {
|
| 88 |
-
return SEARCH_TERMS_KEY
|
| 89 |
-
}
|
| 90 |
-
if (
|
| 91 |
-
currentTab === BreakdownReportKey.sources &&
|
| 92 |
-
isFixedSourceFilter(dashboardState)
|
| 93 |
-
) {
|
| 94 |
-
return BreakdownReportKey.referrers
|
| 95 |
-
}
|
| 96 |
-
return currentTab
|
| 97 |
-
}
|
| 98 |
-
|
| 99 |
-
function initTab(storedMode: string): TabKey {
|
| 100 |
-
if (['channels', BreakdownReportKey.channels].includes(storedMode)) {
|
| 101 |
-
return BreakdownReportKey.channels
|
| 102 |
-
}
|
| 103 |
-
if (['utm_mediums', BreakdownReportKey.utmMediums].includes(storedMode)) {
|
| 104 |
-
return BreakdownReportKey.utmMediums
|
| 105 |
-
}
|
| 106 |
-
if (['utm_sources', BreakdownReportKey.utmSources].includes(storedMode)) {
|
| 107 |
-
return BreakdownReportKey.utmSources
|
| 108 |
-
}
|
| 109 |
-
if (['utm_campaigns', BreakdownReportKey.utmCampaigns].includes(storedMode)) {
|
| 110 |
-
return BreakdownReportKey.utmCampaigns
|
| 111 |
-
}
|
| 112 |
-
if (['utm_contents', BreakdownReportKey.utmContents].includes(storedMode)) {
|
| 113 |
-
return BreakdownReportKey.utmContents
|
| 114 |
-
}
|
| 115 |
-
if (['utm_terms', BreakdownReportKey.utmTerms].includes(storedMode)) {
|
| 116 |
-
return BreakdownReportKey.utmTerms
|
| 117 |
-
}
|
| 118 |
-
return BreakdownReportKey.sources
|
| 119 |
-
}
|
| 120 |
-
|
| 121 |
-
function isUtmTab(tabKey: TabKey) {
|
| 122 |
-
return (UTM_TAB_KEYS as ReadonlyArray<TabKey>).includes(tabKey)
|
| 123 |
-
}
|
| 124 |
-
|
| 125 |
-
export default function Sources() {
|
| 126 |
-
const site = useSiteContext()
|
| 127 |
-
const { dashboardState } = useDashboardStateContext()
|
| 128 |
-
const tabStorageKey = 'sourceTab__' + site.domain
|
| 129 |
-
const [currentTab, setTab] = useState<TabKey>(
|
| 130 |
-
initTab(storage.getItem(tabStorageKey))
|
| 131 |
-
)
|
| 132 |
-
const [currentQueryApiData, setCurrentQueryApiData] =
|
| 133 |
-
useState<api.QueryApiResponse | null>(null)
|
| 134 |
-
const [currentSearchTermsData, setCurrentSearchTermsData] =
|
| 135 |
-
useState<SearchTermsSuccessResponse | null>(null)
|
| 136 |
-
|
| 137 |
-
const previousDashboardState = usePrevious(dashboardState)
|
| 138 |
-
|
| 139 |
-
const currentReportKey = getCurrentReportKey(currentTab, dashboardState)
|
| 140 |
-
|
| 141 |
-
const setAndStoreTab = useCallback(
|
| 142 |
-
(tab: TabKey) => {
|
| 143 |
-
storage.setItem(tabStorageKey, tab)
|
| 144 |
-
setTab(tab)
|
| 145 |
-
},
|
| 146 |
-
[tabStorageKey]
|
| 147 |
-
)
|
| 148 |
-
|
| 149 |
-
function moreLinkProps() {
|
| 150 |
-
if (currentReportKey === SEARCH_TERMS_KEY) {
|
| 151 |
-
return {
|
| 152 |
-
path: GOOGLE_SEARCH_TERMS_DETAILS_PATH,
|
| 153 |
-
search: (search: string) => search
|
| 154 |
-
}
|
| 155 |
-
}
|
| 156 |
-
if (currentReportKey === BreakdownReportKey.referrers) {
|
| 157 |
-
return {
|
| 158 |
-
path: BREAKDOWN_REPORTS[currentReportKey].detailsPath,
|
| 159 |
-
params: {
|
| 160 |
-
referrer: url.maybeEncodeRouteParam(
|
| 161 |
-
getFixedSourceFilterClause(dashboardState)
|
| 162 |
-
)
|
| 163 |
-
},
|
| 164 |
-
search: (search: string) => search
|
| 165 |
-
}
|
| 166 |
-
}
|
| 167 |
-
return {
|
| 168 |
-
path: BREAKDOWN_REPORTS[currentReportKey].detailsPath,
|
| 169 |
-
search: (search: string) => search
|
| 170 |
-
}
|
| 171 |
-
}
|
| 172 |
-
|
| 173 |
-
const ChannelsDimensionCell = useCallback(
|
| 174 |
-
(props: DimensionCellWithBarProps) => (
|
| 175 |
-
<DimensionCellWithBar
|
| 176 |
-
text={url.trimURL(props.row.dimensions[0], MAX_DIMENSION_LENGTH)}
|
| 177 |
-
onClick={() => setAndStoreTab(BreakdownReportKey.sources)}
|
| 178 |
-
barClassName={BAR_COLOR}
|
| 179 |
-
getFilterInfo={defaultGetFilterInfo}
|
| 180 |
-
{...props}
|
| 181 |
-
/>
|
| 182 |
-
),
|
| 183 |
-
[setAndStoreTab]
|
| 184 |
-
)
|
| 185 |
-
|
| 186 |
-
function renderContent() {
|
| 187 |
-
if (currentReportKey === SEARCH_TERMS_KEY) {
|
| 188 |
-
return <SearchTerms onDataReady={setCurrentSearchTermsData} />
|
| 189 |
-
}
|
| 190 |
-
|
| 191 |
-
const reportConfig = BREAKDOWN_REPORTS[currentReportKey]
|
| 192 |
-
|
| 193 |
-
const metrics = reportConfig.getMetrics({
|
| 194 |
-
isRealtime: isRealTimeDashboard(dashboardState),
|
| 195 |
-
hasConversionGoalFilter: hasConversionGoalFilter(dashboardState)
|
| 196 |
-
})
|
| 197 |
-
|
| 198 |
-
const DimensionElement = {
|
| 199 |
-
[BreakdownReportKey.channels]: ChannelsDimensionCell,
|
| 200 |
-
[BreakdownReportKey.sources]: SourcesDimensionCell,
|
| 201 |
-
[BreakdownReportKey.referrers]: ReferrerUrlDimensionCell,
|
| 202 |
-
[BreakdownReportKey.utmMediums]: UtmDimensionCell,
|
| 203 |
-
[BreakdownReportKey.utmSources]: UtmDimensionCell,
|
| 204 |
-
[BreakdownReportKey.utmCampaigns]: UtmDimensionCell,
|
| 205 |
-
[BreakdownReportKey.utmContents]: UtmDimensionCell,
|
| 206 |
-
[BreakdownReportKey.utmTerms]: UtmDimensionCell
|
| 207 |
-
}[currentReportKey]
|
| 208 |
-
|
| 209 |
-
return (
|
| 210 |
-
<IndexBreakdown
|
| 211 |
-
key={currentReportKey}
|
| 212 |
-
metrics={metrics}
|
| 213 |
-
dimensions={reportConfig.dimensions}
|
| 214 |
-
dimensionLabel={reportConfig.dimensionLabel}
|
| 215 |
-
alwaysOnFilters={reportConfig.alwaysOnFilters}
|
| 216 |
-
DimensionElement={DimensionElement}
|
| 217 |
-
onDataReady={setCurrentQueryApiData}
|
| 218 |
-
/>
|
| 219 |
-
)
|
| 220 |
-
}
|
| 221 |
-
|
| 222 |
-
const activeData =
|
| 223 |
-
currentReportKey === SEARCH_TERMS_KEY
|
| 224 |
-
? currentSearchTermsData
|
| 225 |
-
: currentQueryApiData
|
| 226 |
-
|
| 227 |
-
const moreLinkState = activeData
|
| 228 |
-
? activeData.results.length > 0
|
| 229 |
-
? MoreLinkState.READY
|
| 230 |
-
: MoreLinkState.HIDDEN
|
| 231 |
-
: MoreLinkState.LOADING
|
| 232 |
-
|
| 233 |
-
useEffect(() => {
|
| 234 |
-
const isRemovingFilter = (filterName: string) => {
|
| 235 |
-
if (!previousDashboardState) return false
|
| 236 |
-
|
| 237 |
-
return (
|
| 238 |
-
getFiltersByKeyPrefix(previousDashboardState, filterName).length > 0 &&
|
| 239 |
-
getFiltersByKeyPrefix(dashboardState, filterName).length == 0
|
| 240 |
-
)
|
| 241 |
-
}
|
| 242 |
-
|
| 243 |
-
if (
|
| 244 |
-
currentTab == BreakdownReportKey.sources &&
|
| 245 |
-
isRemovingFilter('channel')
|
| 246 |
-
) {
|
| 247 |
-
setAndStoreTab(BreakdownReportKey.channels)
|
| 248 |
-
}
|
| 249 |
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 250 |
-
}, [dashboardState, currentTab])
|
| 251 |
-
|
| 252 |
-
function sourceTabLabel() {
|
| 253 |
-
if (isGoogleSourceFilter(dashboardState)) {
|
| 254 |
-
return 'Search terms'
|
| 255 |
-
} else if (isFixedSourceFilter(dashboardState)) {
|
| 256 |
-
return 'Top referrers'
|
| 257 |
-
} else {
|
| 258 |
-
return 'Sources'
|
| 259 |
-
}
|
| 260 |
-
}
|
| 261 |
-
|
| 262 |
-
return (
|
| 263 |
-
<ReportLayout testId="report-sources" className="overflow-x-hidden">
|
| 264 |
-
<ReportHeader>
|
| 265 |
-
<div className="flex gap-x-3">
|
| 266 |
-
<TabWrapper>
|
| 267 |
-
{[
|
| 268 |
-
{ value: BreakdownReportKey.channels, label: 'Channels' },
|
| 269 |
-
{ value: BreakdownReportKey.sources, label: sourceTabLabel() }
|
| 270 |
-
].map(({ value, label }) => (
|
| 271 |
-
<TabButton
|
| 272 |
-
key={value}
|
| 273 |
-
onClick={() => setAndStoreTab(value as TabKey)}
|
| 274 |
-
active={currentTab === value}
|
| 275 |
-
>
|
| 276 |
-
{label}
|
| 277 |
-
</TabButton>
|
| 278 |
-
))}
|
| 279 |
-
<DropdownTabButton
|
| 280 |
-
className="md:relative"
|
| 281 |
-
transitionClassName="md:left-auto md:w-56 md:origin-top-right"
|
| 282 |
-
active={isUtmTab(currentTab)}
|
| 283 |
-
options={UTM_TAB_KEYS.map((utmTabKey) => ({
|
| 284 |
-
label: BREAKDOWN_REPORTS[utmTabKey].detailsTitle,
|
| 285 |
-
onClick: () => setAndStoreTab(utmTabKey),
|
| 286 |
-
selected: currentTab === utmTabKey
|
| 287 |
-
}))}
|
| 288 |
-
>
|
| 289 |
-
{isUtmTab(currentTab)
|
| 290 |
-
? BREAKDOWN_REPORTS[currentTab].detailsTitle
|
| 291 |
-
: 'Campaigns'}
|
| 292 |
-
</DropdownTabButton>
|
| 293 |
-
</TabWrapper>
|
| 294 |
-
{currentReportKey !== SEARCH_TERMS_KEY && (
|
| 295 |
-
<ImportedWarningBubble queryApiResponse={currentQueryApiData} />
|
| 296 |
-
)}
|
| 297 |
-
</div>
|
| 298 |
-
<MoreLink state={moreLinkState} linkProps={moreLinkProps()} />
|
| 299 |
-
</ReportHeader>
|
| 300 |
-
{renderContent()}
|
| 301 |
-
</ReportLayout>
|
| 302 |
-
)
|
| 303 |
-
}
|
| 304 |
-
|
| 305 |
-
function UtmDimensionCell(props: DimensionCellWithBarProps) {
|
| 306 |
-
const displayValue = url.trimURL(
|
| 307 |
-
props.row.dimensions[0],
|
| 308 |
-
MAX_DIMENSION_LENGTH
|
| 309 |
-
)
|
| 310 |
-
return (
|
| 311 |
-
<DimensionCellWithBar
|
| 312 |
-
text={displayValue}
|
| 313 |
-
barClassName={BAR_COLOR}
|
| 314 |
-
getFilterInfo={defaultGetFilterInfo}
|
| 315 |
-
{...props}
|
| 316 |
-
/>
|
| 317 |
-
)
|
| 318 |
-
}
|
| 319 |
-
|
| 320 |
-
function SourcesDimensionCell(props: DimensionCellWithBarProps) {
|
| 321 |
-
const displayValue = url.trimURL(
|
| 322 |
-
props.row.dimensions[0],
|
| 323 |
-
MAX_DIMENSION_LENGTH
|
| 324 |
-
)
|
| 325 |
-
|
| 326 |
-
return (
|
| 327 |
-
<DimensionCellWithBar
|
| 328 |
-
text={displayValue}
|
| 329 |
-
icon={
|
| 330 |
-
<SourceFavicon name={props.row.dimensions[0]} className="size-4 mr-2" />
|
| 331 |
-
}
|
| 332 |
-
barClassName={BAR_COLOR}
|
| 333 |
-
getFilterInfo={defaultGetFilterInfo}
|
| 334 |
-
{...props}
|
| 335 |
-
/>
|
| 336 |
-
)
|
| 337 |
-
}
|
| 338 |
-
|
| 339 |
-
function ReferrerUrlDimensionCell(props: DimensionCellWithBarProps) {
|
| 340 |
-
const dimensionValue = props.row.dimensions[0]
|
| 341 |
-
const displayValue = url.trimURL(dimensionValue, MAX_DIMENSION_LENGTH)
|
| 342 |
-
|
| 343 |
-
const externalUrl =
|
| 344 |
-
dimensionValue === DIRECT_NONE ? null : `https://${dimensionValue}`
|
| 345 |
-
|
| 346 |
-
return (
|
| 347 |
-
<DimensionCellWithBar
|
| 348 |
-
text={displayValue}
|
| 349 |
-
icon={<SourceFavicon name={dimensionValue} className="size-4 mr-2" />}
|
| 350 |
-
externalLink={
|
| 351 |
-
externalUrl && (
|
| 352 |
-
<IndexExternalLink href={externalUrl} isActive={props.isActive} />
|
| 353 |
-
)
|
| 354 |
-
}
|
| 355 |
-
barClassName={BAR_COLOR}
|
| 356 |
-
getFilterInfo={defaultGetFilterInfo}
|
| 357 |
-
{...props}
|
| 358 |
-
/>
|
| 359 |
-
)
|
| 360 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/dashboard/stats/sources/search-terms.tsx
DELETED
|
@@ -1,151 +0,0 @@
|
|
| 1 |
-
import React, { useEffect, useMemo } from 'react'
|
| 2 |
-
import { numberShortFormatter } from '../../util/number-formatter'
|
| 3 |
-
import RocketIcon from '../modals/rocket-icon'
|
| 4 |
-
import LazyLoader from '../../components/lazy-loader'
|
| 5 |
-
import { PlausibleSite, useSiteContext } from '../../site-context'
|
| 6 |
-
import {
|
| 7 |
-
SearchTermsErrorCode,
|
| 8 |
-
SearchTermsErrorPayload,
|
| 9 |
-
SearchTermsResultItem,
|
| 10 |
-
SearchTermsSuccessResponse,
|
| 11 |
-
useIndexGoogleSearchTermsQuery
|
| 12 |
-
} from './fetch-search-terms'
|
| 13 |
-
import {
|
| 14 |
-
Bar,
|
| 15 |
-
DEFAULT_METRIC_COLUMN_WIDTH,
|
| 16 |
-
IndexBreakdownRenderer
|
| 17 |
-
} from '../reports/index-breakdown'
|
| 18 |
-
import { ColumnConfiguration, MetricValueWrapper } from '../breakdowns'
|
| 19 |
-
|
| 20 |
-
function ErrorMessage({ code }: { code: SearchTermsErrorCode }): JSX.Element {
|
| 21 |
-
if (code === 'not_configured') {
|
| 22 |
-
return <div>The site is not connected to Google Search Keywords</div>
|
| 23 |
-
} else if (code === 'unsupported_filters') {
|
| 24 |
-
return (
|
| 25 |
-
<div>
|
| 26 |
-
Unable to fetch keyword data from Search Console because it does not
|
| 27 |
-
support the current set of filters
|
| 28 |
-
</div>
|
| 29 |
-
)
|
| 30 |
-
} else if (code === 'period_too_recent') {
|
| 31 |
-
return (
|
| 32 |
-
<div>
|
| 33 |
-
No search terms were found for this period. Please adjust or extend your
|
| 34 |
-
time range. Check{' '}
|
| 35 |
-
<a
|
| 36 |
-
href="https://plausible.io/docs/google-search-console-integration#i-dont-see-google-search-query-data-in-my-dashboard"
|
| 37 |
-
target="_blank"
|
| 38 |
-
rel="noreferrer"
|
| 39 |
-
className="hover:underline text-indigo-700 dark:text-indigo-500"
|
| 40 |
-
>
|
| 41 |
-
our documentation
|
| 42 |
-
</a>{' '}
|
| 43 |
-
for more details.
|
| 44 |
-
</div>
|
| 45 |
-
)
|
| 46 |
-
} else {
|
| 47 |
-
return <div>Unable to fetch keyword data from Search Console</div>
|
| 48 |
-
}
|
| 49 |
-
}
|
| 50 |
-
|
| 51 |
-
function ConfigureSearchTermsCTA({
|
| 52 |
-
site
|
| 53 |
-
}: {
|
| 54 |
-
site: PlausibleSite
|
| 55 |
-
}): JSX.Element {
|
| 56 |
-
return (
|
| 57 |
-
<>
|
| 58 |
-
<div>Configure the integration to view search terms</div>
|
| 59 |
-
<a
|
| 60 |
-
href={`/${encodeURIComponent(site.domain)}/settings/integrations`}
|
| 61 |
-
className="button mt-4"
|
| 62 |
-
>
|
| 63 |
-
Connect with Google
|
| 64 |
-
</a>
|
| 65 |
-
</>
|
| 66 |
-
)
|
| 67 |
-
}
|
| 68 |
-
|
| 69 |
-
export function SearchTerms({
|
| 70 |
-
onDataReady
|
| 71 |
-
}: {
|
| 72 |
-
onDataReady: (data: SearchTermsSuccessResponse) => void
|
| 73 |
-
}) {
|
| 74 |
-
const site = useSiteContext()
|
| 75 |
-
|
| 76 |
-
const [visible, setVisible] = React.useState(false)
|
| 77 |
-
|
| 78 |
-
const apiState = useIndexGoogleSearchTermsQuery({ enabled: visible })
|
| 79 |
-
|
| 80 |
-
useEffect(() => {
|
| 81 |
-
if (apiState.data) {
|
| 82 |
-
onDataReady(apiState.data)
|
| 83 |
-
}
|
| 84 |
-
}, [apiState.data, onDataReady])
|
| 85 |
-
|
| 86 |
-
const barMaxValue = useMemo(() => {
|
| 87 |
-
if (!apiState.data) return null
|
| 88 |
-
return Math.max(...apiState.data.results.map((item) => item.visitors))
|
| 89 |
-
}, [apiState.data])
|
| 90 |
-
|
| 91 |
-
const columns = useMemo(():
|
| 92 |
-
| ColumnConfiguration<SearchTermsResultItem>[]
|
| 93 |
-
| null => {
|
| 94 |
-
if (barMaxValue === null) {
|
| 95 |
-
return null
|
| 96 |
-
}
|
| 97 |
-
|
| 98 |
-
return [
|
| 99 |
-
{
|
| 100 |
-
key: 'dimension',
|
| 101 |
-
renderLabel: () => 'Search term',
|
| 102 |
-
renderCell: (item, _isActive) => (
|
| 103 |
-
<Bar
|
| 104 |
-
barWidthPercent={(item.visitors / barMaxValue) * 100}
|
| 105 |
-
className="bg-blue-50 group-hover/row:bg-blue-100"
|
| 106 |
-
>
|
| 107 |
-
{item.name}
|
| 108 |
-
</Bar>
|
| 109 |
-
),
|
| 110 |
-
align: 'left'
|
| 111 |
-
},
|
| 112 |
-
{
|
| 113 |
-
key: 'visitors',
|
| 114 |
-
renderLabel: () => 'Visitors',
|
| 115 |
-
renderCell: (item, _isActive) => (
|
| 116 |
-
<MetricValueWrapper>
|
| 117 |
-
{numberShortFormatter(item.visitors)}
|
| 118 |
-
</MetricValueWrapper>
|
| 119 |
-
),
|
| 120 |
-
width: DEFAULT_METRIC_COLUMN_WIDTH,
|
| 121 |
-
align: 'right'
|
| 122 |
-
}
|
| 123 |
-
]
|
| 124 |
-
}, [barMaxValue])
|
| 125 |
-
|
| 126 |
-
if (apiState.error) {
|
| 127 |
-
const { is_admin, error_code } = apiState.error
|
| 128 |
-
.payload as SearchTermsErrorPayload
|
| 129 |
-
|
| 130 |
-
return (
|
| 131 |
-
<div className="text-center text-gray-700 dark:text-gray-300 text-sm mt-20">
|
| 132 |
-
<RocketIcon />
|
| 133 |
-
<ErrorMessage code={error_code} />
|
| 134 |
-
{error_code === 'not_configured' && is_admin && (
|
| 135 |
-
<ConfigureSearchTermsCTA site={site} />
|
| 136 |
-
)}
|
| 137 |
-
</div>
|
| 138 |
-
)
|
| 139 |
-
}
|
| 140 |
-
return (
|
| 141 |
-
<LazyLoader onVisible={() => setVisible(true)}>
|
| 142 |
-
<IndexBreakdownRenderer<SearchTermsResultItem>
|
| 143 |
-
{...apiState}
|
| 144 |
-
rows={apiState.data?.results ?? []}
|
| 145 |
-
getDimensionValue={(row) => row.name}
|
| 146 |
-
isRealtimeSilentUpdate={false}
|
| 147 |
-
columns={columns}
|
| 148 |
-
/>
|
| 149 |
-
</LazyLoader>
|
| 150 |
-
)
|
| 151 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/dashboard/stats/sources/source-favicon.tsx
DELETED
|
@@ -1,26 +0,0 @@
|
|
| 1 |
-
import React from 'react'
|
| 2 |
-
import classNames from 'classnames'
|
| 3 |
-
|
| 4 |
-
interface SourceFaviconProps {
|
| 5 |
-
name: string
|
| 6 |
-
className?: string
|
| 7 |
-
}
|
| 8 |
-
|
| 9 |
-
export const SourceFavicon = ({ name, className }: SourceFaviconProps) => {
|
| 10 |
-
const sourceName = name.toLowerCase()
|
| 11 |
-
const needsWhiteBg =
|
| 12 |
-
sourceName.includes('github') || sourceName.includes('chatgpt.com')
|
| 13 |
-
|
| 14 |
-
return (
|
| 15 |
-
<img
|
| 16 |
-
alt=""
|
| 17 |
-
src={`/favicon/sources/${encodeURIComponent(name)}`}
|
| 18 |
-
referrerPolicy="no-referrer"
|
| 19 |
-
className={classNames(
|
| 20 |
-
className,
|
| 21 |
-
needsWhiteBg &&
|
| 22 |
-
'dark:bg-white dark:border dark:border-white dark:rounded-full'
|
| 23 |
-
)}
|
| 24 |
-
/>
|
| 25 |
-
)
|
| 26 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/dashboard/theme-context.tsx
DELETED
|
@@ -1,61 +0,0 @@
|
|
| 1 |
-
import React, {
|
| 2 |
-
createContext,
|
| 3 |
-
ReactNode,
|
| 4 |
-
useContext,
|
| 5 |
-
useLayoutEffect,
|
| 6 |
-
useRef,
|
| 7 |
-
useState
|
| 8 |
-
} from 'react'
|
| 9 |
-
|
| 10 |
-
export enum UIMode {
|
| 11 |
-
light = 'light',
|
| 12 |
-
dark = 'dark'
|
| 13 |
-
}
|
| 14 |
-
|
| 15 |
-
const defaultValue = { mode: UIMode.light }
|
| 16 |
-
|
| 17 |
-
const ThemeContext = createContext(defaultValue)
|
| 18 |
-
|
| 19 |
-
function parseUIMode(element: Element | null): UIMode {
|
| 20 |
-
return element?.classList.contains('dark') ? UIMode.dark : UIMode.light
|
| 21 |
-
}
|
| 22 |
-
|
| 23 |
-
export default function ThemeContextProvider({
|
| 24 |
-
children
|
| 25 |
-
}: {
|
| 26 |
-
children: ReactNode
|
| 27 |
-
}) {
|
| 28 |
-
const observerRef = useRef<MutationObserver | null>(null)
|
| 29 |
-
const [mode, setMode] = useState<UIMode>(
|
| 30 |
-
parseUIMode(document.querySelector('html'))
|
| 31 |
-
)
|
| 32 |
-
useLayoutEffect(() => {
|
| 33 |
-
const htmlElement = document.querySelector('html')
|
| 34 |
-
const currentObserver = observerRef.current
|
| 35 |
-
if (htmlElement && !currentObserver) {
|
| 36 |
-
const observer = new MutationObserver((mutations) => {
|
| 37 |
-
mutations.forEach((mutation) => {
|
| 38 |
-
if (
|
| 39 |
-
mutation.type === 'attributes' &&
|
| 40 |
-
mutation.attributeName === 'class'
|
| 41 |
-
) {
|
| 42 |
-
return setMode(parseUIMode(mutation.target as Element))
|
| 43 |
-
}
|
| 44 |
-
})
|
| 45 |
-
})
|
| 46 |
-
observerRef.current = observer
|
| 47 |
-
observer.observe(htmlElement, {
|
| 48 |
-
attributes: true,
|
| 49 |
-
attributeFilter: ['class']
|
| 50 |
-
})
|
| 51 |
-
}
|
| 52 |
-
return () => currentObserver?.disconnect()
|
| 53 |
-
}, [])
|
| 54 |
-
return (
|
| 55 |
-
<ThemeContext.Provider value={{ mode }}>{children}</ThemeContext.Provider>
|
| 56 |
-
)
|
| 57 |
-
}
|
| 58 |
-
|
| 59 |
-
export function useTheme() {
|
| 60 |
-
return useContext(ThemeContext)
|
| 61 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/dashboard/user-context.tsx
DELETED
|
@@ -1,50 +0,0 @@
|
|
| 1 |
-
import React, { createContext, ReactNode, useContext } from 'react'
|
| 2 |
-
|
| 3 |
-
export enum Role {
|
| 4 |
-
owner = 'owner',
|
| 5 |
-
admin = 'admin',
|
| 6 |
-
viewer = 'viewer',
|
| 7 |
-
editor = 'editor',
|
| 8 |
-
public = 'public',
|
| 9 |
-
billing = 'billing'
|
| 10 |
-
}
|
| 11 |
-
|
| 12 |
-
const userContextDefaultValue = {
|
| 13 |
-
loggedIn: false,
|
| 14 |
-
id: null,
|
| 15 |
-
role: Role.public,
|
| 16 |
-
team: {
|
| 17 |
-
identifier: null,
|
| 18 |
-
hasConsolidatedView: false
|
| 19 |
-
}
|
| 20 |
-
} as
|
| 21 |
-
| {
|
| 22 |
-
loggedIn: false
|
| 23 |
-
id: null
|
| 24 |
-
role: Role
|
| 25 |
-
team: { identifier: null; hasConsolidatedView: false }
|
| 26 |
-
}
|
| 27 |
-
| {
|
| 28 |
-
loggedIn: true
|
| 29 |
-
id: number
|
| 30 |
-
role: Role
|
| 31 |
-
team: { identifier: string | null; hasConsolidatedView: boolean }
|
| 32 |
-
}
|
| 33 |
-
|
| 34 |
-
export type UserContextValue = typeof userContextDefaultValue
|
| 35 |
-
|
| 36 |
-
const UserContext = createContext<UserContextValue>(userContextDefaultValue)
|
| 37 |
-
|
| 38 |
-
export const useUserContext = () => {
|
| 39 |
-
return useContext(UserContext)
|
| 40 |
-
}
|
| 41 |
-
|
| 42 |
-
export default function UserContextProvider({
|
| 43 |
-
user,
|
| 44 |
-
children
|
| 45 |
-
}: {
|
| 46 |
-
user: UserContextValue
|
| 47 |
-
children: ReactNode
|
| 48 |
-
}) {
|
| 49 |
-
return <UserContext.Provider value={user}>{children}</UserContext.Provider>
|
| 50 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/dashboard/util/date.js
DELETED
|
@@ -1,169 +0,0 @@
|
|
| 1 |
-
import dayjs from 'dayjs'
|
| 2 |
-
import utc from 'dayjs/plugin/utc'
|
| 3 |
-
import updateLocale from 'dayjs/plugin/updateLocale'
|
| 4 |
-
|
| 5 |
-
dayjs.extend(utc)
|
| 6 |
-
dayjs.extend(updateLocale)
|
| 7 |
-
// The locale 'en' is the default (and the only one currently),
|
| 8 |
-
// but its start of week is Sunday.
|
| 9 |
-
// This change that the week starts on Monday unifies behavior
|
| 10 |
-
// between start of week according to the backend
|
| 11 |
-
// and start of week according to the frontend.
|
| 12 |
-
dayjs.updateLocale('en', {
|
| 13 |
-
weekStart: 1
|
| 14 |
-
})
|
| 15 |
-
|
| 16 |
-
const browserDateFormat = Intl.DateTimeFormat(navigator.language, {
|
| 17 |
-
hour: 'numeric'
|
| 18 |
-
})
|
| 19 |
-
|
| 20 |
-
export function is12HourClock() {
|
| 21 |
-
return browserDateFormat.resolvedOptions().hour12
|
| 22 |
-
}
|
| 23 |
-
|
| 24 |
-
// https://stackoverflow.com/a/50130338
|
| 25 |
-
export function formatISO(date) {
|
| 26 |
-
return date.format('YYYY-MM-DD')
|
| 27 |
-
}
|
| 28 |
-
|
| 29 |
-
export function shiftMonths(date, months) {
|
| 30 |
-
return date.add(months, 'months')
|
| 31 |
-
}
|
| 32 |
-
|
| 33 |
-
export function shiftDays(date, days) {
|
| 34 |
-
return date.add(days, 'days')
|
| 35 |
-
}
|
| 36 |
-
|
| 37 |
-
export function formatMonthYYYY(date) {
|
| 38 |
-
return date.format('MMMM YYYY')
|
| 39 |
-
}
|
| 40 |
-
|
| 41 |
-
export function formatYear(date) {
|
| 42 |
-
return `Year of ${date.year()}`
|
| 43 |
-
}
|
| 44 |
-
|
| 45 |
-
export function formatYearShort(date) {
|
| 46 |
-
return date.getUTCFullYear().toString().substring(2)
|
| 47 |
-
}
|
| 48 |
-
|
| 49 |
-
export function formatDay(date, includeYear = false) {
|
| 50 |
-
if (includeYear) {
|
| 51 |
-
return date.format('ddd, DD MMM YYYY')
|
| 52 |
-
} else {
|
| 53 |
-
return date.format('ddd, DD MMM')
|
| 54 |
-
}
|
| 55 |
-
}
|
| 56 |
-
|
| 57 |
-
export function formatTime(date, { use12HourClock, includeMinutes }) {
|
| 58 |
-
if (use12HourClock) {
|
| 59 |
-
return includeMinutes ? date.format('h:mma') : date.format('ha')
|
| 60 |
-
}
|
| 61 |
-
return date.format('HH:mm')
|
| 62 |
-
}
|
| 63 |
-
|
| 64 |
-
export function formatDayShort(date, includeYear = false) {
|
| 65 |
-
if (includeYear) {
|
| 66 |
-
return date.format('D MMM YY')
|
| 67 |
-
} else {
|
| 68 |
-
return date.format('D MMM')
|
| 69 |
-
}
|
| 70 |
-
}
|
| 71 |
-
|
| 72 |
-
export function formatDateRange(site, from, to) {
|
| 73 |
-
if (!from || !to) return
|
| 74 |
-
if (typeof from === 'string') from = parseUTCDate(from)
|
| 75 |
-
if (typeof to === 'string') to = parseUTCDate(to)
|
| 76 |
-
|
| 77 |
-
if (from.isSame(to)) {
|
| 78 |
-
return formatDay(from)
|
| 79 |
-
} else if (from.isSame(to, 'year')) {
|
| 80 |
-
const includeYear = !isThisYear(site, from)
|
| 81 |
-
return `${formatDayShort(from, false)} - ${formatDayShort(to, includeYear)}`
|
| 82 |
-
} else {
|
| 83 |
-
return `${formatDayShort(from, true)} - ${formatDayShort(to, true)}`
|
| 84 |
-
}
|
| 85 |
-
}
|
| 86 |
-
|
| 87 |
-
export function parseUTCDate(dateString) {
|
| 88 |
-
return dayjs.utc(dateString)
|
| 89 |
-
}
|
| 90 |
-
|
| 91 |
-
export function parseNaiveDate(dateString) {
|
| 92 |
-
return dayjs(dateString)
|
| 93 |
-
}
|
| 94 |
-
|
| 95 |
-
export function utcNow() {
|
| 96 |
-
return dayjs.utc()
|
| 97 |
-
}
|
| 98 |
-
|
| 99 |
-
export function now(offset) {
|
| 100 |
-
return utcNow().utcOffset(offset / 60)
|
| 101 |
-
}
|
| 102 |
-
|
| 103 |
-
export function yesterday(offset) {
|
| 104 |
-
return shiftDays(now(offset), -1)
|
| 105 |
-
}
|
| 106 |
-
|
| 107 |
-
export function lastMonth(site) {
|
| 108 |
-
return shiftMonths(now(site.offset), -1)
|
| 109 |
-
}
|
| 110 |
-
|
| 111 |
-
export function isSameDate(date1, date2) {
|
| 112 |
-
return formatISO(date1) === formatISO(date2)
|
| 113 |
-
}
|
| 114 |
-
|
| 115 |
-
export function isSameMonth(date1, date2) {
|
| 116 |
-
return formatMonthYYYY(date1) === formatMonthYYYY(date2)
|
| 117 |
-
}
|
| 118 |
-
|
| 119 |
-
export function isToday(site, date) {
|
| 120 |
-
return isSameDate(date, now(site.offset))
|
| 121 |
-
}
|
| 122 |
-
|
| 123 |
-
export function isTodayOrYesterday(isoDate) {
|
| 124 |
-
const isoToday = formatISO(dayjs())
|
| 125 |
-
const isoYesterday = formatISO(dayjs().subtract(1, 'day'))
|
| 126 |
-
return isoDate === isoToday || isoDate === isoYesterday
|
| 127 |
-
}
|
| 128 |
-
|
| 129 |
-
export function isThisMonth(site, date) {
|
| 130 |
-
return formatMonthYYYY(date) === formatMonthYYYY(now(site.offset))
|
| 131 |
-
}
|
| 132 |
-
|
| 133 |
-
export function isThisYear(site, date) {
|
| 134 |
-
return date.year() === now(site.offset).year()
|
| 135 |
-
}
|
| 136 |
-
|
| 137 |
-
export function isBefore(date1, date2, period) {
|
| 138 |
-
/* assumes 'day' and 'month' are the only valid periods */
|
| 139 |
-
if (date1.year() !== date2.year()) {
|
| 140 |
-
return date1.year() < date2.year()
|
| 141 |
-
}
|
| 142 |
-
if (period === 'year') {
|
| 143 |
-
return false
|
| 144 |
-
}
|
| 145 |
-
if (date1.month() !== date2.month()) {
|
| 146 |
-
return date1.month() < date2.month()
|
| 147 |
-
}
|
| 148 |
-
if (period === 'month') {
|
| 149 |
-
return false
|
| 150 |
-
}
|
| 151 |
-
return date1.date() < date2.date()
|
| 152 |
-
}
|
| 153 |
-
|
| 154 |
-
export function isAfter(date1, date2, period) {
|
| 155 |
-
/* assumes 'day' and 'month' are the only valid periods */
|
| 156 |
-
if (date1.year() !== date2.year()) {
|
| 157 |
-
return date1.year() > date2.year()
|
| 158 |
-
}
|
| 159 |
-
if (period === 'year') {
|
| 160 |
-
return false
|
| 161 |
-
}
|
| 162 |
-
if (date1.month() !== date2.month()) {
|
| 163 |
-
return date1.month() > date2.month()
|
| 164 |
-
}
|
| 165 |
-
if (period === 'month') {
|
| 166 |
-
return false
|
| 167 |
-
}
|
| 168 |
-
return date1.date() > date2.date()
|
| 169 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/dashboard/util/date.test.ts
DELETED
|
@@ -1,210 +0,0 @@
|
|
| 1 |
-
import {
|
| 2 |
-
formatDayShort,
|
| 3 |
-
formatTime,
|
| 4 |
-
formatMonthYYYY,
|
| 5 |
-
formatISO,
|
| 6 |
-
now,
|
| 7 |
-
parseNaiveDate,
|
| 8 |
-
parseUTCDate,
|
| 9 |
-
shiftMonths,
|
| 10 |
-
yesterday
|
| 11 |
-
} from './date'
|
| 12 |
-
|
| 13 |
-
jest.useFakeTimers()
|
| 14 |
-
|
| 15 |
-
describe(`${now.name} and ${formatISO.name}`, () => {
|
| 16 |
-
/* prettier-ignore */
|
| 17 |
-
const cases = [
|
| 18 |
-
[ 'Los Angeles/America', -3600 * 6, '2024-11-01T20:00:00.000Z', '2024-11-01' ],
|
| 19 |
-
[ 'Sydney/Australia', 3600 * 6, '2024-11-01T20:00:00.000Z', '2024-11-02' ]
|
| 20 |
-
]
|
| 21 |
-
test.each(cases)(
|
| 22 |
-
'in timezone of %s (offset %p) at %s, today is %s',
|
| 23 |
-
(_tz, offset, utcTime, expectedToday) => {
|
| 24 |
-
jest.setSystemTime(new Date(utcTime))
|
| 25 |
-
expect(formatISO(now(offset))).toEqual(expectedToday)
|
| 26 |
-
}
|
| 27 |
-
)
|
| 28 |
-
})
|
| 29 |
-
|
| 30 |
-
/* prettier-ignore */
|
| 31 |
-
const dstChangeOverDayEstonia = [
|
| 32 |
-
// system time today yesterday today-2mo today+2mo today-12mo offset
|
| 33 |
-
[ '2024-03-30T21:00:00.000Z', '2024-03-30', '2024-03-29', '2024-01-30', '2024-05-30', '2023-03-30', 7200 ],
|
| 34 |
-
[ '2024-03-30T22:00:00.000Z', '2024-03-31', '2024-03-30', '2024-01-31', '2024-05-31', '2023-03-31', 7200 ],
|
| 35 |
-
[ '2024-03-30T23:00:00.000Z', '2024-03-31', '2024-03-30', '2024-01-31', '2024-05-31', '2023-03-31', 7200 ],
|
| 36 |
-
[ '2024-03-31T00:00:00.000Z', '2024-03-31', '2024-03-30', '2024-01-31', '2024-05-31', '2023-03-31', 7200 ],
|
| 37 |
-
[ '2024-03-31T01:00:00.000Z', '2024-03-31', '2024-03-30', '2024-01-31', '2024-05-31', '2023-03-31', 10800 ], // <-- all European TZs change to DST at 1AM UTC
|
| 38 |
-
[ '2024-03-31T02:00:00.000Z', '2024-03-31', '2024-03-30', '2024-01-31', '2024-05-31', '2023-03-31', 10800 ],
|
| 39 |
-
[ '2024-03-31T03:00:00.000Z', '2024-03-31', '2024-03-30', '2024-01-31', '2024-05-31', '2023-03-31', 10800 ],
|
| 40 |
-
[ '2024-03-31T04:00:00.000Z', '2024-03-31', '2024-03-30', '2024-01-31', '2024-05-31', '2023-03-31', 10800 ],
|
| 41 |
-
// ...
|
| 42 |
-
[ '2024-03-31T20:00:00.000Z', '2024-03-31', '2024-03-30', '2024-01-31', '2024-05-31', '2023-03-31', 10800 ],
|
| 43 |
-
[ '2024-03-31T21:00:00.000Z', '2024-04-01', '2024-03-31', '2024-02-01', '2024-06-01', '2023-04-01', 10800 ],
|
| 44 |
-
[ '2024-03-31T22:00:00.000Z', '2024-04-01', '2024-03-31', '2024-02-01', '2024-06-01', '2023-04-01', 10800 ],
|
| 45 |
-
[ '2024-03-31T23:00:00.000Z', '2024-04-01', '2024-03-31', '2024-02-01', '2024-06-01', '2023-04-01', 10800 ]
|
| 46 |
-
]
|
| 47 |
-
|
| 48 |
-
/* prettier-ignore */
|
| 49 |
-
const dstChangeOverDayGermany = [
|
| 50 |
-
// system time today yesterday today-2mo today+2mo today-12mo offset
|
| 51 |
-
[ '2024-03-30T21:00:00.000Z', '2024-03-30', '2024-03-29', '2024-01-30', '2024-05-30', '2023-03-30', 3600 ],
|
| 52 |
-
[ '2024-03-30T22:00:00.000Z', '2024-03-30', '2024-03-29', '2024-01-30', '2024-05-30', '2023-03-30', 3600 ],
|
| 53 |
-
[ '2024-03-30T23:00:00.000Z', '2024-03-31', '2024-03-30', '2024-01-31', '2024-05-31', '2023-03-31', 3600 ],
|
| 54 |
-
[ '2024-03-31T00:00:00.000Z', '2024-03-31', '2024-03-30', '2024-01-31', '2024-05-31', '2023-03-31', 3600 ],
|
| 55 |
-
[ '2024-03-31T01:00:00.000Z', '2024-03-31', '2024-03-30', '2024-01-31', '2024-05-31', '2023-03-31', 7200 ], // <-- all European TZs change to DST at 1AM UTC
|
| 56 |
-
[ '2024-03-31T02:00:00.000Z', '2024-03-31', '2024-03-30', '2024-01-31', '2024-05-31', '2023-03-31', 7200 ],
|
| 57 |
-
[ '2024-03-31T03:00:00.000Z', '2024-03-31', '2024-03-30', '2024-01-31', '2024-05-31', '2023-03-31', 7200 ],
|
| 58 |
-
[ '2024-03-31T04:00:00.000Z', '2024-03-31', '2024-03-30', '2024-01-31', '2024-05-31', '2023-03-31', 7200 ],
|
| 59 |
-
// ...
|
| 60 |
-
[ '2024-03-31T20:00:00.000Z', '2024-03-31', '2024-03-30', '2024-01-31', '2024-05-31', '2023-03-31', 7200 ],
|
| 61 |
-
[ '2024-03-31T21:00:00.000Z', '2024-03-31', '2024-03-30', '2024-01-31', '2024-05-31', '2023-03-31', 7200 ],
|
| 62 |
-
[ '2024-03-31T22:00:00.000Z', '2024-04-01', '2024-03-31', '2024-02-01', '2024-06-01', '2023-04-01', 7200 ],
|
| 63 |
-
[ '2024-03-31T23:00:00.000Z', '2024-04-01', '2024-03-31', '2024-02-01', '2024-06-01', '2023-04-01', 7200 ]
|
| 64 |
-
]
|
| 65 |
-
|
| 66 |
-
/* prettier-ignore */
|
| 67 |
-
const dstChangeOverDayEgypt = [
|
| 68 |
-
// system time today yesterday today-2mo today+2mo today-12mo offset
|
| 69 |
-
[ '2024-10-31T20:00:00.000Z', '2024-10-31', '2024-10-30', '2024-08-31', '2024-12-31', '2023-10-31', 10800 ],
|
| 70 |
-
[ '2024-10-31T20:59:59.999Z', '2024-10-31', '2024-10-30', '2024-08-31', '2024-12-31', '2023-10-31', 10800 ], // <-- changeover from DST is on Nov 1st 00:00 local time
|
| 71 |
-
[ '2024-10-31T21:00:00.000Z', '2024-10-31', '2024-10-30', '2024-08-31', '2024-12-31', '2023-10-31', 7200 ],
|
| 72 |
-
[ '2024-10-31T21:59:59.999Z', '2024-10-31', '2024-10-30', '2024-08-31', '2024-12-31', '2023-10-31', 7200 ],
|
| 73 |
-
[ '2024-10-31T22:00:00.000Z', '2024-11-01', '2024-10-31', '2024-09-01', '2025-01-01', '2023-11-01', 7200 ],
|
| 74 |
-
]
|
| 75 |
-
|
| 76 |
-
const sets = [
|
| 77 |
-
['Europe/Tallinn', dstChangeOverDayEstonia],
|
| 78 |
-
['Europe/Berlin', dstChangeOverDayGermany],
|
| 79 |
-
['Africa/Cairo', dstChangeOverDayEgypt]
|
| 80 |
-
] as const
|
| 81 |
-
|
| 82 |
-
for (const [timezone, suite] of sets) {
|
| 83 |
-
describe(`in timezone of ${timezone}, given the correct offset`, () => {
|
| 84 |
-
it.each(suite)(
|
| 85 |
-
'at system time %s, today is %s and yesterday is %s',
|
| 86 |
-
(
|
| 87 |
-
utcTime,
|
| 88 |
-
expectedToday,
|
| 89 |
-
expectedYesterday,
|
| 90 |
-
expectedTwoMonthsBack,
|
| 91 |
-
expectedTwoMonthsAhead,
|
| 92 |
-
expectedOneYearBack,
|
| 93 |
-
offset
|
| 94 |
-
) => {
|
| 95 |
-
jest.setSystemTime(new Date(utcTime))
|
| 96 |
-
expect({
|
| 97 |
-
today: formatISO(now(offset)),
|
| 98 |
-
yesterday: formatISO(yesterday(offset)),
|
| 99 |
-
twoMonthsBack: formatISO(shiftMonths(now(offset), -2)),
|
| 100 |
-
twoMonthsAhead: formatISO(shiftMonths(now(offset), 2)),
|
| 101 |
-
oneYearBack: formatISO(shiftMonths(now(offset), -12))
|
| 102 |
-
}).toEqual({
|
| 103 |
-
today: expectedToday,
|
| 104 |
-
yesterday: expectedYesterday,
|
| 105 |
-
twoMonthsBack: expectedTwoMonthsBack,
|
| 106 |
-
twoMonthsAhead: expectedTwoMonthsAhead,
|
| 107 |
-
oneYearBack: expectedOneYearBack
|
| 108 |
-
})
|
| 109 |
-
}
|
| 110 |
-
)
|
| 111 |
-
})
|
| 112 |
-
}
|
| 113 |
-
|
| 114 |
-
it('startOf("week") returns a Monday', () => {
|
| 115 |
-
expect(formatISO(parseUTCDate('2026-07-02').startOf('week'))).toEqual(
|
| 116 |
-
'2026-06-29' // Monday
|
| 117 |
-
)
|
| 118 |
-
})
|
| 119 |
-
|
| 120 |
-
describe('formatting site-timezoned datetimes from database works flawlessly', () => {
|
| 121 |
-
it('is able to enrich UTC date string with site timezone, formatting the value correctly', () => {
|
| 122 |
-
expect(formatDayShort(parseNaiveDate('2025-01-01 14:00:00'))).toEqual(
|
| 123 |
-
'1 Jan'
|
| 124 |
-
)
|
| 125 |
-
})
|
| 126 |
-
})
|
| 127 |
-
|
| 128 |
-
describe(formatMonthYYYY.name, () => {
|
| 129 |
-
it('formats a date as "Month YYYY"', () => {
|
| 130 |
-
expect(formatMonthYYYY(parseNaiveDate('2025-06-15'))).toEqual('June 2025')
|
| 131 |
-
})
|
| 132 |
-
|
| 133 |
-
it('formats January correctly', () => {
|
| 134 |
-
expect(formatMonthYYYY(parseNaiveDate('2024-01-01'))).toEqual(
|
| 135 |
-
'January 2024'
|
| 136 |
-
)
|
| 137 |
-
})
|
| 138 |
-
})
|
| 139 |
-
|
| 140 |
-
describe(formatDayShort.name, () => {
|
| 141 |
-
it('formats without year by default', () => {
|
| 142 |
-
expect(formatDayShort(parseNaiveDate('2025-06-05'))).toEqual('5 Jun')
|
| 143 |
-
})
|
| 144 |
-
|
| 145 |
-
it('includes 2-digit year when requested', () => {
|
| 146 |
-
expect(formatDayShort(parseNaiveDate('2025-06-05'), true)).toEqual(
|
| 147 |
-
'5 Jun 25'
|
| 148 |
-
)
|
| 149 |
-
})
|
| 150 |
-
})
|
| 151 |
-
|
| 152 |
-
describe(formatTime.name, () => {
|
| 153 |
-
describe('12-hour clock', () => {
|
| 154 |
-
it('formats hour without minutes as ha', () => {
|
| 155 |
-
expect(
|
| 156 |
-
formatTime(parseNaiveDate('2025-06-15 14:00:00'), {
|
| 157 |
-
use12HourClock: true,
|
| 158 |
-
includeMinutes: false
|
| 159 |
-
})
|
| 160 |
-
).toEqual('2pm')
|
| 161 |
-
})
|
| 162 |
-
|
| 163 |
-
it('formats hour with minutes as h:mma', () => {
|
| 164 |
-
expect(
|
| 165 |
-
formatTime(parseNaiveDate('2025-06-15 14:30:00'), {
|
| 166 |
-
use12HourClock: true,
|
| 167 |
-
includeMinutes: true
|
| 168 |
-
})
|
| 169 |
-
).toEqual('2:30pm')
|
| 170 |
-
})
|
| 171 |
-
|
| 172 |
-
it('formats midnight correctly', () => {
|
| 173 |
-
expect(
|
| 174 |
-
formatTime(parseNaiveDate('2025-06-15 00:00:00'), {
|
| 175 |
-
use12HourClock: true,
|
| 176 |
-
includeMinutes: false
|
| 177 |
-
})
|
| 178 |
-
).toEqual('12am')
|
| 179 |
-
})
|
| 180 |
-
})
|
| 181 |
-
|
| 182 |
-
describe('24-hour clock', () => {
|
| 183 |
-
it('formats hour without minutes as HH:mm (not HH format because that would look weird) ', () => {
|
| 184 |
-
expect(
|
| 185 |
-
formatTime(parseNaiveDate('2025-06-15 14:00:00'), {
|
| 186 |
-
use12HourClock: false,
|
| 187 |
-
includeMinutes: false
|
| 188 |
-
})
|
| 189 |
-
).toEqual('14:00')
|
| 190 |
-
})
|
| 191 |
-
|
| 192 |
-
it('formats hour with minutes as HH:mm', () => {
|
| 193 |
-
expect(
|
| 194 |
-
formatTime(parseNaiveDate('2025-06-15 14:30:00'), {
|
| 195 |
-
use12HourClock: false,
|
| 196 |
-
includeMinutes: true
|
| 197 |
-
})
|
| 198 |
-
).toEqual('14:30')
|
| 199 |
-
})
|
| 200 |
-
|
| 201 |
-
it('pads single-digit hours', () => {
|
| 202 |
-
expect(
|
| 203 |
-
formatTime(parseNaiveDate('2025-06-15 09:00:00'), {
|
| 204 |
-
use12HourClock: false,
|
| 205 |
-
includeMinutes: false
|
| 206 |
-
})
|
| 207 |
-
).toEqual('09:00')
|
| 208 |
-
})
|
| 209 |
-
})
|
| 210 |
-
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/dashboard/util/fetch-suggestions.ts
DELETED
|
@@ -1,35 +0,0 @@
|
|
| 1 |
-
import * as api from '../api'
|
| 2 |
-
import { DashboardState, Filter } from '../dashboard-state'
|
| 3 |
-
import { replaceFilterByPrefix, omitFiltersByKeyPrefix } from './filters'
|
| 4 |
-
|
| 5 |
-
export function fetchSuggestions(
|
| 6 |
-
apiPath: string,
|
| 7 |
-
dashboardState: DashboardState,
|
| 8 |
-
input: string,
|
| 9 |
-
additionalFilter?: Filter
|
| 10 |
-
) {
|
| 11 |
-
const updatedQuery = queryForSuggestions(dashboardState, additionalFilter)
|
| 12 |
-
return api.get(apiPath, updatedQuery, { q: input.trim() })
|
| 13 |
-
}
|
| 14 |
-
|
| 15 |
-
function queryForSuggestions(
|
| 16 |
-
dashboardState: DashboardState,
|
| 17 |
-
additionalFilter?: Filter
|
| 18 |
-
): DashboardState {
|
| 19 |
-
let filters = dashboardState.filters
|
| 20 |
-
if (additionalFilter) {
|
| 21 |
-
const [_operation, filterKey, clauses] = additionalFilter
|
| 22 |
-
|
| 23 |
-
// For suggestions, we remove already-applied filter with same key from dashboardState and add new filter (if feasible)
|
| 24 |
-
if (clauses.length > 0) {
|
| 25 |
-
filters = replaceFilterByPrefix(
|
| 26 |
-
dashboardState,
|
| 27 |
-
filterKey,
|
| 28 |
-
additionalFilter
|
| 29 |
-
)
|
| 30 |
-
} else {
|
| 31 |
-
filters = omitFiltersByKeyPrefix(dashboardState, filterKey)
|
| 32 |
-
}
|
| 33 |
-
}
|
| 34 |
-
return { ...dashboardState, filters }
|
| 35 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/dashboard/util/filter-text.test.tsx
DELETED
|
@@ -1,42 +0,0 @@
|
|
| 1 |
-
import React from 'react'
|
| 2 |
-
import { Filter, FilterClauseLabels } from '../dashboard-state'
|
| 3 |
-
import { plainFilterText, styledFilterText } from './filter-text'
|
| 4 |
-
import { render, screen } from '@testing-library/react'
|
| 5 |
-
|
| 6 |
-
describe('styledFilterText() and plainFilterText()', () => {
|
| 7 |
-
it.each<[Filter, FilterClauseLabels, string]>([
|
| 8 |
-
[['is', 'page', ['/docs', '/blog']], {}, 'Page is /docs or /blog'],
|
| 9 |
-
[
|
| 10 |
-
['is', 'country', ['US']],
|
| 11 |
-
{ US: 'United States' },
|
| 12 |
-
'Country is United States'
|
| 13 |
-
],
|
| 14 |
-
[['is', 'goal', ['Signup']], {}, 'Goal is Signup'],
|
| 15 |
-
[
|
| 16 |
-
['is', 'props:browser_language', ['en-US']],
|
| 17 |
-
{},
|
| 18 |
-
'Property browser_language is en-US'
|
| 19 |
-
],
|
| 20 |
-
[
|
| 21 |
-
['has_not_done', 'goal', ['Signup', 'Login']],
|
| 22 |
-
{},
|
| 23 |
-
'Goal is not Signup or Login'
|
| 24 |
-
]
|
| 25 |
-
])(
|
| 26 |
-
'when filter is %p and labels are %p, functions return %p',
|
| 27 |
-
(filter, labels, expectedPlainText) => {
|
| 28 |
-
const dashboardState = { labels }
|
| 29 |
-
|
| 30 |
-
expect(plainFilterText(dashboardState, filter)).toBe(expectedPlainText)
|
| 31 |
-
|
| 32 |
-
render(
|
| 33 |
-
<p data-testid="filter-text">
|
| 34 |
-
{styledFilterText(dashboardState, filter)}
|
| 35 |
-
</p>
|
| 36 |
-
)
|
| 37 |
-
expect(screen.getByTestId('filter-text')).toHaveTextContent(
|
| 38 |
-
expectedPlainText
|
| 39 |
-
)
|
| 40 |
-
}
|
| 41 |
-
)
|
| 42 |
-
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/dashboard/util/filter-text.tsx
DELETED
|
@@ -1,78 +0,0 @@
|
|
| 1 |
-
import React, { ReactNode, isValidElement, Fragment } from 'react'
|
| 2 |
-
import { DashboardState, Filter } from '../dashboard-state'
|
| 3 |
-
import {
|
| 4 |
-
EVENT_PROPS_PREFIX,
|
| 5 |
-
FILTER_OPERATIONS_DISPLAY_NAMES,
|
| 6 |
-
formattedFilters,
|
| 7 |
-
getLabel,
|
| 8 |
-
getPropertyKeyFromFilterKey
|
| 9 |
-
} from './filters'
|
| 10 |
-
|
| 11 |
-
export function styledFilterText(
|
| 12 |
-
dashboardState: Pick<DashboardState, 'labels'>,
|
| 13 |
-
[operation, filterKey, clauses]: Filter
|
| 14 |
-
) {
|
| 15 |
-
if (filterKey.startsWith(EVENT_PROPS_PREFIX)) {
|
| 16 |
-
const propKey = getPropertyKeyFromFilterKey(filterKey)
|
| 17 |
-
return (
|
| 18 |
-
<>
|
| 19 |
-
Property <b>{propKey}</b> {FILTER_OPERATIONS_DISPLAY_NAMES[operation]}{' '}
|
| 20 |
-
{formatClauses(clauses)}
|
| 21 |
-
</>
|
| 22 |
-
)
|
| 23 |
-
}
|
| 24 |
-
|
| 25 |
-
const formattedFilter = (
|
| 26 |
-
formattedFilters as Record<string, string | undefined>
|
| 27 |
-
)[filterKey]
|
| 28 |
-
const clausesLabels = clauses.map((value) =>
|
| 29 |
-
getLabel(dashboardState.labels, filterKey, value)
|
| 30 |
-
)
|
| 31 |
-
|
| 32 |
-
if (!formattedFilter) {
|
| 33 |
-
throw new Error(`Unknown filter: ${filterKey}`)
|
| 34 |
-
}
|
| 35 |
-
|
| 36 |
-
return (
|
| 37 |
-
<>
|
| 38 |
-
{capitalize(formattedFilter)} {FILTER_OPERATIONS_DISPLAY_NAMES[operation]}{' '}
|
| 39 |
-
{formatClauses(clausesLabels)}
|
| 40 |
-
</>
|
| 41 |
-
)
|
| 42 |
-
}
|
| 43 |
-
|
| 44 |
-
export function plainFilterText(
|
| 45 |
-
dashboardState: Pick<DashboardState, 'labels'>,
|
| 46 |
-
filter: Filter
|
| 47 |
-
) {
|
| 48 |
-
return reactNodeToString(styledFilterText(dashboardState, filter))
|
| 49 |
-
}
|
| 50 |
-
|
| 51 |
-
function formatClauses(labels: Array<string | number>): ReactNode[] {
|
| 52 |
-
return labels.map((label, index) => (
|
| 53 |
-
<Fragment key={index}>
|
| 54 |
-
{index > 0 && ' or '}
|
| 55 |
-
<b>{label}</b>
|
| 56 |
-
</Fragment>
|
| 57 |
-
))
|
| 58 |
-
}
|
| 59 |
-
|
| 60 |
-
function capitalize(str: string): string {
|
| 61 |
-
return str[0].toUpperCase() + str.slice(1)
|
| 62 |
-
}
|
| 63 |
-
|
| 64 |
-
function reactNodeToString(reactNode: ReactNode): string {
|
| 65 |
-
let string = ''
|
| 66 |
-
if (typeof reactNode === 'string') {
|
| 67 |
-
string = reactNode
|
| 68 |
-
} else if (typeof reactNode === 'number') {
|
| 69 |
-
string = reactNode.toString()
|
| 70 |
-
} else if (reactNode instanceof Array) {
|
| 71 |
-
reactNode.forEach(function (child) {
|
| 72 |
-
string += reactNodeToString(child)
|
| 73 |
-
})
|
| 74 |
-
} else if (isValidElement(reactNode)) {
|
| 75 |
-
string += reactNodeToString(reactNode.props.children)
|
| 76 |
-
}
|
| 77 |
-
return string
|
| 78 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/dashboard/util/filters.js
DELETED
|
@@ -1,311 +0,0 @@
|
|
| 1 |
-
import { formatSegmentIdAsLabelKey } from '../filtering/segments'
|
| 2 |
-
|
| 3 |
-
export const FILTER_MODAL_TO_FILTER_GROUP = {
|
| 4 |
-
page: ['page', 'entry_page', 'exit_page'],
|
| 5 |
-
source: ['source', 'channel', 'referrer'],
|
| 6 |
-
location: ['country', 'region', 'city'],
|
| 7 |
-
screen: ['screen'],
|
| 8 |
-
browser: ['browser', 'browser_version'],
|
| 9 |
-
os: ['os', 'os_version'],
|
| 10 |
-
utm: ['utm_medium', 'utm_source', 'utm_campaign', 'utm_term', 'utm_content'],
|
| 11 |
-
goal: ['goal'],
|
| 12 |
-
props: ['props'],
|
| 13 |
-
hostname: ['hostname'],
|
| 14 |
-
segment: ['segment']
|
| 15 |
-
}
|
| 16 |
-
|
| 17 |
-
export function getAvailableFilterModals(site) {
|
| 18 |
-
const { props, ...rest } = FILTER_MODAL_TO_FILTER_GROUP
|
| 19 |
-
return {
|
| 20 |
-
...rest,
|
| 21 |
-
...(site.propsAvailable && { props })
|
| 22 |
-
}
|
| 23 |
-
}
|
| 24 |
-
|
| 25 |
-
export const FILTER_GROUP_TO_MODAL_TYPE = Object.fromEntries(
|
| 26 |
-
Object.entries(FILTER_MODAL_TO_FILTER_GROUP).flatMap(
|
| 27 |
-
([modalName, filterGroups]) =>
|
| 28 |
-
filterGroups.map((filterGroup) => [filterGroup, modalName])
|
| 29 |
-
)
|
| 30 |
-
)
|
| 31 |
-
|
| 32 |
-
export const EVENT_PROPS_PREFIX = 'props:'
|
| 33 |
-
|
| 34 |
-
export const FILTER_OPERATIONS = {
|
| 35 |
-
is: 'is',
|
| 36 |
-
isNot: 'is_not',
|
| 37 |
-
contains: 'contains',
|
| 38 |
-
contains_not: 'contains_not',
|
| 39 |
-
has_not_done: 'has_not_done'
|
| 40 |
-
}
|
| 41 |
-
|
| 42 |
-
export const FILTER_OPERATIONS_DISPLAY_NAMES = {
|
| 43 |
-
[FILTER_OPERATIONS.is]: 'is',
|
| 44 |
-
[FILTER_OPERATIONS.isNot]: 'is not',
|
| 45 |
-
[FILTER_OPERATIONS.contains]: 'contains',
|
| 46 |
-
[FILTER_OPERATIONS.contains_not]: 'does not contain',
|
| 47 |
-
// :NOTE: Goal filters are displayed as "is not" in the UI, but in the backend they are wrapped with has_not_done.
|
| 48 |
-
// It is currently unclear if we'll do the same for other event filters in the future.
|
| 49 |
-
[FILTER_OPERATIONS.has_not_done]: 'is not'
|
| 50 |
-
}
|
| 51 |
-
|
| 52 |
-
export function supportsIsNot(filterName) {
|
| 53 |
-
return !['goal', 'prop_key'].includes(filterName)
|
| 54 |
-
}
|
| 55 |
-
|
| 56 |
-
export function supportsContains(filterName) {
|
| 57 |
-
return !['screen']
|
| 58 |
-
.concat(FILTER_MODAL_TO_FILTER_GROUP['location'])
|
| 59 |
-
.includes(filterName)
|
| 60 |
-
}
|
| 61 |
-
|
| 62 |
-
export function supportsHasDoneNot(filterName) {
|
| 63 |
-
return filterName === 'goal'
|
| 64 |
-
}
|
| 65 |
-
|
| 66 |
-
export function isFreeChoiceFilterOperation(operation) {
|
| 67 |
-
return [FILTER_OPERATIONS.contains, FILTER_OPERATIONS.contains_not].includes(
|
| 68 |
-
operation
|
| 69 |
-
)
|
| 70 |
-
}
|
| 71 |
-
|
| 72 |
-
export function getLabel(labels, filterKey, value) {
|
| 73 |
-
if (['country', 'region', 'city'].includes(filterKey)) {
|
| 74 |
-
return labels[value]
|
| 75 |
-
}
|
| 76 |
-
|
| 77 |
-
if (filterKey === 'segment') {
|
| 78 |
-
return labels[formatSegmentIdAsLabelKey(value)]
|
| 79 |
-
}
|
| 80 |
-
|
| 81 |
-
return value
|
| 82 |
-
}
|
| 83 |
-
|
| 84 |
-
export function getPropertyKeyFromFilterKey(filterKey) {
|
| 85 |
-
return filterKey.slice(EVENT_PROPS_PREFIX.length)
|
| 86 |
-
}
|
| 87 |
-
|
| 88 |
-
export function getFiltersByKeyPrefix(dashboardState, prefix) {
|
| 89 |
-
return dashboardState.filters.filter(hasDimensionPrefix(prefix))
|
| 90 |
-
}
|
| 91 |
-
|
| 92 |
-
const hasDimensionPrefix =
|
| 93 |
-
(prefix) =>
|
| 94 |
-
([_operation, dimension, _clauses]) =>
|
| 95 |
-
dimension.startsWith(prefix)
|
| 96 |
-
|
| 97 |
-
export function omitFiltersByKeyPrefix(dashboardState, prefix) {
|
| 98 |
-
return dashboardState.filters.filter(
|
| 99 |
-
([_operation, filterKey, _clauses]) => !filterKey.startsWith(prefix)
|
| 100 |
-
)
|
| 101 |
-
}
|
| 102 |
-
|
| 103 |
-
export function replaceFilterByPrefix(dashboardState, prefix, filter) {
|
| 104 |
-
return omitFiltersByKeyPrefix(dashboardState, prefix).concat([filter])
|
| 105 |
-
}
|
| 106 |
-
|
| 107 |
-
export function isFilteringOnFixedValue(
|
| 108 |
-
dashboardState,
|
| 109 |
-
filterKey,
|
| 110 |
-
expectedValue
|
| 111 |
-
) {
|
| 112 |
-
const filters = dashboardState.filters.filter(
|
| 113 |
-
([_operation, key]) => filterKey == key
|
| 114 |
-
)
|
| 115 |
-
if (filters.length == 1) {
|
| 116 |
-
const [operation, _filterKey, clauses] = filters[0]
|
| 117 |
-
return (
|
| 118 |
-
operation === FILTER_OPERATIONS.is &&
|
| 119 |
-
clauses.length === 1 &&
|
| 120 |
-
(!expectedValue || clauses[0] == expectedValue)
|
| 121 |
-
)
|
| 122 |
-
}
|
| 123 |
-
return false
|
| 124 |
-
}
|
| 125 |
-
|
| 126 |
-
export function hasPageFilter(dashboardState) {
|
| 127 |
-
return dashboardState.resolvedFilters.some(hasDimensionPrefix('page'))
|
| 128 |
-
}
|
| 129 |
-
|
| 130 |
-
export function hasConversionGoalFilter(dashboardState) {
|
| 131 |
-
const resolvedGoalFilters = dashboardState.resolvedFilters.filter(
|
| 132 |
-
hasDimensionPrefix('goal')
|
| 133 |
-
)
|
| 134 |
-
|
| 135 |
-
return resolvedGoalFilters.some(([operation, _filterKey, _clauses]) => {
|
| 136 |
-
return operation !== FILTER_OPERATIONS.has_not_done
|
| 137 |
-
})
|
| 138 |
-
}
|
| 139 |
-
|
| 140 |
-
export function isRealTimeDashboard(dashboardState) {
|
| 141 |
-
return dashboardState?.period === 'realtime'
|
| 142 |
-
}
|
| 143 |
-
|
| 144 |
-
// Note: Currently only a single goal filter can be applied at a time.
|
| 145 |
-
export function getGoalFilter(dashboardState) {
|
| 146 |
-
return getFiltersByKeyPrefix(dashboardState, 'goal')[0] || null
|
| 147 |
-
}
|
| 148 |
-
|
| 149 |
-
export function formatFilterGroup(filterGroup) {
|
| 150 |
-
if (filterGroup === 'utm') {
|
| 151 |
-
return 'UTM tags'
|
| 152 |
-
} else if (filterGroup === 'location') {
|
| 153 |
-
return 'Location'
|
| 154 |
-
} else if (filterGroup === 'props') {
|
| 155 |
-
return 'Property'
|
| 156 |
-
} else {
|
| 157 |
-
return formattedFilters[filterGroup]
|
| 158 |
-
}
|
| 159 |
-
}
|
| 160 |
-
|
| 161 |
-
export function cleanLabels(filters, labels, mergedFilterKey, mergedLabels) {
|
| 162 |
-
const filteredBy = Object.fromEntries(
|
| 163 |
-
filters
|
| 164 |
-
.flatMap(([_operation, filterKey, clauses]) => {
|
| 165 |
-
if (filterKey === 'segment') {
|
| 166 |
-
return clauses.map(formatSegmentIdAsLabelKey)
|
| 167 |
-
}
|
| 168 |
-
if (['country', 'region', 'city'].includes(filterKey)) {
|
| 169 |
-
return clauses
|
| 170 |
-
}
|
| 171 |
-
return []
|
| 172 |
-
})
|
| 173 |
-
.map((value) => [value, true])
|
| 174 |
-
)
|
| 175 |
-
|
| 176 |
-
let result = { ...labels }
|
| 177 |
-
for (const value in labels) {
|
| 178 |
-
if (!filteredBy[value]) {
|
| 179 |
-
delete result[value]
|
| 180 |
-
}
|
| 181 |
-
}
|
| 182 |
-
|
| 183 |
-
if (
|
| 184 |
-
mergedFilterKey &&
|
| 185 |
-
['country', 'region', 'city', 'segment'].includes(mergedFilterKey)
|
| 186 |
-
) {
|
| 187 |
-
result = {
|
| 188 |
-
...result,
|
| 189 |
-
...mergedLabels
|
| 190 |
-
}
|
| 191 |
-
}
|
| 192 |
-
|
| 193 |
-
return result
|
| 194 |
-
}
|
| 195 |
-
|
| 196 |
-
const NO_PREFIX_KEYS = new Set(['segment'])
|
| 197 |
-
const EVENT_FILTER_KEYS = new Set(['name', 'page', 'goal', 'hostname'])
|
| 198 |
-
const EVENT_PREFIX = 'event:'
|
| 199 |
-
const VISIT_PREFIX = 'visit:'
|
| 200 |
-
|
| 201 |
-
export function hasEventFilters(dashboardState) {
|
| 202 |
-
return dashboardState.resolvedFilters.some(
|
| 203 |
-
([_operation, filterKey, _clauses]) => isEventFilterKey(filterKey)
|
| 204 |
-
)
|
| 205 |
-
}
|
| 206 |
-
|
| 207 |
-
function isEventFilterKey(filterKey) {
|
| 208 |
-
return (
|
| 209 |
-
EVENT_FILTER_KEYS.has(filterKey) || filterKey.startsWith(EVENT_PROPS_PREFIX)
|
| 210 |
-
)
|
| 211 |
-
}
|
| 212 |
-
|
| 213 |
-
function remapFilterKey(filterKey) {
|
| 214 |
-
if (NO_PREFIX_KEYS.has(filterKey)) {
|
| 215 |
-
return filterKey
|
| 216 |
-
}
|
| 217 |
-
if (isEventFilterKey(filterKey)) {
|
| 218 |
-
return `${EVENT_PREFIX}${filterKey}`
|
| 219 |
-
}
|
| 220 |
-
return `${VISIT_PREFIX}${filterKey}`
|
| 221 |
-
}
|
| 222 |
-
|
| 223 |
-
function remapApiFilterKey(apiFilterKey) {
|
| 224 |
-
const isNoPrefixKey = NO_PREFIX_KEYS.has(apiFilterKey)
|
| 225 |
-
|
| 226 |
-
if (isNoPrefixKey) {
|
| 227 |
-
return apiFilterKey
|
| 228 |
-
}
|
| 229 |
-
|
| 230 |
-
const isEventKey = apiFilterKey.startsWith(EVENT_PREFIX)
|
| 231 |
-
const isVisitKey = apiFilterKey.startsWith(VISIT_PREFIX)
|
| 232 |
-
|
| 233 |
-
if (isEventKey) {
|
| 234 |
-
return apiFilterKey.substring(EVENT_PREFIX.length)
|
| 235 |
-
}
|
| 236 |
-
if (isVisitKey) {
|
| 237 |
-
return apiFilterKey.substring(VISIT_PREFIX.length)
|
| 238 |
-
}
|
| 239 |
-
|
| 240 |
-
return apiFilterKey // maybe throw?
|
| 241 |
-
}
|
| 242 |
-
|
| 243 |
-
export function remapToApiFilters(filters) {
|
| 244 |
-
return filters.map(remapToApiFilter)
|
| 245 |
-
}
|
| 246 |
-
|
| 247 |
-
export function remapFromApiFilters(apiFilters) {
|
| 248 |
-
return apiFilters.map((apiFilter) => {
|
| 249 |
-
const [operation, ...rest] = apiFilter
|
| 250 |
-
if (operation === 'has_not_done') {
|
| 251 |
-
const [[_, apiFilterKey, clauses]] = rest
|
| 252 |
-
return [
|
| 253 |
-
FILTER_OPERATIONS.has_not_done,
|
| 254 |
-
remapApiFilterKey(apiFilterKey),
|
| 255 |
-
clauses
|
| 256 |
-
]
|
| 257 |
-
}
|
| 258 |
-
const [apiFilterKey, clauses] = rest
|
| 259 |
-
return [operation, remapApiFilterKey(apiFilterKey), clauses]
|
| 260 |
-
})
|
| 261 |
-
}
|
| 262 |
-
|
| 263 |
-
export function serializeApiFilters(filters) {
|
| 264 |
-
return JSON.stringify(remapToApiFilters(filters))
|
| 265 |
-
}
|
| 266 |
-
|
| 267 |
-
function remapToApiFilter([operation, filterKey, clauses, ...modifiers]) {
|
| 268 |
-
const apiFilterKey = remapFilterKey(filterKey)
|
| 269 |
-
if (apiFilterKey === 'segment') {
|
| 270 |
-
return [operation, apiFilterKey, clauses.map((v) => parseInt(v, 10))]
|
| 271 |
-
}
|
| 272 |
-
if (operation === FILTER_OPERATIONS.has_not_done) {
|
| 273 |
-
// :NOTE: Frontend does not support advanced query building that's used in the backend.
|
| 274 |
-
// As such we emulate the backend behavior for has_not_done goal filters
|
| 275 |
-
return ['has_not_done', ['is', apiFilterKey, clauses, ...modifiers]]
|
| 276 |
-
} else {
|
| 277 |
-
return [operation, apiFilterKey, clauses, ...modifiers]
|
| 278 |
-
}
|
| 279 |
-
}
|
| 280 |
-
|
| 281 |
-
export function getFilterGroup([_operation, filterKey, _clauses]) {
|
| 282 |
-
return filterKey.startsWith(EVENT_PROPS_PREFIX) ? 'props' : filterKey
|
| 283 |
-
}
|
| 284 |
-
|
| 285 |
-
export const formattedFilters = {
|
| 286 |
-
goal: 'Goal',
|
| 287 |
-
props: 'Property',
|
| 288 |
-
prop_key: 'Property',
|
| 289 |
-
prop_value: 'Value',
|
| 290 |
-
source: 'Source',
|
| 291 |
-
channel: 'Channel',
|
| 292 |
-
utm_medium: 'UTM medium',
|
| 293 |
-
utm_source: 'UTM source',
|
| 294 |
-
utm_campaign: 'UTM campaign',
|
| 295 |
-
utm_content: 'UTM content',
|
| 296 |
-
utm_term: 'UTM term',
|
| 297 |
-
referrer: 'Referrer URL',
|
| 298 |
-
screen: 'Screen size',
|
| 299 |
-
browser: 'Browser',
|
| 300 |
-
browser_version: 'Browser version',
|
| 301 |
-
os: 'Operating system',
|
| 302 |
-
os_version: 'Operating system version',
|
| 303 |
-
country: 'Country',
|
| 304 |
-
region: 'Region',
|
| 305 |
-
city: 'City',
|
| 306 |
-
page: 'Page',
|
| 307 |
-
hostname: 'Hostname',
|
| 308 |
-
entry_page: 'Entry page',
|
| 309 |
-
exit_page: 'Exit page',
|
| 310 |
-
segment: 'Segment'
|
| 311 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/dashboard/util/filters.test.ts
DELETED
|
@@ -1,82 +0,0 @@
|
|
| 1 |
-
import { getAvailableFilterModals, serializeApiFilters } from './filters'
|
| 2 |
-
|
| 3 |
-
describe(`${getAvailableFilterModals.name}`, () => {
|
| 4 |
-
it('gives limited object when props are not available', () => {
|
| 5 |
-
expect(
|
| 6 |
-
getAvailableFilterModals({
|
| 7 |
-
propsAvailable: false
|
| 8 |
-
})
|
| 9 |
-
).toEqual({
|
| 10 |
-
browser: ['browser', 'browser_version'],
|
| 11 |
-
goal: ['goal'],
|
| 12 |
-
hostname: ['hostname'],
|
| 13 |
-
location: ['country', 'region', 'city'],
|
| 14 |
-
os: ['os', 'os_version'],
|
| 15 |
-
page: ['page', 'entry_page', 'exit_page'],
|
| 16 |
-
screen: ['screen'],
|
| 17 |
-
source: ['source', 'channel', 'referrer'],
|
| 18 |
-
utm: [
|
| 19 |
-
'utm_medium',
|
| 20 |
-
'utm_source',
|
| 21 |
-
'utm_campaign',
|
| 22 |
-
'utm_term',
|
| 23 |
-
'utm_content'
|
| 24 |
-
],
|
| 25 |
-
segment: ['segment']
|
| 26 |
-
})
|
| 27 |
-
})
|
| 28 |
-
|
| 29 |
-
it('gives full object when props and segments are available', () => {
|
| 30 |
-
expect(
|
| 31 |
-
getAvailableFilterModals({
|
| 32 |
-
propsAvailable: true
|
| 33 |
-
})
|
| 34 |
-
).toEqual({
|
| 35 |
-
browser: ['browser', 'browser_version'],
|
| 36 |
-
goal: ['goal'],
|
| 37 |
-
hostname: ['hostname'],
|
| 38 |
-
location: ['country', 'region', 'city'],
|
| 39 |
-
os: ['os', 'os_version'],
|
| 40 |
-
page: ['page', 'entry_page', 'exit_page'],
|
| 41 |
-
screen: ['screen'],
|
| 42 |
-
source: ['source', 'channel', 'referrer'],
|
| 43 |
-
utm: [
|
| 44 |
-
'utm_medium',
|
| 45 |
-
'utm_source',
|
| 46 |
-
'utm_campaign',
|
| 47 |
-
'utm_term',
|
| 48 |
-
'utm_content'
|
| 49 |
-
],
|
| 50 |
-
props: ['props'],
|
| 51 |
-
segment: ['segment']
|
| 52 |
-
})
|
| 53 |
-
})
|
| 54 |
-
})
|
| 55 |
-
|
| 56 |
-
describe(`${serializeApiFilters.name}`, () => {
|
| 57 |
-
it('should prefix filter keys with event: or visit: when appropriate', () => {
|
| 58 |
-
const filters = [
|
| 59 |
-
['is', 'page', ['/docs', '/blog']],
|
| 60 |
-
['contains', 'goal', ['Signup']],
|
| 61 |
-
['contains_not', 'browser', ['chrom'], { case_sensitive: false }],
|
| 62 |
-
['is', 'country', ['US']],
|
| 63 |
-
['is_not', 'utm_source', ['google']]
|
| 64 |
-
]
|
| 65 |
-
expect(serializeApiFilters(filters)).toEqual(
|
| 66 |
-
JSON.stringify([
|
| 67 |
-
['is', 'event:page', ['/docs', '/blog']],
|
| 68 |
-
['contains', 'event:goal', ['Signup']],
|
| 69 |
-
['contains_not', 'visit:browser', ['chrom'], { case_sensitive: false }],
|
| 70 |
-
['is', 'visit:country', ['US']],
|
| 71 |
-
['is_not', 'visit:utm_source', ['google']]
|
| 72 |
-
])
|
| 73 |
-
)
|
| 74 |
-
})
|
| 75 |
-
|
| 76 |
-
it('wraps has_not_done goal filters in API format', () => {
|
| 77 |
-
const filters = [['has_not_done', 'goal', ['Signup']]]
|
| 78 |
-
expect(serializeApiFilters(filters)).toEqual(
|
| 79 |
-
JSON.stringify([['has_not_done', ['is', 'event:goal', ['Signup']]]])
|
| 80 |
-
)
|
| 81 |
-
})
|
| 82 |
-
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/dashboard/util/goals.ts
DELETED
|
@@ -1,34 +0,0 @@
|
|
| 1 |
-
import { Filter } from '../dashboard-state'
|
| 2 |
-
import { FILTER_OPERATIONS } from './filters'
|
| 3 |
-
|
| 4 |
-
export const isPageViewGoal = (goalName: string) => {
|
| 5 |
-
goalName.startsWith('Visit ')
|
| 6 |
-
}
|
| 7 |
-
|
| 8 |
-
export const SPECIAL_GOALS = {
|
| 9 |
-
'404': { title: '404 Pages', prop: 'path' },
|
| 10 |
-
'Outbound Link: Click': { title: 'Outbound Links', prop: 'url' },
|
| 11 |
-
'Cloaked Link: Click': { title: 'Cloaked Links', prop: 'url' },
|
| 12 |
-
'File Download': { title: 'File Downloads', prop: 'url' },
|
| 13 |
-
'Form: Submission': { title: 'Form Actions', prop: 'path' },
|
| 14 |
-
'WP Search Queries': {
|
| 15 |
-
title: 'WordPress Search Queries',
|
| 16 |
-
prop: 'search_query'
|
| 17 |
-
},
|
| 18 |
-
'WP Form Completions': { title: 'WordPress Form Completions', prop: 'path' }
|
| 19 |
-
}
|
| 20 |
-
|
| 21 |
-
export function isSpecialGoal(
|
| 22 |
-
goalName: string | number
|
| 23 |
-
): goalName is keyof typeof SPECIAL_GOALS {
|
| 24 |
-
return goalName in SPECIAL_GOALS
|
| 25 |
-
}
|
| 26 |
-
|
| 27 |
-
export function getSpecialGoal(goalFilter: Filter) {
|
| 28 |
-
const [operation, _filterKey, clauses] = goalFilter
|
| 29 |
-
if (operation === FILTER_OPERATIONS.is && clauses.length == 1) {
|
| 30 |
-
const goalName = clauses[0]
|
| 31 |
-
return isSpecialGoal(goalName) ? SPECIAL_GOALS[goalName] : null
|
| 32 |
-
}
|
| 33 |
-
return null
|
| 34 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/dashboard/util/money.ts
DELETED
|
@@ -1,23 +0,0 @@
|
|
| 1 |
-
import { numberLongFormatter, numberShortFormatter } from './number-formatter'
|
| 2 |
-
|
| 3 |
-
type Money = { long: string; short: string }
|
| 4 |
-
|
| 5 |
-
export function formatMoneyShort(value: Money | number | null) {
|
| 6 |
-
if (typeof value == 'number') {
|
| 7 |
-
return numberShortFormatter(value)
|
| 8 |
-
} else if (value) {
|
| 9 |
-
return value.short
|
| 10 |
-
} else {
|
| 11 |
-
return '-'
|
| 12 |
-
}
|
| 13 |
-
}
|
| 14 |
-
|
| 15 |
-
export function formatMoneyLong(value: Money | number | null) {
|
| 16 |
-
if (typeof value == 'number') {
|
| 17 |
-
return numberLongFormatter(value)
|
| 18 |
-
} else if (value) {
|
| 19 |
-
return value.long
|
| 20 |
-
} else {
|
| 21 |
-
return '-'
|
| 22 |
-
}
|
| 23 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/dashboard/util/number-formatter.test.ts
DELETED
|
@@ -1,33 +0,0 @@
|
|
| 1 |
-
import { numberLongFormatter, numberShortFormatter } from './number-formatter'
|
| 2 |
-
|
| 3 |
-
describe('numberShortFormatter()', () => {
|
| 4 |
-
it('converts to short format', () => {
|
| 5 |
-
expect(numberShortFormatter(0)).toEqual('0')
|
| 6 |
-
expect(numberShortFormatter(-10)).toEqual('-10')
|
| 7 |
-
expect(numberShortFormatter(12)).toEqual('12')
|
| 8 |
-
expect(numberShortFormatter(123)).toEqual('123')
|
| 9 |
-
expect(numberShortFormatter(1234)).toEqual('1.2k')
|
| 10 |
-
expect(numberShortFormatter(12345)).toEqual('12.3k')
|
| 11 |
-
expect(numberShortFormatter(123456)).toEqual('123k')
|
| 12 |
-
expect(numberShortFormatter(1234567)).toEqual('1.2M')
|
| 13 |
-
expect(numberShortFormatter(12345678)).toEqual('12.3M')
|
| 14 |
-
expect(numberShortFormatter(123456789)).toEqual('123M')
|
| 15 |
-
expect(numberShortFormatter(1234567890)).toEqual('1.2B')
|
| 16 |
-
})
|
| 17 |
-
})
|
| 18 |
-
|
| 19 |
-
describe('numberLongFormatter()', () => {
|
| 20 |
-
it('converts to short format', () => {
|
| 21 |
-
expect(numberLongFormatter(0)).toEqual('0')
|
| 22 |
-
expect(numberLongFormatter(-10)).toEqual('-10')
|
| 23 |
-
expect(numberLongFormatter(12)).toEqual('12')
|
| 24 |
-
expect(numberLongFormatter(123)).toEqual('123')
|
| 25 |
-
expect(numberLongFormatter(1234)).toEqual('1,234')
|
| 26 |
-
expect(numberLongFormatter(12345)).toEqual('12,345')
|
| 27 |
-
expect(numberLongFormatter(123456)).toEqual('123,456')
|
| 28 |
-
expect(numberLongFormatter(1234567)).toEqual('1,234,567')
|
| 29 |
-
expect(numberLongFormatter(12345678)).toEqual('12,345,678')
|
| 30 |
-
expect(numberLongFormatter(123456789)).toEqual('123,456,789')
|
| 31 |
-
expect(numberLongFormatter(1234567890)).toEqual('1,234,567,890')
|
| 32 |
-
})
|
| 33 |
-
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/dashboard/util/number-formatter.ts
DELETED
|
@@ -1,84 +0,0 @@
|
|
| 1 |
-
const THOUSAND = 1000
|
| 2 |
-
const HUNDRED_THOUSAND = 100000
|
| 3 |
-
const MILLION = 1000000
|
| 4 |
-
const HUNDRED_MILLION = 100000000
|
| 5 |
-
const BILLION = 1000000000
|
| 6 |
-
const HUNDRED_BILLION = 100000000000
|
| 7 |
-
const TRILLION = 1000000000000
|
| 8 |
-
|
| 9 |
-
const numberFormat = Intl.NumberFormat('en-US')
|
| 10 |
-
|
| 11 |
-
export function numberShortFormatter(num: number): string {
|
| 12 |
-
if (num >= THOUSAND && num < MILLION) {
|
| 13 |
-
const thousands = num / THOUSAND
|
| 14 |
-
if (thousands === Math.floor(thousands) || num >= HUNDRED_THOUSAND) {
|
| 15 |
-
return Math.floor(thousands) + 'k'
|
| 16 |
-
} else {
|
| 17 |
-
return Math.floor(thousands * 10) / 10 + 'k'
|
| 18 |
-
}
|
| 19 |
-
} else if (num >= MILLION && num < BILLION) {
|
| 20 |
-
const millions = num / MILLION
|
| 21 |
-
if (millions === Math.floor(millions) || num >= HUNDRED_MILLION) {
|
| 22 |
-
return Math.floor(millions) + 'M'
|
| 23 |
-
} else {
|
| 24 |
-
return Math.floor(millions * 10) / 10 + 'M'
|
| 25 |
-
}
|
| 26 |
-
} else if (num >= BILLION && num < TRILLION) {
|
| 27 |
-
const billions = num / BILLION
|
| 28 |
-
if (billions === Math.floor(billions) || num >= HUNDRED_BILLION) {
|
| 29 |
-
return Math.floor(billions) + 'B'
|
| 30 |
-
} else {
|
| 31 |
-
return Math.floor(billions * 10) / 10 + 'B'
|
| 32 |
-
}
|
| 33 |
-
} else {
|
| 34 |
-
return num.toString()
|
| 35 |
-
}
|
| 36 |
-
}
|
| 37 |
-
|
| 38 |
-
export function numberLongFormatter(num: number): string {
|
| 39 |
-
return numberFormat.format(num)
|
| 40 |
-
}
|
| 41 |
-
|
| 42 |
-
export function nullable<T>(
|
| 43 |
-
formatter: (num: T) => string
|
| 44 |
-
): (num: T | null) => string {
|
| 45 |
-
return (num: T | null): string => {
|
| 46 |
-
if (num === null) {
|
| 47 |
-
return '-'
|
| 48 |
-
}
|
| 49 |
-
return formatter(num)
|
| 50 |
-
}
|
| 51 |
-
}
|
| 52 |
-
|
| 53 |
-
function pad(num: number, size: number): string {
|
| 54 |
-
return ('000' + num).slice(size * -1)
|
| 55 |
-
}
|
| 56 |
-
|
| 57 |
-
export function durationFormatter(duration: number): string {
|
| 58 |
-
const hours = Math.floor(duration / 60 / 60)
|
| 59 |
-
const minutes = Math.floor(duration / 60) % 60
|
| 60 |
-
const seconds = Math.floor(duration - minutes * 60 - hours * 60 * 60)
|
| 61 |
-
if (hours > 0) {
|
| 62 |
-
return `${hours}h ${minutes}m ${seconds}s`
|
| 63 |
-
} else if (minutes > 0) {
|
| 64 |
-
return `${minutes}m ${pad(seconds, 2)}s`
|
| 65 |
-
} else {
|
| 66 |
-
return `${seconds}s`
|
| 67 |
-
}
|
| 68 |
-
}
|
| 69 |
-
|
| 70 |
-
export function roundedNumberFormatter(number: number): string {
|
| 71 |
-
if (Math.abs(number) > 0 && Math.abs(number) < 0.1) {
|
| 72 |
-
return number.toFixed(2)
|
| 73 |
-
} else {
|
| 74 |
-
return number.toFixed(1).replace(/\.0$/, '')
|
| 75 |
-
}
|
| 76 |
-
}
|
| 77 |
-
|
| 78 |
-
export function percentageFormatter(number: number | null): string {
|
| 79 |
-
if (typeof number === 'number') {
|
| 80 |
-
return roundedNumberFormatter(number) + '%'
|
| 81 |
-
} else {
|
| 82 |
-
return '-'
|
| 83 |
-
}
|
| 84 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/dashboard/util/realtime-update-timer.js
DELETED
|
@@ -1,8 +0,0 @@
|
|
| 1 |
-
export const REALTIME_UPDATE_TIME_MS = 30_000
|
| 2 |
-
const tickEvent = new Event('tick')
|
| 3 |
-
|
| 4 |
-
export function start() {
|
| 5 |
-
setInterval(() => {
|
| 6 |
-
document.dispatchEvent(tickEvent)
|
| 7 |
-
}, REALTIME_UPDATE_TIME_MS)
|
| 8 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/dashboard/util/seconds-since-last-load.js
DELETED
|
@@ -1,15 +0,0 @@
|
|
| 1 |
-
import { useState, useEffect } from 'react'
|
| 2 |
-
|
| 3 |
-
// A function component that renders an integer value of how many
|
| 4 |
-
// seconds have passed from the last data load on the dashboard.
|
| 5 |
-
// Updates the value every second when the component is visible.
|
| 6 |
-
export function SecondsSinceLastLoad({ lastLoadTimestamp }) {
|
| 7 |
-
const [timeNow, setTimeNow] = useState(new Date())
|
| 8 |
-
|
| 9 |
-
useEffect(() => {
|
| 10 |
-
const interval = setInterval(() => setTimeNow(new Date()), 1000)
|
| 11 |
-
return () => clearInterval(interval)
|
| 12 |
-
}, [])
|
| 13 |
-
|
| 14 |
-
return Math.round(Math.abs(lastLoadTimestamp - timeNow) / 1000)
|
| 15 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/dashboard/util/storage.js
DELETED
|
@@ -1,37 +0,0 @@
|
|
| 1 |
-
// This module checks if localStorage is available and uses it for persistent frontend storage
|
| 2 |
-
// if possible. Localstorage can be blocked by browsers when people block third-party cookies and
|
| 3 |
-
// the dashboard is running in embedded mode. In those cases, store stuff in a regular object instead.
|
| 4 |
-
|
| 5 |
-
const memStore = {}
|
| 6 |
-
|
| 7 |
-
// https://stackoverflow.com/a/16427747
|
| 8 |
-
function testLocalStorageAvailability() {
|
| 9 |
-
try {
|
| 10 |
-
const testItem = 'test'
|
| 11 |
-
localStorage.setItem(testItem, testItem)
|
| 12 |
-
localStorage.removeItem(testItem)
|
| 13 |
-
return true
|
| 14 |
-
} catch (_e) {
|
| 15 |
-
return false
|
| 16 |
-
}
|
| 17 |
-
}
|
| 18 |
-
|
| 19 |
-
const isLocalStorageAvailable = testLocalStorageAvailability()
|
| 20 |
-
|
| 21 |
-
export function setItem(key, value) {
|
| 22 |
-
if (isLocalStorageAvailable) {
|
| 23 |
-
window.localStorage.setItem(key, value)
|
| 24 |
-
} else {
|
| 25 |
-
memStore[key] = value
|
| 26 |
-
}
|
| 27 |
-
}
|
| 28 |
-
|
| 29 |
-
export function getItem(key) {
|
| 30 |
-
if (isLocalStorageAvailable) {
|
| 31 |
-
return window.localStorage.getItem(key)
|
| 32 |
-
} else {
|
| 33 |
-
return memStore[key]
|
| 34 |
-
}
|
| 35 |
-
}
|
| 36 |
-
|
| 37 |
-
export const getDomainScopedStorageKey = (key, domain) => `${key}__${domain}`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/dashboard/util/tooltip.tsx
DELETED
|
@@ -1,109 +0,0 @@
|
|
| 1 |
-
import React, { CSSProperties, ReactNode, RefObject, useState } from 'react'
|
| 2 |
-
import { usePopper } from 'react-popper'
|
| 3 |
-
import classNames from 'classnames'
|
| 4 |
-
import { createPortal } from 'react-dom'
|
| 5 |
-
|
| 6 |
-
export function Tooltip({
|
| 7 |
-
children,
|
| 8 |
-
info,
|
| 9 |
-
className,
|
| 10 |
-
onClick,
|
| 11 |
-
boundary,
|
| 12 |
-
containerRef
|
| 13 |
-
}: {
|
| 14 |
-
info: ReactNode
|
| 15 |
-
children: ReactNode
|
| 16 |
-
className?: string
|
| 17 |
-
onClick?: () => void
|
| 18 |
-
/** if provided, the tooltip is confined to the particular element */
|
| 19 |
-
boundary?: HTMLElement | null
|
| 20 |
-
/** if defined, the tooltip is rendered in a portal to this element */
|
| 21 |
-
containerRef?: RefObject<HTMLElement>
|
| 22 |
-
}) {
|
| 23 |
-
const [visible, setVisible] = useState(false)
|
| 24 |
-
const [referenceElement, setReferenceElement] =
|
| 25 |
-
useState<HTMLDivElement | null>(null)
|
| 26 |
-
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(
|
| 27 |
-
null
|
| 28 |
-
)
|
| 29 |
-
|
| 30 |
-
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
| 31 |
-
placement: 'top',
|
| 32 |
-
modifiers: [
|
| 33 |
-
{
|
| 34 |
-
name: 'offset',
|
| 35 |
-
options: {
|
| 36 |
-
offset: [0, 6]
|
| 37 |
-
}
|
| 38 |
-
},
|
| 39 |
-
...(boundary
|
| 40 |
-
? [
|
| 41 |
-
{
|
| 42 |
-
name: 'preventOverflow',
|
| 43 |
-
options: {
|
| 44 |
-
boundary: boundary
|
| 45 |
-
}
|
| 46 |
-
}
|
| 47 |
-
]
|
| 48 |
-
: [])
|
| 49 |
-
]
|
| 50 |
-
})
|
| 51 |
-
|
| 52 |
-
return (
|
| 53 |
-
<div className={classNames('relative', className)}>
|
| 54 |
-
<div
|
| 55 |
-
ref={setReferenceElement}
|
| 56 |
-
onMouseEnter={() => setVisible(true)}
|
| 57 |
-
onMouseLeave={() => setVisible(false)}
|
| 58 |
-
onClick={onClick}
|
| 59 |
-
>
|
| 60 |
-
{children}
|
| 61 |
-
</div>
|
| 62 |
-
{info && visible && (
|
| 63 |
-
<TooltipMessage
|
| 64 |
-
containerRef={containerRef}
|
| 65 |
-
popperStyle={styles.popper}
|
| 66 |
-
popperAttributes={attributes.popper}
|
| 67 |
-
setPopperElement={setPopperElement}
|
| 68 |
-
>
|
| 69 |
-
{info}
|
| 70 |
-
</TooltipMessage>
|
| 71 |
-
)}
|
| 72 |
-
</div>
|
| 73 |
-
)
|
| 74 |
-
}
|
| 75 |
-
|
| 76 |
-
function TooltipMessage({
|
| 77 |
-
containerRef,
|
| 78 |
-
popperStyle,
|
| 79 |
-
popperAttributes,
|
| 80 |
-
setPopperElement,
|
| 81 |
-
children
|
| 82 |
-
}: {
|
| 83 |
-
containerRef?: RefObject<HTMLElement>
|
| 84 |
-
popperStyle: CSSProperties
|
| 85 |
-
popperAttributes?: Record<string, string>
|
| 86 |
-
setPopperElement: (element: HTMLDivElement) => void
|
| 87 |
-
children: ReactNode
|
| 88 |
-
}) {
|
| 89 |
-
const messageElement = (
|
| 90 |
-
<div
|
| 91 |
-
ref={setPopperElement}
|
| 92 |
-
style={popperStyle}
|
| 93 |
-
{...popperAttributes}
|
| 94 |
-
className="pointer-events-none z-[99] [body:has(.modal.is-open)_&]:z-[1000] px-2 py-1 rounded-sm text-sm text-gray-100 font-medium bg-gray-800 dark:bg-gray-700"
|
| 95 |
-
role="tooltip"
|
| 96 |
-
>
|
| 97 |
-
{children}
|
| 98 |
-
</div>
|
| 99 |
-
)
|
| 100 |
-
if (containerRef) {
|
| 101 |
-
if (containerRef.current) {
|
| 102 |
-
return createPortal(messageElement, containerRef.current)
|
| 103 |
-
} else {
|
| 104 |
-
return null
|
| 105 |
-
}
|
| 106 |
-
}
|
| 107 |
-
|
| 108 |
-
return messageElement
|
| 109 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/dashboard/util/url-search-params-v1.ts
DELETED
|
@@ -1,121 +0,0 @@
|
|
| 1 |
-
import { DashboardState, Filter } from '../dashboard-state'
|
| 2 |
-
import { EVENT_PROPS_PREFIX, FILTER_OPERATIONS } from './filters'
|
| 3 |
-
|
| 4 |
-
// As of March 2023, Safari does not support negative lookbehind regexes. In case it throws an error, falls back to plain | matching. This means
|
| 5 |
-
// escaping pipe characters in filters does not currently work in Safari
|
| 6 |
-
let NON_ESCAPED_PIPE_REGEX: string | RegExp
|
| 7 |
-
try {
|
| 8 |
-
NON_ESCAPED_PIPE_REGEX = new RegExp('(?<!\\\\)\\|', 'g')
|
| 9 |
-
} catch (_e) {
|
| 10 |
-
NON_ESCAPED_PIPE_REGEX = '|'
|
| 11 |
-
}
|
| 12 |
-
|
| 13 |
-
const ESCAPED_PIPE = '\\|'
|
| 14 |
-
const OPERATION_PREFIX = {
|
| 15 |
-
[FILTER_OPERATIONS.isNot]: '!',
|
| 16 |
-
[FILTER_OPERATIONS.contains]: '~',
|
| 17 |
-
[FILTER_OPERATIONS.is]: ''
|
| 18 |
-
}
|
| 19 |
-
|
| 20 |
-
const LEGACY_URL_PARAMETERS = {
|
| 21 |
-
goal: null,
|
| 22 |
-
source: null,
|
| 23 |
-
utm_medium: null,
|
| 24 |
-
utm_source: null,
|
| 25 |
-
utm_campaign: null,
|
| 26 |
-
utm_content: null,
|
| 27 |
-
utm_term: null,
|
| 28 |
-
referrer: null,
|
| 29 |
-
screen: null,
|
| 30 |
-
browser: null,
|
| 31 |
-
browser_version: null,
|
| 32 |
-
os: null,
|
| 33 |
-
os_version: null,
|
| 34 |
-
country: 'country_labels',
|
| 35 |
-
region: 'region_labels',
|
| 36 |
-
city: 'city_labels',
|
| 37 |
-
page: null,
|
| 38 |
-
hostname: null,
|
| 39 |
-
entry_page: null,
|
| 40 |
-
exit_page: null
|
| 41 |
-
}
|
| 42 |
-
|
| 43 |
-
function isV1(searchParams: URLSearchParams): boolean {
|
| 44 |
-
for (const k of searchParams.keys()) {
|
| 45 |
-
if (k === 'props' || LEGACY_URL_PARAMETERS.hasOwnProperty(k)) {
|
| 46 |
-
return true
|
| 47 |
-
}
|
| 48 |
-
}
|
| 49 |
-
return false
|
| 50 |
-
}
|
| 51 |
-
|
| 52 |
-
function parseSearch(searchString: string): Record<string, unknown> {
|
| 53 |
-
const searchParams = new URLSearchParams(searchString)
|
| 54 |
-
const updatedSearchRecordEntries = []
|
| 55 |
-
const filters: Filter[] = []
|
| 56 |
-
let labels: DashboardState['labels'] = {}
|
| 57 |
-
|
| 58 |
-
for (const [key, value] of searchParams.entries()) {
|
| 59 |
-
if (LEGACY_URL_PARAMETERS.hasOwnProperty(key)) {
|
| 60 |
-
if (typeof value !== 'string') {
|
| 61 |
-
continue
|
| 62 |
-
}
|
| 63 |
-
const filter = parseLegacyFilter(key, value) as Filter
|
| 64 |
-
filters.push(filter)
|
| 65 |
-
const labelsKey: string | null | undefined =
|
| 66 |
-
LEGACY_URL_PARAMETERS[key as keyof typeof LEGACY_URL_PARAMETERS]
|
| 67 |
-
const labelsParamValue = labelsKey ? searchParams.get(labelsKey) : null
|
| 68 |
-
if (labelsParamValue) {
|
| 69 |
-
const clauses = filter[2]
|
| 70 |
-
const labelsValues = labelsParamValue
|
| 71 |
-
.split('|')
|
| 72 |
-
.filter((label) => !!label)
|
| 73 |
-
const newLabels = Object.fromEntries(
|
| 74 |
-
clauses.map((clause, index) => [clause, labelsValues[index]])
|
| 75 |
-
)
|
| 76 |
-
|
| 77 |
-
labels = Object.assign(labels, newLabels)
|
| 78 |
-
}
|
| 79 |
-
} else {
|
| 80 |
-
updatedSearchRecordEntries.push([key, value])
|
| 81 |
-
}
|
| 82 |
-
}
|
| 83 |
-
|
| 84 |
-
const propsParamValue = searchParams.get('props')
|
| 85 |
-
if (typeof propsParamValue === 'string') {
|
| 86 |
-
filters.push(...(parseLegacyPropsFilter(propsParamValue) as Filter[]))
|
| 87 |
-
}
|
| 88 |
-
updatedSearchRecordEntries.push(['filters', filters], ['labels', labels])
|
| 89 |
-
return Object.fromEntries(updatedSearchRecordEntries)
|
| 90 |
-
}
|
| 91 |
-
|
| 92 |
-
function parseLegacyFilter(filterKey: string, rawValue: string): null | Filter {
|
| 93 |
-
const operation =
|
| 94 |
-
Object.keys(OPERATION_PREFIX).find(
|
| 95 |
-
(operation) => OPERATION_PREFIX[operation] === rawValue[0]
|
| 96 |
-
) || FILTER_OPERATIONS.is
|
| 97 |
-
|
| 98 |
-
const value =
|
| 99 |
-
operation === FILTER_OPERATIONS.is ? rawValue : rawValue.substring(1)
|
| 100 |
-
|
| 101 |
-
const clauses = value
|
| 102 |
-
.split(NON_ESCAPED_PIPE_REGEX)
|
| 103 |
-
.filter((clause) => !!clause)
|
| 104 |
-
// @ts-expect-error API supposedly not present in compilation target, but works anyway
|
| 105 |
-
.map((val) => val.replaceAll(ESCAPED_PIPE, '|'))
|
| 106 |
-
|
| 107 |
-
return [operation, filterKey, clauses]
|
| 108 |
-
}
|
| 109 |
-
|
| 110 |
-
function parseLegacyPropsFilter(rawValue: string) {
|
| 111 |
-
return Object.entries(JSON.parse(rawValue)).flatMap(([key, propVal]) =>
|
| 112 |
-
typeof propVal === 'string'
|
| 113 |
-
? [parseLegacyFilter(`${EVENT_PROPS_PREFIX}${key}`, propVal)]
|
| 114 |
-
: []
|
| 115 |
-
)
|
| 116 |
-
}
|
| 117 |
-
|
| 118 |
-
export const v1 = {
|
| 119 |
-
isV1,
|
| 120 |
-
parseSearch
|
| 121 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/dashboard/util/url-search-params-v2.test.ts
DELETED
|
@@ -1,204 +0,0 @@
|
|
| 1 |
-
import JsonURL from '@jsonurl/jsonurl'
|
| 2 |
-
import { v2 } from './url-search-params-v2'
|
| 3 |
-
|
| 4 |
-
const {
|
| 5 |
-
stringifySearchEntry,
|
| 6 |
-
stringifySearch,
|
| 7 |
-
parseSearch,
|
| 8 |
-
parseSearchFragment
|
| 9 |
-
} = v2
|
| 10 |
-
|
| 11 |
-
describe('using json URL parsing with URLSearchParams intermediate', () => {
|
| 12 |
-
beforeEach(() => {
|
| 13 |
-
// Silence logs in tests
|
| 14 |
-
jest.spyOn(console, 'error').mockImplementation(jest.fn())
|
| 15 |
-
})
|
| 16 |
-
it.each([['#'], ['&'], ['=']])('throws on special symbol %p', (s) => {
|
| 17 |
-
const searchString = `?param=${encodeURIComponent(s)}`
|
| 18 |
-
expect(() =>
|
| 19 |
-
JsonURL.parse(new URLSearchParams(searchString).get('param')!)
|
| 20 |
-
).toThrow()
|
| 21 |
-
})
|
| 22 |
-
})
|
| 23 |
-
|
| 24 |
-
describe(`${stringifySearchEntry.name}`, () => {
|
| 25 |
-
it.each<[[string, unknown], [string, string | undefined]]>([
|
| 26 |
-
[
|
| 27 |
-
['any-key', {}],
|
| 28 |
-
['any-key', undefined]
|
| 29 |
-
],
|
| 30 |
-
[
|
| 31 |
-
['any-key', []],
|
| 32 |
-
['any-key', undefined]
|
| 33 |
-
],
|
| 34 |
-
[
|
| 35 |
-
['any-key', null],
|
| 36 |
-
['any-key', undefined]
|
| 37 |
-
],
|
| 38 |
-
[
|
| 39 |
-
['period', 'realtime'],
|
| 40 |
-
['period', 'realtime']
|
| 41 |
-
],
|
| 42 |
-
[
|
| 43 |
-
['page', 10],
|
| 44 |
-
['page', '10']
|
| 45 |
-
],
|
| 46 |
-
[
|
| 47 |
-
['labels', { US: 'United States', 3448439: 'São Paulo' }],
|
| 48 |
-
['labels', '(3448439:S%C3%A3o+Paulo,US:United+States)']
|
| 49 |
-
],
|
| 50 |
-
[
|
| 51 |
-
['filters', [['is', 'props:foo:bar', ['one', 'two']]]],
|
| 52 |
-
['filters', "((is,'props:foo:bar',(one,two)))"]
|
| 53 |
-
]
|
| 54 |
-
])('when input is %p, returns %p', (input, expected) => {
|
| 55 |
-
const result = stringifySearchEntry(input)
|
| 56 |
-
expect(result).toEqual(expected)
|
| 57 |
-
})
|
| 58 |
-
})
|
| 59 |
-
|
| 60 |
-
describe(`${parseSearchFragment.name}`, () => {
|
| 61 |
-
it.each([
|
| 62 |
-
['', null],
|
| 63 |
-
['("foo":)', null],
|
| 64 |
-
['(invalid', null],
|
| 65 |
-
['null', null],
|
| 66 |
-
|
| 67 |
-
['123', 123],
|
| 68 |
-
['string', 'string'],
|
| 69 |
-
['item=#', 'item=#'],
|
| 70 |
-
['item%3D%23', 'item=#'],
|
| 71 |
-
|
| 72 |
-
['(any:(number:1))', { any: { number: 1 } }],
|
| 73 |
-
['(any:(number:1.001))', { any: { number: 1.001 } }],
|
| 74 |
-
["(any:(string:'1.001'))", { any: { string: '1.001' } }],
|
| 75 |
-
|
| 76 |
-
// Non-JSON strings that should return as string
|
| 77 |
-
['undefined', 'undefined'],
|
| 78 |
-
['not_json', 'not_json'],
|
| 79 |
-
['plainstring', 'plainstring'],
|
| 80 |
-
['a|b', 'a|b'],
|
| 81 |
-
['foo bar#', 'foo bar#']
|
| 82 |
-
])(
|
| 83 |
-
'when searchStringFragment is %p, returns %p',
|
| 84 |
-
(searchStringFragment, expected) => {
|
| 85 |
-
const result = parseSearchFragment(searchStringFragment)
|
| 86 |
-
expect(result).toEqual(expected)
|
| 87 |
-
}
|
| 88 |
-
)
|
| 89 |
-
})
|
| 90 |
-
|
| 91 |
-
describe(`${parseSearch.name}`, () => {
|
| 92 |
-
it.each([
|
| 93 |
-
['', {}],
|
| 94 |
-
['?', {}],
|
| 95 |
-
[
|
| 96 |
-
'?arr=(1,2)',
|
| 97 |
-
{
|
| 98 |
-
arr: [1, 2]
|
| 99 |
-
}
|
| 100 |
-
],
|
| 101 |
-
['?key1=value1&key2=', { key1: 'value1', key2: null }],
|
| 102 |
-
['?key1=value1&key2=value2', { key1: 'value1', key2: 'value2' }],
|
| 103 |
-
[
|
| 104 |
-
'?key1=(foo:bar)&filters=((is,screen,(Mobile,Desktop)))',
|
| 105 |
-
{
|
| 106 |
-
key1: { foo: 'bar' },
|
| 107 |
-
filters: [['is', 'screen', ['Mobile', 'Desktop']]]
|
| 108 |
-
}
|
| 109 |
-
],
|
| 110 |
-
[
|
| 111 |
-
'?filters=((is,country,(US)))&labels=(US:United%2BStates)',
|
| 112 |
-
{
|
| 113 |
-
filters: [['is', 'country', ['US']]],
|
| 114 |
-
labels: {
|
| 115 |
-
US: 'United States'
|
| 116 |
-
}
|
| 117 |
-
}
|
| 118 |
-
]
|
| 119 |
-
])('when searchString is %p, returns %p', (searchString, expected) => {
|
| 120 |
-
const result = parseSearch(searchString)
|
| 121 |
-
expect(result).toEqual(expected)
|
| 122 |
-
})
|
| 123 |
-
})
|
| 124 |
-
|
| 125 |
-
describe(`${stringifySearch.name} and ${parseSearch.name} are inverses of each other`, () => {
|
| 126 |
-
it.each([
|
| 127 |
-
["?filters=((is,'props:browser_language',(en-US)))"],
|
| 128 |
-
[
|
| 129 |
-
'?filters=((contains,utm_term,(_)),(is,screen,(Desktop,Tablet)),(is,page,(/open-source-website-analytics)))&period=custom&keybindHint=A&comparison=previous_period&match_day_of_week=false&from=2024-08-08&to=2024-08-10'
|
| 130 |
-
],
|
| 131 |
-
[
|
| 132 |
-
"?filters=((is,'props:browser_language',(en-US)),(is,country,(US)),(is,os,(iOS)),(is,os_version,('17.3')),(is,page,('/:dashboard/settings/general')))&labels=(US:United%2BStates)"
|
| 133 |
-
],
|
| 134 |
-
[
|
| 135 |
-
'?filters=((is,utm_source,(hackernewsletter)),(is,utm_campaign,(profile)))&period=day&keybindHint=D'
|
| 136 |
-
]
|
| 137 |
-
])(
|
| 138 |
-
`input %p is returned for ${parseSearch.name}(${parseSearch.name}(input))`,
|
| 139 |
-
(searchString) => {
|
| 140 |
-
const searchRecord = parseSearch(searchString)
|
| 141 |
-
const reStringifiedSearch = stringifySearch(searchRecord)
|
| 142 |
-
expect(reStringifiedSearch).toEqual(searchString)
|
| 143 |
-
}
|
| 144 |
-
)
|
| 145 |
-
|
| 146 |
-
it.each([
|
| 147 |
-
// Corresponding test cases for objects parsed from realistic URLs
|
| 148 |
-
|
| 149 |
-
[
|
| 150 |
-
{
|
| 151 |
-
filters: [['is', 'props:browser_language', ['en-US']]]
|
| 152 |
-
},
|
| 153 |
-
"?filters=((is,'props:browser_language',(en-US)))"
|
| 154 |
-
],
|
| 155 |
-
[
|
| 156 |
-
{
|
| 157 |
-
filters: [
|
| 158 |
-
['contains', 'utm_term', ['_']],
|
| 159 |
-
['is', 'screen', ['Desktop', 'Tablet']],
|
| 160 |
-
['is', 'page', ['/open-source/analytics/encoded-hash%23']]
|
| 161 |
-
],
|
| 162 |
-
period: 'custom',
|
| 163 |
-
keybindHint: 'A',
|
| 164 |
-
comparison: 'previous_period',
|
| 165 |
-
match_day_of_week: false,
|
| 166 |
-
from: '2024-08-08',
|
| 167 |
-
to: '2024-08-10'
|
| 168 |
-
},
|
| 169 |
-
'?filters=((contains,utm_term,(_)),(is,screen,(Desktop,Tablet)),(is,page,(%252Fopen-source%252Fanalytics%252Fencoded-hash%252523)))&period=custom&keybindHint=A&comparison=previous_period&match_day_of_week=false&from=2024-08-08&to=2024-08-10'
|
| 170 |
-
],
|
| 171 |
-
[
|
| 172 |
-
{
|
| 173 |
-
filters: [
|
| 174 |
-
['is', 'props:browser_language', ['en-US']],
|
| 175 |
-
['is', 'country', ['US']],
|
| 176 |
-
['is', 'os', ['iOS']],
|
| 177 |
-
['is', 'os_version', ['17.3']],
|
| 178 |
-
['is', 'page', ['/:dashboard/settings/general']]
|
| 179 |
-
],
|
| 180 |
-
labels: { US: 'United States' }
|
| 181 |
-
},
|
| 182 |
-
"?filters=((is,'props:browser_language',(en-US)),(is,country,(US)),(is,os,(iOS)),(is,os_version,('17.3')),(is,page,('/:dashboard/settings/general')))&labels=(US:United%2BStates)"
|
| 183 |
-
],
|
| 184 |
-
[
|
| 185 |
-
{
|
| 186 |
-
filters: [
|
| 187 |
-
['is', 'utm_source', ['hackernewsletter']],
|
| 188 |
-
['is', 'utm_campaign', ['profile']]
|
| 189 |
-
],
|
| 190 |
-
period: 'day',
|
| 191 |
-
keybindHint: 'D'
|
| 192 |
-
},
|
| 193 |
-
'?filters=((is,utm_source,(hackernewsletter)),(is,utm_campaign,(profile)))&period=day&keybindHint=D'
|
| 194 |
-
]
|
| 195 |
-
])(
|
| 196 |
-
`for input %p, ${stringifySearch.name}(input) returns %p and ${parseSearch.name}(${stringifySearch.name}(input)) returns the original input`,
|
| 197 |
-
(searchRecord, expected) => {
|
| 198 |
-
const searchString = stringifySearch(searchRecord)
|
| 199 |
-
const parsedSearchRecord = parseSearch(searchString)
|
| 200 |
-
expect(parsedSearchRecord).toEqual(searchRecord)
|
| 201 |
-
expect(searchString).toEqual(expected)
|
| 202 |
-
}
|
| 203 |
-
)
|
| 204 |
-
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/dashboard/util/url-search-params-v2.ts
DELETED
|
@@ -1,83 +0,0 @@
|
|
| 1 |
-
import JsonURL from '@jsonurl/jsonurl'
|
| 2 |
-
import {
|
| 3 |
-
encodeURIComponentPermissive,
|
| 4 |
-
isSearchEntryDefined
|
| 5 |
-
} from './url-search-params'
|
| 6 |
-
|
| 7 |
-
const permittedCharactersInURLParamKeyValue = ',:/'
|
| 8 |
-
|
| 9 |
-
function isV2(urlSearchParams: URLSearchParams): boolean {
|
| 10 |
-
return !!urlSearchParams.get('filters')
|
| 11 |
-
}
|
| 12 |
-
|
| 13 |
-
function encodeSearchParamEntry([k, v]: [string, string]): string {
|
| 14 |
-
return [k, v]
|
| 15 |
-
.map((s) =>
|
| 16 |
-
encodeURIComponentPermissive(s, permittedCharactersInURLParamKeyValue)
|
| 17 |
-
)
|
| 18 |
-
.join('=')
|
| 19 |
-
}
|
| 20 |
-
|
| 21 |
-
function stringifySearch(searchRecord: Record<string, unknown>): '' | string {
|
| 22 |
-
const definedSearchEntries = Object.entries(searchRecord || {})
|
| 23 |
-
.map(stringifySearchEntry)
|
| 24 |
-
.filter(isSearchEntryDefined)
|
| 25 |
-
|
| 26 |
-
const encodedSearchEntries = definedSearchEntries.map(encodeSearchParamEntry)
|
| 27 |
-
|
| 28 |
-
return encodedSearchEntries.length ? `?${encodedSearchEntries.join('&')}` : ''
|
| 29 |
-
}
|
| 30 |
-
|
| 31 |
-
function stringifySearchEntry([key, value]: [string, unknown]): [
|
| 32 |
-
string,
|
| 33 |
-
undefined | string
|
| 34 |
-
] {
|
| 35 |
-
const isEmptyObjectOrArray =
|
| 36 |
-
typeof value === 'object' &&
|
| 37 |
-
value !== null &&
|
| 38 |
-
Object.entries(value).length === 0
|
| 39 |
-
if (value === undefined || value === null || isEmptyObjectOrArray) {
|
| 40 |
-
return [key, undefined]
|
| 41 |
-
}
|
| 42 |
-
|
| 43 |
-
return [key, JsonURL.stringify(value)]
|
| 44 |
-
}
|
| 45 |
-
|
| 46 |
-
function parseSearchFragment(searchStringFragment: string): null | unknown {
|
| 47 |
-
if (searchStringFragment === '') {
|
| 48 |
-
return null
|
| 49 |
-
}
|
| 50 |
-
// tricky: the search string fragment is already decoded due to URLSearchParams intermediate (see tests),
|
| 51 |
-
// and these symbols are unparseable
|
| 52 |
-
const fragmentWithReEncodedSymbols = searchStringFragment
|
| 53 |
-
/* @ts-expect-error API supposedly not present in compilation target */
|
| 54 |
-
.replaceAll('=', encodeURIComponent('='))
|
| 55 |
-
.replaceAll('#', encodeURIComponent('#'))
|
| 56 |
-
.replaceAll('|', encodeURIComponent('|'))
|
| 57 |
-
.replaceAll(' ', encodeURIComponent(' '))
|
| 58 |
-
|
| 59 |
-
try {
|
| 60 |
-
return JsonURL.parse(fragmentWithReEncodedSymbols)
|
| 61 |
-
} catch (error) {
|
| 62 |
-
console.error(
|
| 63 |
-
`Failed to parse URL fragment ${fragmentWithReEncodedSymbols}`,
|
| 64 |
-
error
|
| 65 |
-
)
|
| 66 |
-
return null
|
| 67 |
-
}
|
| 68 |
-
}
|
| 69 |
-
|
| 70 |
-
function parseSearch(searchString: string): Record<string, unknown> {
|
| 71 |
-
const urlSearchParams = new URLSearchParams(searchString)
|
| 72 |
-
const searchRecord: Record<string, unknown> = {}
|
| 73 |
-
urlSearchParams.forEach((v, k) => (searchRecord[k] = parseSearchFragment(v)))
|
| 74 |
-
return searchRecord
|
| 75 |
-
}
|
| 76 |
-
|
| 77 |
-
export const v2 = {
|
| 78 |
-
isV2,
|
| 79 |
-
parseSearch,
|
| 80 |
-
parseSearchFragment,
|
| 81 |
-
stringifySearch,
|
| 82 |
-
stringifySearchEntry
|
| 83 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/dashboard/util/url-search-params.test.ts
DELETED
|
@@ -1,331 +0,0 @@
|
|
| 1 |
-
import { Filter } from '../dashboard-state'
|
| 2 |
-
import {
|
| 3 |
-
encodeURIComponentPermissive,
|
| 4 |
-
getSearchWithEnforcedSegment,
|
| 5 |
-
isSearchEntryDefined,
|
| 6 |
-
maybeGetLatestReadableSearch,
|
| 7 |
-
maybeReloadForApiVersion,
|
| 8 |
-
parseFilter,
|
| 9 |
-
parseLabelsEntry,
|
| 10 |
-
parseSearch,
|
| 11 |
-
parseSimpleSearchEntry,
|
| 12 |
-
serializeFilter,
|
| 13 |
-
serializeLabelsEntry,
|
| 14 |
-
serializeSimpleSearchEntry,
|
| 15 |
-
stringifySearch
|
| 16 |
-
} from './url-search-params'
|
| 17 |
-
|
| 18 |
-
describe(`${encodeURIComponentPermissive.name}`, () => {
|
| 19 |
-
it.each<[string, string]>([
|
| 20 |
-
['10.00.00/1', '10.00.00/1'],
|
| 21 |
-
['#hashtag', '%23hashtag'],
|
| 22 |
-
['100$ coupon', '100%24%20coupon'],
|
| 23 |
-
['Visit /any/page', 'Visit%20/any/page'],
|
| 24 |
-
['A,B,C', 'A,B,C'],
|
| 25 |
-
['props:colon/forward/slash/signs', 'props:colon/forward/slash/signs'],
|
| 26 |
-
['https://example.com/path', 'https://example.com/path']
|
| 27 |
-
])(
|
| 28 |
-
'when input is %p, returns %s and decodes back to input',
|
| 29 |
-
(input, expected) => {
|
| 30 |
-
const result = encodeURIComponentPermissive(input, ',:/')
|
| 31 |
-
expect(result).toBe(expected)
|
| 32 |
-
expect(decodeURIComponent(result)).toBe(input)
|
| 33 |
-
}
|
| 34 |
-
)
|
| 35 |
-
})
|
| 36 |
-
|
| 37 |
-
describe(`${isSearchEntryDefined.name}`, () => {
|
| 38 |
-
it.each<[[string, string | undefined], boolean]>([
|
| 39 |
-
[['key', undefined], false],
|
| 40 |
-
[['key', 'value'], true],
|
| 41 |
-
[['key', ''], true],
|
| 42 |
-
[['anotherKey', 'undefined'], true]
|
| 43 |
-
])('when entry is %p, returns %s', (entry, expected) => {
|
| 44 |
-
const result = isSearchEntryDefined(entry)
|
| 45 |
-
expect(result).toBe(expected)
|
| 46 |
-
})
|
| 47 |
-
})
|
| 48 |
-
|
| 49 |
-
describe(`${serializeLabelsEntry.name} and ${parseLabelsEntry.name}(...) are opposite of each other`, () => {
|
| 50 |
-
test.each<[[string, string], string]>([
|
| 51 |
-
[['US', 'United States'], 'US,United%20States'],
|
| 52 |
-
[['FR-IDF', 'Île-de-France'], 'FR-IDF,%C3%8Ele-de-France'],
|
| 53 |
-
[['1254661', 'Thāne'], '1254661,Th%C4%81ne']
|
| 54 |
-
])(
|
| 55 |
-
'entry %p serializes to %p, parses back to original',
|
| 56 |
-
(entry, expected) => {
|
| 57 |
-
const serialized = serializeLabelsEntry(entry)
|
| 58 |
-
expect(serialized).toEqual(expected)
|
| 59 |
-
expect(parseLabelsEntry(serialized)).toEqual(entry)
|
| 60 |
-
}
|
| 61 |
-
)
|
| 62 |
-
})
|
| 63 |
-
|
| 64 |
-
describe(`${serializeFilter.name} and ${parseFilter.name}(...) are opposite of each other`, () => {
|
| 65 |
-
test.each<[Filter, string]>([
|
| 66 |
-
[
|
| 67 |
-
['contains', 'entry_page', ['/forecast/:city', ',"\'']],
|
| 68 |
-
"contains,entry_page,/forecast/:city,%2C%22'"
|
| 69 |
-
],
|
| 70 |
-
[
|
| 71 |
-
['is', 'props:complex/prop-with-comma-etc,$#%', ['(none)']],
|
| 72 |
-
'is,props:complex/prop-with-comma-etc%2C%24%23%25,(none)'
|
| 73 |
-
]
|
| 74 |
-
])(
|
| 75 |
-
'filter %p serializes to %p, parses back to original',
|
| 76 |
-
(filter, expected) => {
|
| 77 |
-
const serialized = serializeFilter(filter)
|
| 78 |
-
expect(serialized).toEqual(expected)
|
| 79 |
-
expect(parseFilter(serialized)).toEqual(filter)
|
| 80 |
-
}
|
| 81 |
-
)
|
| 82 |
-
})
|
| 83 |
-
|
| 84 |
-
describe(`${serializeSimpleSearchEntry.name} and ${parseSimpleSearchEntry.name}`, () => {
|
| 85 |
-
test.each<
|
| 86 |
-
[
|
| 87 |
-
[string, unknown],
|
| 88 |
-
[string, string | boolean | undefined],
|
| 89 |
-
[string, string | boolean] | null
|
| 90 |
-
]
|
| 91 |
-
>([
|
| 92 |
-
[['undefined-param', undefined], ['undefined-param', undefined], null],
|
| 93 |
-
[['null-param', null], ['null-param', undefined], null],
|
| 94 |
-
[['array-param', ['any-value']], ['array-param', undefined], null],
|
| 95 |
-
[['obj-param', { 'any-key': 'any-value' }], ['obj-param', undefined], null],
|
| 96 |
-
[
|
| 97 |
-
['date-obj', new Date('2024-01-01T10:00:00.000Z')],
|
| 98 |
-
['date-obj', undefined],
|
| 99 |
-
null
|
| 100 |
-
],
|
| 101 |
-
[
|
| 102 |
-
['page-nr', 5],
|
| 103 |
-
['page-nr', '5'],
|
| 104 |
-
['page-nr', '5']
|
| 105 |
-
],
|
| 106 |
-
[
|
| 107 |
-
['string-param-resembling-boolean', 'true'],
|
| 108 |
-
['string-param-resembling-boolean', 'true'],
|
| 109 |
-
['string-param-resembling-boolean', true]
|
| 110 |
-
],
|
| 111 |
-
[
|
| 112 |
-
['match-day-of-week', false],
|
| 113 |
-
['match-day-of-week', 'false'],
|
| 114 |
-
['match-day-of-week', false]
|
| 115 |
-
],
|
| 116 |
-
[
|
| 117 |
-
['with-imported-data', true],
|
| 118 |
-
['with-imported-data', 'true'],
|
| 119 |
-
['with-imported-data', true]
|
| 120 |
-
],
|
| 121 |
-
[
|
| 122 |
-
['date-string', '2024-12-10'],
|
| 123 |
-
['date-string', '2024-12-10'],
|
| 124 |
-
['date-string', '2024-12-10']
|
| 125 |
-
]
|
| 126 |
-
])(
|
| 127 |
-
'entry %p serializes to %p, parses to %p',
|
| 128 |
-
(entry, expectedSerialized, expectedParsedEntry) => {
|
| 129 |
-
const serialized = serializeSimpleSearchEntry(entry)
|
| 130 |
-
expect(serialized).toEqual(expectedSerialized)
|
| 131 |
-
expect(
|
| 132 |
-
serialized[1] === undefined
|
| 133 |
-
? null
|
| 134 |
-
: parseSimpleSearchEntry(serialized[1])
|
| 135 |
-
).toEqual(expectedParsedEntry === null ? null : expectedParsedEntry[1])
|
| 136 |
-
}
|
| 137 |
-
)
|
| 138 |
-
})
|
| 139 |
-
|
| 140 |
-
describe(`${parseSearch.name}`, () => {
|
| 141 |
-
it.each([
|
| 142 |
-
['?', {}, ''],
|
| 143 |
-
['?=&&', {}, ''],
|
| 144 |
-
['?=undefined', {}, ''],
|
| 145 |
-
['?foo=', { foo: '' }, '?foo='],
|
| 146 |
-
['??foo', { '?foo': '' }, '?%3Ffoo='],
|
| 147 |
-
[
|
| 148 |
-
'?f=is,visit:page,/any/page&f',
|
| 149 |
-
{ filters: [['is', 'visit:page', ['/any/page']]] },
|
| 150 |
-
'?f=is,visit:page,/any/page'
|
| 151 |
-
]
|
| 152 |
-
])(
|
| 153 |
-
'for search string %s, returns search record %p, which in turn stringifies to %s',
|
| 154 |
-
(searchString, expectedSearchRecord, expectedRestringifiedResult) => {
|
| 155 |
-
expect(parseSearch(searchString)).toEqual(expectedSearchRecord)
|
| 156 |
-
expect(stringifySearch(expectedSearchRecord)).toEqual(
|
| 157 |
-
expectedRestringifiedResult
|
| 158 |
-
)
|
| 159 |
-
}
|
| 160 |
-
)
|
| 161 |
-
})
|
| 162 |
-
|
| 163 |
-
describe(`${stringifySearch.name}`, () => {
|
| 164 |
-
it.each([
|
| 165 |
-
[{}, ''],
|
| 166 |
-
[
|
| 167 |
-
{
|
| 168 |
-
filters: [['is', 'props:browser_language', ['en-US']]]
|
| 169 |
-
},
|
| 170 |
-
'?f=is,props:browser_language,en-US'
|
| 171 |
-
],
|
| 172 |
-
[
|
| 173 |
-
{
|
| 174 |
-
filters: [
|
| 175 |
-
['contains', 'utm_term', ['_']],
|
| 176 |
-
['is', 'screen', ['Desktop', 'Tablet']],
|
| 177 |
-
[
|
| 178 |
-
'is',
|
| 179 |
-
'page',
|
| 180 |
-
['/open-source/analytics/encoded-hash%23', '/unencoded-hash#']
|
| 181 |
-
]
|
| 182 |
-
],
|
| 183 |
-
period: 'custom',
|
| 184 |
-
keybindHint: 'A',
|
| 185 |
-
comparison: 'previous_period',
|
| 186 |
-
match_day_of_week: false,
|
| 187 |
-
from: '2024-08-08',
|
| 188 |
-
to: '2024-08-10'
|
| 189 |
-
},
|
| 190 |
-
'?f=contains,utm_term,_&f=is,screen,Desktop,Tablet&f=is,page,/open-source/analytics/encoded-hash%2523,/unencoded-hash%23&period=custom&keybindHint=A&comparison=previous_period&match_day_of_week=false&from=2024-08-08&to=2024-08-10'
|
| 191 |
-
],
|
| 192 |
-
[
|
| 193 |
-
{
|
| 194 |
-
filters: [
|
| 195 |
-
['is', 'props:browser_language', ['en-US']],
|
| 196 |
-
['is', 'country', ['US']],
|
| 197 |
-
['is', 'os', ['iOS']],
|
| 198 |
-
['is', 'os_version', ['17.3', '16.0']],
|
| 199 |
-
['is', 'page', ['/:dashboard/settings/general']]
|
| 200 |
-
],
|
| 201 |
-
labels: { US: 'United States' }
|
| 202 |
-
},
|
| 203 |
-
'?f=is,props:browser_language,en-US&f=is,country,US&f=is,os,iOS&f=is,os_version,17.3,16.0&f=is,page,/:dashboard/settings/general&l=US,United%20States'
|
| 204 |
-
]
|
| 205 |
-
])('works as expected', (searchRecord, expectedSearchString) => {
|
| 206 |
-
expect(stringifySearch(searchRecord)).toEqual(expectedSearchString)
|
| 207 |
-
expect(parseSearch(expectedSearchString)).toEqual(searchRecord)
|
| 208 |
-
})
|
| 209 |
-
})
|
| 210 |
-
|
| 211 |
-
describe(`${maybeGetLatestReadableSearch.name}`, () => {
|
| 212 |
-
it.each([
|
| 213 |
-
[''],
|
| 214 |
-
['?auth=_Y6YOjUl2beUJF_XzG1hk&theme=light&background=%23ee00ee'],
|
| 215 |
-
['?keybindHint=Escape&with_imported=true'],
|
| 216 |
-
['?f=is,page,/blog/:category/:article-name&date=2024-10-10&period=day'],
|
| 217 |
-
['?f=is,country,US&l=US,United%20States']
|
| 218 |
-
])('for modern search string %p returns null', (search) => {
|
| 219 |
-
expect(maybeGetLatestReadableSearch(search)).toBeNull()
|
| 220 |
-
})
|
| 221 |
-
|
| 222 |
-
it('returns updated search string for jsonurl style filters (v2), and running the updated value through the function again returns null (no redirect loop)', () => {
|
| 223 |
-
const search =
|
| 224 |
-
'?filters=((is,exit_page,(/plausible.io)),(is,source,(Brave)),(is,city,(993800)))&labels=(993800:Johannesburg)'
|
| 225 |
-
const expectedUpdatedSearch =
|
| 226 |
-
'?f=is,exit_page,/plausible.io&f=is,source,Brave&f=is,city,993800&l=993800,Johannesburg&r=v2'
|
| 227 |
-
expect(maybeGetLatestReadableSearch(search)).toEqual(expectedUpdatedSearch)
|
| 228 |
-
expect(maybeGetLatestReadableSearch(expectedUpdatedSearch)).toBeNull()
|
| 229 |
-
})
|
| 230 |
-
|
| 231 |
-
it.each([
|
| 232 |
-
['?page=/docs', '?f=is,page,/docs&r=v1'],
|
| 233 |
-
['?page=%C3%AA&embed=true', '?f=is,page,%C3%AA&embed=true&r=v1'],
|
| 234 |
-
[
|
| 235 |
-
'?page=/|/foo&goal=~Signup&source=!Facebook|Instagram',
|
| 236 |
-
'?f=is,page,/,/foo&f=contains,goal,Signup&f=is_not,source,Facebook,Instagram&r=v1'
|
| 237 |
-
]
|
| 238 |
-
])(
|
| 239 |
-
'returns updated search string v1 style filter %s, and running the updated value through the function again returns null (no redirect loop)',
|
| 240 |
-
(searchString, expectedSearchString) => {
|
| 241 |
-
expect(maybeGetLatestReadableSearch(searchString)).toEqual(
|
| 242 |
-
expectedSearchString
|
| 243 |
-
)
|
| 244 |
-
expect(maybeGetLatestReadableSearch(expectedSearchString)).toBeNull()
|
| 245 |
-
}
|
| 246 |
-
)
|
| 247 |
-
})
|
| 248 |
-
|
| 249 |
-
describe(`${getSearchWithEnforcedSegment.name}`, () => {
|
| 250 |
-
it('adds enforced segment appropriately, and running the updated value through the function again returns the same value', () => {
|
| 251 |
-
const segment = { id: 100, name: 'Eastern Europe' }
|
| 252 |
-
const search = '?auth=foo&embed=true'
|
| 253 |
-
const expectedUpdatedSearch =
|
| 254 |
-
'?f=is,segment,100&l=segment-100,Eastern%20Europe&auth=foo&embed=true'
|
| 255 |
-
expect(getSearchWithEnforcedSegment(search, segment)).toEqual(
|
| 256 |
-
expectedUpdatedSearch
|
| 257 |
-
)
|
| 258 |
-
expect(
|
| 259 |
-
getSearchWithEnforcedSegment(expectedUpdatedSearch, segment)
|
| 260 |
-
).toEqual(expectedUpdatedSearch)
|
| 261 |
-
})
|
| 262 |
-
})
|
| 263 |
-
|
| 264 |
-
describe(`${maybeReloadForApiVersion.name}`, () => {
|
| 265 |
-
const dashboardPathname = '/example.com'
|
| 266 |
-
|
| 267 |
-
beforeEach(() => {
|
| 268 |
-
jest.spyOn(console, 'warn').mockImplementation(() => {})
|
| 269 |
-
})
|
| 270 |
-
|
| 271 |
-
afterEach(() => {
|
| 272 |
-
jest.restoreAllMocks()
|
| 273 |
-
})
|
| 274 |
-
|
| 275 |
-
type MockWindowLocation = Location & { replace: jest.Mock }
|
| 276 |
-
|
| 277 |
-
function makeLocation(search: string): MockWindowLocation {
|
| 278 |
-
return {
|
| 279 |
-
pathname: dashboardPathname,
|
| 280 |
-
search,
|
| 281 |
-
hash: '',
|
| 282 |
-
replace: jest.fn()
|
| 283 |
-
} as unknown as MockWindowLocation
|
| 284 |
-
}
|
| 285 |
-
|
| 286 |
-
function makeHeaders(version: string | null): Headers {
|
| 287 |
-
const headers = new Headers()
|
| 288 |
-
if (version !== null) headers.set('x-api-version', version)
|
| 289 |
-
return headers
|
| 290 |
-
}
|
| 291 |
-
|
| 292 |
-
it('reloads when effective API version is greater than expected', () => {
|
| 293 |
-
const location = makeLocation('')
|
| 294 |
-
maybeReloadForApiVersion(location, makeHeaders('1'))
|
| 295 |
-
expect(location.replace).toHaveBeenCalledWith(
|
| 296 |
-
`${dashboardPathname}?api_version_reloaded=1`
|
| 297 |
-
)
|
| 298 |
-
})
|
| 299 |
-
|
| 300 |
-
it('does not reload when effective API version equals expected', () => {
|
| 301 |
-
const location = makeLocation('')
|
| 302 |
-
maybeReloadForApiVersion(location, makeHeaders('0'))
|
| 303 |
-
expect(location.replace).not.toHaveBeenCalled()
|
| 304 |
-
})
|
| 305 |
-
|
| 306 |
-
it('does not reload when effective API version is less than expected (FE loaded from newer node, cluster not fully updated)', () => {
|
| 307 |
-
const location = makeLocation('')
|
| 308 |
-
maybeReloadForApiVersion(location, makeHeaders('-1'))
|
| 309 |
-
expect(location.replace).not.toHaveBeenCalled()
|
| 310 |
-
})
|
| 311 |
-
|
| 312 |
-
it('does not reload when x-api-version header is absent', () => {
|
| 313 |
-
const location = makeLocation('')
|
| 314 |
-
maybeReloadForApiVersion(location, makeHeaders(null))
|
| 315 |
-
expect(location.replace).not.toHaveBeenCalled()
|
| 316 |
-
})
|
| 317 |
-
|
| 318 |
-
it('does not reload when already reloaded for this version', () => {
|
| 319 |
-
const location = makeLocation('?api_version_reloaded=1')
|
| 320 |
-
maybeReloadForApiVersion(location, makeHeaders('1'))
|
| 321 |
-
expect(location.replace).not.toHaveBeenCalled()
|
| 322 |
-
})
|
| 323 |
-
|
| 324 |
-
it('reloads again if a newer version is detected after a previous reload', () => {
|
| 325 |
-
const location = makeLocation('?api_version_reloaded=1')
|
| 326 |
-
maybeReloadForApiVersion(location, makeHeaders('2'))
|
| 327 |
-
expect(location.replace).toHaveBeenCalledWith(
|
| 328 |
-
`${dashboardPathname}?api_version_reloaded=2`
|
| 329 |
-
)
|
| 330 |
-
})
|
| 331 |
-
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/dashboard/util/url-search-params.ts
DELETED
|
@@ -1,376 +0,0 @@
|
|
| 1 |
-
import {
|
| 2 |
-
getSearchToSetSegmentFilter,
|
| 3 |
-
SavedSegment
|
| 4 |
-
} from '../filtering/segments'
|
| 5 |
-
import { Filter, FilterClauseLabels } from '../dashboard-state'
|
| 6 |
-
import { v1 } from './url-search-params-v1'
|
| 7 |
-
import { v2 } from './url-search-params-v2'
|
| 8 |
-
|
| 9 |
-
/**
|
| 10 |
-
* These characters are not URL encoded to have more readable URLs.
|
| 11 |
-
* Browsers seem to handle this just fine.
|
| 12 |
-
* `?f=is,page,/my/page/:some_param` vs `?f=is,page,%2Fmy%2Fpage%2F%3Asome_param``
|
| 13 |
-
*/
|
| 14 |
-
const NOT_URL_ENCODED_CHARACTERS = ':/'
|
| 15 |
-
|
| 16 |
-
export const FILTER_URL_PARAM_NAME = 'f'
|
| 17 |
-
|
| 18 |
-
const LABEL_URL_PARAM_NAME = 'l'
|
| 19 |
-
|
| 20 |
-
const REDIRECTED_SEARCH_PARAM_NAME = 'r'
|
| 21 |
-
|
| 22 |
-
const API_VERSION_RELOAD_PARAM_NAME = 'api_version_reloaded'
|
| 23 |
-
|
| 24 |
-
const EXPECTED_API_VERSION = parseInt(
|
| 25 |
-
document
|
| 26 |
-
.querySelector('meta[name="x-api-version"]')
|
| 27 |
-
?.getAttribute('content') ?? '0',
|
| 28 |
-
10
|
| 29 |
-
)
|
| 30 |
-
|
| 31 |
-
/**
|
| 32 |
-
* Navigates to the current URL with `api_version_reloaded=<currentApiVersion>`
|
| 33 |
-
* appended, using `location.replace` so the pre-reload entry is not kept in
|
| 34 |
-
* browser history.
|
| 35 |
-
*
|
| 36 |
-
* Returns early without navigating if:
|
| 37 |
-
*
|
| 38 |
-
* - the x-plausible-version response header is not present
|
| 39 |
-
* - the expected version matches the actual version
|
| 40 |
-
* - the version is already present in search params
|
| 41 |
-
*
|
| 42 |
-
* The latter prevents an infinite reload loop when the versions are
|
| 43 |
-
* permanently out of sync.
|
| 44 |
-
*
|
| 45 |
-
* BE: lib/plausible_web/plugs/internal_stats_api_version.ex
|
| 46 |
-
*/
|
| 47 |
-
export function maybeReloadForApiVersion(
|
| 48 |
-
windowLocation: Location,
|
| 49 |
-
responseHeaders: Headers
|
| 50 |
-
) {
|
| 51 |
-
const currentApiVersion = getCurrentApiVersion(responseHeaders)
|
| 52 |
-
const params = new URLSearchParams(windowLocation.search)
|
| 53 |
-
|
| 54 |
-
if (
|
| 55 |
-
currentApiVersion === null ||
|
| 56 |
-
currentApiVersion <= EXPECTED_API_VERSION ||
|
| 57 |
-
params.get(API_VERSION_RELOAD_PARAM_NAME) === currentApiVersion.toString()
|
| 58 |
-
) {
|
| 59 |
-
return
|
| 60 |
-
}
|
| 61 |
-
|
| 62 |
-
console.warn('API version mismatch detected, reloading...')
|
| 63 |
-
|
| 64 |
-
const newSearch = searchWithApiVersionReload(
|
| 65 |
-
windowLocation.search,
|
| 66 |
-
currentApiVersion.toString()
|
| 67 |
-
)
|
| 68 |
-
windowLocation.replace(
|
| 69 |
-
`${windowLocation.pathname}${newSearch}${windowLocation.hash}`
|
| 70 |
-
)
|
| 71 |
-
}
|
| 72 |
-
|
| 73 |
-
function getCurrentApiVersion(responseHeaders: Headers): number | null {
|
| 74 |
-
const versionString = responseHeaders?.get('x-api-version')
|
| 75 |
-
return versionString ? parseInt(versionString, 10) : null
|
| 76 |
-
}
|
| 77 |
-
|
| 78 |
-
function searchWithApiVersionReload(search: string, value: string): string {
|
| 79 |
-
return stringifySearch({
|
| 80 |
-
...parseSearch(search),
|
| 81 |
-
[API_VERSION_RELOAD_PARAM_NAME]: value
|
| 82 |
-
})
|
| 83 |
-
}
|
| 84 |
-
|
| 85 |
-
/**
|
| 86 |
-
* This function is able to serialize for URL simple params @see serializeSimpleSearchEntry as well
|
| 87 |
-
* two complex params, labels and filters.
|
| 88 |
-
*/
|
| 89 |
-
export function stringifySearch(
|
| 90 |
-
searchRecord: Record<string, null | undefined | number | string | unknown>
|
| 91 |
-
): '' | string {
|
| 92 |
-
const { filters, labels, ...rest } = searchRecord ?? {}
|
| 93 |
-
const definedSearchEntries = Object.entries(rest)
|
| 94 |
-
.map(serializeSimpleSearchEntry)
|
| 95 |
-
.filter(isSearchEntryDefined)
|
| 96 |
-
.map(([k, v]) => `${k}=${v}`)
|
| 97 |
-
|
| 98 |
-
if (!Array.isArray(filters) || !filters.length) {
|
| 99 |
-
return definedSearchEntries.length
|
| 100 |
-
? `?${definedSearchEntries.join('&')}`
|
| 101 |
-
: ''
|
| 102 |
-
}
|
| 103 |
-
|
| 104 |
-
const serializedFilters = Array.isArray(filters)
|
| 105 |
-
? filters.map((f) => `${FILTER_URL_PARAM_NAME}=${serializeFilter(f)}`)
|
| 106 |
-
: []
|
| 107 |
-
|
| 108 |
-
const serializedLabels = Object.entries(labels ?? {}).map(
|
| 109 |
-
(entry) => `${LABEL_URL_PARAM_NAME}=${serializeLabelsEntry(entry)}`
|
| 110 |
-
)
|
| 111 |
-
|
| 112 |
-
return `?${serializedFilters.concat(serializedLabels).concat(definedSearchEntries).join('&')}`
|
| 113 |
-
}
|
| 114 |
-
|
| 115 |
-
export function normalizeSearchString(searchString: string): string {
|
| 116 |
-
return searchString.startsWith('?') ? searchString.slice(1) : searchString
|
| 117 |
-
}
|
| 118 |
-
|
| 119 |
-
export function parseSearch(searchString: string): Record<string, unknown> {
|
| 120 |
-
const searchRecord: Record<string, string | boolean> = {}
|
| 121 |
-
const filters: Filter[] = []
|
| 122 |
-
const labels: FilterClauseLabels = {}
|
| 123 |
-
|
| 124 |
-
const normalizedSearchString = normalizeSearchString(searchString)
|
| 125 |
-
|
| 126 |
-
if (!normalizedSearchString.length) {
|
| 127 |
-
return searchRecord
|
| 128 |
-
}
|
| 129 |
-
|
| 130 |
-
const meaningfulParams = normalizedSearchString
|
| 131 |
-
.split('&')
|
| 132 |
-
.filter((i) => i.length > 0)
|
| 133 |
-
|
| 134 |
-
for (const param of meaningfulParams) {
|
| 135 |
-
const [key, rawValue = ''] = param.split('=')
|
| 136 |
-
switch (key) {
|
| 137 |
-
case FILTER_URL_PARAM_NAME: {
|
| 138 |
-
const filter = parseFilter(rawValue)
|
| 139 |
-
if (filter.length === 3 && filter[2].length) {
|
| 140 |
-
filters.push(filter)
|
| 141 |
-
}
|
| 142 |
-
break
|
| 143 |
-
}
|
| 144 |
-
case LABEL_URL_PARAM_NAME: {
|
| 145 |
-
const [labelKey, labelValue] = parseLabelsEntry(rawValue)
|
| 146 |
-
if (labelKey.length && labelValue.length) {
|
| 147 |
-
labels[labelKey] = labelValue
|
| 148 |
-
}
|
| 149 |
-
break
|
| 150 |
-
}
|
| 151 |
-
case '': {
|
| 152 |
-
break
|
| 153 |
-
}
|
| 154 |
-
default: {
|
| 155 |
-
const parsedValue = parseSimpleSearchEntry(rawValue)
|
| 156 |
-
if (parsedValue !== null) {
|
| 157 |
-
searchRecord[decodeURIComponent(key)] = parsedValue
|
| 158 |
-
}
|
| 159 |
-
}
|
| 160 |
-
}
|
| 161 |
-
}
|
| 162 |
-
|
| 163 |
-
return {
|
| 164 |
-
...searchRecord,
|
| 165 |
-
...(filters.length && { filters }),
|
| 166 |
-
...(Object.keys(labels).length && { labels })
|
| 167 |
-
}
|
| 168 |
-
}
|
| 169 |
-
|
| 170 |
-
/**
|
| 171 |
-
* Serializes and flattens @see FilterClauseLabels entries.
|
| 172 |
-
* Examples:
|
| 173 |
-
* ["US","United States"] -> "US,United%20States"
|
| 174 |
-
* ["US-CA","California"] -> "US-CA,California"
|
| 175 |
-
* ["5391959","San Francisco"] -> "5391959,San%20Francisco"
|
| 176 |
-
*/
|
| 177 |
-
export function serializeLabelsEntry([labelKey, labelValue]: [string, string]) {
|
| 178 |
-
return `${encodeURIComponentPermissive(labelKey, NOT_URL_ENCODED_CHARACTERS)},${encodeURIComponentPermissive(labelValue, NOT_URL_ENCODED_CHARACTERS)}`
|
| 179 |
-
}
|
| 180 |
-
|
| 181 |
-
/**
|
| 182 |
-
* Parses the output of @see serializeLabelsEntry back to labels object entry.
|
| 183 |
-
*/
|
| 184 |
-
export function parseLabelsEntry(
|
| 185 |
-
labelKeyValueString: string
|
| 186 |
-
): [string, string] {
|
| 187 |
-
const [key, value] = labelKeyValueString.split(',')
|
| 188 |
-
return [decodeURIComponent(key), decodeURIComponent(value)]
|
| 189 |
-
}
|
| 190 |
-
|
| 191 |
-
/**
|
| 192 |
-
* Serializes and flattens filters array item.
|
| 193 |
-
* Examples:
|
| 194 |
-
* ["is", "entry_page", ["/blog", "/news"]] -> "is,entry_page,/blog,/news"
|
| 195 |
-
*/
|
| 196 |
-
export function serializeFilter([operator, dimension, clauses]: Filter) {
|
| 197 |
-
const serializedFilter = [
|
| 198 |
-
encodeURIComponentPermissive(operator, NOT_URL_ENCODED_CHARACTERS),
|
| 199 |
-
encodeURIComponentPermissive(dimension, NOT_URL_ENCODED_CHARACTERS),
|
| 200 |
-
...clauses.map((clause) =>
|
| 201 |
-
encodeURIComponentPermissive(
|
| 202 |
-
clause.toString(),
|
| 203 |
-
NOT_URL_ENCODED_CHARACTERS
|
| 204 |
-
)
|
| 205 |
-
)
|
| 206 |
-
].join(',')
|
| 207 |
-
return serializedFilter
|
| 208 |
-
}
|
| 209 |
-
|
| 210 |
-
/**
|
| 211 |
-
* Parses the output of @see serializeFilter back to filters array item.
|
| 212 |
-
*/
|
| 213 |
-
export function parseFilter(filterString: string): Filter {
|
| 214 |
-
const [operator, dimension, ...unparsedClauses] = filterString.split(',')
|
| 215 |
-
return [
|
| 216 |
-
decodeURIComponent(operator),
|
| 217 |
-
decodeURIComponent(dimension),
|
| 218 |
-
unparsedClauses.map(decodeURIComponent)
|
| 219 |
-
]
|
| 220 |
-
}
|
| 221 |
-
|
| 222 |
-
/**
|
| 223 |
-
* Encodes for URL simple search param values.
|
| 224 |
-
* Encodes numbers and number-like strings as indistinguishable strings. Parse treats them as strings.
|
| 225 |
-
* Encodes booleans and strings "true" and "false" as indistinguishable strings. Parse treats these as booleans.
|
| 226 |
-
* Unifies unhandleable complex search entries like undefined, null, objects and arrays as undefined.
|
| 227 |
-
* Complex URL params must be handled separately.
|
| 228 |
-
*/
|
| 229 |
-
export function serializeSimpleSearchEntry([key, value]: [string, unknown]): [
|
| 230 |
-
string,
|
| 231 |
-
undefined | string
|
| 232 |
-
] {
|
| 233 |
-
if (value === undefined || value === null || typeof value === 'object') {
|
| 234 |
-
return [key, undefined]
|
| 235 |
-
}
|
| 236 |
-
return [
|
| 237 |
-
encodeURIComponentPermissive(key, ',:/'),
|
| 238 |
-
encodeURIComponentPermissive(value.toString(), ',:/')
|
| 239 |
-
]
|
| 240 |
-
}
|
| 241 |
-
|
| 242 |
-
/**
|
| 243 |
-
* Parses output of @see serializeSimpleSearchEntry.
|
| 244 |
-
*/
|
| 245 |
-
export function parseSimpleSearchEntry(
|
| 246 |
-
searchParamValue: string
|
| 247 |
-
): null | string | boolean {
|
| 248 |
-
if (searchParamValue === 'true') {
|
| 249 |
-
return true
|
| 250 |
-
}
|
| 251 |
-
if (searchParamValue === 'false') {
|
| 252 |
-
return false
|
| 253 |
-
}
|
| 254 |
-
return decodeURIComponent(searchParamValue)
|
| 255 |
-
}
|
| 256 |
-
|
| 257 |
-
export function encodeURIComponentPermissive(
|
| 258 |
-
input: string,
|
| 259 |
-
permittedCharacters: string
|
| 260 |
-
): string {
|
| 261 |
-
return Array.from(permittedCharacters)
|
| 262 |
-
.map((character) => [encodeURIComponent(character), character])
|
| 263 |
-
.reduce(
|
| 264 |
-
(acc, [encodedCharacter, character]) =>
|
| 265 |
-
/* @ts-expect-error API supposedly not present in compilation target, but works in major browsers */
|
| 266 |
-
acc.replaceAll(encodedCharacter, character),
|
| 267 |
-
encodeURIComponent(input)
|
| 268 |
-
)
|
| 269 |
-
}
|
| 270 |
-
|
| 271 |
-
export function isSearchEntryDefined(
|
| 272 |
-
entry: [string, undefined | string]
|
| 273 |
-
): entry is [string, string] {
|
| 274 |
-
return entry[1] !== undefined
|
| 275 |
-
}
|
| 276 |
-
|
| 277 |
-
function isAlreadyRedirected(searchParams: URLSearchParams) {
|
| 278 |
-
return ['v1', 'v2'].includes(searchParams.get(REDIRECTED_SEARCH_PARAM_NAME)!)
|
| 279 |
-
}
|
| 280 |
-
|
| 281 |
-
/**
|
| 282 |
-
Dashboard state is kept on the URL for people to be able to link to what that they see.
|
| 283 |
-
Because dashboard state is a complex object, in the interest of readable URLs, custom serialization and parsing is in place.
|
| 284 |
-
|
| 285 |
-
Versions
|
| 286 |
-
* v1: @see v1
|
| 287 |
-
A custom encoding schema was used for filters, (e.g. "?page=/blog").
|
| 288 |
-
This was not flexible enough and diverged from how we represented filters in the code.
|
| 289 |
-
|
| 290 |
-
* v2: @see v2
|
| 291 |
-
jsonurl library was used to serialize the state.
|
| 292 |
-
The links from this solution didn't always auto-sense across all platforms (e.g. Twitter), cutting off too soon and leading users to broken dashboards.
|
| 293 |
-
|
| 294 |
-
* current version: this module.
|
| 295 |
-
Custom encoding.
|
| 296 |
-
|
| 297 |
-
The purpose of this function is to redirect users from one of the previous versions to the current version,
|
| 298 |
-
so previous dashboard links still work.
|
| 299 |
-
*/
|
| 300 |
-
export function maybeGetLatestReadableSearch(
|
| 301 |
-
searchString: string
|
| 302 |
-
): null | string {
|
| 303 |
-
const searchParams = new URLSearchParams(searchString)
|
| 304 |
-
if (isAlreadyRedirected(searchParams)) {
|
| 305 |
-
return null
|
| 306 |
-
}
|
| 307 |
-
const isCurrentVersion = searchParams.get(FILTER_URL_PARAM_NAME)
|
| 308 |
-
if (isCurrentVersion) {
|
| 309 |
-
return null
|
| 310 |
-
}
|
| 311 |
-
|
| 312 |
-
const isV2 = v2.isV2(searchParams)
|
| 313 |
-
const isV1 = v1.isV1(searchParams)
|
| 314 |
-
|
| 315 |
-
if (isV2) {
|
| 316 |
-
return stringifySearch({
|
| 317 |
-
...v2.parseSearch(searchString),
|
| 318 |
-
[REDIRECTED_SEARCH_PARAM_NAME]: 'v2'
|
| 319 |
-
})
|
| 320 |
-
}
|
| 321 |
-
|
| 322 |
-
if (isV1) {
|
| 323 |
-
return stringifySearch({
|
| 324 |
-
...v1.parseSearch(searchString),
|
| 325 |
-
[REDIRECTED_SEARCH_PARAM_NAME]: 'v1'
|
| 326 |
-
})
|
| 327 |
-
}
|
| 328 |
-
|
| 329 |
-
return null
|
| 330 |
-
}
|
| 331 |
-
|
| 332 |
-
/**
|
| 333 |
-
* It's possible to set a particular segment to be always applied on the data on dashboards accessed with a shared link.
|
| 334 |
-
* This function ensures that the particular segment filter is set to the URL string on initial page load.
|
| 335 |
-
* Other functions ensure that it can't be removed.
|
| 336 |
-
*/
|
| 337 |
-
export function getSearchWithEnforcedSegment(
|
| 338 |
-
searchString: string,
|
| 339 |
-
enforcedSegment: Pick<SavedSegment, 'id' | 'name'>
|
| 340 |
-
): string {
|
| 341 |
-
const searchRecord = parseSearch(searchString)
|
| 342 |
-
return stringifySearch(
|
| 343 |
-
getSearchToSetSegmentFilter(enforcedSegment)(searchRecord)
|
| 344 |
-
)
|
| 345 |
-
}
|
| 346 |
-
|
| 347 |
-
/** Called once before React app mounts. If legacy url search params are present, does a redirect to new format. */
|
| 348 |
-
export function maybeDoFERedirect(
|
| 349 |
-
windowLocation: Location,
|
| 350 |
-
windowHistory: History,
|
| 351 |
-
enforcedSegment: Pick<SavedSegment, 'id' | 'name'> | null
|
| 352 |
-
) {
|
| 353 |
-
const originalSearchString = windowLocation.search
|
| 354 |
-
|
| 355 |
-
let updatedSearchString = maybeGetLatestReadableSearch(originalSearchString)
|
| 356 |
-
|
| 357 |
-
if (enforcedSegment) {
|
| 358 |
-
updatedSearchString = getSearchWithEnforcedSegment(
|
| 359 |
-
updatedSearchString ?? originalSearchString,
|
| 360 |
-
enforcedSegment
|
| 361 |
-
)
|
| 362 |
-
}
|
| 363 |
-
|
| 364 |
-
if (
|
| 365 |
-
updatedSearchString === null ||
|
| 366 |
-
updatedSearchString === originalSearchString
|
| 367 |
-
) {
|
| 368 |
-
return
|
| 369 |
-
}
|
| 370 |
-
|
| 371 |
-
windowHistory.pushState(
|
| 372 |
-
{},
|
| 373 |
-
'',
|
| 374 |
-
`${windowLocation.pathname}${updatedSearchString}`
|
| 375 |
-
)
|
| 376 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/dashboard/util/url.test.ts
DELETED
|
@@ -1,105 +0,0 @@
|
|
| 1 |
-
import { apiPath, externalLinkForPage, isValidHttpUrl, trimURL } from './url'
|
| 2 |
-
import { siteContextDefaultValue } from '../site-context'
|
| 3 |
-
|
| 4 |
-
describe('apiPath', () => {
|
| 5 |
-
it.each([
|
| 6 |
-
['example.com', undefined, '/api/stats/example.com/'],
|
| 7 |
-
['example.com', '', '/api/stats/example.com/'],
|
| 8 |
-
['example.com', '/test', '/api/stats/example.com/test/'],
|
| 9 |
-
[
|
| 10 |
-
'example.com/path/is-really/deep',
|
| 11 |
-
'',
|
| 12 |
-
'/api/stats/example.com%2Fpath%2Fis-really%2Fdeep/'
|
| 13 |
-
]
|
| 14 |
-
])(
|
| 15 |
-
'when site.domain is %p and path is %s, should return %s',
|
| 16 |
-
(domain, path, expected) => {
|
| 17 |
-
const result = apiPath({ domain }, path)
|
| 18 |
-
expect(result).toBe(expected)
|
| 19 |
-
}
|
| 20 |
-
)
|
| 21 |
-
})
|
| 22 |
-
|
| 23 |
-
describe('externalLinkForPage', () => {
|
| 24 |
-
it.each([
|
| 25 |
-
['example.com', '/about', 'https://example.com/about'],
|
| 26 |
-
['sub.example.com', '/contact', 'https://sub.example.com/contact'],
|
| 27 |
-
[
|
| 28 |
-
'example.com',
|
| 29 |
-
'/search?q=test#section',
|
| 30 |
-
'https://example.com/search?q=test#section'
|
| 31 |
-
],
|
| 32 |
-
['example.com', '/', 'https://example.com/']
|
| 33 |
-
])(
|
| 34 |
-
'when domain is %s and page is %s, it should return %s',
|
| 35 |
-
(domain, page, expected) => {
|
| 36 |
-
const site = { ...siteContextDefaultValue, domain: domain }
|
| 37 |
-
const result = externalLinkForPage(site, page)
|
| 38 |
-
expect(result).toBe(expected)
|
| 39 |
-
}
|
| 40 |
-
)
|
| 41 |
-
|
| 42 |
-
it('returns null for consolidated view', () => {
|
| 43 |
-
const consolidatedView = {
|
| 44 |
-
...siteContextDefaultValue,
|
| 45 |
-
isConsolidatedView: true
|
| 46 |
-
}
|
| 47 |
-
expect(externalLinkForPage(consolidatedView, '/some-page')).toBe(null)
|
| 48 |
-
})
|
| 49 |
-
})
|
| 50 |
-
|
| 51 |
-
describe('isValidHttpUrl', () => {
|
| 52 |
-
it.each([
|
| 53 |
-
// Valid HTTP and HTTPS URLs
|
| 54 |
-
['http://example.com', true],
|
| 55 |
-
['https://example.com', true],
|
| 56 |
-
['http://www.example.com', true],
|
| 57 |
-
['https://sub.domain.com', true],
|
| 58 |
-
['https://example.com/path?query=1#fragment', true],
|
| 59 |
-
|
| 60 |
-
// Invalid URLs (invalid protocol)
|
| 61 |
-
['ftp://example.com', false],
|
| 62 |
-
['mailto:someone@example.com', false],
|
| 63 |
-
['file:///C:/path/to/file', false],
|
| 64 |
-
['data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==', false],
|
| 65 |
-
|
| 66 |
-
// Invalid URLs (malformed or non-URL strings)
|
| 67 |
-
['//example.com', false],
|
| 68 |
-
['example.com', false],
|
| 69 |
-
['just-a-string', false],
|
| 70 |
-
['', false],
|
| 71 |
-
['https//:example.com', false],
|
| 72 |
-
|
| 73 |
-
// Edge cases
|
| 74 |
-
['http:/example.com', true],
|
| 75 |
-
['http://localhost', true],
|
| 76 |
-
['https://127.0.0.1', true],
|
| 77 |
-
['https://[::1]', true], // IPv6 URL
|
| 78 |
-
['http://user:pass@127.0.0.1', true],
|
| 79 |
-
['https://example.com:8080', true]
|
| 80 |
-
])('for input %s returns %s', (input, expected) => {
|
| 81 |
-
const result = isValidHttpUrl(input)
|
| 82 |
-
expect(result).toBe(expected)
|
| 83 |
-
})
|
| 84 |
-
})
|
| 85 |
-
|
| 86 |
-
describe('trimURL', () => {
|
| 87 |
-
it.each([
|
| 88 |
-
// Test cases where URL length is less than or equal to maxLength
|
| 89 |
-
['https://example.com', 20, 'https://example.com'],
|
| 90 |
-
['http://example.com', 50, 'http://example.com'],
|
| 91 |
-
|
| 92 |
-
// Test cases where host itself is too long
|
| 93 |
-
[
|
| 94 |
-
'https://a-very-long-domain-name.com',
|
| 95 |
-
20,
|
| 96 |
-
'https://a-very-long-dom...domain-name.com'
|
| 97 |
-
]
|
| 98 |
-
])(
|
| 99 |
-
'when url is %s and maxLength is %d, should return %s',
|
| 100 |
-
(url, maxLength, expected) => {
|
| 101 |
-
const result = trimURL(url, maxLength)
|
| 102 |
-
expect(result).toBe(expected)
|
| 103 |
-
}
|
| 104 |
-
)
|
| 105 |
-
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/dashboard/util/url.ts
DELETED
|
@@ -1,86 +0,0 @@
|
|
| 1 |
-
import { PlausibleSite } from '../site-context'
|
| 2 |
-
|
| 3 |
-
export function apiPath(
|
| 4 |
-
site: Pick<PlausibleSite, 'domain'>,
|
| 5 |
-
path = ''
|
| 6 |
-
): string {
|
| 7 |
-
return `/api/stats/${encodeURIComponent(site.domain)}${path}/`
|
| 8 |
-
}
|
| 9 |
-
|
| 10 |
-
export function externalLinkForPage(
|
| 11 |
-
site: PlausibleSite,
|
| 12 |
-
page: string,
|
| 13 |
-
hostname?: string
|
| 14 |
-
): string | null {
|
| 15 |
-
if (hostname) {
|
| 16 |
-
return `https://${hostname}${page}`
|
| 17 |
-
}
|
| 18 |
-
if (site.isConsolidatedView) {
|
| 19 |
-
return null
|
| 20 |
-
}
|
| 21 |
-
try {
|
| 22 |
-
const domainURL = new URL(`https://${site.domain}`)
|
| 23 |
-
return `https://${domainURL.host}${page}`
|
| 24 |
-
} catch (_error) {
|
| 25 |
-
return null
|
| 26 |
-
}
|
| 27 |
-
}
|
| 28 |
-
|
| 29 |
-
export function isValidHttpUrl(input: string): boolean {
|
| 30 |
-
let url
|
| 31 |
-
|
| 32 |
-
try {
|
| 33 |
-
url = new URL(input)
|
| 34 |
-
} catch (_) {
|
| 35 |
-
return false
|
| 36 |
-
}
|
| 37 |
-
|
| 38 |
-
return url.protocol === 'http:' || url.protocol === 'https:'
|
| 39 |
-
}
|
| 40 |
-
|
| 41 |
-
export function trimURL(url: string, maxLength: number): string {
|
| 42 |
-
if (url.length <= maxLength) {
|
| 43 |
-
return url
|
| 44 |
-
}
|
| 45 |
-
|
| 46 |
-
const ellipsis = '...'
|
| 47 |
-
|
| 48 |
-
if (isValidHttpUrl(url)) {
|
| 49 |
-
const [protocol, restURL] = url.split('://')
|
| 50 |
-
const parts = restURL.split('/')
|
| 51 |
-
|
| 52 |
-
const host = parts.shift() || ''
|
| 53 |
-
if (host.length > maxLength - 5) {
|
| 54 |
-
return `${protocol}://${host.substr(0, maxLength - 5)}${ellipsis}${restURL.slice(-maxLength + 5)}`
|
| 55 |
-
}
|
| 56 |
-
|
| 57 |
-
let remainingLength = maxLength - host.length - 5
|
| 58 |
-
let trimmedURL = `${protocol}://${host}`
|
| 59 |
-
|
| 60 |
-
for (const part of parts) {
|
| 61 |
-
if (part.length <= remainingLength) {
|
| 62 |
-
trimmedURL += '/' + part
|
| 63 |
-
remainingLength -= part.length + 1
|
| 64 |
-
} else {
|
| 65 |
-
const startTrim = Math.floor((remainingLength - 3) / 2)
|
| 66 |
-
const endTrim = Math.ceil((remainingLength - 3) / 2)
|
| 67 |
-
trimmedURL += `/${part.substr(0, startTrim)}...${part.slice(-endTrim)}`
|
| 68 |
-
break
|
| 69 |
-
}
|
| 70 |
-
}
|
| 71 |
-
|
| 72 |
-
return trimmedURL
|
| 73 |
-
} else {
|
| 74 |
-
const leftSideLength = Math.floor(maxLength / 2)
|
| 75 |
-
const rightSideLength = maxLength - leftSideLength
|
| 76 |
-
|
| 77 |
-
const leftSide = url.slice(0, leftSideLength)
|
| 78 |
-
const rightSide = url.slice(-rightSideLength)
|
| 79 |
-
|
| 80 |
-
return leftSide + ellipsis + rightSide
|
| 81 |
-
}
|
| 82 |
-
}
|
| 83 |
-
|
| 84 |
-
export function maybeEncodeRouteParam(param: string) {
|
| 85 |
-
return param.includes('/') ? encodeURIComponent(param) : param
|
| 86 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/embed.content.js
DELETED
|
@@ -1,16 +0,0 @@
|
|
| 1 |
-
import 'iframe-resizer/js/iframeResizer.contentWindow'
|
| 2 |
-
|
| 3 |
-
window.iFrameResizer = {
|
| 4 |
-
onMessage: function (msg) {
|
| 5 |
-
if (msg.type === 'load-custom-styles') {
|
| 6 |
-
addCustomStyles(msg.opts)
|
| 7 |
-
}
|
| 8 |
-
}
|
| 9 |
-
}
|
| 10 |
-
|
| 11 |
-
function addCustomStyles(opts) {
|
| 12 |
-
var style = document.createElement('style')
|
| 13 |
-
style.innerHTML = opts.styles
|
| 14 |
-
|
| 15 |
-
document.head.appendChild(style)
|
| 16 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/embed.host.js
DELETED
|
@@ -1,24 +0,0 @@
|
|
| 1 |
-
import iframeResize from 'iframe-resizer/js/iframeResizer'
|
| 2 |
-
|
| 3 |
-
var iframes = iframeResize(
|
| 4 |
-
{
|
| 5 |
-
heightCalculationMethod: 'taggedElement',
|
| 6 |
-
onInit: onInit,
|
| 7 |
-
checkOrigin: false
|
| 8 |
-
},
|
| 9 |
-
'[plausible-embed]'
|
| 10 |
-
)
|
| 11 |
-
|
| 12 |
-
function onInit() {
|
| 13 |
-
var iframe = iframes[0]
|
| 14 |
-
var styles = iframe.getAttribute('styles')
|
| 15 |
-
|
| 16 |
-
if (styles) {
|
| 17 |
-
iframe.iFrameResizer.sendMessage({
|
| 18 |
-
type: 'load-custom-styles',
|
| 19 |
-
opts: {
|
| 20 |
-
styles: styles
|
| 21 |
-
}
|
| 22 |
-
})
|
| 23 |
-
}
|
| 24 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/liveview/combo-box.js
DELETED
|
@@ -1,98 +0,0 @@
|
|
| 1 |
-
// Courtesy of Benjamin von Polheim:
|
| 2 |
-
// https://blog.devgenius.io/build-a-performat-autocomplete-using-phoenix-liveview-and-alpine-js-8bcbbed17ba7
|
| 3 |
-
|
| 4 |
-
export default (id) => ({
|
| 5 |
-
isOpen: false,
|
| 6 |
-
id: id,
|
| 7 |
-
focus: null,
|
| 8 |
-
selectionInProgress: false,
|
| 9 |
-
firstFocusRegistered: false,
|
| 10 |
-
setFocus(f) {
|
| 11 |
-
this.focus = f
|
| 12 |
-
},
|
| 13 |
-
initFocus() {
|
| 14 |
-
if (this.focus === null) {
|
| 15 |
-
this.setFocus(this.leastFocusableIndex())
|
| 16 |
-
if (!this.firstFocusRegistered) {
|
| 17 |
-
document.getElementById(this.id).select()
|
| 18 |
-
this.firstFocusRegistered = true
|
| 19 |
-
}
|
| 20 |
-
}
|
| 21 |
-
},
|
| 22 |
-
trackSubmitValueChange() {
|
| 23 |
-
this.selectionInProgress = false
|
| 24 |
-
},
|
| 25 |
-
open() {
|
| 26 |
-
if (!this.isOpen) {
|
| 27 |
-
this.initFocus()
|
| 28 |
-
this.isOpen = true
|
| 29 |
-
}
|
| 30 |
-
},
|
| 31 |
-
suggestionsCount() {
|
| 32 |
-
return this.$refs.suggestions?.querySelectorAll('li').length
|
| 33 |
-
},
|
| 34 |
-
hasCreatableOption() {
|
| 35 |
-
return this.$refs.suggestions
|
| 36 |
-
?.querySelector('li')
|
| 37 |
-
.classList.contains('creatable')
|
| 38 |
-
},
|
| 39 |
-
leastFocusableIndex() {
|
| 40 |
-
if (this.suggestionsCount() === 0) {
|
| 41 |
-
return 0
|
| 42 |
-
}
|
| 43 |
-
return this.hasCreatableOption() ? 0 : 1
|
| 44 |
-
},
|
| 45 |
-
maxFocusableIndex() {
|
| 46 |
-
return this.hasCreatableOption()
|
| 47 |
-
? this.suggestionsCount() - 1
|
| 48 |
-
: this.suggestionsCount()
|
| 49 |
-
},
|
| 50 |
-
nextFocusableIndex() {
|
| 51 |
-
const currentFocus = this.focus
|
| 52 |
-
return currentFocus + 1 > this.maxFocusableIndex()
|
| 53 |
-
? this.leastFocusableIndex()
|
| 54 |
-
: currentFocus + 1
|
| 55 |
-
},
|
| 56 |
-
prevFocusableIndex() {
|
| 57 |
-
const currentFocus = this.focus
|
| 58 |
-
return currentFocus - 1 >= this.leastFocusableIndex()
|
| 59 |
-
? currentFocus - 1
|
| 60 |
-
: this.maxFocusableIndex()
|
| 61 |
-
},
|
| 62 |
-
close(e) {
|
| 63 |
-
// Pressing Escape should not propagate to window,
|
| 64 |
-
// so we'll only close the suggestions pop-up
|
| 65 |
-
if (this.isOpen && e.key === 'Escape') {
|
| 66 |
-
e.stopPropagation()
|
| 67 |
-
}
|
| 68 |
-
this.isOpen = false
|
| 69 |
-
},
|
| 70 |
-
select() {
|
| 71 |
-
this.$refs[`dropdown-${this.id}-option-${this.focus}`]?.click()
|
| 72 |
-
this.close()
|
| 73 |
-
document.getElementById(this.id).blur()
|
| 74 |
-
},
|
| 75 |
-
scrollTo(idx) {
|
| 76 |
-
this.$refs[`dropdown-${this.id}-option-${idx}`]?.scrollIntoView({
|
| 77 |
-
block: 'nearest',
|
| 78 |
-
behavior: 'smooth',
|
| 79 |
-
inline: 'start'
|
| 80 |
-
})
|
| 81 |
-
},
|
| 82 |
-
focusNext() {
|
| 83 |
-
const nextIndex = this.nextFocusableIndex()
|
| 84 |
-
|
| 85 |
-
this.open()
|
| 86 |
-
|
| 87 |
-
this.setFocus(nextIndex)
|
| 88 |
-
this.scrollTo(nextIndex)
|
| 89 |
-
},
|
| 90 |
-
focusPrev() {
|
| 91 |
-
const prevIndex = this.prevFocusableIndex()
|
| 92 |
-
|
| 93 |
-
this.open()
|
| 94 |
-
|
| 95 |
-
this.setFocus(prevIndex)
|
| 96 |
-
this.scrollTo(prevIndex)
|
| 97 |
-
}
|
| 98 |
-
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/liveview/dropdown.js
DELETED
|
@@ -1,20 +0,0 @@
|
|
| 1 |
-
// From https://alpinejs.dev/component/dropdown
|
| 2 |
-
|
| 3 |
-
export default () => ({
|
| 4 |
-
open: false,
|
| 5 |
-
toggle() {
|
| 6 |
-
if (this.open) {
|
| 7 |
-
return this.close()
|
| 8 |
-
}
|
| 9 |
-
|
| 10 |
-
this.$refs.button.focus()
|
| 11 |
-
this.open = true
|
| 12 |
-
},
|
| 13 |
-
|
| 14 |
-
close(focusAfter) {
|
| 15 |
-
if (!this.open) return
|
| 16 |
-
|
| 17 |
-
this.open = false
|
| 18 |
-
focusAfter?.focus()
|
| 19 |
-
}
|
| 20 |
-
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/liveview/live_socket.js
DELETED
|
@@ -1,78 +0,0 @@
|
|
| 1 |
-
/**
|
| 2 |
-
The modules below this comment block are resolved from '../deps' folder,
|
| 3 |
-
which does not exist when running the lint command in Github CI
|
| 4 |
-
*/
|
| 5 |
-
|
| 6 |
-
/* eslint-disable import/no-unresolved */
|
| 7 |
-
import 'phoenix_html'
|
| 8 |
-
import { Socket } from 'phoenix'
|
| 9 |
-
import { LiveSocket } from 'phoenix_live_view'
|
| 10 |
-
import { Modal, Dropdown } from 'prima'
|
| 11 |
-
import topbar from 'topbar'
|
| 12 |
-
/* eslint-enable import/no-unresolved */
|
| 13 |
-
|
| 14 |
-
import Alpine from 'alpinejs'
|
| 15 |
-
|
| 16 |
-
let csrfToken = document.querySelector("meta[name='csrf-token']")
|
| 17 |
-
let websocketUrl = document.querySelector("meta[name='websocket-url']")
|
| 18 |
-
if (csrfToken && websocketUrl) {
|
| 19 |
-
let Hooks = { Modal, Dropdown }
|
| 20 |
-
Hooks.Metrics = {
|
| 21 |
-
mounted() {
|
| 22 |
-
this.handleEvent('send-metrics', ({ event_name }) => {
|
| 23 |
-
window.plausible(event_name)
|
| 24 |
-
this.pushEvent('send-metrics-after', { event_name })
|
| 25 |
-
})
|
| 26 |
-
}
|
| 27 |
-
}
|
| 28 |
-
let Uploaders = {}
|
| 29 |
-
Uploaders.S3 = function (entries, onViewError) {
|
| 30 |
-
entries.forEach((entry) => {
|
| 31 |
-
let xhr = new XMLHttpRequest()
|
| 32 |
-
onViewError(() => xhr.abort())
|
| 33 |
-
xhr.onload = () =>
|
| 34 |
-
xhr.status === 200 ? entry.progress(100) : entry.error()
|
| 35 |
-
xhr.onerror = () => entry.error()
|
| 36 |
-
xhr.upload.addEventListener('progress', (event) => {
|
| 37 |
-
if (event.lengthComputable) {
|
| 38 |
-
let percent = Math.round((event.loaded / event.total) * 100)
|
| 39 |
-
if (percent < 100) {
|
| 40 |
-
entry.progress(percent)
|
| 41 |
-
}
|
| 42 |
-
}
|
| 43 |
-
})
|
| 44 |
-
let url = entry.meta.url
|
| 45 |
-
xhr.open('PUT', url, true)
|
| 46 |
-
xhr.send(entry.file)
|
| 47 |
-
})
|
| 48 |
-
}
|
| 49 |
-
let token = csrfToken.getAttribute('content')
|
| 50 |
-
let url = websocketUrl.getAttribute('content')
|
| 51 |
-
let liveUrl = url === '' ? '/live' : new URL('/live', url).href
|
| 52 |
-
let liveSocket = new LiveSocket(liveUrl, Socket, {
|
| 53 |
-
heartbeatIntervalMs: 10000,
|
| 54 |
-
params: { _csrf_token: token },
|
| 55 |
-
hooks: Hooks,
|
| 56 |
-
uploaders: Uploaders,
|
| 57 |
-
dom: {
|
| 58 |
-
// for alpinejs integration
|
| 59 |
-
onBeforeElUpdated(from, to) {
|
| 60 |
-
if (from._x_dataStack) {
|
| 61 |
-
Alpine.clone(from, to)
|
| 62 |
-
}
|
| 63 |
-
}
|
| 64 |
-
}
|
| 65 |
-
})
|
| 66 |
-
|
| 67 |
-
topbar.config({
|
| 68 |
-
barColors: { 0: '#303f9f' },
|
| 69 |
-
shadowColor: 'rgba(0, 0, 0, .3)',
|
| 70 |
-
barThickness: 4
|
| 71 |
-
})
|
| 72 |
-
window.addEventListener('phx:page-loading-start', (_info) => topbar.show())
|
| 73 |
-
window.addEventListener('phx:page-loading-stop', (_info) => topbar.hide())
|
| 74 |
-
window.addEventListener('scroll-to-top', () => window.scrollTo(0, 0))
|
| 75 |
-
|
| 76 |
-
liveSocket.connect()
|
| 77 |
-
window.liveSocket = liveSocket
|
| 78 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/liveview/phx_events.js
DELETED
|
@@ -1,20 +0,0 @@
|
|
| 1 |
-
window.addEventListener(`phx:update-value`, (e) => {
|
| 2 |
-
let el = document.getElementById(e.detail.id)
|
| 3 |
-
el.value = e.detail.value
|
| 4 |
-
if (e.detail.fire) {
|
| 5 |
-
el.dispatchEvent(new Event('input', { bubbles: true }))
|
| 6 |
-
}
|
| 7 |
-
})
|
| 8 |
-
|
| 9 |
-
window.addEventListener(`phx:js-exec`, ({ detail }) => {
|
| 10 |
-
document.querySelectorAll(detail.to).forEach((el) => {
|
| 11 |
-
window.liveSocket.execJS(el, el.getAttribute(detail.attr))
|
| 12 |
-
})
|
| 13 |
-
})
|
| 14 |
-
|
| 15 |
-
window.addEventListener(`phx:notify-selection-change`, (event) => {
|
| 16 |
-
let el = document.getElementById(event.detail.id)
|
| 17 |
-
el.dispatchEvent(
|
| 18 |
-
new CustomEvent('selection-change', { detail: event.detail })
|
| 19 |
-
)
|
| 20 |
-
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/polyfills/closest.js
DELETED
|
@@ -1,14 +0,0 @@
|
|
| 1 |
-
if (window.Element && !Element.prototype.closest) {
|
| 2 |
-
Element.prototype.closest = function (s) {
|
| 3 |
-
var matches = (this.document || this.ownerDocument).querySelectorAll(s),
|
| 4 |
-
i,
|
| 5 |
-
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
| 6 |
-
el = this
|
| 7 |
-
do {
|
| 8 |
-
i = matches.length
|
| 9 |
-
// eslint-disable-next-line no-empty
|
| 10 |
-
while (--i >= 0 && matches.item(i) !== el) {}
|
| 11 |
-
} while (i < 0 && (el = el.parentElement))
|
| 12 |
-
return el
|
| 13 |
-
}
|
| 14 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/js/types/globals.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
| 1 |
-
declare const BUILD_EXTRA: boolean
|
|
|
|
|
|
assets/js/types/query-api.d.ts
DELETED
|
@@ -1,216 +0,0 @@
|
|
| 1 |
-
/* Autogenerated, recreate with `npm run --prefix assets generate-types` */
|
| 2 |
-
|
| 3 |
-
export type Metric =
|
| 4 |
-
| "visitors"
|
| 5 |
-
| "visits"
|
| 6 |
-
| "pageviews"
|
| 7 |
-
| "views_per_visit"
|
| 8 |
-
| "bounce_rate"
|
| 9 |
-
| "visit_duration"
|
| 10 |
-
| "events"
|
| 11 |
-
| "percentage"
|
| 12 |
-
| "conversion_rate"
|
| 13 |
-
| "group_conversion_rate"
|
| 14 |
-
| "time_on_page"
|
| 15 |
-
| "total_revenue"
|
| 16 |
-
| "average_revenue"
|
| 17 |
-
| "scroll_depth";
|
| 18 |
-
export type DateRangeShorthand =
|
| 19 |
-
| "all"
|
| 20 |
-
| "day"
|
| 21 |
-
| "24h"
|
| 22 |
-
| "7d"
|
| 23 |
-
| "28d"
|
| 24 |
-
| "30d"
|
| 25 |
-
| "91d"
|
| 26 |
-
| "month"
|
| 27 |
-
| "6mo"
|
| 28 |
-
| "12mo"
|
| 29 |
-
| "year"
|
| 30 |
-
| string;
|
| 31 |
-
/**
|
| 32 |
-
* @minItems 2
|
| 33 |
-
* @maxItems 2
|
| 34 |
-
*/
|
| 35 |
-
export type DateTimeRange = [string, string];
|
| 36 |
-
/**
|
| 37 |
-
* @minItems 2
|
| 38 |
-
* @maxItems 2
|
| 39 |
-
*/
|
| 40 |
-
export type DateRange = [string, string];
|
| 41 |
-
export type Dimensions = SimpleFilterDimensions | CustomPropertyFilterDimensions | GoalDimension | TimeDimensions;
|
| 42 |
-
export type SimpleFilterDimensions =
|
| 43 |
-
| "event:name"
|
| 44 |
-
| "event:page"
|
| 45 |
-
| "event:hostname"
|
| 46 |
-
| "visit:source"
|
| 47 |
-
| "visit:channel"
|
| 48 |
-
| "visit:referrer"
|
| 49 |
-
| "visit:utm_medium"
|
| 50 |
-
| "visit:utm_source"
|
| 51 |
-
| "visit:utm_campaign"
|
| 52 |
-
| "visit:utm_content"
|
| 53 |
-
| "visit:utm_term"
|
| 54 |
-
| "visit:screen"
|
| 55 |
-
| "visit:device"
|
| 56 |
-
| "visit:browser"
|
| 57 |
-
| "visit:browser_version"
|
| 58 |
-
| "visit:os"
|
| 59 |
-
| "visit:os_version"
|
| 60 |
-
| "visit:country"
|
| 61 |
-
| "visit:region"
|
| 62 |
-
| "visit:city"
|
| 63 |
-
| "visit:country_name"
|
| 64 |
-
| "visit:region_name"
|
| 65 |
-
| "visit:city_name"
|
| 66 |
-
| "visit:entry_page"
|
| 67 |
-
| "visit:exit_page"
|
| 68 |
-
| "visit:entry_page_hostname"
|
| 69 |
-
| "visit:exit_page_hostname";
|
| 70 |
-
export type CustomPropertyFilterDimensions = `event:props:${string}`;
|
| 71 |
-
export type GoalDimension = "event:goal";
|
| 72 |
-
export type TimeDimensions = "time" | "time:month" | "time:week" | "time:day" | "time:hour";
|
| 73 |
-
export type FilterTree = FilterEntry | FilterAndOr | FilterNot | FilterHasDone;
|
| 74 |
-
export type FilterEntry = FilterWithoutGoals | FilterWithIs | FilterWithContains | FilterWithPattern;
|
| 75 |
-
/**
|
| 76 |
-
* @minItems 3
|
| 77 |
-
* @maxItems 4
|
| 78 |
-
*/
|
| 79 |
-
export type FilterWithoutGoals =
|
| 80 |
-
| [FilterOperationWithoutGoals, SimpleFilterDimensions | CustomPropertyFilterDimensions, Clauses]
|
| 81 |
-
| [
|
| 82 |
-
FilterOperationWithoutGoals,
|
| 83 |
-
SimpleFilterDimensions | CustomPropertyFilterDimensions,
|
| 84 |
-
Clauses,
|
| 85 |
-
{
|
| 86 |
-
case_sensitive?: boolean;
|
| 87 |
-
}
|
| 88 |
-
];
|
| 89 |
-
/**
|
| 90 |
-
* filter operation
|
| 91 |
-
*/
|
| 92 |
-
export type FilterOperationWithoutGoals = "is_not" | "contains_not";
|
| 93 |
-
/**
|
| 94 |
-
* @minItems 1
|
| 95 |
-
*/
|
| 96 |
-
export type Clauses = [string | number, ...(string | number)[]];
|
| 97 |
-
/**
|
| 98 |
-
* @minItems 3
|
| 99 |
-
* @maxItems 4
|
| 100 |
-
*/
|
| 101 |
-
export type FilterWithIs =
|
| 102 |
-
| ["is", GoalDimension | SimpleFilterDimensions | CustomPropertyFilterDimensions | "segment", Clauses]
|
| 103 |
-
| [
|
| 104 |
-
"is",
|
| 105 |
-
GoalDimension | SimpleFilterDimensions | CustomPropertyFilterDimensions | "segment",
|
| 106 |
-
Clauses,
|
| 107 |
-
{
|
| 108 |
-
case_sensitive?: boolean;
|
| 109 |
-
}
|
| 110 |
-
];
|
| 111 |
-
/**
|
| 112 |
-
* @minItems 3
|
| 113 |
-
* @maxItems 4
|
| 114 |
-
*/
|
| 115 |
-
export type FilterWithContains =
|
| 116 |
-
| ["contains", GoalDimension | SimpleFilterDimensions | CustomPropertyFilterDimensions, Clauses]
|
| 117 |
-
| [
|
| 118 |
-
"contains",
|
| 119 |
-
GoalDimension | SimpleFilterDimensions | CustomPropertyFilterDimensions,
|
| 120 |
-
Clauses,
|
| 121 |
-
{
|
| 122 |
-
case_sensitive?: boolean;
|
| 123 |
-
}
|
| 124 |
-
];
|
| 125 |
-
/**
|
| 126 |
-
* @minItems 3
|
| 127 |
-
* @maxItems 3
|
| 128 |
-
*/
|
| 129 |
-
export type FilterWithPattern = [
|
| 130 |
-
FilterOperationRegex,
|
| 131 |
-
SimpleFilterDimensions | CustomPropertyFilterDimensions,
|
| 132 |
-
Clauses
|
| 133 |
-
];
|
| 134 |
-
/**
|
| 135 |
-
* filter operation
|
| 136 |
-
*/
|
| 137 |
-
export type FilterOperationRegex = "matches" | "matches_not";
|
| 138 |
-
/**
|
| 139 |
-
* @minItems 2
|
| 140 |
-
* @maxItems 2
|
| 141 |
-
*/
|
| 142 |
-
export type FilterAndOr = ["and" | "or", [FilterTree, ...FilterTree[]]];
|
| 143 |
-
/**
|
| 144 |
-
* @minItems 2
|
| 145 |
-
* @maxItems 2
|
| 146 |
-
*/
|
| 147 |
-
export type FilterNot = ["not", FilterTree];
|
| 148 |
-
/**
|
| 149 |
-
* @minItems 2
|
| 150 |
-
* @maxItems 2
|
| 151 |
-
*/
|
| 152 |
-
export type FilterHasDone = ["has_done" | "has_not_done", FilterTree];
|
| 153 |
-
/**
|
| 154 |
-
* @minItems 2
|
| 155 |
-
* @maxItems 2
|
| 156 |
-
*/
|
| 157 |
-
export type OrderByEntry = [
|
| 158 |
-
Metric | SimpleFilterDimensions | CustomPropertyFilterDimensions | TimeDimensions,
|
| 159 |
-
SortDirection
|
| 160 |
-
];
|
| 161 |
-
/**
|
| 162 |
-
* Sorting order
|
| 163 |
-
*/
|
| 164 |
-
export type SortDirection = "asc" | "desc";
|
| 165 |
-
|
| 166 |
-
export interface QueryApiSchema {
|
| 167 |
-
/**
|
| 168 |
-
* Domain of site to query
|
| 169 |
-
*/
|
| 170 |
-
site_id: string;
|
| 171 |
-
/**
|
| 172 |
-
* List of metrics to query
|
| 173 |
-
*
|
| 174 |
-
* @minItems 1
|
| 175 |
-
*/
|
| 176 |
-
metrics: [Metric, ...Metric[]];
|
| 177 |
-
/**
|
| 178 |
-
* Date range to query
|
| 179 |
-
*/
|
| 180 |
-
date_range: DateRangeShorthand | DateTimeRange | DateRange;
|
| 181 |
-
/**
|
| 182 |
-
* What to group the results by. Same as `property` in Plausible API v1
|
| 183 |
-
*/
|
| 184 |
-
dimensions?: Dimensions[];
|
| 185 |
-
/**
|
| 186 |
-
* How to drill into your data
|
| 187 |
-
*/
|
| 188 |
-
filters?: FilterTree[];
|
| 189 |
-
/**
|
| 190 |
-
* How to order query results
|
| 191 |
-
*/
|
| 192 |
-
order_by?: OrderByEntry[];
|
| 193 |
-
include?: {
|
| 194 |
-
time_labels?: boolean;
|
| 195 |
-
imports?: boolean;
|
| 196 |
-
/**
|
| 197 |
-
* If set, returns the total number of result rows rows before pagination under `meta.total_rows`
|
| 198 |
-
*/
|
| 199 |
-
total_rows?: boolean;
|
| 200 |
-
/**
|
| 201 |
-
* If set and using `day`, `month` or `year` date_ranges, the query will be trimmed to the current date
|
| 202 |
-
*/
|
| 203 |
-
trim_relative_date_range?: boolean;
|
| 204 |
-
};
|
| 205 |
-
pagination?: Pagination;
|
| 206 |
-
}
|
| 207 |
-
export interface Pagination {
|
| 208 |
-
/**
|
| 209 |
-
* Number of rows to limit result to.
|
| 210 |
-
*/
|
| 211 |
-
limit?: number;
|
| 212 |
-
/**
|
| 213 |
-
* Pagination offset.
|
| 214 |
-
*/
|
| 215 |
-
offset?: number;
|
| 216 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|