Custom forms list and CUD.

This commit is contained in:
Tomas Bures 2017-07-30 16:22:07 +03:00
parent f6e1938ff9
commit 361af18384
12 changed files with 1068 additions and 209 deletions

View file

@ -27,7 +27,7 @@ export default class Account extends Component {
this.initForm({ this.initForm({
serverValidation: { serverValidation: {
url: '/rest/account-validate', url: '/rest/account-validate',
changed: ['email', 'username', 'currentPassword'] changed: ['email', 'currentPassword']
} }
}); });
} }
@ -53,10 +53,12 @@ export default class Account extends Component {
if (!email) { if (!email) {
state.setIn(['email', 'error'], t('Email must not be empty.')); 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.')); state.setIn(['email', 'error'], t('Invalid email address.'));
} else if (!emailServerValidation || emailServerValidation.exists) { } else if (emailServerValidation && emailServerValidation.exists) {
state.setIn(['email', 'error'], t('The email is already associated with another user in the system.')); 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 { } else {
state.setIn(['email', 'error'], null); state.setIn(['email', 'error'], null);
} }
@ -86,8 +88,10 @@ export default class Account extends Component {
if (!currentPassword) { if (!currentPassword) {
state.setIn(['currentPassword', 'error'], t('Current password must not be empty.')); state.setIn(['currentPassword', 'error'], t('Current password must not be empty.'));
} else if (!currentPasswordServerValidation || currentPasswordServerValidation.incorrect) { } else if (currentPasswordServerValidation && currentPasswordServerValidation.incorrect) {
state.setIn(['currentPassword', 'error'], t('Incorrect password.')); state.setIn(['currentPassword', 'error'], t('Incorrect password.'));
} else if (!currentPasswordServerValidation) {
state.setIn(['email', 'error'], t('Validation is in progress...'));
} else { } else {
state.setIn(['currentPassword', 'error'], null); state.setIn(['currentPassword', 'error'], null);
} }

65
client/src/lib/delete.js Normal file
View file

@ -0,0 +1,65 @@
'use strict';
import React, { Component } from 'react';
import axios from './axios';
import { translate } from 'react-i18next';
import PropTypes from 'prop-types';
import {ModalDialog} from "./bootstrap-components";
@translate()
class DeleteModalDialog extends Component {
static propTypes = {
stateOwner: PropTypes.object.isRequired,
visible: PropTypes.bool.isRequired,
deleteUrl: PropTypes.string.isRequired,
cudUrl: PropTypes.string.isRequired,
listUrl: PropTypes.string.isRequired,
deletingMsg: PropTypes.string.isRequired,
deletedMsg: PropTypes.string.isRequired,
onErrorAsync: PropTypes.func
}
async hideDeleteModal() {
this.props.stateOwner.navigateTo(this.props.cudUrl);
}
async performDelete() {
const t = this.props.t;
const owner = this.props.stateOwner;
await this.hideDeleteModal();
try {
owner.disableForm();
owner.setFormStatusMessage('info', this.props.deletingMsg);
await axios.delete(this.props.deleteUrl);
owner.navigateToWithFlashMessage(this.props.listUrl, 'success', this.props.deletedMsg);
} catch (err) {
if (this.props.onErrorAsync) {
await this.props.onErrorAsync(err);
} else {
throw err;
}
}
}
render() {
const t = this.props.t;
const owner = this.props.stateOwner;
return (
<ModalDialog hidden={!this.props.visible} 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 "{{name}}"?', {name: owner.getFormValue('name')})}
</ModalDialog>
);
}
}
export {
DeleteModalDialog
}

View file

@ -244,7 +244,8 @@ class Dropdown extends Component {
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
label: PropTypes.string.isRequired, label: PropTypes.string.isRequired,
help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
options: PropTypes.array.isRequired options: PropTypes.array,
optGroups: PropTypes.array
} }
static contextTypes = { static contextTypes = {
@ -257,7 +258,18 @@ class Dropdown extends Component {
const owner = this.context.formStateOwner; const owner = this.context.formStateOwner;
const id = this.props.id; const id = this.props.id;
const htmlId = 'form_' + id; const htmlId = 'form_' + id;
const options = props.options.map(option => <option key={option.key} value={option.key}>{option.label}</option>); let options = [];
if (this.props.options) {
options = props.options.map(option => <option key={option.key} value={option.key}>{option.label}</option>);
} else if (this.props.optGroups) {
options = props.optGroups.map(optGroup =>
<optgroup key={optGroup.key} label={optGroup.label}>
{optGroup.options.map(option => <option key={option.key} value={option.key}>{option.label}</option>)}
</optgroup>
);
}
return wrapInput(id, htmlId, owner, props.label, props.help, 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)}> <select id={htmlId} className="form-control" aria-describedby={htmlId + '_help'} value={owner.getFormValue(id)} onChange={evt => owner.updateFormValue(id, evt.target.value)}>
@ -270,7 +282,9 @@ class Dropdown extends Component {
class AlignedRow extends Component { class AlignedRow extends Component {
static propTypes = { static propTypes = {
className: PropTypes.string className: PropTypes.string,
label: PropTypes.string,
htmlId: PropTypes.string
} }
static defaultProps = { static defaultProps = {
@ -278,13 +292,25 @@ class AlignedRow extends Component {
} }
render() { render() {
return ( if (this.props.label) {
<div className="form-group"> return (
<div className={"col-sm-10 col-sm-offset-2 " + this.props.className}> <div className="form-group">
{this.props.children} <label htmlFor={this.props.htmlId} className="col-sm-2 control-label">{this.props.label}</label>
<div className={"col-sm-10 " + this.props.className} id={this.props.htmlId}>
{this.props.children}
</div>
</div> </div>
</div> );
);
} else {
return (
<div className="form-group">
<div className={"col-sm-10 col-sm-offset-2 " + this.props.className} id={this.props.htmlId}>
{this.props.children}
</div>
</div>
);
}
} }
} }
@ -499,7 +525,7 @@ TableSelect.prototype.refresh = function() {
class ACEEditor extends Component { class ACEEditor extends Component {
static propTypes = { static propTypes = {
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
label: PropTypes.string.isRequired, label: PropTypes.string,
help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
height: PropTypes.string, height: PropTypes.string,
mode: PropTypes.string mode: PropTypes.string
@ -882,6 +908,355 @@ function withForm(target) {
return 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',
<span>
<strong>{t('Your updates cannot be saved.')}</strong>{' '}
{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.')}
</span>
);
return;
}
if (error instanceof interoperableErrors.NotFoundError) {
this.disableForm();
this.setFormStatusMessage('danger',
<span>
<strong>{t('Your updates cannot be saved.')}</strong>{' '}
{t('It seems that someone else has deleted the entity in the meantime.')}
</span>
);
return;
}
throw error;
}
};
return target;
}
export { export {
withForm, withForm,

View file

@ -3,14 +3,13 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { translate, Trans } from 'react-i18next'; import { translate, Trans } from 'react-i18next';
import { requiresAuthenticatedUser, withPageHelpers, Title } from '../lib/page'; import {requiresAuthenticatedUser, withPageHelpers, Title, NavButton} from '../lib/page';
import { import {
withForm, Form, FormSendMethod, InputField, TextArea, TableSelect, TableSelectMode, ButtonRow, Button, withForm, Form, FormSendMethod, InputField, TextArea, TableSelect, ButtonRow, Button,
Fieldset, Dropdown, AlignedRow, StaticField, CheckBox Dropdown, StaticField, CheckBox
} from '../lib/form'; } from '../lib/form';
import axios from '../lib/axios';
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling'; 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 { validateNamespace, NamespaceSelect } from '../lib/namespace';
import { UnsubscriptionMode } from '../../../shared/lists'; import { UnsubscriptionMode } from '../../../shared/lists';
@ -23,9 +22,7 @@ export default class CUD extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { this.state = {};
customFormOptions: []
};
if (props.edit) { if (props.edit) {
this.state.entityId = parseInt(props.match.params.id); this.state.entityId = parseInt(props.match.params.id);
@ -38,10 +35,6 @@ export default class CUD extends Component {
edit: PropTypes.bool edit: PropTypes.bool
} }
isDelete() {
return this.props.match.params.action === 'delete';
}
@withAsyncErrorHandler @withAsyncErrorHandler
async loadFormValues() { async loadFormValues() {
await this.getFormValuesFromURL(`/rest/lists/${this.state.entityId}`, data => { await this.getFormValuesFromURL(`/rest/lists/${this.state.entityId}`, data => {
@ -104,6 +97,7 @@ export default class CUD extends Component {
if (data.form === 'default') { if (data.form === 'default') {
data.default_form = null; data.default_form = null;
} }
delete data.form;
}); });
if (submitSuccessful) { 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() { render() {
const t = this.props.t; const t = this.props.t;
const edit = this.props.edit; const edit = this.props.edit;
@ -183,12 +156,14 @@ export default class CUD extends Component {
return ( return (
<div> <div>
{edit && {edit &&
<ModalDialog hidden={!this.isDelete()} title={t('Confirm deletion')} onCloseAsync={::this.hideDeleteModal} buttons={[ <DeleteModalDialog
{ label: t('No'), className: 'btn-primary', onClickAsync: ::this.hideDeleteModal }, stateOwner={this}
{ label: t('Yes'), className: 'btn-danger', onClickAsync: ::this.performDelete } visible={this.props.match.params.action === 'delete'}
]}> deleteUrl={`/rest/lists/${this.state.entityId}`}
{t('Are you sure you want to delete "{{name}}"?', {name: this.getFormValue('name')})} cudUrl={`/lists/edit/${this.state.entityId}`}
</ModalDialog> listUrl="/lists"
deletingMsg={t('Deleting list ...')}
deletedMsg={t('List deleted')}/>
} }
<Title>{edit ? t('Edit List') : t('Create List')}</Title> <Title>{edit ? t('Edit List') : t('Create List')}</Title>
@ -219,7 +194,7 @@ export default class CUD extends Component {
<ButtonRow> <ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/> <Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/>
{edit && <Button className="btn-danger" icon="remove" label={t('Delete List')} onClickAsync={::this.showDeleteModal}/>} {edit && <NavButton className="btn-danger" icon="remove" label={t('Delete')} linkTo={`/lists/edit/${this.state.entityId}/delete`}/>}
</ButtonRow> </ButtonRow>
</Form> </Form>
</div> </div>

View file

@ -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 = <Trans>Custom forms use MJML for formatting. See the MJML documentation <a className="mjml-documentation">here</a></Trans>;
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 => <div><em>{this.templateSettings[fld].label}</em>{' '}{' '}{x}</div>));
} else if (!serverValidation) {
formsServerValidationRunning = true;
}
}
if (!formsErrors.length && formsServerValidationRunning) {
formsErrors.push(t('Validation is in progress...'));
}
if (formsErrors.length) {
state.setIn(['selectedTemplate', 'error'],
<div><strong>{t('List of errors in templates') + ':'}</strong>
<ul>
{formsErrors.map((msg, idx) => <li key={idx}>{msg}</li>)}
</ul>
</div>);
} 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 => `<code>${data}</code>` },
{ data: 5, title: t('Namespace') }
];
const previewListId = this.getFormValue('previewList');
const selectedTemplate = this.getFormValue('selectedTemplate');
return (
<div>
{edit &&
<DeleteModalDialog
stateOwner={this}
visible={this.props.match.params.action === 'delete'}
deleteUrl={`/rest/forms/${this.state.entityId}`}
cudUrl={`/lists/forms/edit/${this.state.entityId}`}
listUrl="/lists/forms"
deletingMsg={t('Deleting form ...')}
deletedMsg={t('Form deleted')}/>
}
<Title>{edit ? t('Edit Custom Forms') : t('Create Custom Forms')}</Title>
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
<InputField id="name" label={t('Name')}/>
<TextArea id="description" label={t('Description')} help={t('HTML is allowed')}/>
<NamespaceSelect/>
<Fieldset label={t('Forms Preview')}>
<TableSelect id="previewList" label={t('List To Preview On')} withHeader dropdown dataUrl='/rest/lists-table' columns={listsColumns} selectionLabelIndex={1} help={t('Select list whose fields will be used to preview the forms.')}/>
{ previewListId &&
<AlignedRow>
<div className="help-block">
<small>
Note: These links are solely for a quick preview. If you submit a preview form you'll get redirected to the list's default form.
</small>
</div>
<p>
<a href={`/lists/forms/preview/${previewListId}`} target="_blank">Subscribe</a>
|
<a href={`/lists/forms/preview/${previewListId}/confirm-subscription-notice`} target="_blank">Confirm Subscription Notice</a>
|
<a href={`/lists/forms/preview/${previewListId}/confirm-unsubscription-notice`} target="_blank">Confirm Unsubscription Notice</a>
|
<a href={`/lists/forms/preview/${previewListId}/subscribed-notice`} target="_blank">Subscribed Notice</a>
|
<a href={`/lists/forms/preview/${previewListId}/updated-notice`} target="_blank">Updated Notice</a>
|
<a href={`/lists/forms/preview/${previewListId}/unsubscribed-notice`} target="_blank">Unsubscribed Notice</a>
|
<a href={`/lists/forms/preview/${previewListId}/manual-unsubscribe-notice`} target="_blank">Manual Unsubscribe Notice</a>
|
<a href={`/lists/forms/preview/${previewListId}/unsubscribe`} target="_blank">Unsubscribe</a>
|
<a href={`/lists/forms/preview/${previewListId}/manage`} target="_blank">Manage</a>
|
<a href={`/lists/forms/preview/${previewListId}/manage-address`} target="_blank">Manage Address</a>
</p>
</AlignedRow>
}
</Fieldset>
{ selectedTemplate &&
<Fieldset label={t('Templates')}>
<Dropdown id="selectedTemplate" label={t('Edit')} optGroups={templateOptGroups} help={this.templateSettings[selectedTemplate].help}/>
<ACEEditor id={selectedTemplate} height="500px" mode={this.templateSettings[selectedTemplate].mode}/>
</Fieldset>
}
<ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/>
{edit && <NavButton className="btn-danger" icon="remove" label={t('Delete')} linkTo={`/lists/forms/edit/${this.state.entityId}/delete`}/>}
</ButtonRow>
</Form>
</div>
);
}
}

View file

@ -9,6 +9,7 @@ import { Section } from '../lib/page';
import ListsList from './List'; import ListsList from './List';
import ListsCUD from './CUD'; import ListsCUD from './CUD';
import FormsList from './forms/List'; import FormsList from './forms/List';
import FormsCUD from './forms/CUD';
import Share from '../shares/Share'; import Share from '../shares/Share';
@ -40,9 +41,25 @@ const getStructure = t => {
render: props => (<Share title={entity => t('Share List "{{name}}"', {name: entity.name})} getUrl={id => `/rest/lists/${id}`} entityTypeId="list" {...props} />) render: props => (<Share title={entity => t('Share List "{{name}}"', {name: entity.name})} getUrl={id => `/rest/lists/${id}`} entityTypeId="list" {...props} />)
}, },
forms: { forms: {
title: t('Forms'), title: t('Custom Forms'),
link: '/lists/forms', link: '/lists/forms',
component: FormsList, component: FormsList,
children: {
edit: {
title: t('Edit Custom Forms'),
params: [':id', ':action?'],
render: props => (<FormsCUD edit {...props} />)
},
create: {
title: t('Create Custom Forms'),
render: props => (<FormsCUD {...props} />)
},
share: {
title: t('Share Custom Forms'),
params: [':id'],
render: props => (<Share title={entity => t('Custom Forms "{{name}}"', {name: entity.name})} getUrl={id => `/rest/forms/${id}`} entityTypeId="customForm" {...props} />)
}
}
} }
} }
} }

View file

@ -3,12 +3,12 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { translate } from 'react-i18next'; 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 { withForm, Form, FormSendMethod, InputField, TextArea, ButtonRow, Button, TreeTableSelect } from '../lib/form';
import axios from '../lib/axios'; import axios from '../lib/axios';
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling'; import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
import interoperableErrors from '../../../shared/interoperable-errors'; import interoperableErrors from '../../../shared/interoperable-errors';
import { ModalDialog } from '../lib/bootstrap-components'; import {DeleteModalDialog} from "../lib/delete";
@translate() @translate()
@withForm @withForm
@ -171,41 +171,19 @@ export default class CUD extends Component {
} }
} }
async showDeleteModal() { async onDeleteError(error) {
this.navigateTo(`/namespaces/edit/${this.state.entityId}/delete`); if (error instanceof interoperableErrors.ChildDetectedError) {
}
async hideDeleteModal() {
this.navigateTo(`/namespaces/edit/${this.state.entityId}`);
}
async performDelete() {
const t = this.props.t;
await this.hideDeleteModal();
try {
this.disableForm(); this.disableForm();
this.setFormStatusMessage('info', t('Deleting namespace...')); this.setFormStatusMessage('danger',
<span>
await axios.delete(`/rest/namespaces/${this.state.entityId}`); <strong>{t('The namespace cannot be deleted.')}</strong>{' '}
{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.')}
this.navigateToWithFlashMessage('/namespaces', 'success', t('Namespace deleted')); </span>
);
} catch (error) { return;
if (error instanceof interoperableErrors.ChildDetectedError) {
this.disableForm();
this.setFormStatusMessage('danger',
<span>
<strong>{t('The namespace cannot be deleted.')}</strong>{' '}
{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.')}
</span>
);
return;
}
throw error;
} }
throw error;
} }
render() { render() {
@ -215,12 +193,15 @@ export default class CUD extends Component {
return ( return (
<div> <div>
{!this.isEditGlobal() && !this.hasChildren && edit && {!this.isEditGlobal() && !this.hasChildren && edit &&
<ModalDialog hidden={!this.isDelete()} title={t('Confirm deletion')} onCloseAsync={::this.hideDeleteModal} buttons={[ <DeleteModalDialog
{ label: t('No'), className: 'btn-primary', onClickAsync: ::this.hideDeleteModal }, stateOwner={this}
{ label: t('Yes'), className: 'btn-danger', onClickAsync: ::this.performDelete } visible={this.props.match.params.action === 'delete'}
]}> deleteUrl={`/rest/namespaces/${this.state.entityId}`}
{t('Are you sure you want to delete namespace "{{namespace}}"?', {namespace: this.getFormValue('name')})} cudUrl={`/namespaces/edit/${this.state.entityId}`}
</ModalDialog> listUrl="/namespaces"
deletingMsg={t('Deleting namespace ...')}
deletedMsg={t('Namespace deleted')}
onErrorAsync={::this.onDeleteError}/>
} }
<Title>{edit ? t('Edit Namespace') : t('Create Namespace')}</Title> <Title>{edit ? t('Edit Namespace') : t('Create Namespace')}</Title>
@ -234,8 +215,7 @@ export default class CUD extends Component {
<ButtonRow> <ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/> <Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/>
{!this.isEditGlobal() && !this.hasChildren && edit && <Button className="btn-danger" icon="remove" label={t('Delete Namespace')} {!this.isEditGlobal() && !this.hasChildren && edit && <NavButton className="btn-danger" icon="remove" label={t('Delete')} linkTo={`/namespaces/edit/${this.state.entityId}/delete`}/>}
onClickAsync={::this.showDeleteModal}/>}
</ButtonRow> </ButtonRow>
</Form> </Form>
</div> </div>

View file

@ -2,17 +2,17 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { translate, Trans } from 'react-i18next'; import { translate } from 'react-i18next';
import { requiresAuthenticatedUser, withPageHelpers, Title } from '../lib/page'; import {requiresAuthenticatedUser, withPageHelpers, Title, NavButton} from '../lib/page';
import { import {
withForm, Form, FormSendMethod, InputField, TextArea, TableSelect, TableSelectMode, ButtonRow, Button, withForm, Form, FormSendMethod, InputField, TextArea, TableSelect, TableSelectMode, ButtonRow, Button,
Fieldset Fieldset
} from '../lib/form'; } from '../lib/form';
import axios from '../lib/axios'; import axios from '../lib/axios';
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling'; import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
import { ModalDialog } from '../lib/bootstrap-components';
import moment from 'moment'; import moment from 'moment';
import { validateNamespace, NamespaceSelect } from '../lib/namespace'; import { validateNamespace, NamespaceSelect } from '../lib/namespace';
import {DeleteModalDialog} from "../lib/delete";
@translate() @translate()
@withForm @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() { render() {
const t = this.props.t; const t = this.props.t;
const edit = this.props.edit; const edit = this.props.edit;
@ -248,12 +227,14 @@ export default class CUD extends Component {
return ( return (
<div> <div>
{edit && {edit &&
<ModalDialog hidden={!this.isDelete()} title={t('Confirm deletion')} onCloseAsync={::this.hideDeleteModal} buttons={[ <DeleteModalDialog
{ label: t('No'), className: 'btn-primary', onClickAsync: ::this.hideDeleteModal }, stateOwner={this}
{ label: t('Yes'), className: 'btn-danger', onClickAsync: ::this.performDelete } visible={this.props.match.params.action === 'delete'}
]}> deleteUrl={`/reports/${this.state.entityId}`}
{t('Are you sure you want to delete "{{name}}"?', {name: this.getFormValue('name')})} cudUrl={`/reports/edit/${this.state.entityId}`}
</ModalDialog> listUrl="/reports"
deletingMsg={t('Deleting report ...')}
deletedMsg={t('Report deleted')}/>
} }
<Title>{edit ? t('Edit Report') : t('Create Report')}</Title> <Title>{edit ? t('Edit Report') : t('Create Report')}</Title>
@ -278,7 +259,7 @@ export default class CUD extends Component {
<ButtonRow> <ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/> <Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/>
{edit && <Button className="btn-danger" icon="remove" label={t('Delete Report')} onClickAsync={::this.showDeleteModal}/>} {edit && <NavButton className="btn-danger" icon="remove" label={t('Delete')} linkTo={`/reports/edit/${this.state.entityId}/delete`}/>}
</ButtonRow> </ButtonRow>
</Form> </Form>
</div> </div>

View file

@ -3,12 +3,11 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { translate, Trans } from 'react-i18next'; 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 { 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 { withErrorHandling, withAsyncErrorHandler } from '../../lib/error-handling';
import { ModalDialog } from '../../lib/bootstrap-components';
import { validateNamespace, NamespaceSelect } from '../../lib/namespace'; import { validateNamespace, NamespaceSelect } from '../../lib/namespace';
import {DeleteModalDialog} from "../../lib/delete";
@translate() @translate()
@withForm @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() { render() {
const t = this.props.t; const t = this.props.t;
const edit = this.props.edit; const edit = this.props.edit;
@ -304,12 +282,14 @@ export default class CUD extends Component {
return ( return (
<div> <div>
{edit && {edit &&
<ModalDialog hidden={!this.isDelete()} title={t('Confirm deletion')} onCloseAsync={::this.hideDeleteModal} buttons={[ <DeleteModalDialog
{ label: t('No'), className: 'btn-primary', onClickAsync: ::this.hideDeleteModal }, stateOwner={this}
{ label: t('Yes'), className: 'btn-danger', onClickAsync: ::this.performDelete } visible={this.props.match.params.action === 'delete'}
]}> deleteUrl={`/reports/templates/${this.state.entityId}`}
{t('Are you sure you want to delete report template "{{name}}"?', {name: this.getFormValue('name')})} cudUrl={`/reports/templates/edit/${this.state.entityId}`}
</ModalDialog> listUrl="/reports/templates"
deletingMsg={t('Deleting report template ...')}
deletedMsg={t('Report template deleted')}/>
} }
<Title>{edit ? t('Edit Report Template') : t('Create Report Template')}</Title> <Title>{edit ? t('Edit Report Template') : t('Create Report Template')}</Title>
@ -327,7 +307,7 @@ export default class CUD extends Component {
<ButtonRow> <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 Stay')} onClickAsync={::this.submitAndStay}/>
<Button type="submit" className="btn-primary" icon="ok" label={t('Save and Leave')}/> <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}/> <NavButton className="btn-danger" icon="remove" label={t('Delete')} linkTo={`/reports/templates/edit/${this.state.entityId}/delete`}/>
</ButtonRow> </ButtonRow>
: :
<ButtonRow> <ButtonRow>

View file

@ -3,15 +3,15 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { translate } from 'react-i18next'; 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 { withForm, Form, FormSendMethod, InputField, ButtonRow, Button, TableSelect } from '../lib/form';
import axios from '../lib/axios'; import axios from '../lib/axios';
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling'; import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
import interoperableErrors from '../../../shared/interoperable-errors'; import interoperableErrors from '../../../shared/interoperable-errors';
import passwordValidator from '../../../shared/password-validator'; import passwordValidator from '../../../shared/password-validator';
import { ModalDialog } from '../lib/bootstrap-components';
import mailtrainConfig from 'mailtrainConfig'; import mailtrainConfig from 'mailtrainConfig';
import { validateNamespace, NamespaceSelect } from '../lib/namespace'; import { validateNamespace, NamespaceSelect } from '../lib/namespace';
import {DeleteModalDialog} from "../lib/delete";
@translate() @translate()
@withForm @withForm
@ -79,8 +79,10 @@ export default class CUD extends Component {
if (!username) { if (!username) {
state.setIn(['username', 'error'], t('User name must not be empty')); 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.')); 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 { } else {
state.setIn(['username', 'error'], null); state.setIn(['username', 'error'], null);
} }
@ -92,8 +94,12 @@ export default class CUD extends Component {
if (!email) { if (!email) {
state.setIn(['email', 'error'], t('Email must not be empty')); 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.')); 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 { } else {
state.setIn(['email', 'error'], null); state.setIn(['email', 'error'], null);
} }
@ -222,12 +228,14 @@ export default class CUD extends Component {
return ( return (
<div> <div>
{edit && canDelete && {edit && canDelete &&
<ModalDialog hidden={!this.isDelete()} title={t('Confirm deletion')} onCloseAsync={::this.hideDeleteModal} buttons={[ <DeleteModalDialog
{ label: t('No'), className: 'btn-primary', onClickAsync: ::this.hideDeleteModal }, stateOwner={this}
{ label: t('Yes'), className: 'btn-danger', onClickAsync: ::this.performDelete } visible={this.props.match.params.action === 'delete'}
]}> deleteUrl={`/users/${this.state.entityId}`}
{t('Are you sure you want to delete user "{{username}}"?', {username: this.getFormValue('username')})} cudUrl={`/users/edit/${this.state.entityId}`}
</ModalDialog> listUrl="/users"
deletingMsg={t('Deleting user ...')}
deletedMsg={t('User deleted')}/>
} }
<Title>{edit ? t('Edit User') : t('Create User')}</Title> <Title>{edit ? t('Edit User') : t('Create User')}</Title>
@ -247,8 +255,7 @@ export default class CUD extends Component {
<ButtonRow> <ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/> <Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/>
{edit && canDelete && <Button className="btn-danger" icon="remove" label={t('Delete User')} {edit && canDelete && <NavButton className="btn-danger" icon="remove" label={t('Delete User')} linkTo={`/users/edit/${this.state.entityId}/delete`}/>}
onClickAsync={::this.showDeleteModal}/>}
</ButtonRow> </ButtonRow>
</Form> </Form>
</div> </div>

View file

@ -3,8 +3,9 @@
const passport = require('./passport'); const passport = require('./passport');
const config = require('config'); const config = require('config');
const permissions = require('./permissions'); const permissions = require('./permissions');
const forms = require('../models/forms');
function getAnonymousConfig(context) { async function getAnonymousConfig(context) {
return { return {
authMethod: passport.authMethod, authMethod: passport.authMethod,
isAuthMethodLocal: passport.isAuthMethodLocal, isAuthMethodLocal: passport.isAuthMethodLocal,
@ -14,17 +15,18 @@ function getAnonymousConfig(context) {
} }
} }
function getAuthenticatedConfig(context) { async function getAuthenticatedConfig(context) {
return { return {
defaultCustomFormValues: await forms.getDefaultCustomFormValues(),
userId: context.user.id userId: context.user.id
} }
} }
function registerRootRoute(router, entryPoint, title) { function registerRootRoute(router, entryPoint, title) {
router.get('/*', passport.csrfProtection, (req, res) => { router.getAsync('/*', passport.csrfProtection, async (req, res) => {
const mailtrainConfig = getAnonymousConfig(req.context); const mailtrainConfig = await getAnonymousConfig(req.context);
if (req.user) { if (req.user) {
Object.assign(mailtrainConfig, getAuthenticatedConfig(req.context)); Object.assign(mailtrainConfig, await getAuthenticatedConfig(req.context));
} }
res.render('react-root', { res.render('react-root', {
@ -37,7 +39,7 @@ function registerRootRoute(router, entryPoint, title) {
} }
module.exports = { module.exports = {
registerRootRoute, registerRootRoute,
getAuthenticatedConfig getAuthenticatedConfig
}; };

View file

@ -2,23 +2,26 @@
const knex = require('../lib/knex'); const knex = require('../lib/knex');
const { enforce, filterObject } = require('../lib/helpers'); const { enforce, filterObject } = require('../lib/helpers');
const hasher = require('node-object-hash')();
const dtHelpers = require('../lib/dt-helpers'); const dtHelpers = require('../lib/dt-helpers');
const interoperableErrors = require('../shared/interoperable-errors'); const interoperableErrors = require('../shared/interoperable-errors');
const shares = require('./shares'); const shares = require('./shares');
const namespaceHelpers = require('../lib/namespace-helpers');
const bluebird = require('bluebird'); const bluebird = require('bluebird');
const fs = bluebird.promisifyAll(require('fs')); const fsReadFile = bluebird.promisify(require('fs').readFile);
const path = require('path'); const path = require('path');
const mjml = require('mjml'); const mjml = require('mjml');
const _ = require('../lib/translate')._; const _ = require('../lib/translate')._;
const formAllowedKeys = [ const formAllowedKeys = new Set([
'name', 'name',
'description', 'description',
'layout', 'layout',
'form_input_style' 'form_input_style',
]; 'namespace'
]);
const allowedFormKeys = [ const allowedFormKeys = new Set([
'web_subscribe', 'web_subscribe',
'web_confirm_subscription_notice', 'web_confirm_subscription_notice',
'mail_confirm_subscription_html', 'mail_confirm_subscription_html',
@ -41,10 +44,11 @@ const allowedFormKeys = [
'mail_unsubscription_confirmed_html', 'mail_unsubscription_confirmed_html',
'mail_unsubscription_confirmed_text', 'mail_unsubscription_confirmed_text',
'web_manual_unsubscribe_notice' 'web_manual_unsubscribe_notice'
]; ]);
const hashKeys = [...formAllowedKeys, ...allowedFormKeys]; const hashKeys = new Set([...formAllowedKeys, ...allowedFormKeys]);
const allowedKeysServerValidate = new Set(['layout', ...allowedFormKeys]);
function hash(entity) { function hash(entity) {
return hasher.hash(filterObject(entity, hashKeys)); return hasher.hash(filterObject(entity, hashKeys));
@ -85,7 +89,7 @@ async function getById(context, id) {
let entity; let entity;
await knex.transaction(async tx => { await knex.transaction(async tx => {
entity = _getById(tx, id); entity = await _getById(tx, id);
}); });
return entity; return entity;
@ -95,14 +99,13 @@ async function getById(context, id) {
async function serverValidate(context, data) { async function serverValidate(context, data) {
const result = {}; const result = {};
const form = filterObject(data, allowedFormKeys); const form = filterObject(data, allowedKeysServerValidate);
const errs = checkForMjmlErrors(form); const errs = checkForMjmlErrors(form);
for (const key in form) { for (const key in form) {
result[key] = {}; result[key] = {};
if (errs[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) { async function create(context, entity) {
await shares.enforceEntityPermission(context, 'namespace', 'createCustomForm'); await shares.enforceEntityPermission(context, 'namespace', entity.namespace, 'createCustomForm');
let id; let id;
await knex.transaction(async tx => { 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)); const ids = await tx('custom_forms').insert(filterObject(entity, formAllowedKeys));
id = ids[0]; id = ids[0];
const form = filterObject(entity, allowedFormKeys);
for (const formKey in form) { for (const formKey in form) {
await tx('custom_forms_data').insert({ await tx('custom_forms_data').insert({
form: id, form: id,
@ -126,6 +133,8 @@ async function create(context, entity) {
data_value: form[formKey] data_value: form[formKey]
}) })
} }
await shares.rebuildPermissions(tx, { entityTypeId: 'customForm', entityId: id });
}); });
return id; return id;
@ -135,13 +144,16 @@ async function updateWithConsistencyCheck(context, entity) {
await shares.enforceEntityPermission(context, 'customForm', entity.id, 'edit'); await shares.enforceEntityPermission(context, 'customForm', entity.id, 'edit');
await knex.transaction(async tx => { await knex.transaction(async tx => {
const existing = _getById(tx, context, id); const existing = await _getById(tx, entity.id);
const existingHash = hash(existing); const existingHash = hash(existing);
if (existingHash != entity.originalHash) { if (existingHash != entity.originalHash) {
throw new interoperableErrors.ChangedError(); throw new interoperableErrors.ChangedError();
} }
await namespaceHelpers.validateEntity(tx, entity);
await namespaceHelpers.validateMove(context, entity, existing, 'customForm', 'createCustomForm', 'delete');
const form = filterObject(entity, allowedFormKeys); const form = filterObject(entity, allowedFormKeys);
enforce(!Object.keys(checkForMjmlErrors(form)).length, 'Error(s) in form templates'); 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) { for (const formKey in form) {
await tx('custom_forms_data').update({ await tx('custom_forms_data').update({
form: entity.id,
data_key: formKey,
data_value: form[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) { async function remove(context, id) {
shares.enforceEntityPermission(context, 'customForm', id, 'delete');
await knex.transaction(async tx => { await knex.transaction(async tx => {
const entity = await tx('custom_forms').where('id', id).first(); const entity = await tx('custom_forms').where('id', id).first();
@ -165,20 +182,18 @@ async function remove(context, id) {
throw shares.throwPermissionDenied(); throw shares.throwPermissionDenied();
} }
shares.enforceEntityPermission(context, 'list', entity.list, 'manageForms');
await tx('custom_forms_data').where('form', id).del(); await tx('custom_forms_data').where('form', id).del();
await tx('custom_forms').where('id', id).del(); await tx('custom_forms').where('id', id).del();
}); });
} }
async function getDefaultFormValues() { async function getDefaultCustomFormValues() {
const basePath = path.join(__dirname, '..'); const basePath = path.join(__dirname, '..');
async function getContents(fileName) { async function getContents(fileName) {
try { 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)); return template.replace(/\{\{#translate\}\}(.*?)\{\{\/translate\}\}/g, (m, s) => _(s));
} catch (err) { } catch (err) {
return false; return false;
@ -195,7 +210,7 @@ async function getDefaultFormValues() {
} }
form.layout = await getContents('views/subscription/layout.mjml.hbs') || ''; 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; return form;
} }
@ -224,24 +239,32 @@ function checkForMjmlErrors(form) {
const template = form[key]; const template = form[key];
const errs = hasMjmlError(template); const errs = hasMjmlError(template);
const msgs = errs.map(x => x.formattedMessage);
if (key === 'mail_confirm_html' && !template.includes('{{confirmUrl}}')) { if (key === 'mail_confirm_html' && !template.includes('{{confirmUrl}}')) {
errs.push('Missing {{confirmUrl}}'); msgs.push('Missing {{confirmUrl}}');
} }
if (errs.length) { if (msgs.length) {
errors[key] = errs; errors[key] = msgs;
} }
} else if (key === 'layout') { } else if (key === 'layout') {
const layout = values[index]; const layout = form[key];
const err = hasMjmlError('', layout); const errs = hasMjmlError('', layout);
if (!layout.includes('{{{body}}}')) { let msgs;
errs.push(`{{{body}}} not found`); if (Array.isArray(errs)) {
msgs = errs.map(x => x.formattedMessage)
} else {
msgs = [ errs.message ];
} }
if (errs.length) { if (!layout.includes('{{{body}}}')) {
errors[key] = errs; msgs.push(`{{{body}}} not found`);
}
if (msgs.length) {
errors[key] = msgs;
} }
} }
} }
@ -256,6 +279,6 @@ module.exports = {
create, create,
updateWithConsistencyCheck, updateWithConsistencyCheck,
remove, remove,
getDefaultFormValues, getDefaultCustomFormValues,
serverValidate serverValidate
}; };