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 &&
+ }
+
+ )
+}
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 %>
<%= link("Click here to enable weekly email reports →", to: "/#{URI.encode_www_form(@site.domain)}/settings/email-reports", class: "py-2 block") %>
diff --git a/lib/plausible_web/views/stats_view.ex b/lib/plausible_web/views/stats_view.ex
index 0f33dd723cf3..8bd16310072a 100644
--- a/lib/plausible_web/views/stats_view.ex
+++ b/lib/plausible_web/views/stats_view.ex
@@ -53,6 +53,14 @@ defmodule PlausibleWeb.StatsView do
"""
end
+ def stats_container_class(conn) do
+ cond do
+ conn.assigns[:embedded] && conn.assigns[:width] == "manual" -> ""
+ conn.assigns[:embedded] -> "max-width-screen-lg mx-auto"
+ !conn.assigns[:embedded] -> "container"
+ end
+ end
+
defp bar_width(count, all) do
max =
Enum.max_by(all, fn