From 3f7b42854612e68bd8f5297e55fee4eb87ac9207 Mon Sep 17 00:00:00 2001 From: Tomas Bures Date: Sun, 9 Jul 2017 23:16:47 +0200 Subject: [PATCH] Reports halfway through Datatable now correctly handles the situation when user is not logged in and access protected resources --- app.js | 11 +- client/src/account/Reset.js | 2 +- client/src/lib/form.js | 55 ++++- client/src/lib/page.css | 4 +- client/src/lib/table.js | 55 +++-- client/src/namespaces/CUD.js | 13 +- client/src/reports/CUD.js | 269 ++++-------------------- client/src/reports/List.js | 22 +- client/src/reports/root.js | 44 ++-- client/src/reports/templates/CUD.js | 10 +- client/src/reports/templates/List.js | 6 +- client/webpack.config.js | 2 +- index.js | 7 +- lib/client-helpers.js | 2 +- lib/report-processor.js | 135 ++++++------ models/namespaces.js | 58 +++-- models/report-templates.js | 28 +-- models/reports.js | 90 +++++--- models/users.js | 10 +- routes/account-legacy-integration.js | 2 +- routes/namespaces-legacy-integration.js | 2 +- routes/reports-REMOVE.js | 2 +- routes/reports-legacy-integration.js | 2 +- routes/rest/report-templates.js | 2 +- routes/rest/reports.js | 42 ++-- routes/users-legacy-integration.js | 2 +- shared/interoperable-errors.js | 13 +- workers/reports/report-processor.js | 2 +- 28 files changed, 421 insertions(+), 471 deletions(-) diff --git a/app.js b/app.js index 9f228293..345110e5 100644 --- a/app.js +++ b/app.js @@ -39,17 +39,17 @@ const blacklist = require('./routes/blacklist'); const editorapi = require('./routes/editorapi'); const grapejs = require('./routes/grapejs'); const mosaico = require('./routes/mosaico'); -const reports = require('./routes/reports'); 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 namespacesLegacyIntegration = require('./routes/namespaces-legacy-integration'); const usersLegacyIntegration = require('./routes/users-legacy-integration'); const accountLegacyIntegration = require('./routes/account-legacy-integration'); -const reportTemplatesLegacyIntegration = require('./routes/report-templates-legacy-integration'); +const reportsLegacyIntegration = require('./routes/reports-legacy-integration'); const interoperableErrors = require('./shared/interoperable-errors'); @@ -246,7 +246,7 @@ app.use('/namespaces', namespacesLegacyIntegration); app.use('/account', accountLegacyIntegration); if (config.reports && config.reports.enabled === true) { - app.use('/report-templates', reportTemplatesLegacyIntegration); + app.use('/reports', reportsLegacyIntegration); } /* ------------------------------------------------------------------- */ @@ -263,10 +263,7 @@ app.use('/rest', account); if (config.reports && config.reports.enabled === true) { app.use('/rest', reportTemplates); -} - -if (config.reports && config.reports.enabled === true) { - app.use('/reports', reports); + app.use('/rest', reports); } // catch 404 and forward to error handler diff --git a/client/src/account/Reset.js b/client/src/account/Reset.js index 1f03a6ea..ba04e5d1 100644 --- a/client/src/account/Reset.js +++ b/client/src/account/Reset.js @@ -101,7 +101,7 @@ export default class Account extends Component { this.setFormStatusMessage('warning', t('There are errors in the form. Please fix them and submit again.')); } } catch (error) { - if (error instanceof interoperableErrors.InvalidToken) { + if (error instanceof interoperableErrors.InvalidTokenError) { this.setFormStatusMessage('danger', {t('Your password cannot be reset.')}{' '} diff --git a/client/src/lib/form.js b/client/src/lib/form.js index b0cd4df1..d8809f00 100644 --- a/client/src/lib/form.js +++ b/client/src/lib/form.js @@ -9,6 +9,7 @@ import interoperableErrors from '../../../shared/interoperable-errors'; import { withPageHelpers } from './page' import { withErrorHandling, withAsyncErrorHandler } from './error-handling'; import { TreeTable, TreeSelectMode } from './tree'; +import { Table, TableSelectMode } from './table'; import brace from 'brace'; import AceEditor from 'react-ace'; @@ -62,6 +63,17 @@ class Form extends Component { return; } + if (error instanceof interoperableErrors.NotFoundError) { + owner.disableForm(); + owner.setFormStatusMessage('danger', + + {t('Your updates cannot be saved.')}{' '} + {t('It seems that someone else has deleted the entity in the meantime.')} + + ); + return; + } + throw error; } } @@ -368,7 +380,46 @@ class TreeTableSelect extends Component { const htmlId = 'form_' + id; return wrapInput(id, htmlId, owner, props.label, props.help, - + + ); + } +} + +class TableSelect extends Component { + static propTypes = { + dataUrl: PropTypes.string, + data: PropTypes.array, + columns: PropTypes.array, + selectionKeyIndex: PropTypes.number, + selectMode: PropTypes.number, + withHeader: PropTypes.bool, + + id: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]) + } + + static defaultProps = { + selectMode: TableSelectMode.SINGLE + } + + static contextTypes = { + formStateOwner: PropTypes.object.isRequired + } + + async onSelectionChangedAsync(sel) { + const owner = this.context.formStateOwner; + owner.updateFormValue(this.props.id, sel); + } + + 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, + ); } } @@ -727,6 +778,8 @@ export { ButtonRow, Button, TreeTableSelect, + TableSelect, + TableSelectMode, ACEEditor, FormSendMethod } diff --git a/client/src/lib/page.css b/client/src/lib/page.css index c8ce5609..d3af310a 100644 --- a/client/src/lib/page.css +++ b/client/src/lib/page.css @@ -1,8 +1,8 @@ -.mt-button-row > button { +.mt-button-row > * { margin-right: 15px; } -.mt-button-row > button:last-child { +.mt-button-row > *:last-child { margin-right: 0px; } diff --git a/client/src/lib/table.js b/client/src/lib/table.js index 24d31f3b..74d37e7d 100644 --- a/client/src/lib/table.js +++ b/client/src/lib/table.js @@ -52,6 +52,7 @@ class Table extends Component { columns: PropTypes.array, selectMode: PropTypes.number, selection: PropTypes.oneOfType([PropTypes.array, PropTypes.string, PropTypes.number]), + selectionKeyIndex: PropTypes.number, onSelectionChangedAsync: PropTypes.func, actionLinks: PropTypes.array, withHeader: PropTypes.bool @@ -61,6 +62,10 @@ class Table extends Component { return this.props.selection !== nextProps.selection || this.props.data != nextProps.data || this.props.dataUrl != nextProps.dataUrl; } + static defaultProps = { + selectionKeyIndex: 0 + } + componentDidMount() { const columns = this.props.columns.slice(); @@ -108,10 +113,7 @@ class Table extends Component { dtOptions.data = this.props.data; } else { dtOptions.serverSide = true; - dtOptions.ajax = { - url: this.props.dataUrl, - type: 'POST' - }; + dtOptions.ajax = ::this.fetchData; } this.table = jQuery(this.domTable).DataTable(dtOptions); @@ -122,6 +124,13 @@ class Table extends Component { this.updateSelection(); } + @withAsyncErrorHandler + async fetchData(data, callback) { + // This custom ajax fetch function allows us to properly handle the case when the user is not authenticated. + const response = await axios.post(this.props.dataUrl, data); + callback(response.data); + } + componentDidUpdate() { if (this.props.data) { this.table.clear(); @@ -132,23 +141,35 @@ class Table extends Component { } updateSelection() { - /* - const tree = this.tree; - if (this.selectMode === TableSelectMode.MULTI) { - const selectSet = new Set(this.props.selection); - - tree.enableUpdate(false); - tree.visit(node => node.setSelected(selectSet.has(node.key))); - tree.enableUpdate(true); - - } else if (this.selectMode === TableSelectMode.SINGLE) { - this.tree.activateKey(this.props.selection); + let selArray = []; + if (this.selectMode === TableSelectMode.SINGLE) { + selArray = [this.props.selection]; + } else if (this.selectMode === TableSelectMode.MULTI) { + selArray = this.props.selection; } - */ + + const selSet = new Set(selArray); + + const selectionKeyIndex = this.props.selectionKeyIndex; + + this.table.rows({ selected: true }).every(function() { + const key = this.data()[selectionKeyIndex]; + if (!selSet.has(key)) { + this.deselect(); + } + + selSet.delete(key); + }); + + this.table.rows((idx, data, node) => selSet.has(data[selectionKeyIndex])).select(); } async onSelect(event, data) { - const sel = this.table.rows( { selected: true } ).data(); + let sel = this.table.rows( { selected: true } ).data().toArray().map(item => item[this.props.selectionKeyIndex]); + + if (this.selectMode === TableSelectMode.SINGLE) { + sel = sel.length ? sel[0] : null; + } if (this.props.onSelectionChangedAsync) { await this.props.onSelectionChangedAsync(sel); diff --git a/client/src/namespaces/CUD.js b/client/src/namespaces/CUD.js index 28a13886..570dce3f 100644 --- a/client/src/namespaces/CUD.js +++ b/client/src/namespaces/CUD.js @@ -144,11 +144,20 @@ export default class CUD extends Component { } catch (error) { if (error instanceof interoperableErrors.LoopDetectedError) { - this.disableForm(); this.setFormStatusMessage('danger', {t('Your updates cannot be saved.')}{' '} - {t('There has been a loop detected in the assignment of the parent namespace. This is most likely because someone else has changed the parent of some namespace in the meantime. Refresh your page to start anew with fresh data. Please note that your changes will be lost.')} + {t('There has been a loop detected in the assignment of the parent namespace. This is most likely because someone else has changed the parent of some namespace in the meantime. Refresh your page to start anew. Please note that your changes will be lost.')} + + ); + return; + } + + if (error instanceof interoperableErrors.DependencyNotFoundError) { + this.setFormStatusMessage('danger', + + {t('Your updates cannot be saved.')}{' '} + {t('It seems that the parent namespace has been deleted in the meantime. Refresh your page to start anew. Please note that your changes will be lost.')} ); return; diff --git a/client/src/reports/CUD.js b/client/src/reports/CUD.js index f313841c..0c607925 100644 --- a/client/src/reports/CUD.js +++ b/client/src/reports/CUD.js @@ -3,10 +3,11 @@ 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 { withForm, Form, FormSendMethod, InputField, TextArea, TableSelect, TableSelectMode, ButtonRow, Button } from '../lib/form'; import axios from '../lib/axios'; import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling'; import { ModalDialog } from '../lib/bootstrap-components'; +import moment from 'moment'; @translate() @withForm @@ -31,191 +32,18 @@ export default class CUD extends Component { @withAsyncErrorHandler async loadFormValues() { - await this.getFormValuesFromURL(`/rest/report-templates/${this.state.entityId}`); + await this.getFormValuesFromURL(`/rest/reports/${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: - '

{{title}}

\n' + - '\n' + - '
\n' + - '
\n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' {{#if results}}\n' + - ' \n' + - ' {{#each results}}\n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' {{/each}}\n' + - ' \n' + - ' {{/if}}\n' + - '
\n' + - ' {{#translate}}Email{{/translate}}\n' + - ' \n' + - ' {{#translate}}Tracker Count{{/translate}}\n' + - '
\n' + - ' {{email}}\n' + - ' \n' + - ' {{tracker_count}}\n' + - '
\n' + - '' - }); - - } 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: - '

{{title}}

\n' + - '\n' + - '
\n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' {{#if results}}\n' + - ' \n' + - ' {{#each results}}\n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' \n' + - ' {{/each}}\n' + - ' \n' + - ' {{/if}}\n' + - '
\n' + - ' {{#translate}}Country{{/translate}}\n' + - ' \n' + - ' {{#translate}}Opened{{/translate}}\n' + - ' \n' + - ' {{#translate}}All{{/translate}}\n' + - ' \n' + - ' {{#translate}}Percentage{{/translate}}\n' + - '
\n' + - ' {{custom_country}}\n' + - ' \n' + - ' {{count_opened}}\n' + - ' \n' + - ' {{count_all}}\n' + - ' \n' + - ' {{percentage}}%\n' + - '
\n' + - '
' - }); - - } 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: '' - }); - } + this.populateFormValues({ + report_template: null, + name: '', + description: '' + }); } } @@ -229,41 +57,24 @@ export default class CUD extends Component { state.setIn(['name', 'error'], null); } - if (!state.getIn(['mime_type', 'value'])) { - state.setIn(['mime_type', 'error'], t('MIME Type must be selected')); + if (!state.getIn(['report_template', 'value'])) { + state.setIn(['report_template', 'error'], t('Report template 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')); - } + state.setIn(['report_template', 'error'], null); } } - 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) { + async submitHandler() { 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}` + url = `/rest/reports/${this.state.entityId}` } else { sendMethod = FormSendMethod.POST; - url = '/rest/report-templates' + url = '/rest/reports' } this.disableForm(); @@ -274,12 +85,7 @@ export default class CUD extends Component { }); if (submitSuccessful) { - if (stay) { - this.enableForm(); - this.setFormStatusMessage('success', t('Report template saved')); - } else { - this.navigateToWithFlashMessage('/report-templates', 'success', t('Report template saved')); - } + this.navigateToWithFlashMessage('/reports', 'success', t('Report saved')); } else { this.enableForm(); this.setFormStatusMessage('warning', t('There are errors in the form. Please fix them and submit again.')); @@ -287,11 +93,11 @@ export default class CUD extends Component { } async showDeleteModal() { - this.navigateTo(`/report-templates/edit/${this.state.entityId}/delete`); + this.navigateTo(`/reports/edit/${this.state.entityId}/delete`); } async hideDeleteModal() { - this.navigateTo(`/report-templates/edit/${this.state.entityId}`); + this.navigateTo(`/reports/edit/${this.state.entityId}`); } async performDelete() { @@ -300,17 +106,23 @@ export default class CUD extends Component { await this.hideDeleteModal(); this.disableForm(); - this.setFormStatusMessage('info', t('Deleting report template...')); + this.setFormStatusMessage('info', t('Deleting report...')); - await axios.delete(`/rest/report-templates/${this.state.entityId}`); + await axios.delete(`/rest/reports/${this.state.entityId}`); - this.navigateToWithFlashMessage('/report-templates', 'success', t('Report template deleted')); + this.navigateToWithFlashMessage('/reports', 'success', t('Report deleted')); } render() { const t = this.props.t; const edit = this.props.edit; - const userId = this.getFormValue('id'); + + 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 (
@@ -319,31 +131,22 @@ export default class CUD extends Component { { 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')})} + {t('Are you sure you want to delete "{{name}}"?', {name: this.getFormValue('name')})} } - {edit ? t('Edit Report Template') : t('Create Report Template')} + {edit ? t('Edit Report') : t('Create Report')} -
+