CUD operations on reports and report templates seem to work

Execution of reports is TBD
This commit is contained in:
Tomas Bures 2017-07-11 11:28:44 +02:00
parent 38cf3e49c0
commit 6d95fa515e
18 changed files with 273 additions and 46 deletions

24
app.js
View file

@ -40,11 +40,13 @@ const editorapi = require('./routes/editorapi');
const grapejs = require('./routes/grapejs');
const mosaico = require('./routes/mosaico');
const namespaces = require('./routes/rest/namespaces');
const users = require('./routes/rest/users');
const account = require('./routes/rest/account');
const reportTemplates = require('./routes/rest/report-templates');
const reports = require('./routes/rest/reports');
const namespacesRest = require('./routes/rest/namespaces');
const usersRest = require('./routes/rest/users');
const accountRest = require('./routes/rest/account');
const reportTemplatesRest = require('./routes/rest/report-templates');
const reportsRest = require('./routes/rest/reports');
const campaignsRest = require('./routes/rest/campaigns');
const listsRest = require('./routes/rest/lists');
const namespacesLegacyIntegration = require('./routes/namespaces-legacy-integration');
const usersLegacyIntegration = require('./routes/users-legacy-integration');
@ -257,13 +259,15 @@ app.all('/rest/*', (req, res, next) => {
next();
});
app.use('/rest', namespaces);
app.use('/rest', users);
app.use('/rest', account);
app.use('/rest', namespacesRest);
app.use('/rest', usersRest);
app.use('/rest', accountRest);
app.use('/rest', campaignsRest);
app.use('/rest', listsRest);
if (config.reports && config.reports.enabled === true) {
app.use('/rest', reportTemplates);
app.use('/rest', reports);
app.use('/rest', reportTemplatesRest);
app.use('/rest', reportsRest);
}
// catch 404 and forward to error handler

View file

@ -402,6 +402,7 @@ class TableSelect extends Component {
columns: PropTypes.array,
selectionKeyIndex: PropTypes.number,
selectionLabelIndex: PropTypes.number,
selectionAsArray: PropTypes.bool,
selectMode: PropTypes.number,
withHeader: PropTypes.bool,
dropdown: PropTypes.bool,
@ -432,9 +433,19 @@ class TableSelect extends Component {
}
async onSelectionDataAsync(sel, data) {
if (this.props.selectMode === TableSelectMode.SINGLE && this.props.dropdown) {
if (this.props.dropdown) {
let label;
if (!data) {
label = '';
} else if (this.props.selectMode === TableSelectMode.SINGLE && !this.props.selectionAsArray) {
label = data[this.props.selectionLabelIndex];
} else {
label = data.map(entry => entry[this.props.selectionLabelIndex]).join('; ');
}
this.setState({
selectedLabel: data ? data[this.props.selectionLabelIndex] : ''
selectedLabel: label
});
}
}
@ -462,7 +473,7 @@ class TableSelect extends Component {
</span>
</div>
<div className={'mt-tableselect-table' + (this.state.open ? '' : ' mt-tableselect-table-hidden')}>
<Table dataUrl={props.dataUrl} columns={props.columns} selectMode={props.selectMode} withHeader={props.withHeader} selection={owner.getFormValue(id)} onSelectionDataAsync={::this.onSelectionDataAsync} onSelectionChangedAsync={::this.onSelectionChangedAsync}/>
<Table dataUrl={props.dataUrl} columns={props.columns} selectMode={props.selectMode} selectionAsArray={this.props.selectionAsArray} withHeader={props.withHeader} selection={owner.getFormValue(id)} onSelectionDataAsync={::this.onSelectionDataAsync} onSelectionChangedAsync={::this.onSelectionChangedAsync}/>
</div>
</div>
);
@ -470,7 +481,7 @@ class TableSelect extends Component {
return wrapInput(id, htmlId, owner, props.label, props.help,
<div>
<div>
<Table dataUrl={props.dataUrl} columns={props.columns} selectMode={props.selectMode} withHeader={props.withHeader} selection={owner.getFormValue(id)} onSelectionChangedAsync={::this.onSelectionChangedAsync}/>
<Table dataUrl={props.dataUrl} columns={props.columns} selectMode={props.selectMode} selectionAsArray={this.props.selectionAsArray} withHeader={props.withHeader} selection={owner.getFormValue(id)} onSelectionChangedAsync={::this.onSelectionChangedAsync}/>
</div>
</div>
);
@ -706,12 +717,24 @@ function withForm(target) {
};
inst.updateFormValue = function(key, value) {
this.setState(previousState => ({
this.setState(previousState => {
const oldValue = previousState.formState.getIn(['data', key, 'value']);
let newState = {
formState: previousState.formState.withMutations(mutState => {
mutState.setIn(['data', key, 'value'], value);
validateFormState(this, mutState);
})
}));
};
const onChangeCallbacks = this.state.formSettings.onChange || {};
if (onChangeCallbacks[key]) {
onChangeCallbacks[key](newState, key, oldValue, value);
}
return newState;
});
};
inst.getFormValue = function(name) {

View file

@ -205,8 +205,10 @@ class SectionContent extends Component {
/* FIXME, once we turn Mailtrain to single-page application, this should become navigateTo */
window.location = '/account/login?next=' + encodeURIComponent(this.props.root);
} else if (error.response && error.response.data && error.response.data.message) {
console.error(error);
this.navigateToWithFlashMessage(this.props.root, 'danger', error.response.data.message);
} else {
console.error(error);
this.navigateToWithFlashMessage(this.props.root, 'danger', error.message);
}
return true;

View file

@ -45,6 +45,7 @@ class Table extends Component {
selectMode: PropTypes.number,
selection: PropTypes.oneOfType([PropTypes.array, PropTypes.string, PropTypes.number]),
selectionKeyIndex: PropTypes.number,
selectionAsArray: PropTypes.bool,
onSelectionChangedAsync: PropTypes.func,
onSelectionDataAsync: PropTypes.func,
actionLinks: PropTypes.array,
@ -58,14 +59,14 @@ class Table extends Component {
getSelectionMap(props) {
let selArray = [];
if (props.selectMode === TableSelectMode.SINGLE) {
if (props.selectMode === TableSelectMode.SINGLE && !this.props.selectionAsArray) {
if (props.selection !== null && props.selection !== undefined) {
selArray = [props.selection];
} else {
selArray = [];
}
} else if (props.selectMode === TableSelectMode.MULTI) {
selArray = props.selection;
} else if ((props.selectMode === TableSelectMode.SINGLE && this.props.selectionAsArray) || props.selectMode === TableSelectMode.MULTI) {
selArray = props.selection || [];
}
const selMap = new Map();
@ -126,8 +127,6 @@ class Table extends Component {
values: keysToFetch
});
console.log(response.data);
for (const row of response.data) {
const key = row[this.props.selectionKeyIndex];
if (this.selectionMap.has(key)) {
@ -270,7 +269,7 @@ class Table extends Component {
let data = selPairs.map(entry => entry[1]);
let sel = selPairs.map(entry => entry[0]);
if (this.props.selectMode === TableSelectMode.SINGLE) {
if (this.props.selectMode === TableSelectMode.SINGLE && !this.props.selectionAsArray) {
if (sel.length) {
sel = sel[0];
data = data[0];

View file

@ -3,7 +3,10 @@
import React, { Component } from 'react';
import { translate, Trans } from 'react-i18next';
import { withPageHelpers, Title } from '../lib/page'
import { withForm, Form, FormSendMethod, InputField, TextArea, TableSelect, TableSelectMode, ButtonRow, Button } from '../lib/form';
import {
withForm, Form, FormSendMethod, InputField, TextArea, TableSelect, TableSelectMode, ButtonRow, Button,
Fieldset
} from '../lib/form';
import axios from '../lib/axios';
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
import { ModalDialog } from '../lib/bootstrap-components';
@ -23,16 +26,40 @@ export default class CUD extends Component {
this.state.entityId = parseInt(props.match.params.id);
}
this.initForm();
this.initForm({
onChange: {
report_template: ::this.onReportTemplateChange
}
});
}
isDelete() {
return this.props.match.params.action === 'delete';
}
@withAsyncErrorHandler
async fetchUserFields(reportTemplateId) {
const result = await axios.get(`/rest/report-template-user-fields/${reportTemplateId}`);
this.updateFormValue('user_fields', result.data);
}
onReportTemplateChange(state, key, oldVal, newVal) {
if (oldVal !== newVal) {
state.formState = state.formState.setIn(['data', 'user_fields', 'value'], '');
if (newVal) {
this.fetchUserFields(newVal);
}
}
}
@withAsyncErrorHandler
async loadFormValues() {
await this.getFormValuesFromURL(`/rest/reports/${this.state.entityId}`);
await this.getFormValuesFromURL(`/rest/reports/${this.state.entityId}`, data => {
for (const key in data.params) {
data[`param_${key}`] = data.params[key];
}
});
}
componentDidMount() {
@ -40,9 +67,10 @@ export default class CUD extends Component {
this.loadFormValues();
} else {
this.populateFormValues({
report_template: null,
name: '',
description: ''
description: '',
report_template: null,
user_fields: null
});
}
}
@ -62,12 +90,43 @@ export default class CUD extends Component {
} else {
state.setIn(['report_template', 'error'], null);
}
for (const paramId of state.keys()) {
if (paramId.startsWith('param_')) {
state.deleteIn([paramId, 'error']);
}
}
const userFieldsSpec = state.getIn(['user_fields', 'value']);
if (userFieldsSpec) {
for (const spec of userFieldsSpec) {
const fldId = `param_${spec.id}`;
const selection = state.getIn([fldId, 'value']) || [];
if (spec.maxOccurences === 1) {
if (spec.minOccurences === 1 && (selection === null || selection === undefined)) {
state.setIn([fldId, 'error'], t('Exactly one item has to be selected'));
}
} else {
if (selection.length < spec.minOccurences) {
state.setIn([fldId, 'error'], t('At least {{ count }} item(s) have to be selected', { count: spec.minOccurences }));
} else if (selection.length > spec.maxOccurences) {
state.setIn([fldId, 'error'], t('At most {{ count }} item(s) can to be selected', { count: spec.maxOccurences }));
}
}
}
}
}
async submitHandler() {
const t = this.props.t;
const edit = this.props.edit;
if (!this.getFormValue('user_fields')) {
this.setFormStatusMessage('warning', t('Report parameters are not selected. Wait for them to get displayed and then fill them in.'));
return;
}
let sendMethod, url;
if (edit) {
sendMethod = FormSendMethod.PUT;
@ -81,7 +140,16 @@ export default class CUD extends Component {
this.setFormStatusMessage('info', t('Saving report template ...'));
const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
delete data.password2;
const params = {};
for (const spec of data.user_fields) {
const fldId = `param_${spec.id}`;
params[spec.id] = data[fldId];
delete data[fldId];
}
delete data.user_fields;
data.params = params;
});
if (submitSuccessful) {
@ -124,6 +192,49 @@ export default class CUD extends Component {
{ data: 3, title: t('Created'), render: data => moment(data).fromNow() }
];
const userFieldsSpec = this.getFormValue('user_fields');
const userFields = [];
function addUserFieldTableSelect(spec, dataUrl, selIndex, columns) {
let dropdown, selectMode;
if (spec.maxOccurences === 1) {
dropdown = true;
selectMode = TableSelectMode.SINGLE;
} else {
dropdown = true;
selectMode = TableSelectMode.MULTI;
}
const fld = <TableSelect key={spec.id} id={`param_${spec.id}`} label={spec.name} selectionAsArray withHeader dropdown={dropdown} selectMode={selectMode} dataUrl={dataUrl} columns={columns} selectionLabelIndex={selIndex}/>;
userFields.push(fld);
}
if (userFieldsSpec) {
for (const spec of userFieldsSpec) {
if (spec.type === 'campaign') {
addUserFieldTableSelect(spec, '/rest/campaigns-table', 1,[
{data: 0, title: "#"},
{data: 1, title: t('Name')},
{data: 2, title: t('Description')},
{data: 3, title: t('Status')},
{data: 4, title: t('Created'), render: data => moment(data).fromNow()}
]);
} else if (spec.type === 'list') {
addUserFieldTableSelect(spec, '/rest/lists-table', 1,[
{data: 0, title: "#"},
{data: 1, title: t('Name')},
{data: 2, title: t('ID')},
{data: 3, title: t('Subscribers')},
{data: 4, title: t('Description')}
]);
} else {
userFields.push(<div className="alert alert-danger" role="alert">{t('Unknown field type "{{type}}"', { type: spec.type })}</div>)
}
}
}
return (
<div>
{edit &&
@ -143,6 +254,16 @@ export default class CUD extends Component {
<TableSelect id="report_template" label={t('Report Template')} withHeader dropdown dataUrl="/rest/report-templates-table" columns={columns} selectionLabelIndex={1}/>
{userFieldsSpec ?
userFields.length > 0 &&
<Fieldset label={t('Report parameters')}>
{userFields}
</Fieldset>
:
this.getFormValue('report_template') &&
<div className="alert alert-info" role="alert">{t('Loading report template...')}</div>
}
<ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/>
{edit && <Button className="btn-danger" icon="remove" label={t('Delete Report')} onClickAsync={::this.showDeleteModal}/>}

View file

@ -21,7 +21,7 @@ export default class List extends Component {
{ data: 1, title: t('Name') },
{ data: 2, title: t('Template') },
{ data: 3, title: t('Description') },
{ data: 4, title: t('Created'), render: data => moment(data).fromNow() }
{ data: 4, title: t('Last Run'), render: data => data ? moment(data).fromNow() : t('Not run yet') }
];
return (

View file

@ -269,9 +269,7 @@ export default class CUD extends Component {
this.disableForm();
this.setFormStatusMessage('info', t('Saving report template ...'));
const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
delete data.password2;
});
const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url);
if (submitSuccessful) {
if (stay) {

View file

@ -44,6 +44,8 @@ async function ajaxList(params, queryFun, columns) {
query.orderBy(columns[params.columns[order.column].data], order.dir);
}
query.options({rowsAsArray:true});
const rows = await query;
const rowsOfArray = rows.map(row => Object.keys(row).map(field => row[field]));

View file

@ -3,7 +3,7 @@
const config = require('config');
const knex = require('knex')({
client: 'mysql',
client: 'mysql2',
connection: config.mysql,
migrations: {
directory: __dirname + '/../setup/knex/migrations'

13
models/campaigns.js Normal file
View file

@ -0,0 +1,13 @@
'use strict';
const knex = require('../lib/knex');
const dtHelpers = require('../lib/dt-helpers');
async function listDTAjax(params) {
return await dtHelpers.ajaxList(params, tx => tx('campaigns'), ['campaigns.id', 'campaigns.name', 'campaigns.description', 'campaigns.status', 'campaigns.created']);
}
module.exports = {
listDTAjax
};

13
models/lists.js Normal file
View file

@ -0,0 +1,13 @@
'use strict';
const knex = require('../lib/knex');
const dtHelpers = require('../lib/dt-helpers');
async function listDTAjax(params) {
return await dtHelpers.ajaxList(params, tx => tx('lists'), ['lists.id', 'lists.name', 'lists.cid', 'lists.subscribers', 'lists.description']);
}
module.exports = {
listDTAjax
};

View file

@ -50,11 +50,21 @@ async function remove(id) {
await knex('report_templates').where('id', id).del();
}
async function getUserFieldsById(id) {
const entity = await knex('report_templates').select(['user_fields']).where('id', id).first();
if (!entity) {
throw new interoperableErrors.NotFoundError();
}
return JSON.parse(entity.user_fields);
}
module.exports = {
hash,
getById,
listDTAjax,
create,
updateWithConsistencyCheck,
remove
remove,
getUserFieldsById
};

View file

@ -21,12 +21,15 @@ function hash(entity) {
return hasher.hash(filterObject(entity, allowedKeys));
}
async function getById(id) {
const entity = await knex('reports').where('id', id).first();
async function getByIdWithUserFields(id) {
const entity = await knex('reports').where('reports.id', id).innerJoin('report_templates', 'reports.report_template', 'report_templates.id').select(['reports.id', 'reports.name', 'reports.description', 'reports.report_template', 'reports.params', 'report_templates.user_fields']).first();
if (!entity) {
throw new interoperableErrors.NotFoundError();
}
entity.user_fields = JSON.parse(entity.user_fields);
entity.params = JSON.parse(entity.params);
return entity;
}
@ -36,12 +39,14 @@ async function listDTAjax(params) {
async function create(entity) {
await knex.transaction(async tx => {
const id = await tx('reports').insert(filterObject(entity, allowedKeys));
if (!await tx('report_templates').select(['id']).where('id', entity.report_template).first()) {
throw new interoperableErrors.DependencyNotFoundError();
}
entity.params = JSON.stringify(entity.params);
const id = await tx('reports').insert(filterObject(entity, allowedKeys));
return id;
});
}
@ -53,6 +58,8 @@ async function updateWithConsistencyCheck(entity) {
throw new interoperableErrors.NotFoundError();
}
existing.params = JSON.parse(existing.params);
const existingHash = hash(existing);
if (existingHash != entity.originalHash) {
throw new interoperableErrors.ChangedError();
@ -62,6 +69,8 @@ async function updateWithConsistencyCheck(entity) {
throw new interoperableErrors.DependencyNotFoundError();
}
entity.params = JSON.stringify(entity.params);
await tx('reports').where('id', entity.id).update(filterObject(entity, allowedKeys));
});
}
@ -87,7 +96,7 @@ async function bulkChangeState(oldState, newState) {
module.exports = {
ReportState,
hash,
getById,
getByIdWithUserFields,
listDTAjax,
create,
updateWithConsistencyCheck,

View file

@ -93,7 +93,7 @@
"morgan": "^1.8.1",
"multer": "^1.3.0",
"multiparty": "^4.1.3",
"mysql": "^2.13.0",
"mysql2": "^1.3.5",
"node-gettext": "^2.0.0-rc.1",
"node-mocks-http": "^1.6.1",
"node-object-hash": "^1.2.0",

14
routes/rest/campaigns.js Normal file
View file

@ -0,0 +1,14 @@
'use strict';
const passport = require('../../lib/passport');
const campaigns = require('../../models/campaigns');
const router = require('../../lib/router-async').create();
router.postAsync('/campaigns-table', passport.loggedIn, async (req, res) => {
return res.json(await campaigns.listDTAjax(req.body));
});
module.exports = router;

14
routes/rest/lists.js Normal file
View file

@ -0,0 +1,14 @@
'use strict';
const passport = require('../../lib/passport');
const lists = require('../../models/lists');
const router = require('../../lib/router-async').create();
router.postAsync('/lists-table', passport.loggedIn, async (req, res) => {
return res.json(await lists.listDTAjax(req.body));
});
module.exports = router;

View file

@ -35,5 +35,10 @@ router.postAsync('/report-templates-table', passport.loggedIn, async (req, res)
return res.json(await reportTemplates.listDTAjax(req.body));
});
router.getAsync('/report-template-user-fields/:reportTemplateId', passport.loggedIn, async (req, res) => {
const userFields = await reportTemplates.getUserFieldsById(req.params.reportTemplateId);
return res.json(userFields);
});
module.exports = router;

View file

@ -9,7 +9,7 @@ const router = require('../../lib/router-async').create();
router.getAsync('/reports/:reportId', passport.loggedIn, async (req, res) => {
const report = await reports.getById(req.params.reportId);
const report = await reports.getByIdWithUserFields(req.params.reportId);
report.hash = reports.hash(report);
return res.json(report);
});