mirror of
https://github.com/plausible/analytics.git
synced 2024-11-23 03:04:43 +03:00
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:
parent
e59cda5d93
commit
9e9555f719
@ -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() {
|
||||
|
@ -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},
|
||||
|
@ -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}/>
|
||||
}
|
||||
}
|
||||
|
@ -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} />
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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()
|
||||
}
|
||||
|
||||
|
@ -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()
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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 (
|
||||
|
@ -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;
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user