Skip to content

Commit b606fc1

Browse files
authored
Fix long URLs in outbound breakdown (#3183)
* Truncate breakdown URLs * Update seeds with Outbound Links * Update changelog Fixes #3158
1 parent 7927448 commit b606fc1

File tree

4 files changed

+104
-35
lines changed

4 files changed

+104
-35
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file.
55

66
### Fixed
77
- Fixed weekly/monthly e-mail report [rendering issues](https://github.com/plausible/analytics/issues/284)
8+
- Fixed [long URLs display](https://github.com/plausible/analytics/issues/3158) in Outbound Link breakdown view
89

910
## v2.0.0 - 2023-07-12
1011

assets/js/dashboard/stats/behaviours/prop-breakdown.js

Lines changed: 23 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,12 @@ import Bar from '../bar'
66
import numberFormatter from '../../util/number-formatter'
77
import * as api from '../../api'
88
import Money from './money'
9+
import { isValidHttpUrl, trimURL } from '../../util/url'
910

1011
const MOBILE_UPPER_WIDTH = 767
1112
const DEFAULT_WIDTH = 1080
1213
const BREAKDOWN_LIMIT = 100
1314

14-
// https://stackoverflow.com/a/43467144
15-
function isValidHttpUrl(string) {
16-
let url;
17-
18-
try {
19-
url = new URL(string);
20-
} catch (_) {
21-
return false;
22-
}
23-
24-
return url.protocol === "http:" || url.protocol === "https:";
25-
}
26-
2715
export default class PropertyBreakdown extends React.Component {
2816
constructor(props) {
2917
super(props)
@@ -72,10 +60,10 @@ export default class PropertyBreakdown extends React.Component {
7260
this.setState({ viewport: window.innerWidth });
7361
}
7462

75-
fetch({concat}) {
63+
fetch({ concat }) {
7664
if (!this.props.query.filters['goal']) return
7765

78-
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/property/${encodeURIComponent(this.state.propKey)}`, this.props.query, {limit: BREAKDOWN_LIMIT, page: this.state.page})
66+
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/property/${encodeURIComponent(this.state.propKey)}`, this.props.query, { limit: BREAKDOWN_LIMIT, page: this.state.page })
7967
.then((res) => {
8068
let breakdown = concat ? this.state.breakdown.concat(res) : res
8169

@@ -88,15 +76,15 @@ export default class PropertyBreakdown extends React.Component {
8876
}
8977

9078
fetchAndReplace() {
91-
this.fetch({concat: false})
79+
this.fetch({ concat: false })
9280
}
9381

9482
fetchAndConcat() {
95-
this.fetch({concat: true})
83+
this.fetch({ concat: true })
9684
}
9785

9886
loadMore() {
99-
this.setState({loading: true, page: this.state.page + 1}, this.fetchAndConcat.bind(this))
87+
this.setState({ loading: true, page: this.state.page + 1 }, this.fetchAndConcat.bind(this))
10088
}
10189

10290
renderUrl(value) {
@@ -114,24 +102,24 @@ export default class PropertyBreakdown extends React.Component {
114102
return (
115103
<span className="flex px-2 py-1.5 group dark:text-gray-300 relative z-9 break-all">
116104
<Link
117-
to={{pathname: window.location.pathname, search: query.toString()}}
105+
to={{ pathname: window.location.pathname, search: query.toString() }}
118106
className="md:truncate hover:underline block"
119107
>
120-
{ value.name }
108+
{trimURL(value.name, 100)}
121109
</Link>
122-
{ this.renderUrl(value) }
110+
{this.renderUrl(value)}
123111
</span>
124112
)
125113
}
126114

127115
renderPropValue(value) {
128116
const query = new URLSearchParams(window.location.search)
129-
query.set('props', JSON.stringify({[this.state.propKey]: value.name}))
117+
query.set('props', JSON.stringify({ [this.state.propKey]: value.name }))
130118
const { viewport } = this.state;
131119

132120
return (
133121
<div className="flex items-center justify-between my-2" key={value.name}>
134-
<div className="flex-1">
122+
<div className="flex-1 truncate">
135123
<Bar
136124
count={value.unique_conversions}
137125
plot="unique_conversions"
@@ -141,17 +129,17 @@ export default class PropertyBreakdown extends React.Component {
141129
{this.renderPropContent(value, query)}
142130
</Bar>
143131
</div>
144-
<div className="dark:text-gray-200">
132+
<div className="flex dark:text-gray-200">
145133
<span className="font-medium inline-block w-20 text-right">{numberFormatter(value.unique_conversions)}</span>
146134
{
147135
viewport > MOBILE_UPPER_WIDTH ?
148-
(
149-
<span
150-
className="font-medium inline-block w-20 text-right"
151-
>{numberFormatter(value.total_conversions)}
152-
</span>
153-
)
154-
: null
136+
(
137+
<span
138+
className="font-medium inline-block w-20 text-right"
139+
>{numberFormatter(value.total_conversions)}
140+
</span>
141+
)
142+
: null
155143
}
156144
<span className="font-medium inline-block w-20 text-right">{numberFormatter(value.conversion_rate)}%</span>
157145
{this.props.renderRevenueColumn && <span className="hidden md:inline-block md:w-20 font-medium text-right"><Money formatted={value.total_revenue} /></span>}
@@ -163,7 +151,7 @@ export default class PropertyBreakdown extends React.Component {
163151

164152
changePropKey(newKey) {
165153
storage.setItem(this.storageKey, newKey)
166-
this.setState({propKey: newKey, loading: true, breakdown: [], page: 1, moreResultsAvailable: false}, this.fetchAndReplace)
154+
this.setState({ propKey: newKey, loading: true, breakdown: [], page: 1, moreResultsAvailable: false }, this.fetchAndReplace)
167155
}
168156

169157
renderLoading() {
@@ -200,11 +188,11 @@ export default class PropertyBreakdown extends React.Component {
200188
<div className="flex-col sm:flex-row flex items-center pb-1">
201189
<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>
202190
<ul className="flex flex-wrap font-medium text-xs text-gray-500 dark:text-gray-400 leading-5 pl-1 sm:pl-2">
203-
{ this.props.goal.prop_names.map(this.renderPill.bind(this)) }
191+
{this.props.goal.prop_names.map(this.renderPill.bind(this))}
204192
</ul>
205193
</div>
206-
{ this.renderBody() }
207-
{ this.renderLoading()}
194+
{this.renderBody()}
195+
{this.renderLoading()}
208196
</div>
209197
)
210198
}

assets/js/dashboard/util/url.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,50 @@ export function externalLinkForPage(domain, page) {
2020
const domainURL = new URL(`https://${domain}`)
2121
return `https://${domainURL.host}${page}`
2222
}
23+
24+
export function isValidHttpUrl(string) {
25+
let url;
26+
27+
try {
28+
url = new URL(string);
29+
} catch (_) {
30+
return false;
31+
}
32+
33+
return url.protocol === "http:" || url.protocol === "https:";
34+
}
35+
36+
37+
export function trimURL(url, maxLength) {
38+
if (url.length <= maxLength) {
39+
return url;
40+
}
41+
42+
if (isValidHttpUrl(url)) {
43+
const [protocol, restURL] = url.split('://');
44+
const parts = restURL.split('/');
45+
46+
const host = parts.shift();
47+
if (host.length > maxLength - 5) {
48+
return `${protocol}://${host.substr(0, maxLength - 5)}...${restURL.slice(-maxLength + 5)}`;
49+
}
50+
51+
let remainingLength = maxLength - host.length - 5;
52+
let trimmedURL = `${protocol}://${host}`;
53+
54+
for (const part of parts) {
55+
if (part.length <= remainingLength) {
56+
trimmedURL += '/' + part;
57+
remainingLength -= part.length + 1;
58+
} else {
59+
const startTrim = Math.floor((remainingLength - 3) / 2);
60+
const endTrim = Math.ceil((remainingLength - 3) / 2);
61+
trimmedURL += `/${part.substr(0, startTrim)}...${part.slice(-endTrim)}`;
62+
break;
63+
}
64+
}
65+
66+
return trimmedURL;
67+
}
68+
return url
69+
}

priv/repo/seeds.exs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ site =
3737
{:ok, goal2} = Plausible.Goals.create(site, %{"page_path" => "/register"})
3838
{:ok, goal3} = Plausible.Goals.create(site, %{"page_path" => "/login"})
3939
{:ok, goal4} = Plausible.Goals.create(site, %{"event_name" => "Purchase", "currency" => "USD"})
40+
{:ok, outbound} = Plausible.Goals.create(site, %{"event_name" => "Outbound Link: Click"})
4041

4142
{:ok, _funnel} =
4243
Plausible.Funnels.create(site, "From homepage to login", [
@@ -160,6 +161,38 @@ native_stats_range
160161
end)
161162
|> Plausible.TestUtils.populate_stats()
162163

164+
native_stats_range
165+
|> Enum.with_index()
166+
|> Enum.flat_map(fn {date, index} ->
167+
Enum.map(0..Enum.random(1..50), fn _ ->
168+
geolocation = Enum.random(geolocations)
169+
170+
[
171+
name: outbound.event_name,
172+
site_id: site.id,
173+
hostname: site.domain,
174+
timestamp: put_random_time.(date, index),
175+
referrer_source: Enum.random(["", "Facebook", "Twitter", "DuckDuckGo", "Google"]),
176+
browser: Enum.random(["Edge", "Chrome", "Safari", "Firefox", "Vivaldi"]),
177+
browser_version: to_string(Enum.random(0..50)),
178+
screen_size: Enum.random(["Mobile", "Tablet", "Desktop", "Laptop"]),
179+
operating_system: Enum.random(["Windows", "macOS", "Linux"]),
180+
operating_system_version: to_string(Enum.random(0..15)),
181+
user_id: Enum.random(1..1200),
182+
"meta.key": ["url"],
183+
"meta.value": [
184+
Enum.random([
185+
"http://dummy.site/long/1/#{String.duplicate("0x", 200)}",
186+
"http://dummy.site/random/long/1/#{String.duplicate("0x", Enum.random(1..300))}"
187+
])
188+
]
189+
]
190+
|> Keyword.merge(geolocation)
191+
|> then(&Plausible.Factory.build(:event, &1))
192+
end)
193+
end)
194+
|> Plausible.TestUtils.populate_stats()
195+
163196
site =
164197
site
165198
|> Plausible.Site.start_import(

0 commit comments

Comments
 (0)