No data yet
@@ -414,7 +468,7 @@ function Behaviours({ importedDataInView, setMode, mode }) {
)
}
- function featureUnavailable() {
+ function featureUnavailable(): ReactNode {
return (
This report is available in Plausible Cloud
@@ -428,7 +482,7 @@ function Behaviours({ importedDataInView, setMode, mode }) {
)
}
- function renderContent() {
+ function renderContent(): ReactNode {
switch (mode) {
case Mode.CONVERSIONS:
return renderConversions()
@@ -441,18 +495,22 @@ function Behaviours({ importedDataInView, setMode, mode }) {
}
}
- function getMoreLinkProps() {
+ function getMoreLinkProps(): {
+ path: string
+ params?: Record
+ search: (search: string) => string
+ } | null {
switch (mode) {
case Mode.CONVERSIONS:
return specialGoal
? {
path: customPropsRoute.path,
params: { propKey: url.maybeEncodeRouteParam(specialGoal.prop) },
- search: (search) => search
+ search: (search: string) => search
}
: {
path: conversionsRoute.path,
- search: (search) => search
+ search: (search: string) => search
}
case Mode.PROPS:
if (!selectedPropKey) {
@@ -461,51 +519,27 @@ function Behaviours({ importedDataInView, setMode, mode }) {
return {
path: customPropsRoute.path,
params: { propKey: url.maybeEncodeRouteParam(selectedPropKey) },
- search: (search) => search
+ search: (search: string) => search
}
default:
return null
}
}
- function isEnabled(mode) {
- return enabledModes.includes(mode)
+ function isEnabled(checkMode: Mode): boolean {
+ return enabledModes.includes(checkMode)
}
- function isRealtime() {
+ function isRealtime(): boolean {
return dashboardState.period === 'realtime'
}
- function renderImportedQueryUnsupportedWarning() {
- if (mode === Mode.CONVERSIONS) {
- return (
-
- )
- } else if (mode === Mode.PROPS) {
- return (
-
- )
- } else {
- return (
-
- )
- }
- }
-
if (!mode) {
return null
}
+ const moreLinkProps = getMoreLinkProps()
+
return (
@@ -586,10 +620,23 @@ function Behaviours({ importedDataInView, setMode, mode }) {
)}
{isRealtime() && last 30min}
- {renderImportedQueryUnsupportedWarning()}
+ {[Mode.CONVERSIONS, Mode.PROPS].includes(mode) ? (
+
+ ) : (
+
+ )}
- {![Mode.FUNNELS, Mode.EXPLORATION].includes(mode) && (
-
+ {moreLinkProps !== null && (
+
)}
{renderContent()}
@@ -597,17 +644,21 @@ function Behaviours({ importedDataInView, setMode, mode }) {
)
}
-function BehavioursOuter({ importedDataInView }) {
+function BehavioursOuter({
+ importedDataInView
+}: {
+ importedDataInView?: boolean
+}): ReactNode {
const site = useSiteContext()
const { enabledModes } = useModesContext()
- const [mode, setMode] = useState(null)
+ const [mode, setMode] = useState
(null)
useEffect(() => {
const storedMode = storage.getItem(STORAGE_KEYS.getForTab({ site }))
// updates current mode when available modes change (if needed), loads user's stored mode
setMode((currentMode) =>
getFirstPreferenceFromEnabledModes(
- [currentMode, storedMode],
+ [currentMode, storedMode] as Mode[],
enabledModes
)
)
@@ -622,7 +673,11 @@ function BehavioursOuter({ importedDataInView }) {
) : null
}
-export default function BehavioursWrapped({ importedDataInView }) {
+export default function BehavioursWrapped({
+ importedDataInView
+}: {
+ importedDataInView?: boolean
+}): ReactNode {
return (
diff --git a/assets/js/dashboard/stats/behaviours/props.js b/assets/js/dashboard/stats/behaviours/props.js
deleted file mode 100644
index 769e5d030169..000000000000
--- a/assets/js/dashboard/stats/behaviours/props.js
+++ /dev/null
@@ -1,73 +0,0 @@
-import React from 'react'
-import ListReport, { MIN_HEIGHT } from '../reports/list-legacy'
-import * as metrics from '../reports/metrics'
-import * as api from '../../api'
-import * as url from '../../util/url'
-import { EVENT_PROPS_PREFIX, hasConversionGoalFilter } from '../../util/filters'
-import { useDashboardStateContext } from '../../dashboard-state-context'
-import { useSiteContext } from '../../site-context'
-
-export default function Properties({ propKey, afterFetchData }) {
- const { dashboardState } = useDashboardStateContext()
- const site = useSiteContext()
-
- function fetchProps() {
- return api.get(
- url.apiPath(site, `/custom-prop-values/${encodeURIComponent(propKey)}`),
- dashboardState
- )
- }
-
- /*global BUILD_EXTRA*/
- function chooseMetrics() {
- return [
- metrics.createVisitors({
- renderLabel: (_dashboardState) => 'Visitors',
- meta: { plot: true }
- }),
- metrics.createEvents({
- renderLabel: (_dashboardState) => 'Events',
- meta: { hiddenOnMobile: true }
- }),
- hasConversionGoalFilter(dashboardState) && metrics.createConversionRate(),
- !hasConversionGoalFilter(dashboardState) && metrics.createPercentage(),
- BUILD_EXTRA &&
- metrics.createTotalRevenue({ meta: { hiddenOnMobile: true } }),
- BUILD_EXTRA &&
- metrics.createAverageRevenue({ meta: { hiddenOnMobile: true } })
- ].filter((metric) => !!metric)
- }
-
- function renderBreakdown() {
- return (
-
- )
- }
-
- const getFilterInfo = (listItem) => ({
- prefix: `${EVENT_PROPS_PREFIX}${propKey}`,
- filter: ['is', `${EVENT_PROPS_PREFIX}${propKey}`, [listItem.name]]
- })
-
- if (!propKey) {
- return (
-
- No custom properties found
-
- )
- }
-
- return (
-
- {renderBreakdown()}
-
- )
-}
diff --git a/assets/js/dashboard/stats/behaviours/props.tsx b/assets/js/dashboard/stats/behaviours/props.tsx
new file mode 100644
index 000000000000..eaee766b7845
--- /dev/null
+++ b/assets/js/dashboard/stats/behaviours/props.tsx
@@ -0,0 +1,88 @@
+import React, { ReactNode, useMemo, useCallback } from 'react'
+
+import {
+ DimensionCellWithBar,
+ DimensionCellWithBarProps,
+ IndexBreakdown,
+ MIN_HEIGHT
+} from '../reports/index-breakdown'
+import { customPropsReportConfig } from '../reports/reports-config'
+import { revenueAvailable } from '../../dashboard-state'
+import { useDashboardStateContext } from '../../dashboard-state-context'
+import { useSiteContext } from '../../site-context'
+import { hasConversionGoalFilter } from '../../util/filters'
+import { QueryApiResponse } from '../../api'
+import {
+ BEHAVIOURS_BAR_COLOR,
+ BEHAVIOURS_METRIC_COLUMN_WIDTH,
+ BEHAVIOURS_METRICS_HIDDEN_ON_MOBILE
+} from '.'
+import { makeGetCustomPropFilterInfo } from '../modals/props'
+
+type PropertiesProps = {
+ propKey: string | null
+ onDataReady?: (data: QueryApiResponse) => void
+}
+
+export default function Properties({
+ propKey,
+ onDataReady
+}: PropertiesProps): ReactNode {
+ const { dashboardState } = useDashboardStateContext()
+ const site = useSiteContext()
+
+ const reportConfig = useMemo(
+ () => (propKey ? customPropsReportConfig(propKey) : null),
+ [propKey]
+ )
+
+ /*global BUILD_EXTRA*/
+ const isRevenueAvailable =
+ BUILD_EXTRA && revenueAvailable(dashboardState, site)
+
+ const metrics = useMemo(() => {
+ if (!reportConfig) return []
+ return reportConfig.getMetrics({
+ hasConversionGoalFilter: hasConversionGoalFilter(dashboardState),
+ isRevenueAvailable
+ })
+ }, [reportConfig, dashboardState, isRevenueAvailable])
+
+ const DimensionElement = useCallback(
+ (props: DimensionCellWithBarProps) => {
+ return (
+
+ )
+ },
+ [propKey]
+ )
+
+ if (!propKey || !reportConfig) {
+ return (
+
+ No custom properties found
+
+ )
+ }
+
+ return (
+
+
+
+ )
+}
diff --git a/assets/js/dashboard/stats/behaviours/special-goal-prop-breakdown.js b/assets/js/dashboard/stats/behaviours/special-goal-prop-breakdown.js
deleted file mode 100644
index b82ef8368f65..000000000000
--- a/assets/js/dashboard/stats/behaviours/special-goal-prop-breakdown.js
+++ /dev/null
@@ -1,64 +0,0 @@
-import React from 'react'
-import ListReport from '../reports/list-legacy'
-import * as metrics from '../reports/metrics'
-import * as url from '../../util/url'
-import * as api from '../../api'
-import { EVENT_PROPS_PREFIX } from '../../util/filters'
-import { useSiteContext } from '../../site-context'
-import { useDashboardStateContext } from '../../dashboard-state-context'
-
-export function SpecialGoalPropBreakdown({ prop, afterFetchData }) {
- const site = useSiteContext()
- const { dashboardState } = useDashboardStateContext()
-
- function fetchData() {
- return api.get(
- url.apiPath(site, `/custom-prop-values/${prop}`),
- dashboardState
- )
- }
-
- function getExternalLinkUrlFactory() {
- if (prop === 'path') {
- return (listItem) => url.externalLinkForPage(site, listItem.name)
- } else if (prop === 'search_query') {
- return null // WP Search Queries should not become external links
- } else {
- return (listItem) => listItem.name
- }
- }
-
- function getFilterInfo(listItem) {
- return {
- prefix: EVENT_PROPS_PREFIX,
- filter: ['is', `${EVENT_PROPS_PREFIX}${prop}`, [listItem['name']]]
- }
- }
-
- function chooseMetrics() {
- return [
- metrics.createVisitors({
- renderLabel: (_dashboardState) => 'Visitors',
- meta: { plot: true }
- }),
- metrics.createEvents({
- renderLabel: (_dashboardState) => 'Events',
- meta: { hiddenOnMobile: true }
- }),
- metrics.createConversionRate()
- ].filter((metric) => !!metric)
- }
-
- return (
-
- )
-}
diff --git a/assets/js/dashboard/stats/behaviours/special-goal-prop-breakdown.tsx b/assets/js/dashboard/stats/behaviours/special-goal-prop-breakdown.tsx
new file mode 100644
index 000000000000..969f221d6b1f
--- /dev/null
+++ b/assets/js/dashboard/stats/behaviours/special-goal-prop-breakdown.tsx
@@ -0,0 +1,90 @@
+import React, { ReactNode, useCallback } from 'react'
+
+import {
+ DimensionCellWithBar,
+ DimensionCellWithBarProps,
+ IndexBreakdown
+} from '../reports/index-breakdown'
+import { customPropsReportConfig } from '../reports/reports-config'
+import { useDashboardStateContext } from '../../dashboard-state-context'
+import { PlausibleSite, useSiteContext } from '../../site-context'
+import { externalLinkForPage } from '../../util/url'
+import { IndexExternalLink } from '../pages/external-link'
+import { EVENT_PROPS_PREFIX, hasConversionGoalFilter } from '../../util/filters'
+import { QueryApiResponse } from '../../api'
+import {
+ BEHAVIOURS_BAR_COLOR,
+ BEHAVIOURS_METRIC_COLUMN_WIDTH,
+ BEHAVIOURS_METRICS_HIDDEN_ON_MOBILE
+} from '.'
+
+type SpecialGoalPropBreakdownProps = {
+ prop: string
+ onDataReady?: (data: QueryApiResponse) => void
+}
+
+export function SpecialGoalPropBreakdown({
+ prop,
+ onDataReady
+}: SpecialGoalPropBreakdownProps): ReactNode {
+ const { dashboardState } = useDashboardStateContext()
+ const site = useSiteContext()
+
+ const reportConfig = customPropsReportConfig(prop)
+
+ const metrics = reportConfig.getMetrics({
+ hasConversionGoalFilter: hasConversionGoalFilter(dashboardState),
+ isRevenueAvailable: false
+ })
+
+ const DimensionElement = useCallback(
+ (props: DimensionCellWithBarProps) => {
+ const value = props.row.dimensions[0]
+ const externalUrl = getExternalUrl(prop, value, site)
+ return (
+
+ )
+ }
+ getFilterInfo={() => ({
+ prefix: `${EVENT_PROPS_PREFIX}${prop}`,
+ filter: ['is', `${EVENT_PROPS_PREFIX}${prop}`, [value]]
+ })}
+ />
+ )
+ },
+ [prop, site]
+ )
+
+ return (
+
+ )
+}
+
+function getExternalUrl(
+ prop: string,
+ value: string,
+ site: PlausibleSite
+): string | null {
+ if (prop === 'path') {
+ return externalLinkForPage(site, value)
+ }
+ if (prop === 'search_query') {
+ return null
+ }
+ return value
+}
diff --git a/assets/js/dashboard/stats/breakdowns.tsx b/assets/js/dashboard/stats/breakdowns.tsx
index ac015830610c..5e47d4add7b5 100644
--- a/assets/js/dashboard/stats/breakdowns.tsx
+++ b/assets/js/dashboard/stats/breakdowns.tsx
@@ -1,4 +1,4 @@
-import React, { ReactNode, useEffect, useRef } from 'react'
+import React, { ReactNode, useEffect, useMemo, useRef } from 'react'
import { SortDirection } from '../../types/query-api'
import type { QueryResultRow, QueryResultQuery } from '../api'
import { Metric } from './metrics'
@@ -10,6 +10,7 @@ import {
addFilter,
ApiFilter,
NonTimeDimension,
+ OrderByEntry,
StatsQuery
} from '../stats-query'
import { Filter } from '../dashboard-state'
@@ -21,6 +22,18 @@ export type SharedBreakdownReportProps = {
dimensions: NonTimeDimension[]
metrics: Metric[]
alwaysOnFilters?: ApiFilter[]
+ /**
+ * When true, `percentage` is shown inline inside the Visitors
+ * cell rather than as its own column. Set to false for reports that want
+ * percentage as a separate breakdown column (e.g. custom properties).
+ */
+ bundlePercentageWithVisitors?: boolean
+ /**
+ * Metrics that should be dropped from the rendered columns when every row
+ * (across all loaded pages) has null for that metric. Used by goal breakdowns
+ * to hide revenue columns when the current rows have no revenue data.
+ */
+ hideMetricsIfAllNull?: Metric[]
}
export type ColumnConfiguration = {
@@ -37,6 +50,8 @@ export type ColumnConfiguration = {
width?: string
/** Aligns column content. */
align?: 'left' | 'right'
+ /** Hides the column on mobile (below md breakpoint). */
+ hideOnMobile?: boolean
}
export type GetFilterInfo = (
@@ -213,6 +228,14 @@ export function extractMetricValue(
return { metricIndex, value, comparison }
}
+const CANNOT_ORDER_BY_DIMENSIONS = ['event:goal']
+
+export function dimensionOrderBy(dimensions: NonTimeDimension[]) {
+ return dimensions
+ .filter((dim) => !CANNOT_ORDER_BY_DIMENSIONS.includes(dim))
+ .map((dim): OrderByEntry => [dim, 'asc'])
+}
+
export function addDimensionSearchFilter(
statsQuery: StatsQuery,
dimension: string,
@@ -225,3 +248,20 @@ export function addDimensionSearchFilter(
{ case_sensitive: false }
] as ApiFilter)
}
+
+export function useColumnsHiddenForAllNull(
+ rows: QueryResultRow[] | null | undefined,
+ query: QueryResultQuery | null | undefined,
+ hideMetricsIfAllNull: Metric[] | undefined
+): Set {
+ return useMemo(() => {
+ const hidden = new Set()
+ if (!hideMetricsIfAllNull || !rows?.length || !query) return hidden
+ for (const metric of hideMetricsIfAllNull) {
+ const idx = query.metrics.indexOf(metric)
+ if (idx === -1) continue
+ if (rows.every((row) => row.metrics[idx] == null)) hidden.add(metric)
+ }
+ return hidden
+ }, [rows, query, hideMetricsIfAllNull])
+}
diff --git a/assets/js/dashboard/stats/imported-query-unsupported-warning.js b/assets/js/dashboard/stats/imported-query-unsupported-warning.js
deleted file mode 100644
index f17719bc5094..000000000000
--- a/assets/js/dashboard/stats/imported-query-unsupported-warning.js
+++ /dev/null
@@ -1,40 +0,0 @@
-import React, { useRef, useEffect } from 'react'
-import { ExclamationCircleIcon } from '@heroicons/react/24/outline'
-import FadeIn from '../fade-in'
-import { useDashboardStateContext } from '../dashboard-state-context'
-import { Tooltip } from '../util/tooltip'
-
-export default function ImportedQueryUnsupportedWarning({
- loading,
- skipImportedReason,
- altCondition,
- message
-}) {
- const { dashboardState } = useDashboardStateContext()
- const portalRef = useRef(null)
- const tooltipMessage =
- message || 'Imported data is excluded due to applied filters'
- const show =
- dashboardState &&
- dashboardState.with_imported &&
- skipImportedReason === 'unsupported_query' &&
- dashboardState.period !== 'realtime'
-
- useEffect(() => {
- if (typeof document !== 'undefined') {
- portalRef.current = document.body
- }
- }, [])
-
- if (show || altCondition) {
- return (
-
-
-
-
-
- )
- } else {
- return null
- }
-}
diff --git a/assets/js/dashboard/stats/imported-warning-bubble.tsx b/assets/js/dashboard/stats/imported-warning-bubble.tsx
index 3d231b081ff0..831b4f60c7d5 100644
--- a/assets/js/dashboard/stats/imported-warning-bubble.tsx
+++ b/assets/js/dashboard/stats/imported-warning-bubble.tsx
@@ -5,27 +5,46 @@ import { useBodyPortalRef } from './breakdowns'
import { QueryApiResponse } from '../api'
export default function ImportedWarningBubble({
- queryApiResponse
+ queryApiResponse,
+ message
}: {
queryApiResponse: QueryApiResponse | null
+ message?: string
}) {
- const portalRef = useBodyPortalRef()
-
const importsSkipReason = queryApiResponse?.meta?.imports_skip_reason
const isRealtime = queryApiResponse?.extraContext.isRealtime
const tooltipMessage =
importsSkipReason === 'unsupported_query' && !isRealtime
- ? 'Imported data is excluded due to applied filters'
+ ? (message ?? 'Imported data is excluded due to applied filters')
: null
- if (tooltipMessage) {
- return (
-
-
-
- )
- } else {
- return null
- }
+ return tooltipMessage ? : null
+}
+
+/**
+ * Renders an imported warning bubble for "Funnels" and "Explore" tabs.
+ * Currently, while the funnel and exploration queries silently ignore
+ * everything related to imports, we should still let the user know that
+ * imports are not included in those reports. Therefore, we rely on the
+ * Top Stats response (i.e. the importedDataInView state) to know whether
+ * imported data is in range, and if so, we render the warning bubble.
+ */
+export function FunnelsApiImportedWarningBubble({
+ importedDataInView
+}: {
+ importedDataInView?: boolean
+}) {
+ return importedDataInView ? (
+
+ ) : null
+}
+
+function WarningBubble({ message }: { message: string }) {
+ const portalRef = useBodyPortalRef()
+ return (
+
+
+
+ )
}
diff --git a/assets/js/dashboard/stats/metrics.ts b/assets/js/dashboard/stats/metrics.ts
index 6df7caf94ae4..166f0f11c8a3 100644
--- a/assets/js/dashboard/stats/metrics.ts
+++ b/assets/js/dashboard/stats/metrics.ts
@@ -89,11 +89,31 @@ export const getBreakdownMetricLabel = (
})
case 'event:goal':
return getConversionsBreakdownMetricLabel(metric)
- default:
+ default: {
+ if (dimensions[0]?.startsWith('event:props:')) {
+ return getCustomPropsBreakdownMetricLabel(metric)
+ }
return getDefaultBreakdownMetricLabel(metric, {
hasConversionGoalFilter,
isRealtime
})
+ }
+ }
+}
+
+const getCustomPropsBreakdownMetricLabel = (metric: Metric): string => {
+ switch (metric) {
+ case 'visitors':
+ return 'Visitors'
+ case 'events':
+ return 'Events'
+ case 'percentage':
+ return '%'
+ default:
+ return getDefaultBreakdownMetricLabel(metric, {
+ hasConversionGoalFilter: false,
+ isRealtime: false
+ })
}
}
diff --git a/assets/js/dashboard/stats/modals/conversions.js b/assets/js/dashboard/stats/modals/conversions.js
deleted file mode 100644
index b5e3e523eca1..000000000000
--- a/assets/js/dashboard/stats/modals/conversions.js
+++ /dev/null
@@ -1,90 +0,0 @@
-import React, { useCallback, useState } from 'react'
-
-import Modal from './modal'
-import BreakdownModal from './breakdown-modal-legacy'
-import * as metrics from '../reports/metrics'
-import * as url from '../../util/url'
-import { useSiteContext } from '../../site-context'
-import { addFilter } from '../../dashboard-state'
-
-/*global BUILD_EXTRA*/
-function ConversionsModal() {
- const [showRevenue, setShowRevenue] = useState(false)
- const site = useSiteContext()
-
- const reportInfo = {
- title: 'Goal conversions',
- dimension: 'goal',
- endpoint: url.apiPath(site, '/conversions'),
- dimensionLabel: 'Goal'
- }
-
- const getFilterInfo = useCallback(
- (listItem) => {
- return {
- prefix: reportInfo.dimension,
- filter: ['is', reportInfo.dimension, [listItem.name]]
- }
- },
- [reportInfo.dimension]
- )
-
- const addSearchFilter = useCallback(
- (dashboardState, searchString) => {
- return addFilter(dashboardState, [
- 'contains',
- reportInfo.dimension,
- [searchString],
- { case_sensitive: false }
- ])
- },
- [reportInfo.dimension]
- )
-
- function chooseMetrics() {
- return [
- metrics.createVisitors({ renderLabel: (_dashboardState) => 'Uniques' }),
- metrics.createEvents({ renderLabel: (_dashboardState) => 'Total' }),
- metrics.createConversionRate(),
- showRevenue && metrics.createAverageRevenue(),
- showRevenue && metrics.createTotalRevenue()
- ].filter((metric) => !!metric)
- }
-
- // After a successful API response, we want to scan the rows of the
- // response and update the internal `showRevenue` state, which decides
- // whether revenue metrics are passed into BreakdownModal in `metrics`.
- const afterFetchData = useCallback((res) => {
- setShowRevenue(revenueInResponse(res))
- }, [])
-
- // After fetching the next page, we never want to set `showRevenue` to
- // `false` as revenue metrics might exist in previously loaded data.
- const afterFetchNextPage = useCallback(
- (res) => {
- if (!showRevenue && revenueInResponse(res)) {
- setShowRevenue(true)
- }
- },
- [showRevenue]
- )
-
- function revenueInResponse(apiResponse) {
- return apiResponse.results.some((item) => item.total_revenue)
- }
-
- return (
-
-
-
- )
-}
-
-export default ConversionsModal
diff --git a/assets/js/dashboard/stats/modals/conversions.tsx b/assets/js/dashboard/stats/modals/conversions.tsx
new file mode 100644
index 000000000000..13e8d9f20044
--- /dev/null
+++ b/assets/js/dashboard/stats/modals/conversions.tsx
@@ -0,0 +1,52 @@
+import React from 'react'
+
+import Modal from './modal'
+import {
+ DetailsBreakdown,
+ DimensionCell,
+ DimensionCellProps
+} from './details-breakdown'
+import {
+ BREAKDOWN_REPORTS,
+ BreakdownReportKey
+} from '../reports/reports-config'
+import { getGoalsFilterInfo } from '../behaviours/conversions'
+import { useSiteContext } from '../../site-context'
+
+function ConversionsModal() {
+ const site = useSiteContext()
+
+ const reportConfig = BREAKDOWN_REPORTS[BreakdownReportKey.goals]
+
+ /*global BUILD_EXTRA*/
+ const metrics = reportConfig.getMetrics({
+ isRevenueAvailable: BUILD_EXTRA && site.revenueGoals.length > 0
+ })
+
+ return (
+
+
+
+ )
+}
+
+function GoalsDimensionCell(props: DimensionCellProps) {
+ return (
+
+ )
+}
+
+export default ConversionsModal
diff --git a/assets/js/dashboard/stats/modals/details-breakdown.tsx b/assets/js/dashboard/stats/modals/details-breakdown.tsx
index b0da56466572..a2ebde904a7c 100644
--- a/assets/js/dashboard/stats/modals/details-breakdown.tsx
+++ b/assets/js/dashboard/stats/modals/details-breakdown.tsx
@@ -20,7 +20,7 @@ import {
import { SortDirection } from '../../../types/query-api'
import { Metric, getBreakdownMetricLabel, isSortable } from '../metrics'
import { BreakdownTable } from './breakdown-table'
-import { NonTimeDimension, OrderByEntry } from '../../stats-query'
+import { NonTimeDimension } from '../../stats-query'
import { useSiteContext } from '../../site-context'
import { DrilldownLink } from '../../components/drilldown-link'
import {
@@ -30,7 +30,9 @@ import {
formatDateRangeLabel,
useBodyPortalRef,
extractMetricValue,
- GetFilterInfo
+ GetFilterInfo,
+ useColumnsHiddenForAllNull,
+ dimensionOrderBy
} from '../breakdowns'
import {
QueryResultRow,
@@ -90,7 +92,9 @@ export function DetailsBreakdown({
defaultOrderBy = [] as MetricOrderBy,
DimensionElement,
searchEnabled = true,
- onDataReady
+ onDataReady,
+ bundlePercentageWithVisitors = true,
+ hideMetricsIfAllNull
}: DetailsBreakdownProps) {
const site = useSiteContext()
const { dashboardState } = useDashboardStateContext()
@@ -123,7 +127,7 @@ export function DetailsBreakdown({
dimensions,
order_by: [
...(orderBy.length ? orderBy : storedOrderBy),
- ...dimensions.map((dim): OrderByEntry => [dim, 'asc'])
+ ...dimensionOrderBy(dimensions)
],
alwaysOnFilters
},
@@ -157,6 +161,18 @@ export function DetailsBreakdown({
[dashboardState, dimensions]
)
+ const flattenedRows = useMemo(() => {
+ return apiState.data?.pages.reduce(
+ (acc, p) => acc.concat(p.results),
+ []
+ )
+ }, [apiState.data])
+ const columnsHiddenForAllNull = useColumnsHiddenForAllNull(
+ flattenedRows,
+ query,
+ hideMetricsIfAllNull
+ )
+
const columns: ColumnConfiguration[] | null = useMemo(() => {
if (!query) return null
@@ -164,7 +180,7 @@ export function DetailsBreakdown({
const hasPercentage = query.metrics.includes('percentage')
const isVisitorsWithPercentageCell = (m: Metric) =>
- hasPercentage && m === 'visitors'
+ bundlePercentageWithVisitors && hasPercentage && m === 'visitors'
return [
{
@@ -181,8 +197,10 @@ export function DetailsBreakdown({
align: 'left'
},
...query.metrics
- // Percentage is not its own column — shown inline in the visitors cell
- .filter((metric) => metric !== 'percentage')
+ .filter((metric) => {
+ if (columnsHiddenForAllNull.has(metric)) return false
+ return !(bundlePercentageWithVisitors && metric === 'percentage')
+ })
.map(
(metric): ColumnConfiguration => ({
key: metric,
@@ -234,7 +252,9 @@ export function DetailsBreakdown({
meta,
orderByDictionary,
toggleSortByMetric,
- metricLabelFor
+ metricLabelFor,
+ bundlePercentageWithVisitors,
+ columnsHiddenForAllNull
])
const tableData = apiState.data
diff --git a/assets/js/dashboard/stats/modals/props.js b/assets/js/dashboard/stats/modals/props.js
deleted file mode 100644
index 173e23b53d29..000000000000
--- a/assets/js/dashboard/stats/modals/props.js
+++ /dev/null
@@ -1,87 +0,0 @@
-import React, { useCallback } from 'react'
-import { useParams } from 'react-router-dom'
-
-import Modal from './modal'
-import { addFilter, revenueAvailable } from '../../dashboard-state'
-import { getSpecialGoal } from '../../util/goals'
-import {
- EVENT_PROPS_PREFIX,
- getGoalFilter,
- hasConversionGoalFilter
-} from '../../util/filters'
-import BreakdownModal from './breakdown-modal-legacy'
-import * as metrics from '../reports/metrics'
-import * as url from '../../util/url'
-import { useDashboardStateContext } from '../../dashboard-state-context'
-import { useSiteContext } from '../../site-context'
-
-function PropsModal() {
- const { dashboardState } = useDashboardStateContext()
- const site = useSiteContext()
- const { propKey } = useParams()
-
- /*global BUILD_EXTRA*/
- const showRevenueMetrics =
- BUILD_EXTRA && revenueAvailable(dashboardState, site)
-
- const goalFilter = getGoalFilter(dashboardState)
- const specialGoal = goalFilter ? getSpecialGoal(goalFilter) : null
-
- const reportInfo = {
- title: specialGoal ? specialGoal.title : 'Custom property breakdown',
- dimension: propKey,
- endpoint: url.apiPath(
- site,
- `/custom-prop-values/${url.maybeEncodeRouteParam(propKey)}`
- ),
- dimensionLabel: propKey,
- defaultOrder: ['visitors', 'desc']
- }
-
- const getFilterInfo = useCallback(
- (listItem) => {
- return {
- prefix: `${EVENT_PROPS_PREFIX}${propKey}`,
- filter: ['is', `${EVENT_PROPS_PREFIX}${propKey}`, [listItem.name]]
- }
- },
- [propKey]
- )
-
- const addSearchFilter = useCallback(
- (dashboardState, searchString) => {
- return addFilter(dashboardState, [
- 'contains',
- `${EVENT_PROPS_PREFIX}${propKey}`,
- [searchString],
- { case_sensitive: false }
- ])
- },
- [propKey]
- )
-
- function chooseMetrics() {
- return [
- metrics.createVisitors({ renderLabel: (_dashboardState) => 'Visitors' }),
- metrics.createEvents({ renderLabel: (_dashboardState) => 'Events' }),
- hasConversionGoalFilter(dashboardState) && metrics.createConversionRate(),
- !hasConversionGoalFilter(dashboardState) && metrics.createPercentage(),
- showRevenueMetrics && metrics.createAverageRevenue(),
- showRevenueMetrics && metrics.createTotalRevenue()
- ].filter((metric) => !!metric)
- }
-
- return (
-
-
-
- )
-}
-
-export default PropsModal
diff --git a/assets/js/dashboard/stats/modals/props.tsx b/assets/js/dashboard/stats/modals/props.tsx
new file mode 100644
index 000000000000..c8eec53b961b
--- /dev/null
+++ b/assets/js/dashboard/stats/modals/props.tsx
@@ -0,0 +1,84 @@
+import React, { useCallback } from 'react'
+import { useParams } from 'react-router-dom'
+
+import Modal from './modal'
+import {
+ DetailsBreakdown,
+ DimensionCell,
+ DimensionCellProps
+} from './details-breakdown'
+import { customPropsReportConfig } from '../reports/reports-config'
+import { revenueAvailable } from '../../dashboard-state'
+import { getSpecialGoal } from '../../util/goals'
+import {
+ EVENT_PROPS_PREFIX,
+ getGoalFilter,
+ hasConversionGoalFilter
+} from '../../util/filters'
+import { useDashboardStateContext } from '../../dashboard-state-context'
+import { useSiteContext } from '../../site-context'
+import { QueryResultRow } from '../../api'
+import { NonTimeDimension } from '../../stats-query'
+import { FilterInfo } from '../../components/drilldown-link'
+
+function PropsModal() {
+ const { dashboardState } = useDashboardStateContext()
+ const site = useSiteContext()
+ const { propKey } = useParams<{ propKey: string }>()
+
+ const DimensionElementForProp = useCallback(
+ (props: DimensionCellProps) => (
+
+ ),
+ [propKey]
+ )
+
+ if (!propKey) {
+ return null
+ }
+
+ const goalFilter = getGoalFilter(dashboardState)
+ const specialGoal = goalFilter ? getSpecialGoal(goalFilter) : null
+
+ const reportConfig = customPropsReportConfig(propKey)
+
+ /*global BUILD_EXTRA*/
+ const isRevenueAvailable =
+ BUILD_EXTRA && revenueAvailable(dashboardState, site) && !specialGoal
+
+ const metrics = reportConfig.getMetrics({
+ hasConversionGoalFilter: hasConversionGoalFilter(dashboardState),
+ isRevenueAvailable
+ })
+
+ const title = specialGoal ? specialGoal.title : 'Custom property breakdown'
+
+ return (
+
+
+
+ )
+}
+
+export function makeGetCustomPropFilterInfo(propKey: string) {
+ const filterKey = `${EVENT_PROPS_PREFIX}${propKey}`
+ return (_dimension: NonTimeDimension, row: QueryResultRow): FilterInfo => ({
+ prefix: filterKey,
+ filter: ['is', filterKey, [row.dimensions[0]]]
+ })
+}
+
+export default PropsModal
diff --git a/assets/js/dashboard/stats/reports/index-breakdown.tsx b/assets/js/dashboard/stats/reports/index-breakdown.tsx
index 0d5700becc8a..423db80c7720 100644
--- a/assets/js/dashboard/stats/reports/index-breakdown.tsx
+++ b/assets/js/dashboard/stats/reports/index-breakdown.tsx
@@ -9,7 +9,7 @@ import FlipMove from 'react-flip-move'
import LazyLoader from '../../components/lazy-loader'
import { useDashboardStateContext } from '../../dashboard-state-context'
import { useSiteContext } from '../../site-context'
-import { NonTimeDimension, OrderByEntry } from '../../stats-query'
+import { NonTimeDimension } from '../../stats-query'
import { Metric, getBreakdownMetricLabel } from '../metrics'
import {
ColumnConfiguration,
@@ -19,7 +19,9 @@ import {
useBodyPortalRef,
extractMetricValue,
MetricValueWrapper,
- GetFilterInfo
+ GetFilterInfo,
+ useColumnsHiddenForAllNull,
+ dimensionOrderBy
} from '../breakdowns'
import { DrilldownLink } from '../../components/drilldown-link'
import { QueryResultRow, QueryResultQuery, QueryApiResponse } from '../../api'
@@ -53,6 +55,7 @@ type IndexBreakdownProps = SharedBreakdownReportProps & {
metricColumnWidth?: string
DimensionElement: (props: DimensionCellWithBarProps) => ReactNode
onDataReady?: (data: QueryApiResponse) => void
+ hideMetricsOnMobile?: Metric[]
}
export function IndexBreakdown({
@@ -62,7 +65,10 @@ export function IndexBreakdown({
dimensionLabel,
alwaysOnFilters,
onDataReady,
- metricColumnWidth = DEFAULT_METRIC_COLUMN_WIDTH
+ metricColumnWidth = DEFAULT_METRIC_COLUMN_WIDTH,
+ bundlePercentageWithVisitors = true,
+ hideMetricsIfAllNull,
+ hideMetricsOnMobile
}: IndexBreakdownProps) {
const site = useSiteContext()
const { dashboardState } = useDashboardStateContext()
@@ -75,10 +81,7 @@ export function IndexBreakdown({
reportParams: {
metrics,
dimensions,
- order_by: [
- ['visitors', 'desc'],
- ...dimensions.map((dim): OrderByEntry => [dim, 'asc'])
- ],
+ order_by: [['visitors', 'desc'], ...dimensionOrderBy(dimensions)],
alwaysOnFilters,
pagination: { limit: MAX_ITEMS, offset: 0 }
}
@@ -121,19 +124,28 @@ export function IndexBreakdown({
[dashboardState, dimensions]
)
+ const columnsHiddenForAllNull = useColumnsHiddenForAllNull(
+ apiState.data?.results,
+ query,
+ hideMetricsIfAllNull
+ )
+
const columns = useMemo((): ColumnConfiguration[] | null => {
if (!query || barMetricIndex === null || barMaxValue === null) return null
- // Only render columns for metrics the API actually returned. Also,
- // percentage is not its own column —- it's shown inline in the
- // visitors cell instead.
- const filteredMetrics = query.metrics.filter((m) => m !== 'percentage')
+ // Only render columns for metrics the API actually returned. When
+ // bundlePercentageWithVisitors is on (default), `percentage` is shown
+ // inline in the Visitors cell rather than as its own column.
+ const filteredMetrics = query.metrics.filter((m) => {
+ if (columnsHiddenForAllNull.has(m)) return false
+ return !(bundlePercentageWithVisitors && m === 'percentage')
+ })
const filterDimension = query.dimensions[0] as NonTimeDimension
const hasPercentage = query.metrics.includes('percentage')
const isVisitorsWithPercentageCell = (m: Metric) =>
- hasPercentage && m === 'visitors'
+ bundlePercentageWithVisitors && hasPercentage && m === 'visitors'
return [
{
@@ -155,6 +167,7 @@ export function IndexBreakdown({
(metric): ColumnConfiguration => ({
key: metric,
renderLabel: () => metricLabelFor(metric),
+ hideOnMobile: hideMetricsOnMobile?.includes(metric),
renderCell: (row, isActive) => {
if (isVisitorsWithPercentageCell(metric)) {
return (
@@ -189,7 +202,10 @@ export function IndexBreakdown({
metricLabelFor,
barMaxValue,
query,
- metricColumnWidth
+ metricColumnWidth,
+ bundlePercentageWithVisitors,
+ columnsHiddenForAllNull,
+ hideMetricsOnMobile
])
return (
@@ -490,7 +506,8 @@ export function IndexBreakdownRenderer({
data-testid="report-header"
className={classNames(
col.width ?? 'grow w-full',
- col.align === 'right' ? 'text-right' : 'truncate'
+ col.align === 'right' ? 'text-right' : 'truncate',
+ col.hideOnMobile && 'hidden md:block'
)}
>
{col.renderLabel()}
@@ -528,7 +545,8 @@ export function IndexBreakdownRenderer({
key={col.key}
className={classNames(
col.width ?? 'grow w-full',
- col.align === 'right' ? 'text-right' : 'md:truncate'
+ col.align === 'right' ? 'text-right' : 'md:truncate',
+ col.hideOnMobile && 'hidden md:block'
)}
>
{col.renderCell(row, isActive)}
diff --git a/assets/js/dashboard/stats/reports/reports-config.ts b/assets/js/dashboard/stats/reports/reports-config.ts
index d89ac4d9f077..a35de3788bd8 100644
--- a/assets/js/dashboard/stats/reports/reports-config.ts
+++ b/assets/js/dashboard/stats/reports/reports-config.ts
@@ -2,7 +2,7 @@ import { ApiFilter, NonTimeDimension } from '../../stats-query'
import { Metric } from '../metrics'
export type MetricContext = {
- hasConversionGoalFilter: boolean
+ hasConversionGoalFilter?: boolean
isRealtime?: boolean
isCsv?: boolean
isDetailed?: boolean
@@ -109,7 +109,8 @@ export enum BreakdownReportKey {
'utmTerms' = 'utmTerms',
'countries' = 'countries',
'regions' = 'regions',
- 'cities' = 'cities'
+ 'cities' = 'cities',
+ 'goals' = 'goals'
}
export const BREAKDOWN_REPORTS: Record<
@@ -330,5 +331,49 @@ export const BREAKDOWN_REPORTS: Record<
detailsPath: 'cities',
dimensionLabel: 'City',
alwaysOnFilters: [['is_not', 'visit:city', [0]]]
+ },
+ [BreakdownReportKey.goals]: {
+ dimensions: ['event:goal'],
+ getMetrics: (ctx: MetricContext) => {
+ if (ctx.isRevenueAvailable) {
+ return [
+ 'visitors',
+ 'events',
+ 'conversion_rate',
+ 'total_revenue',
+ 'average_revenue'
+ ]
+ }
+ return ['visitors', 'events', 'conversion_rate']
+ },
+ detailsTitle: 'Goal conversions',
+ detailsPath: 'conversions',
+ dimensionLabel: 'Goal'
+ }
+}
+
+export function customPropsReportConfig(
+ propKey: string
+): BreakdownReportConfig {
+ return {
+ dimensions: [`event:props:${propKey}` as NonTimeDimension],
+ getMetrics: (ctx: MetricContext) => {
+ if (ctx.hasConversionGoalFilter && ctx.isRevenueAvailable) {
+ return [
+ 'visitors',
+ 'events',
+ 'conversion_rate',
+ 'total_revenue',
+ 'average_revenue'
+ ]
+ }
+ if (ctx.hasConversionGoalFilter) {
+ return ['visitors', 'events', 'conversion_rate']
+ }
+ return ['visitors', 'events', 'percentage']
+ },
+ detailsTitle: 'Custom property breakdown',
+ detailsPath: `custom-prop-values/${propKey}`,
+ dimensionLabel: propKey
}
}
diff --git a/e2e/tests/dashboard/behaviours.spec.ts b/e2e/tests/dashboard/behaviours.spec.ts
index 7413f52e2ee7..6d246c82cbdb 100644
--- a/e2e/tests/dashboard/behaviours.spec.ts
+++ b/e2e/tests/dashboard/behaviours.spec.ts
@@ -643,8 +643,8 @@ test('goals breakdown', async ({ page, request }) => {
/Uniques/,
/Total/,
/CR/,
- /Average/,
- /Revenue/
+ /Revenue/,
+ /Average/
])
await expectRows(modal(page), [