diff --git a/CHANGELOG.md b/CHANGELOG.md index 178674c3bf32..971a887a8bdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ All notable changes to this project will be documented in this file. - Filter goals in realtime filter by clicking goal name - The time format (12 hour or 24 hour) for graph timelines is now presented based on the browser's defined language - Choice of metric for main-graph both in UI and API (visitors, pageviews, bounce_rate, visit_duration) plausible/analytics#1364 +- New width=manual mode for embedded dashboards plausible/analytics#2148 ### Fixed - UI fix where multi-line text in pills would not be underlined properly on small screens. diff --git a/assets/css/app.css b/assets/css/app.css index aa76275e9548..55b09f4ad39d 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -365,3 +365,26 @@ iframe[hidden] { /* This class is used for styling embedded dashboards. Do not remove. */ /* stylelint-disable */ .date-option-group { } + + +.popper-tooltip { + background-color: rgba(25, 30, 56); +} + +.tooltip-arrow, +.tooltip-arrow::before { + position: absolute; + width: 10px; + height: 10px; + background: inherit; +} + +.tooltip-arrow { + visibility: hidden; +} + +.tooltip-arrow::before { + visibility: visible; + content: ''; + transform: rotate(45deg) translateY(1px); +} diff --git a/assets/js/dashboard/stats/graph/top-stats.js b/assets/js/dashboard/stats/graph/top-stats.js index 40ae2ba31b73..25ad59581447 100644 --- a/assets/js/dashboard/stats/graph/top-stats.js +++ b/assets/js/dashboard/stats/graph/top-stats.js @@ -1,9 +1,11 @@ import React from "react"; -import numberFormatter, {durationFormatter} from '../../util/number-formatter' -import { METRIC_MAPPING, METRIC_LABELS } from './visitor-graph' +import classNames from "classnames"; +import { Tooltip } from '../../util/tooltip' +import numberFormatter, { durationFormatter } from '../../util/number-formatter' +import { METRIC_MAPPING } from './visitor-graph' export default class TopStats extends React.Component { - renderComparison(name, comparison) { + renderComparison(name, comparison) { const formattedComparison = numberFormatter(Math.abs(comparison)) if (comparison > 0) { @@ -27,64 +29,87 @@ export default class TopStats extends React.Component { } } - topStatTooltip(stat) { - if (['visit duration', 'time on page', 'bounce rate', 'conversion rate'].includes(stat.name.toLowerCase())) { - return null + topStatNumberLong(stat) { + if (['visit duration', 'time on page'].includes(stat.name.toLowerCase())) { + return durationFormatter(stat.value) + } else if (['bounce rate', 'conversion rate'].includes(stat.name.toLowerCase())) { + return stat.value + '%' } else { - let name = stat.name.toLowerCase() - name = stat.value === 1 ? name.slice(0, -1) : name - return stat.value.toLocaleString() + ' ' + name + return stat.value.toLocaleString() } } + topStatTooltip(stat) { + let statName = stat.name.toLowerCase() + statName = stat.value === 1 ? statName.slice(0, -1) : statName + + return ( +
+
{this.topStatNumberLong(stat)} {statName}
+ {this.canMetricBeGraphed(stat) &&
{this.titleFor(stat)}
} +
+ ) + } + titleFor(stat) { - if(this.props.metric === METRIC_MAPPING[stat.name]) { - return `Hide ${METRIC_LABELS[METRIC_MAPPING[stat.name]].toLowerCase()} from graph` + const isClickable = this.canMetricBeGraphed(stat) + + if (isClickable && this.props.metric === METRIC_MAPPING[stat.name]) { + return "Click to hide" + } else if (isClickable) { + return "Click to show" } else { - return `Show ${METRIC_LABELS[METRIC_MAPPING[stat.name]].toLowerCase()} on graph` + return null + } + } + + canMetricBeGraphed(stat) { + const isTotalUniqueVisitors = this.props.query.filters.goal && stat.name === 'Unique visitors' + const isKnownMetric = Object.keys(METRIC_MAPPING).includes(stat.name) + + return isKnownMetric && !isTotalUniqueVisitors + } + + maybeUpdateMetric(stat) { + if (this.canMetricBeGraphed(stat)) { + this.props.updateMetric(METRIC_MAPPING[stat.name]) } } renderStat(stat) { return ( -
- {this.topStatNumberShort(stat)} + + {this.topStatNumberShort(stat)} {this.renderComparison(stat.name, stat.change)} -
+ ) } render() { - const { updateMetric, metric, topStatData, query } = this.props + const { metric, topStatData, query } = this.props const stats = topStatData && topStatData.top_stats.map((stat, index) => { - let border = index > 0 ? 'lg:border-l border-gray-300' : '' - border = index % 2 === 0 ? border + ' border-r lg:border-r-0' : border - const isClickable = Object.keys(METRIC_MAPPING).includes(stat.name) && !(query.filters.goal && stat.name === 'Unique visitors') const isSelected = metric === METRIC_MAPPING[stat.name] const [statDisplayName, statExtraName] = stat.name.split(/(\(.+\))/g) + const className = classNames('px-4 md:px-6 w-1/2 my-4 lg:w-auto group select-none', { + 'cursor-pointer': this.canMetricBeGraphed(stat), + 'lg:border-l border-gray-300': index > 0, + 'border-r lg:border-r-0': index % 2 === 0 + }) + return ( - - { isClickable ? - ( -
{ updateMetric(METRIC_MAPPING[stat.name]) }} tabIndex={0} title={this.titleFor(stat)}> -
- {statDisplayName} - {statExtraName && {statExtraName}} -
- { this.renderStat(stat) } -
- ) : ( -
-
- {stat.name} -
- { this.renderStat(stat) } -
- )} -
+ { this.maybeUpdateMetric(stat) }}> +
+ {statDisplayName} + {statExtraName && {statExtraName}} +
+
+ {this.topStatNumberShort(stat)} + {this.renderComparison(stat.name, stat.change)} +
+
) }) diff --git a/assets/js/dashboard/util/tooltip.js b/assets/js/dashboard/util/tooltip.js new file mode 100644 index 000000000000..1600138ed48b --- /dev/null +++ b/assets/js/dashboard/util/tooltip.js @@ -0,0 +1,36 @@ +import React, { useState } from "react"; +import { usePopper } from 'react-popper'; +import classNames from 'classnames' + +export function Tooltip({ children, info, className, onClick }) { + const [visible, setVisible] = useState(false); + const [referenceElement, setReferenceElement] = useState(null); + const [popperElement, setPopperElement] = useState(null); + const [arrowElement, setArrowElement] = useState(null); + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: 'top', + modifiers: [ + { name: 'arrow', options: { element: arrowElement } }, + { + name: 'offset', + options: { + offset: [0, 4], + }, + }, + ], + }); + + return ( +
+
setVisible(true)} onMouseLeave={() => setVisible(false)} onClick={onClick}> + {children} + +
+ {info && visible &&
+ {info} +
+
+ } +
+ ) +} diff --git a/assets/package-lock.json b/assets/package-lock.json index 40c3badba47c..9c6360d5a6a6 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -15,6 +15,7 @@ "@heroicons/react": "^1.0.1", "@juggle/resize-observer": "^3.3.1", "@kunukn/react-collapse": "^2.2.9", + "@popperjs/core": "^2.11.6", "@tailwindcss/aspect-ratio": "^0.2.1", "@tailwindcss/forms": "^0.3.2", "@tailwindcss/typography": "^0.4.1", @@ -40,6 +41,7 @@ "react-dom": "^16.13.1", "react-flatpickr": "3.10.5", "react-flip-move": "^3.0.4", + "react-popper": "^2.3.0", "react-router-dom": "^5.2.0", "react-transition-group": "^4.4.2", "tailwindcss": "^2.1.2", @@ -1716,6 +1718,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@popperjs/core": { + "version": "2.11.6", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.6.tgz", + "integrity": "sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, "node_modules/@tailwindcss/aspect-ratio": { "version": "0.2.1", "license": "MIT", @@ -7258,6 +7269,11 @@ "react": "^16.14.0" } }, + "node_modules/react-fast-compare": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz", + "integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==" + }, "node_modules/react-flatpickr": { "version": "3.10.5", "resolved": "https://registry.npmjs.org/react-flatpickr/-/react-flatpickr-3.10.5.tgz", @@ -7279,6 +7295,20 @@ "version": "16.11.0", "license": "MIT" }, + "node_modules/react-popper": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-2.3.0.tgz", + "integrity": "sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q==", + "dependencies": { + "react-fast-compare": "^3.0.1", + "warning": "^4.0.2" + }, + "peerDependencies": { + "@popperjs/core": "^2.0.0", + "react": "^16.8.0 || ^17 || ^18", + "react-dom": "^16.8.0 || ^17 || ^18" + } + }, "node_modules/react-router": { "version": "5.2.0", "license": "MIT", @@ -8851,6 +8881,14 @@ "version": "0.2.3", "license": "MIT" }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/watchpack": { "version": "2.2.0", "license": "MIT", @@ -10208,6 +10246,11 @@ "version": "1.0.0-next.15", "dev": true }, + "@popperjs/core": { + "version": "2.11.6", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.6.tgz", + "integrity": "sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw==" + }, "@tailwindcss/aspect-ratio": { "version": "0.2.1", "requires": {} @@ -13919,6 +13962,11 @@ "scheduler": "^0.19.1" } }, + "react-fast-compare": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz", + "integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA==" + }, "react-flatpickr": { "version": "3.10.5", "resolved": "https://registry.npmjs.org/react-flatpickr/-/react-flatpickr-3.10.5.tgz", @@ -13935,6 +13983,15 @@ "react-is": { "version": "16.11.0" }, + "react-popper": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-2.3.0.tgz", + "integrity": "sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q==", + "requires": { + "react-fast-compare": "^3.0.1", + "warning": "^4.0.2" + } + }, "react-router": { "version": "5.2.0", "requires": { @@ -15043,6 +15100,14 @@ "vlq": { "version": "0.2.3" }, + "warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "requires": { + "loose-envify": "^1.0.0" + } + }, "watchpack": { "version": "2.2.0", "requires": { diff --git a/assets/package.json b/assets/package.json index 1ac62c05603a..f4e383c7c382 100644 --- a/assets/package.json +++ b/assets/package.json @@ -18,6 +18,7 @@ "@heroicons/react": "^1.0.1", "@juggle/resize-observer": "^3.3.1", "@kunukn/react-collapse": "^2.2.9", + "@popperjs/core": "^2.11.6", "@tailwindcss/aspect-ratio": "^0.2.1", "@tailwindcss/forms": "^0.3.2", "@tailwindcss/typography": "^0.4.1", @@ -43,6 +44,7 @@ "react-dom": "^16.13.1", "react-flatpickr": "3.10.5", "react-flip-move": "^3.0.4", + "react-popper": "^2.3.0", "react-router-dom": "^5.2.0", "react-transition-group": "^4.4.2", "tailwindcss": "^2.1.2", diff --git a/lib/plausible_web/templates/stats/stats.html.eex b/lib/plausible_web/templates/stats/stats.html.eex index 7c22aaf02a51..fcf2379b6b29 100644 --- a/lib/plausible_web/templates/stats/stats.html.eex +++ b/lib/plausible_web/templates/stats/stats.html.eex @@ -1,4 +1,4 @@ -
" data-site-domain="<%= @site.domain %>"> +
<%= if @offer_email_report do %>