From 2bdfec1cc02f3377af13ce81d4fa9b27fd352c60 Mon Sep 17 00:00:00 2001 From: Uku Taht Date: Thu, 25 Nov 2021 12:00:17 +0200 Subject: [PATCH] JS refactor: use generic ListReport for country report (#1487) * Use ListReport for countries * Fix countries tests * Replace Browsers with ListReport * Use Listreport for OS and screen size --- assets/js/dashboard/filters.js | 15 +- assets/js/dashboard/router.js | 3 +- assets/js/dashboard/stats/devices/browsers.js | 117 ---------- assets/js/dashboard/stats/devices/index.js | 216 ++++++++---------- .../stats/devices/operating-systems.js | 111 --------- .../js/dashboard/stats/locations/countries.js | 118 ---------- assets/js/dashboard/stats/locations/index.js | 24 +- assets/js/dashboard/stats/modals/countries.js | 119 ---------- assets/js/dashboard/stats/reports/list.js | 38 ++- .../controllers/api/stats_controller.ex | 9 +- .../api/stats_controller/countries_test.exs | 12 +- 11 files changed, 166 insertions(+), 616 deletions(-) delete mode 100644 assets/js/dashboard/stats/devices/browsers.js delete mode 100644 assets/js/dashboard/stats/devices/operating-systems.js delete mode 100644 assets/js/dashboard/stats/locations/countries.js delete mode 100644 assets/js/dashboard/stats/modals/countries.js diff --git a/assets/js/dashboard/filters.js b/assets/js/dashboard/filters.js index 001720db6..ce0c927b4 100644 --- a/assets/js/dashboard/filters.js +++ b/assets/js/dashboard/filters.js @@ -2,7 +2,6 @@ import React, { Fragment, useState } from 'react'; import { Link, withRouter } from 'react-router-dom' import { AdjustmentsIcon, PlusIcon, XIcon, PencilIcon } from '@heroicons/react/solid' import classNames from 'classnames' -import Datamap from 'datamaps' import { Menu, Transition } from '@headlessui/react' import { appliedFilters, navigateToQuery, formattedFilters } from './query' @@ -12,9 +11,11 @@ function removeFilter(key, history, query) { const newOpts = { [key]: false } - if (key === 'goal') { newOpts.props = false } - if (key === 'region') { newOpts.region_name = false } - if (key === 'city') { newOpts.city_name = false } + if (key === 'goal') { newOpts.props = false } + if (key === 'country') { newOpts.country_name = false } + if (key === 'region') { newOpts.region_name = false } + if (key === 'city') { newOpts.city_name = false } + navigateToQuery( history, query, @@ -59,9 +60,9 @@ function filterText(key, rawValue, query) { return <>{osName}.Version {type} {value} } if (key === "country") { - const allCountries = Datamap.prototype.worldTopo.objects.world.geometries; - const selectedCountry = allCountries.find((c) => c.id === value) || {properties: {name: value}}; - return <>Country {type} {selectedCountry.properties.name} + const q = new URLSearchParams(window.location.search) + const countryName = q.get('country_name') + return <>Country {type} {countryName} } if (key === "region") { diff --git a/assets/js/dashboard/router.js b/assets/js/dashboard/router.js index d23d007a7..216c2d4c8 100644 --- a/assets/js/dashboard/router.js +++ b/assets/js/dashboard/router.js @@ -8,7 +8,6 @@ import GoogleKeywordsModal from './stats/modals/google-keywords' import PagesModal from './stats/modals/pages' import EntryPagesModal from './stats/modals/entry-pages' import ExitPagesModal from './stats/modals/exit-pages' -import CountriesModal from './stats/modals/countries' import ModalTable from './stats/modals/table' import FilterModal from './stats/modals/filter' @@ -50,7 +49,7 @@ export default function Router({site, loggedIn, currentUserRole}) { - + diff --git a/assets/js/dashboard/stats/devices/browsers.js b/assets/js/dashboard/stats/devices/browsers.js deleted file mode 100644 index 6dd907f4a..000000000 --- a/assets/js/dashboard/stats/devices/browsers.js +++ /dev/null @@ -1,117 +0,0 @@ -import React from 'react'; -import { Link } from 'react-router-dom' - -import FadeIn from '../../fade-in' -import numberFormatter from '../../number-formatter' -import Bar from '../bar' -import * as api from '../../api' -import * as url from '../../url' -import LazyLoader from '../../lazy-loader' - -export default class Browsers extends React.Component { - constructor(props) { - super(props) - this.state = {loading: true} - this.onVisible = this.onVisible.bind(this) - this.renderBrowserContent = this.renderBrowserContent.bind(this) - } - - componentDidUpdate(prevProps) { - if (this.props.query !== prevProps.query) { - this.setState({loading: true, browsers: null}) - this.fetchBrowsers() - } - } - - onVisible() { - this.fetchBrowsers() - if (this.props.timer) this.props.timer.onTick(this.fetchBrowsers.bind(this)) - } - - fetchBrowsers() { - if (this.props.query.filters.browser) { - api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/browser-versions`, this.props.query) - .then((res) => this.setState({loading: false, browsers: res})) - } else { - api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/browsers`, this.props.query) - .then((res) => this.setState({loading: false, browsers: res})) - } - } - - showConversionRate() { - return !!this.props.query.filters.goal - } - - label() { - return this.props.query.period === 'realtime' ? 'Current visitors' : 'Visitors' - } - - renderBrowserContent(browser, link) { - return ( - - - {browser.name} - - - ) - } - - renderBrowser(browser) { - let link; - if (this.props.query.filters.browser) { - link = url.setQuery('browser_version', browser.name) - } else { - link = url.setQuery('browser', browser.name) - } - const maxWidthDeduction = this.showConversionRate() ? "10rem" : "5rem" - - return ( -
- - {this.renderBrowserContent(browser, link)} - - - {numberFormatter(browser.visitors)} ({browser.percentage}%) - - {this.showConversionRate() && {numberFormatter(browser.conversion_rate)}%} -
- ) - } - - renderList() { - const key = this.props.query.filters.browser ? this.props.query.filters.browser + ' version' : 'Browser' - - if (this.state.browsers && this.state.browsers.length > 0) { - return ( - -
- { key } -
- { this.label() } - {this.showConversionRate() && CR} -
-
- { this.state.browsers && this.state.browsers.map(this.renderBrowser.bind(this)) } -
- ) - } else { - return
No data yet
- } - } - - render() { - return ( - - { this.state.loading &&
} - - { this.renderList() } - -
- ) - } -} diff --git a/assets/js/dashboard/stats/devices/index.js b/assets/js/dashboard/stats/devices/index.js index 1447af65e..f7a3253af 100644 --- a/assets/js/dashboard/stats/devices/index.js +++ b/assets/js/dashboard/stats/devices/index.js @@ -1,16 +1,94 @@ import React from 'react'; -import { Link } from 'react-router-dom' import * as storage from '../../storage' -import LazyLoader from '../../lazy-loader' -import Browsers from './browsers' -import OperatingSystems from './operating-systems' -import FadeIn from '../../fade-in' -import numberFormatter from '../../number-formatter' -import Bar from '../bar' +import ListReport from '../reports/list' import * as api from '../../api' import * as url from '../../url' +function Browsers({query, site}) { + function fetchData() { + return api.get(url.apiPath(site, '/browsers'), query) + } + + return ( + + ) +} + +function BrowserVersions({query, site}) { + function fetchData() { + return api.get(url.apiPath(site, '/browser-versions'), query) + } + + return ( + + ) +} + +function OperatingSystems({query, site}) { + function fetchData() { + return api.get(url.apiPath(site, '/operating-systems'), query) + } + + return ( + + ) +} + +function OperatingSystemVersions({query, site}) { + function fetchData() { + return api.get(url.apiPath(site, '/operating-system-versions'), query) + } + + return ( + + ) +} + +function ScreenSizes({query, site}) { + function fetchData() { + return api.get(url.apiPath(site, '/screen-sizes'), query) + } + + function renderIcon(screenSize) { + return iconFor(screenSize.name) + } + + function renderTooltipText(screenSize) { + return EXPLANATION[screenSize.name] + } + + return ( + + ) +} const EXPLANATION = { 'Mobile': 'up to 576px', @@ -39,110 +117,6 @@ function iconFor(screenSize) { } } -class ScreenSizes 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, sizes: null}) - this.fetchScreenSizes() - } - } - - onVisible() { - this.fetchScreenSizes() - if (this.props.timer) this.props.timer.onTick(this.fetchScreenSizes.bind(this)) - } - - fetchScreenSizes() { - api.get( - `/api/stats/${encodeURIComponent(this.props.site.domain)}/screen-sizes`, - this.props.query - ) - .then((res) => this.setState({loading: false, sizes: res})) - } - - showConversionRate() { - return !!this.props.query.filters.goal - } - - - label() { - return this.props.query.period === 'realtime' ? 'Current visitors' : 'Visitors' - } - - renderScreenSize(size) { - const query = new URLSearchParams(window.location.search) - query.set('screen', size.name) - const maxWidthDeduction = this.showConversionRate() ? "10rem" : "5rem" - - return ( -
- - - - {iconFor(size.name)} {size.name} - - - - - {numberFormatter(size.visitors)} ({size.percentage}%) - - {this.showConversionRate() && {numberFormatter(size.conversion_rate)}%} -
- ) - } - - renderList() { - if (this.state.sizes && this.state.sizes.length > 0) { - return ( - -
- Screen size -
- { this.label() } - {this.showConversionRate() && CR} -
-
- { this.state.sizes && this.state.sizes.map(this.renderScreenSize.bind(this)) } -
- ) - } - return ( -
- No data yet -
- ) - } - - render() { - return ( - - { this.state.loading &&
} - - { this.renderList() } - -
- ) - } -} - export default class Devices extends React.Component { constructor(props) { super(props) @@ -164,23 +138,19 @@ export default class Devices extends React.Component { renderContent() { switch (this.state.mode) { case 'browser': + if (this.props.query.filters.browser) { + return + } return case 'os': - return ( - - ) + if (this.props.query.filters.os) { + return + } + return case 'size': default: return ( - + ) } } diff --git a/assets/js/dashboard/stats/devices/operating-systems.js b/assets/js/dashboard/stats/devices/operating-systems.js deleted file mode 100644 index 9a9d50470..000000000 --- a/assets/js/dashboard/stats/devices/operating-systems.js +++ /dev/null @@ -1,111 +0,0 @@ -import React from 'react'; -import { Link } from 'react-router-dom' - -import FadeIn from '../../fade-in' -import numberFormatter from '../../number-formatter' -import Bar from '../bar' -import * as api from '../../api' -import * as url from '../../url' -import LazyLoader from '../../lazy-loader' - -export default class OperatingSystems extends React.Component { - constructor(props) { - super(props) - this.state = {loading: true} - this.onVisible = this.onVisible.bind(this) - } - - onVisible() { - this.fetchOperatingSystems() - if (this.props.timer) this.props.timer.onTick(this.fetchOperatingSystems.bind(this)) - } - - componentDidUpdate(prevProps) { - if (this.props.query !== prevProps.query) { - this.setState({loading: true, operatingSystems: null}) - this.fetchOperatingSystems() - } - } - - fetchOperatingSystems() { - if (this.props.query.filters.os) { - api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/operating-system-versions`, this.props.query) - .then((res) => this.setState({loading: false, operatingSystems: res})) - } else { - api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/operating-systems`, this.props.query) - .then((res) => this.setState({loading: false, operatingSystems: res})) - } - } - - showConversionRate() { - return !!this.props.query.filters.goal - } - - renderOperatingSystem(os) { - let link; - if (this.props.query.filters.os) { - link = url.setQuery('os_version', os.name) - } else { - link = url.setQuery('os', os.name) - } - const maxWidthDeduction = this.showConversionRate() ? "10rem" : "5rem" - - return ( -
- - - - {os.name} - - - - {numberFormatter(os.visitors)} ({os.percentage}%) - {this.showConversionRate() && {numberFormatter(os.conversion_rate)}%} -
- ) - } - - label() { - return this.props.query.period === 'realtime' ? 'Current visitors' : 'Visitors' - } - - renderList() { - const key = this.props.query.filters.os ? this.props.query.filters.os + ' version' : 'Operating system' - - if (this.state.operatingSystems && this.state.operatingSystems.length > 0) { - return ( - -
- { key } -
- { this.label() } - {this.showConversionRate() && CR} -
-
- { this.state.operatingSystems && this.state.operatingSystems.map(this.renderOperatingSystem.bind(this)) } -
- ) - } else { - return
No data yet
- } - } - - render() { - return ( - - { this.state.loading &&
} - - { this.renderList() } - -
- ) - } -} diff --git a/assets/js/dashboard/stats/locations/countries.js b/assets/js/dashboard/stats/locations/countries.js deleted file mode 100644 index b2c2180bd..000000000 --- a/assets/js/dashboard/stats/locations/countries.js +++ /dev/null @@ -1,118 +0,0 @@ -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 ( -
- - - - {countryFullName} - - - - {numberFormatter(country.visitors)} - {this.showConversionRate() && {numberFormatter(country.conversion_rate)}%} -
- ) - } - - renderList() { - if (this.state.countries && this.state.countries.length > 0) { - return ( - <> -
- Country -
- { this.label() } - {this.showConversionRate() && CR} -
-
- - - { this.state.countries.map(this.renderCountry.bind(this)) } - - - ) - } - - return
No data yet
- } - - render() { - const { loading } = this.state; - return ( - - { loading &&
} - - { this.renderList() } - - {!loading && } -
- ) - } -} diff --git a/assets/js/dashboard/stats/locations/index.js b/assets/js/dashboard/stats/locations/index.js index 6631051d7..c9b15bd6d 100644 --- a/assets/js/dashboard/stats/locations/index.js +++ b/assets/js/dashboard/stats/locations/index.js @@ -1,13 +1,31 @@ import React from 'react'; import * as storage from '../../storage' -import Countries from './countries'; import CountriesMap from './map' import * as api from '../../api' import {apiPath, sitePath} from '../../url' import ListReport from '../reports/list' +function Countries({query, site}) { + function fetchData() { + return api.get(apiPath(site, '/countries'), query, {limit: 9}).then((res) => { + return res.map(row => Object.assign({}, row, {percentage: undefined})) + }) + } + + return ( + + ) +} + function Regions({query, site}) { function fetchData() { return api.get(apiPath(site, '/regions'), query, {country_name: query.filters.country, limit: 9}) @@ -15,12 +33,12 @@ function Regions({query, site}) { return ( ) } @@ -32,12 +50,12 @@ function Cities({query, site}) { return ( ) } diff --git a/assets/js/dashboard/stats/modals/countries.js b/assets/js/dashboard/stats/modals/countries.js deleted file mode 100644 index 2351855b9..000000000 --- a/assets/js/dashboard/stats/modals/countries.js +++ /dev/null @@ -1,119 +0,0 @@ -import React from "react"; -import { Link, withRouter } from 'react-router-dom' -import Datamap from 'datamaps' - -import Modal from './modal' -import * as api from '../../api' -import numberFormatter from '../../number-formatter' -import {parseQuery} from '../../query' - -class CountriesModal extends React.Component { - constructor(props) { - super(props) - this.state = { - loading: true, - query: parseQuery(props.location.search, props.site) - } - } - - componentDidMount() { - api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/countries`, this.state.query, {limit: 300}) - .then((res) => this.setState({loading: false, countries: res})) - } - - label() { - if (this.state.query.period === 'realtime') { - return 'Current visitors' - } - - if (this.showConversionRate()) { - return 'Conversions' - } - - return 'Visitors' - } - - showConversionRate() { - return !!this.state.query.filters.goal - } - - renderCountry(country) { - const query = new URLSearchParams(window.location.search) - query.set('country', country.name) - - 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 ( - - - - {countryFullName} - - - {this.showConversionRate() && {country.total_visitors} } - - {numberFormatter(country.visitors)} {!this.showConversionRate() && ({country.percentage}%)} - - {this.showConversionRate() && {country.conversion_rate}% } - - ) - } - - renderBody() { - if (this.state.loading) { - return ( -
- ) - } - - if (this.state.countries) { - return ( - <> -

Top countries

- -
-
- - - - - {this.showConversionRate() && } - - {this.showConversionRate() && } - - - - { this.state.countries.map(this.renderCountry.bind(this)) } - -
- Country - Total visitors - {this.label()} - CR
-
- - ) - } - - return null - } - - render() { - return ( - - { this.renderBody() } - - ) - } -} - -export default withRouter(CountriesModal) diff --git a/assets/js/dashboard/stats/reports/list.js b/assets/js/dashboard/stats/reports/list.js index e2743c3de..f75723176 100644 --- a/assets/js/dashboard/stats/reports/list.js +++ b/assets/js/dashboard/stats/reports/list.js @@ -32,7 +32,19 @@ export default class ListReport extends React.Component { } label() { - return this.props.query.period === 'realtime' ? 'Current visitors' : 'Visitors' + if (this.props.query.period === 'realtime') { + return 'Current visitors' + } + + if (this.showConversionRate()) { + return 'Conversions' + } + + return 'Visitors' + } + + showConversionRate() { + return !!this.props.query.filters.goal } renderListItem(listItem) { @@ -42,27 +54,34 @@ export default class ListReport extends React.Component { query.set(key, listItem[valueKey]) })) + const maxWidthDeduction = this.showConversionRate() ? "10rem" : "5rem" + const lightBackground = this.props.color || 'bg-green-50' + return (
- + + {this.props.renderIcon && this.props.renderIcon(listItem)} + {this.props.renderIcon && ' '} {listItem.name} - + {numberFormatter(listItem.visitors)} { - listItem.percentage && - ({listItem.percentage}%) + listItem.percentage >= 0 + ? ({listItem.percentage}%) + : null } + {this.showConversionRate() && {listItem.conversion_rate}%}
) } @@ -73,7 +92,10 @@ export default class ListReport extends React.Component { <>
{ this.props.keyLabel } - { this.label() } + + {this.label()} + {this.showConversionRate() && CR} +
{ this.state.list && this.state.list.map(this.renderListItem.bind(this)) } diff --git a/lib/plausible_web/controllers/api/stats_controller.ex b/lib/plausible_web/controllers/api/stats_controller.ex index 199680629..568054441 100644 --- a/lib/plausible_web/controllers/api/stats_controller.ex +++ b/lib/plausible_web/controllers/api/stats_controller.ex @@ -487,14 +487,14 @@ defmodule PlausibleWeb.Api.StatsController do countries = Stats.breakdown(site, query, "visit:country", ["visitors"], pagination) |> maybe_add_cr(site, query, {300, 1}, "country", "visit:country") - |> transform_keys(%{"country" => "name"}) + |> transform_keys(%{"country" => "code"}) |> maybe_add_percentages(query) if params["csv"] do countries = countries |> Enum.map(fn country -> - iso3166 = Stats.CountryName.from_iso3166(country["name"]) + iso3166 = Stats.CountryName.from_iso3166(country["code"]) Map.put(country, "name", iso3166) end) @@ -508,8 +508,9 @@ defmodule PlausibleWeb.Api.StatsController do else countries = Enum.map(countries, fn country -> - alpha3 = Stats.CountryName.to_alpha3(country["name"]) - Map.put(country, "name", alpha3) + name = Stats.CountryName.from_iso3166(country["code"]) + code = Stats.CountryName.to_alpha3(country["code"]) + Map.merge(country, %{"name" => name, "code" => code}) end) json(conn, countries) diff --git a/test/plausible_web/controllers/api/stats_controller/countries_test.exs b/test/plausible_web/controllers/api/stats_controller/countries_test.exs index 2b4f10510..345cd3f07 100644 --- a/test/plausible_web/controllers/api/stats_controller/countries_test.exs +++ b/test/plausible_web/controllers/api/stats_controller/countries_test.exs @@ -22,12 +22,14 @@ defmodule PlausibleWeb.Api.StatsController.CountriesTest do assert json_response(conn, 200) == [ %{ - "name" => "EST", + "code" => "EST", + "name" => "Estonia", "visitors" => 2, "percentage" => 67 }, %{ - "name" => "GBR", + "code" => "GBR", + "name" => "United Kingdom", "visitors" => 1, "percentage" => 33 } @@ -58,13 +60,15 @@ defmodule PlausibleWeb.Api.StatsController.CountriesTest do assert json_response(conn, 200) == [ %{ - "name" => "GBR", + "code" => "GBR", + "name" => "United Kingdom", "total_visitors" => 1, "visitors" => 1, "conversion_rate" => 100.0 }, %{ - "name" => "EST", + "code" => "EST", + "name" => "Estonia", "total_visitors" => 2, "visitors" => 1, "conversion_rate" => 50.0