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:
parent
be7da791db
commit
d4cea46f07
29 changed files with 807 additions and 688 deletions
21
app.js
21
app.js
|
@ -40,15 +40,16 @@ const editorapi = require('./routes/editorapi');
|
|||
const grapejs = require('./routes/grapejs');
|
||||
const mosaico = require('./routes/mosaico');
|
||||
const reports = require('./routes/reports');
|
||||
const reportsTemplates = require('./routes/report-templates');
|
||||
|
||||
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 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 interoperableErrors = require('./shared/interoperable-errors');
|
||||
|
||||
|
@ -212,6 +213,14 @@ app.use((req, res, next) => {
|
|||
});
|
||||
});
|
||||
|
||||
app.use((req, res, next) => {
|
||||
req.context = {
|
||||
user: req.user
|
||||
};
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
app.use('/', routes);
|
||||
app.use('/lists', lists);
|
||||
app.use('/templates', templates);
|
||||
|
@ -235,6 +244,11 @@ app.use('/mosaico', mosaico);
|
|||
app.use('/users', usersLegacyIntegration);
|
||||
app.use('/namespaces', namespacesLegacyIntegration);
|
||||
app.use('/account', accountLegacyIntegration);
|
||||
|
||||
if (config.reports && config.reports.enabled === true) {
|
||||
app.use('/report-templates', reportTemplatesLegacyIntegration);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------- */
|
||||
|
||||
|
||||
|
@ -247,11 +261,12 @@ app.use('/rest', namespaces);
|
|||
app.use('/rest', users);
|
||||
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('/report-templates', reportsTemplates);
|
||||
}
|
||||
|
||||
// catch 404 and forward to error handler
|
||||
|
|
|
@ -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",
|
||||
|
|
28
client/src/lib/bootstrap-components.js
vendored
28
client/src/lib/bootstrap-components.js
vendored
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -21,4 +21,8 @@
|
|||
|
||||
.form-horizontal .control-label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ace_editor {
|
||||
border: 1px solid #ccc;
|
||||
}
|
|
@ -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
|
||||
};
|
351
client/src/report-templates/CUD.js
Normal file
351
client/src/report-templates/CUD.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
44
client/src/report-templates/List.js
Normal file
44
client/src/report-templates/List.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
49
client/src/report-templates/root.js
Normal file
49
client/src/report-templates/root.js
Normal 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')
|
||||
);
|
||||
};
|
||||
|
||||
|
|
@ -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>
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -188,15 +188,16 @@ logger=false
|
|||
browser="phantomjs"
|
||||
|
||||
|
||||
[shares.list.master]
|
||||
[roles.list.master]
|
||||
name="Master"
|
||||
description="All permissions"
|
||||
permissions=["view"]
|
||||
|
||||
[shares.namespace.master]
|
||||
[roles.namespace.master]
|
||||
name="Master"
|
||||
description="All permissions"
|
||||
permissions=["view", "edit", "create", "delete", "create list"]
|
||||
|
||||
[shares.namespace.master.permissions]
|
||||
[roles.namespace.master.childperms]
|
||||
list=["view"]
|
||||
namespace=["view", "edit", "create", "delete", "create list"]
|
||||
|
|
|
@ -3,11 +3,13 @@
|
|||
const passport = require('./passport');
|
||||
const config = require('config');
|
||||
|
||||
function _getConfig() {
|
||||
function _getConfig(context) {
|
||||
return {
|
||||
authMethod: passport.authMethod,
|
||||
isAuthMethodLocal: passport.isAuthMethodLocal,
|
||||
externalPasswordResetLink: config.ldap.passwordresetlink
|
||||
externalPasswordResetLink: config.ldap.passwordresetlink,
|
||||
language: config.language || 'en',
|
||||
userId: context.user ? context.user.id : undefined
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -17,7 +19,7 @@ function registerRootRoute(router, title, entryPoint) {
|
|||
title,
|
||||
reactEntryPoint: entryPoint,
|
||||
reactCsrfToken: req.csrfToken(),
|
||||
mailtrainConfig: JSON.stringify(_getConfig())
|
||||
mailtrainConfig: JSON.stringify(_getConfig(req.context))
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,161 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
const db = require('../db');
|
||||
const tableHelpers = require('../table-helpers');
|
||||
const tools = require('../tools');
|
||||
const _ = require('../translate')._;
|
||||
|
||||
const allowedKeys = ['name', 'description', 'mime_type', 'user_fields', 'js', 'hbs'];
|
||||
|
||||
module.exports.list = (start, limit, callback) => {
|
||||
tableHelpers.list('report_templates', ['*'], 'name', null, start, limit, callback);
|
||||
};
|
||||
|
||||
module.exports.quicklist = callback => {
|
||||
tableHelpers.quicklist('report_templates', ['id', 'name'], 'name', callback);
|
||||
};
|
||||
|
||||
module.exports.filter = (request, callback) => {
|
||||
tableHelpers.filter('report_templates', ['*'], request, ['#', 'name', 'description', 'created'], ['name'], 'created DESC', null, callback);
|
||||
};
|
||||
|
||||
module.exports.get = (id, callback) => {
|
||||
id = Number(id) || 0;
|
||||
|
||||
if (id < 1) {
|
||||
return callback(new Error(_('Missing report template ID')));
|
||||
}
|
||||
|
||||
db.getConnection((err, connection) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
connection.query('SELECT * FROM report_templates WHERE id=?', [id], (err, rows) => {
|
||||
connection.release();
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
if (!rows || !rows.length) {
|
||||
return callback(null, false);
|
||||
}
|
||||
|
||||
const template = tools.convertKeys(rows[0]);
|
||||
|
||||
const userFields = template.userFields.trim();
|
||||
if (userFields !== '') {
|
||||
try {
|
||||
template.userFieldsObject = JSON.parse(userFields);
|
||||
} catch (err) {
|
||||
// This is to handle situation when for some reason we get corrupted JSON in the DB.
|
||||
template.userFieldsObject = {};
|
||||
template.userFields = '{}';
|
||||
}
|
||||
} else {
|
||||
template.userFieldsObject = {};
|
||||
}
|
||||
|
||||
return callback(null, template);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
module.exports.createOrUpdate = (createMode, data, callback) => {
|
||||
data = data || {};
|
||||
|
||||
const id = 'id' in data ? Number(data.id) : 0;
|
||||
|
||||
if (!createMode && id < 1) {
|
||||
return callback(new Error(_('Missing report template ID')));
|
||||
}
|
||||
|
||||
const template = tools.convertKeys(data);
|
||||
const name = (template.name || '').toString().trim();
|
||||
|
||||
if (!name) {
|
||||
return callback(new Error(_('Report template name must be set')));
|
||||
}
|
||||
|
||||
const keys = ['name'];
|
||||
const values = [name];
|
||||
|
||||
Object.keys(template).forEach(key => {
|
||||
let value = typeof template[key] === 'number' ? template[key] : (template[key] || '').toString().trim();
|
||||
key = tools.toDbKey(key);
|
||||
|
||||
if (key === 'description') {
|
||||
value = tools.purifyHTML(value);
|
||||
}
|
||||
|
||||
if (key === 'user_fields') {
|
||||
value = value.trim();
|
||||
|
||||
if (value !== '') {
|
||||
try {
|
||||
JSON.parse(value);
|
||||
} catch (err) {
|
||||
return callback(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allowedKeys.indexOf(key) >= 0 && keys.indexOf(key) < 0) {
|
||||
keys.push(key);
|
||||
values.push(value);
|
||||
}
|
||||
});
|
||||
|
||||
db.getConnection((err, connection) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
let query;
|
||||
|
||||
if (createMode) {
|
||||
query = 'INSERT INTO report_templates (`' + keys.join('`, `') + '`) VALUES (' + values.map(() => '?').join(',') + ')';
|
||||
} else {
|
||||
query = 'UPDATE report_templates SET ' + keys.map(key => '`' + key + '`=?').join(', ') + ' WHERE id=? LIMIT 1';
|
||||
values.push(id);
|
||||
}
|
||||
|
||||
connection.query(query, values, (err, result) => {
|
||||
connection.release();
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
if (createMode) {
|
||||
return callback(null, result && result.insertId || false);
|
||||
} else {
|
||||
return callback(null, result && result.affectedRows || false);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
module.exports.delete = (id, callback) => {
|
||||
id = Number(id) || 0;
|
||||
|
||||
if (id < 1) {
|
||||
return callback(new Error(_('Missing report template ID')));
|
||||
}
|
||||
|
||||
db.getConnection((err, connection) => {
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
connection.query('DELETE FROM report_templates WHERE id=? LIMIT 1', [id], (err, result) => {
|
||||
connection.release();
|
||||
if (err) {
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
const affected = result && result.affectedRows || 0;
|
||||
return callback(err, affected);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
|
@ -126,7 +126,7 @@ if (config.ldap.enabled && LdapStrategy) {
|
|||
}
|
||||
})));
|
||||
|
||||
passport.serializeUser((user, done) => { /* FIXME */ console.log(user); done(null, user); });
|
||||
passport.serializeUser((user, done) => done(null, user));
|
||||
passport.deserializeUser((user, done) => done(null, user));
|
||||
|
||||
} else {
|
||||
|
|
50
lib/permissions.js
Normal file
50
lib/permissions.js
Normal file
|
@ -0,0 +1,50 @@
|
|||
'use strict';
|
||||
|
||||
const config = require('config');
|
||||
|
||||
class ListPermission {
|
||||
constructor(name) {
|
||||
this.name = name;
|
||||
this.entityType = 'list';
|
||||
}
|
||||
}
|
||||
|
||||
class NamespacePermission {
|
||||
constructor(name) {
|
||||
this.name = name;
|
||||
this.entityType = 'namespace';
|
||||
}
|
||||
}
|
||||
|
||||
const ListPermissions = {
|
||||
View: new ListPermissions('view')
|
||||
};
|
||||
|
||||
const NamespacePermissions = {
|
||||
View: new NamespacePermission('view'),
|
||||
Edit: new NamespacePermission('edit'),
|
||||
Create: new NamespacePermission('create'),
|
||||
Delete: new NamespacePermission('delete'),
|
||||
CreateList: new NamespacePermission('create list')
|
||||
};
|
||||
|
||||
async function can(context, operation, entityId) {
|
||||
if (!context.user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = await knex('permissions_' + operation.entityType).select(['id']).where({
|
||||
entity: entityId,
|
||||
user: context.user.id,
|
||||
operation: operation.name
|
||||
}).first();
|
||||
|
||||
return !!result;
|
||||
}
|
||||
|
||||
async function buildPermissions() {
|
||||
|
||||
}
|
||||
|
||||
can(ctx, ListPermissions.View, 3)
|
||||
can(ctx, NamespacePermissions.CreateList, 2)
|
60
models/report-templates.js
Normal file
60
models/report-templates.js
Normal file
|
@ -0,0 +1,60 @@
|
|||
'use strict';
|
||||
|
||||
const knex = require('../lib/knex');
|
||||
const hasher = require('node-object-hash')();
|
||||
const { enforce, filterObject } = require('../lib/helpers');
|
||||
const dtHelpers = require('../lib/dt-helpers');
|
||||
const interoperableErrors = require('../shared/interoperable-errors');
|
||||
|
||||
const allowedKeys = new Set(['name', 'description', 'mime_type', 'user_fields', 'js', 'hbs']);
|
||||
|
||||
function hash(ns) {
|
||||
return hasher.hash(filterObject(ns, allowedKeys));
|
||||
}
|
||||
|
||||
async function getById(templateId) {
|
||||
const template = await knex('report_templates').where('id', templateId).first();
|
||||
if (!template) {
|
||||
throw new interoperableErrors.NotFoundError();
|
||||
}
|
||||
|
||||
return template;
|
||||
}
|
||||
|
||||
async function listDTAjax(params) {
|
||||
return await dtHelpers.ajaxList(params, tx => tx('report_templates'), ['report_templates.id', 'report_templates.name', 'report_templates.description', 'report_templates.created']);
|
||||
}
|
||||
|
||||
async function create(template) {
|
||||
const templateId = await knex('report_templates').insert(filterObject(template, allowedKeys));
|
||||
return templateId;
|
||||
}
|
||||
|
||||
async function updateWithConsistencyCheck(template) {
|
||||
await knex.transaction(async tx => {
|
||||
const existingTemplate = await tx('report_templates').where('id', template.id).first();
|
||||
if (!template) {
|
||||
throw new interoperableErrors.NotFoundError();
|
||||
}
|
||||
|
||||
const existingNsHash = hash(existingTemplate);
|
||||
if (existingNsHash != template.originalHash) {
|
||||
throw new interoperableErrors.ChangedError();
|
||||
}
|
||||
|
||||
await tx('report_templates').where('id', template.id).update(filterObject(template, allowedKeys));
|
||||
});
|
||||
}
|
||||
|
||||
async function remove(templateId) {
|
||||
await knex('report_templates').where('id', templateId).del();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
hash,
|
||||
getById,
|
||||
listDTAjax,
|
||||
create,
|
||||
updateWithConsistencyCheck,
|
||||
remove
|
||||
};
|
|
@ -187,11 +187,11 @@ async function updateWithConsistencyCheck(user, isOwnAccount) {
|
|||
});
|
||||
}
|
||||
|
||||
async function remove(userId) {
|
||||
async function remove(context, userId) {
|
||||
enforce(passport.isAuthMethodLocal, 'Local user management is required');
|
||||
|
||||
// FIXME: enforce that userId is not the current user
|
||||
enforce(userId !== 1, 'Admin cannot be deleted');
|
||||
enforce(context.user.id !== userId, 'User cannot delete himself/herself');
|
||||
|
||||
await knex('users').where('id', userId).del();
|
||||
}
|
||||
|
||||
|
@ -255,7 +255,7 @@ async function sendPasswordReset(usernameOrEmail) {
|
|||
|
||||
const { serviceUrl, adminEmail } = await settings.get(['serviceUrl', 'adminEmail']);
|
||||
|
||||
await mailer.sendMail({
|
||||
await mailerSendMail({
|
||||
from: {
|
||||
address: adminEmail
|
||||
},
|
||||
|
|
10
routes/report-templates-legacy-integration.js
Normal file
10
routes/report-templates-legacy-integration.js
Normal file
|
@ -0,0 +1,10 @@
|
|||
'use strict';
|
||||
|
||||
const _ = require('../lib/translate')._;
|
||||
const clientHelpers = require('../lib/client-helpers');
|
||||
|
||||
const router = require('../lib/router-async').create();
|
||||
|
||||
clientHelpers.registerRootRoute(router, _('Report Templates'), 'reportTemplates');
|
||||
|
||||
module.exports = router;
|
|
@ -1,307 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const passport = require('../lib/passport');
|
||||
const router = new express.Router();
|
||||
const _ = require('../lib/translate')._;
|
||||
const reportTemplates = require('../lib/models/report-templates');
|
||||
const tools = require('../lib/tools');
|
||||
const util = require('util');
|
||||
const htmlescape = require('escape-html');
|
||||
const striptags = require('striptags');
|
||||
|
||||
const allowedMimeTypes = {
|
||||
'text/html': 'HTML',
|
||||
'text/csv': 'CSV'
|
||||
};
|
||||
|
||||
router.all('/*', (req, res, next) => {
|
||||
if (!req.user) {
|
||||
req.flash('danger', _('Need to be logged in to access restricted content'));
|
||||
return res.redirect('/account/login?next=' + encodeURIComponent(req.originalUrl));
|
||||
}
|
||||
res.setSelectedMenu('reports');
|
||||
next();
|
||||
});
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
res.render('report-templates/report-templates', {
|
||||
title: _('Report Templates')
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/ajax', (req, res) => {
|
||||
reportTemplates.filter(req.body, (err, data, total, filteredTotal) => {
|
||||
if (err) {
|
||||
return res.json({
|
||||
error: err.message || err,
|
||||
data: []
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
draw: req.body.draw,
|
||||
recordsTotal: total,
|
||||
recordsFiltered: filteredTotal,
|
||||
data: data.map((row, i) => [
|
||||
(Number(req.body.start) || 0) + 1 + i,
|
||||
htmlescape(row.name || ''),
|
||||
htmlescape(striptags(row.description) || ''),
|
||||
'<span class="datestring" data-date="' + row.created.toISOString() + '" title="' + row.created.toISOString() + '">' + row.created.toISOString() + '</span>',
|
||||
'<span class="glyphicon glyphicon-wrench" aria-hidden="true"></span><a href="/report-templates/edit/' + row.id + '"> ' + _('Edit') + '</a>']
|
||||
)
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/create', passport.csrfProtection, (req, res) => {
|
||||
const data = req.query;
|
||||
const wizard = req.query['type'] || '';
|
||||
|
||||
if (wizard == 'subscribers-all') {
|
||||
if (!('description' in data)) data.description = 'Generates a campaign report listing all subscribers along with their statistics.';
|
||||
|
||||
if (!('mimeType' in data)) data.mimeType = 'text/html';
|
||||
|
||||
if (!('userFields' in data)) data.userFields =
|
||||
'[\n' +
|
||||
' {\n' +
|
||||
' "id": "campaign",\n' +
|
||||
' "name": "Campaign",\n' +
|
||||
' "type": "campaign",\n' +
|
||||
' "minOccurences": 1,\n' +
|
||||
' "maxOccurences": 1\n' +
|
||||
' }\n' +
|
||||
']';
|
||||
|
||||
if (!('js' in data)) data.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' +
|
||||
'});';
|
||||
|
||||
if (!('hbs' in data)) data.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') {
|
||||
if (!('description' in data)) data.description = 'Generates a campaign report with results are aggregated by some "Country" custom field.';
|
||||
|
||||
if (!('mimeType' in data)) data.mimeType = 'text/html';
|
||||
|
||||
if (!('userFields' in data)) data.userFields =
|
||||
'[\n' +
|
||||
' {\n' +
|
||||
' "id": "campaign",\n' +
|
||||
' "name": "Campaign",\n' +
|
||||
' "type": "campaign",\n' +
|
||||
' "minOccurences": 1,\n' +
|
||||
' "maxOccurences": 1\n' +
|
||||
' }\n' +
|
||||
']';
|
||||
|
||||
if (!('js' in data)) data.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' +
|
||||
'});';
|
||||
|
||||
if (!('hbs' in data)) data.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') {
|
||||
if (!('description' in data)) data.description = 'Exports a list as a CSV file.';
|
||||
|
||||
if (!('mimeType' in data)) data.mimeType = 'text/csv';
|
||||
|
||||
if (!('userFields' in data)) data.userFields =
|
||||
'[\n' +
|
||||
' {\n' +
|
||||
' "id": "list",\n' +
|
||||
' "name": "List",\n' +
|
||||
' "type": "list",\n' +
|
||||
' "minOccurences": 1,\n' +
|
||||
' "maxOccurences": 1\n' +
|
||||
' }\n' +
|
||||
']';
|
||||
|
||||
if (!('js' in data)) data.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' +
|
||||
'});';
|
||||
|
||||
if (!('hbs' in data)) data.hbs =
|
||||
'{{#each results}}\n' +
|
||||
'{{firstName}},{{lastName}},{{email}}\n' +
|
||||
'{{/each}}';
|
||||
}
|
||||
|
||||
data.csrfToken = req.csrfToken();
|
||||
data.title = _('Create Report Template');
|
||||
data.useEditor = true;
|
||||
|
||||
data.mimeTypes = Object.keys(allowedMimeTypes).map(key => ({
|
||||
key: key,
|
||||
value: allowedMimeTypes[key],
|
||||
selected: data.mimeType == key
|
||||
}));
|
||||
|
||||
res.render('report-templates/create', data);
|
||||
});
|
||||
|
||||
router.post('/create', passport.parseForm, passport.csrfProtection, (req, res) => {
|
||||
reportTemplates.createOrUpdate(true, req.body, (err, id) => {
|
||||
if (err || !id) {
|
||||
req.flash('danger', err && err.message || err || _('Could not create report template'));
|
||||
return res.redirect('/report-templates/create?' + tools.queryParams(req.body));
|
||||
}
|
||||
req.flash('success', util.format(_('Report template “%s” created'), req.body.name));
|
||||
res.redirect('/report-templates');
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/edit/:id', passport.csrfProtection, (req, res) => {
|
||||
reportTemplates.get(req.params.id, (err, template) => {
|
||||
if (err || !template) {
|
||||
req.flash('danger', err && err.message || err || _('Could not find report template with specified ID'));
|
||||
return res.redirect('/report-templates');
|
||||
}
|
||||
|
||||
template.csrfToken = req.csrfToken();
|
||||
template.title = _('Edit Report Template');
|
||||
template.useEditor = true;
|
||||
|
||||
template.mimeTypes = Object.keys(allowedMimeTypes).map(key => ({
|
||||
key: key,
|
||||
value: allowedMimeTypes[key],
|
||||
selected: template.mimeType == key
|
||||
}));
|
||||
|
||||
res.render('report-templates/edit', template);
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/edit', passport.parseForm, passport.csrfProtection, (req, res) => {
|
||||
reportTemplates.createOrUpdate(false, req.body, (err, updated) => {
|
||||
if (err) {
|
||||
req.flash('danger', err.message || err);
|
||||
} else if (updated) {
|
||||
req.flash('success', _('Report template updated'));
|
||||
} else {
|
||||
req.flash('info', _('Report template not updated'));
|
||||
}
|
||||
|
||||
if (req.body['submit'] == 'update-and-stay') {
|
||||
return res.redirect('/report-templates/edit/' + req.body.id);
|
||||
} else {
|
||||
return res.redirect('/report-templates');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/delete', passport.parseForm, passport.csrfProtection, (req, res) => {
|
||||
reportTemplates.delete(req.body.id, (err, deleted) => {
|
||||
if (err) {
|
||||
req.flash('danger', err && err.message || err);
|
||||
} else if (deleted) {
|
||||
req.flash('success', _('Report template deleted'));
|
||||
} else {
|
||||
req.flash('info', _('Could not delete specified report template'));
|
||||
}
|
||||
|
||||
return res.redirect('/report-templates');
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
39
routes/rest/report-templates.js
Normal file
39
routes/rest/report-templates.js
Normal file
|
@ -0,0 +1,39 @@
|
|||
'use strict';
|
||||
|
||||
const passport = require('../../lib/passport');
|
||||
const _ = require('../../lib/translate')._;
|
||||
const reportTemplates = require('../../models/report-templates');
|
||||
|
||||
const router = require('../../lib/router-async').create();
|
||||
|
||||
|
||||
router.getAsync('/report-templates/:reportTemplateId', passport.loggedIn, async (req, res) => {
|
||||
const reportTemplate = await reportTemplates.getById(req.params.reportTemplateId);
|
||||
reportTemplate.hash = reportTemplates.hash(reportTemplate);
|
||||
return res.json(reportTemplate);
|
||||
});
|
||||
|
||||
router.postAsync('/report-templates', passport.loggedIn, passport.csrfProtection, async (req, res) => {
|
||||
await reportTemplates.create(req.body);
|
||||
return res.json();
|
||||
});
|
||||
|
||||
router.putAsync('/report-templates/:reportTemplateId', passport.loggedIn, passport.csrfProtection, async (req, res) => {
|
||||
const reportTemplate = req.body;
|
||||
reportTemplate.id = parseInt(req.params.reportTemplateId);
|
||||
|
||||
await reportTemplates.updateWithConsistencyCheck(reportTemplate);
|
||||
return res.json();
|
||||
});
|
||||
|
||||
router.deleteAsync('/report-templates/:reportTemplateId', passport.loggedIn, passport.csrfProtection, async (req, res) => {
|
||||
await reportTemplates.remove(req.context, req.params.reportTemplateId);
|
||||
return res.json();
|
||||
});
|
||||
|
||||
router.postAsync('/report-templates-table', passport.loggedIn, async (req, res) => {
|
||||
return res.json(await reportTemplates.listDTAjax(req.body));
|
||||
});
|
||||
|
||||
|
||||
module.exports = router;
|
|
@ -27,7 +27,7 @@ router.putAsync('/users/:userId', passport.loggedIn, passport.csrfProtection, as
|
|||
});
|
||||
|
||||
router.deleteAsync('/users/:userId', passport.loggedIn, passport.csrfProtection, async (req, res) => {
|
||||
await users.remove(req.params.userId);
|
||||
await users.remove(req.context, req.params.userId);
|
||||
return res.json();
|
||||
});
|
||||
|
||||
|
|
|
@ -3,41 +3,41 @@ const config = require('../config');
|
|||
exports.up = function(knex, Promise) {
|
||||
return knex.schema.createTable('shares_list', table => {
|
||||
table.increments('id').primary();
|
||||
table.integer('list').unsigned().notNullable().references('lists.id').onDelete('CASCADE');
|
||||
table.integer('entity').unsigned().notNullable().references('lists.id').onDelete('CASCADE');
|
||||
table.integer('user').unsigned().notNullable().references('users.id').onDelete('CASCADE');
|
||||
table.integer('level').notNullable();
|
||||
table.unique(['list', 'user']);
|
||||
table.integer('role', 64).notNullable();
|
||||
table.unique(['entity', 'user']);
|
||||
})
|
||||
|
||||
.createTable('shares_namespace', table => {
|
||||
table.increments('id').primary();
|
||||
table.integer('namespace').unsigned().notNullable().references('namespaces.id').onDelete('CASCADE');
|
||||
table.integer('entity').unsigned().notNullable().references('namespaces.id').onDelete('CASCADE');
|
||||
table.integer('user').unsigned().notNullable().references('users.id').onDelete('CASCADE');
|
||||
table.string('level', 64).notNullable();
|
||||
table.unique(['namespace', 'user']);
|
||||
table.string('role', 64).notNullable();
|
||||
table.unique(['entity', 'user']);
|
||||
})
|
||||
|
||||
.createTable('permissions_list', table => {
|
||||
table.increments('id').primary();
|
||||
table.integer('list').unsigned().notNullable().references('lists.id').onDelete('CASCADE');
|
||||
table.integer('entity').unsigned().notNullable().references('lists.id').onDelete('CASCADE');
|
||||
table.integer('user').unsigned().notNullable().references('users.id').onDelete('CASCADE');
|
||||
table.string('permission', 64).notNullable();
|
||||
table.unique(['list', 'user', 'permission']);
|
||||
table.string('operation', 64).notNullable();
|
||||
table.unique(['entity', 'user', 'operation']);
|
||||
})
|
||||
|
||||
.createTable('permissions_namespace', table => {
|
||||
table.increments('id').primary();
|
||||
table.integer('namespace').unsigned().notNullable().references('lists.id').onDelete('CASCADE');
|
||||
table.integer('entity').unsigned().notNullable().references('namespaces.id').onDelete('CASCADE');
|
||||
table.integer('user').unsigned().notNullable().references('users.id').onDelete('CASCADE');
|
||||
table.string('permission', 64).notNullable();
|
||||
table.unique(['namespace', 'user', 'permission']);
|
||||
table.string('operation', 64).notNullable();
|
||||
table.unique(['entity', 'user', 'operation']);
|
||||
})
|
||||
|
||||
.then(() => knex('shares_namespace').insert({
|
||||
id: 1,
|
||||
namespace: 1,
|
||||
entity: 1,
|
||||
user: 1,
|
||||
level: 'master'
|
||||
role: 'master'
|
||||
}))
|
||||
|
||||
;
|
||||
|
|
|
@ -1,24 +0,0 @@
|
|||
<ol class="breadcrumb">
|
||||
<li><a href="/">{{#translate}}Home{{/translate}}</a></li>
|
||||
<li><a href="/reports">{{#translate}}Reports{{/translate}}</a></li>
|
||||
<li><a href="/report-templates">{{#translate}}Templates{{/translate}}</a></li>
|
||||
<li class="active">{{#translate}}Create Template{{/translate}}</li>
|
||||
</ol>
|
||||
|
||||
<h2>{{#translate}}Create Report Template{{/translate}}</h2>
|
||||
|
||||
<hr>
|
||||
|
||||
<form class="form-horizontal" method="post" action="/report-templates/create">
|
||||
<input type="hidden" name="_csrf" value="{{csrfToken}}">
|
||||
|
||||
{{> report_template_fields }}
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col-sm-offset-2 col-sm-10">
|
||||
<button type="submit" class="btn btn-primary"><i class="glyphicon glyphicon-plus"></i> {{#translate}}Create Template{{/translate}}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
|
@ -1,36 +0,0 @@
|
|||
<ol class="breadcrumb">
|
||||
<li><a href="/">{{#translate}}Home{{/translate}}</a></li>
|
||||
<li><a href="/reports">{{#translate}}Reports{{/translate}}</a></li>
|
||||
<li><a href="/report-templates">{{#translate}}Templates{{/translate}}</a></li>
|
||||
<li class="active">{{#translate}}Edit Template{{/translate}}</li>
|
||||
</ol>
|
||||
|
||||
<h2>{{#translate}}Edit Report Template{{/translate}}</h2>
|
||||
|
||||
<hr>
|
||||
|
||||
<form method="post" class="delete-form" id="report-templates-delete" action="/report-templates/delete">
|
||||
<input type="hidden" name="_csrf" value="{{csrfToken}}">
|
||||
<input type="hidden" name="id" value="{{id}}" />
|
||||
</form>
|
||||
|
||||
|
||||
<form class="form-horizontal" method="post" action="/report-templates/edit">
|
||||
<input type="hidden" name="_csrf" value="{{csrfToken}}">
|
||||
<input type="hidden" name="id" value="{{id}}" />
|
||||
|
||||
{{> report_template_fields }}
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col-sm-offset-2 col-sm-10">
|
||||
<div class="pull-right">
|
||||
<button type="submit" form="report-templates-delete" class="btn btn-danger"><i class="glyphicon glyphicon-remove"></i> {{#translate}}Delete Template{{/translate}}</button>
|
||||
</div>
|
||||
<button type="submit" name="submit" value="update-and-stay" class="btn btn-primary"><i class="glyphicon glyphicon-ok"></i> {{#translate}}Update and Stay{{/translate}}</button>
|
||||
<button type="submit" name="submit" value="update-and-leave" class="btn btn-primary"><i class="glyphicon glyphicon-ok"></i> {{#translate}}Update and Leave{{/translate}}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
|
@ -1,59 +0,0 @@
|
|||
<div class="form-group">
|
||||
<label for="name" class="col-sm-2 control-label">{{#translate}}Name{{/translate}}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control input-lg" name="name" id="name" value="{{name}}" placeholder="{{#translate}}Template Name{{/translate}}" autofocus required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description" class="col-sm-2 control-label">{{#translate}}Description{{/translate}}</label>
|
||||
<div class="col-sm-10">
|
||||
<textarea class="form-control" rows="3" name="description" id="description">{{description}}</textarea>
|
||||
<span class="help-block">{{#translate}}HTML is allowed{{/translate}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="mimeType" class="col-sm-2 control-label">{{#translate}}Type{{/translate}}</label>
|
||||
<div class="col-sm-10">
|
||||
<select name="mimeType" class="form-control">
|
||||
{{#each mimeTypes}}
|
||||
<option value="{{key}}" {{#if selected}} selected {{/if}}>{{value}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="col-sm-2 control-label">{{#translate}}User selectable fields{{/translate}}</label>
|
||||
<div class="col-sm-offset-2 col-sm-10">
|
||||
<div class="help-block" style="margin-top: -8px;">
|
||||
<small>JSON specification of user selectable fields.</small>
|
||||
</div>
|
||||
<div class="code-editor-json" style="height: 250px; border: 1px solid #ccc;"></div>
|
||||
<input type="hidden" name="userFields" value="{{userFields}}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="col-sm-2 control-label">{{#translate}}Data processing code{{/translate}}</label>
|
||||
<div class="col-sm-offset-2 col-sm-10">
|
||||
<div class="help-block" style="margin-top: -8px;">
|
||||
<small>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.</small>
|
||||
</div>
|
||||
<div class="code-editor-javascript" style="height: 700px; border: 1px solid #ccc;"></div>
|
||||
<input type="hidden" name="js" value="{{js}}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="col-sm-2 control-label">{{#translate}}Rendering template{{/translate}}</label>
|
||||
<div class="col-sm-offset-2 col-sm-10">
|
||||
<div class="help-block" style="margin-top: -8px;">
|
||||
<small>Use HTML with Handlebars syntax. See documentation <a href="http://handlebarsjs.com/">here</a>.</small>
|
||||
</div>
|
||||
<div class="code-editor-handlebars" style="height: 700px; border: 1px solid #ccc;"></div>
|
||||
<input type="hidden" name="hbs" value="{{hbs}}">
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
<ol class="breadcrumb">
|
||||
<li><a href="/">{{#translate}}Home{{/translate}}</a></li>
|
||||
<li><a href="/reports">{{#translate}}Reports{{/translate}}</a></li>
|
||||
<li class="active">{{#translate}}Templates{{/translate}}</li>
|
||||
</ol>
|
||||
|
||||
<div class="pull-right">
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-primary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
{{#translate}}Create Template{{/translate}} <span class="caret"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a href="/report-templates/create">{{#translate}}Blank{{/translate}}</a></li>
|
||||
<li><a href="/report-templates/create?type=subscribers-all">{{#translate}}All Subscribers{{/translate}}</a></li>
|
||||
<li><a href="/report-templates/create?type=subscribers-grouped">{{#translate}}Grouped Subscribers{{/translate}}</a></li>
|
||||
<li><a href="/report-templates/create?type=export-list-csv">{{#translate}}Export List as CSV{{/translate}}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>{{#translate}}Report Templates{{/translate}}</h2>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table data-topic-url="/report-templates" data-sort-column="2" data-sort-order="desc" class="table table-bordered table-hover data-table-ajax display nowrap" width="100%" data-row-sort="0,1,0,1,0">
|
||||
<thead>
|
||||
<th class="col-md-1">
|
||||
#
|
||||
</th>
|
||||
<th>
|
||||
{{#translate}}Name{{/translate}}
|
||||
</th>
|
||||
<th>
|
||||
{{#translate}}Description{{/translate}}
|
||||
</th>
|
||||
<th>
|
||||
{{#translate}}Created{{/translate}}
|
||||
</th>
|
||||
<th class="col-md-1">
|
||||
|
||||
</th>
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
Loading…
Reference in a new issue