-
- {this.props.children}
+ if (this.props.label) {
+ return (
+
+
+
+ {this.props.children}
+
-
- );
+ );
+
+ } else {
+ return (
+
+
+ {this.props.children}
+
+
+ );
+ }
}
}
@@ -499,7 +525,7 @@ TableSelect.prototype.refresh = function() {
class ACEEditor extends Component {
static propTypes = {
id: PropTypes.string.isRequired,
- label: PropTypes.string.isRequired,
+ label: PropTypes.string,
help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
height: PropTypes.string,
mode: PropTypes.string
@@ -882,6 +908,355 @@ function withForm(target) {
return target;
}
+function withForm(target) {
+ const inst = target.prototype;
+
+ const cleanFormState = Immutable.Map({
+ state: FormState.Loading,
+ isValidationShown: false,
+ isDisabled: false,
+ statusMessageText: '',
+ data: Immutable.Map(),
+ isServerValidationRunning: false
+ });
+
+ // formValidateResolve is called by "validateForm" once client receives validation response from server that does not
+ // trigger another server validation
+ let formValidateResolve = null;
+
+ function scheduleValidateForm(self) {
+ setTimeout(() => {
+ self.setState(previousState => ({
+ formState: previousState.formState.withMutations(mutState => {
+ validateFormState(self, mutState);
+ })
+ }));
+ }, 0);
+ }
+
+ function validateFormState(self, mutState) {
+ const settings = self.state.formSettings;
+
+ if (!mutState.get('isServerValidationRunning') && settings.serverValidation) {
+ const payload = {};
+ let payloadNotEmpty = false;
+
+ for (const attr of settings.serverValidation.extra || []) {
+ payload[attr] = mutState.getIn(['data', attr, 'value']);
+ }
+
+ for (const attr of settings.serverValidation.changed) {
+ const currValue = mutState.getIn(['data', attr, 'value']);
+ const serverValue = mutState.getIn(['data', attr, 'serverValue']);
+
+ // This really assumes that all form values are preinitialized (i.e. not undef)
+ if (currValue !== serverValue) {
+ mutState.setIn(['data', attr, 'serverValidated'], false);
+ payload[attr] = currValue;
+ payloadNotEmpty = true;
+ }
+ }
+
+ if (payloadNotEmpty) {
+ mutState.set('isServerValidationRunning', true);
+
+ axios.post(settings.serverValidation.url, payload)
+ .then(response => {
+
+ self.setState(previousState => ({
+ formState: previousState.formState.withMutations(mutState => {
+ mutState.set('isServerValidationRunning', false);
+
+ mutState.update('data', stateData => stateData.withMutations(mutStateData => {
+ for (const attr in payload) {
+ mutStateData.setIn([attr, 'serverValue'], payload[attr]);
+
+ if (payload[attr] === mutState.getIn(['data', attr, 'value'])) {
+ mutStateData.setIn([attr, 'serverValidated'], true);
+ mutStateData.setIn([attr, 'serverValidation'], response.data[attr] || true);
+ }
+ }
+ }));
+ })
+ }));
+
+ scheduleValidateForm(self);
+ })
+ .catch(error => {
+ console.log('Error in "validateFormState": ' + error);
+
+ self.setState(previousState => ({
+ formState: previousState.formState.set('isServerValidationRunning', false)
+ }));
+
+ // TODO: It might be good not to give up immediatelly, but retry a couple of times
+ // scheduleValidateForm(self);
+ });
+ } else {
+ if (formValidateResolve) {
+ const resolve = formValidateResolve;
+ formValidateResolve = null;
+ resolve();
+ }
+ }
+ }
+
+ if (self.localValidateFormValues) {
+ mutState.update('data', stateData => stateData.withMutations(mutStateData => {
+ self.localValidateFormValues(mutStateData);
+ }));
+ }
+ }
+
+ inst.initForm = function(settings) {
+ const state = this.state || {};
+ state.formState = cleanFormState;
+ state.formSettings = settings || {};
+ this.state = state;
+ };
+
+ inst.resetFormState = function() {
+ this.setState({
+ formState: cleanFormState
+ });
+ };
+
+ inst.getFormValuesFromURL = async function(url, mutator) {
+ setTimeout(() => {
+ this.setState(previousState => {
+ if (previousState.formState.get('state') === FormState.Loading) {
+ return {
+ formState: previousState.formState.set('state', FormState.LoadingWithNotice)
+ };
+ }
+ });
+ }, 500);
+
+ const response = await axios.get(url);
+
+ const data = response.data;
+
+ data.originalHash = data.hash;
+ delete data.hash;
+
+ if (mutator) {
+ mutator(data);
+ }
+
+ this.populateFormValues(data);
+ };
+
+ inst.validateAndSendFormValuesToURL = async function(method, url, mutator) {
+ await this.waitForFormServerValidated();
+
+ if (this.isFormWithoutErrors()) {
+ const data = this.getFormValues();
+
+ if (mutator) {
+ mutator(data);
+ }
+
+ let response;
+ if (method === FormSendMethod.PUT) {
+ response = await axios.put(url, data);
+ } else if (method === FormSendMethod.POST) {
+ response = await axios.post(url, data);
+ }
+
+ return response.data || true;
+
+ } else {
+ this.showFormValidation();
+ return false;
+ }
+ };
+
+
+ inst.populateFormValues = function(data) {
+ this.setState(previousState => ({
+ formState: previousState.formState.withMutations(mutState => {
+ mutState.set('state', FormState.Ready);
+
+ mutState.update('data', stateData => stateData.withMutations(mutStateData => {
+ for (const key in data) {
+ mutStateData.set(key, Immutable.Map({
+ value: data[key]
+ }));
+ }
+ }));
+
+ validateFormState(this, mutState);
+ })
+ }));
+ };
+
+ inst.waitForFormServerValidated = async function() {
+ if (!this.isFormServerValidated()) {
+ await new Promise(resolve => { formValidateResolve = resolve; });
+ }
+ };
+
+ inst.scheduleFormRevalidate = function() {
+ scheduleValidateForm(this);
+ };
+
+ inst.updateFormValue = function(key, value) {
+ 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) {
+ return this.state.formState.getIn(['data', name, 'value']);
+ };
+
+ inst.getFormValues = function(name) {
+ return this.state.formState.get('data').map(attr => attr.get('value')).toJS();
+ };
+
+ inst.getFormError = function(name) {
+ return this.state.formState.getIn(['data', name, 'error']);
+ };
+
+ inst.isFormWithLoadingNotice = function() {
+ return this.state.formState.get('state') === FormState.LoadingWithNotice;
+ };
+
+ inst.isFormLoading = function() {
+ return this.state.formState.get('state') === FormState.Loading || this.state.formState.get('state') === FormState.LoadingWithNotice;
+ };
+
+ inst.isFormReady = function() {
+ return this.state.formState.get('state') === FormState.Ready;
+ };
+
+ inst.isFormValidationShown = function() {
+ return this.state.formState.get('isValidationShown');
+ };
+
+ inst.addFormValidationClass = function(className, name) {
+ if (this.isFormValidationShown()) {
+ const error = this.getFormError(name);
+ if (error) {
+ return className + ' has-error';
+ } else {
+ return className + ' has-success';
+ }
+ } else {
+ return className;
+ }
+ };
+
+ inst.getFormValidationMessage = function(name) {
+ if (this.isFormValidationShown()) {
+ return this.getFormError(name);
+ } else {
+ return '';
+ }
+ };
+
+ inst.showFormValidation = function() {
+ this.setState(previousState => ({formState: previousState.formState.set('isValidationShown', true)}));
+ };
+
+ inst.hideFormValidation = function() {
+ this.setState(previousState => ({formState: previousState.formState.set('isValidationShown', false)}));
+ };
+
+ inst.isFormWithoutErrors = function() {
+ return !this.state.formState.get('data').find(attr => attr.get('error'));
+ };
+
+ inst.isFormServerValidated = function() {
+ return !this.state.formSettings.serverValidation || this.state.formSettings.serverValidation.changed.every(attr => this.state.formState.getIn(['data', attr, 'serverValidated']));
+ };
+
+ inst.getFormStatusMessageText = function() {
+ return this.state.formState.get('statusMessageText');
+ };
+
+ inst.getFormStatusMessageSeverity = function() {
+ return this.state.formState.get('statusMessageSeverity');
+ };
+
+ inst.setFormStatusMessage = function(severity, text) {
+ this.setState(previousState => ({
+ formState: previousState.formState.withMutations(map => {
+ map.set('statusMessageText', text);
+ map.set('statusMessageSeverity', severity);
+ })
+ }));
+ };
+
+ inst.clearFormStatusMessage = function() {
+ this.setState(previousState => ({
+ formState: previousState.formState.withMutations(map => {
+ map.set('statusMessageText', '');
+ })
+ }));
+ };
+
+ inst.enableForm = function() {
+ this.setState(previousState => ({formState: previousState.formState.set('isDisabled', false)}));
+ };
+
+ inst.disableForm = function() {
+ this.setState(previousState => ({formState: previousState.formState.set('isDisabled', true)}));
+ };
+
+ inst.isFormDisabled = function() {
+ return this.state.formState.get('isDisabled');
+ };
+
+ inst.formHandleChangedError = async function(fn) {
+ const t = this.props.t;
+ try {
+ await fn();
+ } catch (error) {
+ if (error instanceof interoperableErrors.ChangedError) {
+ this.disableForm();
+ this.setFormStatusMessage('danger',
+
+ {t('Your updates cannot be saved.')}{' '}
+ {t('Someone else has introduced modification in the meantime. Refresh your page to start anew with fresh data. Please note that your changes will be lost.')}
+
+ );
+ return;
+ }
+
+ if (error instanceof interoperableErrors.NotFoundError) {
+ this.disableForm();
+ this.setFormStatusMessage('danger',
+
+ {t('Your updates cannot be saved.')}{' '}
+ {t('It seems that someone else has deleted the entity in the meantime.')}
+
+ );
+ return;
+ }
+
+ throw error;
+ }
+ };
+
+ return target;
+}
+
export {
withForm,
diff --git a/client/src/lists/CUD.js b/client/src/lists/CUD.js
index 6579145d..6cbd0b6c 100644
--- a/client/src/lists/CUD.js
+++ b/client/src/lists/CUD.js
@@ -3,14 +3,13 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { translate, Trans } from 'react-i18next';
-import { requiresAuthenticatedUser, withPageHelpers, Title } from '../lib/page';
+import {requiresAuthenticatedUser, withPageHelpers, Title, NavButton} from '../lib/page';
import {
- withForm, Form, FormSendMethod, InputField, TextArea, TableSelect, TableSelectMode, ButtonRow, Button,
- Fieldset, Dropdown, AlignedRow, StaticField, CheckBox
+ withForm, Form, FormSendMethod, InputField, TextArea, TableSelect, ButtonRow, Button,
+ Dropdown, StaticField, CheckBox
} from '../lib/form';
-import axios from '../lib/axios';
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
-import { ModalDialog } from '../lib/bootstrap-components';
+import { DeleteModalDialog } from '../lib/delete';
import { validateNamespace, NamespaceSelect } from '../lib/namespace';
import { UnsubscriptionMode } from '../../../shared/lists';
@@ -23,9 +22,7 @@ export default class CUD extends Component {
constructor(props) {
super(props);
- this.state = {
- customFormOptions: []
- };
+ this.state = {};
if (props.edit) {
this.state.entityId = parseInt(props.match.params.id);
@@ -38,10 +35,6 @@ export default class CUD extends Component {
edit: PropTypes.bool
}
- isDelete() {
- return this.props.match.params.action === 'delete';
- }
-
@withAsyncErrorHandler
async loadFormValues() {
await this.getFormValuesFromURL(`/rest/lists/${this.state.entityId}`, data => {
@@ -104,6 +97,7 @@ export default class CUD extends Component {
if (data.form === 'default') {
data.default_form = null;
}
+ delete data.form;
});
if (submitSuccessful) {
@@ -114,27 +108,6 @@ export default class CUD extends Component {
}
}
- async showDeleteModal() {
- this.navigateTo(`/lists/edit/${this.state.entityId}/delete`);
- }
-
- async hideDeleteModal() {
- this.navigateTo(`/lists/edit/${this.state.entityId}`);
- }
-
- async performDelete() {
- const t = this.props.t;
-
- await this.hideDeleteModal();
-
- this.disableForm();
- this.setFormStatusMessage('info', t('Deleting list...'));
-
- await axios.delete(`/rest/lists/${this.state.entityId}`);
-
- this.navigateToWithFlashMessage('/lists', 'success', t('List deleted'));
- }
-
render() {
const t = this.props.t;
const edit = this.props.edit;
@@ -183,12 +156,14 @@ export default class CUD extends Component {
return (
{edit &&
-
- {t('Are you sure you want to delete "{{name}}"?', {name: this.getFormValue('name')})}
-
+
}
{edit ? t('Edit List') : t('Create List')}
@@ -219,7 +194,7 @@ export default class CUD extends Component {
diff --git a/client/src/lists/forms/CUD.js b/client/src/lists/forms/CUD.js
new file mode 100644
index 00000000..4d6b708d
--- /dev/null
+++ b/client/src/lists/forms/CUD.js
@@ -0,0 +1,450 @@
+'use strict';
+
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { translate, Trans } from 'react-i18next';
+import {requiresAuthenticatedUser, withPageHelpers, Title, NavButton} from '../../lib/page';
+import {
+ withForm, Form, FormSendMethod, InputField, TextArea, TableSelect, ButtonRow, Button,
+ Fieldset, Dropdown, AlignedRow, ACEEditor
+} from '../../lib/form';
+import { withErrorHandling, withAsyncErrorHandler } from '../../lib/error-handling';
+import { validateNamespace, NamespaceSelect } from '../../lib/namespace';
+import {DeleteModalDialog} from "../../lib/delete";
+import mailtrainConfig from 'mailtrainConfig';
+
+@translate()
+@withForm
+@withPageHelpers
+@withErrorHandling
+@requiresAuthenticatedUser
+export default class CUD extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {};
+
+ if (props.edit) {
+ this.state.entityId = parseInt(props.match.params.id);
+ }
+
+ this.serverValidatedFields = [
+ 'layout',
+ 'web_subscribe',
+ 'web_confirm_subscription_notice',
+ 'mail_confirm_subscription_html',
+ 'mail_confirm_subscription_text',
+ 'mail_already_subscribed_html',
+ 'mail_already_subscribed_text',
+ 'web_subscribed_notice',
+ 'mail_subscription_confirmed_html',
+ 'mail_subscription_confirmed_text',
+ 'web_manage',
+ 'web_manage_address',
+ 'web_updated_notice',
+ 'web_unsubscribe',
+ 'web_confirm_unsubscription_notice',
+ 'mail_confirm_unsubscription_html',
+ 'mail_confirm_unsubscription_text',
+ 'mail_confirm_address_change_html',
+ 'mail_confirm_address_change_text',
+ 'web_unsubscribed_notice',
+ 'mail_unsubscription_confirmed_html',
+ 'mail_unsubscription_confirmed_text',
+ 'web_manual_unsubscribe_notice'
+ ];
+
+ this.initForm({
+ serverValidation: {
+ url: '/rest/forms-validate',
+ changed: this.serverValidatedFields
+ }
+ });
+
+
+ const t = props.t;
+
+ const helpEmailText = t('The plaintext version for this email');
+ const helpMjmlGeneral =
Custom forms use MJML for formatting. See the MJML documentation here;
+
+ this.templateSettings = {
+ layout: {
+ label: t('Layout'),
+ mode: 'html',
+ help: helpMjmlGeneral,
+ isLayout: true
+ },
+ form_input_style: {
+ label: t('Form Input Style'),
+ mode: 'css',
+ help: t('This CSS stylesheet defines the appearance of form input elements and alerts')
+ },
+ web_subscribe: {
+ label: t('Web - Subscribe'),
+ mode: 'html',
+ help: helpMjmlGeneral
+ },
+ web_confirm_subscription_notice: {
+ label: t('Web - Confirm Subscription Notice'),
+ mode: 'html',
+ help: helpMjmlGeneral
+ },
+ mail_confirm_subscription_html: {
+ label: t('Mail - Confirm Subscription (MJML)'),
+ mode: 'html',
+ help: helpMjmlGeneral
+ },
+ mail_confirm_subscription_text: {
+ label: t('Mail - Confirm Subscription (Text)'),
+ mode: 'text',
+ help: helpEmailText
+ },
+ mail_already_subscribed_html: {
+ label: t('Mail - Already Subscribed (MJML)'),
+ mode: 'html',
+ help: helpMjmlGeneral
+ },
+ mail_already_subscribed_text: {
+ label: t('Mail - Already Subscribed (Text)'),
+ mode: 'text',
+ help: helpEmailText
+ },
+ web_subscribed_notice: {
+ label: t('Web - Subscribed Notice'),
+ mode: 'html',
+ help: helpMjmlGeneral
+ },
+ mail_subscription_confirmed_html: {
+ label: t('Mail - Subscription Confirmed (MJML)'),
+ mode: 'html',
+ help: helpMjmlGeneral
+ },
+ mail_subscription_confirmed_text: {
+ label: t('Mail - Subscription Confirmed (Text)'),
+ mode: 'text',
+ help: helpEmailText
+ },
+ web_manage: {
+ label: t('Web - Manage Preferences'),
+ mode: 'html',
+ help: helpMjmlGeneral
+ },
+ web_manage_address: {
+ label: t('Web - Manage Address'),
+ mode: 'html',
+ help: helpMjmlGeneral
+ },
+ mail_confirm_address_change_html: {
+ label: t('Mail - Confirm Address Change (MJML)'),
+ mode: 'html',
+ help: helpMjmlGeneral
+ },
+ mail_confirm_address_change_text: {
+ label: t('Mail - Confirm Address Change (Text)'),
+ mode: 'text',
+ help: helpEmailText
+ },
+ web_updated_notice: {
+ label: t('Web - Updated Notice'),
+ mode: 'html',
+ help: helpMjmlGeneral
+ },
+ web_unsubscribe: {
+ label: t('Web - Unsubscribe'),
+ mode: 'html',
+ help: helpMjmlGeneral
+ },
+ web_confirm_unsubscription_notice: {
+ label: t('Web - Confirm Unsubscription Notice'),
+ mode: 'html',
+ help: helpMjmlGeneral
+ },
+ mail_confirm_unsubscription_html: {
+ label: t('Mail - Confirm Unsubscription (MJML)'),
+ mode: 'html',
+ help: helpMjmlGeneral
+ },
+ mail_confirm_unsubscription_text: {
+ label: t('Mail - Confirm Unsubscription (Text)'),
+ mode: 'text',
+ help: helpEmailText
+ },
+ web_unsubscribed_notice: {
+ label: t('Web - Unsubscribed Notice'),
+ mode: 'html',
+ help: helpMjmlGeneral
+ },
+ mail_unsubscription_confirmed_html: {
+ label: t('Mail - Unsubscription Confirmed (MJML)'),
+ mode: 'html',
+ help: helpMjmlGeneral
+ },
+ mail_unsubscription_confirmed_text: {
+ label: t('Mail - Unsubscription Confirmed (Text)'),
+ mode: 'text',
+ help: helpEmailText
+ },
+ web_manual_unsubscribe_notice: {
+ label: t('Web - Manual Unsubscribe Notice'),
+ mode: 'html',
+ help: helpMjmlGeneral
+ }
+ };
+
+ this.templateGroups = {
+ general: {
+ label: t('General'),
+ options: [
+ 'layout',
+ 'form_input_style'
+ ]
+ },
+ subscribe: {
+ label: t('Subscribe'),
+ options: [
+ 'web_subscribe',
+ 'web_confirm_subscription_notice',
+ 'mail_confirm_subscription_html',
+ 'mail_confirm_subscription_text',
+ 'mail_already_subscribed_html',
+ 'mail_already_subscribed_text',
+ 'web_subscribed_notice',
+ 'mail_subscription_confirmed_html',
+ 'mail_subscription_confirmed_text'
+ ]
+ },
+ manage: {
+ label: t('Manage'),
+ options: [
+ 'web_manage',
+ 'web_manage_address',
+ 'mail_confirm_address_change_html',
+ 'mail_confirm_address_change_text',
+ 'web_updated_notice'
+ ]
+ },
+ unsubscribe: {
+ label: t('Unsubscribe'),
+ options: [
+ 'web_unsubscribe',
+ 'web_confirm_unsubscription_notice',
+ 'mail_confirm_unsubscription_html',
+ 'mail_confirm_unsubscription_text',
+ 'web_unsubscribed_notice',
+ 'mail_unsubscription_confirmed_html',
+ 'mail_unsubscription_confirmed_text',
+ 'web_manual_unsubscribe_notice'
+ ]
+ },
+ };
+
+ }
+
+ static propTypes = {
+ edit: PropTypes.bool
+ }
+
+ @withAsyncErrorHandler
+ async loadOrPopulateFormValues() {
+ function supplyDefaults(data) {
+ for (const key in mailtrainConfig.defaultCustomFormValues) {
+ if (!data[key]) {
+ data[key] = mailtrainConfig.defaultCustomFormValues[key];
+ }
+ }
+ }
+
+ if (this.props.edit) {
+ await this.getFormValuesFromURL(`/rest/forms/${this.state.entityId}`, data => {
+ data.selectedTemplate = 'layout';
+ supplyDefaults(data);
+ });
+ } else {
+ const data = {
+ name: '',
+ description: '',
+ selectedTemplate: 'layout',
+ namespace: null
+ };
+ supplyDefaults(data);
+
+ this.populateFormValues(data);
+ }
+ }
+
+ componentDidMount() {
+ this.loadOrPopulateFormValues();
+ }
+
+ 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);
+ }
+
+ validateNamespace(t, state);
+
+ let formsServerValidationRunning = false;
+ const formsErrors = [];
+
+ for (const fld of this.serverValidatedFields) {
+ const serverValidation = state.getIn([fld, 'serverValidation']);
+
+ if (serverValidation && serverValidation.errors) {
+ formsErrors.push(...serverValidation.errors.map(x =>
{this.templateSettings[fld].label}{' '}–{' '}{x}
));
+ } else if (!serverValidation) {
+ formsServerValidationRunning = true;
+ }
+ }
+
+ if (!formsErrors.length && formsServerValidationRunning) {
+ formsErrors.push(t('Validation is in progress...'));
+ }
+
+ if (formsErrors.length) {
+ state.setIn(['selectedTemplate', 'error'],
+
{t('List of errors in templates') + ':'}
+
+ {formsErrors.map((msg, idx) => - {msg}
)}
+
+
);
+ } else {
+ state.setIn(['selectedTemplate', 'error'], null);
+ }
+ }
+
+ async submitHandler() {
+ const t = this.props.t;
+ const edit = this.props.edit;
+
+ let sendMethod, url;
+ if (edit) {
+ sendMethod = FormSendMethod.PUT;
+ url = `/rest/forms/${this.state.entityId}`
+ } else {
+ sendMethod = FormSendMethod.POST;
+ url = '/rest/forms'
+ }
+
+ this.disableForm();
+ this.setFormStatusMessage('info', t('Saving forms ...'));
+
+ const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
+ delete data.selectedTemplate;
+ delete data.previewList;
+ });
+
+ if (submitSuccessful) {
+ this.navigateToWithFlashMessage('/lists/forms', 'success', t('Forms saved'));
+ } else {
+ this.enableForm();
+ this.setFormStatusMessage('warning', t('There are errors in the form. Please fix them and submit again.'));
+ }
+ }
+
+ render() {
+ const t = this.props.t;
+ const edit = this.props.edit;
+
+ const templateOptGroups = [];
+
+ for (const grpKey in this.templateGroups) {
+ const grp = this.templateGroups[grpKey];
+ templateOptGroups.push({
+ key: grpKey,
+ label: grp.label,
+ options: grp.options.map(opt => ({
+ key: opt,
+ label: this.templateSettings[opt].label
+ }))
+ });
+ }
+
+
+ const listsColumns = [
+ { data: 0, title: "#" },
+ { data: 1, title: t('Name') },
+ { data: 2, title: t('ID'), render: data => `
${data}
` },
+ { data: 5, title: t('Namespace') }
+ ];
+
+ const previewListId = this.getFormValue('previewList');
+ const selectedTemplate = this.getFormValue('selectedTemplate');
+
+ return (
+
+ {edit &&
+
+ }
+
+
{edit ? t('Edit Custom Forms') : t('Create Custom Forms')}
+
+
+
+ );
+ }
+}
\ No newline at end of file
diff --git a/client/src/lists/root.js b/client/src/lists/root.js
index 510ecc8d..28e08c0b 100644
--- a/client/src/lists/root.js
+++ b/client/src/lists/root.js
@@ -9,6 +9,7 @@ import { Section } from '../lib/page';
import ListsList from './List';
import ListsCUD from './CUD';
import FormsList from './forms/List';
+import FormsCUD from './forms/CUD';
import Share from '../shares/Share';
@@ -40,9 +41,25 @@ const getStructure = t => {
render: props => (
t('Share List "{{name}}"', {name: entity.name})} getUrl={id => `/rest/lists/${id}`} entityTypeId="list" {...props} />)
},
forms: {
- title: t('Forms'),
+ title: t('Custom Forms'),
link: '/lists/forms',
component: FormsList,
+ children: {
+ edit: {
+ title: t('Edit Custom Forms'),
+ params: [':id', ':action?'],
+ render: props => ()
+ },
+ create: {
+ title: t('Create Custom Forms'),
+ render: props => ()
+ },
+ share: {
+ title: t('Share Custom Forms'),
+ params: [':id'],
+ render: props => ( t('Custom Forms "{{name}}"', {name: entity.name})} getUrl={id => `/rest/forms/${id}`} entityTypeId="customForm" {...props} />)
+ }
+ }
}
}
}
diff --git a/client/src/namespaces/CUD.js b/client/src/namespaces/CUD.js
index 88def7e4..710cdf38 100644
--- a/client/src/namespaces/CUD.js
+++ b/client/src/namespaces/CUD.js
@@ -3,12 +3,12 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { translate } from 'react-i18next';
-import { requiresAuthenticatedUser, withPageHelpers, Title } from '../lib/page';
+import {requiresAuthenticatedUser, withPageHelpers, Title, NavButton} from '../lib/page';
import { withForm, Form, FormSendMethod, InputField, TextArea, ButtonRow, Button, TreeTableSelect } from '../lib/form';
import axios from '../lib/axios';
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
import interoperableErrors from '../../../shared/interoperable-errors';
-import { ModalDialog } from '../lib/bootstrap-components';
+import {DeleteModalDialog} from "../lib/delete";
@translate()
@withForm
@@ -171,41 +171,19 @@ export default class CUD extends Component {
}
}
- async showDeleteModal() {
- this.navigateTo(`/namespaces/edit/${this.state.entityId}/delete`);
- }
-
- async hideDeleteModal() {
- this.navigateTo(`/namespaces/edit/${this.state.entityId}`);
- }
-
- async performDelete() {
- const t = this.props.t;
-
- await this.hideDeleteModal();
-
- try {
+ async onDeleteError(error) {
+ if (error instanceof interoperableErrors.ChildDetectedError) {
this.disableForm();
- this.setFormStatusMessage('info', t('Deleting namespace...'));
-
- await axios.delete(`/rest/namespaces/${this.state.entityId}`);
-
- this.navigateToWithFlashMessage('/namespaces', 'success', t('Namespace deleted'));
-
- } catch (error) {
- if (error instanceof interoperableErrors.ChildDetectedError) {
- this.disableForm();
- this.setFormStatusMessage('danger',
-
- {t('The namespace cannot be deleted.')}{' '}
- {t('There has been a child namespace found. 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.')}
-
- );
- return;
- }
-
- throw error;
+ this.setFormStatusMessage('danger',
+
+ {t('The namespace cannot be deleted.')}{' '}
+ {t('There has been a child namespace found. 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.')}
+
+ );
+ return;
}
+
+ throw error;
}
render() {
@@ -215,12 +193,15 @@ export default class CUD extends Component {
return (
{!this.isEditGlobal() && !this.hasChildren && edit &&
-
- {t('Are you sure you want to delete namespace "{{namespace}}"?', {namespace: this.getFormValue('name')})}
-
+
}
{edit ? t('Edit Namespace') : t('Create Namespace')}
@@ -234,8 +215,7 @@ export default class CUD extends Component {
- {!this.isEditGlobal() && !this.hasChildren && edit && }
+ {!this.isEditGlobal() && !this.hasChildren && edit && }
diff --git a/client/src/reports/CUD.js b/client/src/reports/CUD.js
index 2b6c3459..ea477e4e 100644
--- a/client/src/reports/CUD.js
+++ b/client/src/reports/CUD.js
@@ -2,17 +2,17 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
-import { translate, Trans } from 'react-i18next';
-import { requiresAuthenticatedUser, withPageHelpers, Title } from '../lib/page';
+import { translate } from 'react-i18next';
+import {requiresAuthenticatedUser, withPageHelpers, Title, NavButton} from '../lib/page';
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';
import moment from 'moment';
import { validateNamespace, NamespaceSelect } from '../lib/namespace';
+import {DeleteModalDialog} from "../lib/delete";
@translate()
@withForm
@@ -170,27 +170,6 @@ export default class CUD extends Component {
}
}
- async showDeleteModal() {
- this.navigateTo(`/reports/edit/${this.state.entityId}/delete`);
- }
-
- async hideDeleteModal() {
- this.navigateTo(`/reports/edit/${this.state.entityId}`);
- }
-
- async performDelete() {
- const t = this.props.t;
-
- await this.hideDeleteModal();
-
- this.disableForm();
- this.setFormStatusMessage('info', t('Deleting report...'));
-
- await axios.delete(`/rest/reports/${this.state.entityId}`);
-
- this.navigateToWithFlashMessage('/reports', 'success', t('Report deleted'));
- }
-
render() {
const t = this.props.t;
const edit = this.props.edit;
@@ -248,12 +227,14 @@ export default class CUD extends Component {
return (
{edit &&
-
- {t('Are you sure you want to delete "{{name}}"?', {name: this.getFormValue('name')})}
-
+
}
{edit ? t('Edit Report') : t('Create Report')}
@@ -278,7 +259,7 @@ export default class CUD extends Component {
- {edit && }
+ {edit && }
diff --git a/client/src/reports/templates/CUD.js b/client/src/reports/templates/CUD.js
index 7fbcb952..677a1234 100644
--- a/client/src/reports/templates/CUD.js
+++ b/client/src/reports/templates/CUD.js
@@ -3,12 +3,11 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { translate, Trans } from 'react-i18next';
-import { requiresAuthenticatedUser, withPageHelpers, Title } from '../../lib/page'
+import {requiresAuthenticatedUser, withPageHelpers, Title, NavButton} 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';
import { validateNamespace, NamespaceSelect } from '../../lib/namespace';
+import {DeleteModalDialog} from "../../lib/delete";
@translate()
@withForm
@@ -276,27 +275,6 @@ export default class CUD extends Component {
}
}
- async showDeleteModal() {
- this.navigateTo(`/reports/templates/edit/${this.state.entityId}/delete`);
- }
-
- async hideDeleteModal() {
- this.navigateTo(`/reports/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('/reports/templates', 'success', t('Report template deleted'));
- }
-
render() {
const t = this.props.t;
const edit = this.props.edit;
@@ -304,12 +282,14 @@ export default class CUD extends Component {
return (
{edit &&
-
- {t('Are you sure you want to delete report template "{{name}}"?', {name: this.getFormValue('name')})}
-
+
}
{edit ? t('Edit Report Template') : t('Create Report Template')}
@@ -327,7 +307,7 @@ export default class CUD extends Component {
-
+
:
diff --git a/client/src/users/CUD.js b/client/src/users/CUD.js
index 470abb66..ae117883 100644
--- a/client/src/users/CUD.js
+++ b/client/src/users/CUD.js
@@ -3,15 +3,15 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { translate } from 'react-i18next';
-import { requiresAuthenticatedUser, withPageHelpers, Title } from '../lib/page';
+import {requiresAuthenticatedUser, withPageHelpers, Title, NavButton} from '../lib/page';
import { withForm, Form, FormSendMethod, InputField, ButtonRow, Button, TableSelect } from '../lib/form';
import axios from '../lib/axios';
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
import interoperableErrors from '../../../shared/interoperable-errors';
import passwordValidator from '../../../shared/password-validator';
-import { ModalDialog } from '../lib/bootstrap-components';
import mailtrainConfig from 'mailtrainConfig';
import { validateNamespace, NamespaceSelect } from '../lib/namespace';
+import {DeleteModalDialog} from "../lib/delete";
@translate()
@withForm
@@ -79,8 +79,10 @@ export default class CUD extends Component {
if (!username) {
state.setIn(['username', 'error'], t('User name must not be empty'));
- } else if (!usernameServerValidation || usernameServerValidation.exists) {
+ } else if (usernameServerValidation && usernameServerValidation.exists) {
state.setIn(['username', 'error'], t('The user name already exists in the system.'));
+ } else if (!usernameServerValidation) {
+ state.setIn(['email', 'error'], t('Validation is in progress...'));
} else {
state.setIn(['username', 'error'], null);
}
@@ -92,8 +94,12 @@ export default class CUD extends Component {
if (!email) {
state.setIn(['email', 'error'], t('Email must not be empty'));
- } else if (!emailServerValidation || emailServerValidation.invalid) {
+ } else if (emailServerValidation && emailServerValidation.invalid) {
state.setIn(['email', 'error'], t('Invalid email address.'));
+ } else if (emailServerValidation && emailServerValidation.exists) {
+ state.setIn(['email', 'error'], t('The email is already associated with another user in the system.'));
+ } else if (!emailServerValidation) {
+ state.setIn(['email', 'error'], t('Validation is in progress...'));
} else {
state.setIn(['email', 'error'], null);
}
@@ -222,12 +228,14 @@ export default class CUD extends Component {
return (
{edit && canDelete &&
-
- {t('Are you sure you want to delete user "{{username}}"?', {username: this.getFormValue('username')})}
-
+
}
{edit ? t('Edit User') : t('Create User')}
@@ -247,8 +255,7 @@ export default class CUD extends Component {
- {edit && canDelete && }
+ {edit && canDelete && }
diff --git a/lib/client-helpers.js b/lib/client-helpers.js
index a9514801..496cf65d 100644
--- a/lib/client-helpers.js
+++ b/lib/client-helpers.js
@@ -3,8 +3,9 @@
const passport = require('./passport');
const config = require('config');
const permissions = require('./permissions');
+const forms = require('../models/forms');
-function getAnonymousConfig(context) {
+async function getAnonymousConfig(context) {
return {
authMethod: passport.authMethod,
isAuthMethodLocal: passport.isAuthMethodLocal,
@@ -14,17 +15,18 @@ function getAnonymousConfig(context) {
}
}
-function getAuthenticatedConfig(context) {
+async function getAuthenticatedConfig(context) {
return {
+ defaultCustomFormValues: await forms.getDefaultCustomFormValues(),
userId: context.user.id
}
}
function registerRootRoute(router, entryPoint, title) {
- router.get('/*', passport.csrfProtection, (req, res) => {
- const mailtrainConfig = getAnonymousConfig(req.context);
+ router.getAsync('/*', passport.csrfProtection, async (req, res) => {
+ const mailtrainConfig = await getAnonymousConfig(req.context);
if (req.user) {
- Object.assign(mailtrainConfig, getAuthenticatedConfig(req.context));
+ Object.assign(mailtrainConfig, await getAuthenticatedConfig(req.context));
}
res.render('react-root', {
@@ -37,7 +39,7 @@ function registerRootRoute(router, entryPoint, title) {
}
module.exports = {
- registerRootRoute,
+ registerRootRoute,
getAuthenticatedConfig
};
diff --git a/models/forms.js b/models/forms.js
index b755517c..efdb0c0e 100644
--- a/models/forms.js
+++ b/models/forms.js
@@ -2,23 +2,26 @@
const knex = require('../lib/knex');
const { enforce, filterObject } = require('../lib/helpers');
+const hasher = require('node-object-hash')();
const dtHelpers = require('../lib/dt-helpers');
const interoperableErrors = require('../shared/interoperable-errors');
const shares = require('./shares');
+const namespaceHelpers = require('../lib/namespace-helpers');
const bluebird = require('bluebird');
-const fs = bluebird.promisifyAll(require('fs'));
+const fsReadFile = bluebird.promisify(require('fs').readFile);
const path = require('path');
const mjml = require('mjml');
const _ = require('../lib/translate')._;
-const formAllowedKeys = [
+const formAllowedKeys = new Set([
'name',
'description',
'layout',
- 'form_input_style'
-];
+ 'form_input_style',
+ 'namespace'
+]);
-const allowedFormKeys = [
+const allowedFormKeys = new Set([
'web_subscribe',
'web_confirm_subscription_notice',
'mail_confirm_subscription_html',
@@ -41,10 +44,11 @@ const allowedFormKeys = [
'mail_unsubscription_confirmed_html',
'mail_unsubscription_confirmed_text',
'web_manual_unsubscribe_notice'
-];
+]);
-const hashKeys = [...formAllowedKeys, ...allowedFormKeys];
+const hashKeys = new Set([...formAllowedKeys, ...allowedFormKeys]);
+const allowedKeysServerValidate = new Set(['layout', ...allowedFormKeys]);
function hash(entity) {
return hasher.hash(filterObject(entity, hashKeys));
@@ -85,7 +89,7 @@ async function getById(context, id) {
let entity;
await knex.transaction(async tx => {
- entity = _getById(tx, id);
+ entity = await _getById(tx, id);
});
return entity;
@@ -95,14 +99,13 @@ async function getById(context, id) {
async function serverValidate(context, data) {
const result = {};
- const form = filterObject(data, allowedFormKeys);
-
+ const form = filterObject(data, allowedKeysServerValidate);
const errs = checkForMjmlErrors(form);
for (const key in form) {
result[key] = {};
if (errs[key]) {
- result.key.errors = errs[key];
+ result[key].errors = errs[key];
}
}
@@ -111,14 +114,18 @@ async function serverValidate(context, data) {
async function create(context, entity) {
- await shares.enforceEntityPermission(context, 'namespace', 'createCustomForm');
+ await shares.enforceEntityPermission(context, 'namespace', entity.namespace, 'createCustomForm');
let id;
await knex.transaction(async tx => {
+ await namespaceHelpers.validateEntity(tx, entity);
+
+ const form = filterObject(entity, allowedFormKeys);
+ enforce(!Object.keys(checkForMjmlErrors(form)).length, 'Error(s) in form templates');
+
const ids = await tx('custom_forms').insert(filterObject(entity, formAllowedKeys));
id = ids[0];
- const form = filterObject(entity, allowedFormKeys);
for (const formKey in form) {
await tx('custom_forms_data').insert({
form: id,
@@ -126,6 +133,8 @@ async function create(context, entity) {
data_value: form[formKey]
})
}
+
+ await shares.rebuildPermissions(tx, { entityTypeId: 'customForm', entityId: id });
});
return id;
@@ -135,13 +144,16 @@ async function updateWithConsistencyCheck(context, entity) {
await shares.enforceEntityPermission(context, 'customForm', entity.id, 'edit');
await knex.transaction(async tx => {
- const existing = _getById(tx, context, id);
+ const existing = await _getById(tx, entity.id);
const existingHash = hash(existing);
if (existingHash != entity.originalHash) {
throw new interoperableErrors.ChangedError();
}
+ await namespaceHelpers.validateEntity(tx, entity);
+ await namespaceHelpers.validateMove(context, entity, existing, 'customForm', 'createCustomForm', 'delete');
+
const form = filterObject(entity, allowedFormKeys);
enforce(!Object.keys(checkForMjmlErrors(form)).length, 'Error(s) in form templates');
@@ -149,15 +161,20 @@ async function updateWithConsistencyCheck(context, entity) {
for (const formKey in form) {
await tx('custom_forms_data').update({
- form: entity.id,
- data_key: formKey,
data_value: form[formKey]
+ }).where({
+ form: entity.id,
+ data_key: formKey
});
}
+
+ await shares.rebuildPermissions(tx, { entityTypeId: 'customForm', entityId: entity.id });
});
}
async function remove(context, id) {
+ shares.enforceEntityPermission(context, 'customForm', id, 'delete');
+
await knex.transaction(async tx => {
const entity = await tx('custom_forms').where('id', id).first();
@@ -165,20 +182,18 @@ async function remove(context, id) {
throw shares.throwPermissionDenied();
}
- shares.enforceEntityPermission(context, 'list', entity.list, 'manageForms');
-
await tx('custom_forms_data').where('form', id).del();
await tx('custom_forms').where('id', id).del();
});
}
-async function getDefaultFormValues() {
+async function getDefaultCustomFormValues() {
const basePath = path.join(__dirname, '..');
async function getContents(fileName) {
try {
- const template = await fs.readFile(path.join(basePath, fileName), 'utf8');
+ const template = await fsReadFile(path.join(basePath, fileName), 'utf8');
return template.replace(/\{\{#translate\}\}(.*?)\{\{\/translate\}\}/g, (m, s) => _(s));
} catch (err) {
return false;
@@ -195,7 +210,7 @@ async function getDefaultFormValues() {
}
form.layout = await getContents('views/subscription/layout.mjml.hbs') || '';
- form.formInputStyle = await getContents('public/subscription/form-input-style.css') || '@import url(/subscription/form-input-style.css);';
+ form.form_input_style = await getContents('public/subscription/form-input-style.css') || '@import url(/subscription/form-input-style.css);';
return form;
}
@@ -224,24 +239,32 @@ function checkForMjmlErrors(form) {
const template = form[key];
const errs = hasMjmlError(template);
+ const msgs = errs.map(x => x.formattedMessage);
if (key === 'mail_confirm_html' && !template.includes('{{confirmUrl}}')) {
- errs.push('Missing {{confirmUrl}}');
+ msgs.push('Missing {{confirmUrl}}');
}
- if (errs.length) {
- errors[key] = errs;
+ if (msgs.length) {
+ errors[key] = msgs;
}
} else if (key === 'layout') {
- const layout = values[index];
- const err = hasMjmlError('', layout);
+ const layout = form[key];
+ const errs = hasMjmlError('', layout);
- if (!layout.includes('{{{body}}}')) {
- errs.push(`{{{body}}} not found`);
+ let msgs;
+ if (Array.isArray(errs)) {
+ msgs = errs.map(x => x.formattedMessage)
+ } else {
+ msgs = [ errs.message ];
}
- if (errs.length) {
- errors[key] = errs;
+ if (!layout.includes('{{{body}}}')) {
+ msgs.push(`{{{body}}} not found`);
+ }
+
+ if (msgs.length) {
+ errors[key] = msgs;
}
}
}
@@ -256,6 +279,6 @@ module.exports = {
create,
updateWithConsistencyCheck,
remove,
- getDefaultFormValues,
+ getDefaultCustomFormValues,
serverValidate
};
\ No newline at end of file