Fix long URLs in outbound breakdown (#3183)

* Truncate breakdown URLs

* Update seeds with Outbound Links

* Update changelog

Fixes https://github.com/plausible/analytics/issues/3158
This commit is contained in:
hq1 2023-07-24 12:37:20 +02:00 committed by GitHub
parent 79274480aa
commit b606fc1809
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 104 additions and 35 deletions

View File

@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file.
### Fixed
- Fixed weekly/monthly e-mail report [rendering issues](https://github.com/plausible/analytics/issues/284)
- Fixed [long URLs display](https://github.com/plausible/analytics/issues/3158) in Outbound Link breakdown view
## v2.0.0 - 2023-07-12

View File

@ -6,24 +6,12 @@ import Bar from '../bar'
import numberFormatter from '../../util/number-formatter'
import * as api from '../../api'
import Money from './money'
import { isValidHttpUrl, trimURL } from '../../util/url'
const MOBILE_UPPER_WIDTH = 767
const DEFAULT_WIDTH = 1080
const BREAKDOWN_LIMIT = 100
// https://stackoverflow.com/a/43467144
function isValidHttpUrl(string) {
let url;
try {
url = new URL(string);
} catch (_) {
return false;
}
return url.protocol === "http:" || url.protocol === "https:";
}
export default class PropertyBreakdown extends React.Component {
constructor(props) {
super(props)
@ -72,10 +60,10 @@ export default class PropertyBreakdown extends React.Component {
this.setState({ viewport: window.innerWidth });
}
fetch({concat}) {
fetch({ concat }) {
if (!this.props.query.filters['goal']) return
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/property/${encodeURIComponent(this.state.propKey)}`, this.props.query, {limit: BREAKDOWN_LIMIT, page: this.state.page})
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/property/${encodeURIComponent(this.state.propKey)}`, this.props.query, { limit: BREAKDOWN_LIMIT, page: this.state.page })
.then((res) => {
let breakdown = concat ? this.state.breakdown.concat(res) : res
@ -88,15 +76,15 @@ export default class PropertyBreakdown extends React.Component {
}
fetchAndReplace() {
this.fetch({concat: false})
this.fetch({ concat: false })
}
fetchAndConcat() {
this.fetch({concat: true})
this.fetch({ concat: true })
}
loadMore() {
this.setState({loading: true, page: this.state.page + 1}, this.fetchAndConcat.bind(this))
this.setState({ loading: true, page: this.state.page + 1 }, this.fetchAndConcat.bind(this))
}
renderUrl(value) {
@ -114,24 +102,24 @@ export default class PropertyBreakdown extends React.Component {
return (
<span className="flex px-2 py-1.5 group dark:text-gray-300 relative z-9 break-all">
<Link
to={{pathname: window.location.pathname, search: query.toString()}}
to={{ pathname: window.location.pathname, search: query.toString() }}
className="md:truncate hover:underline block"
>
{ value.name }
{trimURL(value.name, 100)}
</Link>
{ this.renderUrl(value) }
{this.renderUrl(value)}
</span>
)
}
renderPropValue(value) {
const query = new URLSearchParams(window.location.search)
query.set('props', JSON.stringify({[this.state.propKey]: value.name}))
query.set('props', JSON.stringify({ [this.state.propKey]: value.name }))
const { viewport } = this.state;
return (
<div className="flex items-center justify-between my-2" key={value.name}>
<div className="flex-1">
<div className="flex-1 truncate">
<Bar
count={value.unique_conversions}
plot="unique_conversions"
@ -141,7 +129,7 @@ export default class PropertyBreakdown extends React.Component {
{this.renderPropContent(value, query)}
</Bar>
</div>
<div className="dark:text-gray-200">
<div className="flex dark:text-gray-200">
<span className="font-medium inline-block w-20 text-right">{numberFormatter(value.unique_conversions)}</span>
{
viewport > MOBILE_UPPER_WIDTH ?
@ -163,7 +151,7 @@ export default class PropertyBreakdown extends React.Component {
changePropKey(newKey) {
storage.setItem(this.storageKey, newKey)
this.setState({propKey: newKey, loading: true, breakdown: [], page: 1, moreResultsAvailable: false}, this.fetchAndReplace)
this.setState({ propKey: newKey, loading: true, breakdown: [], page: 1, moreResultsAvailable: false }, this.fetchAndReplace)
}
renderLoading() {
@ -200,11 +188,11 @@ export default class PropertyBreakdown extends React.Component {
<div className="flex-col sm:flex-row flex items-center pb-1">
<span className="text-xs font-bold text-gray-600 dark:text-gray-300 self-start sm:self-auto mb-1 sm:mb-0">Breakdown by:</span>
<ul className="flex flex-wrap font-medium text-xs text-gray-500 dark:text-gray-400 leading-5 pl-1 sm:pl-2">
{ this.props.goal.prop_names.map(this.renderPill.bind(this)) }
{this.props.goal.prop_names.map(this.renderPill.bind(this))}
</ul>
</div>
{ this.renderBody() }
{ this.renderLoading()}
{this.renderBody()}
{this.renderLoading()}
</div>
)
}

View File

@ -20,3 +20,50 @@ export function externalLinkForPage(domain, page) {
const domainURL = new URL(`https://${domain}`)
return `https://${domainURL.host}${page}`
}
export function isValidHttpUrl(string) {
let url;
try {
url = new URL(string);
} catch (_) {
return false;
}
return url.protocol === "http:" || url.protocol === "https:";
}
export function trimURL(url, maxLength) {
if (url.length <= maxLength) {
return url;
}
if (isValidHttpUrl(url)) {
const [protocol, restURL] = url.split('://');
const parts = restURL.split('/');
const host = parts.shift();
if (host.length > maxLength - 5) {
return `${protocol}://${host.substr(0, maxLength - 5)}...${restURL.slice(-maxLength + 5)}`;
}
let remainingLength = maxLength - host.length - 5;
let trimmedURL = `${protocol}://${host}`;
for (const part of parts) {
if (part.length <= remainingLength) {
trimmedURL += '/' + part;
remainingLength -= part.length + 1;
} else {
const startTrim = Math.floor((remainingLength - 3) / 2);
const endTrim = Math.ceil((remainingLength - 3) / 2);
trimmedURL += `/${part.substr(0, startTrim)}...${part.slice(-endTrim)}`;
break;
}
}
return trimmedURL;
}
return url
}

View File

@ -37,6 +37,7 @@ site =
{:ok, goal2} = Plausible.Goals.create(site, %{"page_path" => "/register"})
{:ok, goal3} = Plausible.Goals.create(site, %{"page_path" => "/login"})
{:ok, goal4} = Plausible.Goals.create(site, %{"event_name" => "Purchase", "currency" => "USD"})
{:ok, outbound} = Plausible.Goals.create(site, %{"event_name" => "Outbound Link: Click"})
{:ok, _funnel} =
Plausible.Funnels.create(site, "From homepage to login", [
@ -160,6 +161,38 @@ native_stats_range
end)
|> Plausible.TestUtils.populate_stats()
native_stats_range
|> Enum.with_index()
|> Enum.flat_map(fn {date, index} ->
Enum.map(0..Enum.random(1..50), fn _ ->
geolocation = Enum.random(geolocations)
[
name: outbound.event_name,
site_id: site.id,
hostname: site.domain,
timestamp: put_random_time.(date, index),
referrer_source: Enum.random(["", "Facebook", "Twitter", "DuckDuckGo", "Google"]),
browser: Enum.random(["Edge", "Chrome", "Safari", "Firefox", "Vivaldi"]),
browser_version: to_string(Enum.random(0..50)),
screen_size: Enum.random(["Mobile", "Tablet", "Desktop", "Laptop"]),
operating_system: Enum.random(["Windows", "macOS", "Linux"]),
operating_system_version: to_string(Enum.random(0..15)),
user_id: Enum.random(1..1200),
"meta.key": ["url"],
"meta.value": [
Enum.random([
"http://dummy.site/long/1/#{String.duplicate("0x", 200)}",
"http://dummy.site/random/long/1/#{String.duplicate("0x", Enum.random(1..300))}"
])
]
]
|> Keyword.merge(geolocation)
|> then(&Plausible.Factory.build(:event, &1))
end)
end)
|> Plausible.TestUtils.populate_stats()
site =
site
|> Plausible.Site.start_import(