Reports ported to ReactJS and Knex

Note that the interface for the custom JS code inside a report template has changed. It now offers promise-based interface and exposes knex.
This commit is contained in:
Tomas Bures 2017-07-13 13:27:03 +02:00
parent 6d95fa515e
commit d63eed9ca9
27 changed files with 649 additions and 953 deletions

View file

@ -49,36 +49,6 @@ class Form extends Component {
};
}
static async handleChangedError(owner, fn) {
try {
await fn();
} catch (error) {
if (error instanceof interoperableErrors.ChangedError) {
owner.disableForm();
owner.setFormStatusMessage('danger',
<span>
<strong>{t('Your updates cannot be saved.')}</strong>{' '}
{t('Someone else has introduced modification in the meantime. Refresh your page to start anew with fresh data. Please note that your changes will be lost.')}
</span>
);
return;
}
if (error instanceof interoperableErrors.NotFoundError) {
owner.disableForm();
owner.setFormStatusMessage('danger',
<span>
<strong>{t('Your updates cannot be saved.')}</strong>{' '}
{t('It seems that someone else has deleted the entity in the meantime.')}
</span>
);
return;
}
throw error;
}
}
@withAsyncErrorHandler
async onSubmit(evt) {
const t = this.props.t;
@ -88,7 +58,7 @@ class Form extends Component {
evt.preventDefault();
if (this.props.onSubmitAsync) {
await Form.handleChangedError(owner, async () => await this.props.onSubmitAsync(evt));
await this.formHandleChangedError(async () => await this.props.onSubmitAsync(evt));
}
}
@ -520,6 +490,7 @@ class ACEEditor extends Component {
showPrintMargin={false}
value={owner.getFormValue(id)}
tabSize={2}
setOptions={{useWorker: false}} // This disables syntax check because it does not always work well (e.g. in case of JS code in report templates)
/>
);
}
@ -839,6 +810,37 @@ function withForm(target) {
return this.state.formState.get('isDisabled');
};
inst.formHandleChangedError = async function(fn) {
const t = this.props.t;
try {
await fn();
} catch (error) {
if (error instanceof interoperableErrors.ChangedError) {
this.disableForm();
this.setFormStatusMessage('danger',
<span>
<strong>{t('Your updates cannot be saved.')}</strong>{' '}
{t('Someone else has introduced modification in the meantime. Refresh your page to start anew with fresh data. Please note that your changes will be lost.')}
</span>
);
return;
}
if (error instanceof interoperableErrors.NotFoundError) {
this.disableForm();
this.setFormStatusMessage('danger',
<span>
<strong>{t('Your updates cannot be saved.')}</strong>{' '}
{t('It seems that someone else has deleted the entity in the meantime.')}
</span>
);
return;
}
throw error;
}
};
return target;
}

View file

@ -11,11 +11,11 @@
padding-bottom: 5px;
}
.mt-action-links > a {
.mt-action-links > * {
margin-right: 8px;
}
.mt-action-links > a:last-child {
.mt-action-links > *:last-child {
margin-right: 0px;
}

View file

@ -29,7 +29,7 @@ const TableSelectMode = {
};
@translate()
@translate(null, { withRef: true })
@withPageHelpers
@withErrorHandling
class Table extends Component {
@ -48,8 +48,9 @@ class Table extends Component {
selectionAsArray: PropTypes.bool,
onSelectionChangedAsync: PropTypes.func,
onSelectionDataAsync: PropTypes.func,
actionLinks: PropTypes.array,
withHeader: PropTypes.bool
actions: PropTypes.func,
withHeader: PropTypes.bool,
refreshInterval: PropTypes.number
}
static defaultProps = {
@ -57,6 +58,12 @@ class Table extends Component {
selectionKeyIndex: 0
}
refresh() {
if (this.table) {
this.table.rows().draw('page');
}
}
getSelectionMap(props) {
let selArray = [];
if (props.selectMode === TableSelectMode.SINGLE && !this.props.selectionAsArray) {
@ -162,17 +169,56 @@ class Table extends Component {
componentDidMount() {
const columns = this.props.columns.slice();
if (this.props.actionLinks) {
const actionLinks = this.props.actionLinks;
if (this.props.actions) {
const createdCellFn = (td, data) => {
const linksContainer = jQuery('<span class="mt-action-links"/>');
for (const {label, link} of actionLinks) {
const dest = link(data);
const lnkHtml = ReactDOMServer.renderToStaticMarkup(<a href={dest}>{label}</a>);
const lnk = jQuery(lnkHtml);
lnk.click((evt) => { evt.preventDefault(); this.navigateTo(dest) });
linksContainer.append(lnk);
let actions = this.props.actions(data);
let options = {};
if (!Array.isArray(actions)) {
options = actions;
actions = actions.actions;
}
for (const action of actions) {
if (action.action) {
const html = ReactDOMServer.renderToStaticMarkup(<a href="">{action.label}</a>);
const elem = jQuery(html);
elem.click((evt) => { evt.preventDefault(); action.action(this) });
linksContainer.append(elem);
} else if (action.link) {
const html = ReactDOMServer.renderToStaticMarkup(<a href={action.link}>{action.label}</a>);
const elem = jQuery(html);
elem.click((evt) => { evt.preventDefault(); this.navigateTo(action.link) });
linksContainer.append(elem);
} else if (action.href) {
const html = ReactDOMServer.renderToStaticMarkup(<a href={action.href}>{action.label}</a>);
const elem = jQuery(html);
linksContainer.append(elem);
} else {
const html = ReactDOMServer.renderToStaticMarkup(action.label);
const elem = jQuery(html);
linksContainer.append(elem);
}
}
if (options.refreshTimeout) {
const currentMS = Date.now();
if (!this.refreshTimeoutAt || this.refreshTimeoutAt > currentMS + options.refreshTimeout) {
clearTimeout(this.refreshTimeoutId);
this.refreshTimeoutAt = currentMS + options.refreshTimeout;
this.refreshTimeoutId = setTimeout(() => {
this.refreshTimeoutAt = 0;
this.refresh();
}, options.refreshTimeout);
}
}
jQuery(td).html(linksContainer);
@ -238,6 +284,15 @@ class Table extends Component {
this.table = jQuery(this.domTable).DataTable(dtOptions);
if (this.props.refreshInterval) {
this.refreshIntervalId = setInterval(() => this.refresh(), this.props.refreshInterval);
}
this.table.on('destroy.dt', () => {
clearInterval(this.refreshIntervalId);
clearTimeout(this.refreshTimeoutId);
});
this.fetchSelectionData();
}
@ -306,6 +361,14 @@ class Table extends Component {
}
}
/*
Refreshes the table. This method is provided to allow programmatic refresh from a handler outside the table.
The reference to the table can be obtained by ref.
*/
Table.prototype.refresh = function() {
this.getWrappedInstance().refresh()
};
export {
Table,
TableSelectMode

View file

@ -65,7 +65,7 @@ class TreeTable extends Component {
selectMode: PropTypes.number,
selection: PropTypes.oneOfType([PropTypes.array, PropTypes.string, PropTypes.number]),
onSelectionChangedAsync: PropTypes.func,
actionLinks: PropTypes.array,
actions: PropTypes.func,
withHeader: PropTypes.bool
}
@ -100,19 +100,18 @@ class TreeTable extends Component {
};
let createNodeFn;
if (this.props.actionLinks) {
const actionLinks = this.props.actionLinks;
if (this.props.actions) {
createNodeFn = (event, data) => {
const node = data.node;
const tdList = jQuery(node.tr).find(">td");
const linksContainer = jQuery('<span class="mt-action-links"/>');
for (const {label, link} of actionLinks) {
const dest = link(node.key);
const lnkHtml = ReactDOMServer.renderToStaticMarkup(<a href={dest}>{label}</a>);
const actions = this.props.actions(node.key);
for (const {label, link} of actions) {
const lnkHtml = ReactDOMServer.renderToStaticMarkup(<a href={link}>{label}</a>);
const lnk = jQuery(lnkHtml);
lnk.click((evt) => { evt.preventDefault(); this.navigateTo(dest) });
lnk.click((evt) => { evt.preventDefault(); this.navigateTo(link) });
linksContainer.append(lnk);
}
@ -202,7 +201,7 @@ class TreeTable extends Component {
render() {
const t = this.props.t;
const props = this.props;
const actionLinks = props.actionLinks;
const actions = props.actions;
const withHeader = props.withHeader;
let containerClass = 'mt-treetable-container';
@ -223,14 +222,14 @@ class TreeTable extends Component {
<thead>
<tr>
<th>{t('Name')}</th>
{actionLinks && <th></th>}
{actions && <th></th>}
</tr>
</thead>
}
<tbody>
<tr>
<td></td>
{actionLinks && <td></td>}
{actions && <td></td>}
</tr>
</tbody>
</table>

View file

@ -10,10 +10,10 @@ export default class List extends Component {
render() {
const t = this.props.t;
const actionLinks = [
const actions = key => [
{
label: 'Edit',
link: key => '/namespaces/edit/' + key
link: '/namespaces/edit/' + key
}
];
@ -25,7 +25,7 @@ export default class List extends Component {
<Title>{t('Namespaces')}</Title>
<TreeTable withHeader dataUrl="/rest/namespaces-tree" actionLinks={actionLinks} />
<TreeTable withHeader dataUrl="/rest/namespaces-tree" actions={actions} />
</div>
);
}

View file

@ -4,24 +4,100 @@ import React, { Component } from 'react';
import { translate } from 'react-i18next';
import { Title, Toolbar, NavButton } from '../lib/page';
import { Table } from '../lib/table';
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
import moment from 'moment';
import axios from '../lib/axios';
import { ReportState } from '../../../shared/reports';
@translate()
@withErrorHandling
export default class List extends Component {
@withAsyncErrorHandler
async stop(table, id) {
await axios.post(`/rest/report-stop/${id}`);
table.refresh();
}
@withAsyncErrorHandler
async start(table, id) {
await axios.post(`/rest/report-start/${id}`);
table.refresh();
}
render() {
const t = this.props.t;
const actionLinks = [{
label: 'Edit',
link: data => '/reports/edit/' + data[0]
}];
const actions = data => {
let view, startStop, refreshTimeout;
const state = data[5];
const id = data[0];
const mimeType = data[6];
if (state === ReportState.PROCESSING || state === ReportState.SCHEDULED) {
view = {
label: <span className="glyphicon glyphicon-hourglass" aria-hidden="true" title="Processing"></span>,
};
startStop = {
label: <span className="glyphicon glyphicon-stop" aria-hidden="true" title="Stop"></span>,
action: (table) => this.stop(table, id)
};
refreshTimeout = 1000;
} else if (state === ReportState.FINISHED) {
if (mimeType === 'text/html') {
view = {
label: <span className="glyphicon glyphicon-eye-open" aria-hidden="true" title="View"></span>,
link: `reports/view/${id}`
};
} else if (mimeType === 'text/csv') {
view = {
label: <span className="glyphicon glyphicon-download-alt" aria-hidden="true" title="Download"></span>,
href: `reports/download/${id}`
};
}
startStop = {
label: <span className="glyphicon glyphicon-repeat" aria-hidden="true" title="Refresh report"></span>,
action: (table) => this.start(table, id)
};
} else if (state === ReportState.FAILED) {
view = {
label: <span className="glyphicon glyphicon-thumbs-down" aria-hidden="true" title="Report generation failed"></span>,
};
startStop = {
label: <span className="glyphicon glyphicon-repeat" aria-hidden="true" title="Regenerate report"></span>,
action: (table) => this.start(table, id)
};
}
return {
refreshTimeout,
actions: [
view,
{
label: <span className="glyphicon glyphicon-modal-window" aria-hidden="true" title="View console output"></span>,
link: `reports/output/${id}`
},
startStop,
{
label: <span className="glyphicon glyphicon-wrench" aria-hidden="true" title="Edit"></span>,
link: `/reports/edit/${id}`
}
]
};
};
const columns = [
{ data: 0, title: "#" },
{ data: 1, title: t('Name') },
{ data: 2, title: t('Template') },
{ data: 3, title: t('Description') },
{ data: 4, title: t('Last Run'), render: data => data ? moment(data).fromNow() : t('Not run yet') }
{ data: 4, title: t('Created'), render: data => data ? moment(data).fromNow() : '' }
];
return (
@ -33,7 +109,7 @@ export default class List extends Component {
<Title>{t('Reports')}</Title>
<Table withHeader dataUrl="/rest/reports-table" columns={columns} actionLinks={actionLinks} />
<Table withHeader dataUrl="/rest/reports-table" columns={columns} actions={actions} />
</div>
);
}

View file

@ -0,0 +1,53 @@
'use strict';
import React, { Component } from 'react';
import { translate } from 'react-i18next';
import { withPageHelpers, Title } from '../lib/page'
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
import axios from '../lib/axios';
@translate()
@withPageHelpers
@withErrorHandling
export default class Output extends Component {
constructor(props) {
super(props);
this.state = {
output: null
};
}
@withAsyncErrorHandler
async loadOutput() {
const id = parseInt(this.props.match.params.id);
const outputResp = await axios.get(`/rest/report-output/${id}`);
const reportResp = await axios.get(`/rest/reports/${id}`);
this.setState({
output: outputResp.data,
report: reportResp.data,
});
}
componentDidMount() {
this.loadOutput();
}
render() {
const t = this.props.t;
if (this.state.report) {
return (
<div>
<Title>{t('Output for report {{name}}', { name: this.state.report.name })}</Title>
<pre>{this.state.output}</pre>
</div>
);
} else {
return <div>{t('Loading report output ...')}</div>;
}
}
}

View file

@ -0,0 +1,57 @@
'use strict';
import React, { Component } from 'react';
import { translate } from 'react-i18next';
import { withPageHelpers, Title } from '../lib/page'
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
import axios from '../lib/axios';
import { ReportState } from '../../../shared/reports';
@translate()
@withPageHelpers
@withErrorHandling
export default class View extends Component {
constructor(props) {
super(props);
this.state = {
content: null
};
}
@withAsyncErrorHandler
async loadContent() {
const id = parseInt(this.props.match.params.id);
const contentResp = await axios.get(`/rest/report-content/${id}`);
const reportResp = await axios.get(`/rest/reports/${id}`);
this.setState({
content: contentResp.data,
report: reportResp.data
});
}
componentDidMount() {
this.loadContent();
}
render() {
const t = this.props.t;
if (this.state.report) {
if (this.state.report.state === ReportState.FINISHED) {
return (
<div>
<Title>{t('Report {{name}}', { name: this.state.report.name })}</Title>
<div dangerouslySetInnerHTML={{ __html: this.state.content }}/>
</div>
);
} else {
return <div className="alert alert-danger" role="alert">{t('Report not generated')}</div>;
}
} else {
return <div>{t('Loading report ...')}</div>;
}
}
}

View file

@ -8,6 +8,8 @@ import i18n from '../lib/i18n';
import { Section } from '../lib/page'
import ReportsCUD from './CUD'
import ReportsList from './List'
import ReportsView from './View'
import ReportsOutput from './Output'
import ReportTemplatesCUD from './templates/CUD'
import ReportTemplatesList from './templates/List'
@ -33,6 +35,16 @@ const getStructure = t => {
title: t('Create Report'),
render: props => (<ReportsCUD {...props} />)
},
view: {
title: t('View Report'),
params: [':id' ],
render: props => (<ReportsView {...props} />)
},
output: {
title: t('View Report Output'),
params: [':id' ],
render: props => (<ReportsOutput {...props} />)
},
'templates': {
title: t('Templates'),
link: '/reports/templates',

View file

@ -57,17 +57,8 @@ export default class CUD extends Component {
' }\n' +
']',
js:
'campaigns.results(inputs.campaign, ["*"], "", (err, results) => {\n' +
' if (err) {\n' +
' return callback(err);\n' +
' }\n' +
'\n' +
' const data = {\n' +
' results: results\n' +
' };\n' +
'\n' +
' return callback(null, data);\n' +
'});',
'const results = await campaigns.getResults(inputs.campaign, ["*"]);\n' +
'render({ results });',
hbs:
'<h2>{{title}}</h2>\n' +
'\n' +
@ -115,21 +106,17 @@ export default class CUD extends Component {
' }\n' +
']',
js:
'campaigns.results(inputs.campaign, ["custom_country", "count(*) AS count_all", "SUM(IF(tracker.count IS NULL, 0, 1)) AS count_opened"], "GROUP BY custom_country", (err, results) => {\n' +
' if (err) {\n' +
' return callback(err);\n' +
' }\n' +
'const results = await campaigns.getResults(inputs.campaign, ["custom_country"], query =>\n' +
' query.count("* AS count_all")\n' +
' .select(knex.raw("SUM(IF(tracker.count IS NULL, 0, 1)) AS count_opened"))\n' +
' .groupBy("custom_country")\n' +
');\n' +
'\n' +
' for (let row of results) {\n' +
' row["percentage"] = Math.round((row.count_opened / row.count_all) * 100);\n' +
' }\n' +
'for (const row of results) {\n' +
' row.percentage = Math.round((row.count_opened / row.count_all) * 100);\n' +
'}\n' +
'\n' +
' let data = {\n' +
' results: results\n' +
' };\n' +
'\n' +
' return callback(null, data);\n' +
'});',
'render({ results });',
hbs:
'<h2>{{title}}</h2>\n' +
'\n' +
@ -189,17 +176,8 @@ export default class CUD extends Component {
' }\n' +
']',
js:
'subscriptions.list(inputs.list.id,0,0, (err, results) => {\n' +
' if (err) {\n' +
' return callback(err);\n' +
' }\n' +
'\n' +
' let data = {\n' +
' results: results\n' +
' };\n' +
'\n' +
' return callback(null, data);\n' +
'});',
'const results = await subscriptions.list(inputs.list.id);\n' +
'render({ results });',
hbs:
'{{#each results}}\n' +
'{{firstName}},{{lastName}},{{email}}\n' +
@ -246,11 +224,11 @@ export default class CUD extends Component {
}
async submitAndStay() {
await Form.handleChangedError(this, async () => await this.doSubmit(true));
await this.formHandleChangedError(async () => await this.doSubmit(true));
}
async submitAndLeave() {
await Form.handleChangedError(this, async () => await this.doSubmit(false));
await this.formHandleChangedError(async () => await this.doSubmit(false));
}
async doSubmit(stay) {
@ -273,6 +251,7 @@ export default class CUD extends Component {
if (submitSuccessful) {
if (stay) {
await this.loadFormValues();
this.enableForm();
this.setFormStatusMessage('success', t('Report template saved'));
} else {

View file

@ -12,10 +12,12 @@ export default class List extends Component {
render() {
const t = this.props.t;
const actionLinks = [{
label: 'Edit',
link: data => '/reports/templates/edit/' + data[0]
}];
const actions = data => [
{
label: 'Edit',
link: '/reports/templates/edit/' + data[0]
}
];
const columns = [
{ data: 0, title: "#" },
@ -37,7 +39,7 @@ export default class List extends Component {
<Title>{t('Report Templates')}</Title>
<Table withHeader dataUrl="/rest/report-templates-table" columns={columns} actionLinks={actionLinks} />
<Table withHeader dataUrl="/rest/report-templates-table" columns={columns} actions={actions} />
</div>
);
}

View file

@ -11,7 +11,7 @@ export default class List extends Component {
render() {
const t = this.props.t;
let actionLinks;
let actions;
const columns = [
{ data: 0, title: "#" },
@ -21,10 +21,12 @@ export default class List extends Component {
if (mailtrainConfig.isAuthMethodLocal) {
columns.push({ data: 2, title: "Full Name" });
actionLinks = [{
label: 'Edit',
link: data => '/users/edit/' + data[0]
}];
actions = data => [
{
label: 'Edit',
link: '/users/edit/' + data[0]
}
];
}
return (
@ -35,7 +37,7 @@ export default class List extends Component {
<Title>{t('Users')}</Title>
<Table withHeader dataUrl="/rest/users-table" columns={columns} actionLinks={actionLinks} />
<Table withHeader dataUrl="/rest/users-table" columns={columns} actions={actions} />
</div>
);
}