mirror of
https://github.com/plausible/analytics.git
synced 2024-12-23 17:44:43 +03:00
Make the ListReport component more flexible (#3078)
* refactor ListReport Pass a 'getFilterFor' function instead of a 'filter' object to ListReport * Keep the statsBoxClass in one place only * add classname prop to MoreLink * define metric structs as ListReport inputs * Fix a bug If the query changes, we also want to reset the eventListener function. Otherwise we keep calling an outdated function that fetches old data. * fix CI
This commit is contained in:
parent
01fba1f34b
commit
0541098330
@ -12,6 +12,7 @@ import Devices from './stats/devices'
|
|||||||
import Behaviours from './stats/behaviours'
|
import Behaviours from './stats/behaviours'
|
||||||
import ComparisonInput from './comparison-input'
|
import ComparisonInput from './comparison-input'
|
||||||
import { withPinnedHeader } from './pinned-header-hoc';
|
import { withPinnedHeader } from './pinned-header-hoc';
|
||||||
|
import { statsBoxClass } from '.';
|
||||||
|
|
||||||
function Historical(props) {
|
function Historical(props) {
|
||||||
const tooltipBoundary = React.useRef(null)
|
const tooltipBoundary = React.useRef(null)
|
||||||
@ -31,14 +32,25 @@ function Historical(props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<VisitorGraph site={props.site} query={props.query} />
|
<VisitorGraph site={props.site} query={props.query} />
|
||||||
<div className="items-start justify-between block w-full md:flex">
|
|
||||||
<Sources site={props.site} query={props.query} />
|
<div className="w-full md:flex">
|
||||||
<Pages site={props.site} query={props.query} />
|
<div className={ statsBoxClass }>
|
||||||
|
<Sources site={props.site} query={props.query} />
|
||||||
|
</div>
|
||||||
|
<div className={ statsBoxClass }>
|
||||||
|
<Pages site={props.site} query={props.query} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="items-start justify-between block w-full md:flex">
|
|
||||||
<Locations site={props.site} query={props.query} />
|
<div className="w-full md:flex">
|
||||||
<Devices site={props.site} query={props.query} />
|
<div className={ statsBoxClass }>
|
||||||
|
<Locations site={props.site} query={props.query} />
|
||||||
|
</div>
|
||||||
|
<div className={ statsBoxClass }>
|
||||||
|
<Devices site={props.site} query={props.query} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Behaviours site={props.site} query={props.query} currentUserRole={props.currentUserRole} />
|
<Behaviours site={props.site} query={props.query} currentUserRole={props.currentUserRole} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
@ -7,6 +7,8 @@ import {parseQuery} from './query'
|
|||||||
import * as api from './api'
|
import * as api from './api'
|
||||||
import { withComparisonProvider } from './comparison-provider-hoc';
|
import { withComparisonProvider } from './comparison-provider-hoc';
|
||||||
|
|
||||||
|
export const statsBoxClass = "stats-item relative w-full mt-6 p-4 flex flex-col bg-white dark:bg-gray-825 shadow-xl rounded"
|
||||||
|
|
||||||
class Dashboard extends React.Component {
|
class Dashboard extends React.Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props)
|
super(props)
|
||||||
|
@ -10,6 +10,7 @@ import Locations from './stats/locations'
|
|||||||
import Devices from './stats/devices'
|
import Devices from './stats/devices'
|
||||||
import Behaviours from './stats/behaviours'
|
import Behaviours from './stats/behaviours'
|
||||||
import { withPinnedHeader } from './pinned-header-hoc';
|
import { withPinnedHeader } from './pinned-header-hoc';
|
||||||
|
import { statsBoxClass } from '.';
|
||||||
|
|
||||||
class Realtime extends React.Component {
|
class Realtime extends React.Component {
|
||||||
render() {
|
render() {
|
||||||
@ -28,13 +29,21 @@ class Realtime extends React.Component {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<VisitorGraph site={this.props.site} query={this.props.query} lastLoadTimestamp={this.props.lastLoadTimestamp} />
|
<VisitorGraph site={this.props.site} query={this.props.query} lastLoadTimestamp={this.props.lastLoadTimestamp} />
|
||||||
<div className="items-start justify-between block w-full md:flex">
|
<div className="w-full md:flex">
|
||||||
<Sources site={this.props.site} query={this.props.query} />
|
<div className={ statsBoxClass }>
|
||||||
<Pages site={this.props.site} query={this.props.query} />
|
<Sources site={this.props.site} query={this.props.query} />
|
||||||
|
</div>
|
||||||
|
<div className={ statsBoxClass }>
|
||||||
|
<Pages site={this.props.site} query={this.props.query} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="items-start justify-between block w-full md:flex">
|
<div className="w-full md:flex">
|
||||||
<Locations site={this.props.site} query={this.props.query} />
|
<div className={ statsBoxClass }>
|
||||||
<Devices site={this.props.site} query={this.props.query} />
|
<Locations site={this.props.site} query={this.props.query} />
|
||||||
|
</div>
|
||||||
|
<div className={ statsBoxClass }>
|
||||||
|
<Devices site={this.props.site} query={this.props.query} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Behaviours site={this.props.site} query={this.props.query} currentUserRole={this.props.currentUserRole} />
|
<Behaviours site={this.props.site} query={this.props.query} currentUserRole={this.props.currentUserRole} />
|
||||||
</div>
|
</div>
|
||||||
|
@ -12,11 +12,12 @@ function barWidth(count, all, plot) {
|
|||||||
|
|
||||||
export default function Bar({count, all, bg, maxWidthDeduction, children, plot = "visitors"}) {
|
export default function Bar({count, all, bg, maxWidthDeduction, children, plot = "visitors"}) {
|
||||||
const width = barWidth(count, all, plot)
|
const width = barWidth(count, all, plot)
|
||||||
|
const style = maxWidthDeduction ? {maxWidth: `calc(100% - ${maxWidthDeduction})`} : {}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="w-full relative"
|
className="w-full h-full relative"
|
||||||
style={{maxWidth: `calc(100% - ${maxWidthDeduction})`}}
|
style={style}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`absolute top-0 left-0 h-full ${bg || ''}`}
|
className={`absolute top-0 left-0 h-full ${bg || ''}`}
|
||||||
|
@ -4,17 +4,23 @@ import * as storage from '../../util/storage'
|
|||||||
import ListReport from '../reports/list'
|
import ListReport from '../reports/list'
|
||||||
import * as api from '../../api'
|
import * as api from '../../api'
|
||||||
import * as url from '../../util/url'
|
import * as url from '../../util/url'
|
||||||
|
import { VISITORS_METRIC, PERCENTAGE_METRIC, maybeWithCR } from '../reports/metrics';
|
||||||
|
|
||||||
function Browsers({ query, site }) {
|
function Browsers({ query, site }) {
|
||||||
function fetchData() {
|
function fetchData() {
|
||||||
return api.get(url.apiPath(site, '/browsers'), query)
|
return api.get(url.apiPath(site, '/browsers'), query)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getFilterFor(listItem) {
|
||||||
|
return { browser: listItem['name']}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListReport
|
<ListReport
|
||||||
fetchData={fetchData}
|
fetchData={fetchData}
|
||||||
filter={{ browser: 'name' }}
|
getFilterFor={getFilterFor}
|
||||||
keyLabel="Browser"
|
keyLabel="Browser"
|
||||||
|
metrics={maybeWithCR([VISITORS_METRIC, PERCENTAGE_METRIC], query)}
|
||||||
query={query}
|
query={query}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@ -25,14 +31,19 @@ function BrowserVersions({ query, site }) {
|
|||||||
return api.get(url.apiPath(site, '/browser-versions'), query)
|
return api.get(url.apiPath(site, '/browser-versions'), query)
|
||||||
}
|
}
|
||||||
|
|
||||||
const isNotSet = query.filters.browser === '(not set)'
|
function getFilterFor(listItem) {
|
||||||
const filter = isNotSet ? {} : { browser_version: 'name' }
|
if (query.filters.browser === '(not set)') {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
return { browser_version: listItem['name']}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListReport
|
<ListReport
|
||||||
fetchData={fetchData}
|
fetchData={fetchData}
|
||||||
filter={filter}
|
getFilterFor={getFilterFor}
|
||||||
keyLabel="Browser version"
|
keyLabel="Browser version"
|
||||||
|
metrics={maybeWithCR([VISITORS_METRIC, PERCENTAGE_METRIC], query)}
|
||||||
query={query}
|
query={query}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@ -44,11 +55,16 @@ function OperatingSystems({ query, site }) {
|
|||||||
return api.get(url.apiPath(site, '/operating-systems'), query)
|
return api.get(url.apiPath(site, '/operating-systems'), query)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getFilterFor(listItem) {
|
||||||
|
return { os: listItem['name']}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListReport
|
<ListReport
|
||||||
fetchData={fetchData}
|
fetchData={fetchData}
|
||||||
filter={{ os: 'name' }}
|
getFilterFor={getFilterFor}
|
||||||
keyLabel="Operating system"
|
keyLabel="Operating system"
|
||||||
|
metrics={maybeWithCR([VISITORS_METRIC, PERCENTAGE_METRIC], query)}
|
||||||
query={query}
|
query={query}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@ -59,14 +75,19 @@ function OperatingSystemVersions({ query, site }) {
|
|||||||
return api.get(url.apiPath(site, '/operating-system-versions'), query)
|
return api.get(url.apiPath(site, '/operating-system-versions'), query)
|
||||||
}
|
}
|
||||||
|
|
||||||
const isNotSet = query.filters.os === '(not set)'
|
function getFilterFor(listItem) {
|
||||||
const filter = isNotSet ? {} : { os_version: 'name' }
|
if (query.filters.os === '(not set)') {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
return { os_version: listItem['name']}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListReport
|
<ListReport
|
||||||
fetchData={fetchData}
|
fetchData={fetchData}
|
||||||
filter={filter}
|
getFilterFor={getFilterFor}
|
||||||
keyLabel="Operating System Version"
|
keyLabel="Operating System Version"
|
||||||
|
metrics={maybeWithCR([VISITORS_METRIC, PERCENTAGE_METRIC], query)}
|
||||||
query={query}
|
query={query}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@ -82,11 +103,16 @@ function ScreenSizes({ query, site }) {
|
|||||||
return iconFor(screenSize.name)
|
return iconFor(screenSize.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getFilterFor(listItem) {
|
||||||
|
return { screen: listItem['name']}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListReport
|
<ListReport
|
||||||
fetchData={fetchData}
|
fetchData={fetchData}
|
||||||
filter={{ screen: 'name' }}
|
getFilterFor={getFilterFor}
|
||||||
keyLabel="Screen size"
|
keyLabel="Screen size"
|
||||||
|
metrics={maybeWithCR([VISITORS_METRIC, PERCENTAGE_METRIC], query)}
|
||||||
query={query}
|
query={query}
|
||||||
renderIcon={renderIcon}
|
renderIcon={renderIcon}
|
||||||
/>
|
/>
|
||||||
@ -177,22 +203,16 @@ export default class Devices extends React.Component {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div>
|
||||||
className="stats-item flex flex-col mt-6 stats-item--has-header w-full"
|
<div className="flex justify-between w-full">
|
||||||
>
|
<h3 className="font-bold dark:text-gray-100">Devices</h3>
|
||||||
<div
|
<div className="flex text-xs font-medium text-gray-500 dark:text-gray-400 space-x-2">
|
||||||
className="stats-item-header flex flex-col flex-grow relative p-4 bg-white rounded shadow-xl dark:bg-gray-825"
|
{this.renderPill('Browser', 'browser')}
|
||||||
>
|
{this.renderPill('OS', 'os')}
|
||||||
<div className="flex justify-between w-full">
|
{this.renderPill('Size', 'size')}
|
||||||
<h3 className="font-bold dark:text-gray-100">Devices</h3>
|
|
||||||
<div className="flex text-xs font-medium text-gray-500 dark:text-gray-400 space-x-2">
|
|
||||||
{this.renderPill('Browser', 'browser')}
|
|
||||||
{this.renderPill('OS', 'os')}
|
|
||||||
{this.renderPill('Size', 'size')}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{this.renderContent()}
|
|
||||||
</div>
|
</div>
|
||||||
|
{this.renderContent()}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -6,6 +6,7 @@ import CountriesMap from './map'
|
|||||||
import * as api from '../../api'
|
import * as api from '../../api'
|
||||||
import {apiPath, sitePath} from '../../util/url'
|
import {apiPath, sitePath} from '../../util/url'
|
||||||
import ListReport from '../reports/list'
|
import ListReport from '../reports/list'
|
||||||
|
import { VISITORS_METRIC, maybeWithCR } from '../reports/metrics';
|
||||||
|
|
||||||
function Countries({query, site, onClick}) {
|
function Countries({query, site, onClick}) {
|
||||||
function fetchData() {
|
function fetchData() {
|
||||||
@ -18,12 +19,17 @@ function Countries({query, site, onClick}) {
|
|||||||
return <span className="mr-1">{country.flag}</span>
|
return <span className="mr-1">{country.flag}</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getFilterFor(listItem) {
|
||||||
|
return { country: listItem['code'], country_labels: listItem['name'] }
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListReport
|
<ListReport
|
||||||
fetchData={fetchData}
|
fetchData={fetchData}
|
||||||
filter={{country: 'code', country_labels: 'name'}}
|
getFilterFor={getFilterFor}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
keyLabel="Country"
|
keyLabel="Country"
|
||||||
|
metrics={maybeWithCR([VISITORS_METRIC], query)}
|
||||||
detailsLink={sitePath(site, '/countries')}
|
detailsLink={sitePath(site, '/countries')}
|
||||||
query={query}
|
query={query}
|
||||||
renderIcon={renderIcon}
|
renderIcon={renderIcon}
|
||||||
@ -41,12 +47,17 @@ function Regions({query, site, onClick}) {
|
|||||||
return <span className="mr-1">{region.country_flag}</span>
|
return <span className="mr-1">{region.country_flag}</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getFilterFor(listItem) {
|
||||||
|
return {region: listItem['code'], region_labels: listItem['name']}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListReport
|
<ListReport
|
||||||
fetchData={fetchData}
|
fetchData={fetchData}
|
||||||
filter={{region: 'code', region_labels: 'name'}}
|
getFilterFor={getFilterFor}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
keyLabel="Region"
|
keyLabel="Region"
|
||||||
|
metrics={maybeWithCR([VISITORS_METRIC], query)}
|
||||||
detailsLink={sitePath(site, '/regions')}
|
detailsLink={sitePath(site, '/regions')}
|
||||||
query={query}
|
query={query}
|
||||||
renderIcon={renderIcon}
|
renderIcon={renderIcon}
|
||||||
@ -64,11 +75,16 @@ function Cities({query, site}) {
|
|||||||
return <span className="mr-1">{city.country_flag}</span>
|
return <span className="mr-1">{city.country_flag}</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getFilterFor(listItem) {
|
||||||
|
return {city: listItem['code'], city_labels: listItem['name']}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListReport
|
<ListReport
|
||||||
fetchData={fetchData}
|
fetchData={fetchData}
|
||||||
filter={{city: 'code', city_labels: 'name'}}
|
getFilterFor={getFilterFor}
|
||||||
keyLabel="City"
|
keyLabel="City"
|
||||||
|
metrics={maybeWithCR([VISITORS_METRIC], query)}
|
||||||
detailsLink={sitePath(site, '/cities')}
|
detailsLink={sitePath(site, '/cities')}
|
||||||
query={query}
|
query={query}
|
||||||
renderIcon={renderIcon}
|
renderIcon={renderIcon}
|
||||||
@ -167,25 +183,19 @@ export default class Locations extends React.Component {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div>
|
||||||
className="stats-item flex flex-col w-full mt-6 stats-item--has-header"
|
<div className="w-full flex justify-between">
|
||||||
>
|
<h3 className="font-bold dark:text-gray-100">
|
||||||
<div
|
{labelFor[this.state.mode] || 'Locations'}
|
||||||
className="stats-item-header flex flex-col flex-grow bg-white dark:bg-gray-825 shadow-xl rounded p-4 relative"
|
</h3>
|
||||||
>
|
<div className="flex text-xs font-medium text-gray-500 dark:text-gray-400 space-x-2">
|
||||||
<div className="w-full flex justify-between">
|
{ this.renderPill('Map', 'map') }
|
||||||
<h3 className="font-bold dark:text-gray-100">
|
{ this.renderPill('Countries', 'countries') }
|
||||||
{labelFor[this.state.mode] || 'Locations'}
|
{ this.renderPill('Regions', 'regions') }
|
||||||
</h3>
|
{ this.renderPill('Cities', 'cities') }
|
||||||
<div className="flex font-medium text-xs text-gray-500 dark:text-gray-400 space-x-2">
|
|
||||||
{ this.renderPill('Map', 'map') }
|
|
||||||
{ this.renderPill('Countries', 'countries') }
|
|
||||||
{ this.renderPill('Regions', 'regions') }
|
|
||||||
{ this.renderPill('Cities', 'cities') }
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{ this.renderContent() }
|
|
||||||
</div>
|
</div>
|
||||||
|
{this.renderContent()}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
export default function MoreLink({url, site, list, endpoint}) {
|
export default function MoreLink({url, site, list, endpoint, className}) {
|
||||||
if (list.length > 0) {
|
if (list.length > 0) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center w-full py-3 md:pb-3 md:pt-0 md:absolute md:bottom-0 md:left-0">
|
<div className={`w-full text-center ${className ? className : ''}`}>
|
||||||
<Link
|
<Link
|
||||||
to={url || `/${encodeURIComponent(site.domain)}/${endpoint}${window.location.search}`}
|
to={url || `/${encodeURIComponent(site.domain)}/${endpoint}${window.location.search}`}
|
||||||
// eslint-disable-next-line max-len
|
// eslint-disable-next-line max-len
|
||||||
|
@ -4,6 +4,7 @@ import * as storage from '../../util/storage'
|
|||||||
import * as url from '../../util/url'
|
import * as url from '../../util/url'
|
||||||
import * as api from '../../api'
|
import * as api from '../../api'
|
||||||
import ListReport from './../reports/list'
|
import ListReport from './../reports/list'
|
||||||
|
import { VISITORS_METRIC, UNIQUE_ENTRANCES_METRIC, UNIQUE_EXITS_METRIC, maybeWithCR } from './../reports/metrics';
|
||||||
|
|
||||||
function EntryPages({query, site}) {
|
function EntryPages({query, site}) {
|
||||||
function fetchData() {
|
function fetchData() {
|
||||||
@ -14,13 +15,16 @@ function EntryPages({query, site}) {
|
|||||||
return url.externalLinkForPage(site.domain, page.name)
|
return url.externalLinkForPage(site.domain, page.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getFilterFor(listItem) {
|
||||||
|
return { entry_page: listItem['name']}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListReport
|
<ListReport
|
||||||
fetchData={fetchData}
|
fetchData={fetchData}
|
||||||
filter={{entry_page: 'name'}}
|
getFilterFor={getFilterFor}
|
||||||
keyLabel="Entry page"
|
keyLabel="Entry page"
|
||||||
valueLabel="Unique Entrances"
|
metrics={maybeWithCR([UNIQUE_ENTRANCES_METRIC], query)}
|
||||||
valueKey="unique_entrances"
|
|
||||||
detailsLink={url.sitePath(site, '/entry-pages')}
|
detailsLink={url.sitePath(site, '/entry-pages')}
|
||||||
query={query}
|
query={query}
|
||||||
externalLinkDest={externalLinkDest}
|
externalLinkDest={externalLinkDest}
|
||||||
@ -38,13 +42,16 @@ function ExitPages({query, site}) {
|
|||||||
return url.externalLinkForPage(site.domain, page.name)
|
return url.externalLinkForPage(site.domain, page.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getFilterFor(listItem) {
|
||||||
|
return { exit_page: listItem['name']}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListReport
|
<ListReport
|
||||||
fetchData={fetchData}
|
fetchData={fetchData}
|
||||||
filter={{exit_page: 'name'}}
|
getFilterFor={getFilterFor}
|
||||||
keyLabel="Exit page"
|
keyLabel="Exit page"
|
||||||
valueLabel="Unique Exits"
|
metrics={maybeWithCR([UNIQUE_EXITS_METRIC], query)}
|
||||||
valueKey="unique_exits"
|
|
||||||
detailsLink={url.sitePath(site, '/exit-pages')}
|
detailsLink={url.sitePath(site, '/exit-pages')}
|
||||||
query={query}
|
query={query}
|
||||||
externalLinkDest={externalLinkDest}
|
externalLinkDest={externalLinkDest}
|
||||||
@ -62,11 +69,16 @@ function TopPages({query, site}) {
|
|||||||
return url.externalLinkForPage(site.domain, page.name)
|
return url.externalLinkForPage(site.domain, page.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getFilterFor(listItem) {
|
||||||
|
return { page: listItem['name'] }
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListReport
|
<ListReport
|
||||||
fetchData={fetchData}
|
fetchData={fetchData}
|
||||||
filter={{page: 'name'}}
|
getFilterFor={getFilterFor}
|
||||||
keyLabel="Page"
|
keyLabel="Page"
|
||||||
|
metrics={maybeWithCR([VISITORS_METRIC], query)}
|
||||||
detailsLink={url.sitePath(site, '/pages')}
|
detailsLink={url.sitePath(site, '/pages')}
|
||||||
query={query}
|
query={query}
|
||||||
externalLinkDest={externalLinkDest}
|
externalLinkDest={externalLinkDest}
|
||||||
@ -136,26 +148,20 @@ export default class Pages extends React.Component {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div>
|
||||||
className="stats-item flex flex-col w-full mt-6 stats-item--has-header"
|
{/* Header Container */}
|
||||||
>
|
<div className="w-full flex justify-between">
|
||||||
<div
|
<h3 className="font-bold dark:text-gray-100">
|
||||||
className="stats-item-header flex flex-col flex-grow bg-white dark:bg-gray-825 shadow-xl rounded p-4 relative"
|
{labelFor[this.state.mode] || 'Page Visits'}
|
||||||
>
|
</h3>
|
||||||
{/* Header Container */}
|
<div className="flex font-medium text-xs text-gray-500 dark:text-gray-400 space-x-2">
|
||||||
<div className="w-full flex justify-between">
|
{ this.renderPill('Top Pages', 'pages') }
|
||||||
<h3 className="font-bold dark:text-gray-100">
|
{ this.renderPill('Entry Pages', 'entry-pages') }
|
||||||
{labelFor[this.state.mode] || 'Page Visits'}
|
{ this.renderPill('Exit Pages', 'exit-pages') }
|
||||||
</h3>
|
|
||||||
<div className="flex font-medium text-xs text-gray-500 dark:text-gray-400 space-x-2">
|
|
||||||
{ this.renderPill('Top Pages', 'pages') }
|
|
||||||
{ this.renderPill('Entry Pages', 'entry-pages') }
|
|
||||||
{ this.renderPill('Exit Pages', 'exit-pages') }
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/* Main Contents */}
|
|
||||||
{ this.renderContent() }
|
|
||||||
</div>
|
</div>
|
||||||
|
{/* Main Contents */}
|
||||||
|
{ this.renderContent() }
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -2,13 +2,19 @@ import React, { useState, useEffect, useCallback } from 'react';
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import FlipMove from 'react-flip-move';
|
import FlipMove from 'react-flip-move';
|
||||||
|
|
||||||
|
import { displayMetricValue, metricLabelFor } from './metrics';
|
||||||
import FadeIn from '../../fade-in'
|
import FadeIn from '../../fade-in'
|
||||||
import MoreLink from '../more-link'
|
import MoreLink from '../more-link'
|
||||||
import numberFormatter from '../../util/number-formatter'
|
|
||||||
import Bar from '../bar'
|
import Bar from '../bar'
|
||||||
import LazyLoader from '../../components/lazy-loader'
|
import LazyLoader from '../../components/lazy-loader'
|
||||||
|
|
||||||
|
const MAX_ITEMS = 9
|
||||||
|
const MIN_HEIGHT = 380
|
||||||
|
const ROW_HEIGHT = 32
|
||||||
|
const ROW_GAP_HEIGHT = 4
|
||||||
|
const DATA_CONTAINER_HEIGHT = (ROW_HEIGHT + ROW_GAP_HEIGHT) * (MAX_ITEMS - 1) + ROW_HEIGHT
|
||||||
|
const COL_MIN_WIDTH = 70
|
||||||
|
|
||||||
function ExternalLink({item, externalLinkDest}) {
|
function ExternalLink({item, externalLinkDest}) {
|
||||||
if (externalLinkDest) {
|
if (externalLinkDest) {
|
||||||
const dest = externalLinkDest(item)
|
const dest = externalLinkDest(item)
|
||||||
@ -18,9 +24,9 @@ function ExternalLink({item, externalLinkDest}) {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
href={dest}
|
href={dest}
|
||||||
className="hidden group-hover:block"
|
className="w-4 h-4 hidden group-hover:block"
|
||||||
>
|
>
|
||||||
<svg className="inline w-4 h-4 ml-1 -mt-1 text-gray-600 dark:text-gray-400" fill="currentColor" viewBox="0 0 20 20"><path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z"></path><path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z"></path></svg>
|
<svg className="inline w-full h-full ml-1 -mt-1 text-gray-600 dark:text-gray-400" fill="currentColor" viewBox="0 0 20 20"><path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z"></path><path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z"></path></svg>
|
||||||
</a>
|
</a>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -28,117 +34,225 @@ function ExternalLink({item, externalLinkDest}) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The main function component for rendering list reports and making them react to what
|
||||||
|
// is happening on the dashboard.
|
||||||
|
|
||||||
|
// A `fetchData` function must be passed through props. This function defines the format
|
||||||
|
// of the data, which is expected to be a list of objects. Think of these objects as rows
|
||||||
|
// with keys being columns. The number of columns is dynamic and should be configured
|
||||||
|
// via the `metrics` input list. For example:
|
||||||
|
|
||||||
|
// | keyLabel | METRIC_1.label | METRIC_2.label | ...
|
||||||
|
// |--------------------|---------------------------|---------------------------|-----
|
||||||
|
// | LISTITEM_1.name | LISTITEM_1[METRIC_1.name] | LISTITEM_1[METRIC_2.name] | ...
|
||||||
|
// | LISTITEM_2.name | LISTITEM_2[METRIC_1.name] | LISTITEM_2[METRIC_2.name] | ...
|
||||||
|
|
||||||
|
// Further configuration of the report is possible through optional props.
|
||||||
|
|
||||||
|
// REQUIRED PROPS:
|
||||||
|
|
||||||
|
// * `keyLabel` - What each entry in the list represents (for UI only).
|
||||||
|
|
||||||
|
// * `query` - The query object representing the current state of the dashboard.
|
||||||
|
|
||||||
|
// * `fetchData` - a function that returns an `api.get` promise that will resolve to the
|
||||||
|
// list of data.
|
||||||
|
|
||||||
|
// * `metrics` - a list of `metric` objects. Each `metric` object is required to have at
|
||||||
|
// least the `name` and the `label` keys. If the metric should have a different label
|
||||||
|
// in realtime or goal-filtered views, we'll use `realtimeLabel` and `GoalFilterLabel`.
|
||||||
|
|
||||||
|
// * `getFilterFor` - a function that takes a list item and returns the query link (with
|
||||||
|
// the filter) to navigate to when this list item is clicked.
|
||||||
|
|
||||||
|
// OPTIONAL PROPS:
|
||||||
|
|
||||||
|
// * `onClick` - function with additional action to be taken when a list entry is clicked.
|
||||||
|
|
||||||
|
// * `detailsLink` - the pathname to the detailed view of this report. E.g.:
|
||||||
|
// `/dummy.site/pages`
|
||||||
|
|
||||||
|
// * `externalLinkDest` - a function that takes a list item and returns an external url
|
||||||
|
// to navigate to. If this prop is given, an additional icon is rendered upon hovering
|
||||||
|
// the entry.
|
||||||
|
|
||||||
|
// * `renderIconFor` - a function that takes a list item and returns the
|
||||||
|
// HTML of an icon (such as a flag or screen size icon) for a listItem.
|
||||||
|
|
||||||
|
// * `color` - color of the comparison bars in light-mode
|
||||||
|
|
||||||
export default function ListReport(props) {
|
export default function ListReport(props) {
|
||||||
const [state, setState] = useState({loading: true, list: null})
|
const [state, setState] = useState({loading: true, list: null})
|
||||||
const [visible, setVisible] = useState(false)
|
const [visible, setVisible] = useState(false)
|
||||||
const valueKey = props.valueKey || 'visitors'
|
const metrics = props.metrics
|
||||||
const showConversionRate = !!props.query.filters.goal
|
|
||||||
|
const isRealtime = props.query.period === 'realtime'
|
||||||
|
const goalFilterApplied = !!props.query.filters.goal
|
||||||
|
|
||||||
const fetchData = useCallback(() => {
|
const fetchData = useCallback(() => {
|
||||||
if (props.query.period !== 'realtime') {
|
if (!isRealtime) {
|
||||||
setState({loading: true, list: null})
|
setState({loading: true, list: null})
|
||||||
}
|
}
|
||||||
props.fetchData()
|
props.fetchData()
|
||||||
.then((res) => setState({loading: false, list: res}))
|
.then((res) => setState({loading: false, list: res}))
|
||||||
}, [props.query])
|
}, [props.query])
|
||||||
|
|
||||||
function onVisible() {
|
const onVisible = () => { setVisible(true) }
|
||||||
setVisible(true)
|
|
||||||
if (props.query.period == 'realtime') {
|
|
||||||
document.addEventListener('tick', fetchData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) { fetchData() }
|
if (isRealtime) {
|
||||||
|
// When a goal filter is applied or removed, we always want the component to go into a
|
||||||
|
// loading state, even in realtime mode, because the metrics list will change. We can
|
||||||
|
// only read the new metrics once the new list is loaded.
|
||||||
|
setState({loading: true, list: null})
|
||||||
|
}
|
||||||
|
}, [goalFilterApplied]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
if (isRealtime) { document.addEventListener('tick', fetchData) }
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => { document.removeEventListener('tick', fetchData) }
|
||||||
}, [props.query, visible]);
|
}, [props.query, visible]);
|
||||||
|
|
||||||
useEffect(() => {
|
function renderReport() {
|
||||||
return () => { document.removeEventListener('tick', fetchData) }
|
if (state.list && state.list.length > 0) {
|
||||||
}, []);
|
return (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<div style={{height: ROW_HEIGHT}}>
|
||||||
|
{ renderReportHeader() }
|
||||||
|
</div>
|
||||||
|
|
||||||
function label() {
|
<div style={{minHeight: DATA_CONTAINER_HEIGHT}}>
|
||||||
if (props.query.period === 'realtime') {
|
{ renderReportBody() }
|
||||||
return 'Current visitors'
|
</div>
|
||||||
|
|
||||||
|
{ maybeRenderMoreLink() }
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
return renderNoDataYet()
|
||||||
if (showConversionRate) {
|
|
||||||
return 'Conversions'
|
|
||||||
}
|
|
||||||
|
|
||||||
return props.valueLabel || 'Visitors'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderListItem(listItem) {
|
function renderReportHeader() {
|
||||||
const query = new URLSearchParams(window.location.search)
|
const metricLabels = metrics.map((metric) => {
|
||||||
|
return (<span key={metric.name} className="text-right" style={{minWidth: COL_MIN_WIDTH}}>{ metricLabelFor(metric, props.query) }</span>)
|
||||||
Object.entries(props.filter).forEach((([key, valueKey]) => {
|
})
|
||||||
query.set(key, listItem[valueKey])
|
|
||||||
}))
|
|
||||||
|
|
||||||
const maxWidthDeduction = showConversionRate ? "10rem" : "5rem"
|
|
||||||
const lightBackground = props.color || 'bg-green-50'
|
|
||||||
const noop = () => {}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between my-1 text-sm" key={listItem.name}>
|
<div className="pt-3 w-full text-xs font-bold tracking-wide text-gray-500 flex items-center dark:text-gray-400">
|
||||||
<Bar
|
<span className="flex-grow">{ props.keyLabel }</span>
|
||||||
count={listItem[valueKey]}
|
{ metricLabels }
|
||||||
all={state.list}
|
|
||||||
bg={`${lightBackground} dark:bg-gray-500 dark:bg-opacity-15`}
|
|
||||||
maxWidthDeduction={maxWidthDeduction}
|
|
||||||
plot={valueKey}
|
|
||||||
>
|
|
||||||
<span className="flex px-2 py-1.5 group dark:text-gray-300 relative z-9 break-all">
|
|
||||||
<Link onClick={props.onClick || noop} className="md:truncate block hover:underline" to={{search: query.toString()}}>
|
|
||||||
{props.renderIcon && props.renderIcon(listItem)}
|
|
||||||
{props.renderIcon && ' '}
|
|
||||||
{listItem.name}
|
|
||||||
</Link>
|
|
||||||
<ExternalLink item={listItem} externalLinkDest={props.externalLinkDest} />
|
|
||||||
</span>
|
|
||||||
</Bar>
|
|
||||||
<span className="font-medium dark:text-gray-200 w-20 text-right" tooltip={listItem[valueKey]}>
|
|
||||||
{numberFormatter(listItem[valueKey])}
|
|
||||||
{
|
|
||||||
listItem.percentage >= 0
|
|
||||||
? <span className="inline-block w-8 pl-1 text-xs text-right">({listItem.percentage}%)</span>
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
{showConversionRate && <span className="font-medium dark:text-gray-200 w-20 text-right">{listItem.conversion_rate}%</span>}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderList() {
|
function renderReportBody() {
|
||||||
if (state.list && state.list.length > 0) {
|
return (
|
||||||
return (
|
<FlipMove className="flex-grow">
|
||||||
<>
|
{state.list.map(renderRow)}
|
||||||
<div className="flex items-center justify-between mt-3 mb-2 text-xs font-bold tracking-wide text-gray-500 dark:text-gray-400">
|
</FlipMove>
|
||||||
<span>{ props.keyLabel }</span>
|
)
|
||||||
<span className="text-right">
|
}
|
||||||
<span className="inline-block w-30">{label()}</span>
|
|
||||||
{showConversionRate && <span className="inline-block w-20">CR</span>}
|
function renderRow(listItem) {
|
||||||
</span>
|
return (
|
||||||
|
<div key={listItem.name} style={{minHeight: ROW_HEIGHT}}>
|
||||||
|
<div className="flex w-full" style={{marginTop: ROW_GAP_HEIGHT}}>
|
||||||
|
{ renderBarFor(listItem) }
|
||||||
|
{ renderMetricValuesFor(listItem) }
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBarFor(listItem) {
|
||||||
|
const query = new URLSearchParams(window.location.search)
|
||||||
|
const filter = props.getFilterFor(listItem)
|
||||||
|
|
||||||
|
Object.entries(filter).forEach((([key, value]) => {
|
||||||
|
query.set(key, value)
|
||||||
|
}))
|
||||||
|
|
||||||
|
const lightBackground = props.color || 'bg-green-50'
|
||||||
|
const noop = () => {}
|
||||||
|
const metricToPlot = metrics[0].name
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-grow w-full overflow-hidden">
|
||||||
|
<Bar
|
||||||
|
count={listItem[metricToPlot]}
|
||||||
|
all={state.list}
|
||||||
|
bg={`${lightBackground} dark:bg-gray-500 dark:bg-opacity-15`}
|
||||||
|
plot={metricToPlot}
|
||||||
|
>
|
||||||
|
<div className="flex justify-start px-2 py-1.5 group text-sm dark:text-gray-300 relative z-9 break-all w-full">
|
||||||
|
<Link onClick={props.onClick || noop} to={{search: query.toString()}} className="max-w-max w-full flex hover:underline md:overflow-hidden">
|
||||||
|
{maybeRenderIconFor(listItem)}
|
||||||
|
|
||||||
|
<span className="w-full md:truncate">
|
||||||
|
{listItem.name}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<ExternalLink item={listItem} externalLinkDest={props.externalLinkDest} />
|
||||||
</div>
|
</div>
|
||||||
<FlipMove>
|
</Bar>
|
||||||
{ state.list.map(renderListItem) }
|
</div>
|
||||||
</FlipMove>
|
)
|
||||||
</>
|
}
|
||||||
|
|
||||||
|
function maybeRenderIconFor(listItem) {
|
||||||
|
if (props.renderIcon) {
|
||||||
|
return (
|
||||||
|
<span className="pr-1">
|
||||||
|
{props.renderIcon(listItem)}
|
||||||
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return <div className="font-medium text-center text-gray-500 mt-44 dark:text-gray-400">No data yet</div>
|
function renderMetricValuesFor(listItem) {
|
||||||
|
return metrics.map((metric) => {
|
||||||
|
return (
|
||||||
|
<div key={`${listItem.name}__${metric.name}`} style={{width: COL_MIN_WIDTH, minWidth: COL_MIN_WIDTH}} className="text-right">
|
||||||
|
<span className="font-medium text-sm dark:text-gray-200 text-right">
|
||||||
|
{ displayMetricValue(listItem[metric.name], metric) }
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLoading() {
|
||||||
|
return (
|
||||||
|
<div className="w-full flex flex-col justify-center" style={{minHeight: `${MIN_HEIGHT}px`}}>
|
||||||
|
<div className="mx-auto loading"><div></div></div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNoDataYet() {
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex flex-col justify-center" style={{minHeight: `${MIN_HEIGHT}px`}}>
|
||||||
|
<div className="mx-auto font-medium text-gray-500 dark:text-gray-400">No data yet</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeRenderMoreLink() {
|
||||||
|
return props.detailsLink && !state.loading && <MoreLink url={props.detailsLink} list={state.list}/>
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LazyLoader onVisible={onVisible} className="flex flex-col flex-grow">
|
<LazyLoader onVisible={onVisible} >
|
||||||
{ state.loading && <div className="mx-auto loading mt-44"><div></div></div> }
|
<div className="w-full" style={{minHeight: `${MIN_HEIGHT}px`}}>
|
||||||
<FadeIn show={!state.loading} className="flex-grow">
|
{ state.loading && renderLoading() }
|
||||||
{ renderList() }
|
<FadeIn show={!state.loading} className="h-full">
|
||||||
</FadeIn>
|
{ renderReport() }
|
||||||
{props.detailsLink && !state.loading && <MoreLink url={props.detailsLink} list={state.list} />}
|
</FadeIn>
|
||||||
|
</div>
|
||||||
</LazyLoader>
|
</LazyLoader>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
47
assets/js/dashboard/stats/reports/metrics.js
Normal file
47
assets/js/dashboard/stats/reports/metrics.js
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import numberFormatter from "../../util/number-formatter"
|
||||||
|
import React from "react"
|
||||||
|
|
||||||
|
export const VISITORS_METRIC = {
|
||||||
|
name: 'visitors',
|
||||||
|
label: 'Visitors',
|
||||||
|
realtimeLabel: 'Current visitors',
|
||||||
|
goalFilterLabel: 'Conversions'
|
||||||
|
}
|
||||||
|
export const UNIQUE_ENTRANCES_METRIC = {
|
||||||
|
...VISITORS_METRIC,
|
||||||
|
name: 'unique_entrances',
|
||||||
|
label: 'Unique Entrances'
|
||||||
|
}
|
||||||
|
export const UNIQUE_EXITS_METRIC = {
|
||||||
|
...VISITORS_METRIC,
|
||||||
|
name: 'unique_exits',
|
||||||
|
label: 'Unique Exits'
|
||||||
|
}
|
||||||
|
export const PERCENTAGE_METRIC = { name: 'percentage', label: '%' }
|
||||||
|
export const CR_METRIC = { name: 'conversion_rate', label: 'CR' }
|
||||||
|
|
||||||
|
export function maybeWithCR(metrics, query) {
|
||||||
|
if (metrics.includes(PERCENTAGE_METRIC) && query.filters.goal) {
|
||||||
|
return metrics.filter((m) => { return m !== PERCENTAGE_METRIC }).concat([CR_METRIC])
|
||||||
|
}
|
||||||
|
else if (query.filters.goal) {
|
||||||
|
return metrics.concat(CR_METRIC)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return metrics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function displayMetricValue(value, metric) {
|
||||||
|
if ([PERCENTAGE_METRIC, CR_METRIC].includes(metric)) {
|
||||||
|
return `${value}%`
|
||||||
|
} else {
|
||||||
|
return <span tooltip={value}>{ numberFormatter(value) }</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function metricLabelFor(metric, query) {
|
||||||
|
if (metric.realtimeLabel && query.period === 'realtime') { return metric.realtimeLabel }
|
||||||
|
if (metric.goalFilterLabel && query.filters.goal) { return metric.goalFilterLabel }
|
||||||
|
return metric.label
|
||||||
|
}
|
@ -155,7 +155,7 @@ export default class Referrers extends React.Component {
|
|||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
{this.renderList()}
|
{this.renderList()}
|
||||||
<MoreLink site={this.props.site} list={this.state.referrers} endpoint={`referrers/${this.props.query.filters.source}`} />
|
<MoreLink site={this.props.site} list={this.state.referrers} endpoint={`referrers/${this.props.query.filters.source}`} className="w-full pb-4 absolute bottom-0 left-0"/>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -163,9 +163,7 @@ export default class Referrers extends React.Component {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div>
|
||||||
className="relative p-4 bg-white rounded shadow-xl stats-item flex flex-col dark:bg-gray-825 mt-6 w-full"
|
|
||||||
>
|
|
||||||
<LazyLoader onVisible={this.onVisible} className="flex flex-col flex-grow">
|
<LazyLoader onVisible={this.onVisible} className="flex flex-col flex-grow">
|
||||||
<h3 className="font-bold dark:text-gray-100">Top Referrers</h3>
|
<h3 className="font-bold dark:text-gray-100">Top Referrers</h3>
|
||||||
{this.state.loading && <div className="mx-auto loading mt-44"><div></div></div>}
|
{this.state.loading && <div className="mx-auto loading mt-44"><div></div></div>}
|
||||||
|
@ -119,7 +119,7 @@ export default class SearchTerms extends React.Component {
|
|||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<h3 className="font-bold dark:text-gray-100">Search Terms</h3>
|
<h3 className="font-bold dark:text-gray-100">Search Terms</h3>
|
||||||
{ this.renderList() }
|
{ this.renderList() }
|
||||||
<MoreLink site={this.props.site} list={this.state.searchTerms} endpoint="referrers/Google" />
|
<MoreLink site={this.props.site} list={this.state.searchTerms} endpoint="referrers/Google" className="w-full pb-4 absolute bottom-0 left-0"/>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -127,9 +127,7 @@ export default class SearchTerms extends React.Component {
|
|||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div>
|
||||||
className="stats-item flex flex-col relative bg-white dark:bg-gray-825 shadow-xl rounded p-4 mt-6 w-full"
|
|
||||||
>
|
|
||||||
{ this.state.loading && <div className="loading mt-44 mx-auto"><div></div></div> }
|
{ this.state.loading && <div className="loading mt-44 mx-auto"><div></div></div> }
|
||||||
<FadeIn show={!this.state.loading} className="flex-grow">
|
<FadeIn show={!this.state.loading} className="flex-grow">
|
||||||
<LazyLoader onVisible={this.onVisible}>
|
<LazyLoader onVisible={this.onVisible}>
|
||||||
|
@ -106,7 +106,7 @@ class AllSources extends React.Component {
|
|||||||
<FlipMove className="flex-grow">
|
<FlipMove className="flex-grow">
|
||||||
{this.state.referrers.map(this.renderReferrer.bind(this))}
|
{this.state.referrers.map(this.renderReferrer.bind(this))}
|
||||||
</FlipMove>
|
</FlipMove>
|
||||||
<MoreLink site={this.props.site} list={this.state.referrers} endpoint="sources" />
|
<MoreLink site={this.props.site} list={this.state.referrers} endpoint="sources" className="pb-4 absolute bottom-0 left-0"/>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@ -114,27 +114,19 @@ class AllSources extends React.Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
renderContent() {
|
|
||||||
return (
|
|
||||||
<LazyLoader className="flex flex-col flex-grow" onVisible={this.onVisible}>
|
|
||||||
<div id="sources" className="flex justify-between w-full">
|
|
||||||
<h3 className="font-bold dark:text-gray-100">Top Sources</h3>
|
|
||||||
{this.props.renderTabs()}
|
|
||||||
</div>
|
|
||||||
{this.state.loading && <div className="mx-auto loading mt-44"><div></div></div>}
|
|
||||||
<FadeIn show={!this.state.loading} className="flex flex-col flex-grow">
|
|
||||||
{this.renderList()}
|
|
||||||
</FadeIn>
|
|
||||||
</LazyLoader>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div>
|
||||||
className="relative p-4 bg-white rounded shadow-xl stats-item flex flex-col mt-6 w-full dark:bg-gray-825"
|
<LazyLoader className="flex flex-col flex-grow" onVisible={this.onVisible}>
|
||||||
>
|
<div id="sources" className="flex justify-between w-full">
|
||||||
{this.renderContent()}
|
<h3 className="font-bold dark:text-gray-100">Top Sources</h3>
|
||||||
|
{this.props.renderTabs()}
|
||||||
|
</div>
|
||||||
|
{this.state.loading && <div className="mx-auto loading mt-44"><div></div></div>}
|
||||||
|
<FadeIn show={!this.state.loading} className="flex flex-col flex-grow">
|
||||||
|
{this.renderList()}
|
||||||
|
</FadeIn>
|
||||||
|
</LazyLoader>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -245,7 +237,7 @@ class UTMSources extends React.Component {
|
|||||||
<FlipMove className="flex-grow">
|
<FlipMove className="flex-grow">
|
||||||
{this.state.referrers.map(this.renderReferrer.bind(this))}
|
{this.state.referrers.map(this.renderReferrer.bind(this))}
|
||||||
</FlipMove>
|
</FlipMove>
|
||||||
<MoreLink site={this.props.site} list={this.state.referrers} endpoint={UTM_TAGS[this.props.tab].endpoint} />
|
<MoreLink site={this.props.site} list={this.state.referrers} endpoint={UTM_TAGS[this.props.tab].endpoint} className="pb-4 absolute bottom-0 left-0"/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@ -253,27 +245,19 @@ class UTMSources extends React.Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
renderContent() {
|
|
||||||
return (
|
|
||||||
<LazyLoader onVisible={this.onVisible}>
|
|
||||||
<div className="flex justify-between w-full">
|
|
||||||
<h3 className="font-bold dark:text-gray-100">Top Sources</h3>
|
|
||||||
{this.props.renderTabs()}
|
|
||||||
</div>
|
|
||||||
{this.state.loading && <div className="mx-auto loading mt-44"><div></div></div>}
|
|
||||||
<FadeIn show={!this.state.loading} className="flex flex-col flex-grow">
|
|
||||||
{this.renderList()}
|
|
||||||
</FadeIn>
|
|
||||||
</LazyLoader>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div>
|
||||||
className="relative p-4 bg-white rounded shadow-xl stats-item flex flex-col dark:bg-gray-825 mt-6 w-full"
|
<LazyLoader onVisible={this.onVisible}>
|
||||||
>
|
<div className="flex justify-between w-full">
|
||||||
{this.renderContent()}
|
<h3 className="font-bold dark:text-gray-100">Top Sources</h3>
|
||||||
|
{this.props.renderTabs()}
|
||||||
|
</div>
|
||||||
|
{this.state.loading && <div className="mx-auto loading mt-44"><div></div></div>}
|
||||||
|
<FadeIn show={!this.state.loading} className="flex flex-col flex-grow">
|
||||||
|
{this.renderList()}
|
||||||
|
</FadeIn>
|
||||||
|
</LazyLoader>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user