mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-02-10 23:36:14 -05:00
Added filtered events tables to the analytics page (#15669)
closes TryGhost/Team#2087
This commit is contained in:
parent
dcd607ad94
commit
bbf6b95cbf
7 changed files with 416 additions and 23 deletions
|
@ -44,47 +44,72 @@
|
|||
<Tabs::Tabs class="gh-tabs-analytics" as |tabs|>
|
||||
{{#if this.post.hasBeenEmailed}}
|
||||
<tabs.tab>
|
||||
<h3>Sent</h3>
|
||||
<p>{{format-number this.post.email.emailCount}}</p>
|
||||
<h3>Sent</h3>
|
||||
<p>{{format-number this.post.email.emailCount}}</p>
|
||||
</tabs.tab>
|
||||
|
||||
<tabs.tabPanel>Sent</tabs.tabPanel>
|
||||
<tabs.tabPanel>
|
||||
<Posts::PostActivityFeed
|
||||
@postId={{this.post.id}}
|
||||
@eventType="sent"
|
||||
@linkQuery={{hash filterParam=(concat "emails.post_id:[" this.post.id "]") }}
|
||||
@linkText="See filtered members for sent"
|
||||
/>
|
||||
</tabs.tabPanel>
|
||||
|
||||
{{#if this.post.showEmailOpenAnalytics }}
|
||||
<tabs.tab>
|
||||
<h3>Opened</h3>
|
||||
<p>{{format-number this.post.email.openedCount}} <strong>{{this.post.email.openRate}}%</strong></p>
|
||||
<h3>Opened</h3>
|
||||
<p>{{format-number this.post.email.openedCount}} <strong>{{this.post.email.openRate}}%</strong></p>
|
||||
</tabs.tab>
|
||||
|
||||
<tabs.tabPanel>Opened</tabs.tabPanel>
|
||||
<tabs.tabPanel>
|
||||
<Posts::PostActivityFeed
|
||||
@postId={{this.post.id}}
|
||||
@eventType="opened"
|
||||
@linkQuery={{hash filterParam=(concat "opened_emails.post_id:[" this.post.id "]") }}
|
||||
@linkText="See filtered members for opened"
|
||||
/>
|
||||
</tabs.tabPanel>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.post.showEmailClickAnalytics }}
|
||||
<tabs.tab>
|
||||
<h3>Clicked</h3>
|
||||
<p>{{format-number this.post.count.clicks}} <strong>{{this.post.clickRate}}%</strong></p>
|
||||
<h3>Clicked</h3>
|
||||
<p>{{format-number this.post.count.clicks}} <strong>{{this.post.clickRate}}%</strong></p>
|
||||
</tabs.tab>
|
||||
|
||||
<tabs.tabPanel>Clicked</tabs.tabPanel>
|
||||
<tabs.tabPanel>
|
||||
<Posts::PostActivityFeed
|
||||
@postId={{this.post.id}}
|
||||
@eventType="clicked"
|
||||
@linkQuery={{hash filterParam=(concat "clicked_links.post_id:[" this.post.id "]") }}
|
||||
@linkText="See filtered members for clicked"
|
||||
/>
|
||||
</tabs.tabPanel>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
{{#if this.post.showAudienceFeedback }}
|
||||
<tabs.tab>
|
||||
<h3><span class="hide-when-small">More </span><div class="visible-when-small">like</div><span class="hide-when-small"> this</span></h3>
|
||||
<p>{{format-number this.post.count.positive_feedback}} <strong>{{this.post.sentiment}}%</strong></p>
|
||||
</tabs.tab>
|
||||
<tabs.tab>
|
||||
<h3><span class="hide-when-small">More </span><div class="visible-when-small">like</div><span class="hide-when-small"> this</span></h3>
|
||||
<p>{{format-number this.post.count.positive_feedback}} <strong>{{this.post.sentiment}}%</strong></p>
|
||||
</tabs.tab>
|
||||
|
||||
<tabs.tabPanel>More like this</tabs.tabPanel>
|
||||
<tabs.tabPanel>
|
||||
<Posts::PostActivityFeed @postId={{this.post.id}} @eventType="feedback" />
|
||||
</tabs.tabPanel>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.post.showAttributionAnalytics }}
|
||||
<tabs.tab>
|
||||
<h3>{{gh-pluralize this.post.count.conversions "Conversions" without-count=true}}</h3>
|
||||
<p>{{format-number this.post.count.conversions}}</p>
|
||||
<h3>{{gh-pluralize this.post.count.conversions "Conversions" without-count=true}}</h3>
|
||||
<p>{{format-number this.post.count.conversions}}</p>
|
||||
</tabs.tab>
|
||||
|
||||
<tabs.tabPanel>Conversions</tabs.tabPanel>
|
||||
<tabs.tabPanel>
|
||||
<Posts::PostActivityFeed @postId={{this.post.id}} @eventType="conversion" />
|
||||
</tabs.tabPanel>
|
||||
{{/if}}
|
||||
</Tabs::Tabs>
|
||||
|
||||
|
@ -112,7 +137,7 @@
|
|||
|
||||
{{#if this.showLinks }}
|
||||
{{#if (is-empty this.links) }}
|
||||
{{!-- Empty state --}}
|
||||
{{!-- Empty state --}}
|
||||
{{else}}
|
||||
<Posts::LinksTable @links={{this.links}} @updateLink={{this.updateLink}} />
|
||||
{{/if}}
|
||||
|
|
79
ghost/admin/app/components/posts/post-activity-feed.hbs
Normal file
79
ghost/admin/app/components/posts/post-activity-feed.hbs
Normal file
|
@ -0,0 +1,79 @@
|
|||
<div class="gh-dashboard-list-body gh-post-activity-feed">
|
||||
{{#let (activity-feed-fetcher filter=(members-event-filter post=@postId excludedEvents=this.getEventTypes) pageSize=this.pageSize) as |eventsFetcher|}}
|
||||
{{#if eventsFetcher.isError}}
|
||||
<div class="gh-dashboard-list-error">
|
||||
<p>There was an error loading events</p>
|
||||
{{#if eventsFetcher.errorMessage}}
|
||||
<code>{{eventsFetcher.errorMessage}}</code>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#each eventsFetcher.data as |event|}}
|
||||
{{#let (parse-member-event event) as |parsedEvent|}}
|
||||
<div class="gh-dashboard-list-item">
|
||||
<div class="gh-dashboard-list-item-sub">
|
||||
<GhMemberAvatar @member={{parsedEvent.member}} @containerClass="w6 h6 mr3 flex-shrink-0" />
|
||||
<LinkTo class="gh-dashboard-list-text" @route="member" @model="{{parsedEvent.memberId}}">{{parsedEvent.subject}}</LinkTo>
|
||||
</div>
|
||||
<div class="gh-dashboard-list-item-sub">
|
||||
{{svg-jar parsedEvent.icon }}
|
||||
<span class="gh-dashboard-list-subtext">
|
||||
<span class="gh-members-activity-description">
|
||||
<span class="gh-members-activity-event-text">{{capitalize-first-letter parsedEvent.action}}</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="gh-dashboard-list-item-sub">
|
||||
<span class="gh-dashboard-list-subtext">{{moment-from-now parsedEvent.timestamp}}</span>
|
||||
</div>
|
||||
</div>
|
||||
{{/let}}
|
||||
{{/each}}
|
||||
|
||||
{{#let (compute (fn this.getAmountOfStubs eventsFetcher)) as |stubs|}}
|
||||
{{#each stubs}}
|
||||
<div class="gh-dashboard-list-item"></div>
|
||||
{{/each}}
|
||||
{{/let}}
|
||||
|
||||
<div class="gh-post-activity-feed-footer">
|
||||
<div class="gh-post-activity-feed-pagination">
|
||||
<div class="gh-post-activity-feed-pagination-group">
|
||||
<button
|
||||
class="gh-post-activity-feed-pagination-button gh-post-activity-feed-prev-button"
|
||||
type="button"
|
||||
title="Previous page"
|
||||
disabled={{compute (fn this.isPreviousButtonDisabled eventsFetcher)}}
|
||||
{{on "click" eventsFetcher.loadPreviousPage}}
|
||||
>
|
||||
{{svg-jar "arrow-left-pagination"}}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="gh-post-activity-feed-pagination-button gh-post-activity-feed-next-button"
|
||||
type="button"
|
||||
title="Next page"
|
||||
disabled={{compute (fn this.isNextButtonDisabled eventsFetcher)}}
|
||||
{{on "click" eventsFetcher.loadNextPage}}
|
||||
>
|
||||
{{svg-jar "arrow-right-pagination"}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Showing {{eventsFetcher.previousEvents}} - {{eventsFetcher.shownEvents}} of {{eventsFetcher.totalEvents}}
|
||||
</div>
|
||||
|
||||
{{#if (and @linkQuery @linkText)}}
|
||||
<LinkTo
|
||||
class="gh-post-activity-feed-pagination-link"
|
||||
@route="members"
|
||||
@query={{@linkQuery}}
|
||||
>
|
||||
{{svg-jar "filter"}}
|
||||
{{@linkText}}
|
||||
</LinkTo>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{/let}}
|
||||
</div>
|
52
ghost/admin/app/components/posts/post-activity-feed.js
Normal file
52
ghost/admin/app/components/posts/post-activity-feed.js
Normal file
|
@ -0,0 +1,52 @@
|
|||
import Component from '@glimmer/component';
|
||||
import {action} from '@ember/object';
|
||||
|
||||
const allEvents = [
|
||||
'comment_event',
|
||||
'click_event',
|
||||
'signup_event',
|
||||
'subscription_event',
|
||||
'email_delivered_event',
|
||||
'email_opened_event',
|
||||
'email_failed_event',
|
||||
'feedback_event'
|
||||
];
|
||||
|
||||
const eventTypes = {
|
||||
sent: ['email_delivered_event'],
|
||||
opened: ['email_opened_event'],
|
||||
clicked: ['click_event'],
|
||||
feedback: ['feedback_event'],
|
||||
conversion: ['subscription_event', 'signup_event']
|
||||
};
|
||||
|
||||
export default class PostActivityFeed extends Component {
|
||||
_pageSize = 5;
|
||||
|
||||
get getEventTypes() {
|
||||
const filteredEvents = eventTypes[this.args.eventType];
|
||||
return allEvents.filter(event => !filteredEvents.includes(event));
|
||||
}
|
||||
|
||||
get pageSize() {
|
||||
return this._pageSize;
|
||||
}
|
||||
|
||||
// calculate amount of empty rows which require to keep table height the same for each tab/page
|
||||
@action
|
||||
getAmountOfStubs({data}) {
|
||||
const stubs = this._pageSize - data.length;
|
||||
|
||||
return new Array(stubs).fill(1);
|
||||
}
|
||||
|
||||
@action
|
||||
isPreviousButtonDisabled({hasReachedStart, isLoading}) {
|
||||
return hasReachedStart || isLoading;
|
||||
}
|
||||
|
||||
@action
|
||||
isNextButtonDisabled({hasReachedEnd, isLoading}) {
|
||||
return hasReachedEnd || isLoading;
|
||||
}
|
||||
}
|
141
ghost/admin/app/helpers/activity-feed-fetcher.js
Normal file
141
ghost/admin/app/helpers/activity-feed-fetcher.js
Normal file
|
@ -0,0 +1,141 @@
|
|||
import moment from 'moment-timezone';
|
||||
import {Resource} from 'ember-could-get-used-to-this';
|
||||
import {TrackedArray} from 'tracked-built-ins';
|
||||
import {action} from '@ember/object';
|
||||
import {inject as service} from '@ember/service';
|
||||
import {task} from 'ember-concurrency';
|
||||
import {tracked} from '@glimmer/tracking';
|
||||
|
||||
const actions = {
|
||||
showPrevious: 'showPrevious',
|
||||
showNext: 'showNext'
|
||||
};
|
||||
|
||||
export default class MembersEventsFetcher extends Resource {
|
||||
@service ajax;
|
||||
@service ghostPaths;
|
||||
@service store;
|
||||
|
||||
@tracked data = new TrackedArray([]);
|
||||
@tracked isLoading = false;
|
||||
@tracked isError = false;
|
||||
@tracked errorMessage = null;
|
||||
|
||||
@tracked hasReachedStart = true;
|
||||
@tracked hasReachedEnd = true;
|
||||
|
||||
@tracked shownEvents = 0;
|
||||
@tracked totalEvents = 0;
|
||||
|
||||
// save last event's date of each page for easy navigation to previous page
|
||||
@tracked eventsBookmarks = [];
|
||||
|
||||
get value() {
|
||||
return {
|
||||
isLoading: this.isLoading,
|
||||
isError: this.isError,
|
||||
errorMessage: this.errorMessage,
|
||||
data: this.data,
|
||||
loadNextPage: this.loadNextPage,
|
||||
loadPreviousPage: this.loadPreviousPage,
|
||||
hasReachedStart: this.hasReachedStart,
|
||||
hasReachedEnd: this.hasReachedEnd,
|
||||
totalEvents: this.totalEvents,
|
||||
shownEvents: this.shownEvents,
|
||||
previousEvents: this.getAmountOfPreviousEvents()
|
||||
};
|
||||
}
|
||||
|
||||
getAmountOfPreviousEvents() {
|
||||
return this.totalEvents > this.args.named.pageSize
|
||||
? this.shownEvents - this.args.named.pageSize + 1
|
||||
: this.data.length;
|
||||
}
|
||||
|
||||
async setup() {
|
||||
const currentTime = moment.utc().format('YYYY-MM-DD HH:mm:ss');
|
||||
let filter = `data.created_at:<'${currentTime}'`;
|
||||
|
||||
if (this.args.named.filter) {
|
||||
filter += `+${this.args.named.filter}`;
|
||||
}
|
||||
|
||||
await this.loadEventsTask.perform({filter}, actions.showNext);
|
||||
}
|
||||
|
||||
@action
|
||||
loadNextPage() {
|
||||
const lastEvent = this.data[this.data.length - 1];
|
||||
const lastEventDate = moment.utc(lastEvent.data.created_at).format('YYYY-MM-DD HH:mm:ss');
|
||||
let filter = `data.created_at:<'${lastEventDate}'`;
|
||||
|
||||
if (this.args.named.filter) {
|
||||
filter += `+${this.args.named.filter}`;
|
||||
}
|
||||
|
||||
this.eventsBookmarks.push(lastEventDate);
|
||||
this.loadEventsTask.perform({filter}, actions.showNext);
|
||||
}
|
||||
|
||||
@action
|
||||
loadPreviousPage() {
|
||||
this.eventsBookmarks.pop();
|
||||
let filter = `data.created_at:<'${this.eventsBookmarks[this.eventsBookmarks.length - 1]}'`;
|
||||
|
||||
if (this.args.named.filter) {
|
||||
filter += `+${this.args.named.filter}`;
|
||||
}
|
||||
|
||||
this.shownEvents = this.shownEvents - this.data.length;
|
||||
|
||||
this.loadEventsTask.perform({filter}, actions.showPrevious);
|
||||
}
|
||||
|
||||
updateState(meta, actionType) {
|
||||
if (!this.data.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.totalEvents) {
|
||||
this.totalEvents = meta.pagination.total;
|
||||
}
|
||||
|
||||
if (actionType === actions.showNext) {
|
||||
this.shownEvents = this.shownEvents + this.data.length;
|
||||
}
|
||||
|
||||
this.hasReachedStart = this.totalEvents === meta.pagination.total;
|
||||
this.hasReachedEnd = this.shownEvents === this.totalEvents;
|
||||
|
||||
// todo: it's temporarily fix, pagination breaks if few events happen at the same time, easy to reproduce on email clicks
|
||||
if ((this.shownEvents < this.totalEvents) && (this.data.length < this.args.named.pageSize)) {
|
||||
this.hasReachedEnd = true;
|
||||
}
|
||||
}
|
||||
|
||||
@task
|
||||
*loadEventsTask(queryParams, actionType) {
|
||||
try {
|
||||
this.isLoading = true;
|
||||
|
||||
const url = this.ghostPaths.url.api('members/events');
|
||||
const data = Object.assign({}, queryParams, {limit: this.args.named.pageSize});
|
||||
const {events, meta} = yield this.ajax.request(url, {data});
|
||||
|
||||
this.data = events;
|
||||
this.updateState(meta, actionType);
|
||||
} catch (e) {
|
||||
this.isError = true;
|
||||
|
||||
const errorMessage = e.payload?.errors?.[0]?.message;
|
||||
if (errorMessage) {
|
||||
this.errorMessage = errorMessage;
|
||||
}
|
||||
|
||||
// TODO: log to Sentry
|
||||
console.error(e); // eslint-disable-line
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -13,7 +13,7 @@ export default class MembersEventFilter extends Helper {
|
|||
|
||||
compute(
|
||||
positionalParams,
|
||||
{excludedEvents = [], member = '', excludeEmailEvents = false}
|
||||
{excludedEvents = [], member = '', post = '', excludeEmailEvents = false}
|
||||
) {
|
||||
const excludedEventsSet = new Set();
|
||||
|
||||
|
@ -43,6 +43,10 @@ export default class MembersEventFilter extends Helper {
|
|||
filterParts.push(`data.member_id:${member}`);
|
||||
}
|
||||
|
||||
if (post) {
|
||||
filterParts.push(`data.post_id:${post}`);
|
||||
}
|
||||
|
||||
return filterParts.join('+');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1596,7 +1596,7 @@ a.gh-post-list-cta.stats.is-hovered:hover > * {
|
|||
}
|
||||
|
||||
.gh-tabs-analytics .tab-panel-selected {
|
||||
padding: 12px 26px;
|
||||
padding: 12px 26px 0;
|
||||
/* help to hide shadow from selected tab */
|
||||
opacity: 0.99999;
|
||||
background-color: #ffffff;
|
||||
|
@ -1629,13 +1629,18 @@ a.gh-post-list-cta.stats.is-hovered:hover > * {
|
|||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.gh-tabs-analytics .gh-dashboard-list-item {
|
||||
grid-template-columns: 40% 40% 20%;
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.gh-tabs-analytics .tab{
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.gh-tabs-analytics .tab-panel-selected {
|
||||
padding: 12px 18px;
|
||||
padding: 12px 18px 0;
|
||||
}
|
||||
|
||||
.gh-tabs-analytics .tab-list {
|
||||
|
@ -1662,7 +1667,7 @@ a.gh-post-list-cta.stats.is-hovered:hover > * {
|
|||
}
|
||||
|
||||
.gh-tabs-analytics .tab-panel-selected {
|
||||
padding: 12px 14px;
|
||||
padding: 12px 14px 0;
|
||||
}
|
||||
|
||||
.gh-tabs-analytics p {
|
||||
|
@ -1673,3 +1678,90 @@ a.gh-post-list-cta.stats.is-hovered:hover > * {
|
|||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.gh-post-activity-feed-pagination svg {
|
||||
width: 7px;
|
||||
height: 12px;
|
||||
fill: #2BBA3C;
|
||||
}
|
||||
|
||||
.gh-post-activity-feed-footer {
|
||||
display: flex;
|
||||
min-height: 50px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
border-top: 1px solid #eceef0;
|
||||
}
|
||||
|
||||
.gh-post-activity-feed-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: #ABB0B6;
|
||||
}
|
||||
|
||||
.gh-post-activity-feed-pagination-button {
|
||||
padding: 8px 8px;
|
||||
}
|
||||
|
||||
.gh-post-activity-feed-pagination-button:disabled {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.gh-post-activity-feed-pagination-button:hover:not(:disabled) {
|
||||
filter: brightness(0.8);
|
||||
}
|
||||
|
||||
.gh-post-activity-feed-pagination-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.3;
|
||||
color: #2BBA3C;
|
||||
}
|
||||
|
||||
.gh-post-activity-feed-pagination-link:hover {
|
||||
filter: brightness(0.8);
|
||||
}
|
||||
|
||||
.gh-post-activity-feed-pagination-link svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.gh-post-activity-feed-pagination-link path {
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
.gh-post-activity-feed .gh-dashboard-list-item + .gh-dashboard-list-item {
|
||||
border-top: 1px solid rgba(235, 238, 240, 0.5);
|
||||
}
|
||||
|
||||
.gh-post-activity-feed .gh-dashboard-list-item {
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
.gh-post-activity-feed .gh-dashboard-list-subtext,
|
||||
.gh-post-activity-feed .gh-members-activity-description {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.gh-post-activity-feed-pagination-group {
|
||||
font-size: 0px;
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.gh-post-activity-feed-footer {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 16px 0;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -118,7 +118,7 @@ module.exports = function (defaults) {
|
|||
includePolyfill: false
|
||||
},
|
||||
'ember-composable-helpers': {
|
||||
only: ['join', 'optional', 'pick', 'toggle', 'toggle-action']
|
||||
only: ['join', 'optional', 'pick', 'toggle', 'toggle-action', 'compute']
|
||||
},
|
||||
'ember-promise-modals': {
|
||||
excludeCSS: true
|
||||
|
|
Loading…
Add table
Reference in a new issue