Embed improvements (#2148)

* Replace current tooltip with Popper.js

* Merge tooltip and title for top stats

* Format bounce rate and visit duration numbers in tooltip

* Add 'width=manual' mode for embed

* Add changelog entry

* Use helper function canMetricBeGraphed
This commit is contained in:
Uku Taht 2022-09-01 11:22:04 +03:00 committed by GitHub
parent f4a242ac71
commit 7683638b84
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 200 additions and 40 deletions

View File

@ -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.

View File

@ -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);
}

View File

@ -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 (
<div>
<div>{this.topStatNumberLong(stat)} {statName}</div>
{this.canMetricBeGraphed(stat) && <div className="font-normal text-xs">{this.titleFor(stat)}</div>}
</div>
)
}
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 (
<div className="flex items-center justify-between my-1 whitespace-nowrap">
<b className="mr-4 text-xl md:text-2xl dark:text-gray-100" tooltip={this.topStatTooltip(stat)}>{this.topStatNumberShort(stat)}</b>
<Tooltip info={this.topStatTooltip(stat)} className="flex items-center justify-between my-1 whitespace-nowrap">
<b className="mr-4 text-xl md:text-2xl dark:text-gray-100">{this.topStatNumberShort(stat)}</b>
{this.renderComparison(stat.name, stat.change)}
</div>
</Tooltip>
)
}
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 (
<React.Fragment key={stat.name}>
{ isClickable ?
(
<div className={`px-4 md:px-6 w-1/2 my-4 lg:w-auto group cursor-pointer select-none ${border}`} onClick={() => { updateMetric(METRIC_MAPPING[stat.name]) }} tabIndex={0} title={this.titleFor(stat)}>
<div
className={`text-xs font-bold tracking-wide text-gray-500 uppercase dark:text-gray-400 whitespace-nowrap flex w-content border-b ${isSelected ? 'text-indigo-700 dark:text-indigo-500 border-indigo-700 dark:border-indigo-500' : 'group-hover:text-indigo-700 dark:group-hover:text-indigo-500 border-transparent'}`}>
{statDisplayName}
{statExtraName && <span className="hidden sm:inline-block ml-1">{statExtraName}</span>}
</div>
{ this.renderStat(stat) }
</div>
) : (
<div className={`px-4 md:px-6 w-1/2 my-4 lg:w-auto ${border}`}>
<div className='text-xs font-bold tracking-wide text-gray-500 uppercase dark:text-gray-400 whitespace-nowrap flex'>
{stat.name}
</div>
{ this.renderStat(stat) }
</div>
)}
</React.Fragment>
<Tooltip key={stat.name} info={this.topStatTooltip(stat)} className={className} onClick={() => { this.maybeUpdateMetric(stat) }}>
<div
className={`text-xs font-bold tracking-wide text-gray-500 uppercase dark:text-gray-400 whitespace-nowrap flex w-content border-b ${isSelected ? 'text-indigo-700 dark:text-indigo-500 border-indigo-700 dark:border-indigo-500' : 'group-hover:text-indigo-700 dark:group-hover:text-indigo-500 border-transparent'}`}>
{statDisplayName}
{statExtraName && <span className="hidden sm:inline-block ml-1">{statExtraName}</span>}
</div>
<div className="flex items-center justify-between my-1 whitespace-nowrap">
<b className="mr-4 text-xl md:text-2xl dark:text-gray-100">{this.topStatNumberShort(stat)}</b>
{this.renderComparison(stat.name, stat.change)}
</div>
</Tooltip>
)
})

View File

@ -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 (
<div className={classNames('relative', className)}>
<div ref={setReferenceElement} onMouseEnter={() => setVisible(true)} onMouseLeave={() => setVisible(false)} onClick={onClick}>
{children}
</div>
{info && visible && <div ref={setPopperElement} style={styles.popper} {...attributes.popper} className="z-50 p-2 rounded text-sm text-gray-100 font-bold popper-tooltip" role="tooltip">
{info}
<div ref={setArrowElement} style={styles.arrow} className="tooltip-arrow"></div>
</div>
}
</div>
)
}

View File

@ -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": {

View File

@ -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",

View File

@ -1,4 +1,4 @@
<div class="<%= if !@conn.assigns[:embedded], do: "container", else: "max-w-screen-lg mx-auto" %>" data-site-domain="<%= @site.domain %>">
<div class="<%= stats_container_class(@conn) %>" data-site-domain="<%= @site.domain %>">
<%= if @offer_email_report do %>
<div class="w-full px-4 text-sm font-bold text-center text-blue-900 bg-blue-200 rounded transition" style="top: 91px" role="alert">
<%= link("Click here to enable weekly email reports →", to: "/#{URI.encode_www_form(@site.domain)}/settings/email-reports", class: "py-2 block") %>

View File

@ -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