From b606fc18096500647ed8d74076c4fda84e144d56 Mon Sep 17 00:00:00 2001 From: hq1 Date: Mon, 24 Jul 2023 12:37:20 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + .../stats/behaviours/prop-breakdown.js | 58 ++++++++----------- assets/js/dashboard/util/url.js | 47 +++++++++++++++ priv/repo/seeds.exs | 33 +++++++++++ 4 files changed, 104 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0edbc5169..0e6755196 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/assets/js/dashboard/stats/behaviours/prop-breakdown.js b/assets/js/dashboard/stats/behaviours/prop-breakdown.js index 9fc60a910..764b441f8 100644 --- a/assets/js/dashboard/stats/behaviours/prop-breakdown.js +++ b/assets/js/dashboard/stats/behaviours/prop-breakdown.js @@ -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 ( - { value.name } + {trimURL(value.name, 100)} - { this.renderUrl(value) } + {this.renderUrl(value)} ) } 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 (
-
+
-
+
{numberFormatter(value.unique_conversions)} { viewport > MOBILE_UPPER_WIDTH ? - ( - {numberFormatter(value.total_conversions)} - - ) - : null + ( + {numberFormatter(value.total_conversions)} + + ) + : null } {numberFormatter(value.conversion_rate)}% {this.props.renderRevenueColumn && } @@ -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 {
Breakdown by:
    - { this.props.goal.prop_names.map(this.renderPill.bind(this)) } + {this.props.goal.prop_names.map(this.renderPill.bind(this))}
- { this.renderBody() } - { this.renderLoading()} + {this.renderBody()} + {this.renderLoading()}
) } diff --git a/assets/js/dashboard/util/url.js b/assets/js/dashboard/util/url.js index 34143f495..8a1c4440f 100644 --- a/assets/js/dashboard/util/url.js +++ b/assets/js/dashboard/util/url.js @@ -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 +} diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 162bf657a..da6525629 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -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(