From d103a2cc794c3ba3b232f2a48784e52008e7bf18 Mon Sep 17 00:00:00 2001 From: Tomas Bures Date: Sun, 16 Dec 2018 13:47:08 +0100 Subject: [PATCH] Panels with campaign statistics and some fixes in computation of clicks. --- client/src/campaigns/Statistics.js | 103 ++++---- client/src/campaigns/StatisticsLinkClicks.js | 52 ++++ client/src/campaigns/StatisticsOpened.js | 231 ++++++++++++++++++ client/src/campaigns/StatisticsSubsList.js | 55 +++++ client/src/campaigns/Status.js | 55 ++++- client/src/campaigns/root.js | 90 ++++++- client/src/campaigns/styles.scss | 53 +++- client/src/lib/form.js | 13 +- client/src/lib/page.js | 2 +- client/src/lib/table.js | 9 +- client/src/settings/Update.js | 1 + server/lib/client-helpers.js | 7 +- server/models/campaigns.js | 168 ++++++++++++- server/models/links.js | 12 +- server/models/settings.js | 2 +- server/routes/links.js | 28 +-- server/routes/rest/campaigns.js | 21 ++ .../migrations/20170506102634_v1_to_v2.js | 5 + 18 files changed, 811 insertions(+), 96 deletions(-) create mode 100644 client/src/campaigns/StatisticsLinkClicks.js create mode 100644 client/src/campaigns/StatisticsOpened.js create mode 100644 client/src/campaigns/StatisticsSubsList.js diff --git a/client/src/campaigns/Statistics.js b/client/src/campaigns/Statistics.js index f527aa79..419db352 100644 --- a/client/src/campaigns/Statistics.js +++ b/client/src/campaigns/Statistics.js @@ -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 ( + {val}{showZoomIn && } + ); + } + + 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 ( + + {showZoomIn && } +
+
+ {val} ({rate}%) +
+
+
+ ); + } return (
{t('campaignStatistics')} - Loading Chart
} - 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' }} - /> - - - + {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')} + ); } } \ No newline at end of file diff --git a/client/src/campaigns/StatisticsLinkClicks.js b/client/src/campaigns/StatisticsLinkClicks.js new file mode 100644 index 00000000..a81eac15 --- /dev/null +++ b/client/src/campaigns/StatisticsLinkClicks.js @@ -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 => {data} }, + { data: 1, title: t('Unique visitors') }, + { data: 2, title: t('Total clicks') } + ]; + + return ( +
+ {t('Campaign links')} + + this.table = node} withHeader dataUrl={`rest/campaigns-link-clicks-table/${this.props.entity.id}`} columns={linksColumns} /> + + ); + } +} \ No newline at end of file diff --git a/client/src/campaigns/StatisticsOpened.js b/client/src/campaigns/StatisticsOpened.js new file mode 100644 index 00000000..18dbe356 --- /dev/null +++ b/client/src/campaigns/StatisticsOpened.js @@ -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 => {data} }, + { data: 2, title: t('listId'), render: data => {data} }, + { 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) => ( +
  • + {label} +
  • + ); + + const navPills = ( +
      + {renderNavPill('countries', t('Countries'))} + {renderNavPill('devices', t('Devices'))} +
    + ); + + + let charts = null; + + if (agg === 'devices') { + charts = ( +
    + {navPills} +

    {t('Distribution by device type')}

    + {t('Loading chart')}
    } + 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 + } + } + }} + /> + + ); + } else if (agg === 'countries') { + charts = ( +
    + {navPills} +

    {t('Distribution by country')}

    +
    +
    + {t('Loading chart')}
    } + 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 + } + } + }} + /> +
    +
    + [entry.key || t('Unknown'), entry.count]) + ]} + mapsApiKey={mailtrainConfig.mapsApiKey} + /> +
    +
    + + ); + } + + console.log(mailtrainConfig); + + return ( +
    + {t('Detailed Statistics')} + + {charts} + +
    + +

    {t('List of subscribers that opened the campaign')}

    +
    this.table = node} withHeader dataUrl={`rest/campaigns-opens-table/${entity.id}`} columns={subscribersColumns} /> + + ); + } +} \ No newline at end of file diff --git a/client/src/campaigns/StatisticsSubsList.js b/client/src/campaigns/StatisticsSubsList.js new file mode 100644 index 00000000..c31a2264 --- /dev/null +++ b/client/src/campaigns/StatisticsSubsList.js @@ -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 => {data} }, + { data: 2, title: t('listId'), render: data => {data} }, + { data: 3, title: t('list') }, + { data: 4, title: t('listNamespace') } + ]; + + return ( +
    + {this.props.title} + +
    this.table = node} withHeader dataUrl={`rest/campaigns-subscribers-by-status-table/${this.props.entity.id}/${this.props.status}`} columns={subscribersColumns} /> + + ); + } +} \ No newline at end of file diff --git a/client/src/campaigns/Status.js b/client/src/campaigns/Status.js index 0589e814..f5df1455 100644 --- a/client/src/campaigns/Status.js +++ b/client/src/campaigns/Status.js @@ -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 = ( + + ); + 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 ( -
    +
    {yesNoDialog} {entity.scheduled ? t('campaignIsScheduledForDelivery') : t('campaignIsReadyToBeSentOut')} @@ -265,7 +304,7 @@ class SendControls extends Component { } else if (entity.status === CampaignStatus.SENDING || (entity.status === CampaignStatus.SCHEDULED && !entity.scheduled)) { return ( -
    +
    {yesNoDialog} {t('campaignIsBeingSentOut')} @@ -280,7 +319,7 @@ class SendControls extends Component { const subscrInfo = entity.subscriptionsTotal === undefined ? '' : ` (${entity.subscriptionsToSend} ${t('subscribers-1')})`; return ( -
    +
    {yesNoDialog} {t('allMessagesSent!HitContinueIfYouYouWant')} @@ -294,7 +333,7 @@ class SendControls extends Component { } else if (entity.status === CampaignStatus.INACTIVE) { return ( -
    +
    {yesNoDialog} {t('yourCampaignIsCurrentlyDisabledClick')} @@ -306,7 +345,7 @@ class SendControls extends Component { } else if (entity.status === CampaignStatus.ACTIVE) { return ( -
    +
    {yesNoDialog} {t('yourCampaignIsEnabledAndSendingMessages')} diff --git a/client/src/campaigns/root.js b/client/src/campaigns/root.js index 4fa2303a..b266182f 100644 --- a/client/src/campaigns/root.js +++ b/client/src/campaigns/root.js @@ -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 => + panelRender: props => , + children: { + delivered: { + title: t('Delivered'), + link: params => `/campaigns/${params.campaignId}/statistics/delivered`, + panelRender: props => + }, + complained: { + title: t('Complained'), + link: params => `/campaigns/${params.campaignId}/statistics/complained`, + panelRender: props => + }, + bounced: { + title: t('Bounced'), + link: params => `/campaigns/${params.campaignId}/statistics/bounced`, + panelRender: props => + }, + unsubscribed: { + title: t('Unsubscribed'), + link: params => `/campaigns/${params.campaignId}/statistics/unsubscribed`, + panelRender: props => + }, + '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 => + } + } + }, + 'clicks': { + title: t('Clicks'), + link: params => `/campaigns/${params.campaignId}/statistics/clicks`, + panelRender: props => + } + } }, ':action(edit|delete)': { title: t('edit'), diff --git a/client/src/campaigns/styles.scss b/client/src/campaigns/styles.scss index 363268c1..9760c4ee 100644 --- a/client/src/campaigns/styles.scss +++ b/client/src/campaigns/styles.scss @@ -39,4 +39,55 @@ .sendButtonRow { margin-top: 10px; -} \ No newline at end of file +} + +.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; +} + + diff --git a/client/src/lib/form.js b/client/src/lib/form.js index 9ad478c4..93a2b774 100644 --- a/client/src/lib/form.js +++ b/client/src/lib/form.js @@ -288,10 +288,12 @@ class InputField extends Component { let type = 'text'; if (props.type === 'password') { type = 'password'; + } else if (props.type === 'hidden') { + type = 'hidden'; } return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help, - owner.updateFormValue(id, evt.target.value)}/> + owner.updateFormValue(id, evt.target.value)}/> ); } } @@ -736,12 +738,15 @@ class TableSelect extends Component { label: PropTypes.string, help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), format: PropTypes.string, - disabled: PropTypes.bool + disabled: PropTypes.bool, + + pageLength: PropTypes.number } static defaultProps = { selectMode: TableSelectMode.SINGLE, - selectionLabelIndex: 0 + selectionLabelIndex: 0, + pageLength: 10 } static contextTypes = { @@ -814,7 +819,7 @@ class TableSelect extends Component { return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help,
    -
    this.table = node} data={props.data} dataUrl={props.dataUrl} columns={props.columns} selectMode={props.selectMode} selectionAsArray={this.props.selectionAsArray} withHeader={props.withHeader} selectionKeyIndex={props.selectionKeyIndex} selection={owner.getFormValue(id)} onSelectionChangedAsync={::this.onSelectionChangedAsync}/> +
    this.table = node} data={props.data} dataUrl={props.dataUrl} columns={props.columns} pageLength={props.pageLength} selectMode={props.selectMode} selectionAsArray={this.props.selectionAsArray} withHeader={props.withHeader} selectionKeyIndex={props.selectionKeyIndex} selection={owner.getFormValue(id)} onSelectionChangedAsync={::this.onSelectionChangedAsync}/> ); diff --git a/client/src/lib/page.js b/client/src/lib/page.js index 6bdc9fdd..cfbabd50 100644 --- a/client/src/lib/page.js +++ b/client/src/lib/page.js @@ -28,7 +28,7 @@ class Breadcrumb extends Component { const params = this.props.params; let title; if (typeof entry.title === 'function') { - title = entry.title(this.props.resolved); + title = entry.title(this.props.resolved, params); } else { title = entry.title; } diff --git a/client/src/lib/table.js b/client/src/lib/table.js index f615bbff..f609b5bb 100644 --- a/client/src/lib/table.js +++ b/client/src/lib/table.js @@ -48,12 +48,14 @@ class Table extends Component { onSelectionChangedAsync: PropTypes.func, onSelectionDataAsync: PropTypes.func, withHeader: PropTypes.bool, - refreshInterval: PropTypes.number + refreshInterval: PropTypes.number, + pageLength: PropTypes.number } static defaultProps = { selectMode: TableSelectMode.NONE, - selectionKeyIndex: 0 + selectionKeyIndex: 0, + pageLength: 50 } refresh() { @@ -262,7 +264,8 @@ class Table extends Component { } const dtOptions = { - columns + columns, + pageLength: this.props.pageLength }; const self = this; diff --git a/client/src/settings/Update.js b/client/src/settings/Update.js index f0558729..e427a966 100644 --- a/client/src/settings/Update.js +++ b/client/src/settings/Update.js @@ -79,6 +79,7 @@ export default class Update extends Component { +