mirror of
https://github.com/plausible/analytics.git
synced 2024-11-22 18:52:38 +03:00
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:
parent
79274480aa
commit
b606fc1809
@ -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
|
||||
|
||||
|
@ -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,17 +129,17 @@ 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 ?
|
||||
(
|
||||
<span
|
||||
className="font-medium inline-block w-20 text-right"
|
||||
>{numberFormatter(value.total_conversions)}
|
||||
</span>
|
||||
)
|
||||
: null
|
||||
(
|
||||
<span
|
||||
className="font-medium inline-block w-20 text-right"
|
||||
>{numberFormatter(value.total_conversions)}
|
||||
</span>
|
||||
)
|
||||
: null
|
||||
}
|
||||
<span className="font-medium inline-block w-20 text-right">{numberFormatter(value.conversion_rate)}%</span>
|
||||
{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 {
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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(
|
||||
|
Loading…
Reference in New Issue
Block a user