Leon4gr45 commited on
Commit
9f20be1
·
verified ·
1 Parent(s): 95687e2

Clean up all uploaded files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. assets/js/dashboard/stats/more-link-state.js +0 -5
  2. assets/js/dashboard/stats/more-link.js +0 -59
  3. assets/js/dashboard/stats/pages/details.tsx +0 -146
  4. assets/js/dashboard/stats/pages/external-link.tsx +0 -50
  5. assets/js/dashboard/stats/pages/index.tsx +0 -321
  6. assets/js/dashboard/stats/reports/change-arrow.test.tsx +0 -81
  7. assets/js/dashboard/stats/reports/change-arrow.tsx +0 -46
  8. assets/js/dashboard/stats/reports/index-breakdown.tsx +0 -594
  9. assets/js/dashboard/stats/reports/metric-formatter.ts +0 -70
  10. assets/js/dashboard/stats/reports/report-header.js +0 -9
  11. assets/js/dashboard/stats/reports/report-layout.tsx +0 -25
  12. assets/js/dashboard/stats/reports/reports-config.ts +0 -454
  13. assets/js/dashboard/stats/sources/details.tsx +0 -123
  14. assets/js/dashboard/stats/sources/fetch-search-terms.tsx +0 -133
  15. assets/js/dashboard/stats/sources/index.tsx +0 -360
  16. assets/js/dashboard/stats/sources/search-terms.tsx +0 -151
  17. assets/js/dashboard/stats/sources/source-favicon.tsx +0 -26
  18. assets/js/dashboard/theme-context.tsx +0 -61
  19. assets/js/dashboard/user-context.tsx +0 -50
  20. assets/js/dashboard/util/date.js +0 -169
  21. assets/js/dashboard/util/date.test.ts +0 -210
  22. assets/js/dashboard/util/fetch-suggestions.ts +0 -35
  23. assets/js/dashboard/util/filter-text.test.tsx +0 -42
  24. assets/js/dashboard/util/filter-text.tsx +0 -78
  25. assets/js/dashboard/util/filters.js +0 -311
  26. assets/js/dashboard/util/filters.test.ts +0 -82
  27. assets/js/dashboard/util/goals.ts +0 -34
  28. assets/js/dashboard/util/money.ts +0 -23
  29. assets/js/dashboard/util/number-formatter.test.ts +0 -33
  30. assets/js/dashboard/util/number-formatter.ts +0 -84
  31. assets/js/dashboard/util/realtime-update-timer.js +0 -8
  32. assets/js/dashboard/util/seconds-since-last-load.js +0 -15
  33. assets/js/dashboard/util/storage.js +0 -37
  34. assets/js/dashboard/util/tooltip.tsx +0 -109
  35. assets/js/dashboard/util/url-search-params-v1.ts +0 -121
  36. assets/js/dashboard/util/url-search-params-v2.test.ts +0 -204
  37. assets/js/dashboard/util/url-search-params-v2.ts +0 -83
  38. assets/js/dashboard/util/url-search-params.test.ts +0 -331
  39. assets/js/dashboard/util/url-search-params.ts +0 -376
  40. assets/js/dashboard/util/url.test.ts +0 -105
  41. assets/js/dashboard/util/url.ts +0 -86
  42. assets/js/embed.content.js +0 -16
  43. assets/js/embed.host.js +0 -24
  44. assets/js/liveview/combo-box.js +0 -98
  45. assets/js/liveview/dropdown.js +0 -20
  46. assets/js/liveview/live_socket.js +0 -78
  47. assets/js/liveview/phx_events.js +0 -20
  48. assets/js/polyfills/closest.js +0 -14
  49. assets/js/types/globals.d.ts +0 -1
  50. 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
- }