diff --git a/assets/js/dashboard/stats-query.ts b/assets/js/dashboard/stats-query.ts index 234a593d8ba9..77a27af2df0d 100644 --- a/assets/js/dashboard/stats-query.ts +++ b/assets/js/dashboard/stats-query.ts @@ -19,6 +19,7 @@ export type ApiFilter = export type NonTimeDimension = | apiTypes.SimpleFilterDimensions | apiTypes.CustomPropertyFilterDimensions + | apiTypes.GoalDimension export type TimeDimension = apiTypes.TimeDimensions | 'time:minute' diff --git a/assets/js/dashboard/stats/behaviours/conversions.js b/assets/js/dashboard/stats/behaviours/conversions.js deleted file mode 100644 index a99841f99c4d..000000000000 --- a/assets/js/dashboard/stats/behaviours/conversions.js +++ /dev/null @@ -1,58 +0,0 @@ -import React from 'react' -import * as api from '../../api' -import * as url from '../../util/url' - -import * as metrics from '../reports/metrics' -import ListReport from '../reports/list-legacy' -import { useSiteContext } from '../../site-context' -import { useDashboardStateContext } from '../../dashboard-state-context' - -export default function Conversions({ afterFetchData, onGoalFilterClick }) { - const site = useSiteContext() - const { dashboardState } = useDashboardStateContext() - - function fetchConversions() { - return api.get(url.apiPath(site, '/conversions'), dashboardState, { - limit: 9 - }) - } - - function getFilterInfo(listItem) { - return { - prefix: 'goal', - filter: ['is', 'goal', [listItem.name]] - } - } - - function chooseMetrics() { - return [ - metrics.createVisitors({ - renderLabel: (_dashboardState) => 'Uniques', - meta: { plot: true } - }), - metrics.createEvents({ - renderLabel: (_dashboardState) => 'Total', - meta: { hiddenOnMobile: true } - }), - metrics.createConversionRate(), - BUILD_EXTRA && - metrics.createTotalRevenue({ meta: { hiddenOnMobile: true } }), - BUILD_EXTRA && - metrics.createAverageRevenue({ meta: { hiddenOnMobile: true } }) - ].filter((metric) => !!metric) - } - - /*global BUILD_EXTRA*/ - return ( - - ) -} diff --git a/assets/js/dashboard/stats/behaviours/conversions.tsx b/assets/js/dashboard/stats/behaviours/conversions.tsx new file mode 100644 index 000000000000..2cc5b7181b02 --- /dev/null +++ b/assets/js/dashboard/stats/behaviours/conversions.tsx @@ -0,0 +1,79 @@ +import React, { ReactNode, useCallback } from 'react' + +import { + DimensionCellWithBar, + DimensionCellWithBarProps, + IndexBreakdown +} from '../reports/index-breakdown' +import { + BREAKDOWN_REPORTS, + BreakdownReportKey +} from '../reports/reports-config' +import { QueryApiResponse, QueryResultRow } from '../../api' +import { NonTimeDimension } from '../../stats-query' +import { FilterInfo } from '../../components/drilldown-link' +import { + BEHAVIOURS_BAR_COLOR, + BEHAVIOURS_METRIC_COLUMN_WIDTH, + BEHAVIOURS_METRICS_HIDDEN_ON_MOBILE +} from '.' +import { useSiteContext } from '../../site-context' + +type ConversionsProps = { + onDataReady?: (data: QueryApiResponse) => void + onGoalFilterClick?: (goalName: string) => void +} + +export default function Conversions({ + onDataReady, + onGoalFilterClick +}: ConversionsProps): ReactNode { + const site = useSiteContext() + const reportConfig = BREAKDOWN_REPORTS[BreakdownReportKey.goals] + + /*global BUILD_EXTRA*/ + const metrics = reportConfig.getMetrics({ + isRevenueAvailable: BUILD_EXTRA && site.revenueGoals.length > 0 + }) + + const DimensionElement = useCallback( + (props: DimensionCellWithBarProps) => { + const goalName = props.row.dimensions[0] + return ( + onGoalFilterClick && onGoalFilterClick(goalName)} + /> + ) + }, + [onGoalFilterClick] + ) + + return ( + + ) +} + +export function getGoalsFilterInfo( + _dimension: NonTimeDimension, + row: QueryResultRow +): FilterInfo { + const goalName = row.dimensions[0] + return { + prefix: 'goal', + filter: ['is', 'goal', [goalName]] + } +} diff --git a/assets/js/dashboard/stats/behaviours/index.js b/assets/js/dashboard/stats/behaviours/index.tsx similarity index 73% rename from assets/js/dashboard/stats/behaviours/index.js rename to assets/js/dashboard/stats/behaviours/index.tsx index c611b3bb35b7..57ad01f83604 100644 --- a/assets/js/dashboard/stats/behaviours/index.js +++ b/assets/js/dashboard/stats/behaviours/index.tsx @@ -1,6 +1,16 @@ -import React, { useState, useEffect, useCallback } from 'react' +import React, { + ComponentType, + Dispatch, + ReactNode, + SetStateAction, + useState, + useEffect, + useCallback +} from 'react' import * as storage from '../../util/storage' -import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning' +import ImportedWarningBubble, { + FunnelsApiImportedWarningBubble +} from '../imported-warning-bubble' import Properties from './props' import { FeatureSetupNotice } from '../../components/feature-setup-notice' import { @@ -13,7 +23,7 @@ import { getGoalFilter, FILTER_OPERATIONS } from '../../util/filters' -import { useSiteContext } from '../../site-context' +import { PlausibleSite, useSiteContext } from '../../site-context' import { useDashboardStateContext } from '../../dashboard-state-context' import { useUserContext } from '../../user-context' import { DropdownTabButton, TabButton, TabWrapper } from '../../components/tabs' @@ -33,11 +43,25 @@ import { } from './modes-context' import { SpecialGoalPropBreakdown } from './special-goal-prop-breakdown' import Conversions from './conversions' -import { getSpecialGoal, isPageViewGoal, isSpecialGoal } from '../../util/goals' +import { getSpecialGoal, isSpecialGoal } from '../../util/goals' +import { DashboardState, Filter } from '../../dashboard-state' +import { QueryApiResponse } from '../../api' +import { DEFAULT_METRIC_COLUMN_WIDTH } from '../reports/index-breakdown' +import { Metric } from '../metrics' + +export const BEHAVIOURS_BAR_COLOR = 'bg-red-50 group-hover/row:bg-red-100' +export const BEHAVIOURS_METRIC_COLUMN_WIDTH = `${DEFAULT_METRIC_COLUMN_WIDTH} md:w-22 md:min-w-22` +export const BEHAVIOURS_METRICS_HIDDEN_ON_MOBILE: Metric[] = [ + 'events', + 'total_revenue', + 'average_revenue' +] /*global BUILD_EXTRA*/ /*global require*/ -function maybeRequireFunnels() { +function maybeRequireFunnels(): { + default: ComponentType<{ funnelName: string }> | null +} { if (BUILD_EXTRA) { // eslint-disable-next-line @typescript-eslint/no-require-imports return require('../../extra/funnel') @@ -46,7 +70,7 @@ function maybeRequireFunnels() { } } -function maybeRequireExploration() { +function maybeRequireExploration(): { default: ComponentType | null } { if (BUILD_EXTRA) { // eslint-disable-next-line @typescript-eslint/no-require-imports return require('../../extra/exploration') @@ -58,10 +82,10 @@ function maybeRequireExploration() { const Funnel = maybeRequireFunnels().default const FunnelExploration = maybeRequireExploration().default -function singleGoalFilterApplied(dashboardState) { +function singleGoalFilterApplied(dashboardState: DashboardState): boolean { const goalFilter = getGoalFilter(dashboardState) if (goalFilter) { - const [operation, _filterKey, clauses] = goalFilter + const [operation, _filterKey, clauses] = goalFilter as Filter return operation === FILTER_OPERATIONS.is && clauses.length === 1 } else { return false @@ -69,25 +93,37 @@ function singleGoalFilterApplied(dashboardState) { } const STORAGE_KEYS = { - getForTab: ({ site }) => + getForTab: ({ site }: { site: PlausibleSite }): string => storage.getDomainScopedStorageKey('behavioursTab', site.domain), - getForFunnel: ({ site }) => + getForFunnel: ({ site }: { site: PlausibleSite }): string => storage.getDomainScopedStorageKey('behavioursTabFunnel', site.domain), - getForPropKey: ({ site }) => + getForPropKey: ({ site }: { site: PlausibleSite }): string => storage.getDomainScopedStorageKey('prop_key', site.domain), - getForPropKeyForGoal: ({ goalName, site }) => { - return storage.getDomainScopedStorageKey( - `${goalName}__prop_key)`, - site.domain - ) - } + getForPropKeyForGoal: ({ + goalName, + site + }: { + goalName: string + site: PlausibleSite + }): string => + storage.getDomainScopedStorageKey(`${goalName}__prop_key)`, site.domain) } -function getPropKeyFromStorage({ site, dashboardState }) { +function getPropKeyFromStorage({ + site, + dashboardState +}: { + site: PlausibleSite + dashboardState: DashboardState +}): string | null { if (singleGoalFilterApplied(dashboardState)) { - const [_operation, _dimension, [goalName]] = getGoalFilter(dashboardState) + const goalFilter = getGoalFilter(dashboardState) as Filter + const [_operation, _dimension, [goalName]] = goalFilter const storedForGoal = storage.getItem( - STORAGE_KEYS.getForPropKeyForGoal({ goalName, site }) + STORAGE_KEYS.getForPropKeyForGoal({ + goalName: String(goalName), + site + }) ) if (storedForGoal) { return storedForGoal @@ -97,11 +133,20 @@ function getPropKeyFromStorage({ site, dashboardState }) { return storage.getItem(STORAGE_KEYS.getForPropKey({ site })) } -function storePropKey({ site, propKey, dashboardState }) { +function storePropKey({ + site, + propKey, + dashboardState +}: { + site: PlausibleSite + propKey: string + dashboardState: DashboardState +}): void { if (singleGoalFilterApplied(dashboardState)) { - const [_operation, _dimension, [goalName]] = getGoalFilter(dashboardState) + const goalFilter = getGoalFilter(dashboardState) as Filter + const [_operation, _dimension, [goalName]] = goalFilter storage.setItem( - STORAGE_KEYS.getForPropKeyForGoal({ goalName, site }), + STORAGE_KEYS.getForPropKeyForGoal({ goalName: String(goalName), site }), propKey ) } else { @@ -109,20 +154,35 @@ function storePropKey({ site, propKey, dashboardState }) { } } -function getDefaultSelectedFunnel({ site }) { +function getDefaultSelectedFunnel({ + site +}: { + site: PlausibleSite +}): string | undefined { const stored = storage.getItem(STORAGE_KEYS.getForFunnel({ site })) const storedExists = stored && site.funnels.some((f) => f.name === stored) if (storedExists) { - return stored + return stored as string } else if (site.funnels.length > 0) { const firstAvailable = site.funnels[0].name storage.setItem(STORAGE_KEYS.getForFunnel({ site }), firstAvailable) return firstAvailable } + return undefined +} + +type BehavioursProps = { + importedDataInView?: boolean + setMode: Dispatch> + mode: Mode } -function Behaviours({ importedDataInView, setMode, mode }) { +function Behaviours({ + importedDataInView, + setMode, + mode +}: BehavioursProps): ReactNode { const { dashboardState } = useDashboardStateContext() const goalFilter = getGoalFilter(dashboardState) const specialGoal = goalFilter ? getSpecialGoal(goalFilter) : null @@ -132,33 +192,37 @@ function Behaviours({ importedDataInView, setMode, mode }) { const adminAccess = ['owner', 'admin', 'editor', 'super_admin'].includes( user.role ) - const [loading, setLoading] = useState(true) - const [selectedFunnel, setSelectedFunnel] = useState( + const [selectedFunnel, setSelectedFunnel] = useState( getDefaultSelectedFunnel({ site }) ) const initialSelectedPropKey = getPropKeyFromStorage({ site, dashboardState }) || null - const [selectedPropKey, setSelectedPropKey] = useState(initialSelectedPropKey) - const [propertyKeys, setPropertyKeys] = useState( + const [selectedPropKey, setSelectedPropKey] = useState( + initialSelectedPropKey + ) + const [propertyKeys, setPropertyKeys] = useState( selectedPropKey !== null ? [selectedPropKey] : [] ) const [showingPropsForGoalFilter, setShowingPropsForGoalFilter] = useState(false) - const [skipImportedReason, setSkipImportedReason] = useState(null) - const [moreLinkState, setMoreLinkState] = useState(MoreLinkState.LOADING) + const [currentQueryApiResponse, setCurrentQueryApiResponse] = + useState(null) + + const moreLinkState = currentQueryApiResponse + ? currentQueryApiResponse.results.length > 0 + ? MoreLinkState.READY + : MoreLinkState.HIDDEN + : MoreLinkState.LOADING const onGoalFilterClick = useCallback( - (e) => { - const goalName = e.target.innerHTML - const isSpecial = isSpecialGoal(goalName) - const isPageview = isPageViewGoal(goalName) + (goalName: string) => { + const isSpecialGoalClick = isSpecialGoal(goalName) if ( - !isSpecial && - !isPageview && + !isSpecialGoalClick && enabledModes.includes(Mode.PROPS) && site.hasProps ) { @@ -182,16 +246,13 @@ function Behaviours({ importedDataInView, setMode, mode }) { // eslint-disable-next-line react-hooks/exhaustive-deps }, [hasConversionGoalFilter(dashboardState)]) - useEffect(() => setLoading(true), [dashboardState, mode]) useEffect(() => { - if (mode === Mode.PROPS && !selectedPropKey) { - setMoreLinkState(MoreLinkState.HIDDEN) - } else { - setMoreLinkState(MoreLinkState.LOADING) + if ([Mode.FUNNELS, Mode.EXPLORATION].includes(mode)) { + setCurrentQueryApiResponse(null) } - }, [dashboardState, mode, selectedPropKey]) + }, [dashboardState, mode]) - function setFunnelFactory(selectedFunnelName) { + function setFunnelFactory(selectedFunnelName: string): () => void { return () => { storage.setItem(STORAGE_KEYS.getForTab({ site }), Mode.FUNNELS) storage.setItem(STORAGE_KEYS.getForFunnel({ site }), selectedFunnelName) @@ -200,7 +261,7 @@ function Behaviours({ importedDataInView, setMode, mode }) { } } - function setPropKeyFactory(selectedPropKeyName) { + function setPropKeyFactory(selectedPropKeyName: string): () => void { return () => { storage.setItem(STORAGE_KEYS.getForTab({ site }), Mode.PROPS) storePropKey({ site, propKey: selectedPropKeyName, dashboardState }) @@ -221,7 +282,7 @@ function Behaviours({ importedDataInView, setMode, mode }) { .get(url.apiPath(site, '/suggestions/prop_key'), dashboardState, { q: '' }) - .then((propKeys) => { + .then((propKeys: Array<{ value: string }>) => { const propKeyValues = propKeys.map((entry) => entry.value) setPropertyKeys(propKeyValues) if (propKeyValues.length > 0) { @@ -239,7 +300,7 @@ function Behaviours({ importedDataInView, setMode, mode }) { setSelectedPropKey(null) } }) - .catch((error) => { + .catch((error: unknown) => { console.error('Failed to fetch property keys:', error) setPropertyKeys([]) setSelectedPropKey(null) @@ -251,37 +312,27 @@ function Behaviours({ importedDataInView, setMode, mode }) { } }, [site, dashboardState, enabledModes]) - function setTabFactory(tab) { + function setTabFactory(tab: Mode): () => void { return () => { storage.setItem(STORAGE_KEYS.getForTab({ site }), tab) setMode(tab) } } - function afterFetchData(apiResponse) { - setLoading(false) - setSkipImportedReason(apiResponse.skip_imported_reason) - if (apiResponse.results && apiResponse.results.length > 0) { - setMoreLinkState(MoreLinkState.READY) - } else { - setMoreLinkState(MoreLinkState.HIDDEN) - } - } - - function renderConversions() { + function renderConversions(): ReactNode { if (site.hasGoals) { if (specialGoal) { return ( ) } else { return ( ) } @@ -305,7 +356,7 @@ function Behaviours({ importedDataInView, setMode, mode }) { } } - function renderExploration() { + function renderExploration(): ReactNode { if (FunnelExploration === null) { return featureUnavailable() } @@ -334,7 +385,7 @@ function Behaviours({ importedDataInView, setMode, mode }) { ) } - function renderFunnels() { + function renderFunnels(): ReactNode { if (Funnel === null) { return featureUnavailable() } else if (Funnel && selectedFunnel && site.funnelsAvailable) { @@ -370,10 +421,13 @@ function Behaviours({ importedDataInView, setMode, mode }) { } } - function renderProps() { + function renderProps(): ReactNode { if (site.hasProps && site.propsAvailable) { return ( - + ) } else if (adminAccess) { let callToAction @@ -406,7 +460,7 @@ function Behaviours({ importedDataInView, setMode, mode }) { } } - function noDataYet() { + function noDataYet(): ReactNode { return (
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), [