Added filtered events tables to the analytics page (#15669)

closes TryGhost/Team#2087
This commit is contained in:
Elena Baidakova 2022-10-21 12:31:25 +04:00 committed by GitHub
parent dcd607ad94
commit bbf6b95cbf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 416 additions and 23 deletions

View File

@ -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}}

View 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>

View 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;
}
}

View 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;
}
}
}

View File

@ -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('+');
}
}

View File

@ -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;
}
}

View File

@ -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