Panels with campaign statistics and some fixes in computation of clicks.

This commit is contained in:
Tomas Bures 2018-12-16 13:47:08 +01:00
parent ba996d845d
commit d103a2cc79
18 changed files with 811 additions and 96 deletions

View file

@ -16,8 +16,12 @@ import {
import axios
from "../lib/axios";
import {getUrl} from "../lib/urls";
import {AlignedRow} from "../lib/form";
import {Icon} from "../lib/bootstrap-components";
import Chart from 'react-google-charts';
import styles
from "./styles.scss";
import {Link} from "react-router-dom";
@withTranslation()
@withPageHelpers
@ -30,7 +34,8 @@ export default class Statistics extends Component {
const t = props.t;
this.state = {
entity: props.entity
entity: props.entity,
statisticsOverview: props.statisticsOverview
};
this.refreshTimeoutHandler = ::this.periodicRefreshTask;
@ -38,7 +43,8 @@ export default class Statistics extends Component {
}
static propTypes = {
entity: PropTypes.object
entity: PropTypes.object,
statisticsOverview: PropTypes.object
}
@withAsyncErrorHandler
@ -48,8 +54,12 @@ export default class Statistics extends Component {
resp = await axios.get(getUrl(`rest/campaigns-stats/${this.props.entity.id}`));
const entity = resp.data;
resp = await axios.get(getUrl(`rest/campaign-statistics/${this.props.entity.id}/overview`));
const statisticsOverview = resp.data;
this.setState({
entity
entity,
statisticsOverview
});
}
@ -57,7 +67,7 @@ export default class Statistics extends Component {
// The periodic task runs all the time, so that we don't have to worry about starting/stopping it as a reaction to the buttons.
await this.refreshEntity();
if (this.refreshTimeoutHandler) { // For some reason the task gets rescheduled if server is restarted while the page is shown. That why we have this check here.
this.refreshTimeoutId = setTimeout(this.refreshTimeoutHandler, 10000);
this.refreshTimeoutId = setTimeout(this.refreshTimeoutHandler, 60000);
}
}
@ -76,49 +86,56 @@ export default class Statistics extends Component {
const t = this.props.t;
const entity = this.state.entity;
const stats = this.state.statisticsOverview;
const renderMetrics = (key, label, showZoomIn = true) => {
const val = stats[key]
return (
<AlignedRow label={label}><span className={styles.statsMetrics}>{val}</span>{showZoomIn && <span className={styles.zoomIn}><Link to={`/campaigns/${entity.id}/statistics/${key}`}><Icon icon="zoom-in"/></Link></span>}</AlignedRow>
);
}
const renderMetricsWithProgress = (key, label, progressBarClass, showZoomIn = true) => {
const val = stats[key]
if (!stats.total) {
return renderMetrics(key, label);
}
const rate = Math.round(val / stats.total * 100);
return (
<AlignedRow label={label}>
{showZoomIn && <span className={styles.statsProgressBarZoomIn}><Link to={`/campaigns/${entity.id}/statistics/${key}`}><Icon icon="zoom-in"/></Link></span>}
<div className={`progress ${styles.statsProgressBar}`}>
<div
className={`progress-bar progress-bar-${progressBarClass}`}
role="progressbar"
aria-valuenow={stats.bounced}
aria-valuemin="0"
aria-valuemax="100"
style={{minWidth: '6em', width: rate + '%'}}>
{val}&nbsp;({rate}%)
</div>
</div>
</AlignedRow>
);
}
return (
<div>
<Title>{t('campaignStatistics')}</Title>
<Chart
width="100%"
height="500px"
chartType="PieChart"
loader={<div>Loading Chart</div>}
data={[
['Task', 'Hours per Day'],
['Work', 11],
['Eat', 2],
['Commute', 2],
['Watch TV', 2],
['Sleep', 7],
]}
options={{
title: 'My Daily Activities',
}}
rootProps={{ 'data-testid': '1' }}
/>
<Chart
width="100%"
height="500px"
chartType="GeoChart"
data={[
['Country', 'Popularity'],
['Germany', 200],
['United States', 300],
['Brazil', 400],
['Canada', 500],
['France', 600],
['RU', 700],
]}
// Note: you will need to get a mapsApiKey for your project.
// See: https://developers.google.com/chart/interactive/docs/basic_load_libs#load-settings
mapsApiKey="YOUR_KEY_HERE"
rootProps={{ 'data-testid': '1' }}
/>
</div>
{renderMetrics('total', t('Total'), false)}
{renderMetrics('delivered', t('Delivered'))}
{renderMetrics('blacklisted', t('Blacklisted'), false)}
{renderMetricsWithProgress('bounced', t('Bounced'), 'info')}
{renderMetricsWithProgress('complained', t('Complaints'), 'danger')}
{renderMetricsWithProgress('unsubscribed', t('Unsubscribed'), 'warning')}
{!entity.open_tracking_disabled && renderMetricsWithProgress('opened', t('Opened'), 'success')}
{!entity.click_tracking_disabled && renderMetricsWithProgress('clicks', t('Clicked'), 'success')}
</div>
);
}
}

View file

@ -0,0 +1,52 @@
'use strict';
import React, {Component} from 'react';
import PropTypes
from 'prop-types';
import {withTranslation} from '../lib/i18n';
import {
requiresAuthenticatedUser,
Title,
withPageHelpers
} from '../lib/page';
import {withErrorHandling} from '../lib/error-handling';
import {Table} from "../lib/table";
@withTranslation()
@withPageHelpers
@withErrorHandling
@requiresAuthenticatedUser
export default class StatisticsLinkClicks extends Component {
constructor(props) {
super(props);
const t = props.t;
this.state = {
};
}
static propTypes = {
entity: PropTypes.object,
title: PropTypes.string
}
render() {
const t = this.props.t;
const linksColumns = [
{ data: 0, title: t('URL'), render: data => <code>{data}</code> },
{ data: 1, title: t('Unique visitors') },
{ data: 2, title: t('Total clicks') }
];
return (
<div>
<Title>{t('Campaign links')}</Title>
<Table ref={node => this.table = node} withHeader dataUrl={`rest/campaigns-link-clicks-table/${this.props.entity.id}`} columns={linksColumns} />
</div>
);
}
}

View file

@ -0,0 +1,231 @@
'use strict';
import React, {Component} from 'react';
import PropTypes
from 'prop-types';
import {withTranslation} from '../lib/i18n';
import {
requiresAuthenticatedUser,
Title,
withPageHelpers
} from '../lib/page';
import {
withAsyncErrorHandler,
withErrorHandling
} from '../lib/error-handling';
import axios
from "../lib/axios";
import {getUrl} from "../lib/urls";
import Chart from 'react-google-charts';
import {AlignedRow} from "../lib/form";
import {
ActionLink,
Icon
} from "../lib/bootstrap-components";
import {SubscriptionStatus} from "../../../shared/lists";
import styles from "./styles.scss";
import {Table} from "../lib/table";
import {Link} from "react-router-dom";
import mailtrainConfig from "mailtrainConfig";
@withTranslation()
@withPageHelpers
@withErrorHandling
@requiresAuthenticatedUser
export default class StatisticsOpened extends Component {
constructor(props) {
super(props);
const t = props.t;
this.state = {
entity: props.entity,
statisticsOpened: props.statisticsOpened
};
this.refreshTimeoutHandler = ::this.periodicRefreshTask;
this.refreshTimeoutId = 0;
}
static propTypes = {
entity: PropTypes.object,
statisticsOpened: PropTypes.object,
agg: PropTypes.string
}
@withAsyncErrorHandler
async refreshEntity() {
let resp;
resp = await axios.get(getUrl(`rest/campaigns-stats/${this.props.entity.id}`));
const entity = resp.data;
resp = await axios.get(getUrl(`rest/campaign-statistics/${this.props.entity.id}/opened`));
const statisticsOpened = resp.data;
this.setState({
entity,
statisticsOpened
});
}
async periodicRefreshTask() {
// The periodic task runs all the time, so that we don't have to worry about starting/stopping it as a reaction to the buttons.
await this.refreshEntity();
if (this.refreshTimeoutHandler) { // For some reason the task gets rescheduled if server is restarted while the page is shown. That why we have this check here.
this.refreshTimeoutId = setTimeout(this.refreshTimeoutHandler, 60000);
}
}
componentDidMount() {
// noinspection JSIgnoredPromiseFromCall
this.periodicRefreshTask();
}
componentWillUnmount() {
clearTimeout(this.refreshTimeoutId);
this.refreshTimeoutHandler = null;
}
render() {
const t = this.props.t;
const entity = this.state.entity;
const agg = this.props.agg;
const stats = this.state.statisticsOpened;
const subscribersColumns = [
{ data: 0, title: t('Email') },
{ data: 1, title: t('subscriptionId'), render: data => <code>{data}</code> },
{ data: 2, title: t('listId'), render: data => <code>{data}</code> },
{ data: 3, title: t('list') },
{ data: 4, title: t('listNamespace') },
{ data: 5, title: t('Opens count') }
];
console.log(this.state.statisticsOpened);
const renderNavPill = (key, label) => (
<li role="presentation" className={agg === key ? 'active' : ''}>
<Link to={`/campaigns/${entity.id}/statistics/opened/${key}`}>{label}</Link>
</li>
);
const navPills = (
<ul className={`nav nav-pills ${styles.navPills}`}>
{renderNavPill('countries', t('Countries'))}
{renderNavPill('devices', t('Devices'))}
</ul>
);
let charts = null;
if (agg === 'devices') {
charts = (
<div className={styles.charts}>
{navPills}
<h4 className={styles.chartTitle}>{t('Distribution by device type')}</h4>
<Chart
width="100%"
height="300px"
chartType="PieChart"
loader={<div>{t('Loading chart')}</div>}
data={[
[t('Device type'), t('Count')],
...stats.devices.map(entry => [entry.key, entry.count])
]}
options={{
chartArea: {
left: "25%",
top: 15,
width: "100%",
height: 270
},
tooltip: {
showColorCode: true
},
legend: {
position: "right",
alignment: "start",
textStyle: {
fontSize: 14
}
}
}}
/>
</div>
);
} else if (agg === 'countries') {
charts = (
<div className={styles.charts}>
{navPills}
<h4 className={styles.sectionTitle}>{t('Distribution by country')}</h4>
<div className="row">
<div className={`col-md-6 ${styles.chart}`}>
<Chart
width="100%"
height="300px"
chartType="PieChart"
loader={<div>{t('Loading chart')}</div>}
data={[
[t('Country'), t('Count')],
...stats.countries.map(entry => [entry.key || t('Unknown'), entry.count])
]}
options={{
chartArea: {
left: "25%",
top: 15,
width: "100%",
height: 270
},
tooltip: {
showColorCode: true
},
legend: {
position: "right",
alignment: "start",
textStyle: {
fontSize: 14
}
}
}}
/>
</div>
<div className={`col-md-6 ${styles.chart}`}>
<Chart
width="100%"
height="300px"
chartType="GeoChart"
data={[
['Country', 'Count'],
...stats.countries.map(entry => [entry.key || t('Unknown'), entry.count])
]}
mapsApiKey={mailtrainConfig.mapsApiKey}
/>
</div>
</div>
</div>
);
}
console.log(mailtrainConfig);
return (
<div>
<Title>{t('Detailed Statistics')}</Title>
{charts}
<hr/>
<h4 className={styles.sectionTitle}>{t('List of subscribers that opened the campaign')}</h4>
<Table ref={node => this.table = node} withHeader dataUrl={`rest/campaigns-opens-table/${entity.id}`} columns={subscribersColumns} />
</div>
);
}
}

View file

@ -0,0 +1,55 @@
'use strict';
import React, {Component} from 'react';
import PropTypes
from 'prop-types';
import {withTranslation} from '../lib/i18n';
import {
requiresAuthenticatedUser,
Title,
withPageHelpers
} from '../lib/page';
import {withErrorHandling} from '../lib/error-handling';
import {Table} from "../lib/table";
@withTranslation()
@withPageHelpers
@withErrorHandling
@requiresAuthenticatedUser
export default class StatisticsSubsList extends Component {
constructor(props) {
super(props);
const t = props.t;
this.state = {
};
}
static propTypes = {
entity: PropTypes.object,
status: PropTypes.number,
title: PropTypes.string
}
render() {
const t = this.props.t;
const subscribersColumns = [
{ data: 0, title: t('Email') },
{ data: 1, title: t('subscriptionId'), render: data => <code>{data}</code> },
{ data: 2, title: t('listId'), render: data => <code>{data}</code> },
{ data: 3, title: t('list') },
{ data: 4, title: t('listNamespace') }
];
return (
<div>
<Title>{this.props.title}</Title>
<Table ref={node => this.table = node} withHeader dataUrl={`rest/campaigns-subscribers-by-status-table/${this.props.entity.id}/${this.props.status}`} columns={subscribersColumns} />
</div>
);
}
}

View file

@ -27,7 +27,8 @@ import {getCampaignLabels} from './helpers';
import {Table} from "../lib/table";
import {
Button,
Icon
Icon,
ModalDialog
} from "../lib/bootstrap-components";
import axios from "../lib/axios";
import {getUrl, getPublicUrl} from "../lib/urls";
@ -214,8 +215,15 @@ class SendControls extends Component {
}
async resetAsync() {
await this.postAndMaskStateError(`rest/campaign-reset/${this.props.entity.id}`);
await this.refreshEntity();
const t = this.props.t;
this.actionDialog(
t('Confirm reset'),
t('Do you want to reset the campaign? All statistics and the track of delivered messages will be lost.'),
async () => {
await this.postAndMaskStateError(`rest/campaign-reset/${this.props.entity.id}`);
await this.refreshEntity();
}
);
}
async enableAsync() {
@ -228,16 +236,47 @@ class SendControls extends Component {
await this.refreshEntity();
}
actionDialog(title, message, callback) {
this.setState({
modalTitle: title,
modalMessage: message,
modalCallback: callback,
modalVisible: true
});
}
modalAction(isYes) {
if (isYes && this.state.modalCallback) {
this.state.modalCallback();
}
this.setState({
modalTitle: '',
modalMessage: '',
modalCallback: null,
modalVisible: false
});
}
render() {
const t = this.props.t;
const entity = this.props.entity;
const yesNoDialog = (
<ModalDialog hidden={!this.state.modalVisible} title={this.state.modalTitle} onCloseAsync={() => this.modalAction(false)} buttons={[
{ label: t('no'), className: 'btn-primary', onClickAsync: () => this.modalAction(false) },
{ label: t('yes'), className: 'btn-danger', onClickAsync: () => this.modalAction(true) }
]}>
{this.state.modalMessage}
</ModalDialog>
);
if (entity.status === CampaignStatus.IDLE || entity.status === CampaignStatus.PAUSED || (entity.status === CampaignStatus.SCHEDULED && entity.scheduled)) {
const subscrInfo = entity.subscriptionsTotal === undefined ? '' : ` (${entity.subscriptionsToSend} ${t('subscribers-1')})`;
return (
<div>
<div>{yesNoDialog}
<AlignedRow label={t('sendStatus')}>
{entity.scheduled ? t('campaignIsScheduledForDelivery') : t('campaignIsReadyToBeSentOut')}
</AlignedRow>
@ -265,7 +304,7 @@ class SendControls extends Component {
} else if (entity.status === CampaignStatus.SENDING || (entity.status === CampaignStatus.SCHEDULED && !entity.scheduled)) {
return (
<div>
<div>{yesNoDialog}
<AlignedRow label={t('sendStatus')}>
{t('campaignIsBeingSentOut')}
</AlignedRow>
@ -280,7 +319,7 @@ class SendControls extends Component {
const subscrInfo = entity.subscriptionsTotal === undefined ? '' : ` (${entity.subscriptionsToSend} ${t('subscribers-1')})`;
return (
<div>
<div>{yesNoDialog}
<AlignedRow label={t('sendStatus')}>
{t('allMessagesSent!HitContinueIfYouYouWant')}
</AlignedRow>
@ -294,7 +333,7 @@ class SendControls extends Component {
} else if (entity.status === CampaignStatus.INACTIVE) {
return (
<div>
<div>{yesNoDialog}
<AlignedRow label={t('sendStatus')}>
{t('yourCampaignIsCurrentlyDisabledClick')}
</AlignedRow>
@ -306,7 +345,7 @@ class SendControls extends Component {
} else if (entity.status === CampaignStatus.ACTIVE) {
return (
<div>
<div>{yesNoDialog}
<AlignedRow label={t('sendStatus')}>
{t('yourCampaignIsEnabledAndSendingMessages')}
</AlignedRow>

View file

@ -1,24 +1,46 @@
'use strict';
import React from 'react';
import React
from 'react';
import Status from './Status';
import Statistics from './Statistics';
import CampaignsCUD from './CUD';
import Content from './Content';
import CampaignsList from './List';
import Share from '../shares/Share';
import Files from "../lib/files";
import Status
from './Status';
import Statistics
from './Statistics';
import CampaignsCUD
from './CUD';
import Content
from './Content';
import CampaignsList
from './List';
import Share
from '../shares/Share';
import Files
from "../lib/files";
import {
CampaignSource,
CampaignStatus,
CampaignType
} from "../../../shared/campaigns";
import TriggersCUD from './triggers/CUD';
import TriggersList from './triggers/List';
import TriggersCUD
from './triggers/CUD';
import TriggersList
from './triggers/List';
import StatisticsSubsList
from "./StatisticsSubsList";
import {SubscriptionStatus} from "../../../shared/lists";
import StatisticsOpened
from "./StatisticsOpened";
import StatisticsLinkClicks
from "./StatisticsLinkClicks";
function getMenus(t) {
const aggLabels = {
'countries': t('Countries'),
'devices': t('Devices')
};
return {
'campaigns': {
title: t('campaigns'),
@ -30,7 +52,7 @@ function getMenus(t) {
resolve: {
campaign: params => `rest/campaigns-settings/${params.campaignId}`
},
link: params => `/campaigns/${params.campaignId}/edit`,
link: params => `/campaigns/${params.campaignId}/status`,
navs: {
status: {
title: t('status'),
@ -40,9 +62,53 @@ function getMenus(t) {
},
statistics: {
title: t('statistics'),
resolve: {
statisticsOverview: params => `rest/campaign-statistics/${params.campaignId}/overview`
},
link: params => `/campaigns/${params.campaignId}/statistics`,
visible: resolved => resolved.campaign.permissions.includes('viewStats') && (resolved.campaign.status === CampaignStatus.SENDING || resolved.campaign.status === CampaignStatus.PAUSED || resolved.campaign.status === CampaignStatus.FINISHED),
panelRender: props => <Statistics entity={props.resolved.campaign} />
panelRender: props => <Statistics entity={props.resolved.campaign} statisticsOverview={props.resolved.statisticsOverview} />,
children: {
delivered: {
title: t('Delivered'),
link: params => `/campaigns/${params.campaignId}/statistics/delivered`,
panelRender: props => <StatisticsSubsList entity={props.resolved.campaign} title={t('Delivered Emails')} status={SubscriptionStatus.SUBSCRIBED} />
},
complained: {
title: t('Complained'),
link: params => `/campaigns/${params.campaignId}/statistics/complained`,
panelRender: props => <StatisticsSubsList entity={props.resolved.campaign} title={t('Subscribers that Complained')} status={SubscriptionStatus.COMPLAINED} />
},
bounced: {
title: t('Bounced'),
link: params => `/campaigns/${params.campaignId}/statistics/bounced`,
panelRender: props => <StatisticsSubsList entity={props.resolved.campaign} title={t('Emails that Bounced')} status={SubscriptionStatus.BOUNCED} />
},
unsubscribed: {
title: t('Unsubscribed'),
link: params => `/campaigns/${params.campaignId}/statistics/unsubscribed`,
panelRender: props => <StatisticsSubsList entity={props.resolved.campaign} title={t('Subscribers that Unsubscribed')} status={SubscriptionStatus.UNSUBSCRIBED} />
},
'opened': {
title: t('Opened'),
resolve: {
statisticsOpened: params => `rest/campaign-statistics/${params.campaignId}/opened`
},
link: params => `/campaigns/${params.campaignId}/statistics/opened/countries`,
children: {
':agg(countries|devices)': {
title: (resolved, params) => aggLabels[params.agg],
link: params => `/campaigns/${params.campaignId}/statistics/opened/${params.agg}`,
panelRender: props => <StatisticsOpened entity={props.resolved.campaign} statisticsOpened={props.resolved.statisticsOpened} agg={props.match.params.agg} />
}
}
},
'clicks': {
title: t('Clicks'),
link: params => `/campaigns/${params.campaignId}/statistics/clicks`,
panelRender: props => <StatisticsLinkClicks entity={props.resolved.campaign} />
}
}
},
':action(edit|delete)': {
title: t('edit'),

View file

@ -39,4 +39,55 @@
.sendButtonRow {
margin-top: 10px;
}
}
.statsMetrics {
width: 10ex;
display: inline-block;
}
.statsProgressBar {
margin-right: 30px;
margin-bottom: 0px;
}
.statsProgressBarZoomIn {
float: right;
width: 30px;
text-align: right;
display: block;
}
.zoomIn {
padding-left: 15px;
}
.navPills {
margin-top: -3px;
margin-bottom: 5px;
float: right;
& > li {
display: inline-block;
float: none;
& > a {
padding: 3px 10px;
}
}
}
.charts {
margin-bottom: 30px;
.chart {
margin-bottom: 30px;
}
}
.sectionTitle {
font-weight: bold;
margin-bottom: 30px;
}