Report templates ported to ReactJS and Knex.

Does not run yet because reports have dependencies on the old report templates.
This commit is contained in:
Tomas Bures 2017-07-09 15:41:53 +02:00
parent be7da791db
commit d4cea46f07
29 changed files with 807 additions and 688 deletions

View file

@ -24,9 +24,11 @@
"i18next": "^8.4.3",
"i18next-xhr-backend": "^1.4.2",
"immutable": "^3.8.1",
"moment": "^2.18.1",
"owasp-password-strength-test": "github:bures/owasp-password-strength-test",
"prop-types": "^15.5.10",
"react": "^15.6.1",
"react-ace": "^5.1.0",
"react-dom": "^15.6.1",
"react-i18next": "^4.6.1",
"react-router-dom": "^4.1.1",

View file

@ -73,6 +73,33 @@ class Button extends Component {
}
}
class DropdownMenu extends Component {
static propTypes = {
label: PropTypes.string,
className: PropTypes.string
}
render() {
const props = this.props;
let className = 'btn dropdown-toggle';
if (props.className) {
className = className + ' ' + props.className;
}
return (
<div className="btn-group">
<button type="button" className={className} data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
{props.label}{' '}<span className="caret"></span>
</button>
<ul className="dropdown-menu">
{props.children}
</ul>
</div>
);
}
}
@withErrorHandling
class ActionLink extends Component {
@ -210,6 +237,7 @@ class ModalDialog extends Component {
export {
Button,
DropdownMenu,
ActionLink,
DismissibleAlert,
ModalDialog

View file

@ -10,6 +10,13 @@ import { withPageHelpers } from './page'
import { withErrorHandling, withAsyncErrorHandler } from './error-handling';
import { TreeTable, TreeSelectMode } from './tree';
import brace from 'brace';
import AceEditor from 'react-ace';
import 'brace/mode/javascript';
import 'brace/mode/json';
import 'brace/mode/handlebars';
import 'brace/theme/github';
const FormState = {
Loading: 0,
LoadingWithNotice: 1,
@ -40,18 +47,9 @@ class Form extends Component {
};
}
@withAsyncErrorHandler
async onSubmit(evt) {
const t = this.props.t;
const owner = this.props.stateOwner;
static async handleChangedError(owner, fn) {
try {
evt.preventDefault();
if (this.props.onSubmitAsync) {
await this.props.onSubmitAsync(evt);
}
await fn();
} catch (error) {
if (error instanceof interoperableErrors.ChangedError) {
owner.disableForm();
@ -68,6 +66,19 @@ class Form extends Component {
}
}
@withAsyncErrorHandler
async onSubmit(evt) {
const t = this.props.t;
const owner = this.props.stateOwner;
evt.preventDefault();
if (this.props.onSubmitAsync) {
await Form.handleChangedError(owner, async () => await this.props.onSubmitAsync(evt));
}
}
render() {
const t = this.props.t;
const owner = this.props.stateOwner;
@ -148,7 +159,7 @@ class InputField extends Component {
label: PropTypes.string.isRequired,
placeholder: PropTypes.string,
type: PropTypes.string,
help: PropTypes.string
help: PropTypes.oneOfType([PropTypes.string, PropTypes.object])
}
static defaultProps = {
@ -180,7 +191,7 @@ class CheckBox extends Component {
static propTypes = {
id: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
help: PropTypes.string
help: PropTypes.oneOfType([PropTypes.string, PropTypes.object])
}
static contextTypes = {
@ -203,8 +214,7 @@ class TextArea extends Component {
static propTypes = {
id: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
placeholder: PropTypes.string,
help: PropTypes.string
help: PropTypes.oneOfType([PropTypes.string, PropTypes.object])
}
static contextTypes = {
@ -223,6 +233,35 @@ class TextArea extends Component {
}
}
class Dropdown extends Component {
static propTypes = {
id: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
options: PropTypes.array.isRequired
}
static contextTypes = {
formStateOwner: PropTypes.object.isRequired
}
render() {
const props = this.props;
const owner = this.context.formStateOwner;
const id = this.props.id;
const htmlId = 'form_' + id;
const options = props.options.map(option => <option key={option.key} value={option.key}>{option.label}</option>);
return wrapInput(id, htmlId, owner, props.label, props.help,
<select id={htmlId} className="form-control" aria-describedby={htmlId + '_help'} value={owner.getFormValue(id)} onChange={evt => owner.updateFormValue(id, evt.target.value)}>
{options}
</select>
);
}
}
class AlignedRow extends Component {
static propTypes = {
className: PropTypes.string
@ -310,7 +349,7 @@ class TreeTableSelect extends Component {
label: PropTypes.string.isRequired,
dataUrl: PropTypes.string,
data: PropTypes.array,
help: PropTypes.string
help: PropTypes.oneOfType([PropTypes.string, PropTypes.object])
}
static contextTypes = {
@ -334,6 +373,42 @@ class TreeTableSelect extends Component {
}
}
class ACEEditor extends Component {
static propTypes = {
id: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
height: PropTypes.string,
mode: PropTypes.string
}
static contextTypes = {
formStateOwner: PropTypes.object.isRequired
}
render() {
const props = this.props;
const owner = this.context.formStateOwner;
const id = this.props.id;
const htmlId = 'form_' + id;
return wrapInput(id, htmlId, owner, props.label, props.help,
<AceEditor
id={htmlId}
mode={props.mode}
theme="github"
onChange={data => owner.updateFormValue(id, data)}
fontSize={12}
width="100%"
height={props.height}
showPrintMargin={false}
value={owner.getFormValue(id)}
tabSize={2}
/>
);
}
}
function withForm(target) {
const inst = target.prototype;
@ -647,9 +722,11 @@ export {
InputField,
CheckBox,
TextArea,
Dropdown,
AlignedRow,
ButtonRow,
Button,
TreeTableSelect,
ACEEditor,
FormSendMethod
}

View file

@ -1,12 +1,13 @@
import i18n from 'i18next';
import XHR from 'i18next-xhr-backend';
// import Cache from 'i18next-localstorage-cache';
import mailtrainConfig from 'mailtrainConfig';
i18n
.use(XHR)
// .use(Cache)
.init({
lng: 'en', // FIXME set language from mailtrain (ideally from react-root.hbs)
lng: mailtrainConfig.language,
wait: true, // globally set to wait for loaded translations in translate hoc

View file

@ -21,4 +21,8 @@
.form-horizontal .control-label {
display: block;
}
.ace_editor {
border: 1px solid #ccc;
}

View file

@ -295,6 +295,20 @@ class NavButton extends Component {
}
}
class DropdownLink extends Component {
static propTypes = {
to: PropTypes.string
}
render() {
const props = this.props;
return (
<li><Link to={props.to}>{props.children}</Link></li>
);
}
}
function withPageHelpers(target) {
withErrorHandling(target);
@ -340,5 +354,6 @@ export {
Title,
Toolbar,
NavButton,
DropdownLink,
withPageHelpers
};

View file

@ -0,0 +1,351 @@
'use strict';
import React, { Component } from 'react';
import { translate, Trans } from 'react-i18next';
import { withPageHelpers, Title } from '../lib/page'
import { withForm, Form, FormSendMethod, InputField, TextArea, Dropdown, ACEEditor, ButtonRow, Button } from '../lib/form';
import axios from '../lib/axios';
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
import { ModalDialog } from '../lib/bootstrap-components';
@translate()
@withForm
@withPageHelpers
@withErrorHandling
export default class CUD extends Component {
constructor(props) {
super(props);
this.state = {};
if (props.edit) {
this.state.entityId = parseInt(props.match.params.id);
}
this.initForm();
}
isDelete() {
return this.props.match.params.action === 'delete';
}
@withAsyncErrorHandler
async loadFormValues() {
await this.getFormValuesFromURL(`/rest/report-templates/${this.state.entityId}`);
}
componentDidMount() {
if (this.props.edit) {
this.loadFormValues();
} else {
const wizard = this.props.match.params.wizard;
if (wizard === 'subscribers-all') {
this.populateFormValues({
name: '',
description: 'Generates a campaign report listing all subscribers along with their statistics.',
mime_type: 'text/html',
user_fields:
'[\n' +
' {\n' +
' "id": "campaign",\n' +
' "name": "Campaign",\n' +
' "type": "campaign",\n' +
' "minOccurences": 1,\n' +
' "maxOccurences": 1\n' +
' }\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' +
'});',
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' +
' <thead>\n' +
' <th>\n' +
' {{#translate}}Email{{/translate}}\n' +
' </th>\n' +
' <th>\n' +
' {{#translate}}Tracker Count{{/translate}}\n' +
' </th>\n' +
' </thead>\n' +
' {{#if results}}\n' +
' <tbody>\n' +
' {{#each results}}\n' +
' <tr>\n' +
' <th scope="row">\n' +
' {{email}}\n' +
' </th>\n' +
' <td style="width: 20%;">\n' +
' {{tracker_count}}\n' +
' </td>\n' +
' </tr>\n' +
' {{/each}}\n' +
' </tbody>\n' +
' {{/if}}\n' +
' </table>\n' +
'</div>'
});
} else if (wizard === 'subscribers-grouped') {
this.populateFormValues({
name: '',
description: 'Generates a campaign report with results are aggregated by some "Country" custom field.',
mime_type: 'text/html',
user_fields:
'[\n' +
' {\n' +
' "id": "campaign",\n' +
' "name": "Campaign",\n' +
' "type": "campaign",\n' +
' "minOccurences": 1,\n' +
' "maxOccurences": 1\n' +
' }\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' +
'\n' +
' for (let 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' +
'});',
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' +
' <thead>\n' +
' <th>\n' +
' {{#translate}}Country{{/translate}}\n' +
' </th>\n' +
' <th>\n' +
' {{#translate}}Opened{{/translate}}\n' +
' </th>\n' +
' <th>\n' +
' {{#translate}}All{{/translate}}\n' +
' </th>\n' +
' <th>\n' +
' {{#translate}}Percentage{{/translate}}\n' +
' </th>\n' +
' </thead>\n' +
' {{#if results}}\n' +
' <tbody>\n' +
' {{#each results}}\n' +
' <tr>\n' +
' <th scope="row">\n' +
' {{custom_country}}\n' +
' </th>\n' +
' <td style="width: 20%;">\n' +
' {{count_opened}}\n' +
' </td>\n' +
' <td style="width: 20%;">\n' +
' {{count_all}}\n' +
' </td>\n' +
' <td style="width: 20%;">\n' +
' {{percentage}}%\n' +
' </td>\n' +
' </tr>\n' +
' {{/each}}\n' +
' </tbody>\n' +
' {{/if}}\n' +
' </table>\n' +
'</div>'
});
} else if (wizard === 'export-list-csv') {
this.populateFormValues({
name: '',
description: 'Exports a list as a CSV file.',
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:
'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' +
'});',
hbs:
'{{#each results}}\n' +
'{{firstName}},{{lastName}},{{email}}\n' +
'{{/each}}'
});
} else {
this.populateFormValues({
name: '',
description: '',
mime_type: 'text/html',
user_fields: '',
js: '',
hbs: ''
});
}
}
}
localValidateFormValues(state) {
const t = this.props.t;
const edit = this.props.edit;
if (!state.getIn(['name', 'value'])) {
state.setIn(['name', 'error'], t('Name must not be empty'));
} else {
state.setIn(['name', 'error'], null);
}
if (!state.getIn(['mime_type', 'value'])) {
state.setIn(['mime_type', 'error'], t('MIME Type must be selected'));
} else {
state.setIn(['mime_type', 'error'], null);
}
try {
const userFields = JSON.parse(state.getIn(['user_fields', 'value']));
state.setIn(['user_fields', 'error'], null);
} catch (err) {
if (err instanceof SyntaxError) {
state.setIn(['user_fields', 'error'], t('Syntax error in the user fields specification'));
}
}
}
async submitAndStay() {
await Form.handleChangedError(this, async () => await this.doSubmit(true));
}
async submitAndLeave() {
await Form.handleChangedError(this, async () => await this.doSubmit(false));
}
async doSubmit(stay) {
const t = this.props.t;
const edit = this.props.edit;
let sendMethod, url;
if (edit) {
sendMethod = FormSendMethod.PUT;
url = `/rest/report-templates/${this.state.entityId}`
} else {
sendMethod = FormSendMethod.POST;
url = '/rest/report-templates'
}
this.disableForm();
this.setFormStatusMessage('info', t('Saving report template ...'));
const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
delete data.password2;
});
if (submitSuccessful) {
if (stay) {
this.enableForm();
this.setFormStatusMessage('success', t('Report template saved'));
} else {
this.navigateToWithFlashMessage('/report-templates', 'success', t('Report template saved'));
}
} else {
this.enableForm();
this.setFormStatusMessage('warning', t('There are errors in the form. Please fix them and submit again.'));
}
}
async showDeleteModal() {
this.navigateTo(`/report-templates/edit/${this.state.entityId}/delete`);
}
async hideDeleteModal() {
this.navigateTo(`/report-templates/edit/${this.state.entityId}`);
}
async performDelete() {
const t = this.props.t;
await this.hideDeleteModal();
this.disableForm();
this.setFormStatusMessage('info', t('Deleting report template...'));
await axios.delete(`/rest/report-templates/${this.state.entityId}`);
this.navigateToWithFlashMessage('/report-templates', 'success', t('Report template deleted'));
}
render() {
const t = this.props.t;
const edit = this.props.edit;
const userId = this.getFormValue('id');
return (
<div>
{edit &&
<ModalDialog hidden={!this.isDelete()} title={t('Confirm deletion')} onCloseAsync={::this.hideDeleteModal} buttons={[
{ label: t('No'), className: 'btn-primary', onClickAsync: ::this.hideDeleteModal },
{ label: t('Yes'), className: 'btn-danger', onClickAsync: ::this.performDelete }
]}>
{t('Are you sure you want to delete report template "{{name}}"?', {name: this.getFormValue('name')})}
</ModalDialog>
}
<Title>{edit ? t('Edit Report Template') : t('Create Report Template')}</Title>
<Form stateOwner={this} onSubmitAsync={::this.submitAndLeave}>
<InputField id="name" label={t('Name')}/>
<TextArea id="description" label={t('Description')} help={t('HTML is allowed')}/>
<Dropdown id="mime_type" label={t('Type')} options={[{key: 'text/html', label: t('HTML')}, {key: 'text/csv', label: t('CSV')}]}/>
<ACEEditor id="user_fields" height="250px" mode="json" label={t('User selectable fields')} help={t('JSON specification of user selectable fields.')}/>
<ACEEditor id="js" height="700px" mode="javascript" label={t('Data processing code')} help={<Trans>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="hbs" height="700px" mode="handlebars" label={t('Rendering template')} help={<Trans>Use HTML with Handlebars syntax. See documentation <a href="http://handlebarsjs.com/">here</a>.</Trans>}/>
{edit ?
<ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Save and Stay')} onClickAsync={::this.submitAndStay}/>
<Button type="submit" className="btn-primary" icon="ok" label={t('Save and Leave')}/>
<Button className="btn-danger" icon="remove" label={t('Delete Template')} onClickAsync={::this.showDeleteModal}/>
</ButtonRow>
:
<ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/>
</ButtonRow>
}
</Form>
</div>
);
}
}

View file

@ -0,0 +1,44 @@
'use strict';
import React, { Component } from 'react';
import { translate } from 'react-i18next';
import { DropdownMenu } from '../lib/bootstrap-components';
import { Title, Toolbar, DropdownLink } from '../lib/page';
import { Table } from '../lib/table';
import moment from 'moment';
@translate()
export default class List extends Component {
render() {
const t = this.props.t;
const actionLinks = [{
label: 'Edit',
link: data => '/report-templates/edit/' + data[0]
}];
const columns = [
{ data: 0, title: "#" },
{ data: 1, title: t('Name') },
{ data: 2, title: t('Description') },
{ data: 3, title: t('Created'), render: data => moment(data).fromNow() }
];
return (
<div>
<Toolbar>
<DropdownMenu className="btn-primary" label={t('Create Report Template')}>
<DropdownLink to="/report-templates/create">{t('Blank')}</DropdownLink>
<DropdownLink to="/report-templates/create/subscribers-all">{t('All Subscribers')}</DropdownLink>
<DropdownLink to="/report-templates/create/subscribers-grouped">{t('Grouped Subscribers')}</DropdownLink>
<DropdownLink to="/report-templates/create/export-list-csv">{t('Export List as CSV')}</DropdownLink>
</DropdownMenu>
</Toolbar>
<Title>{t('Users')}</Title>
<Table withHeader dataUrl="/rest/report-templates-table" columns={columns} actionLinks={actionLinks} />
</div>
);
}
}

View file

@ -0,0 +1,49 @@
'use strict';
import React from 'react';
import ReactDOM from 'react-dom';
import { I18nextProvider } from 'react-i18next';
import i18n from '../lib/i18n';
import { Section } from '../lib/page'
import CUD from './CUD'
import List from './List'
const getStructure = t => {
const subPaths = {};
return {
'': {
title: t('Home'),
externalLink: '/',
children: {
'report-templates': {
title: t('Report Templates'),
link: '/report-templates',
component: List,
children: {
edit: {
title: t('Edit Report Template'),
params: [':id', ':action?'],
render: props => (<CUD edit {...props} />)
},
create: {
title: t('Create Report Template'),
params: [':wizard?'],
render: props => (<CUD {...props} />)
}
}
}
}
}
}
};
export default function() {
ReactDOM.render(
<I18nextProvider i18n={ i18n }><Section root='/report-templates' structure={getStructure}/></I18nextProvider>,
document.getElementById('root')
);
};

View file

@ -10,6 +10,7 @@ import interoperableErrors from '../../../shared/interoperable-errors';
import passwordValidator from '../../../shared/password-validator';
import validators from '../../../shared/validators';
import { ModalDialog } from '../lib/bootstrap-components';
import mailtrainConfig from 'mailtrainConfig';
@translate()
@withForm
@ -200,11 +201,12 @@ export default class CUD extends Component {
render() {
const t = this.props.t;
const edit = this.props.edit;
const isAdmin = this.getFormValue('id') === 1;
const userId = this.getFormValue('id');
const canDelete = userId !== 1 && mailtrainConfig.userId !== userId;
return (
<div>
{edit && !isAdmin &&
{edit && canDelete &&
<ModalDialog hidden={!this.isDelete()} title={t('Confirm deletion')} onCloseAsync={::this.hideDeleteModal} buttons={[
{ label: t('No'), className: 'btn-primary', onClickAsync: ::this.hideDeleteModal },
{ label: t('Yes'), className: 'btn-danger', onClickAsync: ::this.performDelete }
@ -224,7 +226,7 @@ export default class CUD extends Component {
<ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/>
{edit && !isAdmin && <Button className="btn-danger" icon="remove" label={t('Delete User')}
{edit && canDelete && <Button className="btn-danger" icon="remove" label={t('Delete User')}
onClickAsync={::this.showDeleteModal}/>}
</ButtonRow>
</Form>

View file

@ -11,12 +11,7 @@ export default class List extends Component {
render() {
const t = this.props.t;
const actionLinks = [
{
label: 'Edit',
link: data => '/users/edit/' + data[0]
}
];
let actionLinks;
const columns = [
{ data: 0, title: "#" },
@ -25,6 +20,11 @@ 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]
}];
}
return (

View file

@ -5,7 +5,8 @@ module.exports = {
entry: {
namespaces: ['babel-polyfill', './src/namespaces/root.js'],
users: ['babel-polyfill', './src/users/root.js'],
account: ['babel-polyfill', './src/account/root.js']
account: ['babel-polyfill', './src/account/root.js'],
reportTemplates: ['babel-polyfill', './src/report-templates/root.js']
},
output: {
library: 'MailtrainReactBody',