Ghost/ghost/admin/app/components/posts/analytics.js
Sag 98b51b666d
Fixed ember concurrency cancellation errors (#20324)
fixes https://linear.app/tryghost/issue/SLO-121
fixes https://linear.app/tryghost/issue/SLO-138
fixes https://linear.app/tryghost/issue/SLO-139
fixes https://linear.app/tryghost/issue/SLO-140
fixes https://linear.app/tryghost/issue/SLO-141
fixes https://linear.app/tryghost/issue/SLO-142

- ember-concurrency prevents two executions of the same task from
running at the same time
- when a task is cancelled, the library raises an error by default
- however, we don't need to surface that error as the cancellation of
the second execution is intentional
2024-06-04 16:20:49 +02:00

321 lines
9.1 KiB
JavaScript

import Component from '@glimmer/component';
import {action} from '@ember/object';
import {didCancel, task} from 'ember-concurrency';
import {inject as service} from '@ember/service';
import {tracked} from '@glimmer/tracking';
/**
* @typedef {import('../../services/dashboard-stats').SourceAttributionCount} SourceAttributionCount
*/
const DISPLAY_OPTIONS = [{
name: 'Free signups',
value: 'signups'
}, {
name: 'Paid conversions',
value: 'paid'
}];
export default class Analytics extends Component {
@service ajax;
@service ghostPaths;
@service settings;
@service membersUtils;
@service utils;
@service feature;
@service store;
@tracked sources = null;
@tracked links = null;
@tracked mentions = null;
@tracked sortColumn = 'signups';
@tracked showSuccess;
@tracked updateLinkId;
displayOptions = DISPLAY_OPTIONS;
get post() {
return this.args.post;
}
get allowedDisplayOptions() {
if (!this.hasPaidConversionData) {
return this.displayOptions.filter(d => d.value === 'signups');
}
if (!this.hasFreeSignups) {
return this.displayOptions.filter(d => d.value === 'paid');
}
return this.displayOptions;
}
get isDropdownDisabled() {
if (!this.hasPaidConversionData || !this.hasFreeSignups) {
return true;
}
return false;
}
get selectedDisplayOption() {
if (!this.hasPaidConversionData) {
return this.displayOptions.find(d => d.value === 'signups');
}
if (!this.hasFreeSignups) {
return this.displayOptions.find(d => d.value === 'paid');
}
return this.displayOptions.find(d => d.value === this.sortColumn) ?? this.displayOptions[0];
}
get selectedSortColumn() {
if (!this.hasPaidConversionData) {
return 'signups';
}
if (!this.hasFreeSignups) {
return 'paid';
}
return this.sortColumn;
}
get hasPaidConversionData() {
return this.sources.some(sourceData => sourceData.paidConversions > 0);
}
get hasFreeSignups() {
return this.sources.some(sourceData => sourceData.signups > 0);
}
get totalFeedback() {
return this.post.count.positive_feedback + this.post.count.negative_feedback;
}
get feedbackChartData() {
const values = [this.post.count.positive_feedback, this.post.count.negative_feedback];
const labels = ['More like this', 'Less like this'];
const links = [
{filterParam: '(feedback.post_id:\'' + this.post.id + '\'+feedback.score:1)'},
{filterParam: '(feedback.post_id:\'' + this.post.id + '\'+feedback.score:0)'}
];
const colors = ['#F080B2', '#8452f633'];
return {values, labels, links, colors};
}
@action
onDisplayChange(selected) {
this.sortColumn = selected.value;
}
@action
setSortColumn(column) {
this.sortColumn = column;
}
@action
updateLink(linkId, linkTo) {
if (this._updateLinks.isRunning) {
return this._updateLinks.last;
}
return this._updateLinks.perform(linkId, linkTo);
}
@action
loadData() {
if (this.showSources) {
this.fetchReferrersStats();
} else {
this.sources = [];
}
if (this.showLinks) {
this.fetchLinks();
} else {
this.links = [];
}
if (this.showMentions) {
this.fetchMentions();
} else {
this.mentions = [];
}
}
updateLinkData(linksData) {
let updatedLinks;
if (this.links?.length) {
updatedLinks = this.links.map((link) => {
let linkData = linksData.find(l => l.link.link_id === link.link.link_id);
if (linkData) {
return {
...linkData,
link: {
...linkData.link,
originalTo: linkData.link.to,
to: this.utils.cleanTrackedUrl(linkData.link.to, false),
title: this.utils.cleanTrackedUrl(linkData.link.to, true)
}
};
}
return link;
});
} else {
updatedLinks = linksData.map((link) => {
return {
...link,
link: {
...link.link,
originalTo: link.link.to,
to: this.utils.cleanTrackedUrl(link.link.to, false),
title: this.utils.cleanTrackedUrl(link.link.to, true)
}
};
});
}
// Remove duplicates by title ad merge
const linksByTitle = updatedLinks.reduce((acc, link) => {
if (!acc[link.link.title]) {
acc[link.link.title] = link;
} else {
if (!acc[link.link.title].count) {
acc[link.link.title].count = {clicks: 0};
}
if (!acc[link.link.title].count.clicks) {
acc[link.link.title].count.clicks = 0;
}
acc[link.link.title].count.clicks += (link.count?.clicks ?? 0);
}
return acc;
}, {});
this.links = Object.values(linksByTitle);
}
async fetchReferrersStats() {
try {
if (this._fetchReferrersStats.isRunning) {
return this._fetchReferrersStats.last;
}
return this._fetchReferrersStats.perform();
} catch (e) {
// Do not throw cancellation errors
if (didCancel(e)) {
return;
}
throw e;
}
}
async fetchLinks() {
try {
if (this._fetchLinks.isRunning) {
return this._fetchLinks.last;
}
return this._fetchLinks.perform();
} catch (e) {
// Do not throw cancellation errors
if (didCancel(e)) {
return;
}
throw e;
}
}
@task
*_updateLinks(linkId, newLink) {
this.updateLinkId = linkId;
let currentLink;
this.links = this.links?.map((link) => {
if (link.link.link_id === linkId) {
currentLink = new URL(link.link.originalTo);
return {
...link,
link: {
...link.link,
to: this.utils.cleanTrackedUrl(newLink, false),
title: this.utils.cleanTrackedUrl(newLink, true)
}
};
}
return link;
});
const filter = `post_id:'${this.post.id}'+to:'${currentLink}'`;
let bulkUpdateUrl = this.ghostPaths.url.api(`links/bulk`) + `?filter=${encodeURIComponent(filter)}`;
yield this.ajax.put(bulkUpdateUrl, {
data: {
bulk: {
action: 'updateLink',
meta: {link: {to: newLink}}
}
}
});
// Refresh links data
const linksFilter = `post_id:'${this.post.id}'`;
let statsUrl = this.ghostPaths.url.api(`links/`) + `?filter=${encodeURIComponent(linksFilter)}`;
let result = yield this.ajax.request(statsUrl);
this.updateLinkData(result.links);
this.showSuccess = this.updateLinkId;
setTimeout(() => {
this.showSuccess = null;
}, 2000);
}
@task
*_fetchReferrersStats() {
let statsUrl = this.ghostPaths.url.api(`stats/referrers/posts/${this.post.id}`);
let result = yield this.ajax.request(statsUrl);
this.sources = result.stats.map((stat) => {
return {
source: stat.source ?? 'Direct',
signups: stat.signups,
paidConversions: stat.paid_conversions
};
});
}
@task
*_fetchLinks() {
const filter = `post_id:'${this.post.id}'`;
let statsUrl = this.ghostPaths.url.api(`links/`) + `?filter=${encodeURIComponent(filter)}`;
let result = yield this.ajax.request(statsUrl);
this.updateLinkData(result.links);
}
async fetchMentions() {
if (this._fetchMentions.isRunning) {
return this._fetchMentions.last;
}
return this._fetchMentions.perform();
}
@task
*_fetchMentions() {
const filter = `resource_id:'${this.post.id}'+resource_type:post`;
this.mentions = yield this.store.query('mention', {limit: 5, order: 'created_at desc', filter});
}
get showLinks() {
return this.post.showEmailClickAnalytics;
}
get showSources() {
return this.post.showAttributionAnalytics;
}
get showMentions() {
return this.feature.get('webmentions');
}
get isLoaded() {
return this.links !== null && this.souces !== null && this.mentions !== null;
}
}