Custom forms list and CUD.
This commit is contained in:
parent
f6e1938ff9
commit
361af18384
12 changed files with 1068 additions and 209 deletions
|
@ -27,7 +27,7 @@ export default class Account extends Component {
|
|||
this.initForm({
|
||||
serverValidation: {
|
||||
url: '/rest/account-validate',
|
||||
changed: ['email', 'username', 'currentPassword']
|
||||
changed: ['email', 'currentPassword']
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -53,10 +53,12 @@ export default class Account 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) {
|
||||
} 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);
|
||||
}
|
||||
|
@ -86,8 +88,10 @@ export default class Account extends Component {
|
|||
|
||||
if (!currentPassword) {
|
||||
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.'));
|
||||
} else if (!currentPasswordServerValidation) {
|
||||
state.setIn(['email', 'error'], t('Validation is in progress...'));
|
||||
} else {
|
||||
state.setIn(['currentPassword', 'error'], null);
|
||||
}
|
||||
|
|
65
client/src/lib/delete.js
Normal file
65
client/src/lib/delete.js
Normal 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
|
||||
}
|
|
@ -244,7 +244,8 @@ class Dropdown extends Component {
|
|||
id: PropTypes.string.isRequired,
|
||||
label: PropTypes.string.isRequired,
|
||||
help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
|
||||
options: PropTypes.array.isRequired
|
||||
options: PropTypes.array,
|
||||
optGroups: PropTypes.array
|
||||
}
|
||||
|
||||
static contextTypes = {
|
||||
|
@ -257,7 +258,18 @@ class Dropdown extends Component {
|
|||
const owner = this.context.formStateOwner;
|
||||
const id = this.props.id;
|
||||
const htmlId = 'form_' + id;
|
||||
const options = props.options.map(option => <option key={option.key} value={option.key}>{option.label}</option>);
|
||||
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,
|
||||
<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 {
|
||||
static propTypes = {
|
||||
className: PropTypes.string
|
||||
className: PropTypes.string,
|
||||
label: PropTypes.string,
|
||||
htmlId: PropTypes.string
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
|
@ -278,13 +292,25 @@ class AlignedRow extends Component {
|
|||
}
|
||||
|
||||
render() {
|
||||
if (this.props.label) {
|
||||
return (
|
||||
<div className="form-group">
|
||||
<div className={"col-sm-10 col-sm-offset-2 " + this.props.className}>
|
||||
<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>
|
||||
);
|
||||
|
||||
} 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 {
|
||||
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',
|
||||
<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 {
|
||||
withForm,
|
||||
|
|
|
@ -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 (
|
||||
<div>
|
||||
{edit &&
|
||||
<ModalDialog hidden={!this.isDelete()} title={t('Confirm deletion')} onCloseAsync={::this.hideDeleteModal} buttons={[
|
||||
{ label: t('No'), className: 'btn-primary', onClickAsync: ::this.hideDeleteModal },
|
||||
{ label: t('Yes'), className: 'btn-danger', onClickAsync: ::this.performDelete }
|
||||
]}>
|
||||
{t('Are you sure you want to delete "{{name}}"?', {name: this.getFormValue('name')})}
|
||||
</ModalDialog>
|
||||
<DeleteModalDialog
|
||||
stateOwner={this}
|
||||
visible={this.props.match.params.action === 'delete'}
|
||||
deleteUrl={`/rest/lists/${this.state.entityId}`}
|
||||
cudUrl={`/lists/edit/${this.state.entityId}`}
|
||||
listUrl="/lists"
|
||||
deletingMsg={t('Deleting list ...')}
|
||||
deletedMsg={t('List deleted')}/>
|
||||
}
|
||||
|
||||
<Title>{edit ? t('Edit List') : t('Create List')}</Title>
|
||||
|
@ -219,7 +194,7 @@ export default class CUD extends Component {
|
|||
|
||||
<ButtonRow>
|
||||
<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>
|
||||
</Form>
|
||||
</div>
|
||||
|
|
450
client/src/lists/forms/CUD.js
Normal file
450
client/src/lists/forms/CUD.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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 => (<Share title={entity => 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 => (<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} />)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,28 +171,7 @@ 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 {
|
||||
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) {
|
||||
async onDeleteError(error) {
|
||||
if (error instanceof interoperableErrors.ChildDetectedError) {
|
||||
this.disableForm();
|
||||
this.setFormStatusMessage('danger',
|
||||
|
@ -206,7 +185,6 @@ export default class CUD extends Component {
|
|||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const t = this.props.t;
|
||||
|
@ -215,12 +193,15 @@ export default class CUD extends Component {
|
|||
return (
|
||||
<div>
|
||||
{!this.isEditGlobal() && !this.hasChildren && edit &&
|
||||
<ModalDialog hidden={!this.isDelete()} title={t('Confirm deletion')} onCloseAsync={::this.hideDeleteModal} buttons={[
|
||||
{ label: t('No'), className: 'btn-primary', onClickAsync: ::this.hideDeleteModal },
|
||||
{ label: t('Yes'), className: 'btn-danger', onClickAsync: ::this.performDelete }
|
||||
]}>
|
||||
{t('Are you sure you want to delete namespace "{{namespace}}"?', {namespace: this.getFormValue('name')})}
|
||||
</ModalDialog>
|
||||
<DeleteModalDialog
|
||||
stateOwner={this}
|
||||
visible={this.props.match.params.action === 'delete'}
|
||||
deleteUrl={`/rest/namespaces/${this.state.entityId}`}
|
||||
cudUrl={`/namespaces/edit/${this.state.entityId}`}
|
||||
listUrl="/namespaces"
|
||||
deletingMsg={t('Deleting namespace ...')}
|
||||
deletedMsg={t('Namespace deleted')}
|
||||
onErrorAsync={::this.onDeleteError}/>
|
||||
}
|
||||
|
||||
<Title>{edit ? t('Edit Namespace') : t('Create Namespace')}</Title>
|
||||
|
@ -234,8 +215,7 @@ export default class CUD extends Component {
|
|||
|
||||
<ButtonRow>
|
||||
<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')}
|
||||
onClickAsync={::this.showDeleteModal}/>}
|
||||
{!this.isEditGlobal() && !this.hasChildren && edit && <NavButton className="btn-danger" icon="remove" label={t('Delete')} linkTo={`/namespaces/edit/${this.state.entityId}/delete`}/>}
|
||||
</ButtonRow>
|
||||
</Form>
|
||||
</div>
|
||||
|
|
|
@ -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 (
|
||||
<div>
|
||||
{edit &&
|
||||
<ModalDialog hidden={!this.isDelete()} title={t('Confirm deletion')} onCloseAsync={::this.hideDeleteModal} buttons={[
|
||||
{ label: t('No'), className: 'btn-primary', onClickAsync: ::this.hideDeleteModal },
|
||||
{ label: t('Yes'), className: 'btn-danger', onClickAsync: ::this.performDelete }
|
||||
]}>
|
||||
{t('Are you sure you want to delete "{{name}}"?', {name: this.getFormValue('name')})}
|
||||
</ModalDialog>
|
||||
<DeleteModalDialog
|
||||
stateOwner={this}
|
||||
visible={this.props.match.params.action === 'delete'}
|
||||
deleteUrl={`/reports/${this.state.entityId}`}
|
||||
cudUrl={`/reports/edit/${this.state.entityId}`}
|
||||
listUrl="/reports"
|
||||
deletingMsg={t('Deleting report ...')}
|
||||
deletedMsg={t('Report deleted')}/>
|
||||
}
|
||||
|
||||
<Title>{edit ? t('Edit Report') : t('Create Report')}</Title>
|
||||
|
@ -278,7 +259,7 @@ export default class CUD extends Component {
|
|||
|
||||
<ButtonRow>
|
||||
<Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/>
|
||||
{edit && <Button className="btn-danger" icon="remove" label={t('Delete Report')} onClickAsync={::this.showDeleteModal}/>}
|
||||
{edit && <NavButton className="btn-danger" icon="remove" label={t('Delete')} linkTo={`/reports/edit/${this.state.entityId}/delete`}/>}
|
||||
</ButtonRow>
|
||||
</Form>
|
||||
</div>
|
||||
|
|
|
@ -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 (
|
||||
<div>
|
||||
{edit &&
|
||||
<ModalDialog hidden={!this.isDelete()} title={t('Confirm deletion')} onCloseAsync={::this.hideDeleteModal} buttons={[
|
||||
{ label: t('No'), className: 'btn-primary', onClickAsync: ::this.hideDeleteModal },
|
||||
{ label: t('Yes'), className: 'btn-danger', onClickAsync: ::this.performDelete }
|
||||
]}>
|
||||
{t('Are you sure you want to delete report template "{{name}}"?', {name: this.getFormValue('name')})}
|
||||
</ModalDialog>
|
||||
<DeleteModalDialog
|
||||
stateOwner={this}
|
||||
visible={this.props.match.params.action === 'delete'}
|
||||
deleteUrl={`/reports/templates/${this.state.entityId}`}
|
||||
cudUrl={`/reports/templates/edit/${this.state.entityId}`}
|
||||
listUrl="/reports/templates"
|
||||
deletingMsg={t('Deleting report template ...')}
|
||||
deletedMsg={t('Report template deleted')}/>
|
||||
}
|
||||
|
||||
<Title>{edit ? t('Edit Report Template') : t('Create Report Template')}</Title>
|
||||
|
@ -327,7 +307,7 @@ export default class CUD extends Component {
|
|||
<ButtonRow>
|
||||
<Button type="submit" className="btn-primary" icon="ok" label={t('Save and Stay')} onClickAsync={::this.submitAndStay}/>
|
||||
<Button type="submit" className="btn-primary" icon="ok" label={t('Save and Leave')}/>
|
||||
<Button className="btn-danger" icon="remove" label={t('Delete Template')} onClickAsync={::this.showDeleteModal}/>
|
||||
<NavButton className="btn-danger" icon="remove" label={t('Delete')} linkTo={`/reports/templates/edit/${this.state.entityId}/delete`}/>
|
||||
</ButtonRow>
|
||||
:
|
||||
<ButtonRow>
|
||||
|
|
|
@ -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 (
|
||||
<div>
|
||||
{edit && canDelete &&
|
||||
<ModalDialog hidden={!this.isDelete()} title={t('Confirm deletion')} onCloseAsync={::this.hideDeleteModal} buttons={[
|
||||
{ label: t('No'), className: 'btn-primary', onClickAsync: ::this.hideDeleteModal },
|
||||
{ label: t('Yes'), className: 'btn-danger', onClickAsync: ::this.performDelete }
|
||||
]}>
|
||||
{t('Are you sure you want to delete user "{{username}}"?', {username: this.getFormValue('username')})}
|
||||
</ModalDialog>
|
||||
<DeleteModalDialog
|
||||
stateOwner={this}
|
||||
visible={this.props.match.params.action === 'delete'}
|
||||
deleteUrl={`/users/${this.state.entityId}`}
|
||||
cudUrl={`/users/edit/${this.state.entityId}`}
|
||||
listUrl="/users"
|
||||
deletingMsg={t('Deleting user ...')}
|
||||
deletedMsg={t('User deleted')}/>
|
||||
}
|
||||
|
||||
<Title>{edit ? t('Edit User') : t('Create User')}</Title>
|
||||
|
@ -247,8 +255,7 @@ export default class CUD extends Component {
|
|||
|
||||
<ButtonRow>
|
||||
<Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/>
|
||||
{edit && canDelete && <Button className="btn-danger" icon="remove" label={t('Delete User')}
|
||||
onClickAsync={::this.showDeleteModal}/>}
|
||||
{edit && canDelete && <NavButton className="btn-danger" icon="remove" label={t('Delete User')} linkTo={`/users/edit/${this.state.entityId}/delete`}/>}
|
||||
</ButtonRow>
|
||||
</Form>
|
||||
</div>
|
||||
|
|
|
@ -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', {
|
||||
|
|
|
@ -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
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue