Adds list-based country report option (#1381)

* Adds list option for countries report

* Changelog

* Renames Geo to Locations

* Lint

Co-authored-by: Uku Taht <Uku.taht@gmail.com>
This commit is contained in:
Vignesh Joglekar 2021-10-14 03:55:43 -05:00 committed by GitHub
parent 7e9d83d62e
commit 0c982a8670
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 230 additions and 27 deletions

View File

@ -17,6 +17,7 @@ All notable changes to this project will be documented in this file.
- Ability to invite users to sites with different roles plausible/analytics#1122
- Option to configure a custom name for the script file
- Add Conversion Rate to Top Sources, Top Pages Devices, Countries when filtered by a goal plausible/analytics#1299
- Add list view for countries report in dashboard plausible/analytics#1381
- Add ability to view more than 100 custom goal properties plausible/analytics#1353
### Fixed

View File

@ -7,7 +7,7 @@ import CurrentVisitors from './stats/current-visitors'
import VisitorGraph from './stats/visitor-graph'
import Sources from './stats/sources'
import Pages from './stats/pages'
import Countries from './stats/countries'
import Locations from './stats/locations';
import Devices from './stats/devices'
import Conversions from './stats/conversions'
import { withPinnedHeader } from './pinned-header-hoc';
@ -47,7 +47,7 @@ class Historical extends React.Component {
<Pages site={this.props.site} query={this.props.query} />
</div>
<div className="items-start justify-between block w-full md:flex">
<Countries site={this.props.site} query={this.props.query} />
<Locations site={this.props.site} query={this.props.query} />
<Devices site={this.props.site} query={this.props.query} />
</div>
{ this.renderConversions() }

View File

@ -6,7 +6,7 @@ import Filters from './filters'
import VisitorGraph from './stats/visitor-graph'
import Sources from './stats/sources'
import Pages from './stats/pages'
import Countries from './stats/countries'
import Locations from './stats/locations'
import Devices from './stats/devices'
import Conversions from './stats/conversions'
import { withPinnedHeader } from './pinned-header-hoc';
@ -45,7 +45,7 @@ class Realtime extends React.Component {
<Pages site={this.props.site} query={this.props.query} timer={this.props.timer} />
</div>
<div className="items-start justify-between block w-full md:flex">
<Countries site={this.props.site} query={this.props.query} timer={this.props.timer} />
<Locations site={this.props.site} query={this.props.query} timer={this.props.timer} />
<Devices site={this.props.site} query={this.props.query} timer={this.props.timer} />
</div>

View File

@ -0,0 +1,118 @@
import React from 'react';
import { Link } from 'react-router-dom'
import FlipMove from 'react-flip-move';
import Datamap from 'datamaps'
import FadeIn from '../../fade-in'
import Bar from '../bar'
import MoreLink from '../more-link'
import numberFormatter from '../../number-formatter'
import * as api from '../../api'
import * as url from '../../url'
import LazyLoader from '../../lazy-loader'
export default class Countries extends React.Component {
constructor(props) {
super(props)
this.state = {loading: true}
this.onVisible = this.onVisible.bind(this)
}
componentDidUpdate(prevProps) {
if (this.props.query !== prevProps.query) {
this.setState({loading: true, countries: null})
this.fetchCountries()
}
}
onVisible() {
this.fetchCountries()
if (this.props.timer) this.props.timer.onTick(this.fetchCountries.bind(this))
}
showConversionRate() {
return !!this.props.query.filters.goal
}
fetchCountries() {
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/countries`, this.props.query)
.then((res) => this.setState({loading: false, countries: res}))
}
label() {
if (this.showConversionRate()) {
return 'Conversions'
}
return 'Visitors'
}
renderCountry(country) {
const maxWidthDeduction = this.showConversionRate() ? "10rem" : "5rem"
const allCountries = Datamap.prototype.worldTopo.objects.world.geometries;
const thisCountry = allCountries.find((c) => c.id === country.name) || {properties: {name: country.name}};
const countryFullName = thisCountry.properties.name
return (
<div
className="flex items-center justify-between my-1 text-sm"
key={country.name}
>
<Bar
count={country.count}
all={this.state.countries}
bg="bg-orange-50 dark:bg-gray-500 dark:bg-opacity-15"
maxWidthDeduction={maxWidthDeduction}
>
<span
className="flex px-2 py-1.5 group dark:text-gray-300 relative z-9 break-all"
>
<Link
to={url.setQuery('country', country.name)}
className="md:truncate block hover:underline"
>
{countryFullName}
</Link>
</span>
</Bar>
<span className="font-medium dark:text-gray-200 w-20 text-right">{numberFormatter(country.count)}</span>
{this.showConversionRate() && <span className="font-medium dark:text-gray-200 w-20 text-right">{numberFormatter(country.conversion_rate)}%</span>}
</div>
)
}
renderList() {
if (this.state.countries && this.state.countries.length > 0) {
return (
<>
<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>Country</span>
<div className="text-right">
<span className="inline-block w-20">{ this.label() }</span>
{this.showConversionRate() && <span className="inline-block w-20">CR</span>}
</div>
</div>
<FlipMove>
{ this.state.countries.map(this.renderCountry.bind(this)) }
</FlipMove>
</>
)
}
return <div className="font-medium text-center text-gray-500 mt-44 dark:text-gray-400">No data yet</div>
}
render() {
const { loading } = this.state;
return (
<LazyLoader onVisible={this.onVisible} className="flex flex-col flex-grow">
{ loading && <div className="mx-auto loading mt-44"><div></div></div> }
<FadeIn show={!loading} className="flex-grow">
{ this.renderList() }
</FadeIn>
{!loading && <MoreLink site={this.props.site} list={this.state.countries} endpoint="countries" />}
</LazyLoader>
)
}
}

View File

@ -0,0 +1,88 @@
import React from 'react';
import * as storage from '../../storage'
import Countries from './countries';
import CountriesMap from './map'
const labelFor = {
'map': 'Countries Map',
'countries': 'Countries',
'regions': 'Regions',
'cities': 'Cities',
}
export default class Locations extends React.Component {
constructor(props) {
super(props)
this.tabKey = `geoTab__${ props.site.domain}`
const storedTab = storage.getItem(this.tabKey)
this.state = {
mode: storedTab || 'map'
}
}
setMode(mode) {
return () => {
storage.setItem(this.tabKey, mode)
this.setState({mode})
}
}
renderContent() {
switch(this.state.mode) {
case "countries":
return <Countries site={this.props.site} query={this.props.query} timer={this.props.timer}/>
case "map":
default:
return <CountriesMap site={this.props.site} query={this.props.query} timer={this.props.timer}/>
}
}
renderPill(name, mode) {
const isActive = this.state.mode === mode
if (isActive) {
return (
<li
className="inline-block h-5 text-indigo-700 dark:text-indigo-500 font-bold border-b-2 border-indigo-700 dark:border-indigo-500"
>
{name}
</li>
)
}
return (
<li
className="hover:text-indigo-600 cursor-pointer"
onClick={this.setMode(mode)}
>
{name}
</li>
)
}
render() {
return (
<div
className="stats-item flex flex-col w-full mt-6 stats-item--has-header"
>
<div
className="stats-item__header flex flex-col flex-grow bg-white dark:bg-gray-825 shadow-xl rounded p-4 relative"
>
<div className="w-full flex justify-between">
<h3 className="font-bold dark:text-gray-100">
{labelFor[this.state.mode] || 'Locations'}
</h3>
<ul 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') } */}
</ul>
</div>
{ this.renderContent() }
</div>
</div>
)
}
}

View File

@ -3,15 +3,15 @@ import Datamap from 'datamaps'
import { withRouter } from 'react-router-dom'
import * as d3 from "d3"
import numberFormatter from '../number-formatter'
import FadeIn from '../fade-in'
import LazyLoader from '../lazy-loader'
import MoreLink from './more-link'
import * as api from '../api'
import { navigateToQuery } from '../query'
import { withThemeConsumer } from '../theme-consumer-hoc';
import numberFormatter from '../../number-formatter'
import FadeIn from '../../fade-in'
import LazyLoader from '../../lazy-loader'
import MoreLink from '../more-link'
import * as api from '../../api'
import { navigateToQuery } from '../../query'
import { withThemeConsumer } from '../../theme-consumer-hoc';
class Countries extends React.Component {
class CountriesMap extends React.Component {
constructor(props) {
super(props)
this.resizeMap = this.resizeMap.bind(this)
@ -137,8 +137,7 @@ class Countries extends React.Component {
if (this.state.countries) {
return (
<React.Fragment>
<h3 className="font-bold dark:text-gray-100">Countries</h3>
<div className="mx-auto mt-6" style={{width: '100%', maxWidth: '475px', height: '335px'}} id="map-container"></div>
<div className="mx-auto mt-4" style={{width: '100%', maxWidth: '475px', height: '335px'}} id="map-container"></div>
<MoreLink site={this.props.site} list={this.state.countries} endpoint="countries" />
{ this.geolocationDbNotice() }
</React.Fragment>
@ -148,18 +147,14 @@ class Countries extends React.Component {
render() {
return (
<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}>
{ this.state.loading && <div className="mx-auto my-32 loading"><div></div></div> }
<FadeIn show={!this.state.loading}>
{ this.renderBody() }
</FadeIn>
</LazyLoader>
</div>
<LazyLoader onVisible={this.onVisible}>
{ this.state.loading && <div className="mx-auto my-32 loading"><div></div></div> }
<FadeIn show={!this.state.loading}>
{ this.renderBody() }
</FadeIn>
</LazyLoader>
)
}
}
export default withRouter(withThemeConsumer(Countries))
export default withRouter(withThemeConsumer(CountriesMap))

View File

@ -17,7 +17,7 @@ class CountriesModal extends React.Component {
}
componentDidMount() {
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/countries`, this.state.query, {limit: 100})
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/countries`, this.state.query, {limit: 300})
.then((res) => this.setState({loading: false, countries: res}))
}

View File

@ -404,9 +404,10 @@ defmodule PlausibleWeb.Api.StatsController do
def countries(conn, params) do
site = conn.assigns[:site]
query = Query.from(site.timezone, params) |> Filters.add_prefix()
pagination = parse_pagination(params)
countries =
Stats.breakdown(site, query, "visit:country", ["visitors"], {300, 1})
Stats.breakdown(site, query, "visit:country", ["visitors"], pagination)
|> maybe_add_cr(site, query, {300, 1}, "country", "visit:country")
|> transform_keys(%{"country" => "name", "visitors" => "count"})
|> Enum.map(fn country ->