Improvements to the Properties feature (#3261)

* automatic CONVERSIONS -> PROPS navigation

In the Behaviours section, we'll navigate from Goal Conversions > Properties,
when a goal filter is applied by clicking on an entry in the Goal Conversions
list. This navigation does not happen for pageview goals or special goals (e.g.
'Outbound Link: Click', 'File Download', etc.)

The Behaviours component will remember that the navigation happened in order
to return the user to the previous view when the goal filter is removed.

* ability to store prop_keys per goal in localStorage

* pass the onclick handler to the Details view too

This makes sure that when a  listItem is clicked in the Details view,
the same behaviour (such as changing the Locations tab from 'Countries'
to 'Regions') is called.

* bugfix - always escape pipes when applying goal/prop filters

* disable propkey selection when prop filter applied

* make the propkey selector take up all the width
This commit is contained in:
RobertJoonas 2023-08-14 18:35:40 +01:00 committed by GitHub
parent e59cda5d93
commit 9e9555f719
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 80 additions and 21 deletions

View File

@ -166,7 +166,7 @@ export default function PlausibleCombobox(props) {
const containerClass = classNames('relative w-full', {
[props.className]: !!props.className,
'opacity-20 cursor-default pointer-events-none': props.isDisabled
'opacity-30 cursor-default pointer-events-none': props.isDisabled
})
function renderSingleOptionContent() {

View File

@ -22,6 +22,7 @@ export default function Conversions(props) {
fetchData={fetchConversions}
getFilterFor={getFilterFor}
keyLabel="Goal"
onClick={props.onGoalFilterClick}
metrics={[
{name: 'visitors', label: "Uniques", plot: true},
{name: 'events', label: "Total", hiddenOnMobile: true},

View File

@ -5,7 +5,7 @@ import { CR_METRIC } from "../reports/metrics"
import * as url from "../../util/url"
import * as api from "../../api"
const SPECIAL_GOALS = {
export const SPECIAL_GOALS = {
'404': {title: '404 Pages', prop: 'path'},
'Outbound Link: Click': {title: 'Outbound Links', prop: 'url'},
'Cloaked Link: Click': {title: 'Cloaked Links', prop: 'url'},
@ -60,6 +60,6 @@ export default function GoalConversions(props) {
if (SPECIAL_GOALS[query.filters.goal]) {
return <SpecialPropBreakdown site={site} query={props.query} />
} else {
return <Conversions site={site} query={props.query} />
return <Conversions site={site} query={props.query} onGoalFilterClick={props.onGoalFilterClick}/>
}
}

View File

@ -1,4 +1,4 @@
import React, { Fragment, useState, useEffect } from 'react'
import React, { Fragment, useState, useEffect, useCallback } from 'react'
import { Menu, Transition } from '@headlessui/react'
import { ChevronDownIcon } from '@heroicons/react/20/solid'
import classNames from 'classnames'
@ -9,6 +9,7 @@ import DeprecatedConversions from './deprecated-conversions'
import Properties from './props'
import Funnel from './funnel'
import { FeatureSetupNotice } from '../../components/notice'
import { SPECIAL_GOALS } from './goal-conversions'
const ACTIVE_CLASS = 'inline-block h-5 text-indigo-700 dark:text-indigo-500 font-bold active-prop-heading truncate text-left'
const DEFAULT_CLASS = 'hover:text-indigo-600 cursor-pointer truncate text-left'
@ -33,6 +34,27 @@ export default function Behaviours(props) {
const [funnelNames, _setFunnelNames] = useState(site.funnels.map(({ name }) => name))
const [selectedFunnel, setSelectedFunnel] = useState(storage.getItem(funnelKey))
const [showingPropsForGoalFilter, setShowingPropsForGoalFilter] = useState(false)
const onGoalFilterClick = useCallback((e) => {
const goalName = e.target.innerHTML
const isSpecialGoal = Object.keys(SPECIAL_GOALS).includes(goalName)
const isPageviewGoal = goalName.startsWith('Visit ')
if (!isSpecialGoal && !isPageviewGoal && enabledModes.includes(PROPS) && site.hasProps) {
setShowingPropsForGoalFilter(true)
setMode(PROPS)
}
}, [])
useEffect(() => {
const justRemovedGoalFilter = !query.filters.goal
if (mode === PROPS && justRemovedGoalFilter && showingPropsForGoalFilter) {
setShowingPropsForGoalFilter(false)
setMode(CONVERSIONS)
}
}, [!!query.filters.goal])
useEffect(() => {
setMode(defaultMode())
@ -126,7 +148,7 @@ export default function Behaviours(props) {
function renderConversions() {
if (site.hasGoals) {
if (site.flags.props) {
return <GoalConversions site={site} query={query} />
return <GoalConversions site={site} query={query} onGoalFilterClick={onGoalFilterClick} />
} else {
return <DeprecatedConversions site={site} query={query} />
}

View File

@ -1,20 +1,49 @@
import React, { useCallback, useState } from "react";
import React, { useCallback, useState, useEffect } from "react";
import ListReport from "../reports/list";
import Combobox from '../../components/combobox'
import * as api from '../../api'
import * as url from '../../util/url'
import { CR_METRIC, PERCENTAGE_METRIC } from "../reports/metrics";
import * as storage from "../../util/storage";
import { parsePrefix, escapeFilterValue } from "../../util/filters"
export default function Properties(props) {
const { site, query } = props
const propKeyStorageName = `prop_key__${site.domain}`
const [propKey, setPropKey] = useState(defaultPropKey())
const propKeyStorageNameForGoal = `${query.filters.goal}__prop_key__${site.domain}`
const [propKey, setPropKey] = useState(choosePropKey())
function defaultPropKey() {
const stored = storage.getItem(propKeyStorageName)
if (stored) { return stored }
return null
useEffect(() => {
setPropKey(choosePropKey())
}, [query.filters.goal, query.filters.props])
function singleGoalFilterApplied() {
const goalFilter = query.filters.goal
if (goalFilter) {
const { type, values } = parsePrefix(goalFilter)
return type === 'is' && values.length === 1
} else {
return false
}
}
function choosePropKey() {
if (query.filters.props) {
return Object.keys(query.filters.props)[0]
} else {
return getPropKeyFromStorage()
}
}
function getPropKeyFromStorage() {
if (singleGoalFilterApplied()) {
const storedForGoal = storage.getItem(propKeyStorageNameForGoal)
if (storedForGoal) { return storedForGoal }
}
return storage.getItem(propKeyStorageName)
}
function fetchProps() {
@ -30,8 +59,12 @@ export default function Properties(props) {
function onPropKeySelect() {
return (selectedOptions) => {
const newPropKey = selectedOptions.length === 0 ? null : selectedOptions[0].value
if (newPropKey) { storage.setItem(propKeyStorageName, newPropKey) }
if (newPropKey) {
const storageName = singleGoalFilterApplied() ? propKeyStorageNameForGoal : propKeyStorageName
storage.setItem(storageName, newPropKey)
}
setPropKey(newPropKey)
}
}
@ -58,14 +91,14 @@ export default function Properties(props) {
)
}
const getFilterFor = (listItem) => { return {'props': JSON.stringify({[propKey]: listItem['name']})} }
const getFilterFor = (listItem) => { return {'props': JSON.stringify({[propKey]: escapeFilterValue(listItem.name)})} }
const comboboxValues = propKey ? [{value: propKey, label: propKey}] : []
const boxClass = 'pl-2 pr-8 py-1 bg-transparent dark:text-gray-300 rounded-md shadow-sm border border-gray-300 dark:border-gray-500'
return (
<div className="w-full mt-4">
<div className="w-56">
<Combobox boxClass={boxClass} fetchOptions={fetchPropKeyOptions()} singleOption={true} values={comboboxValues} onSelect={onPropKeySelect()} placeholder={'Select a property'} />
<div>
<Combobox isDisabled={!!query.filters.props} boxClass={boxClass} fetchOptions={fetchPropKeyOptions()} singleOption={true} values={comboboxValues} onSelect={onPropKeySelect()} placeholder={'Select a property'} />
</div>
{ propKey && renderBreakdown() }
</div>

View File

@ -8,6 +8,7 @@ import * as api from '../../api'
import * as url from "../../util/url";
import numberFormatter from '../../util/number-formatter'
import {parseQuery} from '../../query'
import { escapeFilterValue } from '../../util/filters'
function ConversionsModal(props) {
const site = props.site
@ -49,7 +50,7 @@ function ConversionsModal(props) {
function filterSearchLink(listItem) {
const searchParams = new URLSearchParams(window.location.search)
searchParams.set('goal', listItem.name)
searchParams.set('goal', escapeFilterValue(listItem.name))
return searchParams.toString()
}

View File

@ -9,6 +9,7 @@ import * as url from "../../util/url";
import numberFormatter from '../../util/number-formatter'
import { parseQuery } from '../../query'
import { specialTitleWhenGoalFilter } from "../behaviours/goal-conversions";
import { escapeFilterValue } from "../../util/filters"
function PropsModal(props) {
const site = props.site
@ -51,7 +52,7 @@ function PropsModal(props) {
function filterSearchLink(listItem) {
const searchParams = new URLSearchParams(window.location.search)
searchParams.set('props', JSON.stringify({ [propKey]: listItem['name'] }))
searchParams.set('props', JSON.stringify({ [propKey]: escapeFilterValue(listItem.name) }))
return searchParams.toString()
}

View File

@ -20,7 +20,7 @@ function detailsIcon() {
)
}
export default function MoreLink({url, site, list, endpoint, className}) {
export default function MoreLink({url, site, list, endpoint, className, onClick}) {
if (list.length > 0) {
return (
<div className={`w-full text-center ${className ? className : ''}`}>
@ -28,6 +28,7 @@ export default function MoreLink({url, site, list, endpoint, className}) {
to={url || `/${encodeURIComponent(site.domain)}/${endpoint}${window.location.search}`}
// eslint-disable-next-line max-len
className="leading-snug font-bold text-sm text-gray-500 dark:text-gray-400 hover:text-red-500 dark:hover:text-red-400 transition tracking-wide"
onClick={onClick}
>
{ detailsIcon() }
DETAILS

View File

@ -307,7 +307,7 @@ export default function ListReport(props) {
const hideDetails = props.maybeHideDetails && !moreResultsAvailable
const showDetails = props.detailsLink && !state.loading && !hideDetails
return showDetails && <MoreLink className={'mt-2'} url={props.detailsLink} list={state.list} />
return showDetails && <MoreLink className={'mt-2'} url={props.detailsLink} list={state.list} onClick={props.onClick}/>
}
return (

View File

@ -51,7 +51,7 @@ export function toFilterQuery(type, clauses) {
return prefix + result;
}
function parsePrefix(rawValue) {
export function parsePrefix(rawValue) {
const type = Object.keys(FILTER_PREFIXES)
.find(type => FILTER_PREFIXES[type] === rawValue[0]) || FILTER_TYPES.is;