Conversions listreport (#3215)

* rename conversions.js to deprecated-conversions.js

* add a new Conversions component and switch by props FF

* add a Details view to the new Conversions component

* allow querying conversions with pagination

...and keep the current behaviour for DeprecatedConversions, always returning
page=1 and limit=100

* hide some columns on mobile

* prevent ListReport first column header overflow
This commit is contained in:
RobertJoonas 2023-08-01 19:33:53 +01:00 committed by GitHub
parent 8ac166b447
commit 232bdd34a1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 278 additions and 112 deletions

View File

@ -10,6 +10,7 @@ import EntryPagesModal from './stats/modals/entry-pages'
import ExitPagesModal from './stats/modals/exit-pages'
import ModalTable from './stats/modals/table'
import PropsModal from './stats/modals/props'
import ConversionsModal from './stats/modals/conversions'
import FilterModal from './stats/modals/filter-modal'
import * as url from './util/url';
@ -62,6 +63,9 @@ export default function Router({site, loggedIn, currentUserRole}) {
<Route path="/:domain/custom-prop-values/:prop_key">
<PropsModal site={site}/>
</Route>
<Route path="/:domain/conversions">
<ConversionsModal site={site}/>
</Route>
<Route path={["/:domain/filter/:field"]}>
<FilterModal site={site} />
</Route>

View File

@ -1,119 +1,37 @@
import React from 'react';
import { Link } from 'react-router-dom'
import FlipMove from 'react-flip-move'
import Bar from '../bar'
import PropBreakdown from './prop-breakdown'
import numberFormatter from '../../util/number-formatter'
import * as api from '../../api'
import * as url from '../../util/url'
import { escapeFilterValue } from '../../util/filters'
import LazyLoader from '../../components/lazy-loader'
import Money from './money'
export default class Conversions extends React.Component {
constructor(props) {
super(props)
this.htmlNode = React.createRef()
this.state = { loading: true, }
this.onVisible = this.onVisible.bind(this)
this.fetchConversions = this.fetchConversions.bind(this)
import { CR_METRIC } from '../reports/metrics';
import ListReport from '../reports/list';
export default function Conversions(props) {
const {site, query} = props
function fetchConversions() {
return api.get(url.apiPath(site, '/conversions'), query, {limit: 20})
}
componentWillUnmount() {
document.removeEventListener('tick', this.fetchConversions)
function getFilterFor(listItem) {
return {goal: escapeFilterValue(listItem.name)}
}
onVisible() {
this.fetchConversions()
if (this.props.query.period === 'realtime') {
document.addEventListener('tick', this.fetchConversions)
}
}
componentDidUpdate(prevProps) {
if (this.props.query !== prevProps.query) {
const height = this.htmlNode.current.offsetHeight
this.setState({ loading: true, goals: null, prevHeight: height })
this.fetchConversions()
}
}
fetchConversions() {
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/conversions`, this.props.query)
.then((res) => this.setState({ loading: false, goals: res, prevHeight: null }))
}
renderGoal(goal, renderRevenueColumn) {
const renderProps = this.props.query.filters['goal'] == goal.name && goal.prop_names
return (
<div className="my-2 text-sm" key={goal.name}>
<div className="flex items-center justify-between my-2">
<span className="flex-1">
<Bar
count={goal.visitors}
all={this.state.goals}
bg="bg-red-50 dark:bg-gray-500 dark:bg-opacity-15"
plot="visitors"
>
<Link to={url.setQuery('goal', escapeFilterValue(goal.name))} className="block px-2 py-1.5 hover:underline relative z-9 break-all lg:truncate dark:text-gray-200">{goal.name}</Link>
</Bar>
</span>
<div className="dark:text-gray-200">
<span className="inline-block w-20 font-medium text-right">{numberFormatter(goal.visitors)}</span>
<span className="hidden md:inline-block md:w-20 font-medium text-right">{numberFormatter(goal.events)}</span>
<span className="inline-block w-20 font-medium text-right">{goal.conversion_rate}%</span>
{renderRevenueColumn && <span className="hidden md:inline-block md:w-20 font-medium text-right"><Money formatted={goal.total_revenue} /></span>}
{renderRevenueColumn && <span className="hidden md:inline-block md:w-20 font-medium text-right"><Money formatted={goal.average_revenue} /></span>}
</div>
</div>
{ renderProps && <PropBreakdown site={this.props.site} query={this.props.query} goal={goal} renderRevenueColumn={renderRevenueColumn } /> }
</div>
)
}
renderInner() {
if (this.state.loading) {
return <div className="mx-auto my-2 loading"><div></div></div>
} else if (this.state.goals) {
const hasRevenue = this.state.goals.some((goal) => goal.total_revenue)
return (
<React.Fragment>
<div className="flex items-center justify-between mt-3 mb-2 text-xs font-bold tracking-wide text-gray-500 dark:text-gray-400">
<span>Goal</span>
<div className="text-right">
<span className="inline-block w-20">Uniques</span>
<span className="hidden md:inline-block md:w-20">Total</span>
<span className="inline-block w-20">CR</span>
{hasRevenue && <span className="hidden md:inline-block md:w-20">Revenue</span>}
{hasRevenue && <span className="hidden md:inline-block md:w-20">Average</span>}
</div>
</div>
<FlipMove>
{ this.state.goals.map((goal) => this.renderGoal.bind(this)(goal, hasRevenue) ) }
</FlipMove>
</React.Fragment>
)
}
}
renderConversions() {
return (
<div ref={this.htmlNode} style={{ minHeight: '132px', height: this.state.prevHeight ?? 'auto' }} >
<LazyLoader onVisible={this.onVisible}>
{this.renderInner()}
</LazyLoader>
</div>
)
}
render() {
return (
<div>
{this.renderConversions()}
</div>
)
}
return (
<ListReport
fetchData={fetchConversions}
getFilterFor={getFilterFor}
keyLabel="Goal"
metrics={[
{name: 'visitors', label: "Uniques", plot: true},
{name: 'events', label: "Total", hiddenOnMobile: true},
CR_METRIC,
{name: 'total_revenue', label: 'Revenue', hiddenOnMobile: true},
{name: 'average_revenue', label: 'Average', hiddenOnMobile: true}
]}
detailsLink={url.sitePath(site, '/conversions')}
query={query}
color="bg-red-50"
/>
)
}

View File

@ -0,0 +1,119 @@
import React from 'react';
import { Link } from 'react-router-dom'
import FlipMove from 'react-flip-move'
import Bar from '../bar'
import PropBreakdown from './prop-breakdown'
import numberFormatter from '../../util/number-formatter'
import * as api from '../../api'
import * as url from '../../util/url'
import { escapeFilterValue } from '../../util/filters'
import LazyLoader from '../../components/lazy-loader'
import Money from './money'
export default class DeprecatedConversions extends React.Component {
constructor(props) {
super(props)
this.htmlNode = React.createRef()
this.state = { loading: true, }
this.onVisible = this.onVisible.bind(this)
this.fetchConversions = this.fetchConversions.bind(this)
}
componentWillUnmount() {
document.removeEventListener('tick', this.fetchConversions)
}
onVisible() {
this.fetchConversions()
if (this.props.query.period === 'realtime') {
document.addEventListener('tick', this.fetchConversions)
}
}
componentDidUpdate(prevProps) {
if (this.props.query !== prevProps.query) {
const height = this.htmlNode.current.offsetHeight
this.setState({ loading: true, goals: null, prevHeight: height })
this.fetchConversions()
}
}
fetchConversions() {
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/conversions`, this.props.query, {limit: 100})
.then((res) => this.setState({ loading: false, goals: res, prevHeight: null }))
}
renderGoal(goal, renderRevenueColumn) {
const renderProps = this.props.query.filters['goal'] == goal.name && goal.prop_names
return (
<div className="my-2 text-sm" key={goal.name}>
<div className="flex items-center justify-between my-2">
<span className="flex-1">
<Bar
count={goal.visitors}
all={this.state.goals}
bg="bg-red-50 dark:bg-gray-500 dark:bg-opacity-15"
plot="visitors"
>
<Link to={url.setQuery('goal', escapeFilterValue(goal.name))} className="block px-2 py-1.5 hover:underline relative z-9 break-all lg:truncate dark:text-gray-200">{goal.name}</Link>
</Bar>
</span>
<div className="dark:text-gray-200">
<span className="inline-block w-20 font-medium text-right">{numberFormatter(goal.visitors)}</span>
<span className="hidden md:inline-block md:w-20 font-medium text-right">{numberFormatter(goal.events)}</span>
<span className="inline-block w-20 font-medium text-right">{goal.conversion_rate}%</span>
{renderRevenueColumn && <span className="hidden md:inline-block md:w-20 font-medium text-right"><Money formatted={goal.total_revenue} /></span>}
{renderRevenueColumn && <span className="hidden md:inline-block md:w-20 font-medium text-right"><Money formatted={goal.average_revenue} /></span>}
</div>
</div>
{ renderProps && <PropBreakdown site={this.props.site} query={this.props.query} goal={goal} renderRevenueColumn={renderRevenueColumn } /> }
</div>
)
}
renderInner() {
if (this.state.loading) {
return <div className="mx-auto my-2 loading"><div></div></div>
} else if (this.state.goals) {
const hasRevenue = this.state.goals.some((goal) => goal.total_revenue)
return (
<React.Fragment>
<div className="flex items-center justify-between mt-3 mb-2 text-xs font-bold tracking-wide text-gray-500 dark:text-gray-400">
<span>Goal</span>
<div className="text-right">
<span className="inline-block w-20">Uniques</span>
<span className="hidden md:inline-block md:w-20">Total</span>
<span className="inline-block w-20">CR</span>
{hasRevenue && <span className="hidden md:inline-block md:w-20">Revenue</span>}
{hasRevenue && <span className="hidden md:inline-block md:w-20">Average</span>}
</div>
</div>
<FlipMove>
{ this.state.goals.map((goal) => this.renderGoal.bind(this)(goal, hasRevenue) ) }
</FlipMove>
</React.Fragment>
)
}
}
renderConversions() {
return (
<div ref={this.htmlNode} style={{ minHeight: '132px', height: this.state.prevHeight ?? 'auto' }} >
<LazyLoader onVisible={this.onVisible}>
{this.renderInner()}
</LazyLoader>
</div>
)
}
render() {
return (
<div>
{this.renderConversions()}
</div>
)
}
}

View File

@ -5,6 +5,7 @@ import classNames from 'classnames'
import * as storage from '../../util/storage'
import Conversions from './conversions'
import DeprecatedConversions from './deprecated-conversions'
import Properties from './props'
import Funnel from './funnel'
import { FeatureSetupNotice } from '../../components/notice'
@ -123,7 +124,13 @@ export default function Behaviours(props) {
}
function renderConversions() {
if (site.hasGoals) { return <Conversions site={site} query={props.query} /> }
if (site.hasGoals) {
if (site.flags.props) {
return <Conversions site={site} query={props.query} />
} else {
return <DeprecatedConversions site={site} query={props.query} />
}
}
else if (adminAccess) {
return (
<FeatureSetupNotice

View File

@ -0,0 +1,117 @@
import React, { useEffect, useState } from "react";
import { Link } from 'react-router-dom'
import { withRouter } from 'react-router-dom'
import Money from "../behaviours/money";
import Modal from './modal'
import * as api from '../../api'
import * as url from "../../util/url";
import numberFormatter from '../../util/number-formatter'
import {parseQuery} from '../../query'
function ConversionsModal(props) {
const site = props.site
const query = parseQuery(props.location.search, site)
const [loading, setLoading] = useState(true)
const [moreResultsAvailable, setMoreResultsAvailable] = useState(false)
const [page, setPage] = useState(1)
const [list, setList] = useState([])
useEffect(() => {
fetchData()
}, [])
function fetchData() {
api.get(url.apiPath(site, `/conversions`), query, {limit: 100, page})
.then((res) => {
setLoading(false)
setList(list.concat(res))
setPage(page + 1)
setMoreResultsAvailable(res.length >= 100)
})
}
function loadMore() {
setLoading(true)
fetchData()
}
function renderLoadMore() {
return (
<div className="w-full text-center my-4">
<button onClick={loadMore} type="button" className="button">
Load more
</button>
</div>
)
}
function filterSearchLink(listItem) {
const searchParams = new URLSearchParams(window.location.search)
searchParams.set('goal', listItem.name)
return searchParams.toString()
}
function renderListItem(listItem, hasRevenue) {
return (
<tr className="text-sm dark:text-gray-200" key={listItem.name}>
<td className="p-2">
<Link
to={{pathname: url.siteBasePath(site), search: filterSearchLink(listItem)}}
className="hover:underline block truncate">
{listItem.name}
</Link>
</td>
<td className="p-2 w-24 font-medium" align="right">{numberFormatter(listItem.visitors)}</td>
<td className="p-2 w-24 font-medium" align="right">{numberFormatter(listItem.events)}</td>
<td className="p-2 w-24 font-medium" align="right">{listItem.conversion_rate}%</td>
{ hasRevenue && <td className="p-2 w-24 font-medium" align="right"><Money formatted={listItem.total_revenue}/></td> }
{ hasRevenue && <td className="p-2 w-24 font-medium" align="right"><Money formatted={listItem.average_revenue}/></td> }
</tr>
)
}
function renderLoading() {
return <div className="loading my-16 mx-auto"><div></div></div>
}
function renderBody() {
const hasRevenue = list.some((goal) => goal.total_revenue)
return (
<>
<h1 className="text-xl font-bold dark:text-gray-100">Goal Conversions</h1>
<div className="my-4 border-b border-gray-300"></div>
<main className="modal__content">
<table className="w-max overflow-x-auto md:w-full table-striped table-fixed">
<thead>
<tr>
<th className="p-2 w-48 md:w-56 lg:w-1/3 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400 truncate" align="left">Goal</th>
<th className="p-2 w-24 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Uniques</th>
<th className="p-2 w-24 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Total</th>
<th className="p-2 w-24 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">CR</th>
{hasRevenue && <th className="p-2 w-24 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Revenue</th>}
{hasRevenue && <th className="p-2 w-24 text-xs tracking-wide font-bold text-gray-500 dark:text-gray-400" align="right">Average</th>}
</tr>
</thead>
<tbody>
{ list.map((item) => renderListItem(item, hasRevenue)) }
</tbody>
</table>
</main>
</>
)
}
return (
<Modal site={site}>
{ renderBody() }
{ loading && renderLoading() }
{ !loading && moreResultsAvailable && renderLoadMore() }
</Modal>
)
}
export default withRouter(ConversionsModal)

View File

@ -190,7 +190,7 @@ export default function ListReport(props) {
return (
<div className="pt-3 w-full text-xs font-bold tracking-wide text-gray-500 flex items-center dark:text-gray-400">
<span className="flex-grow">{ props.keyLabel }</span>
<span className="flex-grow truncate">{ props.keyLabel }</span>
{ metricLabels }
</div>
)

View File

@ -1110,6 +1110,7 @@ defmodule PlausibleWeb.Api.StatsController do
end
def conversions(conn, params) do
pagination = parse_pagination(params)
site = Plausible.Repo.preload(conn.assigns.site, :goals)
query = Query.from(site, params) |> Filters.add_prefix()
@ -1133,7 +1134,7 @@ defmodule PlausibleWeb.Api.StatsController do
conversions =
site
|> Stats.breakdown(query, "event:goal", metrics, {100, 1})
|> Stats.breakdown(query, "event:goal", metrics, pagination)
|> transform_keys(%{goal: :name})
|> Enum.map(fn goal ->
goal