Fixes in reports. Reports seem to work now

This commit is contained in:
Tomas Bures 2018-12-21 19:09:18 +01:00
parent 0be4af5f6c
commit 5a16d789a0
30 changed files with 716 additions and 338 deletions

View file

@ -10,7 +10,10 @@ import axios from '../lib/axios';
import { ReportState } from '../../../shared/reports';
import {Icon} from "../lib/bootstrap-components";
import {checkPermissions} from "../lib/permissions";
import {getUrl} from "../lib/urls";
import {
getPublicUrl,
getUrl
} from "../lib/urls";
import {
tableAddDeleteButton,
tableRestActionDialogInit,
@ -114,7 +117,7 @@ export default class List extends Component {
} else if (mimeType === 'text/csv') {
viewContent = {
label: <Icon icon="download-alt" title={t('download')}/>,
href: `/reports/${id}/download`
href: getUrl(`rpts/${id}/download`)
};
}

View file

@ -1,57 +0,0 @@
'use strict';
import React, { Component } from 'react';
import { withTranslation } from '../lib/i18n';
import { requiresAuthenticatedUser, withPageHelpers, Title } from '../lib/page'
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
import axios from '../lib/axios';
import {getUrl} from "../lib/urls";
@withTranslation()
@withPageHelpers
@withErrorHandling
@requiresAuthenticatedUser
export default class Output extends Component {
constructor(props) {
super(props);
this.state = {
output: null
};
}
@withAsyncErrorHandler
async loadOutput() {
const id = parseInt(this.props.match.params.reportId);
const outputRespPromise = axios.get(getUrl(`rest/report-output/${id}`));
const reportRespPromise = axios.get(getUrl(`rest/reports/${id}`));
const [outputResp, reportResp] = await Promise.all([outputRespPromise, reportRespPromise]);
this.setState({
output: outputResp.data,
report: reportResp.data,
});
}
componentDidMount() {
// noinspection JSIgnoredPromiseFromCall
this.loadOutput();
}
render() {
const t = this.props.t;
if (this.state.report) {
return (
<div>
<Title>{t('outputForReportName', { name: this.state.report.name })}</Title>
<pre>{this.state.output}</pre>
</div>
);
} else {
return <div>{t('loadingReportOutput')}</div>;
}
}
}

View file

@ -1,61 +0,0 @@
'use strict';
import React, { Component } from 'react';
import { withTranslation } from '../lib/i18n';
import { requiresAuthenticatedUser, withPageHelpers, Title } from '../lib/page'
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
import axios from '../lib/axios';
import { ReportState } from '../../../shared/reports';
import {getUrl} from "../lib/urls";
@withTranslation()
@withPageHelpers
@withErrorHandling
@requiresAuthenticatedUser
export default class View extends Component {
constructor(props) {
super(props);
this.state = {
content: null
};
}
@withAsyncErrorHandler
async loadContent() {
const id = parseInt(this.props.match.params.reportId);
const contentRespPromise = axios.get(getUrl(`rest/report-content/${id}`));
const reportRespPromise = axios.get(getUrl(`rest/reports/${id}`));
const [contentResp, reportResp] = await Promise.all([contentRespPromise, reportRespPromise]);
this.setState({
content: contentResp.data,
report: reportResp.data
});
}
componentDidMount() {
// noinspection JSIgnoredPromiseFromCall
this.loadContent();
}
render() {
const t = this.props.t;
if (this.state.report) {
if (this.state.report.state === ReportState.FINISHED) {
return (
<div>
<Title>{t('reportName', { name: this.state.report.name })}</Title>
<div dangerouslySetInnerHTML={{ __html: this.state.content }}/>
</div>
);
} else {
return <div className="alert alert-danger" role="alert">{t('reportNotGenerated')}</div>;
}
} else {
return <div>{t('loadingReport')}</div>;
}
}
}

View file

@ -0,0 +1,128 @@
'use strict';
import React, { Component } from 'react';
import { withTranslation } from '../lib/i18n';
import {
requiresAuthenticatedUser,
withPageHelpers,
Title,
Toolbar,
NavButton
} from '../lib/page'
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
import axios from '../lib/axios';
import { ReportState } from '../../../shared/reports';
import {getUrl} from "../lib/urls";
import {Button} from "../lib/bootstrap-components";
import {Link} from "react-router-dom";
import PropTypes
from "prop-types";
@withTranslation()
@withPageHelpers
@withErrorHandling
@requiresAuthenticatedUser
export default class ViewAndOutput extends Component {
constructor(props) {
super(props);
this.state = {
content: null
};
this.reloadTimeoutHandler = ::this.loadContent;
this.reloadTimeoutId = 0;
const t = props.t;
this.viewTypes = {
view: {
url: 'rest/report-content',
getTitle: name => t('reportName', { name }),
loading: t('loadingReport'),
getContent: content => <div dangerouslySetInnerHTML={{ __html: content }}/>
},
output: {
url: 'rest/report-output',
getTitle: name => t('outputForReportName', { name }),
loading: t('loadingReportOutput'),
getContent: content => <pre>{content}</pre>
}
}
}
static propTypes = {
viewType: PropTypes.string.isRequired
}
@withAsyncErrorHandler
async loadContent() {
const id = parseInt(this.props.match.params.reportId);
const contentRespPromise = axios.get(getUrl(this.viewTypes[this.props.viewType].url + '/' + id));
const reportRespPromise = axios.get(getUrl(`rest/reports/${id}`));
const [contentResp, reportResp] = await Promise.all([contentRespPromise, reportRespPromise]);
this.setState({
content: contentResp.data,
report: reportResp.data
});
const state = reportResp.data.state;
if (state === ReportState.PROCESSING || state === ReportState.SCHEDULED) {
if (this.reloadTimeoutHandler) { // For some reason the task gets rescheduled if server is restarted while the page is shown. That why we have this check here.
this.reloadTimeoutId = setTimeout(this.reloadTimeoutHandler, 1000);
}
}
}
componentDidMount() {
// noinspection JSIgnoredPromiseFromCall
this.loadContent();
}
componentWillUnmount() {
clearTimeout(this.reloadTimeoutId);
this.reloadTimeoutHandler = null;
}
@withAsyncErrorHandler
async refresh() {
const id = parseInt(this.props.match.params.reportId);
await axios.post(getUrl(`rest/report-start/${id}`));
// noinspection JSIgnoredPromiseFromCall
this.loadContent();
}
render() {
const t = this.props.t;
const viewType = this.viewTypes[this.props.viewType];
if (this.state.report) {
let reportContent = null;
if (this.state.report.state === ReportState.FINISHED) {
reportContent = viewType.getContent(this.state.content);
} else if (this.state.report.state === ReportState.SCHEDULED || this.state.report.state === ReportState.PROCESSING) {
reportContent = <div className="alert alert-info" role="alert">{t('Report is being generated')}</div>;
} else {
reportContent = <div className="alert alert-danger" role="alert">{t('reportNotGenerated')}</div>;
}
return (
<div>
<Toolbar>
<Button className="btn-primary" icon="repeat" label={t('Refresh')} onClickAsync={::this.refresh}/>
</Toolbar>
<Title>{viewType.getTitle(this.state.report.name)}</Title>
{reportContent}
</div>
);
} else {
return <div>{viewType.loading}</div>;
}
}
}

View file

@ -3,8 +3,7 @@
import React from 'react';
import ReportsCUD from './CUD';
import ReportsList from './List';
import ReportsView from './View';
import ReportsOutput from './Output';
import ReportsViewAndOutput from './ViewAndOutput';
import ReportTemplatesCUD from './templates/CUD';
import ReportTemplatesList from './templates/List';
import Share from '../shares/Share';
@ -36,7 +35,7 @@ function getMenus(t) {
title: t('view'),
link: params => `/reports/${params.reportId}/view`,
visible: resolved => resolved.report.permissions.includes('viewContent') && resolved.report.state === ReportState.FINISHED && resolved.report.mime_type === 'text/html',
panelRender: props => (<ReportsView {...props} />),
panelRender: props => (<ReportsViewAndOutput viewType="view" {...props} />),
},
download: {
title: t('download'),
@ -47,7 +46,7 @@ function getMenus(t) {
title: t('output'),
link: params => `/reports/${params.reportId}/output`,
visible: resolved => resolved.report.permissions.includes('viewOutput'),
panelRender: props => (<ReportsOutput {...props} />)
panelRender: props => (<ReportsViewAndOutput viewType="output" {...props} />)
},
share: {
title: t('share'),

View file

@ -61,10 +61,10 @@ export default class CUD extends Component {
} else {
const wizard = this.props.wizard;
if (wizard === 'subscribers-all') {
if (wizard === 'open-counts') {
this.populateFormValues({
name: '',
description: 'Generates a campaign report listing all subscribers along with their statistics.',
description: 'Generates a campaign report listing all subscribers along with open counts.',
namespace: mailtrainConfig.user.namespace,
mime_type: 'text/html',
user_fields:
@ -78,13 +78,13 @@ export default class CUD extends Component {
' }\n' +
']',
js:
'const results = await campaigns.getResults(inputs.campaign, ["*"]);\n' +
'render({ results });',
'const results = await campaigns.getCampaignOpenStatistics(inputs.campaign, ["*"])\n' +
'render({ results })',
hbs:
'<h2>{{title}}</h2>\n' +
'\n' +
'<div class="table-responsive">\n' +
' <table class="table table-bordered table-hover data-table display nowrap" width="100%" data-row-sort="1,1" data-paging="false">\n' +
' <table class="table table-bordered table-hover" width="100%">\n' +
' <thead>\n' +
' <th>\n' +
' Email\n' +
@ -98,10 +98,10 @@ export default class CUD extends Component {
' {{#each results}}\n' +
' <tr>\n' +
' <th scope="row">\n' +
' {{email}}\n' +
' {{subscription:email}}\n' +
' </th>\n' +
' <td style="width: 20%;">\n' +
' {{tracker_count}}\n' +
' {{tracker:count}}\n' +
' </td>\n' +
' </tr>\n' +
' {{/each}}\n' +
@ -111,10 +111,46 @@ export default class CUD extends Component {
'</div>'
});
} else if (wizard === 'subscribers-grouped') {
} else if (wizard === 'open-counts-csv') {
this.populateFormValues({
name: '',
description: 'Generates a campaign report with results are aggregated by some "Country" custom field.',
description: 'Generates a campaign report as CSV that lists all subscribers along with open counts.',
namespace: mailtrainConfig.user.namespace,
mime_type: 'text/csv',
user_fields:
'[\n' +
' {\n' +
' "id": "campaign",\n' +
' "name": "Campaign",\n' +
' "type": "campaign",\n' +
' "minOccurences": 1,\n' +
' "maxOccurences": 1\n' +
' }\n' +
']',
js:
'const sampleRowTransform = new stream.Transform({\n' +
' objectMode: true,\n' +
' transform(row, encoding, callback) {\n' +
' callback(null, row)\n' +
' }\n' +
'})\n' +
'\n' +
'const results = await campaigns.getCampaignOpenStatisticsStream(inputs.campaign, [\'subscription:email\', \'tracker:count\'])\n' +
'\n' +
'results.pipe(sampleRowTransform)\n' +
'\n' +
'await renderCsvFromStream(sampleRowTransform, {\n' +
' header: true,\n' +
' columns: [ { key: \'subscription:email\', header: \'Email\' }, { key: \'tracker:count\', header: \'Open count\' } ],\n' +
' delimiter: \',\'\n' +
'})',
hbs: ''
});
} else if (wizard === 'aggregated-open-counts') {
this.populateFormValues({
name: '',
description: 'Generates a campaign report with results are aggregated by "Country" custom field. (Note that this custom field has to be presents in the subscription custom fields.)',
namespace: mailtrainConfig.user.namespace,
mime_type: 'text/html',
user_fields:
@ -128,22 +164,22 @@ export default class CUD extends Component {
' }\n' +
']',
js:
'const results = await campaigns.getResults(inputs.campaign, ["merge_country"], query =>\n' +
'const results = await campaigns.getCampaignOpenStatistics(inputs.campaign, ["field:country", "count_opened", "count_all"], query =>\n' +
' query.count("* AS count_all")\n' +
' .select(knex.raw("SUM(IF(tracker.count IS NULL, 0, 1)) AS count_opened"))\n' +
' .groupBy("merge_country")\n' +
');\n' +
' .select(knex.raw("SUM(IF(`tracker:count` IS NULL, 0, 1)) AS count_opened"))\n' +
' .groupBy("field:country")\n' +
')\n' +
'\n' +
'for (const row of results) {\n' +
' row.percentage = Math.round((row.count_opened / row.count_all) * 100);\n' +
' row.percentage = Math.round((row["tracker:count"] / row.count_all) * 100)\n' +
'}\n' +
'\n' +
'render({ results });',
'render({ results })',
hbs:
'<h2>{{title}}</h2>\n' +
'\n' +
'<div class="table-responsive">\n' +
' <table class="table table-bordered table-hover data-table display nowrap" width="100%" data-row-sort="1,1,1,1" data-paging="false">\n' +
' <table class="table table-bordered table-hover" width="100%">\n' +
' <thead>\n' +
' <th>\n' +
' Country\n' +
@ -163,7 +199,7 @@ export default class CUD extends Component {
' {{#each results}}\n' +
' <tr>\n' +
' <th scope="row">\n' +
' {{merge_country}}\n' +
' {{field:merge_country}}\n' +
' </th>\n' +
' <td style="width: 20%;">\n' +
' {{count_opened}}\n' +
@ -182,31 +218,6 @@ export default class CUD extends Component {
'</div>'
});
} else if (wizard === 'export-list-csv') {
this.populateFormValues({
name: '',
description: 'Exports a list as a CSV file.',
namespace: mailtrainConfig.user.namespace,
mime_type: 'text/csv',
user_fields:
'[\n' +
' {\n' +
' "id": "list",\n' +
' "name": "List",\n' +
' "type": "list",\n' +
' "minOccurences": 1,\n' +
' "maxOccurences": 1\n' +
' }\n' +
']',
js:
'const results = await subscriptions.list(inputs.list.id);\n' +
'render({ results });',
hbs:
'{{#each results}}\n' +
'{{firstName}},{{lastName}},{{email}}\n' +
'{{/each}}'
});
} else {
this.populateFormValues({
name: '',
@ -298,7 +309,7 @@ export default class CUD extends Component {
<DeleteModalDialog
stateOwner={this}
visible={this.props.action === 'delete'}
deleteUrl={`rest/reports/templates/${this.props.entity.id}`}
deleteUrl={`rest/report-templates/${this.props.entity.id}`}
backUrl={`/reports/templates/${this.props.entity.id}/edit`}
successUrl="/reports/templates"
deletingMsg={t('deletingReportTemplate')}
@ -313,7 +324,7 @@ export default class CUD extends Component {
<Dropdown id="mime_type" label={t('type')} options={[{key: 'text/html', label: t('html')}, {key: 'text/csv', label: t('csv')}]}/>
<NamespaceSelect/>
<ACEEditor id="user_fields" height="250px" mode="json" label={t('userSelectableFields')} help={t('jsonSpecificationOfUserSelectableFields')}/>
<ACEEditor id="js" height="700px" mode="javascript" label={t('dataProcessingCode')} help={<Trans i18nKey="writeTheBodyOfTheJavaScriptFunctionWith">Write the body of the JavaScript function with signature <code>function(inputs, callback)</code> that returns an object to be rendered by the Handlebars template below.</Trans>}/>
<ACEEditor id="js" height="700px" mode="javascript" label={t('dataProcessingCode')} help={<Trans i18nKey="writeTheBodyOfTheJavaScriptFunctionWith">Write the body of the JavaScript function with signature <code>async function(inputs)</code> that returns an object to be rendered by the Handlebars template below.</Trans>}/>
<ACEEditor id="hbs" height="700px" mode="handlebars" label={t('renderingTemplate')} help={<Trans i18nKey="useHtmlWithHandlebarsSyntaxSee">Use HTML with Handlebars syntax. See documentation <a href="http://handlebarsjs.com/">here</a>.</Trans>}/>
{isEdit ?

View file

@ -74,7 +74,7 @@ export default class List extends Component {
});
}
tableAddDeleteButton(actions, this, perms, `rest/reports/templates/${data[0]}`, data[1], t('deletingReportTemplate'), t('reportTemplateDeleted'));
tableAddDeleteButton(actions, this, perms, `rest/report-templates/${data[0]}`, data[1], t('deletingReportTemplate'), t('reportTemplateDeleted'));
return actions;
}
@ -88,9 +88,9 @@ export default class List extends Component {
<Toolbar>
<DropdownMenu className="btn-primary" label={t('createReportTemplate')}>
<MenuLink to="/reports/templates/create">{t('blank')}</MenuLink>
<MenuLink to="/reports/templates/create/subscribers-all">{t('allSubscribers')}</MenuLink>
<MenuLink to="/reports/templates/create/subscribers-grouped">{t('groupedSubscribers')}</MenuLink>
<MenuLink to="/reports/templates/create/export-list-csv">{t('exportListAsCsv')}</MenuLink>
<MenuLink to="/reports/templates/create/open-counts">{t('Open counts')}</MenuLink>
<MenuLink to="/reports/templates/create/open-counts-csv">{t('Open counts as CSV')}</MenuLink>
<MenuLink to="/reports/templates/create/aggregated-open-counts">{t('Aggregrated open counts')}</MenuLink>
</DropdownMenu>
</Toolbar>
}

View file

@ -50,7 +50,7 @@ import {getLang} from "../../shared/langs";
const topLevelMenuKeys = ['lists', 'templates', 'campaigns'];
if (mailtrainConfig.reportsEnabmed) {
if (mailtrainConfig.reportsEnabled) {
topLevelMenuKeys.push('reports');
}

View file

@ -12,6 +12,7 @@ import {
} from "../lib/form";
import {Trans} from "react-i18next";
import styles from "./styles.scss";
import mailtrainConfig from 'mailtrainConfig';
export const mailerTypesOrder = [
MailerType.ZONE_MTA,
@ -140,11 +141,14 @@ export function getMailerTypes(t) {
{ key: 'eu-west-1', label: t('euwest1')}
];
const zoneMtaTypeOptions = [
{ key: ZoneMTAType.WITH_MAILTRAIN_HEADER_CONF, label: t('Dynamic configuration of DKIM keys via ZoneMTA\'s Mailtrain plugin (use this option for builtin ZoneMTA)')},
{ key: ZoneMTAType.WITH_HTTP_CONF, label: t('Dynamic configuration of DKIM keys via ZoneMTA\'s HTTP config plugin')},
{ key: ZoneMTAType.REGULAR, label: t('No dynamic configuration of DKIM keys')}
]
const zoneMtaTypeOptions = [];
if (mailtrainConfig.builtinZoneMTAEnabled) {
zoneMtaTypeOptions.push({ key: ZoneMTAType.BUILTIN, label: t('Built-in ZoneMTA')});
}
zoneMtaTypeOptions.push({ key: ZoneMTAType.WITH_MAILTRAIN_HEADER_CONF, label: t('Dynamic configuration of DKIM keys via ZoneMTA\'s Mailtrain plugin (use this option for builtin ZoneMTA)')});
zoneMtaTypeOptions.push({ key: ZoneMTAType.WITH_HTTP_CONF, label: t('Dynamic configuration of DKIM keys via ZoneMTA\'s HTTP config plugin')});
zoneMtaTypeOptions.push({ key: ZoneMTAType.REGULAR, label: t('No dynamic configuration of DKIM keys')});
mailerTypes[MailerType.GENERIC_SMTP] = {
getForm: owner =>
@ -196,18 +200,22 @@ export function getMailerTypes(t) {
<Fieldset label={t('mailerSettings')}>
<Dropdown id="mailer_type" label={t('mailerType')} options={typeOptions}/>
<Dropdown id="zoneMtaType" label={t('Dynamic configuration')} options={zoneMtaTypeOptions}/>
<InputField id="smtpHostname" label={t('hostname')} placeholder={t('hostnameEgSmtpexamplecom')}/>
<InputField id="smtpPort" label={t('port')} placeholder={t('portEg465AutodetectedIfLeftBlank')}/>
<Dropdown id="smtpEncryption" label={t('encryption')} options={smtpEncryptionOptions}/>
<CheckBox id="smtpUseAuth" text={t('enableSmtpAuthentication')}/>
{ owner.getFormValue('smtpUseAuth') &&
<div>
<InputField id="smtpUser" label={t('username')} placeholder={t('usernameEgMyaccount@examplecom')}/>
<InputField id="smtpPassword" label={t('password')} placeholder={t('usernameEgMyaccount@examplecom')}/>
</div>
{(zoneMtaType === ZoneMTAType.REGULAR || zoneMtaType === ZoneMTAType.WITH_MAILTRAIN_HEADER_CONF || zoneMtaType === ZoneMTAType.WITH_HTTP_CONF) &&
<div>
<InputField id="smtpHostname" label={t('hostname')} placeholder={t('hostnameEgSmtpexamplecom')}/>
<InputField id="smtpPort" label={t('port')} placeholder={t('portEg465AutodetectedIfLeftBlank')}/>
<Dropdown id="smtpEncryption" label={t('encryption')} options={smtpEncryptionOptions}/>
<CheckBox id="smtpUseAuth" text={t('enableSmtpAuthentication')}/>
{ owner.getFormValue('smtpUseAuth') &&
<div>
<InputField id="smtpUser" label={t('username')} placeholder={t('usernameEgMyaccount@examplecom')}/>
<InputField id="smtpPassword" label={t('password')} placeholder={t('usernameEgMyaccount@examplecom')}/>
</div>
}
</div>
}
</Fieldset>
{(zoneMtaType === ZoneMTAType.WITH_MAILTRAIN_HEADER_CONF || zoneMtaType === ZoneMTAType.WITH_HTTP_CONF) &&
{(zoneMtaType === ZoneMTAType.BUILTIN || zoneMtaType === ZoneMTAType.WITH_MAILTRAIN_HEADER_CONF || zoneMtaType === ZoneMTAType.WITH_HTTP_CONF) &&
<Fieldset label={t('dkimSigning')}>
<Trans i18nKey="ifYouAreUsingZoneMtaThenMailtrainCan"><p>If you are using ZoneMTA then Mailtrain can provide a DKIM key for signing all outgoing messages.</p></Trans>
<Trans i18nKey="doNotUseSensitiveKeysHereThePrivateKeyIs"><p className="text-warning">Do not use sensitive keys here. The private key is not encrypted in the database.</p></Trans>
@ -231,7 +239,7 @@ export function getMailerTypes(t) {
},
initData: () => ({
...getInitGenericSMTP(),
zoneMtaType: ZoneMTAType.REGULAR,
zoneMtaType: mailtrainConfig.builtinZoneMTAEnabled ? ZoneMTAType.BUILTIN : ZoneMTAType.REGULAR,
dkimApiKey: '',
dkimDomain: '',
dkimSelector: '',