+ }
+
+ if (format === 'inline') {
+ return (
+
+ {labelBlock}{input}
+ {helpBlock}
+ {validationBlock}
+
+ );
+ } else {
+ return (
+
+ {labelBlock}
+
+ {input}
+ {helpBlock}
+ {validationBlock}
+
+
+ );
+ }
+}
+
+@withComponentMixins([
+ withFormStateOwner
+])
+class StaticField extends Component {
+ static propTypes = {
+ id: PropTypes.string.isRequired,
+ label: PropTypes.string,
+ help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
+ className: PropTypes.string,
+ format: PropTypes.string,
+ withValidation: PropTypes.bool
+ }
+
+ render() {
+ const props = this.props;
+ const owner = this.getFormStateOwner();
+ const id = this.props.id;
+ const htmlId = 'form_' + id;
+
+ let className = 'form-control';
+
+ if (props.withValidation) {
+ className = owner.addFormValidationClass(className, id);
+ }
+
+ if (props.className) {
+ className += ' ' + props.className;
+ }
+
+ return wrapInput(props.withValidation ? id : null, htmlId, owner, props.format, '', props.label, props.help,
+
{props.children}
+ );
+ }
+}
+
+@withComponentMixins([
+ withFormStateOwner
+])
+class InputField extends Component {
+ static propTypes = {
+ id: PropTypes.string.isRequired,
+ label: PropTypes.string,
+ placeholder: PropTypes.string,
+ type: PropTypes.string,
+ help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
+ format: PropTypes.string
+ }
+
+ static defaultProps = {
+ type: 'text'
+ }
+
+ render() {
+ const props = this.props;
+ const owner = this.getFormStateOwner();
+ const id = this.props.id;
+ const htmlId = 'form_' + id;
+
+ let type = 'text';
+ if (props.type === 'password') {
+ type = 'password';
+ } else if (props.type === 'hidden') {
+ type = 'hidden';
+ }
+
+ const className = owner.addFormValidationClass('form-control', id);
+
+ /* This is for debugging purposes when React reports that InputField is uncontrolled
+ const value = owner.getFormValue(id);
+ if (value === null || value === undefined) console.log(`Warning: InputField ${id} is ${value}`);
+ */
+ const value = owner.getFormValue(id);
+ if (value === null || value === undefined) console.log(`Warning: InputField ${id} is ${value}`);
+
+ return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help,
+
owner.updateFormValue(id, evt.target.value)}/>
+ );
+ }
+}
+
+class CheckBox extends Component {
+ static propTypes = {
+ id: PropTypes.string.isRequired,
+ text: PropTypes.string,
+ label: PropTypes.string,
+ help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
+ format: PropTypes.string,
+ className: PropTypes.string
+ }
+
+ render() {
+ return (
+
+ {
+ owner => {
+ const props = this.props;
+ const id = this.props.id;
+ const htmlId = 'form_' + id;
+
+ const inputClassName = owner.addFormValidationClass('form-check-input', id);
+
+ return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help,
+
+ owner.updateFormValue(id, !owner.getFormValue(id))}/>
+ {props.text}
+
+ );
+ }
+
+ }
+
+ );
+
+ }
+}
+
+@withComponentMixins([
+ withFormStateOwner
+])
+class CheckBoxGroup extends Component {
+ static propTypes = {
+ id: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
+ options: PropTypes.array,
+ className: PropTypes.string,
+ format: PropTypes.string
+ }
+
+ onChange(key) {
+ const id = this.props.id;
+ const owner = this.getFormStateOwner();
+ const existingSelection = owner.getFormValue(id);
+
+ let newSelection;
+ if (existingSelection.includes(key)) {
+ newSelection = existingSelection.filter(x => x !== key);
+ } else {
+ newSelection = [key, ...existingSelection];
+ }
+ owner.updateFormValue(id, newSelection.sort());
+ }
+
+ render() {
+ const props = this.props;
+
+ const owner = this.getFormStateOwner();
+ const id = this.props.id;
+ const htmlId = 'form_' + id;
+
+ const selection = owner.getFormValue(id);
+
+ const options = [];
+ for (const option of props.options) {
+ const optClassName = owner.addFormValidationClass('form-check-input', id);
+ const optId = htmlId + '_' + option.key;
+
+ let number = options.push(
+
+ this.onChange(option.key)}/>
+ {option.label}
+
+ );
+ }
+
+ let className = 'form-control';
+ if (props.className) {
+ className += ' ' + props.className;
+ }
+
+ return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help,
+
+ {options}
+
+ );
+ }
+}
+
+@withComponentMixins([
+ withFormStateOwner
+])
+class RadioGroup extends Component {
+ static propTypes = {
+ id: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
+ options: PropTypes.array,
+ className: PropTypes.string,
+ format: PropTypes.string
+ }
+
+ render() {
+ const props = this.props;
+
+ const owner = this.getFormStateOwner();
+ const id = this.props.id;
+ const htmlId = 'form_' + id;
+
+ const value = owner.getFormValue(id);
+
+ const options = [];
+ for (const option of props.options) {
+ const optClassName = owner.addFormValidationClass('form-check-input', id);
+ const optId = htmlId + '_' + option.key;
+
+ let number = options.push(
+
+ owner.updateFormValue(id, option.key)}/>
+ {option.label}
+
+ );
+ }
+
+ let className = 'form-control';
+ if (props.className) {
+ className += ' ' + props.className;
+ }
+
+ return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help,
+
+ {options}
+
+ );
+ }
+}
+
+@withComponentMixins([
+ withFormStateOwner
+])
+class TextArea extends Component {
+ constructor() {
+ super();
+ this.onChange = evt => {
+ const id = this.props.id;
+ const owner = this.getFormStateOwner();
+ owner.updateFormValue(id, evt.target.value);
+ }
+ }
+
+ static propTypes = {
+ id: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ placeholder: PropTypes.string,
+ help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
+ format: PropTypes.string,
+ className: PropTypes.string
+ }
+
+ render() {
+ const props = this.props;
+ const owner = this.getFormStateOwner();
+ const id = props.id;
+ const htmlId = 'form_' + id;
+ const className = owner.addFormValidationClass('form-control ' + (props.className || '') , id);
+
+ return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help,
+
+ );
+ }
+}
+
+
+@withComponentMixins([
+ withFormStateOwner
+])
+class ColorPicker extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ opened: false
+ };
+ }
+
+ static propTypes = {
+ id: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
+ }
+
+ toggle() {
+ this.setState({
+ opened: !this.state.opened
+ });
+ }
+
+ selected(value) {
+ const owner = this.getFormStateOwner();
+ const id = this.props.id;
+
+ this.setState({
+ opened: false
+ });
+
+ owner.updateFormValue(id, value.rgb);
+ }
+
+ render() {
+ const props = this.props;
+ const owner = this.getFormStateOwner();
+ const id = this.props.id;
+ const htmlId = 'form_' + id;
+ const t = props.t;
+ const color = owner.getFormValue(id);
+
+ return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help,
+
+
+ {this.state.opened &&
+
+
+
+ }
+
+ );
+ }
+}
+
+@withComponentMixins([
+ withTranslation,
+ withFormStateOwner
+])
+class DatePicker extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ opened: false
+ };
+ }
+
+ static propTypes = {
+ id: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
+ format: PropTypes.string,
+ birthday: PropTypes.bool,
+ dateFormat: PropTypes.string,
+ formatDate: PropTypes.func,
+ parseDate: PropTypes.func
+ }
+
+ static defaultProps = {
+ dateFormat: DateFormat.INTL
+ }
+
+ async toggleDayPicker() {
+ this.setState({
+ opened: !this.state.opened
+ });
+ }
+
+ daySelected(date) {
+ const owner = this.getFormStateOwner();
+ const id = this.props.id;
+ const props = this.props;
+
+ if (props.formatDate) {
+ owner.updateFormValue(id, props.formatDate(date));
+ } else {
+ owner.updateFormValue(id, props.birthday ? formatBirthday(props.dateFormat, date) : formatDate(props.dateFormat, date));
+ }
+
+ this.setState({
+ opened: false
+ });
+ }
+
+ render() {
+ const props = this.props;
+ const owner = this.getFormStateOwner();
+ const id = this.props.id;
+ const htmlId = 'form_' + id;
+ const t = props.t;
+
+ function BirthdayPickerCaption({ date, localeUtils, onChange }) {
+ const months = localeUtils.getMonths();
+ return (
+
+ {months[date.getMonth()]}
+
+ );
+ }
+
+ let selectedDate, captionElement, fromMonth, toMonth, placeholder;
+ const selectedDateStr = owner.getFormValue(id) || '';
+ if (props.birthday) {
+ if (props.parseDate) {
+ selectedDate = props.parseDate(selectedDateStr);
+ if (selectedDate) {
+ selectedDate = moment(selectedDate).set('year', birthdayYear).toDate();
+ }
+ } else {
+ selectedDate = parseBirthday(props.dateFormat, selectedDateStr);
+ }
+
+ if (!selectedDate) {
+ selectedDate = moment().set('year', birthdayYear).toDate();
+ }
+
+ captionElement =
;
+ fromMonth = new Date(birthdayYear, 0, 1);
+ toMonth = new Date(birthdayYear, 11, 31);
+ placeholder = getBirthdayFormatString(props.dateFormat);
+
+ } else {
+ if (props.parseDate) {
+ selectedDate = props.parseDate(selectedDateStr);
+ } else {
+ selectedDate = parseDate(props.dateFormat, selectedDateStr);
+ }
+
+ if (!selectedDate) {
+ selectedDate = moment().toDate();
+ }
+
+ placeholder = getDateFormatString(props.dateFormat);
+ }
+
+ const className = owner.addFormValidationClass('form-control', id);
+
+ return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help,
+ <>
+
+
owner.updateFormValue(id, evt.target.value)}/>
+
+
+
+
+ {this.state.opened &&
+
+ this.daySelected(date)}
+ selectedDays={selectedDate}
+ initialMonth={selectedDate}
+ fromMonth={fromMonth}
+ toMonth={toMonth}
+ captionElement={captionElement}
+ />
+
+ }
+ >
+ );
+ }
+}
+
+
+@withComponentMixins([
+ withFormStateOwner
+])
+class Dropdown extends Component {
+ static propTypes = {
+ id: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
+ options: PropTypes.array,
+ className: PropTypes.string,
+ format: PropTypes.string,
+ disabled: PropTypes.bool
+ }
+
+ render() {
+ const props = this.props;
+
+ const owner = this.getFormStateOwner();
+ const id = this.props.id;
+ const htmlId = 'form_' + id;
+ const options = [];
+
+ if (this.props.options) {
+ for (const optOrGrp of props.options) {
+ if (optOrGrp.options) {
+ options.push(
+
+ {optOrGrp.options.map(opt => {opt.label} )}
+
+ )
+ } else {
+ options.push(
{optOrGrp.label} )
+ }
+ }
+ }
+
+ const className = owner.addFormValidationClass('form-control ' + (props.className || '') , id);
+
+ return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help,
+
owner.updateFormValue(id, evt.target.value)} disabled={props.disabled}>
+ {options}
+
+ );
+ }
+}
+
+@withComponentMixins([
+ withFormStateOwner
+])
+class AlignedRow extends Component {
+ static propTypes = {
+ className: PropTypes.string,
+ label: PropTypes.string,
+ htmlId: PropTypes.string,
+ format: PropTypes.string
+ }
+
+ static defaultProps = {
+ className: ''
+ }
+
+ render() {
+ const props = this.props;
+ const owner = this.getFormStateOwner();
+
+ return wrapInput(null, props.htmlId, owner, props.format, props.className, props.label, null, this.props.children);
+ }
+}
+
+
+class ButtonRow extends Component {
+ static propTypes = {
+ className: PropTypes.string,
+ format: PropTypes.string
+ }
+
+ render() {
+ let className = styles.buttonRow;
+ if (this.props.className) {
+ className += ' ' + this.props.className;
+ }
+
+ return (
+
{this.props.children}
+ );
+ }
+}
+
+
+@withComponentMixins([
+ withFormStateOwner
+])
+class TreeTableSelect extends Component {
+ static propTypes = {
+ id: PropTypes.string.isRequired,
+ label: PropTypes.string,
+ dataUrl: PropTypes.string,
+ data: PropTypes.array,
+ help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
+ format: PropTypes.string,
+ }
+
+ async onSelectionChangedAsync(sel) {
+ const owner = this.getFormStateOwner();
+ owner.updateFormValue(this.props.id, sel);
+ }
+
+ render() {
+ const props = this.props;
+ const owner = this.getFormStateOwner();
+ const id = this.props.id;
+ const htmlId = 'form_' + id;
+
+ const className = owner.addFormValidationClass('' , id);
+
+ return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help,
+
+ );
+ }
+}
+
+@withComponentMixins([
+ withTranslation,
+ withFormStateOwner
+], ['refresh'])
+class TableSelect extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ selectedLabel: '',
+ open: false
+ };
+ }
+
+ static propTypes = {
+ dataUrl: PropTypes.string,
+ data: PropTypes.array,
+ columns: PropTypes.array,
+ selectionKeyIndex: PropTypes.number,
+ selectionLabelIndex: PropTypes.number,
+ selectionAsArray: PropTypes.bool,
+ selectMode: PropTypes.number,
+ withHeader: PropTypes.bool,
+ dropdown: PropTypes.bool,
+
+ id: PropTypes.string.isRequired,
+ label: PropTypes.string,
+ help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
+ format: PropTypes.string,
+ disabled: PropTypes.bool,
+
+ pageLength: PropTypes.number
+ }
+
+ static defaultProps = {
+ selectMode: TableSelectMode.SINGLE,
+ selectionLabelIndex: 0,
+ pageLength: 10
+ }
+
+ async onSelectionChangedAsync(sel, data) {
+ if (this.props.selectMode === TableSelectMode.SINGLE && this.props.dropdown) {
+ this.setState({
+ open: false
+ });
+ }
+
+ const owner = this.getFormStateOwner();
+ owner.updateFormValue(this.props.id, sel);
+ }
+
+ async onSelectionDataAsync(sel, data) {
+ if (this.props.dropdown) {
+ let label;
+
+ if (!data) {
+ label = '';
+ } else if (this.props.selectMode === TableSelectMode.SINGLE && !this.props.selectionAsArray) {
+ label = data[this.props.selectionLabelIndex];
+ } else {
+ label = data.map(entry => entry[this.props.selectionLabelIndex]).join('; ');
+ }
+
+ this.setState({
+ selectedLabel: label
+ });
+ }
+ }
+
+ async toggleOpen() {
+ this.setState({
+ open: !this.state.open
+ });
+ }
+
+ refresh() {
+ this.table.refresh();
+ }
+
+ render() {
+ const props = this.props;
+ const owner = this.getFormStateOwner();
+ const id = this.props.id;
+ const htmlId = 'form_' + id;
+ const t = props.t;
+
+ if (props.dropdown) {
+ const className = owner.addFormValidationClass('form-control' , id);
+
+ return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help,
+
+
+
+ {!props.disabled &&
+
+
+
+ }
+
+
+
this.table = node} data={props.data} dataUrl={props.dataUrl} columns={props.columns} selectMode={props.selectMode} selectionAsArray={this.props.selectionAsArray} withHeader={props.withHeader} selectionKeyIndex={props.selectionKeyIndex} selection={owner.getFormValue(id)} onSelectionDataAsync={::this.onSelectionDataAsync} onSelectionChangedAsync={::this.onSelectionChangedAsync}/>
+
+
+ );
+ } else {
+ return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help,
+
+
+
this.table = node} data={props.data} dataUrl={props.dataUrl} columns={props.columns} pageLength={props.pageLength} selectMode={props.selectMode} selectionAsArray={this.props.selectionAsArray} withHeader={props.withHeader} selectionKeyIndex={props.selectionKeyIndex} selection={owner.getFormValue(id)} onSelectionChangedAsync={::this.onSelectionChangedAsync}/>
+
+
+ );
+ }
+ }
+}
+
+
+@withComponentMixins([
+ withFormStateOwner
+])
+class ACEEditor extends Component {
+ static propTypes = {
+ id: PropTypes.string.isRequired,
+ label: PropTypes.string,
+ help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
+ height: PropTypes.string,
+ mode: PropTypes.string,
+ format: PropTypes.string
+ }
+
+ render() {
+ const props = this.props;
+ const owner = this.getFormStateOwner();
+ const id = this.props.id;
+ const htmlId = 'form_' + id;
+
+ return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help,
+ owner.updateFormValue(id, data)}
+ fontSize={12}
+ width="100%"
+ height={props.height}
+ showPrintMargin={false}
+ value={owner.getFormValue(id)}
+ tabSize={2}
+ setOptions={{useWorker: false}} // This disables syntax check because it does not always work well (e.g. in case of JS code in report templates)
+ />
+ );
+ }
+}
+
+
+const withForm = createComponentMixin({
+ decoratorFn: (TargetClass, InnerClass) => {
+ const proto = InnerClass.prototype;
+
+ const cleanFormState = Immutable.Map({
+ state: FormState.Loading,
+ isValidationShown: false,
+ isDisabled: false,
+ statusMessageText: '',
+ data: Immutable.Map(),
+ savedData: Immutable.Map(),
+ isServerValidationRunning: false
+ });
+
+ const getSaveData = (self, formStateData) => {
+ let data = formStateData.map(attr => attr.get('value')).toJS();
+
+ if (self.submitFormValuesMutator) {
+ const newData = self.submitFormValuesMutator(data, false);
+ if (newData !== undefined) {
+ data = newData;
+ }
+ }
+
+ return data;
+ };
+
+ // 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 || []) {
+ if (typeof attr === 'string') {
+ payload[attr] = mutState.getIn(['data', attr, 'value']);
+ } else {
+ const data = mutState.get('data').map(attr => attr.get('value')).toJS();
+ payload[attr.key] = attr.data(data);
+ }
+ }
+
+ 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(getUrl(settings.serverValidation.url), payload)
+ .then(response => {
+
+ if (self.isComponentMounted()) {
+ 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 => {
+ if (self.isComponentMounted()) {
+ 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);
+ }));
+ }
+ }
+
+ const previousComponentDidMount = proto.componentDidMount;
+ proto.componentDidMount = function () {
+ this._isComponentMounted = true;
+ if (previousComponentDidMount) {
+ previousComponentDidMount.apply(this);
+ }
+ };
+
+ const previousComponentWillUnmount = proto.componentWillUnmount;
+ proto.componentWillUnmount = function () {
+ this._isComponentMounted = false;
+ if (previousComponentWillUnmount) {
+ previousComponentDidMount.apply(this);
+ }
+ };
+
+ proto.isComponentMounted = function () {
+ return !!this._isComponentMounted;
+ }
+
+ proto.initForm = function (settings) {
+ const state = this.state || {};
+ state.formState = cleanFormState;
+ state.formSettings = {
+ leaveConfirmation: true,
+ ...(settings || {})
+ };
+ this.state = state;
+ };
+
+ proto.resetFormState = function () {
+ this.setState({
+ formState: cleanFormState
+ });
+ };
+
+ proto.getFormValuesFromEntity = function (entity) {
+ const settings = this.state.formSettings;
+ const data = Object.assign({}, entity);
+
+ data.originalHash = data.hash;
+ delete data.hash;
+
+ if (this.getFormValuesMutator) {
+ this.getFormValuesMutator(data, this.getFormValues());
+ }
+
+ this.populateFormValues(data);
+ };
+
+ proto.getFormValuesFromURL = async function (url) {
+ const settings = this.state.formSettings;
+ 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(getUrl(url));
+
+ let data = response.data;
+
+ data.originalHash = data.hash;
+ delete data.hash;
+
+ if (this.getFormValuesMutator) {
+ const newData = this.getFormValuesMutator(data, this.getFormValues());
+
+ if (newData !== undefined) {
+ data = newData;
+ }
+ }
+
+ this.populateFormValues(data);
+ };
+
+ proto.validateAndSendFormValuesToURL = async function (method, url) {
+ const settings = this.state.formSettings;
+ await this.waitForFormServerValidated();
+
+ if (this.isFormWithoutErrors()) {
+ if (settings.getPreSubmitUpdater) {
+ const preSubmitUpdater = await settings.getPreSubmitUpdater();
+
+ await new Promise((resolve, reject) => {
+ this.setState(previousState => ({
+ formState: previousState.formState.withMutations(mutState => {
+ mutState.update('data', stateData => stateData.withMutations(preSubmitUpdater));
+ })
+ }), resolve);
+ });
+ }
+
+ let data = this.getFormValues();
+
+ if (this.submitFormValuesMutator) {
+ const newData = this.submitFormValuesMutator(data, true);
+ if (newData !== undefined) {
+ data = newData;
+ }
+ }
+
+ const response = await axios.method(method, getUrl(url), data);
+
+ if (settings.leaveConfirmation) {
+ await new Promise((resolve, reject) => {
+ this.setState(previousState => ({
+ formState: previousState.formState.set('savedData', getSaveData(this, previousState.formState.get('data')))
+ }), resolve);
+ });
+ }
+
+ return response.data || true;
+
+ } else {
+ this.showFormValidation();
+ return false;
+ }
+ };
+
+
+ proto.populateFormValues = function (data) {
+ const settings = this.state.formSettings;
+
+ 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]
+ }));
+ }
+ }));
+
+ if (settings.leaveConfirmation) {
+ mutState.set('savedData', getSaveData(this, mutState.get('data')));
+ }
+
+ validateFormState(this, mutState);
+ })
+ }));
+ };
+
+ proto.waitForFormServerValidated = async function () {
+ if (!this.isFormServerValidated()) {
+ await new Promise(resolve => {
+ formValidateResolve = resolve;
+ });
+ }
+ };
+
+ proto.scheduleFormRevalidate = function () {
+ scheduleValidateForm(this);
+ };
+
+ proto.updateForm = function (mutator) {
+ this.setState(previousState => {
+ const onChangeBeforeValidationCallback = this.state.formSettings.onChangeBeforeValidation || {};
+
+ const formState = previousState.formState.withMutations(mutState => {
+ mutState.update('data', stateData => stateData.withMutations(mutStateData => {
+ mutator(mutStateData);
+
+ if (typeof onChangeBeforeValidationCallback === 'object') {
+ for (const key in onChangeBeforeValidationCallback) {
+ const oldValue = previousState.formState.getIn(['data', key, 'value']);
+ const newValue = mutStateData.getIn([key, 'value']);
+ onChangeBeforeValidationCallback[key](mutStateData, key, oldValue, newValue);
+ }
+ } else {
+ onChangeBeforeValidationCallback(mutStateData);
+ }
+ }));
+
+ validateFormState(this, mutState);
+ });
+
+ let newState = {
+ formState
+ };
+
+
+ const onChangeCallback = this.state.formSettings.onChange || {};
+
+ if (typeof onChangeCallback === 'object') {
+ for (const key in onChangeCallback) {
+ const oldValue = previousState.formState.getIn(['data', key, 'value']);
+ const newValue = formState.getIn(['data', key, 'value']);
+ onChangeCallback[key](newState, key, oldValue, newValue);
+ }
+ } else {
+ onChangeCallback(newState);
+ }
+
+ return newState;
+ });
+ };
+
+ proto.updateFormValue = function (key, value) {
+ this.setState(previousState => {
+ const oldValue = previousState.formState.getIn(['data', key, 'value']);
+
+ const onChangeBeforeValidationCallback = this.state.formSettings.onChangeBeforeValidation || {};
+
+ const formState = previousState.formState.withMutations(mutState => {
+ mutState.update('data', stateData => stateData.withMutations(mutStateData => {
+ mutStateData.setIn([key, 'value'], value);
+
+ if (typeof onChangeBeforeValidationCallback === 'object') {
+ if (onChangeBeforeValidationCallback[key]) {
+ onChangeBeforeValidationCallback[key](mutStateData, key, oldValue, value);
+ }
+ } else {
+ onChangeBeforeValidationCallback(mutStateData, key, oldValue, value);
+ }
+ }));
+
+ validateFormState(this, mutState);
+ });
+
+ let newState = {
+ formState
+ };
+
+
+ const onChangeCallback = this.state.formSettings.onChange || {};
+
+ if (typeof onChangeCallback === 'object') {
+ if (onChangeCallback[key]) {
+ onChangeCallback[key](newState, key, oldValue, value);
+ }
+ } else {
+ onChangeCallback(newState, key, oldValue, value);
+ }
+
+ return newState;
+ });
+ };
+
+ proto.getFormValue = function (name) {
+ return this.state.formState.getIn(['data', name, 'value']);
+ };
+
+ proto.getFormValues = function (name) {
+ if (!this.state || !this.state.formState) return undefined;
+ return this.state.formState.get('data').map(attr => attr.get('value')).toJS();
+ };
+
+ proto.getFormError = function (name) {
+ return this.state.formState.getIn(['data', name, 'error']);
+ };
+
+ proto.isFormWithLoadingNotice = function () {
+ return this.state.formState.get('state') === FormState.LoadingWithNotice;
+ };
+
+ proto.isFormLoading = function () {
+ return this.state.formState.get('state') === FormState.Loading || this.state.formState.get('state') === FormState.LoadingWithNotice;
+ };
+
+ proto.isFormReady = function () {
+ return this.state.formState.get('state') === FormState.Ready;
+ };
+
+ const _isFormChanged = self => {
+ const currentData = getSaveData(self, self.state.formState.get('data'));
+ const savedData = self.state.formState.get('savedData');
+
+ function isDifferent(data1, data2, prefix) {
+ if (typeof data1 === 'object' && typeof data2 === 'object' && data1 && data2) {
+ const keys = new Set([...Object.keys(data1), ...Object.keys(data2)]);
+ for (const key of keys) {
+ if (isDifferent(data1[key], data2[key], `${prefix}/${key}`)) {
+ return true;
+ }
+ }
+ } else if (data1 !== data2) {
+ // console.log(prefix);
+ return true;
+ }
+ return false;
+ }
+
+ const result = isDifferent(currentData, savedData, '');
+
+ return result;
+ };
+
+ proto.isFormChanged = function () {
+ const settings = this.state.formSettings;
+
+ if (!settings.leaveConfirmation) return false;
+
+ if (settings.getPreSubmitUpdater) {
+ // getPreSubmitUpdater is an async function. We cannot do anything async here. So to be on the safe side,
+ // we simply assume that the form has been changed.
+ return true;
+ }
+
+ return _isFormChanged(this);
+ };
+
+ proto.isFormChangedAsync = async function () {
+ const settings = this.state.formSettings;
+
+ if (!settings.leaveConfirmation) return false;
+
+ if (settings.getPreSubmitUpdater) {
+ const preSubmitUpdater = await settings.getPreSubmitUpdater();
+
+ await new Promise((resolve, reject) => {
+ this.setState(previousState => ({
+ formState: previousState.formState.withMutations(mutState => {
+ mutState.update('data', stateData => stateData.withMutations(preSubmitUpdater));
+ })
+ }), resolve);
+ });
+ }
+
+ return _isFormChanged(this);
+
+ };
+
+ proto.isFormValidationShown = function () {
+ return this.state.formState.get('isValidationShown');
+ };
+
+ proto.addFormValidationClass = function (className, name) {
+ if (this.isFormValidationShown()) {
+ const error = this.getFormError(name);
+ if (error) {
+ return className + ' is-invalid';
+ } else {
+ return className + ' is-valid';
+ }
+ } else {
+ return className;
+ }
+ };
+
+ proto.getFormValidationMessage = function (name) {
+ if (this.isFormValidationShown()) {
+ return this.getFormError(name);
+ } else {
+ return '';
+ }
+ };
+
+ proto.showFormValidation = function () {
+ this.setState(previousState => ({formState: previousState.formState.set('isValidationShown', true)}));
+ };
+
+ proto.hideFormValidation = function () {
+ this.setState(previousState => ({formState: previousState.formState.set('isValidationShown', false)}));
+ };
+
+ proto.isFormWithoutErrors = function () {
+ return !this.state.formState.get('data').find(attr => attr.get('error'));
+ };
+
+ proto.isFormServerValidated = function () {
+ return !this.state.formSettings.serverValidation || this.state.formSettings.serverValidation.changed.every(attr => this.state.formState.getIn(['data', attr, 'serverValidated']));
+ };
+
+ proto.getFormStatusMessageText = function () {
+ return this.state.formState.get('statusMessageText');
+ };
+
+ proto.getFormStatusMessageSeverity = function () {
+ return this.state.formState.get('statusMessageSeverity');
+ };
+
+ proto.setFormStatusMessage = function (severity, text) {
+ this.setState(previousState => ({
+ formState: previousState.formState.withMutations(map => {
+ map.set('statusMessageText', text);
+ map.set('statusMessageSeverity', severity);
+ })
+ }));
+ };
+
+ proto.clearFormStatusMessage = function () {
+ this.setState(previousState => ({
+ formState: previousState.formState.withMutations(map => {
+ map.set('statusMessageText', '');
+ })
+ }));
+ };
+
+ proto.enableForm = function () {
+ this.setState(previousState => ({formState: previousState.formState.set('isDisabled', false)}));
+ };
+
+ proto.disableForm = function () {
+ this.setState(previousState => ({formState: previousState.formState.set('isDisabled', true)}));
+ };
+
+ proto.isFormDisabled = function () {
+ return this.state.formState.get('isDisabled');
+ };
+
+ proto.formHandleErrors = async function (fn) {
+ const t = this.props.t;
+ try {
+ await fn();
+ } catch (error) {
+ if (error instanceof interoperableErrors.ChangedError) {
+ this.disableForm();
+ this.setFormStatusMessage('danger',
+
+ {t('yourUpdatesCannotBeSaved')} {' '}
+ {t('someoneElseHasIntroducedModificationIn')}
+
+ );
+ return;
+ }
+
+ if (error instanceof interoperableErrors.NamespaceNotFoundError) {
+ this.disableForm();
+ this.setFormStatusMessage('danger',
+
+ {t('yourUpdatesCannotBeSaved')} {' '}
+ {t('itSeemsThatSomeoneElseHasDeletedThe')}
+
+ );
+ return;
+ }
+
+ if (error instanceof interoperableErrors.NotFoundError) {
+ this.disableForm();
+ this.setFormStatusMessage('danger',
+
+ {t('yourUpdatesCannotBeSaved')} {' '}
+ {t('itSeemsThatSomeoneElseHasDeletedThe-1')}
+
+ );
+ return;
+ }
+
+ throw error;
+ }
+ };
+
+ return {};
+ }
+});
+
+function filterData(obj, allowedKeys) {
+ const result = {};
+ for (const key in obj) {
+ if (key === 'originalHash') {
+ result[key] = obj[key];
+ } else {
+ for (const allowedKey of allowedKeys) {
+ if ((typeof allowedKey === 'function' && allowedKey(key)) || allowedKey === key) {
+ result[key] = obj[key];
+ break;
+ }
+ }
+ }
+ }
+
+ return result;
+}
+
+export {
+ withForm,
+ Form,
+ Fieldset,
+ StaticField,
+ InputField,
+ CheckBox,
+ CheckBoxGroup,
+ RadioGroup,
+ TextArea,
+ ColorPicker,
+ DatePicker,
+ Dropdown,
+ AlignedRow,
+ ButtonRow,
+ Button,
+ TreeTableSelect,
+ TableSelect,
+ TableSelectMode,
+ ACEEditor,
+ FormSendMethod,
+ filterData
+}
diff --git a/client/src/lib/helpers.js b/client/src/lib/helpers.js
new file mode 100644
index 00000000..8c2f8e27
--- /dev/null
+++ b/client/src/lib/helpers.js
@@ -0,0 +1,8 @@
+'use strict';
+
+import ellipsize from "ellipsize";
+
+
+export function ellipsizeBreadcrumbLabel(label) {
+ return ellipsize(label, 40)
+}
\ No newline at end of file
diff --git a/client/src/lib/i18n.js b/client/src/lib/i18n.js
new file mode 100644
index 00000000..ba39afe7
--- /dev/null
+++ b/client/src/lib/i18n.js
@@ -0,0 +1,92 @@
+'use strict';
+
+import React from 'react';
+import {I18nextProvider, withNamespaces} from 'react-i18next';
+import i18n from 'i18next';
+import LanguageDetector from 'i18next-browser-languagedetector';
+import mailtrainConfig from 'mailtrainConfig';
+
+import {convertToFake, getLang} from '../../../shared/langs';
+import {createComponentMixin} from "./decorator-helpers";
+
+import lang_en_US_common from "../../../locales/en-US/common";
+import lang_es_ES_common from "../../../locales/es-ES/common";
+import lang_pt_BR_common from "../../../locales/pt-BR/common";
+
+
+const resourcesCommon = {
+ 'en-US': lang_en_US_common,
+ 'es-ES': lang_es_ES_common,
+ 'pt-BR': lang_pt_BR_common,
+ 'fk-FK': convertToFake(lang_en_US_common)
+};
+
+const resources = {};
+for (const lng of mailtrainConfig.enabledLanguages) {
+ const langDesc = getLang(lng);
+ resources[langDesc.longCode] = {
+ common: resourcesCommon[langDesc.longCode]
+ };
+}
+
+i18n
+ .use(LanguageDetector)
+ .init({
+ resources,
+
+ fallbackLng: mailtrainConfig.defaultLanguage,
+ defaultNS: 'common',
+
+ interpolation: {
+ escapeValue: false // not needed for react
+ },
+
+ react: {
+ wait: true
+ },
+
+ detection: {
+ order: ['querystring', 'cookie', 'localStorage', 'navigator'],
+ lookupQuerystring: 'locale',
+ lookupCookie: 'i18nextLng',
+ lookupLocalStorage: 'i18nextLng',
+ caches: ['localStorage', 'cookie']
+ },
+
+ whitelist: mailtrainConfig.enabledLanguages,
+ load: 'currentOnly',
+
+ debug: false
+ });
+
+
+export default i18n;
+
+
+export const TranslationContext = React.createContext(null);
+
+export const withTranslation = createComponentMixin({
+ contexts: [{context: TranslationContext, propName: 't'}]
+});
+
+const TranslationContextProvider = withNamespaces()(props => {
+ return (
+
+ {props.children}
+
+ );
+});
+
+export function TranslationRoot(props) {
+ return (
+
+
+ {props.children}
+
+
+ );
+}
+
+export function tMark(key) {
+ return key;
+}
diff --git a/client/src/lib/mjml.js b/client/src/lib/mjml.js
new file mode 100644
index 00000000..a721cab8
--- /dev/null
+++ b/client/src/lib/mjml.js
@@ -0,0 +1,77 @@
+'use strict';
+
+import {isArray, mergeWith} from 'lodash';
+import kebabCase from 'lodash/kebabCase';
+import mjml2html, {BodyComponent, components, defaultSkeleton, dependencies, HeadComponent} from "mjml4-in-browser";
+
+export { BodyComponent, HeadComponent };
+
+const initComponents = {...components};
+const initDependencies = {...dependencies};
+
+
+// MJML uses global state. This class wraps MJML state and provides a custom mjml2html function which sets the right state before calling the original mjml2html
+export class MJML {
+ constructor() {
+ this.components = initComponents;
+ this.dependencies = initDependencies;
+ this.headRaw = [];
+ }
+
+ registerDependencies(dep) {
+ function mergeArrays(objValue, srcValue) {
+ if (isArray(objValue) && isArray(srcValue)) {
+ return objValue.concat(srcValue)
+ }
+ }
+
+ mergeWith(this.dependencies, dep, mergeArrays);
+ }
+
+ registerComponent(Component) {
+ this.components[kebabCase(Component.name)] = Component;
+ }
+
+ addToHeader(src) {
+ this.headRaw.push(src);
+ }
+
+ mjml2html(mjml) {
+ function setObj(obj, src) {
+ for (const prop of Object.keys(obj)) {
+ delete obj[prop];
+ }
+
+ Object.assign(obj, src);
+ }
+
+ const origComponents = {...components};
+ const origDependencies = {...dependencies};
+
+ setObj(components, this.components);
+ setObj(dependencies, this.dependencies);
+
+ const res = mjml2html(mjml, {
+ skeleton: options => {
+ const headRaw = options.headRaw || [];
+ options.headRaw = headRaw.concat(this.headRaw);
+ return defaultSkeleton(options);
+ }
+ });
+
+ setObj(components, origComponents);
+ setObj(dependencies, origDependencies);
+
+ return res;
+ }
+}
+
+const mjmlInstance = new MJML();
+
+export default function defaultMjml2html(src) {
+ return mjmlInstance.mjml2html(src);
+}
+
+
+
+
diff --git a/client/src/lib/modals.js b/client/src/lib/modals.js
new file mode 100644
index 00000000..76b99a65
--- /dev/null
+++ b/client/src/lib/modals.js
@@ -0,0 +1,399 @@
+'use strict';
+
+import React, {Component} from 'react';
+import axios, {HTTPMethod} from './axios';
+import {withTranslation} from './i18n';
+import PropTypes from 'prop-types';
+import {Icon, ModalDialog} from "./bootstrap-components";
+import {getUrl} from "./urls";
+import {withPageHelpers} from "./page";
+import styles from './styles.scss';
+import interoperableErrors from '../../../shared/interoperable-errors';
+import {Link} from "react-router-dom";
+import {withComponentMixins} from "./decorator-helpers";
+import {withAsyncErrorHandler} from "./error-handling";
+import ACEEditorRaw from 'react-ace';
+
+@withComponentMixins([
+ withTranslation,
+ withPageHelpers
+])
+export class RestActionModalDialog extends Component {
+ static propTypes = {
+ title: PropTypes.string.isRequired,
+ message: PropTypes.string.isRequired,
+ stateOwner: PropTypes.object,
+ visible: PropTypes.bool.isRequired,
+ actionMethod: PropTypes.func.isRequired,
+ actionUrl: PropTypes.string.isRequired,
+ actionData: PropTypes.object,
+
+ backUrl: PropTypes.string,
+ successUrl: PropTypes.string,
+
+ onBack: PropTypes.func,
+ onPerformingAction: PropTypes.func,
+ onSuccess: PropTypes.func,
+
+ actionInProgressMsg: PropTypes.string.isRequired,
+ actionDoneMsg: PropTypes.string.isRequired,
+
+ onErrorAsync: PropTypes.func
+ }
+
+ async hideModal(isBack) {
+ if (this.props.backUrl) {
+ this.navigateTo(this.props.backUrl);
+ } else {
+ if (isBack) {
+ this.props.onBack();
+ } else {
+ this.props.onPerformingAction();
+ }
+ }
+ }
+
+ async performAction() {
+ const props = this.props;
+ const t = props.t;
+ const owner = props.stateOwner;
+
+ await this.hideModal(false);
+
+ try {
+ if (!owner) {
+ this.setFlashMessage('info', props.actionInProgressMsg);
+ } else {
+ owner.disableForm();
+ owner.setFormStatusMessage('info', props.actionInProgressMsg);
+ }
+
+ await axios.method(props.actionMethod, getUrl(props.actionUrl), props.actionData);
+
+ if (props.successUrl) {
+ this.navigateToWithFlashMessage(props.successUrl, 'success', props.actionDoneMsg);
+ } else {
+ props.onSuccess();
+ this.setFlashMessage('success', props.actionDoneMsg);
+ }
+ } catch (err) {
+ if (props.onErrorAsync) {
+ await props.onErrorAsync(err);
+ } else {
+ throw err;
+ }
+ }
+ }
+
+ render() {
+ const t = this.props.t;
+
+ return (
+ await this.hideModal(true)} buttons={[
+ { label: t('no'), className: 'btn-primary', onClickAsync: async () => await this.hideModal(true) },
+ { label: t('yes'), className: 'btn-danger', onClickAsync: ::this.performAction }
+ ]}>
+ {this.props.message}
+
+ );
+ }
+}
+
+const entityTypeLabels = {
+ 'namespace': t => t('namespace'),
+ 'list': t => t('list'),
+ 'customForm': t => t('customForms'),
+ 'campaign': t => t('campaign'),
+ 'template': t => t('template'),
+ 'sendConfiguration': t => t('sendConfiguration'),
+ 'report': t => t('report'),
+ 'reportTemplate': t => t('reportTemplate'),
+ 'mosaicoTemplate': t => t('mosaicoTemplate'),
+ 'user': t => t('User')
+};
+
+function _getDependencyErrorMessage(err, t, name) {
+ return (
+
+ {err.data.dependencies.length > 0 ?
+ <>
+
{t('cannoteDeleteNameDueToTheFollowing', {name})}
+
+ {err.data.dependencies.map(dep =>
+ dep.link ?
+ {entityTypeLabels[dep.entityTypeId](t)}: {dep.name}
+ : // if no dep.link is present, it means the user has no permission to view the entity, thus only id without the link is shown
+ {entityTypeLabels[dep.entityTypeId](t)}: [{dep.id}]
+ )}
+ {err.data.andMore && {t('andMore')} }
+
+ >
+ :
+
{t('Cannot delete {{name}} due to hidden dependencies', {name})}
+ }
+
+ );
+}
+
+
+@withComponentMixins([
+ withTranslation,
+ withPageHelpers
+])
+export class DeleteModalDialog extends Component {
+ constructor(props) {
+ super(props);
+ const t = props.t;
+ }
+
+ static propTypes = {
+ visible: PropTypes.bool.isRequired,
+ stateOwner: PropTypes.object.isRequired,
+ deleteUrl: PropTypes.string.isRequired,
+ backUrl: PropTypes.string.isRequired,
+ successUrl: PropTypes.string.isRequired,
+ deletingMsg: PropTypes.string.isRequired,
+ deletedMsg: PropTypes.string.isRequired,
+ name: PropTypes.string
+ }
+
+ async onErrorAsync(err) {
+ const t = this.props.t;
+
+ if (err instanceof interoperableErrors.DependencyPresentError) {
+ const owner = this.props.stateOwner;
+
+ const name = owner.getFormValue('name');
+ this.setFlashMessage('danger', _getDependencyErrorMessage(err, t, name));
+
+ window.scrollTo(0, 0); // This is to scroll up because the flash message appears on top and it's quite misleading if the delete fails and the message is not in the viewport
+
+ owner.enableForm();
+ owner.clearFormStatusMessage();
+
+ } else {
+ throw err;
+ }
+ }
+
+ render() {
+ const t = this.props.t;
+ const owner = this.props.stateOwner;
+ const name = this.props.name || owner.getFormValue('name') || '';
+
+ return
+ }
+}
+
+export function tableRestActionDialogInit(owner) {
+ owner.tableRestActionDialogData = {};
+ owner.state.tableRestActionDialogShown = false;
+}
+
+
+
+function _hide(owner, dontRefresh = false) {
+ const refreshTables = owner.tableRestActionDialogData.refreshTables;
+
+ owner.setState({ tableRestActionDialogShown: false });
+
+ if (!dontRefresh) {
+ owner.tableRestActionDialogData = {};
+
+ if (refreshTables) {
+ refreshTables();
+ } else {
+ owner.table.refresh();
+ }
+ } else {
+ // _hide is called twice: (1) at performing action, and at (2) success. Here we keep the refreshTables
+ // reference till it is really needed in step #2.
+ owner.tableRestActionDialogData = { refreshTables };
+ }
+}
+
+export function tableAddDeleteButton(actions, owner, perms, deleteUrl, name, deletingMsg, deletedMsg) {
+ const t = owner.props.t;
+
+ async function onErrorAsync(err) {
+ if (err instanceof interoperableErrors.DependencyPresentError) {
+ owner.setFlashMessage('danger', _getDependencyErrorMessage(err, t, name));
+ window.scrollTo(0, 0); // This is to scroll up because the flash message appears on top and it's quite misleading if the delete fails and the message is not in the viewport
+ _hide(owner);
+ } else {
+ throw err;
+ }
+ }
+
+ if (!perms || perms.includes('delete')) {
+ if (owner.tableRestActionDialogData.shown) {
+ actions.push({
+ label:
+ });
+ } else {
+ actions.push({
+ label: ,
+ action: () => {
+ owner.tableRestActionDialogData = {
+ shown: true,
+ title: t('confirmDeletion'),
+ message:t('areYouSureYouWantToDeleteName?', {name}),
+ httpMethod: HTTPMethod.DELETE,
+ actionUrl: deleteUrl,
+ actionInProgressMsg: deletingMsg,
+ actionDoneMsg: deletedMsg,
+ onErrorAsync: onErrorAsync
+ };
+
+ owner.setState({
+ tableRestActionDialogShown: true
+ });
+
+ owner.table.refresh();
+ }
+ });
+ }
+ }
+}
+
+export function tableAddRestActionButton(actions, owner, action, button, title, message, actionInProgressMsg, actionDoneMsg, onErrorAsync) {
+ const t = owner.props.t;
+
+ if (owner.tableRestActionDialogData.shown) {
+ actions.push({
+ label:
+ });
+ } else {
+ actions.push({
+ label: ,
+ action: () => {
+ owner.tableRestActionDialogData = {
+ shown: true,
+ title: title,
+ message: message,
+ httpMethod: action.method,
+ actionUrl: action.url,
+ actionData: action.data,
+ actionInProgressMsg: actionInProgressMsg,
+ actionDoneMsg: actionDoneMsg,
+ onErrorAsync: onErrorAsync,
+ refreshTables: action.refreshTables
+ };
+
+ owner.setState({
+ tableRestActionDialogShown: true
+ });
+
+ if (action.refreshTables) {
+ action.refreshTables();
+ } else {
+ owner.table.refresh();
+ }
+ }
+ });
+ }
+}
+
+export function tableRestActionDialogRender(owner) {
+ const data = owner.tableRestActionDialogData;
+
+ return _hide(owner)}
+ onPerformingAction={() => _hide(owner, true)}
+ onSuccess={() => _hide(owner)}
+ actionInProgressMsg={data.actionInProgressMsg || ''}
+ actionDoneMsg={data.actionDoneMsg || ''}
+ onErrorAsync={data.onErrorAsync}
+ />
+
+}
+
+
+@withComponentMixins([
+ withTranslation
+])
+export class ContentModalDialog extends Component {
+ constructor(props) {
+ super(props);
+ const t = props.t;
+
+ this.state = {
+ content: null
+ };
+ }
+
+ static propTypes = {
+ visible: PropTypes.bool.isRequired,
+ title: PropTypes.string.isRequired,
+ getContentAsync: PropTypes.func.isRequired,
+ onHide: PropTypes.func.isRequired
+ }
+
+ @withAsyncErrorHandler
+ async fetchContent() {
+ const content = await this.props.getContentAsync();
+ this.setState({
+ content
+ });
+ }
+
+ componentDidMount() {
+ if (this.props.visible) {
+ // noinspection JSIgnoredPromiseFromCall
+ this.fetchContent();
+ }
+ }
+
+ componentDidUpdate(prevProps) {
+ if (this.props.visible && !prevProps.visible) {
+ // noinspection JSIgnoredPromiseFromCall
+ this.fetchContent();
+ } else if (!this.props.visible && this.state.content !== null) {
+ this.setState({
+ content: null
+ });
+ }
+ }
+
+ render() {
+ const t = this.props.t;
+
+ return (
+ this.props.onHide()}>
+ {this.props.visible && this.state.content &&
+
+ }
+
+ );
+ }
+}
diff --git a/client/src/lib/namespace.js b/client/src/lib/namespace.js
new file mode 100644
index 00000000..2c8aeef7
--- /dev/null
+++ b/client/src/lib/namespace.js
@@ -0,0 +1,47 @@
+'use strict';
+
+import React, {Component} from 'react';
+import {withTranslation} from './i18n';
+import {TreeTableSelect} from './form';
+import {withComponentMixins} from "./decorator-helpers";
+
+
+@withComponentMixins([
+ withTranslation
+])
+export class NamespaceSelect extends Component {
+ render() {
+ const t = this.props.t;
+
+ return (
+
+ );
+ }
+}
+
+export function validateNamespace(t, state) {
+ if (!state.getIn(['namespace', 'value'])) {
+ state.setIn(['namespace', 'error'], t('namespaceMustBeSelected'));
+ } else {
+ state.setIn(['namespace', 'error'], null);
+ }
+}
+
+export function getDefaultNamespace(permissions) {
+ return permissions.viewUsersNamespace && permissions.createEntityInUsersNamespace ? mailtrainConfig.user.namespace : null;
+}
+
+export function namespaceCheckPermissions(createOperation) {
+ return {
+ createEntityInUsersNamespace: {
+ entityTypeId: 'namespace',
+ entityId: mailtrainConfig.user.namespace,
+ requiredOperations: [createOperation]
+ },
+ viewUsersNamespace: {
+ entityTypeId: 'namespace',
+ entityId: mailtrainConfig.user.namespace,
+ requiredOperations: ['view']
+ }
+ };
+}
diff --git a/client/src/lib/page-common.js b/client/src/lib/page-common.js
new file mode 100644
index 00000000..d0f1493e
--- /dev/null
+++ b/client/src/lib/page-common.js
@@ -0,0 +1,448 @@
+'use strict';
+
+import React, {Component} from "react";
+import PropTypes from "prop-types";
+import {Redirect, Route, Switch} from "react-router-dom";
+import {withAsyncErrorHandler, withErrorHandling} from "./error-handling";
+import axios from "../lib/axios";
+import {getUrl} from "./urls";
+import {createComponentMixin, withComponentMixins} from "./decorator-helpers";
+import {withTranslation} from "./i18n";
+import shallowEqual from "shallowequal";
+import {checkPermissions} from "./permissions";
+
+async function resolve(route, match, prevResolverState) {
+ const resolved = {};
+ const permissions = {};
+ const resolverState = {
+ resolvedByUrl: {},
+ permissionsBySig: {}
+ };
+
+ prevResolverState = prevResolverState || {
+ resolvedByUrl: {},
+ permissionsBySig: {}
+ };
+
+ async function processResolve() {
+ const keysToGo = new Set(Object.keys(route.resolve));
+
+ while (keysToGo.size > 0) {
+ const urlsToResolve = [];
+ const keysToResolve = [];
+
+ for (const key of keysToGo) {
+ const resolveEntry = route.resolve[key];
+
+ let allDepsSatisfied = true;
+ let urlFn = null;
+
+ if (typeof resolveEntry === 'function') {
+ urlFn = resolveEntry;
+
+ } else {
+ if (resolveEntry.dependencies) {
+ for (const dep of resolveEntry.dependencies) {
+ if (!(dep in resolved)) {
+ allDepsSatisfied = false;
+ break;
+ }
+ }
+ }
+
+ urlFn = resolveEntry.url;
+ }
+
+ if (allDepsSatisfied) {
+ urlsToResolve.push(urlFn(match.params, resolved));
+ keysToResolve.push(key);
+ }
+ }
+
+ if (keysToResolve.length === 0) {
+ throw new Error('Cyclic dependency in "resolved" entries of ' + route.path);
+ }
+
+ const urlsToResolveByRest = [];
+ const keysToResolveByRest = [];
+
+ for (let idx = 0; idx < keysToResolve.length; idx++) {
+ const key = keysToResolve[idx];
+ const url = urlsToResolve[idx];
+
+ if (url in prevResolverState.resolvedByUrl) {
+ const entity = prevResolverState.resolvedByUrl[url];
+ resolved[key] = entity;
+ resolverState.resolvedByUrl[url] = entity;
+
+ } else {
+ urlsToResolveByRest.push(url);
+ keysToResolveByRest.push(key);
+ }
+ }
+
+ if (keysToResolveByRest.length > 0) {
+ const promises = urlsToResolveByRest.map(url => {
+ if (url) {
+ return axios.get(getUrl(url));
+ } else {
+ return Promise.resolve({data: null});
+ }
+ });
+ const resolvedArr = await Promise.all(promises);
+
+ for (let idx = 0; idx < keysToResolveByRest.length; idx++) {
+ resolved[keysToResolveByRest[idx]] = resolvedArr[idx].data;
+ resolverState.resolvedByUrl[urlsToResolveByRest[idx]] = resolvedArr[idx].data;
+ }
+ }
+
+ for (const key of keysToResolve) {
+ keysToGo.delete(key);
+ }
+ }
+ }
+
+ async function processCheckPermissions() {
+ const checkPermsRequest = {};
+
+ function getSig(checkPermissionsEntry) {
+ return `${checkPermissionsEntry.entityTypeId}-${checkPermissionsEntry.entityId || ''}-${checkPermissionsEntry.requiredOperations.join(',')}`;
+ }
+
+ for (const key in route.checkPermissions) {
+ const checkPermissionsEntry = route.checkPermissions[key];
+ const sig = getSig(checkPermissionsEntry);
+
+ if (sig in prevResolverState.permissionsBySig) {
+ const perm = prevResolverState.permissionsBySig[sig];
+ permissions[key] = perm;
+ resolverState.permissionsBySig[sig] = perm;
+
+ } else {
+ checkPermsRequest[key] = checkPermissionsEntry;
+ }
+ }
+
+ if (Object.keys(checkPermsRequest).length > 0) {
+ const result = await checkPermissions(checkPermsRequest);
+
+ for (const key in checkPermsRequest) {
+ const checkPermissionsEntry = checkPermsRequest[key];
+ const perm = result.data[key];
+
+ permissions[key] = perm;
+ resolverState.permissionsBySig[getSig(checkPermissionsEntry)] = perm;
+ }
+ }
+
+ }
+
+ await Promise.all([processResolve(), processCheckPermissions()]);
+
+ return { resolved, permissions, resolverState };
+}
+
+export function getRoutes(structure, parentRoute) {
+ function _getRoutes(urlPrefix, resolve, checkPermissions, parents, structure, navs, primaryMenuComponent, secondaryMenuComponent) {
+ let routes = [];
+ for (let routeKey in structure) {
+ const entry = structure[routeKey];
+
+ let path = urlPrefix + routeKey;
+ let pathWithParams = path;
+
+ if (entry.extraParams) {
+ pathWithParams = pathWithParams + '/' + entry.extraParams.join('/');
+ }
+
+ let entryResolve;
+ if (entry.resolve) {
+ entryResolve = Object.assign({}, resolve, entry.resolve);
+ } else {
+ entryResolve = resolve;
+ }
+
+ let entryCheckPermissions;
+ if (entry.checkPermissions) {
+ entryCheckPermissions = Object.assign({}, checkPermissions, entry.checkPermissions);
+ } else {
+ entryCheckPermissions = checkPermissions;
+ }
+
+ let navKeys;
+ const entryNavs = [];
+ if (entry.navs) {
+ navKeys = Object.keys(entry.navs);
+
+ for (const navKey of navKeys) {
+ const nav = entry.navs[navKey];
+
+ entryNavs.push({
+ title: nav.title,
+ visible: nav.visible,
+ link: nav.link,
+ externalLink: nav.externalLink
+ });
+ }
+ }
+
+ const route = {
+ path: (pathWithParams === '' ? '/' : pathWithParams),
+ exact: !entry.structure && entry.exact !== false,
+ structure: entry.structure,
+ panelComponent: entry.panelComponent,
+ panelRender: entry.panelRender,
+ primaryMenuComponent: entry.primaryMenuComponent || primaryMenuComponent,
+ secondaryMenuComponent: entry.secondaryMenuComponent || secondaryMenuComponent,
+ title: entry.title,
+ link: entry.link,
+ panelInFullScreen: entry.panelInFullScreen,
+ insideIframe: entry.insideIframe,
+ resolve: entryResolve,
+ checkPermissions: entryCheckPermissions,
+ parents,
+ navs: [...navs, ...entryNavs],
+
+ // This is primarily for route embedding via "structure"
+ routeSpec: entry,
+ urlPrefix,
+ siblingNavs: navs,
+ routeKey
+ };
+
+ routes.push(route);
+
+ const childrenParents = [...parents, route];
+
+ if (entry.navs) {
+ for (let navKeyIdx = 0; navKeyIdx < navKeys.length; navKeyIdx++) {
+ const navKey = navKeys[navKeyIdx];
+ const nav = entry.navs[navKey];
+
+ const childNavs = [...entryNavs];
+ childNavs[navKeyIdx] = Object.assign({}, childNavs[navKeyIdx], { active: true });
+
+ routes = routes.concat(_getRoutes(path + '/', entryResolve, entryCheckPermissions, childrenParents, { [navKey]: nav }, childNavs, route.primaryMenuComponent, route.secondaryMenuComponent));
+ }
+ }
+
+ if (entry.children) {
+ routes = routes.concat(_getRoutes(path + '/', entryResolve, entryCheckPermissions, childrenParents, entry.children, entryNavs, route.primaryMenuComponent, route.secondaryMenuComponent));
+ }
+ }
+
+ return routes;
+ }
+
+ if (parentRoute) {
+ // This embeds the structure in the parent route.
+
+ const routeSpec = parentRoute.routeSpec;
+
+ const extStructure = {
+ ...routeSpec,
+ structure: undefined,
+ ...structure,
+ navs: { ...(routeSpec.navs || {}), ...(structure.navs || {}) },
+ children: { ...(routeSpec.children || {}), ...(structure.children || {}) }
+ };
+
+ return _getRoutes(parentRoute.urlPrefix, parentRoute.resolve, parentRoute.checkPermissions, parentRoute.parents, { [parentRoute.routeKey]: extStructure }, parentRoute.siblingNavs, parentRoute.primaryMenuComponent, parentRoute.secondaryMenuComponent);
+
+ } else {
+ return _getRoutes('', {}, {}, [], { "": structure }, [], null, null);
+ }
+}
+
+
+@withComponentMixins([
+ withErrorHandling
+])
+export class Resolver extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ resolved: null,
+ permissions: null,
+ resolverState: null
+ };
+
+ if (Object.keys(props.route.resolve).length === 0 && Object.keys(props.route.checkPermissions).length === 0) {
+ this.state.resolved = {};
+ this.state.permissions = {};
+ }
+ }
+
+ static propTypes = {
+ route: PropTypes.object.isRequired,
+ render: PropTypes.func.isRequired,
+ location: PropTypes.object,
+ match: PropTypes.object
+ }
+
+ @withAsyncErrorHandler
+ async resolve(prevMatch) {
+ const props = this.props;
+
+ if (Object.keys(props.route.resolve).length === 0 && Object.keys(props.route.checkPermissions).length === 0) {
+ this.setState({
+ resolved: {},
+ permissions: {},
+ resolverState: null
+ });
+
+ } else {
+ const prevResolverState = this.state.resolverState;
+
+ if (this.state.resolverState) {
+ this.setState({
+ resolved: null,
+ permissions: null,
+ resolverState: null
+ });
+ }
+
+ const {resolved, permissions, resolverState} = await resolve(props.route, props.match, prevResolverState);
+
+ if (!this.disregardResolve) { // This is to prevent the warning about setState on discarded component when we immediatelly redirect.
+ this.setState({
+ resolved,
+ permissions,
+ resolverState
+ });
+ }
+ }
+ }
+
+ componentDidMount() {
+ // noinspection JSIgnoredPromiseFromCall
+ this.resolve();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (this.props.location.state !== prevProps.location.state || !shallowEqual(this.props.match.params, prevProps.match.params)) {
+ // noinspection JSIgnoredPromiseFromCall
+ this.resolve(prevProps.route, prevProps.match);
+ }
+ }
+
+ componentWillUnmount() {
+ this.disregardResolve = true; // This is to prevent the warning about setState on discarded component when we immediatelly redirect.
+ }
+
+ render() {
+ return this.props.render(this.state.resolved, this.state.permissions, this.props);
+ }
+}
+
+
+class RedirectRoute extends Component {
+ static propTypes = {
+ route: PropTypes.object.isRequired
+ }
+
+ render() {
+ const route = this.props.route;
+ const params = this.props.match.params;
+
+ let link;
+ if (typeof route.link === 'function') {
+ link = route.link(params);
+ } else {
+ link = route.link;
+ }
+
+ return ;
+ }
+}
+
+
+@withComponentMixins([
+ withTranslation
+])
+class SubRoute extends Component {
+ static propTypes = {
+ route: PropTypes.object.isRequired,
+ location: PropTypes.object.isRequired,
+ match: PropTypes.object.isRequired,
+ flashMessage: PropTypes.object,
+ panelRouteCtor: PropTypes.func.isRequired,
+ loadingMessageFn: PropTypes.func.isRequired
+ }
+
+ render() {
+ const t = this.props.t;
+ const route = this.props.route;
+ const params = this.props.match.params;
+
+ const render = (resolved, permissions) => {
+ if (resolved && permissions) {
+ const subStructure = route.structure(resolved, permissions, params);
+ const routes = getRoutes(subStructure, route);
+
+ const _renderRoute = route => {
+ const render = props => renderRoute(route, this.props.panelRouteCtor, this.props.loadingMessageFn, this.props.flashMessage, props);
+ return
+ };
+
+ return (
+ {routes.map(x => _renderRoute(x))}
+ );
+
+ } else {
+ return this.props.loadingMessageFn();
+ }
+ };
+
+ return ;
+ }
+}
+
+export function renderRoute(route, panelRouteCtor, loadingMessageFn, flashMessage, props) {
+ if (route.structure) {
+ return ;
+
+ } else if (!route.panelRender && !route.panelComponent && route.link) {
+ return ;
+
+ } else {
+ const PanelRoute = panelRouteCtor;
+ return ;
+ }
+
+}
+
+export const SectionContentContext = React.createContext(null);
+export const withPageHelpers = createComponentMixin({
+ contexts: [{context: SectionContentContext, propName: 'sectionContent'}],
+ deps: [withErrorHandling],
+ decoratorFn: (TargetClass, InnerClass) => {
+ InnerClass.prototype.setFlashMessage = function (severity, text) {
+ return this.props.sectionContent.setFlashMessage(severity, text);
+ };
+
+ InnerClass.prototype.navigateTo = function (path) {
+ return this.props.sectionContent.navigateTo(path);
+ };
+
+ InnerClass.prototype.navigateBack = function () {
+ return this.props.sectionContent.navigateBack();
+ };
+
+ InnerClass.prototype.navigateToWithFlashMessage = function (path, severity, text) {
+ return this.props.sectionContent.navigateToWithFlashMessage(path, severity, text);
+ };
+
+ InnerClass.prototype.registerBeforeUnloadHandlers = function (handlers) {
+ return this.props.sectionContent.registerBeforeUnloadHandlers(handlers);
+ };
+
+ InnerClass.prototype.deregisterBeforeUnloadHandlers = function (handlers) {
+ return this.props.sectionContent.deregisterBeforeUnloadHandlers(handlers);
+ };
+
+ return {};
+ }
+});
diff --git a/client/src/lib/page.js b/client/src/lib/page.js
new file mode 100644
index 00000000..be9562f6
--- /dev/null
+++ b/client/src/lib/page.js
@@ -0,0 +1,727 @@
+'use strict';
+
+import React, {Component} from "react";
+import i18n, {withTranslation} from './i18n';
+import PropTypes from "prop-types";
+import {withRouter} from "react-router";
+import {BrowserRouter as Router, Link, Route, Switch} from "react-router-dom";
+import {withErrorHandling} from "./error-handling";
+import interoperableErrors from "../../../shared/interoperable-errors";
+import {ActionLink, Button, DismissibleAlert, DropdownActionLink, Icon} from "./bootstrap-components";
+import mailtrainConfig from "mailtrainConfig";
+import styles from "./styles.scss";
+import {getRoutes, renderRoute, Resolver, SectionContentContext, withPageHelpers} from "./page-common";
+import {getBaseDir} from "./urls";
+import {createComponentMixin, withComponentMixins} from "./decorator-helpers";
+import {getLang} from "../../../shared/langs";
+
+export { withPageHelpers }
+
+class Breadcrumb extends Component {
+ constructor(props) {
+ super(props);
+ }
+
+ static propTypes = {
+ route: PropTypes.object.isRequired,
+ params: PropTypes.object.isRequired,
+ resolved: PropTypes.object.isRequired
+ }
+
+ renderElement(entry, isActive) {
+ const params = this.props.params;
+ let title;
+ if (typeof entry.title === 'function') {
+ title = entry.title(this.props.resolved, params);
+ } else {
+ title = entry.title;
+ }
+
+ if (isActive) {
+ return {title} ;
+
+ } else if (entry.externalLink) {
+ let externalLink;
+ if (typeof entry.externalLink === 'function') {
+ externalLink = entry.externalLink(params);
+ } else {
+ externalLink = entry.externalLink;
+ }
+
+ return {title} ;
+
+ } else if (entry.link) {
+ let link;
+ if (typeof entry.link === 'function') {
+ link = entry.link(params);
+ } else {
+ link = entry.link;
+ }
+ return {title} ;
+
+ } else {
+ return {title} ;
+ }
+ }
+
+ render() {
+ const route = this.props.route;
+
+ const renderedElems = [...route.parents.map(x => this.renderElement(x)), this.renderElement(route, true)];
+
+ return {renderedElems} ;
+ }
+}
+
+class TertiaryNavBar extends Component {
+ static propTypes = {
+ route: PropTypes.object.isRequired,
+ params: PropTypes.object.isRequired,
+ resolved: PropTypes.object.isRequired,
+ className: PropTypes.string
+ }
+
+ renderElement(key, entry) {
+ const params = this.props.params;
+ let title;
+ if (typeof entry.title === 'function') {
+ title = entry.title(this.props.resolved);
+ } else {
+ title = entry.title;
+ }
+
+ let liClassName = 'nav-item';
+ let linkClassName = 'nav-link';
+ if (entry.active) {
+ linkClassName += ' active';
+ }
+
+ if (entry.link) {
+ let link;
+
+ if (typeof entry.link === 'function') {
+ link = entry.link(params);
+ } else {
+ link = entry.link;
+ }
+
+ return {title} ;
+
+ } else if (entry.externalLink) {
+ let externalLink;
+ if (typeof entry.externalLink === 'function') {
+ externalLink = entry.externalLink(params);
+ } else {
+ externalLink = entry.externalLink;
+ }
+
+ return {title} ;
+
+ } else {
+ return {title} ;
+ }
+ }
+
+ render() {
+ const route = this.props.route;
+
+ const keys = Object.keys(route.navs);
+ const renderedElems = [];
+
+ for (const key of keys) {
+ const entry = route.navs[key];
+
+ let visible = true;
+ if (typeof entry.visible === 'function') {
+ visible = entry.visible(this.props.resolved);
+ }
+
+ if (visible) {
+ renderedElems.push(this.renderElement(key, entry));
+ }
+ }
+
+ if (renderedElems.length > 1) {
+ let className = styles.tertiaryNav + ' nav nav-pills';
+ if (this.props.className) {
+ className += ' ' + this.props.className;
+ }
+
+ return ;
+ } else {
+ return null;
+ }
+ }
+}
+
+
+
+function getLoadingMessage(t) {
+ return (
+
+ {t('loading')}
+
+ );
+}
+
+function renderFrameWithContent(t, panelInFullScreen, showSidebar, primaryMenu, secondaryMenu, content) {
+ if (panelInFullScreen) {
+ return (
+
+ );
+
+ } else {
+ return (
+
+
+
+ {showSidebar &&
+
+
+
+ }
+
+ Mailtrain
+
+
+
+
+
+
+ {primaryMenu}
+
+
+
+
+
+ {showSidebar &&
+
+ {secondaryMenu}
+
+ }
+
+ {content}
+
+
+
+
+
+ );
+ }
+}
+
+
+@withComponentMixins([
+ withTranslation
+])
+class PanelRoute extends Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ panelInFullScreen: props.route.panelInFullScreen
+ };
+
+ this.sidebarAnimationNodeListener = evt => {
+ if (evt.propertyName === 'left') {
+ this.forceUpdate();
+ }
+ };
+
+ this.setPanelInFullScreen = panelInFullScreen => this.setState({ panelInFullScreen });
+ }
+
+ static propTypes = {
+ route: PropTypes.object.isRequired,
+ location: PropTypes.object.isRequired,
+ match: PropTypes.object.isRequired,
+ flashMessage: PropTypes.object
+ }
+
+ registerSidebarAnimationListener() {
+ if (this.sidebarAnimationNode) {
+ this.sidebarAnimationNode.addEventListener("transitionend", this.sidebarAnimationNodeListener);
+ }
+ }
+
+ componentDidMount() {
+ this.registerSidebarAnimationListener();
+ }
+
+ componentDidUpdate(prevProps) {
+ this.registerSidebarAnimationListener();
+ }
+
+ render() {
+ const t = this.props.t;
+ const route = this.props.route;
+ const params = this.props.match.params;
+
+ const showSidebar = !!route.secondaryMenuComponent;
+
+ const panelInFullScreen = this.state.panelInFullScreen;
+
+ const render = (resolved, permissions) => {
+ let primaryMenu = null;
+ let secondaryMenu = null;
+ let content = null;
+
+ if (resolved && permissions) {
+ const compProps = {
+ match: this.props.match,
+ location: this.props.location,
+ resolved,
+ permissions,
+ setPanelInFullScreen: this.setPanelInFullScreen,
+ panelInFullScreen: this.state.panelInFullScreen
+ };
+
+ let panel;
+ if (route.panelComponent) {
+ panel = React.createElement(route.panelComponent, compProps);
+ } else if (route.panelRender) {
+ panel = route.panelRender(compProps);
+ }
+
+ if (route.primaryMenuComponent) {
+ primaryMenu = React.createElement(route.primaryMenuComponent, compProps);
+ }
+
+ if (route.secondaryMenuComponent) {
+ secondaryMenu = React.createElement(route.secondaryMenuComponent, compProps);
+ }
+
+ const panelContent = (
+
+ {this.props.flashMessage}
+ {panel}
+
+ );
+
+ if (panelInFullScreen) {
+ content = panelContent;
+ } else {
+ content = (
+ <>
+
+
+
+
+ {panelContent}
+ >
+ );
+ }
+
+ } else {
+ content = getLoadingMessage(t);
+ }
+
+ return renderFrameWithContent(t, panelInFullScreen, showSidebar, primaryMenu, secondaryMenu, content);
+ };
+
+
+ return ;
+ }
+}
+
+
+export class BeforeUnloadListeners {
+ constructor() {
+ this.listeners = new Set();
+ }
+
+ register(listener) {
+ this.listeners.add(listener);
+ }
+
+ deregister(listener) {
+ this.listeners.delete(listener);
+ }
+
+ shouldUnloadBeCancelled() {
+ for (const lst of this.listeners) {
+ if (lst.handler()) return true;
+ }
+
+ return false;
+ }
+
+ async shouldUnloadBeCancelledAsync() {
+ for (const lst of this.listeners) {
+ if (await lst.handlerAsync()) return true;
+ }
+
+ return false;
+ }
+}
+
+@withRouter
+@withComponentMixins([
+ withTranslation,
+ withErrorHandling
+], ['onNavigationConfirmationDialog'])
+export class SectionContent extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ flashMessageText: ''
+ };
+
+ this.historyUnlisten = props.history.listen((location, action) => {
+ // noinspection JSIgnoredPromiseFromCall
+ this.closeFlashMessage();
+ });
+
+ this.beforeUnloadListeners = new BeforeUnloadListeners();
+ this.beforeUnloadHandler = ::this.onBeforeUnload;
+ this.historyUnblock = null;
+ }
+
+ static propTypes = {
+ structure: PropTypes.object.isRequired,
+ root: PropTypes.string.isRequired
+ }
+
+ onBeforeUnload(event) {
+ if (this.beforeUnloadListeners.shouldUnloadBeCancelled()) {
+ event.preventDefault();
+ event.returnValue = '';
+ }
+ }
+
+ onNavigationConfirmationDialog(message, callback) {
+ this.beforeUnloadListeners.shouldUnloadBeCancelledAsync().then(res => {
+ if (res) {
+ const allowTransition = window.confirm(message);
+ callback(allowTransition);
+ } else {
+ callback(true);
+ }
+ });
+ }
+
+ componentDidMount() {
+ window.addEventListener('beforeunload', this.beforeUnloadHandler);
+ this.historyUnblock = this.props.history.block('Changes you made may not be saved. Are you sure you want to leave this page?');
+ }
+
+ componentWillUnmount() {
+ window.removeEventListener('beforeunload', this.beforeUnloadHandler);
+ this.historyUnblock();
+ }
+
+ setFlashMessage(severity, text) {
+ this.setState({
+ flashMessageText: text,
+ flashMessageSeverity: severity
+ });
+ }
+
+ navigateTo(path) {
+ this.props.history.push(path);
+ }
+
+ navigateBack() {
+ this.props.history.goBack();
+ }
+
+ navigateToWithFlashMessage(path, severity, text) {
+ this.props.history.push(path);
+ this.setFlashMessage(severity, text);
+ }
+
+ ensureAuthenticated() {
+ if (!mailtrainConfig.isAuthenticated) {
+ this.navigateTo('/login?next=' + encodeURIComponent(window.location.pathname));
+ }
+ }
+
+ registerBeforeUnloadHandlers(handlers) {
+ this.beforeUnloadListeners.register(handlers);
+ }
+
+ deregisterBeforeUnloadHandlers(handlers) {
+ this.beforeUnloadListeners.deregister(handlers);
+ }
+
+ errorHandler(error) {
+ if (error instanceof interoperableErrors.NotLoggedInError) {
+ if (window.location.pathname !== '/login') { // There may be multiple async requests failing at the same time. So we take the pathname only from the first one.
+ this.navigateTo('/login?next=' + encodeURIComponent(window.location.pathname));
+ }
+ } else if (error.response && error.response.data && error.response.data.message) {
+ console.error(error);
+ this.navigateToWithFlashMessage(this.props.root, 'danger', error.response.data.message);
+ } else {
+ console.error(error);
+ this.navigateToWithFlashMessage(this.props.root, 'danger', error.message);
+ }
+ return true;
+ }
+
+ async closeFlashMessage() {
+ this.setState({
+ flashMessageText: ''
+ });
+ }
+
+ renderRoute(route) {
+ const t = this.props.t;
+
+ const render = props => {
+ let flashMessage;
+ if (this.state.flashMessageText) {
+ flashMessage = {this.state.flashMessageText} ;
+ }
+
+ return renderRoute(
+ route,
+ PanelRoute,
+ () => renderFrameWithContent(t,false, false, null, null, getLoadingMessage(this.props.t)),
+ flashMessage,
+ props
+ );
+ };
+
+ return
+ }
+
+ render() {
+ const routes = getRoutes(this.props.structure);
+
+ return (
+
+ {routes.map(x => this.renderRoute(x))}
+
+ );
+ }
+}
+
+@withComponentMixins([
+ withTranslation
+])
+export class Section extends Component {
+ constructor(props) {
+ super(props);
+ this.getUserConfirmationHandler = ::this.onGetUserConfirmation;
+ this.sectionContent = null;
+ }
+
+ static propTypes = {
+ structure: PropTypes.oneOfType([PropTypes.object, PropTypes.func]).isRequired,
+ root: PropTypes.string.isRequired
+ }
+
+ onGetUserConfirmation(message, callback) {
+ this.sectionContent.onNavigationConfirmationDialog(message, callback);
+ }
+
+ render() {
+ let structure = this.props.structure;
+ if (typeof structure === 'function') {
+ structure = structure(this.props.t);
+ }
+
+ return (
+
+ this.sectionContent = node} root={this.props.root} structure={structure} />
+
+ );
+ }
+}
+
+
+export class Title extends Component {
+ render() {
+ return (
+
+
{this.props.children}
+
+
+ );
+ }
+}
+
+export class Toolbar extends Component {
+ static propTypes = {
+ className: PropTypes.string,
+ };
+
+ render() {
+ let className = styles.toolbar + ' ' + styles.buttonRow;
+ if (this.props.className) {
+ className += ' ' + this.props.className;
+ }
+
+ return (
+
+ {this.props.children}
+
+ );
+ }
+}
+
+export class LinkButton extends Component {
+ static propTypes = {
+ label: PropTypes.string,
+ icon: PropTypes.string,
+ className: PropTypes.string,
+ to: PropTypes.string
+ };
+
+ render() {
+ const props = this.props;
+
+ return (
+
+ );
+ }
+}
+
+export class DropdownLink extends Component {
+ static propTypes = {
+ to: PropTypes.string,
+ className: PropTypes.string
+ }
+
+ render() {
+ const props = this.props;
+
+ const clsName = "dropdown-item" + (props.className ? " " + props.className : "")
+ return (
+ {props.children}
+ );
+ }
+}
+
+export class NavLink extends Component {
+ static propTypes = {
+ to: PropTypes.string,
+ icon: PropTypes.string,
+ iconFamily: PropTypes.string,
+ className: PropTypes.string
+ }
+
+ render() {
+ const props = this.props;
+
+ const clsName = "nav-item" + (props.className ? " " + props.className : "")
+
+ let icon;
+ if (props.icon) {
+ icon = <>{' '}>;
+ }
+
+ return (
+ {icon}{props.children}
+ );
+ }
+}
+
+export class NavActionLink extends Component {
+ static propTypes = {
+ onClickAsync: PropTypes.func,
+ icon: PropTypes.string,
+ iconFamily: PropTypes.string,
+ className: PropTypes.string
+ }
+
+ render() {
+ const props = this.props;
+
+ const clsName = "nav-item" + (props.className ? " " + props.className : "")
+
+ let icon;
+ if (props.icon) {
+ icon = <>{' '}>;
+ }
+
+ return (
+ {icon}{props.children}
+ );
+ }
+}
+
+export class NavDropdown extends Component {
+ static propTypes = {
+ label: PropTypes.string,
+ icon: PropTypes.string,
+ className: PropTypes.string,
+ menuClassName: PropTypes.string
+ }
+
+ render() {
+ const props = this.props;
+
+ const className = 'nav-item dropdown' + (props.className ? ' ' + props.className : '');
+ const menuClassName = 'dropdown-menu' + (props.menuClassName ? ' ' + props.menuClassName : '');
+
+ return (
+
+ {props.icon ?
+
+ {' '}{props.label}
+
+ :
+
+ {props.label}
+
+ }
+
+
+ );
+ }
+}
+
+
+export const requiresAuthenticatedUser = createComponentMixin({
+ deps: [withPageHelpers],
+ decoratorFn: (TargetClass, InnerClass) => {
+ class RequiresAuthenticatedUser extends React.Component {
+ constructor(props) {
+ super(props);
+ props.sectionContent.ensureAuthenticated();
+ }
+
+ render() {
+ return
+ }
+ }
+
+ return {
+ cls: RequiresAuthenticatedUser
+ };
+ }
+});
+
+export function getLanguageChooser(t) {
+ const languageOptions = [];
+ for (const lng of mailtrainConfig.enabledLanguages) {
+ const langDesc = getLang(lng);
+ const label = langDesc.getLabel(t);
+
+ languageOptions.push(
+ i18n.changeLanguage(langDesc.longCode)}>{label}
+ )
+ }
+
+ const currentLngCode = getLang(i18n.language).getShortLabel(t);
+
+ const languageChooser = (
+
+ {languageOptions}
+
+ );
+
+ return languageChooser;
+}
\ No newline at end of file
diff --git a/client/src/lib/permissions.js b/client/src/lib/permissions.js
new file mode 100644
index 00000000..ceb7d035
--- /dev/null
+++ b/client/src/lib/permissions.js
@@ -0,0 +1,8 @@
+'use strict';
+
+import {getUrl} from "./urls";
+import axios from "./axios";
+
+export async function checkPermissions(request) {
+ return await axios.post(getUrl('rest/permissions-check'), request);
+}
diff --git a/client/src/lib/public-path.js b/client/src/lib/public-path.js
new file mode 100644
index 00000000..40acd017
--- /dev/null
+++ b/client/src/lib/public-path.js
@@ -0,0 +1,5 @@
+'use strict';
+
+import {getUrl} from "./urls";
+
+__webpack_public_path__ = getUrl('client/');
diff --git a/client/src/lib/sandbox-common.scss b/client/src/lib/sandbox-common.scss
new file mode 100644
index 00000000..818b31a9
--- /dev/null
+++ b/client/src/lib/sandbox-common.scss
@@ -0,0 +1,90 @@
+$navbarHeight: 34px;
+$editorNormalHeight: 800px !default;
+
+.editor {
+ .host {
+ @if $editorNormalHeight {
+ height: $editorNormalHeight;
+ }
+ }
+}
+
+.editorFullscreen {
+ position: fixed;
+ top: 0px;
+ bottom: 0px;
+ left: 0px;
+ right: 0px;
+ z-index: 1000;
+ background: white;
+ margin-top: $navbarHeight;
+
+ .navbar {
+ margin-top: -$navbarHeight;
+ }
+
+ .host {
+ height: 100%;
+ }
+}
+
+.navbar {
+ background: #f86c6b;
+ width: 100%;
+ height: $navbarHeight;
+ display: flex;
+ justify-content: space-between;
+}
+
+.navbarLeft {
+ .logo {
+ display: inline-block;
+ height: $navbarHeight;
+ padding: 5px 0 5px 10px;
+ filter: brightness(0) invert(1);
+ }
+
+ .title {
+ display: inline-block;
+ padding: 5px 0 5px 10px;
+ font-size: 18px;
+ font-weight: bold;
+ float: left;
+ color: white;
+ height: $navbarHeight;
+ }
+}
+
+.navbarRight {
+ .btn, .btnDisabled {
+ display: inline-block;
+ padding: 0px 15px;
+ line-height: $navbarHeight;
+ text-align: center;
+ font-size: 14px;
+ font-weight: bold;
+ font-family: sans-serif;
+ cursor: pointer;
+
+ &, &:not([href]):not([tabindex]) { // This is to override reboot.scss in bootstrap
+ color: white;
+ }
+ }
+
+ .btn:hover {
+ background-color: #c05454;
+ text-decoration: none;
+
+ &, &:not([href]):not([tabindex]) { // This is to override reboot.scss in bootstrap
+ color: white;
+ }
+ }
+
+ .btnDisabled {
+ cursor: default;
+
+ &, &:not([href]):not([tabindex]) { // This is to override reboot.scss in bootstrap
+ color: #621d1d;
+ }
+ }
+}
diff --git a/client/src/lib/sandboxed-ckeditor-root.js b/client/src/lib/sandboxed-ckeditor-root.js
new file mode 100644
index 00000000..6ab2f49f
--- /dev/null
+++ b/client/src/lib/sandboxed-ckeditor-root.js
@@ -0,0 +1,135 @@
+'use strict';
+
+import './public-path';
+
+import React, {Component} from 'react';
+import ReactDOM from 'react-dom';
+import {TranslationRoot, withTranslation} from './i18n';
+import {parentRPC, UntrustedContentRoot} from './untrusted';
+import PropTypes from "prop-types";
+import styles from "./sandboxed-ckeditor.scss";
+import {getPublicUrl, getSandboxUrl, getTrustedUrl} from "./urls";
+import {base, unbase} from "../../../shared/templates";
+
+import CKEditor from "react-ckeditor-component";
+
+import {initialHeight} from "./sandboxed-ckeditor-shared";
+import {withComponentMixins} from "./decorator-helpers";
+
+
+@withComponentMixins([
+ withTranslation
+])
+class CKEditorSandbox extends Component {
+ constructor(props) {
+ super(props);
+
+ const trustedUrlBase = getTrustedUrl();
+ const sandboxUrlBase = getSandboxUrl();
+ const publicUrlBase = getPublicUrl();
+ const source = this.props.initialSource && base(this.props.initialSource, trustedUrlBase, sandboxUrlBase, publicUrlBase);
+
+ this.state = {
+ source
+ };
+ }
+
+ static propTypes = {
+ entityTypeId: PropTypes.string,
+ entityId: PropTypes.number,
+ initialSource: PropTypes.string
+ }
+
+ async exportState(method, params) {
+ const trustedUrlBase = getTrustedUrl();
+ const sandboxUrlBase = getSandboxUrl();
+ const publicUrlBase = getPublicUrl();
+
+ const preHtml = ' ';
+ const postHtml = '';
+
+ const unbasedSource = unbase(this.state.source, trustedUrlBase, sandboxUrlBase, publicUrlBase, true);
+
+ return {
+ source: unbasedSource,
+ html: preHtml + unbasedSource + postHtml
+ };
+ }
+
+ async setHeight(methods, params) {
+ this.node.editorInstance.resize('100%', params);
+ }
+
+ componentDidMount() {
+ parentRPC.setMethodHandler('exportState', ::this.exportState);
+ parentRPC.setMethodHandler('setHeight', ::this.setHeight);
+ }
+
+ render() {
+ const config = {
+ toolbarGroups: [
+ {
+ name: "document",
+ groups: ["document", "doctools"]
+ },
+ {
+ name: "clipboard",
+ groups: ["clipboard", "undo"]
+ },
+ {name: "styles"},
+ {
+ name: "basicstyles",
+ groups: ["basicstyles", "cleanup"]
+ },
+ {
+ name: "editing",
+ groups: ["find", "selection", "spellchecker"]
+ },
+ {name: "forms"},
+ {
+ name: "paragraph",
+ groups: ["list",
+ "indent", "blocks", "align", "bidi"]
+ },
+ {name: "links"},
+ {name: "insert"},
+ {name: "colors"},
+ {name: "tools"},
+ {name: "others"},
+ {
+ name: "document-mode",
+ groups: ["mode"]
+ }
+ ],
+
+ removeButtons: 'Underline,Subscript,Superscript,Maximize',
+ resize_enabled: false,
+ height: initialHeight
+ };
+
+ return (
+
+ this.node = node}
+ content={this.state.source}
+ events={{
+ change: evt => this.setState({source: evt.editor.getData()}),
+ }}
+ config={config}
+ />
+
+ );
+ }
+}
+
+export default function() {
+ parentRPC.init();
+
+ ReactDOM.render(
+
+ } />
+ ,
+ document.getElementById('root')
+ );
+};
+
+
diff --git a/client/src/lib/sandboxed-ckeditor-shared.js b/client/src/lib/sandboxed-ckeditor-shared.js
new file mode 100644
index 00000000..8754615f
--- /dev/null
+++ b/client/src/lib/sandboxed-ckeditor-shared.js
@@ -0,0 +1,3 @@
+'use strict';
+
+export const initialHeight = 600;
diff --git a/client/src/lib/sandboxed-ckeditor.js b/client/src/lib/sandboxed-ckeditor.js
new file mode 100644
index 00000000..7e0c96d1
--- /dev/null
+++ b/client/src/lib/sandboxed-ckeditor.js
@@ -0,0 +1,112 @@
+'use strict';
+
+import React, {Component} from 'react';
+import {withTranslation} from './i18n';
+import PropTypes from "prop-types";
+import styles from "./sandboxed-ckeditor.scss";
+
+import {UntrustedContentHost} from './untrusted';
+import {Icon} from "./bootstrap-components";
+import {getTrustedUrl} from "./urls";
+
+import {initialHeight} from "./sandboxed-ckeditor-shared";
+import {withComponentMixins} from "./decorator-helpers";
+
+const navbarHeight = 34; // Sync this with navbarheight in sandboxed-ckeditor.scss
+
+@withComponentMixins([
+ withTranslation
+], ['exportState'])
+export class CKEditorHost extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ fullscreen: false
+ };
+
+ this.onWindowResizeHandler = ::this.onWindowResize;
+ this.contentNodeRefHandler = node => this.contentNode = node;
+ }
+
+ static propTypes = {
+ entityTypeId: PropTypes.string,
+ entity: PropTypes.object,
+ initialSource: PropTypes.string,
+ title: PropTypes.string,
+ onSave: PropTypes.func,
+ canSave: PropTypes.bool,
+ onTestSend: PropTypes.func,
+ onShowExport: PropTypes.func,
+ onFullscreenAsync: PropTypes.func
+ }
+
+ async toggleFullscreenAsync() {
+ const fullscreen = !this.state.fullscreen;
+ this.setState({
+ fullscreen
+ });
+ await this.props.onFullscreenAsync(fullscreen);
+
+ let newHeight;
+ if (fullscreen) {
+ newHeight = window.innerHeight - navbarHeight;
+ } else {
+ newHeight = initialHeight;
+ }
+ await this.contentNode.ask('setHeight', newHeight);
+ }
+
+ async exportState() {
+ return await this.contentNode.ask('exportState');
+ }
+
+ onWindowResize() {
+ if (this.state.fullscreen) {
+ const newHeight = window.innerHeight - navbarHeight;
+ // noinspection JSIgnoredPromiseFromCall
+ this.contentNode.ask('setHeight', newHeight);
+ }
+ }
+
+ componentDidMount() {
+ window.addEventListener('resize', this.onWindowResizeHandler, false);
+ }
+
+ componentWillUnmount() {
+ window.removeEventListener('resize', this.onWindowResizeHandler, false);
+ }
+
+ render() {
+ const t = this.props.t;
+
+ const editorData = {
+ entityTypeId: this.props.entityTypeId,
+ entityId: this.props.entity.id,
+ initialSource: this.props.initialSource
+ };
+
+ const tokenData = {
+ entityTypeId: this.props.entityTypeId,
+ entityId: this.props.entity.id
+ };
+
+ return (
+
+
+
+ {this.state.fullscreen &&
}
+
{this.props.title}
+
+
+
+
+
+ );
+ }
+}
diff --git a/client/src/lib/sandboxed-ckeditor.scss b/client/src/lib/sandboxed-ckeditor.scss
new file mode 100644
index 00000000..36737f23
--- /dev/null
+++ b/client/src/lib/sandboxed-ckeditor.scss
@@ -0,0 +1,7 @@
+$editorNormalHeight: false;
+@import "sandbox-common";
+
+.sandbox {
+ height: 100%;
+ overflow: hidden;
+}
\ No newline at end of file
diff --git a/client/src/lib/sandboxed-codeeditor-root.js b/client/src/lib/sandboxed-codeeditor-root.js
new file mode 100644
index 00000000..97326e06
--- /dev/null
+++ b/client/src/lib/sandboxed-codeeditor-root.js
@@ -0,0 +1,220 @@
+'use strict';
+
+import './public-path';
+
+import React, {Component} from 'react';
+import ReactDOM from 'react-dom';
+import {TranslationRoot, withTranslation} from './i18n';
+import {parentRPC, UntrustedContentRoot} from './untrusted';
+import PropTypes from "prop-types";
+import styles from "./sandboxed-codeeditor.scss";
+import {getPublicUrl, getSandboxUrl, getTrustedUrl} from "./urls";
+import {base, unbase} from "../../../shared/templates";
+import ACEEditorRaw from 'react-ace';
+import 'brace/theme/github';
+import 'brace/ext/searchbox';
+import 'brace/mode/html';
+import {CodeEditorSourceType} from "./sandboxed-codeeditor-shared";
+
+import mjml2html from "./mjml";
+
+import juice from "juice";
+
+import {withComponentMixins} from "./decorator-helpers";
+
+const refreshTimeout = 1000;
+
+@withComponentMixins([
+ withTranslation
+])
+class CodeEditorSandbox extends Component {
+ constructor(props) {
+ super(props);
+
+ let defaultSource;
+
+ if (props.sourceType === CodeEditorSourceType.MJML) {
+ defaultSource =
+ '\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' ';
+
+ } else if (props.sourceType === CodeEditorSourceType.HTML) {
+ defaultSource =
+ '\n' +
+ '\n' +
+ '\n' +
+ ' \n' +
+ ' Title of the document \n' +
+ '\n' +
+ '\n' +
+ ' Content of the document......\n' +
+ '\n' +
+ '';
+ }
+
+
+ const trustedUrlBase = getTrustedUrl();
+ const sandboxUrlBase = getSandboxUrl();
+ const publicUrlBase = getPublicUrl();
+ const source = this.props.initialSource ? base(this.props.initialSource, trustedUrlBase, sandboxUrlBase, publicUrlBase) : defaultSource;
+
+ this.state = {
+ source,
+ preview: props.initialPreview,
+ wrapEnabled: props.initialWrap
+ };
+ this.state.previewContents = this.getHtml();
+
+ this.onCodeChangedHandler = ::this.onCodeChanged;
+
+ this.refreshHandler = ::this.refresh;
+ this.refreshTimeoutId = null;
+
+ this.onMessageFromPreviewHandler = ::this.onMessageFromPreview;
+ this.previewScroll = {x: 0, y: 0};
+ }
+
+ static propTypes = {
+ entityTypeId: PropTypes.string,
+ entityId: PropTypes.number,
+ initialSource: PropTypes.string,
+ sourceType: PropTypes.string,
+ initialPreview: PropTypes.bool,
+ initialWrap: PropTypes.bool
+ }
+
+ async exportState(method, params) {
+ const trustedUrlBase = getTrustedUrl();
+ const sandboxUrlBase = getSandboxUrl();
+ const publicUrlBase = getPublicUrl();
+ return {
+ html: unbase(this.getHtml(), trustedUrlBase, sandboxUrlBase, publicUrlBase, true),
+ source: unbase(this.state.source, trustedUrlBase, sandboxUrlBase, publicUrlBase, true)
+ };
+ }
+
+ async setPreview(method, preview) {
+ this.setState({
+ preview
+ });
+ }
+
+ async setWrap(method, wrap) {
+ this.setState({
+ wrapEnabled: wrap
+ });
+ }
+
+ componentDidMount() {
+ parentRPC.setMethodHandler('exportState', ::this.exportState);
+ parentRPC.setMethodHandler('setPreview', ::this.setPreview);
+ parentRPC.setMethodHandler('setWrap', ::this.setWrap);
+
+ window.addEventListener('message', this.onMessageFromPreviewHandler, false);
+ }
+
+ componentWillUnmount() {
+ clearTimeout(this.refreshTimeoutId);
+ }
+
+ getHtml() {
+ let contents;
+ if (this.props.sourceType === CodeEditorSourceType.MJML) {
+ try {
+ const res = mjml2html(this.state.source);
+ contents = res.html;
+ } catch (err) {
+ contents = '';
+ }
+ } else if (this.props.sourceType === CodeEditorSourceType.HTML) {
+ contents = juice(this.state.source);
+ }
+
+ return contents;
+ }
+
+ onCodeChanged(data) {
+ this.setState({
+ source: data
+ });
+
+ if (!this.refreshTimeoutId) {
+ this.refreshTimeoutId = setTimeout(() => this.refresh(), refreshTimeout);
+ }
+ }
+
+ onMessageFromPreview(evt) {
+ if (evt.data.type === 'scroll') {
+ this.previewScroll = evt.data.data;
+ }
+ }
+
+ refresh() {
+ this.refreshTimeoutId = null;
+
+ this.setState({
+ previewContents: this.getHtml()
+ });
+ }
+
+ render() {
+ const previewScript =
+ '(function() {\n' +
+ ' function reportScroll() { window.parent.postMessage({type: \'scroll\', data: {x: window.scrollX, y: window.scrollY}}, \'*\'); }\n' +
+ ' reportScroll();\n' +
+ ' window.addEventListener(\'scroll\', reportScroll);\n' +
+ ' window.addEventListener(\'load\', function(evt) { window.scrollTo(' + this.previewScroll.x + ',' + this.previewScroll.y +'); });\n' +
+ '})();\n';
+
+ const previewContents = this.state.previewContents.replace(/<\s*head\s*>/i, ``);
+
+ return (
+
+
+ {
+ this.state.preview &&
+
+
+
+ }
+
+ );
+ }
+}
+
+export default function() {
+ parentRPC.init();
+
+ ReactDOM.render(
+
+ } />
+ ,
+ document.getElementById('root')
+ );
+};
+
+
diff --git a/client/src/lib/sandboxed-codeeditor-shared.js b/client/src/lib/sandboxed-codeeditor-shared.js
new file mode 100644
index 00000000..bd886fa4
--- /dev/null
+++ b/client/src/lib/sandboxed-codeeditor-shared.js
@@ -0,0 +1,11 @@
+'use strict';
+
+export const CodeEditorSourceType = {
+ MJML: 'mjml',
+ HTML: 'html'
+};
+
+export const getCodeEditorSourceTypeOptions = t => [
+ {key: CodeEditorSourceType.MJML, label: t('mjml')},
+ {key: CodeEditorSourceType.HTML, label: t('html')}
+];
diff --git a/client/src/lib/sandboxed-codeeditor.js b/client/src/lib/sandboxed-codeeditor.js
new file mode 100644
index 00000000..21d59a8a
--- /dev/null
+++ b/client/src/lib/sandboxed-codeeditor.js
@@ -0,0 +1,109 @@
+'use strict';
+
+import React, {Component} from 'react';
+import {withTranslation} from './i18n';
+import PropTypes from "prop-types";
+import styles from "./sandboxed-codeeditor.scss";
+
+import {UntrustedContentHost} from './untrusted';
+import {Icon} from "./bootstrap-components";
+import {getTrustedUrl} from "./urls";
+import {withComponentMixins} from "./decorator-helpers";
+
+@withComponentMixins([
+ withTranslation
+], ['exportState'])
+export class CodeEditorHost extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ fullscreen: false,
+ preview: true,
+ wrap: true
+ };
+
+ this.contentNodeRefHandler = node => this.contentNode = node;
+ }
+
+ static propTypes = {
+ entityTypeId: PropTypes.string,
+ entity: PropTypes.object,
+ initialSource: PropTypes.string,
+ sourceType: PropTypes.string,
+ title: PropTypes.string,
+ onSave: PropTypes.func,
+ canSave: PropTypes.bool,
+ onTestSend: PropTypes.func,
+ onShowExport: PropTypes.func,
+ onFullscreenAsync: PropTypes.func
+ }
+
+ async toggleFullscreenAsync() {
+ const fullscreen = !this.state.fullscreen;
+ this.setState({
+ fullscreen
+ });
+ await this.props.onFullscreenAsync(fullscreen);
+ }
+
+ async togglePreviewAsync() {
+ const preview = !this.state.preview;
+ this.setState({
+ preview
+ });
+
+ await this.contentNode.ask('setPreview', preview);
+ }
+
+ async toggleWrapAsync() {
+ const wrap = !this.state.wrap;
+ this.setState({
+ wrap
+ });
+
+ await this.contentNode.ask('setWrap', wrap);
+ }
+
+ async exportState() {
+ return await this.contentNode.ask('exportState');
+ }
+
+ render() {
+ const t = this.props.t;
+
+ const editorData = {
+ entityTypeId: this.props.entityTypeId,
+ entityId: this.props.entity.id,
+ initialSource: this.props.initialSource,
+ sourceType: this.props.sourceType,
+ initialPreview: this.state.preview,
+ initialWrap: this.state.wrap
+ };
+
+ const tokenData = {
+ entityTypeId: this.props.entityTypeId,
+ entityId: this.props.entity.id
+ };
+
+ return (
+
+
+
+ {this.state.fullscreen &&
}
+
{this.props.title}
+
+
+
+
+
+ );
+ }
+}
diff --git a/client/src/lib/sandboxed-codeeditor.scss b/client/src/lib/sandboxed-codeeditor.scss
new file mode 100644
index 00000000..7fcf5604
--- /dev/null
+++ b/client/src/lib/sandboxed-codeeditor.scss
@@ -0,0 +1,35 @@
+@import "sandbox-common";
+
+.sandbox {
+}
+
+.aceEditorWithPreview, .aceEditorWithoutPreview, .preview {
+ position: absolute;
+ height: 100%;
+}
+
+.aceEditorWithPreview {
+ border-right: #e8e8e8 solid 2px;
+ width: 50%;
+}
+
+.aceEditorWithoutPreview {
+ width: 100%;
+}
+
+.preview {
+ border-left: #e8e8e8 solid 2px;
+ width: 50%;
+ left: 50%;
+ overflow: hidden;
+
+ iframe {
+ width: 100%;
+ height: 100%;
+ border: 0px none;
+
+ body {
+ margin: 0px;
+ }
+ }
+}
\ No newline at end of file
diff --git a/client/src/lib/sandboxed-grapesjs-root.js b/client/src/lib/sandboxed-grapesjs-root.js
new file mode 100644
index 00000000..f9c2f76d
--- /dev/null
+++ b/client/src/lib/sandboxed-grapesjs-root.js
@@ -0,0 +1,635 @@
+'use strict';
+
+import './public-path';
+
+import React, {Component} from 'react';
+import ReactDOM from 'react-dom';
+import {TranslationRoot, withTranslation} from './i18n';
+import {parentRPC, UntrustedContentRoot} from './untrusted';
+import PropTypes from "prop-types";
+import {getPublicUrl, getSandboxUrl, getTrustedUrl} from "./urls";
+import {base, unbase} from "../../../shared/templates";
+import mjml2html from "./mjml";
+
+import 'grapesjs/dist/css/grapes.min.css';
+import grapesjs from 'grapesjs';
+
+import 'grapesjs-mjml';
+
+import 'grapesjs-preset-newsletter';
+import 'grapesjs-preset-newsletter/dist/grapesjs-preset-newsletter.css';
+
+import "./sandboxed-grapesjs.scss";
+
+import axios from './axios';
+import {GrapesJSSourceType} from "./sandboxed-grapesjs-shared";
+import {withComponentMixins} from "./decorator-helpers";
+
+
+grapesjs.plugins.add('mailtrain-remove-buttons', (editor, opts = {}) => {
+ // This needs to be done in on-load and after gjs plugin because grapesjs-preset-newsletter tries to set titles to all buttons (including those we remove)
+ // see https://github.com/artf/grapesjs-preset-newsletter/blob/e0a91636973a5a1481e9d7929e57a8869b1db72e/src/index.js#L248
+ editor.on('load', () => {
+ const panelManager = editor.Panels;
+ panelManager.removeButton('options','fullscreen');
+ panelManager.removeButton('options','export-template');
+ });
+});
+
+
+@withComponentMixins([
+ withTranslation
+])
+export class GrapesJSSandbox extends Component {
+ constructor(props) {
+ super(props);
+
+ this.initialized = false;
+
+ this.state = {
+ assets: null
+ };
+ }
+
+ static propTypes = {
+ entityTypeId: PropTypes.string,
+ entityId: PropTypes.number,
+ initialSource: PropTypes.string,
+ initialStyle: PropTypes.string,
+ sourceType: PropTypes.string
+ }
+
+ async exportState(method, params) {
+ const props = this.props;
+
+ const editor = this.editor;
+
+ // If exportState comes during text editing (via RichTextEditor), we need to cancel the editing, so that the
+ // text being edited is stored in the model
+ const sel = editor.getSelected();
+ if (sel && sel.view && sel.view.disableEditing) {
+ sel.view.disableEditing();
+ }
+
+ const trustedUrlBase = getTrustedUrl();
+ const sandboxUrlBase = getSandboxUrl();
+ const publicUrlBase = getPublicUrl();
+
+ const source = unbase(editor.getHtml(), trustedUrlBase, sandboxUrlBase, publicUrlBase, true);
+ const style = unbase(editor.getCss(), trustedUrlBase, sandboxUrlBase, publicUrlBase, true);
+
+ let html;
+
+ if (props.sourceType === GrapesJSSourceType.MJML) {
+ const preMjml = '';
+ const postMjml = ' ';
+ const mjml = preMjml + source + postMjml;
+
+ const mjmlRes = mjml2html(mjml);
+ html = mjmlRes.html;
+
+ } else if (props.sourceType === GrapesJSSourceType.HTML) {
+ const commandManager = editor.Commands;
+
+ const cmdGetCode = commandManager.get('gjs-get-inlined-html');
+ const htmlBody = cmdGetCode.run(editor);
+
+ const preHtml = ' ';
+ const postHtml = '';
+ html = preHtml + unbase(htmlBody, trustedUrlBase, sandboxUrlBase, publicUrlBase, true) + postHtml;
+ }
+
+
+ return {
+ html,
+ style: style,
+ source: source
+ };
+ }
+
+ async fetchAssets() {
+ const props = this.props;
+ const resp = await axios.get(getSandboxUrl(`rest/files-list/${props.entityTypeId}/file/${props.entityId}`));
+ this.setState({
+ assets: resp.data.map( f => ({type: 'image', src: getPublicUrl(`files/${props.entityTypeId}/file/${props.entityId}/${f.filename}`)}) )
+ });
+ }
+
+ componentDidMount() {
+ // noinspection JSIgnoredPromiseFromCall
+ this.fetchAssets();
+ }
+
+ componentDidUpdate() {
+ if (!this.initialized && this.state.assets !== null) {
+ this.initGrapesJs();
+ this.initialized = true;
+ }
+ }
+
+ initGrapesJs() {
+ const props = this.props;
+
+ parentRPC.setMethodHandler('exportState', ::this.exportState);
+
+ const trustedUrlBase = getTrustedUrl();
+ const sandboxUrlBase = getSandboxUrl();
+ const publicUrlBase = getPublicUrl();
+
+ const config = {
+ noticeOnUnload: false,
+ container: this.canvasNode,
+ height: '100%',
+ width: '100%',
+ storageManager:{
+ type: 'none'
+ },
+ assetManager: {
+ assets: this.state.assets,
+ upload: getSandboxUrl(`grapesjs/upload/${this.props.entityTypeId}/${this.props.entityId}`),
+ uploadText: 'Drop images here or click to upload',
+ headers: {
+ 'X-CSRF-TOKEN': '{{csrfToken}}',
+ },
+ autoAdd: true
+ },
+ styleManager: {
+ clearProperties: true,
+ },
+ fromElement: false,
+ components: '',
+ style: '',
+ plugins: [
+ ],
+ pluginsOpts: {
+ }
+ };
+
+ let defaultSource, defaultStyle;
+
+ if (props.sourceType === GrapesJSSourceType.MJML) {
+ defaultSource =
+ '\n' +
+ ' \n' +
+ ' \n' +
+ ' Lorem Ipsum... \n' +
+ ' \n' +
+ ' \n' +
+ ' ';
+
+ defaultStyle = '';
+
+ config.plugins.push('gjs-mjml');
+ config.pluginsOpts['gjs-mjml'] = {
+ preMjml: '',
+ postMjml: ' '
+ };
+
+ } else if (props.sourceType === GrapesJSSourceType.HTML) {
+ defaultSource =
+ '\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' View in browser\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ '
\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' GrapesJS Newsletter Builder\n' +
+ ' \n' +
+ '
\n' +
+ ' \n' +
+ ' \n' +
+ '
\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' Build your newsletters faster than ever\n' +
+ ' \n' +
+ ' \n' +
+ ' Import, build, test and export responsive newsletter templates faster than ever using the GrapesJS Newsletter Builder.\n' +
+ '
\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ '
\n' +
+ ' \n' +
+ ' \n' +
+ '
\n' +
+ ' \n' +
+ ' \n' +
+ '
\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' Built-in Blocks\n' +
+ ' \n' +
+ ' Drag and drop built-in blocks from the right panel and style them in a matter of seconds\n' +
+ '
\n' +
+ ' \n' +
+ ' \n' +
+ '
\n' +
+ ' \n' +
+ ' \n' +
+ '
\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' Toggle images\n' +
+ ' \n' +
+ ' Build a good looking newsletter even without images enabled by the email clients\n' +
+ '
\n' +
+ ' \n' +
+ ' \n' +
+ '
\n' +
+ ' \n' +
+ ' \n' +
+ '
\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' Test it\n' +
+ ' \n' +
+ ' You can send email tests directly from the editor and check how are looking on your email clients\n' +
+ '
\n' +
+ ' \n' +
+ ' \n' +
+ '
\n' +
+ ' \n' +
+ ' \n' +
+ '
\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ ' Responsive\n' +
+ ' \n' +
+ ' Using the device manager you\'ll always send a fully responsive contents\n' +
+ '
\n' +
+ ' \n' +
+ ' \n' +
+ '
\n' +
+ ' \n' +
+ ' \n' +
+ '
\n' +
+ ' \n' +
+ ' \n' +
+ '
\n' +
+ ' \n' +
+ ' \n' +
+ ' \n' +
+ '
\n' +
+ ' \n' +
+ ' \n' +
+ '
';
+
+ defaultStyle =
+ '.link {\n' +
+ ' color: rgb(217, 131, 166);\n' +
+ ' }\n' +
+ ' .row{\n' +
+ ' vertical-align:top;\n' +
+ ' }\n' +
+ ' .main-body{\n' +
+ ' min-height:150px;\n' +
+ ' padding: 5px;\n' +
+ ' width:100%;\n' +
+ ' height:100%;\n' +
+ ' background-color:rgb(234, 236, 237);\n' +
+ ' }\n' +
+ ' .c926{\n' +
+ ' color:rgb(158, 83, 129);\n' +
+ ' width:100%;\n' +
+ ' font-size:50px;\n' +
+ ' }\n' +
+ ' .cell.c849{\n' +
+ ' width:11%;\n' +
+ ' }\n' +
+ ' .c1144{\n' +
+ ' padding: 10px;\n' +
+ ' font-size:17px;\n' +
+ ' font-weight: 300;\n' +
+ ' }\n' +
+ ' .card{\n' +
+ ' min-height:150px;\n' +
+ ' padding: 5px;\n' +
+ ' margin-bottom:20px;\n' +
+ ' height:0px;\n' +
+ ' }\n' +
+ ' .card-cell{\n' +
+ ' background-color:rgb(255, 255, 255);\n' +
+ ' overflow:hidden;\n' +
+ ' border-radius: 3px;\n' +
+ ' padding: 0;\n' +
+ ' text-align:center;\n' +
+ ' }\n' +
+ ' .card.sector{\n' +
+ ' background-color:rgb(255, 255, 255);\n' +
+ ' border-radius: 3px;\n' +
+ ' border-collapse:separate;\n' +
+ ' }\n' +
+ ' .c1271{\n' +
+ ' width:100%;\n' +
+ ' margin: 0 0 15px 0;\n' +
+ ' font-size:50px;\n' +
+ ' color:rgb(120, 197, 214);\n' +
+ ' line-height:250px;\n' +
+ ' text-align:center;\n' +
+ ' }\n' +
+ ' .table100{\n' +
+ ' width:100%;\n' +
+ ' }\n' +
+ ' .c1357{\n' +
+ ' min-height:150px;\n' +
+ ' padding: 5px;\n' +
+ ' margin: auto;\n' +
+ ' height:0px;\n' +
+ ' }\n' +
+ ' .darkerfont{\n' +
+ ' color:rgb(65, 69, 72);\n' +
+ ' }\n' +
+ ' .button{\n' +
+ ' font-size:12px;\n' +
+ ' padding: 10px 20px;\n' +
+ ' background-color:rgb(217, 131, 166);\n' +
+ ' color:rgb(255, 255, 255);\n' +
+ ' text-align:center;\n' +
+ ' border-radius: 3px;\n' +
+ ' font-weight:300;\n' +
+ ' }\n' +
+ ' .table100.c1437{\n' +
+ ' text-align:left;\n' +
+ ' }\n' +
+ ' .cell.cell-bottom{\n' +
+ ' text-align:center;\n' +
+ ' height:51px;\n' +
+ ' }\n' +
+ ' .card-title{\n' +
+ ' font-size:25px;\n' +
+ ' font-weight:300;\n' +
+ ' color:rgb(68, 68, 68);\n' +
+ ' }\n' +
+ ' .card-content{\n' +
+ ' font-size:13px;\n' +
+ ' line-height:20px;\n' +
+ ' color:rgb(111, 119, 125);\n' +
+ ' padding: 10px 20px 0 20px;\n' +
+ ' vertical-align:top;\n' +
+ ' }\n' +
+ ' .container{\n' +
+ ' font-family: Helvetica, serif;\n' +
+ ' min-height:150px;\n' +
+ ' padding: 5px;\n' +
+ ' margin:auto;\n' +
+ ' height:0px;\n' +
+ ' width:90%;\n' +
+ ' max-width:550px;\n' +
+ ' }\n' +
+ ' .cell.c856{\n' +
+ ' vertical-align:middle;\n' +
+ ' }\n' +
+ ' .container-cell{\n' +
+ ' vertical-align:top;\n' +
+ ' font-size:medium;\n' +
+ ' padding-bottom:50px;\n' +
+ ' }\n' +
+ ' .c1790{\n' +
+ ' min-height:150px;\n' +
+ ' padding: 5px;\n' +
+ ' margin:auto;\n' +
+ ' height:0px;\n' +
+ ' }\n' +
+ ' .table100.c1790{\n' +
+ ' min-height:30px;\n' +
+ ' border-collapse:separate;\n' +
+ ' margin: 0 0 10px 0;\n' +
+ ' }\n' +
+ ' .browser-link{\n' +
+ ' font-size:12px;\n' +
+ ' }\n' +
+ ' .top-cell{\n' +
+ ' text-align:right;\n' +
+ ' color:rgb(152, 156, 165);\n' +
+ ' }\n' +
+ ' .table100.c1357{\n' +
+ ' margin: 0;\n' +
+ ' border-collapse:collapse;\n' +
+ ' }\n' +
+ ' .c1769{\n' +
+ ' width:30%;\n' +
+ ' }\n' +
+ ' .c1776{\n' +
+ ' width:70%;\n' +
+ ' }\n' +
+ ' .c1766{\n' +
+ ' margin: 0 auto 10px 0;\n' +
+ ' padding: 5px;\n' +
+ ' width:100%;\n' +
+ ' min-height:30px;\n' +
+ ' }\n' +
+ ' .cell.c1769{\n' +
+ ' width:11%;\n' +
+ ' }\n' +
+ ' .cell.c1776{\n' +
+ ' vertical-align:middle;\n' +
+ ' }\n' +
+ ' .c1542{\n' +
+ ' margin: 0 auto 10px auto;\n' +
+ ' padding:5px;\n' +
+ ' width:100%;\n' +
+ ' }\n' +
+ ' .card-footer{\n' +
+ ' padding: 20px 0;\n' +
+ ' text-align:center;\n' +
+ ' }\n' +
+ ' .c2280{\n' +
+ ' height:150px;\n' +
+ ' margin:0 auto 10px auto;\n' +
+ ' padding:5px 5px 5px 5px;\n' +
+ ' width:100%;\n' +
+ ' }\n' +
+ ' .c2421{\n' +
+ ' padding:10px;\n' +
+ ' }\n' +
+ ' .c2577{\n' +
+ ' padding:10px;\n' +
+ ' }\n' +
+ ' .footer{\n' +
+ ' margin-top: 50px;\n' +
+ ' color:rgb(152, 156, 165);\n' +
+ ' text-align:center;\n' +
+ ' font-size:11px;\n' +
+ ' padding: 5px;\n' +
+ ' }\n' +
+ ' .quote {\n' +
+ ' font-style: italic;\n' +
+ ' }\n' +
+ ' .list-item{\n' +
+ ' height:auto;\n' +
+ ' width:100%;\n' +
+ ' margin: 0 auto 10px auto;\n' +
+ ' padding: 5px;\n' +
+ ' }\n' +
+ ' .list-item-cell{\n' +
+ ' background-color:rgb(255, 255, 255);\n' +
+ ' border-radius: 3px;\n' +
+ ' overflow: hidden;\n' +
+ ' padding: 0;\n' +
+ ' }\n' +
+ ' .list-cell-left{\n' +
+ ' width:30%;\n' +
+ ' padding: 0;\n' +
+ ' }\n' +
+ ' .list-cell-right{\n' +
+ ' width:70%;\n' +
+ ' color:rgb(111, 119, 125);\n' +
+ ' font-size:13px;\n' +
+ ' line-height:20px;\n' +
+ ' padding: 10px 20px 0px 20px;\n' +
+ ' }\n' +
+ ' .list-item-content{\n' +
+ ' border-collapse: collapse;\n' +
+ ' margin: 0 auto;\n' +
+ ' padding: 5px;\n' +
+ ' height:150px;\n' +
+ ' width:100%;\n' +
+ ' }\n' +
+ ' .list-item-image{\n' +
+ ' color:rgb(217, 131, 166);\n' +
+ ' font-size:45px;\n' +
+ ' width: 100%;\n' +
+ ' }\n' +
+ ' .grid-item-image{\n' +
+ ' line-height:150px;\n' +
+ ' font-size:50px;\n' +
+ ' color:rgb(120, 197, 214);\n' +
+ ' margin-bottom:15px;\n' +
+ ' width:100%;\n' +
+ ' }\n' +
+ ' .grid-item-row {\n' +
+ ' margin: 0 auto 10px;\n' +
+ ' padding: 5px 0;\n' +
+ ' width: 100%;\n' +
+ ' }\n' +
+ ' .grid-item-card {\n' +
+ ' width:100%;\n' +
+ ' padding: 5px 0;\n' +
+ ' margin-bottom: 10px;\n' +
+ ' }\n' +
+ ' .grid-item-card-cell{\n' +
+ ' background-color:rgb(255, 255, 255);\n' +
+ ' overflow: hidden;\n' +
+ ' border-radius: 3px;\n' +
+ ' text-align:center;\n' +
+ ' padding: 0;\n' +
+ ' }\n' +
+ ' .grid-item-card-content{\n' +
+ ' font-size:13px;\n' +
+ ' color:rgb(111, 119, 125);\n' +
+ ' padding: 0 10px 20px 10px;\n' +
+ ' width:100%;\n' +
+ ' line-height:20px;\n' +
+ ' }\n' +
+ ' .grid-item-cell2-l{\n' +
+ ' vertical-align:top;\n' +
+ ' padding-right:10px;\n' +
+ ' width:50%;\n' +
+ ' }\n' +
+ ' .grid-item-cell2-r{\n' +
+ ' vertical-align:top;\n' +
+ ' padding-left:10px;\n' +
+ ' width:50%;\n' +
+ ' }';
+
+ config.plugins.push('gjs-preset-newsletter');
+ }
+
+ config.components = props.initialSource ? base(props.initialSource, trustedUrlBase, sandboxUrlBase, publicUrlBase) : defaultSource;
+ config.style = props.initialStyle ? base(props.initialStyle, trustedUrlBase, sandboxUrlBase, publicUrlBase) : defaultStyle;
+
+ config.plugins.push('mailtrain-remove-buttons');
+
+ this.editor = grapesjs.init(config);
+ }
+
+ render() {
+ return (
+
+
this.canvasNode = node}/>
+
+ );
+ }
+}
+
+
+export default function() {
+ parentRPC.init();
+
+ ReactDOM.render(
+
+ } />
+ ,
+ document.getElementById('root')
+ );
+};
+
+
diff --git a/client/src/lib/sandboxed-grapesjs-shared.js b/client/src/lib/sandboxed-grapesjs-shared.js
new file mode 100644
index 00000000..282283dc
--- /dev/null
+++ b/client/src/lib/sandboxed-grapesjs-shared.js
@@ -0,0 +1,11 @@
+'use strict';
+
+export const GrapesJSSourceType = {
+ MJML: 'mjml',
+ HTML: 'html'
+};
+
+export const getGrapesJSSourceTypeOptions = t => [
+ {key: GrapesJSSourceType.MJML, label: t('mjml')},
+ {key: GrapesJSSourceType.HTML, label: t('html')}
+];
diff --git a/client/src/lib/sandboxed-grapesjs.js b/client/src/lib/sandboxed-grapesjs.js
new file mode 100644
index 00000000..42fa23a4
--- /dev/null
+++ b/client/src/lib/sandboxed-grapesjs.js
@@ -0,0 +1,89 @@
+'use strict';
+
+import React, {Component} from 'react';
+import {withTranslation} from './i18n';
+import PropTypes from "prop-types";
+import styles from "./sandboxed-grapesjs.scss";
+
+import {UntrustedContentHost} from './untrusted';
+import {Icon} from "./bootstrap-components";
+import {getTrustedUrl} from "./urls";
+import {withComponentMixins} from "./decorator-helpers";
+import {GrapesJSSourceType} from "./sandboxed-grapesjs-shared";
+
+@withComponentMixins([
+ withTranslation
+], ['exportState'])
+export class GrapesJSHost extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ fullscreen: false
+ };
+
+ this.contentNodeRefHandler = node => this.contentNode = node;
+ }
+
+ static propTypes = {
+ entityTypeId: PropTypes.string,
+ entity: PropTypes.object,
+ initialSource: PropTypes.string,
+ initialStyle: PropTypes.string,
+ sourceType: PropTypes.string,
+ title: PropTypes.string,
+ onSave: PropTypes.func,
+ canSave: PropTypes.bool,
+ onTestSend: PropTypes.func,
+ onShowExport: PropTypes.func,
+ onFullscreenAsync: PropTypes.func
+ }
+
+ async toggleFullscreenAsync() {
+ const fullscreen = !this.state.fullscreen;
+ this.setState({
+ fullscreen
+ });
+ await this.props.onFullscreenAsync(fullscreen);
+ }
+
+ async exportState() {
+ return await this.contentNode.ask('exportState');
+ }
+
+ render() {
+ const t = this.props.t;
+
+ const editorData = {
+ entityTypeId: this.props.entityTypeId,
+ entityId: this.props.entity.id,
+ initialSource: this.props.initialSource,
+ initialStyle: this.props.initialStyle,
+ sourceType: this.props.sourceType
+ };
+
+ const tokenData = {
+ entityTypeId: this.props.entityTypeId,
+ entityId: this.props.entity.id
+ };
+
+ return (
+
+
+
+ {this.state.fullscreen &&
}
+
{this.props.title}
+
+
+
+
+
+ );
+ }
+}
diff --git a/client/src/lib/sandboxed-grapesjs.scss b/client/src/lib/sandboxed-grapesjs.scss
new file mode 100644
index 00000000..1e85d731
--- /dev/null
+++ b/client/src/lib/sandboxed-grapesjs.scss
@@ -0,0 +1,18 @@
+@import "sandbox-common";
+
+:global .grapesjs-body {
+ margin: 0px;
+}
+
+:global .gjs-editor-cont {
+ position: absolute;
+}
+
+:global .gjs-devices-c .gjs-devices {
+ padding-right: 15px;
+}
+
+:global .gjs-pn-devices-c, :global .gjs-pn-views {
+ padding: 4px;
+}
+
diff --git a/client/src/lib/sandboxed-mosaico-root.js b/client/src/lib/sandboxed-mosaico-root.js
new file mode 100644
index 00000000..4a3dfd5a
--- /dev/null
+++ b/client/src/lib/sandboxed-mosaico-root.js
@@ -0,0 +1,158 @@
+'use strict';
+
+import './public-path';
+
+import React, {Component} from 'react';
+import ReactDOM from 'react-dom';
+import {TranslationRoot, withTranslation} from './i18n';
+import {parentRPC, UntrustedContentRoot} from './untrusted';
+import PropTypes from "prop-types";
+import {getPublicUrl, getSandboxUrl, getTrustedUrl} from "./urls";
+import {base, unbase} from "../../../shared/templates";
+import {withComponentMixins} from "./decorator-helpers";
+import juice from "juice";
+
+
+@withComponentMixins([
+ withTranslation
+])
+class MosaicoSandbox extends Component {
+ constructor(props) {
+ super(props);
+ this.viewModel = null;
+ this.state = {
+ };
+ }
+
+ static propTypes = {
+ entityTypeId: PropTypes.string,
+ entityId: PropTypes.number,
+ templateId: PropTypes.number,
+ templatePath: PropTypes.string,
+ initialModel: PropTypes.string,
+ initialMetadata: PropTypes.string
+ }
+
+ async exportState(method, params) {
+ const trustedUrlBase = getTrustedUrl();
+ const sandboxUrlBase = getSandboxUrl();
+ const publicUrlBase = getPublicUrl();
+
+
+ /* juice is called to inline css styles of situations like this
+
+
+ ...
+
+
+ */
+ let html = this.viewModel.export();
+ html = juice(html);
+
+ return {
+ html: unbase(html, trustedUrlBase, sandboxUrlBase, publicUrlBase, true),
+ model: unbase(this.viewModel.exportJSON(), trustedUrlBase, sandboxUrlBase, publicUrlBase),
+ metadata: unbase(this.viewModel.exportMetadata(), trustedUrlBase, sandboxUrlBase, publicUrlBase)
+ };
+ }
+
+ componentDidMount() {
+ parentRPC.setMethodHandler('exportState', ::this.exportState);
+
+ if (!Mosaico.isCompatible()) {
+ alert('Update your browser!');
+ return;
+ }
+
+ const plugins = [...window.mosaicoPlugins];
+
+ plugins.push(viewModel => {
+ this.viewModel = viewModel;
+ });
+
+ // (Custom) HTML postRenderers
+ plugins.push(viewModel => {
+ viewModel.originalExportHTML = viewModel.exportHTML;
+ viewModel.exportHTML = () => {
+ let html = viewModel.originalExportHTML();
+
+ // Chrome workaround begin -----------------------------------------------------------------------------------
+ // Chrome v. 74 (and likely other versions too) has problem with how KO sets data during export.
+ // As the result, the images that have been in the template from previous editing (i.e. before page refresh)
+ // get lost. The code below refreshes the KO binding, thus effectively reloading the images.
+ const isChrome = !!window.chrome && (!!window.chrome.webstore || !!window.chrome.runtime);
+ if (isChrome) {
+ ko.cleanNode(document.body);
+ ko.applyBindings(viewModel, document.body);
+ }
+ // Chrome workaround end -------------------------------------------------------------------------------------
+
+ for (const portRender of window.mosaicoHTMLPostRenderers) {
+ html = postRender(html);
+ }
+ return html;
+ };
+ });
+
+ // Custom convertedUrl (https://github.com/voidlabs/mosaico/blob/a359e263f1af5cf05e2c2d56c771732f2ef6c8c6/src/js/app.js#L42)
+ // which does not complain about mismatch of domains between TRUSTED and PUBLIC
+ plugins.push(viewModel => {
+ ko.bindingHandlers.wysiwygSrc.convertedUrl = (src, method, width, height) => getTrustedUrl(`mosaico/img?src=${encodeURIComponent(src)}&method=${encodeURIComponent(method)}¶ms=${width},${height}`);
+ });
+
+ plugins.unshift(vm => {
+ // This is an override of the default paths in Mosaico
+ vm.logoPath = getTrustedUrl('static/mosaico/rs/img/mosaico32.png');
+ vm.logoUrl = '#';
+ });
+
+ const config = {
+ imgProcessorBackend: getTrustedUrl('mosaico/img'),
+ emailProcessorBackend: getSandboxUrl('mosaico/dl'),
+ fileuploadConfig: {
+ url: getSandboxUrl(`mosaico/upload/${this.props.entityTypeId}/${this.props.entityId}`)
+ },
+ strings: window.mosaicoLanguageStrings
+ };
+
+ const trustedUrlBase = getTrustedUrl();
+ const sandboxUrlBase = getSandboxUrl();
+ const publicUrlBase = getPublicUrl();
+ const metadata = this.props.initialMetadata && JSON.parse(base(this.props.initialMetadata, trustedUrlBase, sandboxUrlBase, publicUrlBase));
+ const model = this.props.initialModel && JSON.parse(base(this.props.initialModel, trustedUrlBase, sandboxUrlBase, publicUrlBase));
+ const template = this.props.templateId ? getSandboxUrl(`mosaico/templates/${this.props.templateId}/index.html`) : this.props.templatePath;
+
+ const allPlugins = plugins.concat(window.mosaicoPlugins);
+
+ Mosaico.start(config, template, metadata, model, allPlugins);
+ }
+
+ render() {
+ return
;
+ }
+}
+
+
+
+export default function() {
+ parentRPC.init();
+
+ ReactDOM.render(
+
+ } />
+ ,
+ document.getElementById('root')
+ );
+};
+
+
diff --git a/client/src/lib/sandboxed-mosaico.js b/client/src/lib/sandboxed-mosaico.js
new file mode 100644
index 00000000..7704c0d1
--- /dev/null
+++ b/client/src/lib/sandboxed-mosaico.js
@@ -0,0 +1,90 @@
+'use strict';
+
+import React, {Component} from 'react';
+import {withTranslation} from './i18n';
+import PropTypes from "prop-types";
+import styles from "./sandboxed-mosaico.scss";
+
+import {UntrustedContentHost} from './untrusted';
+import {Icon} from "./bootstrap-components";
+import {getTrustedUrl} from "./urls";
+import {withComponentMixins} from "./decorator-helpers";
+
+
+@withComponentMixins([
+ withTranslation
+], ['exportState'])
+export class MosaicoHost extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ fullscreen: false
+ };
+
+ this.contentNodeRefHandler = node => this.contentNode = node;
+ }
+
+ static propTypes = {
+ entityTypeId: PropTypes.string,
+ entity: PropTypes.object,
+ title: PropTypes.string,
+ onSave: PropTypes.func,
+ canSave: PropTypes.bool,
+ onTestSend: PropTypes.func,
+ onShowExport: PropTypes.func,
+ onFullscreenAsync: PropTypes.func,
+ templateId: PropTypes.number,
+ templatePath: PropTypes.string,
+ initialModel: PropTypes.string,
+ initialMetadata: PropTypes.string
+ }
+
+ async toggleFullscreenAsync() {
+ const fullscreen = !this.state.fullscreen;
+ this.setState({
+ fullscreen
+ });
+ await this.props.onFullscreenAsync(fullscreen);
+ }
+
+ async exportState() {
+ return await this.contentNode.ask('exportState');
+ }
+
+ render() {
+ const t = this.props.t;
+
+ const editorData = {
+ entityTypeId: this.props.entityTypeId,
+ entityId: this.props.entity.id,
+ templateId: this.props.templateId,
+ templatePath: this.props.templatePath,
+ initialModel: this.props.initialModel,
+ initialMetadata: this.props.initialMetadata
+ };
+
+ const tokenData = {
+ entityTypeId: this.props.entityTypeId,
+ entityId: this.props.entity.id
+ };
+
+ return (
+
+
+
+ {this.state.fullscreen &&
}
+
{this.props.title}
+
+
+
+
+
+ );
+ }
+}
diff --git a/client/src/lib/sandboxed-mosaico.scss b/client/src/lib/sandboxed-mosaico.scss
new file mode 100644
index 00000000..35c5933f
--- /dev/null
+++ b/client/src/lib/sandboxed-mosaico.scss
@@ -0,0 +1,8 @@
+@import "sandbox-common";
+
+:global .mo-standalone {
+ top: 0px;
+ bottom: 0px;
+ width: 100%;
+ position: absolute;
+}
diff --git a/client/src/lib/styles.scss b/client/src/lib/styles.scss
new file mode 100644
index 00000000..8d2daff0
--- /dev/null
+++ b/client/src/lib/styles.scss
@@ -0,0 +1,185 @@
+@import "../scss/variables.scss";
+
+.toolbar {
+ float: right;
+ margin-bottom: 15px;
+}
+
+.form { // This is here to give the styles below higher priority than Bootstrap has
+ :global .DayPicker {
+ border: $input-border-width solid $input-border-color;
+ border-radius: $input-border-radius;
+ padding: $input-padding-y $input-padding-x;
+ }
+
+ :global .form-horizontal .control-label {
+ display: block;
+ }
+
+ :global .form-control[disabled] {
+ cursor: default;
+ background-color: #eeeeee;
+ opacity: 1;
+ }
+
+ :global .ace_editor {
+ border: 1px solid #ccc;
+ }
+
+ .buttonRow:last-child {
+ // This is to move Save/Delete buttons a bit down
+ margin-top: 15px;
+ }
+}
+
+.staticFormGroup {
+ margin-bottom: 15px;
+}
+
+.dayPickerWrapper {
+ text-align: right;
+}
+
+.buttonRow {
+}
+
+.buttonRow > * {
+ margin-right: 15px;
+}
+
+.buttonRow > *:last-child {
+ margin-right: 0px;
+}
+
+.formDisabled {
+ background-color: #eeeeee;
+ opacity: 1;
+}
+
+.formStatus {
+ padding-top: 5px;
+ padding-bottom: 5px;
+}
+
+.dataTableTable {
+ overflow-x: auto;
+}
+
+.actionLinks > * {
+ margin-right: 8px;
+}
+
+.actionLinks > *:last-child {
+ margin-right: 0px;
+}
+
+.tableSelectDropdown {
+ margin-bottom: 15px;
+}
+
+.tableSelectTable.tableSelectTableHidden {
+ display: none;
+ height: 0px;
+ margin-top: -15px;
+}
+
+.tableSelectDropdown input[readonly] {
+ background-color: white;
+}
+
+:global h3.legend {
+ font-size: 21px;
+ margin-bottom: 20px;
+}
+
+.tertiaryNav {
+ justify-content: flex-end;
+ flex-grow: 1;
+ align-self: center;
+
+ margin-left: 5px;
+ margin-right: 5px;
+
+ :global .nav-item .nav-link {
+ padding: 3px 10px;
+ }
+}
+
+.colorPickerSwatchWrapper {
+ padding: 7px;
+ background: #fff;
+ border: 1px solid #AAB2BD;
+ border-radius: 4px;
+ display: inline-block;
+ cursor: pointer;
+
+ .colorPickerSwatchColor {
+ width: 60px;
+ height: 18px;
+ borderRadius: 2px;
+ }
+}
+
+.colorPickerWrapper {
+ text-align: right;
+}
+
+.checkboxText{
+ padding-top: 3px;
+}
+
+.dropZone{
+ padding-top: 20px;
+ padding-bottom: 20px;
+ margin-bottom: 3px;
+ margin-top: 3px;
+ border: 2px solid #E6E9ED;
+ border-radius: 5px;
+ background-color: #FAFAD2;
+ text-align: center;
+ font-size: 20px;
+ color: #808080;
+
+ p:last-child {
+ margin-bottom: 0px;
+ }
+}
+
+.dropZoneActive{
+ border-color: #90EE90;
+ color: #000;
+ background-color: #DDFFDD;
+}
+
+
+.untrustedContent {
+ border: 0px none;
+ width: 100%;
+ overflow: hidden;
+}
+
+.withElementInFullscreen {
+ height: 0px;
+ overflow: hidden;
+}
+
+.iconDisabled {
+ color: $link-color;
+ text-decoration: $link-decoration;
+}
+
+.errorsList {
+ margin-bottom: 0px;
+}
+
+
+:global .modal-dialog {
+ @media (min-width: 768px) {
+ max-width: 700px;
+ }
+
+ @media (min-width: 1000px) {
+ max-width: 900px;
+ }
+}
+
diff --git a/client/src/lib/table.js b/client/src/lib/table.js
new file mode 100644
index 00000000..0d11194f
--- /dev/null
+++ b/client/src/lib/table.js
@@ -0,0 +1,424 @@
+'use strict';
+
+import React, {Component} from 'react';
+import ReactDOMServer from 'react-dom/server';
+import PropTypes from 'prop-types';
+import {withTranslation} from './i18n';
+
+import jQuery from 'jquery';
+
+import 'datatables.net';
+import 'datatables.net-bs4';
+import 'datatables.net-bs4/css/dataTables.bootstrap4.css';
+
+import axios from './axios';
+
+import {withPageHelpers} from './page'
+import {withAsyncErrorHandler, withErrorHandling} from './error-handling';
+import styles from "./styles.scss";
+import {getUrl} from "./urls";
+import {withComponentMixins} from "./decorator-helpers";
+
+//dtFactory();
+//dtSelectFactory();
+
+
+const TableSelectMode = {
+ NONE: 0,
+ SINGLE: 1,
+ MULTI: 2
+};
+
+@withComponentMixins([
+ withTranslation,
+ withErrorHandling,
+ withPageHelpers
+], ['refresh'])
+class Table extends Component {
+ constructor(props) {
+ super(props);
+ this.mounted = false;
+ this.selectionMap = this.getSelectionMap(props);
+ }
+
+ static propTypes = {
+ dataUrl: PropTypes.string,
+ data: PropTypes.array,
+ columns: PropTypes.array,
+ selectMode: PropTypes.number,
+ selection: PropTypes.oneOfType([PropTypes.array, PropTypes.string, PropTypes.number]),
+ selectionKeyIndex: PropTypes.number,
+ selectionAsArray: PropTypes.bool,
+ onSelectionChangedAsync: PropTypes.func,
+ onSelectionDataAsync: PropTypes.func,
+ withHeader: PropTypes.bool,
+ refreshInterval: PropTypes.number,
+ pageLength: PropTypes.number
+ }
+
+ static defaultProps = {
+ selectMode: TableSelectMode.NONE,
+ selectionKeyIndex: 0,
+ pageLength: 50
+ }
+
+ refresh() {
+ if (this.table) {
+ this.table.rows().draw('page');
+ }
+ }
+
+ getSelectionMap(props) {
+ let selArray = [];
+ if (props.selectMode === TableSelectMode.SINGLE && !this.props.selectionAsArray) {
+ if (props.selection !== null && props.selection !== undefined) {
+ selArray = [props.selection];
+ } else {
+ selArray = [];
+ }
+ } else if ((props.selectMode === TableSelectMode.SINGLE && this.props.selectionAsArray) || props.selectMode === TableSelectMode.MULTI) {
+ selArray = props.selection || [];
+ }
+
+ const selMap = new Map();
+
+ for (const elem of selArray) {
+ selMap.set(elem, undefined);
+ }
+
+ if (props.data) {
+ for (const rowData of props.data) {
+ const key = rowData[props.selectionKeyIndex];
+ if (selMap.has(key)) {
+ selMap.set(key, rowData);
+ }
+ }
+
+ } else if (this.table) {
+ this.table.rows().every(function() {
+ const rowData = this.data();
+ const key = rowData[props.selectionKeyIndex];
+ if (selMap.has(key)) {
+ selMap.set(key, rowData);
+ }
+ });
+ }
+
+ return selMap;
+ }
+
+ updateSelectInfo() {
+ if (!this.jqSelectInfo) {
+ return; // If the table is updated very quickly after mounting, the datatable may not be initialized yet.
+ }
+
+ const t = this.props.t;
+
+ const count = this.selectionMap.size;
+ if (this.selectionMap.size > 0) {
+ const jqInfo = jQuery('
' + t('countEntriesSelected', { count }) + ' ');
+ const jqDeselectLink = jQuery('
Deselect all. ').on('click', ::this.deselectAll);
+
+ this.jqSelectInfo.empty().append(jqInfo).append(jqDeselectLink);
+ } else {
+ this.jqSelectInfo.empty();
+ }
+ }
+
+ @withAsyncErrorHandler
+ async fetchData(data, callback) {
+ // This custom ajax fetch function allows us to properly handle the case when the user is not authenticated.
+ const response = await axios.post(getUrl(this.props.dataUrl), data);
+ callback(response.data);
+ }
+
+ @withAsyncErrorHandler
+ async fetchAndNotifySelectionData() {
+ if (this.props.onSelectionDataAsync) {
+ if (!this.props.data) {
+ const keysToFetch = [];
+ for (const pair of this.selectionMap.entries()) {
+ if (!pair[1]) {
+ keysToFetch.push(pair[0]);
+ }
+ }
+
+ if (keysToFetch.length > 0) {
+ const response = await axios.post(getUrl(this.props.dataUrl), {
+ operation: 'getBy',
+ column: this.props.selectionKeyIndex,
+ values: keysToFetch
+ });
+
+ for (const row of response.data) {
+ const key = row[this.props.selectionKeyIndex];
+ if (this.selectionMap.has(key)) {
+ this.selectionMap.set(key, row);
+ }
+ }
+ }
+ }
+
+ // noinspection JSIgnoredPromiseFromCall
+ this.notifySelection(this.props.onSelectionDataAsync, this.selectionMap);
+ }
+ }
+
+ shouldComponentUpdate(nextProps, nextState) {
+ const nextSelectionMap = this.getSelectionMap(nextProps);
+
+ let updateDueToSelectionChange = false;
+ if (nextSelectionMap.size !== this.selectionMap.size) {
+ updateDueToSelectionChange = true;
+ } else {
+ for (const key of this.selectionMap.keys()) {
+ if (!nextSelectionMap.has(key)) {
+ updateDueToSelectionChange = true;
+ break;
+ }
+ }
+ }
+
+ this.selectionMap = nextSelectionMap;
+
+ return updateDueToSelectionChange || this.props.data !== nextProps.data || this.props.dataUrl !== nextProps.dataUrl;
+ }
+
+ componentDidMount() {
+ this.mounted = true;
+
+ const columns = this.props.columns.slice();
+
+ // XSS protection and actions rendering
+ for (const column of columns) {
+ if (column.actions) {
+ const createdCellFn = (td, data, rowData) => {
+ const linksContainer = jQuery(`
`);
+
+ let actions = column.actions(rowData);
+ let options = {};
+
+ if (!Array.isArray(actions)) {
+ options = actions;
+ actions = actions.actions;
+ }
+
+ for (const action of actions) {
+ if (action.action) {
+ const html = ReactDOMServer.renderToStaticMarkup(
{action.label} );
+ const elem = jQuery(html);
+ elem.click((evt) => { evt.preventDefault(); action.action(this) });
+ linksContainer.append(elem);
+
+ } else if (action.link) {
+ const html = ReactDOMServer.renderToStaticMarkup(
{action.label} );
+ const elem = jQuery(html);
+ elem.click((evt) => { evt.preventDefault(); this.navigateTo(action.link) });
+ linksContainer.append(elem);
+
+ } else if (action.href) {
+ const html = ReactDOMServer.renderToStaticMarkup(
{action.label} );
+ const elem = jQuery(html);
+ linksContainer.append(elem);
+
+ } else {
+ const html = ReactDOMServer.renderToStaticMarkup(
{action.label} );
+ const elem = jQuery(html);
+ linksContainer.append(elem);
+ }
+ }
+
+ if (options.refreshTimeout) {
+ const currentMS = Date.now();
+
+ if (!this.refreshTimeoutAt || this.refreshTimeoutAt > currentMS + options.refreshTimeout) {
+ clearTimeout(this.refreshTimeoutId);
+
+ this.refreshTimeoutAt = currentMS + options.refreshTimeout;
+
+ this.refreshTimeoutId = setTimeout(() => {
+ this.refreshTimeoutAt = 0;
+ this.refresh();
+ }, options.refreshTimeout);
+ }
+ }
+
+ jQuery(td).html(linksContainer);
+ };
+
+ column.type = 'html';
+ column.createdCell = createdCellFn;
+
+ if (!('data' in column)) {
+ column.data = null;
+ column.orderable = false;
+ column.searchable = false;
+ }
+ } else {
+ const originalRender = column.render;
+ column.render = (data, ...rest) => {
+ if (originalRender) {
+ const markup = originalRender(data, ...rest);
+ return ReactDOMServer.renderToStaticMarkup(
{markup}
);
+ } else {
+ return ReactDOMServer.renderToStaticMarkup(
{data}
)
+ }
+ };
+ }
+
+ column.title = ReactDOMServer.renderToStaticMarkup(
{column.title}
);
+ }
+
+ const dtOptions = {
+ columns,
+ autoWidth: false,
+ pageLength: this.props.pageLength,
+ dom: // This overrides Bootstrap 4 settings. It may need to be updated if there are updates in the DataTables Bootstrap 4 plugin.
+ "<'row'<'col-sm-12 col-md-6'l><'col-sm-12 col-md-6'f>>" +
+ "<'row'<'col-sm-12'<'" + styles.dataTableTable + "'tr>>>" +
+ "<'row'<'col-sm-12 col-md-5'i><'col-sm-12 col-md-7'p>>"
+ };
+
+ const self = this;
+ dtOptions.createdRow = function(row, data) {
+ const rowKey = data[self.props.selectionKeyIndex];
+
+ if (self.selectionMap.has(rowKey)) {
+ jQuery(row).addClass('selected');
+ }
+
+ jQuery(row).on('click', () => {
+ const selectionMap = self.selectionMap;
+
+ if (self.props.selectMode === TableSelectMode.SINGLE) {
+ if (selectionMap.size !== 1 || !selectionMap.has(rowKey)) {
+ // noinspection JSIgnoredPromiseFromCall
+ self.notifySelection(self.props.onSelectionChangedAsync, new Map([[rowKey, data]]));
+ }
+
+ } else if (self.props.selectMode === TableSelectMode.MULTI) {
+ const newSelMap = new Map(selectionMap);
+
+ if (selectionMap.has(rowKey)) {
+ newSelMap.delete(rowKey);
+ } else {
+ newSelMap.set(rowKey, data);
+ }
+
+ // noinspection JSIgnoredPromiseFromCall
+ self.notifySelection(self.props.onSelectionChangedAsync, newSelMap);
+ }
+ });
+ };
+
+ dtOptions.initComplete = function() {
+ self.jqSelectInfo = jQuery('
');
+ const jqWrapper = jQuery(self.domTable).parents('.dataTables_wrapper');
+ jQuery('.dataTables_info', jqWrapper).after(self.jqSelectInfo);
+
+ self.updateSelectInfo();
+ };
+
+ if (this.props.data) {
+ dtOptions.data = this.props.data;
+ } else {
+ dtOptions.serverSide = true;
+ dtOptions.ajax = ::this.fetchData;
+ }
+
+ this.table = jQuery(this.domTable).DataTable(dtOptions);
+
+ if (this.props.refreshInterval) {
+ this.refreshIntervalId = setInterval(() => this.refresh(), this.props.refreshInterval);
+ }
+
+ this.table.on('destroy.dt', () => {
+ clearInterval(this.refreshIntervalId);
+ clearTimeout(this.refreshTimeoutId);
+ });
+
+ // noinspection JSIgnoredPromiseFromCall
+ this.fetchAndNotifySelectionData();
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if (this.props.data) {
+ this.table.clear();
+ this.table.rows.add(this.props.data);
+ } else {
+ // XXX: Changing URL changing from data to dataUrl is not implemented
+ this.refresh();
+ }
+
+ const self = this;
+ this.table.rows().every(function() {
+ const key = this.data()[self.props.selectionKeyIndex];
+ if (self.selectionMap.has(key)) {
+ jQuery(this.node()).addClass('selected');
+ } else {
+ jQuery(this.node()).removeClass('selected');
+ }
+ });
+
+ this.updateSelectInfo();
+
+ // noinspection JSIgnoredPromiseFromCall
+ this.fetchAndNotifySelectionData();
+ }
+
+ componentWillUnmount() {
+ this.mounted = false;
+ clearInterval(this.refreshIntervalId);
+ clearTimeout(this.refreshTimeoutId);
+ }
+
+ async notifySelection(eventCallback, newSelectionMap) {
+ if (this.mounted && eventCallback) {
+ const selPairs = Array.from(newSelectionMap).sort((l, r) => l[0] - r[0]);
+
+ let data = selPairs.map(entry => entry[1]);
+ let sel = selPairs.map(entry => entry[0]);
+
+ if (this.props.selectMode === TableSelectMode.SINGLE && !this.props.selectionAsArray) {
+ if (sel.length) {
+ sel = sel[0];
+ data = data[0];
+ } else {
+ sel = null;
+ data = null;
+ }
+ }
+
+ await eventCallback(sel, data);
+ }
+ }
+
+ async deselectAll(evt) {
+ evt.preventDefault();
+
+ // noinspection JSIgnoredPromiseFromCall
+ this.notifySelection(this.props.onSelectionChangedAsync, new Map());
+ }
+
+ render() {
+ const t = this.props.t;
+ const props = this.props;
+
+ let className = 'table table-striped table-bordered';
+
+ if (this.props.selectMode !== TableSelectMode.NONE) {
+ className += ' table-hover';
+ }
+
+ return (
+
+
{ this.domTable = domElem; }} className={className} cellSpacing="0" width="100%" />
+
+ );
+ }
+}
+
+export {
+ Table,
+ TableSelectMode
+}
\ No newline at end of file
diff --git a/client/src/lib/tree.js b/client/src/lib/tree.js
new file mode 100644
index 00000000..1cc8f1d6
--- /dev/null
+++ b/client/src/lib/tree.js
@@ -0,0 +1,392 @@
+'use strict';
+
+import React, {Component} from 'react';
+import ReactDOMServer from 'react-dom/server';
+import {withTranslation} from './i18n';
+import PropTypes from 'prop-types';
+
+import jQuery from 'jquery';
+import '../../static/jquery/jquery-ui-1.12.1.min.js';
+import '../../static/fancytree/jquery.fancytree-all.min.js';
+import '../../static/fancytree/skin-bootstrap/ui.fancytree.min.css';
+import './tree.scss';
+import axios from './axios';
+
+import {withPageHelpers} from './page'
+import {withAsyncErrorHandler, withErrorHandling} from './error-handling';
+import styles from "./styles.scss";
+import {getUrl} from "./urls";
+import {withComponentMixins} from "./decorator-helpers";
+
+const TreeSelectMode = {
+ NONE: 0,
+ SINGLE: 1,
+ MULTI: 2
+};
+
+@withComponentMixins([
+ withTranslation,
+ withErrorHandling,
+ withPageHelpers
+], ['refresh'])
+class TreeTable extends Component {
+ constructor(props) {
+ super(props);
+
+ this.mounted = false;
+
+ this.state = {
+ treeData: null
+ };
+
+ if (props.data) {
+ this.state.treeData = props.data;
+ }
+
+ // Select Mode simply cannot be changed later. This is just to make sure we avoid inconsistencies if someone changes it anyway.
+ this.selectMode = this.props.selectMode;
+ }
+
+ static defaultProps = {
+ selectMode: TreeSelectMode.NONE
+ }
+
+ refresh() {
+ if (this.tree && !this.props.data && this.props.dataUrl) {
+ // noinspection JSIgnoredPromiseFromCall
+ this.loadData();
+ }
+ }
+
+ @withAsyncErrorHandler
+ async loadData() {
+ const response = await axios.get(getUrl(this.props.dataUrl));
+ const treeData = response.data;
+
+ for (const root of treeData) {
+ root.expanded = true;
+ for (const child of root.children) {
+ child.expanded = true;
+ }
+ }
+
+ if (this.mounted) {
+ this.setState({
+ treeData
+ });
+ }
+ }
+
+ static propTypes = {
+ dataUrl: PropTypes.string,
+ data: PropTypes.array,
+ selectMode: PropTypes.number,
+ selection: PropTypes.oneOfType([PropTypes.array, PropTypes.string, PropTypes.number]),
+ onSelectionChangedAsync: PropTypes.func,
+ actions: PropTypes.func,
+ withHeader: PropTypes.bool,
+ withDescription: PropTypes.bool,
+ noTable: PropTypes.bool,
+ withIcons: PropTypes.bool,
+ className: PropTypes.string
+ }
+
+ shouldComponentUpdate(nextProps, nextState) {
+ return this.props.selection !== nextProps.selection || this.props.data !== nextProps.data || this.props.dataUrl !== nextProps.dataUrl ||
+ this.state.treeData != nextState.treeData || this.props.className !== nextProps.className;
+ }
+
+ // XSS protection
+ sanitizeTreeData(unsafeData) {
+ const data = [];
+ if (unsafeData) {
+ for (const unsafeEntry of unsafeData) {
+ const entry = Object.assign({}, unsafeEntry);
+ entry.unsanitizedTitle = entry.title;
+ entry.title = ReactDOMServer.renderToStaticMarkup({entry.title}
);
+ entry.description = ReactDOMServer.renderToStaticMarkup({entry.description}
);
+ if (entry.children) {
+ entry.children = this.sanitizeTreeData(entry.children);
+ }
+ data.push(entry);
+ }
+ }
+
+ return data;
+ }
+
+ componentDidMount() {
+ this.mounted = true;
+
+ if (!this.props.data && this.props.dataUrl) {
+ // noinspection JSIgnoredPromiseFromCall
+ this.loadData();
+ }
+
+ let createNodeFn;
+ createNodeFn = (event, data) => {
+ const node = data.node;
+ const tdList = jQuery(node.tr).find(">td");
+
+ let tdIdx = 1;
+
+ if (this.props.withDescription) {
+ const descHtml = node.data.description; // This was already sanitized in sanitizeTreeData when the data was loaded
+ tdList.eq(tdIdx).html(descHtml);
+ tdIdx += 1;
+ }
+
+ if (this.props.actions) {
+ const linksContainer = jQuery(` `);
+
+ const actions = this.props.actions(node);
+
+ for (const action of actions) {
+ if (action.action) {
+ const html = ReactDOMServer.renderToStaticMarkup({action.label} );
+ const elem = jQuery(html);
+ elem.click((evt) => { evt.preventDefault(); action.action(this) });
+ linksContainer.append(elem);
+
+ } else if (action.link) {
+ const html = ReactDOMServer.renderToStaticMarkup({action.label} );
+ const elem = jQuery(html);
+ elem.click((evt) => { evt.preventDefault(); this.navigateTo(action.link) });
+ linksContainer.append(elem);
+
+ } else if (action.href) {
+ const html = ReactDOMServer.renderToStaticMarkup({action.label} );
+ const elem = jQuery(html);
+ linksContainer.append(elem);
+
+ } else {
+ const html = ReactDOMServer.renderToStaticMarkup({action.label} );
+ const elem = jQuery(html);
+ linksContainer.append(elem);
+ }
+ }
+
+ tdList.eq(tdIdx).html(linksContainer);
+ tdIdx += 1;
+ }
+ };
+
+ const treeOpts = {
+ extensions: ['glyph'],
+ glyph: {
+ map: {
+ expanderClosed: 'fas fa-angle-right',
+ expanderLazy: 'fas fa-angle-right', // glyphicon-plus-sign
+ expanderOpen: 'fas fa-angle-down', // glyphicon-collapse-down
+ checkbox: 'fas fa-square',
+ checkboxSelected: 'fas fa-check-square',
+
+ folder: 'fas fa-folder',
+ folderOpen: 'fas fa-folder-open',
+ doc: 'fas fa-file',
+ docOpen: 'fas fa-file'
+ }
+ },
+ selectMode: (this.selectMode === TreeSelectMode.MULTI ? 2 : 1),
+ icon: !!this.props.withIcons,
+ autoScroll: true,
+ scrollParent: jQuery(this.domTableContainer),
+ source: this.sanitizeTreeData(this.state.treeData),
+ toggleEffect: false,
+ createNode: createNodeFn,
+ checkbox: this.selectMode === TreeSelectMode.MULTI,
+ activate: (this.selectMode === TreeSelectMode.SINGLE ? ::this.onActivate : null),
+ deactivate: (this.selectMode === TreeSelectMode.SINGLE ? ::this.onActivate : null),
+ select: (this.selectMode === TreeSelectMode.MULTI ? ::this.onSelect : null),
+ };
+
+ if (!this.props.noTable) {
+ treeOpts.extensions.push('table');
+ treeOpts.table = {
+ nodeColumnIdx: 0
+ };
+ }
+
+ this.tree = jQuery(this.domTable).fancytree(treeOpts).fancytree("getTree");
+
+ this.updateSelection();
+ }
+
+ componentDidUpdate(prevProps, prevState) {
+ if (this.props.data) {
+ this.setState({
+ treeData: this.props.data
+ });
+ } else if (this.props.dataUrl && prevProps.dataUrl !== this.props.dataUrl) {
+ // noinspection JSIgnoredPromiseFromCall
+ this.loadData();
+ }
+
+ if (this.props.selection !== prevProps.selection || this.state.treeData != prevState.treeData) {
+ if (this.state.treeData != prevState.treeData) {
+ this.tree.reload(this.sanitizeTreeData(this.state.treeData));
+ }
+
+ this.updateSelection();
+ }
+ }
+
+ componentWillUnmount() {
+ this.mounted = false;
+ }
+
+ updateSelection() {
+ const tree = this.tree;
+ if (this.selectMode === TreeSelectMode.MULTI) {
+ const selectSet = new Set(this.props.selection.map(key => this.stringifyKey(key)));
+
+ tree.enableUpdate(false);
+ tree.visit(node => node.setSelected(selectSet.has(node.key)));
+ tree.enableUpdate(true);
+
+ } else if (this.selectMode === TreeSelectMode.SINGLE) {
+ let selection = this.stringifyKey(this.props.selection);
+
+ if (this.state.treeData) {
+ if (!tree.getNodeByKey(selection)) {
+ selection = null;
+ }
+
+ if (selection === null && !this.tree.getActiveNode()) {
+ // This covers the case when we mount the tree and selection is not present in the tree.
+ // At this point, nothing is selected, so the onActive event won't trigger. So we have to
+ // call it manually, so that the form can update and set null instead of the invalid selection.
+ this.onActivate();
+ } else {
+ tree.activateKey(selection);
+ }
+ }
+ }
+ }
+
+ @withAsyncErrorHandler
+ async onSelectionChanged(sel) {
+ if (this.props.onSelectionChangedAsync) {
+ await this.props.onSelectionChangedAsync(sel);
+ }
+ }
+
+ stringifyKey(key) {
+ if (key !== null && key !== undefined) {
+ return key.toString();
+ } else {
+ return key;
+ }
+ }
+
+ destringifyKey(key) {
+ if (/^(\-|\+)?([0-9]+|Infinity)$/.test(key)) {
+ return Number(key);
+ } else {
+ return key;
+ }
+ }
+
+ // Single-select
+ onActivate(event, data) {
+ const activeNode = this.tree.getActiveNode();
+ const selection = activeNode ? this.destringifyKey(activeNode.key) : null;
+
+ if (selection !== this.props.selection) {
+ // noinspection JSIgnoredPromiseFromCall
+ this.onSelectionChanged(selection);
+ }
+ }
+
+ // Multi-select
+ onSelect(event, data) {
+ const newSel = this.tree.getSelectedNodes().map(node => this.destringifyKey(node.key)).sort();
+ const oldSel = this.props.selection;
+
+ let updated = false;
+ const length = oldSel.length;
+ if (length === newSel.length) {
+ for (let i = 0; i < length; i++) {
+ if (oldSel[i] !== newSel[i]) {
+ updated = true;
+ break;
+ }
+ }
+ } else {
+ updated = true;
+ }
+
+ if (updated) {
+ // noinspection JSIgnoredPromiseFromCall
+ this.onSelectionChanged(newSel);
+ }
+ }
+
+ render() {
+ const t = this.props.t;
+ const props = this.props;
+ const actions = props.actions;
+ const withHeader = props.withHeader;
+ const withDescription = props.withDescription;
+
+ let containerClass = 'mt-treetable-container ' + (this.props.className || '');
+ if (this.selectMode === TreeSelectMode.NONE) {
+ containerClass += ' mt-treetable-inactivable';
+ } else {
+ if (!props.noTable) {
+ containerClass += ' table-hover';
+ }
+ }
+
+ if (!this.withHeader) {
+ containerClass += ' mt-treetable-noheader';
+ }
+
+ // FIXME: style={{ height: '100px', overflow: 'auto'}}
+
+ if (props.noTable) {
+ return (
+ { this.domTableContainer = domElem; }} >
+
{ this.domTable = domElem; }}>
+
+
+ );
+
+ } else {
+ let tableClass = 'table table-striped table-condensed';
+ if (this.selectMode !== TreeSelectMode.NONE) {
+ tableClass += ' table-hover';
+ }
+
+ return (
+ { this.domTableContainer = domElem; }} >
+
{ this.domTable = domElem; }} className={tableClass}>
+ {props.withHeader &&
+
+
+ {t('name')}
+ {withDescription && {t('description')} }
+ {actions && }
+
+
+ }
+
+
+
+ {withDescription && }
+ {actions && }
+
+
+
+
+ );
+ }
+
+ }
+}
+
+
+export {
+ TreeTable,
+ TreeSelectMode
+}
\ No newline at end of file
diff --git a/client/src/lib/tree.scss b/client/src/lib/tree.scss
new file mode 100644
index 00000000..06aa0198
--- /dev/null
+++ b/client/src/lib/tree.scss
@@ -0,0 +1,92 @@
+@import "../scss/variables.scss";
+
+:global {
+
+.mt-treetable-container .fancytree-container {
+ border: none;
+}
+
+.mt-treetable-container span.fancytree-expander {
+ color: #333333;
+}
+
+.mt-treetable-container.mt-treetable-inactivable>table.fancytree-ext-table.fancytree-container>tbody>tr.fancytree-active>td,
+.mt-treetable-container.mt-treetable-inactivable>table.fancytree-ext-table.fancytree-container>tbody>tr.fancytree-active:hover>td,
+.mt-treetable-container.mt-treetable-inactivable .fancytree-container span.fancytree-node.fancytree-active span.fancytree-title,
+.mt-treetable-container.mt-treetable-inactivable .fancytree-container span.fancytree-node.fancytree-active span.fancytree-title:hover,
+.mt-treetable-container.mt-treetable-inactivable .fancytree-container span.fancytree-node.fancytree-active:hover span.fancytree-title,
+.mt-treetable-container.mt-treetable-inactivable .fancytree-container span.fancytree-node span.fancytree-title:hover {
+ background-color: transparent;
+}
+
+.mt-treetable-container.mt-treetable-inactivable .fancytree-container span.fancytree-node span.fancytree-title {
+ cursor: default;
+}
+
+.mt-treetable-container.mt-treetable-inactivable .fancytree-container span.fancytree-node.fancytree-active span.fancytree-title,
+.mt-treetable-container.mt-treetable-inactivable .fancytree-container span.fancytree-node.fancytree-active span.fancytree-title:hover {
+ border-color: transparent;
+}
+
+.mt-treetable-container.mt-treetable-inactivable>table.fancytree-ext-table.fancytree-container>tbody>tr.fancytree-active>td span.fancytree-title,
+.mt-treetable-container.mt-treetable-inactivable>table.fancytree-ext-table.fancytree-container>tbody>tr.fancytree-active>td span.fancytree-expander,
+.mt-treetable-container.mt-treetable-inactivable .fancytree-container span.fancytree-node.fancytree-active span.fancytree-title,
+.mt-treetable-container.mt-treetable-inactivable .fancytree-container>tbody>tr.fancytree-active>td {
+ outline: 0px none;
+ color: #333333;
+}
+
+.mt-treetable-container span.fancytree-node span.fancytree-expander:hover {
+ color: inherit;
+}
+
+.mt-treetable-container {
+ padding-top: 9px;
+ padding-bottom: 9px;
+}
+
+.mt-treetable-container>table.fancytree-ext-table {
+ margin-bottom: 0px;
+}
+
+.mt-treetable-container.mt-treetable-noheader>.table>tbody>tr>td {
+ border-top: 0px none;
+}
+
+.mt-treetable-container .mt-treetable-title {
+ min-width: 150px;
+}
+
+
+
+.form-group .mt-treetable-container {
+ border: $input-border-width solid $input-border-color;
+ border-radius: $input-border-radius;
+ padding-top: $input-padding-y;
+ padding-bottom: $input-padding-y;
+
+ -webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,0.075);
+ box-shadow: inset 0 1px 1px rgba(0,0,0,0.075);
+ -webkit-transition: border-color ease-in-out .15s,-webkit-box-shadow ease-in-out .15s;
+ -o-transition: border-color ease-in-out .15s,box-shadow ease-in-out .15s;
+ transition: border-color ease-in-out .15s,box-shadow ease-in-out .15s;
+}
+
+.form-group .mt-treetable-container.is-valid {
+ border-color: $form-feedback-valid-color;
+ -webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,0.075);
+ box-shadow: inset 0 1px 1px rgba(0,0,0,0.075);
+}
+
+.form-group .mt-treetable-container.is-invalid {
+ border-color: $form-feedback-invalid-color;
+ -webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,0.075);
+ box-shadow: inset 0 1px 1px rgba(0,0,0,0.075);
+}
+
+.mt-treetable-container .table td {
+ padding-top: 3px;
+ padding-bottom: 3px;
+}
+
+}
diff --git a/client/src/lib/untrusted.js b/client/src/lib/untrusted.js
new file mode 100644
index 00000000..6948f9c7
--- /dev/null
+++ b/client/src/lib/untrusted.js
@@ -0,0 +1,331 @@
+'use strict';
+
+import React, {Component} from "react";
+import PropTypes from "prop-types";
+import {withTranslation} from './i18n';
+import {requiresAuthenticatedUser, withPageHelpers} from "./page";
+import {withAsyncErrorHandler, withErrorHandling} from "./error-handling";
+import axios from "./axios";
+import styles from "./styles.scss";
+import {getSandboxUrl, getUrl, setRestrictedAccessToken} from "./urls";
+import {withComponentMixins} from "./decorator-helpers";
+
+@withComponentMixins([
+ withErrorHandling,
+ withPageHelpers,
+ requiresAuthenticatedUser
+], ['ask'])
+export class UntrustedContentHost extends Component {
+ constructor(props) {
+ super(props);
+
+ this.refreshAccessTokenTimeout = null;
+ this.accessToken = null;
+ this.contentNodeIsLoaded = false;
+
+ this.state = {
+ hasAccessToken: false
+ };
+
+ this.receiveMessageHandler = ::this.receiveMessage;
+ this.contentNodeRefHandler = node => this.contentNode = node;
+
+ this.rpcCounter = 0;
+ this.rpcResolves = new Map();
+ }
+
+ static propTypes = {
+ contentSrc: PropTypes.string,
+ contentProps: PropTypes.object,
+ tokenMethod: PropTypes.string,
+ tokenParams: PropTypes.object,
+ className: PropTypes.string,
+ singleToken: PropTypes.bool,
+ onMethodAsync: PropTypes.func
+ }
+
+ isInitialized() {
+ return !!this.accessToken && !!this.props.contentProps;
+ }
+
+ async receiveMessage(evt) {
+ const msg = evt.data;
+
+ if (msg.type === 'initNeeded') {
+ // It seems that sometime the message that the content node does not arrive. However if the content root notifies us, we just proceed
+ this.contentNodeIsLoaded = true;
+
+ if (this.isInitialized()) {
+ this.sendMessage('init', {
+ accessToken: this.accessToken,
+ contentProps: this.props.contentProps
+ });
+ }
+ } else if (msg.type === 'rpcResponse') {
+ const resolve = this.rpcResolves.get(msg.data.msgId);
+ resolve(msg.data.ret);
+ } else if (msg.type === 'rpcRequest') {
+ const ret = await this.props.onMethodAsync(msg.data.method, msg.data.params);
+ this.sendMessage('rpcResponse', {msgId: msg.data.msgId, ret});
+ } else if (msg.type === 'clientHeight') {
+ const newHeight = msg.data;
+ this.contentNode.height = newHeight;
+ }
+ }
+
+ sendMessage(type, data) {
+ if (this.contentNodeIsLoaded && this.contentNode) { // This is to avoid errors: Failed to execute 'postMessage' on 'DOMWindow': The target origin provided ('http://localhost:8081') does not match the recipient window's origin ('http://localhost:3000')"
+ // When the child window is closed during processing of the message, the this.contentNode becomes null and we can't deliver the response
+ this.contentNode.contentWindow.postMessage({type, data}, getSandboxUrl());
+ }
+ }
+
+ async ask(method, params) {
+ if (this.contentNodeIsLoaded) {
+ this.rpcCounter += 1;
+ const msgId = this.rpcCounter;
+
+ this.sendMessage('rpcRequest', {
+ method,
+ params,
+ msgId
+ });
+
+ return await (new Promise((resolve, reject) => {
+ this.rpcResolves.set(msgId, resolve);
+ }));
+ }
+ }
+
+ @withAsyncErrorHandler
+ async refreshAccessToken() {
+ if (this.props.singleToken && this.accessToken) {
+ await axios.put(getUrl('rest/restricted-access-token'), {
+ token: this.accessToken
+ });
+ } else {
+ const result = await axios.post(getUrl('rest/restricted-access-token'), {
+ method: this.props.tokenMethod,
+ params: this.props.tokenParams
+ });
+
+ this.accessToken = result.data;
+
+ if (!this.state.hasAccessToken) {
+ this.setState({
+ hasAccessToken: true
+ })
+ }
+
+ this.sendMessage('accessToken', this.accessToken);
+ }
+ }
+
+ scheduleRefreshAccessToken() {
+ this.refreshAccessTokenTimeout = setTimeout(() => {
+ // noinspection JSIgnoredPromiseFromCall
+ this.refreshAccessToken();
+ this.scheduleRefreshAccessToken();
+ }, 30 * 1000);
+ }
+
+ handleUpdate() {
+ if (this.isInitialized()) {
+ this.sendMessage('initAvailable');
+ }
+
+ if (!this.state.hasAccessToken) {
+ // noinspection JSIgnoredPromiseFromCall
+ this.refreshAccessToken();
+ }
+ }
+
+ componentDidMount() {
+ this.scheduleRefreshAccessToken();
+ window.addEventListener('message', this.receiveMessageHandler, false);
+
+ this.handleUpdate();
+ }
+
+ componentDidUpdate() {
+ this.handleUpdate();
+ }
+
+ componentWillUnmount() {
+ clearTimeout(this.refreshAccessTokenTimeout);
+ window.removeEventListener('message', this.receiveMessageHandler, false);
+ }
+
+ contentNodeLoaded() {
+ this.contentNodeIsLoaded = true;
+ }
+
+ render() {
+ return (
+ // The 40 px below corresponds to the height in .sandbox-loading-message
+
+ );
+ }
+}
+
+
+@withComponentMixins([
+ withTranslation
+])
+export class UntrustedContentRoot extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ initialized: false,
+ };
+
+ this.receiveMessageHandler = ::this.receiveMessage;
+
+ this.periodicTimeoutHandler = ::this.onPeriodicTimeout;
+ this.periodicTimeoutId = 0;
+
+ this.clientHeight = 0;
+ }
+
+ static propTypes = {
+ render: PropTypes.func
+ }
+
+
+ onPeriodicTimeout() {
+ const newHeight = document.body.clientHeight;
+ if (this.clientHeight !== newHeight) {
+ this.clientHeight = newHeight;
+ this.sendMessage('clientHeight', newHeight);
+ }
+ this.periodicTimeoutId = setTimeout(this.periodicTimeoutHandler, 250);
+ }
+
+
+ async receiveMessage(evt) {
+ const msg = evt.data;
+
+ if (msg.type === 'initAvailable') {
+ this.sendMessage('initNeeded');
+
+ } else if (msg.type === 'init') {
+ setRestrictedAccessToken(msg.data.accessToken);
+ this.setState({
+ initialized: true,
+ contentProps: msg.data.contentProps
+ });
+
+ } else if (msg.type === 'accessToken') {
+ setRestrictedAccessToken(msg.data);
+ }
+ }
+
+ sendMessage(type, data) {
+ window.parent.postMessage({type, data}, '*');
+ }
+
+ componentDidMount() {
+ window.addEventListener('message', this.receiveMessageHandler, false);
+ this.periodicTimeoutId = setTimeout(this.periodicTimeoutHandler, 0);
+ this.sendMessage('initNeeded');
+ }
+
+ componentWillUnmount() {
+ window.removeEventListener('message', this.receiveMessageHandler, false);
+ clearTimeout(this.periodicTimeoutId);
+ }
+
+ render() {
+ const t = this.props.t;
+
+ if (this.state.initialized) {
+ return this.props.render(this.state.contentProps);
+ } else {
+ return (
+
+ {t('loading')}
+
+ );
+ }
+ }
+}
+
+class ParentRPC {
+ constructor(props) {
+ this.receiveMessageHandler = ::this.receiveMessage;
+
+ this.rpcCounter = 0;
+ this.rpcResolves = new Map();
+ this.methodHandlers = new Map();
+
+ this.initialized = false;
+ }
+
+ init() {
+ window.addEventListener('message', this.receiveMessageHandler, false);
+ this.initialized = true;
+ }
+
+ setMethodHandler(method, handler) {
+ this.enforceInitialized();
+ this.methodHandlers.set(method, handler);
+ }
+
+ clearMethodHandler(method) {
+ this.enforceInitialized();
+ this.methodHandlers.delete(method);
+ }
+
+ async ask(method, params) {
+ this.enforceInitialized();
+ this.rpcCounter += 1;
+ const msgId = this.rpcCounter;
+
+ this.sendMessage('rpcRequest', {
+ method,
+ params,
+ msgId
+ });
+
+ return await (new Promise((resolve, reject) => {
+ this.rpcResolves.set(msgId, resolve);
+ }));
+ }
+
+
+ // ---------------------------------------------------------------------------
+ // Private methods
+
+ enforceInitialized() {
+ if (!this.initialized) {
+ throw new Error('ParentRPC not initialized');
+ }
+ }
+
+ async receiveMessage(evt) {
+ const msg = evt.data;
+
+ if (msg.type === 'rpcResponse') {
+ const resolve = this.rpcResolves.get(msg.data.msgId);
+ resolve(msg.data.ret);
+
+ } else if (msg.type === 'rpcRequest') {
+ let ret;
+
+ const method = msg.data.method;
+ if (this.methodHandlers.has(method)) {
+ const handler = this.methodHandlers.get(method);
+ ret = await handler(method, msg.data.params);
+ }
+
+ this.sendMessage('rpcResponse', {msgId: msg.data.msgId, ret});
+ }
+ }
+
+ sendMessage(type, data) {
+ window.parent.postMessage({type, data}, '*');
+ }
+}
+
+export const parentRPC = new ParentRPC();
\ No newline at end of file
diff --git a/client/src/lib/urls.js b/client/src/lib/urls.js
new file mode 100644
index 00000000..242a2eaa
--- /dev/null
+++ b/client/src/lib/urls.js
@@ -0,0 +1,60 @@
+'use strict';
+
+import {anonymousRestrictedAccessToken} from '../../../shared/urls';
+import {AppType} from '../../../shared/app';
+import mailtrainConfig from "mailtrainConfig";
+import i18n from './i18n';
+
+let restrictedAccessToken = anonymousRestrictedAccessToken;
+
+function setRestrictedAccessToken(token) {
+ restrictedAccessToken = token;
+}
+
+function getTrustedUrl(path) {
+ return mailtrainConfig.trustedUrlBase + (path || '');
+}
+
+function getSandboxUrl(path, customRestrictedAccessToken) {
+ const localRestrictedAccessToken = customRestrictedAccessToken || restrictedAccessToken;
+ return mailtrainConfig.sandboxUrlBase + localRestrictedAccessToken + '/' + (path || '');
+}
+
+function getPublicUrl(path, opts) {
+ const url = new URL(path || '', mailtrainConfig.publicUrlBase);
+
+ if (opts && opts.withLocale) {
+ url.searchParams.append('locale', i18n.language);
+ }
+
+ return url.toString();
+}
+
+function getUrl(path) {
+ if (mailtrainConfig.appType === AppType.TRUSTED) {
+ return getTrustedUrl(path);
+ } else if (mailtrainConfig.appType === AppType.SANDBOXED) {
+ return getSandboxUrl(path);
+ } else if (mailtrainConfig.appType === AppType.PUBLIC) {
+ return getPublicUrl(path);
+ }
+}
+
+function getBaseDir() {
+ if (mailtrainConfig.appType === AppType.TRUSTED) {
+ return mailtrainConfig.trustedUrlBaseDir;
+ } else if (mailtrainConfig.appType === AppType.SANDBOXED) {
+ return mailtrainConfig.sandboxUrlBaseDir + restrictedAccessToken;
+ } else if (mailtrainConfig.appType === AppType.PUBLIC) {
+ return mailtrainConfig.publicUrlBaseDir;
+ }
+}
+
+export {
+ getTrustedUrl,
+ getSandboxUrl,
+ getPublicUrl,
+ getUrl,
+ getBaseDir,
+ setRestrictedAccessToken
+}
\ No newline at end of file
diff --git a/client/src/lists/CUD.js b/client/src/lists/CUD.js
new file mode 100644
index 00000000..588282fa
--- /dev/null
+++ b/client/src/lists/CUD.js
@@ -0,0 +1,296 @@
+'use strict';
+
+import React, {Component} from 'react';
+import PropTypes from 'prop-types';
+import {Trans} from 'react-i18next';
+import {withTranslation} from '../lib/i18n';
+import {LinkButton, requiresAuthenticatedUser, Title, withPageHelpers} from '../lib/page';
+import {
+ Button,
+ ButtonRow,
+ CheckBox,
+ Dropdown,
+ filterData,
+ Form,
+ FormSendMethod,
+ InputField,
+ StaticField,
+ TableSelect,
+ TextArea,
+ withForm,
+ withFormErrorHandlers
+} from '../lib/form';
+import {withErrorHandling} from '../lib/error-handling';
+import {DeleteModalDialog} from '../lib/modals';
+import {getDefaultNamespace, NamespaceSelect, validateNamespace} from '../lib/namespace';
+import {FieldWizard, UnsubscriptionMode} from '../../../shared/lists';
+import styles from "../lib/styles.scss";
+import {getMailerTypes} from "../send-configurations/helpers";
+import {withComponentMixins} from "../lib/decorator-helpers";
+
+@withComponentMixins([
+ withTranslation,
+ withForm,
+ withErrorHandling,
+ withPageHelpers,
+ requiresAuthenticatedUser
+])
+export default class CUD extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {};
+
+ this.initForm();
+
+ this.mailerTypes = getMailerTypes(props.t);
+ }
+
+ static propTypes = {
+ action: PropTypes.string.isRequired,
+ entity: PropTypes.object,
+ permissions: PropTypes.object
+ }
+
+ getFormValuesMutator(data) {
+ data.form = data.default_form ? 'custom' : 'default';
+ data.listunsubscribe_disabled = !!data.listunsubscribe_disabled;
+ }
+
+ submitFormValuesMutator(data) {
+ if (data.form === 'default') {
+ data.default_form = null;
+ }
+
+ if (data.fieldWizard === FieldWizard.FIRST_LAST_NAME || data.fieldWizard === FieldWizard.NAME) {
+ data.to_name = null;
+ }
+
+ return filterData(data, ['name', 'description', 'default_form', 'public_subscribe', 'unsubscription_mode',
+ 'contact_email', 'homepage', 'namespace', 'to_name', 'listunsubscribe_disabled', 'send_configuration',
+ 'fieldWizard'
+ ]);
+ }
+
+ componentDidMount() {
+ if (this.props.entity) {
+ this.getFormValuesFromEntity(this.props.entity);
+
+ } else {
+ this.populateFormValues({
+ name: '',
+ description: '',
+ form: 'default',
+ default_form: 'default',
+ public_subscribe: true,
+ contact_email: '',
+ homepage: '',
+ unsubscription_mode: UnsubscriptionMode.ONE_STEP,
+ namespace: getDefaultNamespace(this.props.permissions),
+ to_name: '',
+ fieldWizard: FieldWizard.FIRST_LAST_NAME,
+ send_configuration: null,
+ listunsubscribe_disabled: false
+ });
+ }
+ }
+
+ localValidateFormValues(state) {
+ const t = this.props.t;
+
+ if (!state.getIn(['name', 'value'])) {
+ state.setIn(['name', 'error'], t('nameMustNotBeEmpty'));
+ } else {
+ state.setIn(['name', 'error'], null);
+ }
+
+ if (!state.getIn(['send_configuration', 'value'])) {
+ state.setIn(['send_configuration', 'error'], t('sendConfigurationMustBeSelected'));
+ } else {
+ state.setIn(['send_configuration', 'error'], null);
+ }
+
+ if (state.getIn(['form', 'value']) === 'custom' && !state.getIn(['default_form', 'value'])) {
+ state.setIn(['default_form', 'error'], t('customFormMustBeSelected'));
+ } else {
+ state.setIn(['default_form', 'error'], null);
+ }
+
+ validateNamespace(t, state);
+ }
+
+ @withFormErrorHandlers
+ async submitHandler(submitAndLeave) {
+ const t = this.props.t;
+
+ let sendMethod, url;
+ if (this.props.entity) {
+ sendMethod = FormSendMethod.PUT;
+ url = `rest/lists/${this.props.entity.id}`
+ } else {
+ sendMethod = FormSendMethod.POST;
+ url = 'rest/lists'
+ }
+
+ this.disableForm();
+ this.setFormStatusMessage('info', t('saving'));
+
+ const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url);
+
+ if (submitResult) {
+ if (this.props.entity) {
+ if (submitAndLeave) {
+ this.navigateToWithFlashMessage('/lists', 'success', t('listUpdated'));
+ } else {
+ await this.getFormValuesFromURL(`rest/lists/${this.props.entity.id}`);
+ this.enableForm();
+ this.setFormStatusMessage('success', t('listUpdated'));
+ }
+ } else {
+ if (submitAndLeave) {
+ this.navigateToWithFlashMessage('/lists', 'success', t('listCreated'));
+ } else {
+ this.navigateToWithFlashMessage(`/lists/${submitResult}/edit`, 'success', t('listCreated'));
+ }
+ }
+ } else {
+ this.enableForm();
+ this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd'));
+ }
+ }
+
+ render() {
+ const t = this.props.t;
+ const isEdit = !!this.props.entity;
+ const canDelete = isEdit && this.props.entity.permissions.includes('delete');
+
+ const unsubcriptionModeOptions = [
+ {
+ key: UnsubscriptionMode.ONE_STEP,
+ label: t('onestepIeNoEmailWithConfirmationLink')
+ },
+ {
+ key: UnsubscriptionMode.ONE_STEP_WITH_FORM,
+ label: t('onestepWithUnsubscriptionFormIeNoEmail')
+ },
+ {
+ key: UnsubscriptionMode.TWO_STEP,
+ label: t('twostepIeAnEmailWithConfirmationLinkWill')
+ },
+ {
+ key: UnsubscriptionMode.TWO_STEP_WITH_FORM,
+ label: t('twostepWithUnsubscriptionFormIeAnEmail')
+ },
+ {
+ key: UnsubscriptionMode.MANUAL,
+ label: t('manualIeUnsubscriptionHasToBePerformedBy')
+ }
+ ];
+
+ const formsOptions = [
+ {
+ key: 'default',
+ label: t('defaultMailtrainForms')
+ },
+ {
+ key: 'custom',
+ label: t('customFormsSelectFormBelow')
+ }
+ ];
+
+ const customFormsColumns = [
+ {data: 0, title: "#"},
+ {data: 1, title: t('name')},
+ {data: 2, title: t('description')},
+ {data: 3, title: t('namespace')}
+ ];
+
+ const sendConfigurationsColumns = [
+ { data: 1, title: t('name') },
+ { data: 2, title: t('id'), render: data => {data}
},
+ { data: 3, title: t('description') },
+ { data: 4, title: t('type'), render: data => this.mailerTypes[data].typeName },
+ { data: 6, title: t('namespace') }
+ ];
+
+ let toNameFields;
+ if (isEdit) {
+ toNameFields = ;
+ } else {
+ const fieldWizardOptions = [
+ {key: FieldWizard.NONE, label: t('emptyCustomNoFields')},
+ {key: FieldWizard.NAME, label: t('nameOneField')},
+ {key: FieldWizard.FIRST_LAST_NAME, label: t('firstNameAndLastNameTwoFields')},
+ ];
+
+ const fieldWizardValue = this.getFormValue('fieldWizard');
+
+ const fieldWizardSelector =
+
+ if (fieldWizardValue === FieldWizard.NONE) {
+ toNameFields = (
+ <>
+ {fieldWizardSelector}
+
+ >
+ );
+ } else {
+ toNameFields = fieldWizardSelector;
+ }
+ }
+
+ return (
+
+ {canDelete &&
+
+ }
+
+ {isEdit ? t('editList') : t('createList')}
+
+
+
+ );
+ }
+}
diff --git a/client/src/lists/List.js b/client/src/lists/List.js
new file mode 100644
index 00000000..f7751ebd
--- /dev/null
+++ b/client/src/lists/List.js
@@ -0,0 +1,137 @@
+'use strict';
+
+import React, {Component} from 'react';
+import {withTranslation} from '../lib/i18n';
+import {LinkButton, requiresAuthenticatedUser, Title, Toolbar, withPageHelpers} from '../lib/page';
+import {withErrorHandling} from '../lib/error-handling';
+import {Table} from '../lib/table';
+import {Icon} from "../lib/bootstrap-components";
+import {tableAddDeleteButton, tableRestActionDialogInit, tableRestActionDialogRender} from "../lib/modals";
+import {withComponentMixins} from "../lib/decorator-helpers";
+import {withForm} from "../lib/form";
+import PropTypes from 'prop-types';
+
+@withComponentMixins([
+ withTranslation,
+ withForm,
+ withErrorHandling,
+ withPageHelpers,
+ requiresAuthenticatedUser
+])
+export default class List extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {};
+ tableRestActionDialogInit(this);
+ }
+
+ static propTypes = {
+ permissions: PropTypes.object
+ }
+
+ render() {
+ const t = this.props.t;
+
+ const permissions = this.props.permissions;
+ const createPermitted = permissions.createList;
+ const customFormsPermitted = permissions.createCustomForm || permissions.viewCustomForm;
+
+ const columns = [
+ {
+ data: 1,
+ title: t('name'),
+ actions: data => {
+ const perms = data[7];
+ if (perms.includes('viewSubscriptions')) {
+ return [{label: data[1], link: `/lists/${data[0]}/subscriptions`}];
+ } else {
+ return [{label: data[1]}];
+ }
+ }
+ },
+ { data: 2, title: t('id'), render: data => {data}
},
+ { data: 3, title: t('subscribers') },
+ { data: 4, title: t('description') },
+ { data: 5, title: t('namespace') },
+ {
+ actions: data => {
+ const actions = [];
+ const triggersCount = data[6];
+ const perms = data[7];
+
+ if (perms.includes('viewSubscriptions')) {
+ actions.push({
+ label: ,
+ link: `/lists/${data[0]}/subscriptions`
+ });
+ }
+
+ if (perms.includes('edit')) {
+ actions.push({
+ label: ,
+ link: `/lists/${data[0]}/edit`
+ });
+ }
+
+ if (perms.includes('viewFields')) {
+ actions.push({
+ label: ,
+ link: `/lists/${data[0]}/fields`
+ });
+ }
+
+ if (perms.includes('viewSegments')) {
+ actions.push({
+ label: ,
+ link: `/lists/${data[0]}/segments`
+ });
+ }
+
+ if (perms.includes('viewImports')) {
+ actions.push({
+ label: ,
+ link: `/lists/${data[0]}/imports`
+ });
+ }
+
+ if (triggersCount > 0) {
+ actions.push({
+ label: ,
+ link: `/lists/${data[0]}/triggers`
+ });
+ }
+
+ if (perms.includes('share')) {
+ actions.push({
+ label: ,
+ link: `/lists/${data[0]}/share`
+ });
+ }
+
+ tableAddDeleteButton(actions, this, perms, `rest/lists/${data[0]}`, data[1], t('deletingList'), t('listDeleted'));
+
+ return actions;
+ }
+ }
+ ];
+
+ return (
+
+ {tableRestActionDialogRender(this)}
+
+ { createPermitted &&
+
+ }
+ { customFormsPermitted &&
+
+ }
+
+
+
{t('lists')}
+
+
this.table = node} withHeader dataUrl="rest/lists-table" columns={columns} />
+
+ );
+ }
+}
\ No newline at end of file
diff --git a/client/src/lists/TriggersList.js b/client/src/lists/TriggersList.js
new file mode 100644
index 00000000..075ba940
--- /dev/null
+++ b/client/src/lists/TriggersList.js
@@ -0,0 +1,82 @@
+'use strict';
+
+import React, {Component} from 'react';
+import PropTypes from 'prop-types';
+import {withTranslation} from '../lib/i18n';
+import {requiresAuthenticatedUser, Title, withPageHelpers} from '../lib/page';
+import {withErrorHandling} from '../lib/error-handling';
+import {Table} from '../lib/table';
+import {getTriggerTypes} from '../campaigns/triggers/helpers';
+import {Icon} from "../lib/bootstrap-components";
+import mailtrainConfig from 'mailtrainConfig';
+import {tableAddDeleteButton, tableRestActionDialogInit, tableRestActionDialogRender} from "../lib/modals";
+import {withComponentMixins} from "../lib/decorator-helpers";
+
+@withComponentMixins([
+ withTranslation,
+ withErrorHandling,
+ withPageHelpers,
+ requiresAuthenticatedUser
+])
+export default class List extends Component {
+ constructor(props) {
+ super(props);
+
+ const {entityLabels, eventLabels} = getTriggerTypes(props.t);
+ this.entityLabels = entityLabels;
+ this.eventLabels = eventLabels;
+
+ this.state = {};
+ tableRestActionDialogInit(this);
+ }
+
+ static propTypes = {
+ list: PropTypes.object
+ }
+
+ componentDidMount() {
+ }
+
+ render() {
+ const t = this.props.t;
+
+ const columns = [
+ { data: 1, title: t('name') },
+ { data: 2, title: t('description') },
+ { data: 3, title: t('campaign') },
+ { data: 4, title: t('entity'), render: data => this.entityLabels[data], searchable: false },
+ { data: 5, title: t('event'), render: (data, cmd, rowData) => this.eventLabels[rowData[4]][data], searchable: false },
+ { data: 6, title: t('daysAfter'), render: data => Math.round(data / (3600 * 24)) },
+ { data: 7, title: t('enabled'), render: data => data ? t('yes') : t('no'), searchable: false},
+ {
+ actions: data => {
+ const actions = [];
+ const perms = data[9];
+ const campaignId = data[8];
+
+ if (mailtrainConfig.globalPermissions.setupAutomation && perms.includes('manageTriggers')) {
+ actions.push({
+ label: ,
+ link: `/campaigns/${campaignId}/triggers/${data[0]}/edit`
+ });
+ }
+
+ if (perms.includes('manageTriggers')) {
+ tableAddDeleteButton(actions, this, null, `rest/triggers/${campaignId}/${data[0]}`, data[1], t('deletingTrigger'), t('triggerDeleted'));
+ }
+
+ return actions;
+ }
+ }
+ ];
+
+ return (
+
+ {tableRestActionDialogRender(this)}
+
{t('triggers')}
+
+
this.table = node} withHeader dataUrl={`rest/triggers-by-list-table/${this.props.list.id}`} columns={columns} />
+
+ );
+ }
+}
\ No newline at end of file
diff --git a/client/src/lists/fields/CUD.js b/client/src/lists/fields/CUD.js
new file mode 100644
index 00000000..84b40bd5
--- /dev/null
+++ b/client/src/lists/fields/CUD.js
@@ -0,0 +1,535 @@
+'use strict';
+
+import React, {Component} from 'react';
+import PropTypes from 'prop-types';
+import {Trans} from 'react-i18next';
+import {withTranslation} from '../../lib/i18n';
+import {LinkButton, requiresAuthenticatedUser, Title, withPageHelpers} from '../../lib/page';
+import {
+ ACEEditor,
+ Button,
+ ButtonRow,
+ CheckBox,
+ Dropdown,
+ Fieldset,
+ filterData,
+ Form,
+ FormSendMethod,
+ InputField,
+ StaticField,
+ TableSelect,
+ TextArea,
+ withForm,
+ withFormErrorHandlers
+} from '../../lib/form';
+import {withErrorHandling} from '../../lib/error-handling';
+import {DeleteModalDialog} from "../../lib/modals";
+import {getFieldTypes} from './helpers';
+import validators from '../../../../shared/validators';
+import slugify from 'slugify';
+import {DateFormat, parseBirthday, parseDate} from '../../../../shared/date';
+import styles from "../../lib/styles.scss";
+import 'brace/mode/json';
+import 'brace/mode/handlebars';
+import {withComponentMixins} from "../../lib/decorator-helpers";
+
+@withComponentMixins([
+ withTranslation,
+ withForm,
+ withErrorHandling,
+ withPageHelpers,
+ requiresAuthenticatedUser
+])
+export default class CUD extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {};
+
+ this.fieldTypes = getFieldTypes(props.t);
+
+ this.initForm({
+ serverValidation: {
+ url: `rest/fields-validate/${this.props.list.id}`,
+ changed: ['key'],
+ extra: ['id']
+ },
+ onChangeBeforeValidation: {
+ name: ::this.onChangeName
+ }
+ });
+ }
+
+ static propTypes = {
+ action: PropTypes.string.isRequired,
+ list: PropTypes.object,
+ fields: PropTypes.array,
+ entity: PropTypes.object
+ }
+
+ onChangeName(mutStateData, attr, oldValue, newValue) {
+ const oldComputedKey = ('MERGE_' + slugify(oldValue, '_')).toUpperCase().replace(/[^A-Z0-9_]/g, '');
+ const oldKey = mutStateData.getIn(['key', 'value']);
+
+ if (oldKey === '' || oldKey === oldComputedKey) {
+ const newKey = ('MERGE_' + slugify(newValue, '_')).toUpperCase().replace(/[^A-Z0-9_]/g, '');
+ mutStateData.setIn(['key', 'value'], newKey);
+ }
+ }
+
+ getFormValuesMutator(data) {
+ data.settings = data.settings || {};
+
+ if (data.default_value === null) {
+ data.default_value = '';
+ }
+
+ if (data.help === null) {
+ data.help = '';
+ }
+
+ data.isInGroup = data.group !== null;
+
+ data.enumOptions = '';
+ data.dateFormat = DateFormat.EUR;
+ data.renderTemplate = '';
+
+ switch (data.type) {
+ case 'checkbox-grouped':
+ case 'radio-grouped':
+ case 'dropdown-grouped':
+ case 'json':
+ data.renderTemplate = data.settings.renderTemplate;
+ break;
+
+ case 'radio-enum':
+ case 'dropdown-enum':
+ data.enumOptions = this.renderEnumOptions(data.settings.options);
+ data.renderTemplate = data.settings.renderTemplate;
+ break;
+
+ case 'date':
+ case 'birthday':
+ data.dateFormat = data.settings.dateFormat;
+ break;
+
+ case 'option':
+ data.checkedLabel = data.isInGroup ? '' : data.settings.checkedLabel;
+ data.uncheckedLabel = data.isInGroup ? '' : data.settings.uncheckedLabel;
+ break;
+ }
+
+ data.orderListBefore = data.orderListBefore.toString();
+ data.orderSubscribeBefore = data.orderSubscribeBefore.toString();
+ data.orderManageBefore = data.orderManageBefore.toString();
+ }
+
+ submitFormValuesMutator(data) {
+ if (data.default_value.trim() === '') {
+ data.default_value = null;
+ }
+
+ if (data.help.trim() === '') {
+ data.help = null;
+ }
+
+ if (!data.isInGroup) {
+ data.group = null;
+ }
+
+ data.settings = {};
+ switch (data.type) {
+ case 'checkbox-grouped':
+ case 'radio-grouped':
+ case 'dropdown-grouped':
+ case 'json':
+ data.settings.renderTemplate = data.renderTemplate;
+ break;
+
+ case 'radio-enum':
+ case 'dropdown-enum':
+ data.settings.options = this.parseEnumOptions(data.enumOptions).options;
+ data.settings.renderTemplate = data.renderTemplate;
+ break;
+
+ case 'date':
+ case 'birthday':
+ data.settings.dateFormat = data.dateFormat;
+ break;
+
+ case 'option':
+ if (!data.isInGroup) {
+ data.settings.checkedLabel = data.checkedLabel;
+ data.settings.uncheckedLabel = data.uncheckedLabel;
+ }
+ break;
+ }
+
+ if (data.group !== null) {
+ data.orderListBefore = data.orderSubscribeBefore = data.orderManageBefore = 'none';
+ } else {
+ data.orderListBefore = Number.parseInt(data.orderListBefore) || data.orderListBefore;
+ data.orderSubscribeBefore = Number.parseInt(data.orderSubscribeBefore) || data.orderSubscribeBefore;
+ data.orderManageBefore = Number.parseInt(data.orderManageBefore) || data.orderManageBefore;
+ }
+
+ return filterData(data, ['name', 'help', 'key', 'default_value', 'type', 'group', 'settings',
+ 'orderListBefore', 'orderSubscribeBefore', 'orderManageBefore']);
+ }
+
+ componentDidMount() {
+ if (this.props.entity) {
+ this.getFormValuesFromEntity(this.props.entity);
+
+ } else {
+ this.populateFormValues({
+ name: '',
+ type: 'text',
+ key: '',
+ default_value: '',
+ help: '',
+ group: null,
+ isInGroup: false,
+ renderTemplate: '',
+ enumOptions: '',
+ dateFormat: 'eur',
+ checkedLabel: '',
+ uncheckedLabel: '',
+ orderListBefore: 'end', // possible values are / 'end' / 'none'
+ orderSubscribeBefore: 'end',
+ orderManageBefore: 'end'
+ });
+ }
+ }
+
+ localValidateFormValues(state) {
+ const t = this.props.t;
+
+ if (!state.getIn(['name', 'value'])) {
+ state.setIn(['name', 'error'], t('nameMustNotBeEmpty'));
+ } else {
+ state.setIn(['name', 'error'], null);
+ }
+
+ const keyServerValidation = state.getIn(['key', 'serverValidation']);
+ if (!validators.mergeTagValid(state.getIn(['key', 'value']))) {
+ state.setIn(['key', 'error'], t('mergeTagIsInvalidMayMustBeUppercaseAnd'));
+ } else if (!keyServerValidation) {
+ state.setIn(['key', 'error'], t('validationIsInProgress'));
+ } else if (keyServerValidation.exists) {
+ state.setIn(['key', 'error'], t('anotherFieldWithTheSameMergeTagExists'));
+ } else {
+ state.setIn(['key', 'error'], null);
+ }
+
+ const type = state.getIn(['type', 'value']);
+
+ const group = state.getIn(['group', 'value']);
+ const isInGroup = state.getIn(['isInGroup', 'value']);
+ if (isInGroup && !group) {
+ state.setIn(['group', 'error'], t('groupHasToBeSelected'));
+ } else {
+ state.setIn(['group', 'error'], null);
+ }
+
+ const defaultValue = state.getIn(['default_value', 'value']);
+ if (defaultValue === '') {
+ state.setIn(['default_value', 'error'], null);
+ } else if (type === 'number' && !/^[0-9]*$/.test(defaultValue.trim())) {
+ state.setIn(['default_value', 'error'], t('defaultValueIsNotIntegerNumber'));
+ } else if (type === 'date' && !parseDate(state.getIn(['dateFormat', 'value']), defaultValue)) {
+ state.setIn(['default_value', 'error'], t('defaultValueIsNotAProperlyFormattedDate'));
+ } else if (type === 'birthday' && !parseBirthday(state.getIn(['dateFormat', 'value']), defaultValue)) {
+ state.setIn(['default_value', 'error'], t('defaultValueIsNotAProperlyFormatted'));
+ } else {
+ state.setIn(['default_value', 'error'], null);
+ }
+
+ if (type === 'radio-enum' || type === 'dropdown-enum') {
+ const enumOptions = this.parseEnumOptions(state.getIn(['enumOptions', 'value']));
+ if (enumOptions.errors) {
+ state.setIn(['enumOptions', 'error'], {enumOptions.errors.map((err, idx) =>
{err}
)}
);
+ } else {
+ state.setIn(['enumOptions', 'error'], null);
+
+ if (defaultValue !== '' && !(enumOptions.options.find(x => x.key === defaultValue))) {
+ state.setIn(['default_value', 'error'], t('defaultValueIsNotOneOfTheAllowedOptions'));
+ }
+ }
+ } else {
+ state.setIn(['enumOptions', 'error'], null);
+ }
+ }
+
+ parseEnumOptions(text) {
+ const t = this.props.t;
+ const errors = [];
+ const options = [];
+
+ const lines = text.split('\n');
+ for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
+ const line = lines[lineIdx].trim();
+
+ if (line != '') {
+ const matches = line.match(/^([^|]*)[|](.*)$/);
+ if (matches) {
+ const key = matches[1].trim();
+ const label = matches[2].trim();
+ options.push({ key, label });
+ } else {
+ errors.push(t('errrorOnLineLine', { line: lineIdx + 1}));
+ }
+ }
+ }
+
+ if (errors.length) {
+ return {
+ errors
+ };
+ } else {
+ return {
+ options
+ };
+ }
+ }
+
+ renderEnumOptions(options) {
+ return options.map(opt => `${opt.key}|${opt.label}`).join('\n');
+ }
+
+
+ @withFormErrorHandlers
+ async submitHandler(submitAndLeave) {
+ const t = this.props.t;
+
+ let sendMethod, url;
+ if (this.props.entity) {
+ sendMethod = FormSendMethod.PUT;
+ url = `rest/fields/${this.props.list.id}/${this.props.entity.id}`
+ } else {
+ sendMethod = FormSendMethod.POST;
+ url = `rest/fields/${this.props.list.id}`
+ }
+
+ try {
+ this.disableForm();
+ this.setFormStatusMessage('info', t('saving'));
+
+ const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url);
+
+ if (submitResult) {
+ if (this.props.entity) {
+ if (submitAndLeave) {
+ this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/fields`, 'success', t('fieldUpdated'));
+ } else {
+ await this.getFormValuesFromURL(`rest/fields/${this.props.list.id}/${this.props.entity.id}`);
+ this.enableForm();
+ this.setFormStatusMessage('success', t('fieldUpdated'));
+ }
+ } else {
+ if (submitAndLeave) {
+ this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/fields`, 'success', t('fieldCreated'));
+ } else {
+ this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/fields/${submitResult}/edit`, 'success', t('fieldCreated'));
+ }
+ }
+ } else {
+ this.enableForm();
+ this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd'));
+ }
+ } catch (error) {
+ throw error;
+ }
+ }
+
+ render() {
+ const t = this.props.t;
+ const isEdit = !!this.props.entity;
+
+
+ const getOrderOptions = fld => {
+ return [
+ {key: 'none', label: t('notVisible')},
+ ...this.props.fields.filter(x => (!this.props.entity || x.id !== this.props.entity.id) && x[fld] !== null && x.group === null).sort((x, y) => x[fld] - y[fld]).map(x => ({ key: x.id.toString(), label: `${x.name} (${this.fieldTypes[x.type].label})`})),
+ {key: 'end', label: t('endOfList')}
+ ];
+ };
+
+
+ const typeOptions = Object.keys(this.fieldTypes).map(key => ({key, label: this.fieldTypes[key].label}));
+
+ const type = this.getFormValue('type');
+ const isInGroup = this.getFormValue('isInGroup');
+
+ let fieldSettings = null;
+ switch (type) {
+ case 'text':
+ case 'website':
+ case 'longtext':
+ case 'gpg':
+ case 'number':
+ fieldSettings =
+
+
+ ;
+ break;
+
+ case 'checkbox-grouped':
+ case 'radio-grouped':
+ case 'dropdown-grouped':
+ fieldSettings =
+
+ You can control the appearance of the merge tag with this template. The template
+ uses handlebars syntax and you can find all values from {'{{values}}'}
array, for
+ example {'{{#each values}} {{this}} {{/each}}'}
. If template is not defined then
+ multiple values are joined with commas.}
+ />
+ ;
+ break;
+
+ case 'radio-enum':
+ case 'dropdown-enum':
+ fieldSettings =
+
+ Specify the options to select from in the following format:key|label
. For example:
+ au|Australia
at|Austria
}
+ />
+ Default key (e.g. au
used when the field is empty.')}/>
+ You can control the appearance of the merge tag with this template. The template
+ uses handlebars syntax and you can find all values from {'{{values}}'}
array.
+ Each entry in the array is an object with attributes key
and label
.
+ For example {'{{#each values}} {{this.value}} {{/each}}'}
. If template is not defined then
+ multiple values are joined with commas.}
+ />
+ ;
+ break;
+
+ case 'date':
+ fieldSettings =
+
+
+ Default value used when the field is empty.}/>
+ ;
+ break;
+
+ case 'birthday':
+ fieldSettings =
+
+
+ Default value used when the field is empty.}/>
+ ;
+ break;
+
+ case 'json':
+ fieldSettings =
+ Default key (e.g. au
used when the field is empty.')}/>
+ You can use this template to render JSON values (if the JSON is an array then the array is
+ exposed as values
, otherwise you can access the JSON keys directly).}
+ />
+ ;
+ break;
+
+ case 'option':
+ const fieldsGroupedColumns = [
+ { data: 4, title: "#" },
+ { data: 1, title: t('name') },
+ { data: 2, title: t('type'), render: data => this.fieldTypes[data].label, sortable: false, searchable: false },
+ { data: 3, title: t('mergeTag') }
+ ];
+
+ fieldSettings =
+
+
+ {isInGroup &&
+
+ }
+ {!isInGroup &&
+ <>
+
+
+ >
+ }
+
+ ;
+ break;
+ }
+
+
+ return (
+
+ {isEdit &&
+
+ }
+
+ {isEdit ? t('editField') : t('createField')}
+
+
+
+
+ {isEdit ?
+ {(this.fieldTypes[this.getFormValue('type')] || {}).label}
+ :
+
+ }
+
+
+
+
+
+ {fieldSettings}
+
+ {type !== 'option' &&
+
+
+
+
+
+ }
+
+
+
+ await this.submitHandler(true)}/>
+ {isEdit && }
+
+
+
+ );
+ }
+}
\ No newline at end of file
diff --git a/client/src/lists/fields/List.js b/client/src/lists/fields/List.js
new file mode 100644
index 00000000..33461a41
--- /dev/null
+++ b/client/src/lists/fields/List.js
@@ -0,0 +1,80 @@
+'use strict';
+
+import React, {Component} from 'react';
+import PropTypes from 'prop-types';
+import {withTranslation} from '../../lib/i18n';
+import {LinkButton, requiresAuthenticatedUser, Title, Toolbar, withPageHelpers} from '../../lib/page';
+import {withErrorHandling} from '../../lib/error-handling';
+import {Table} from '../../lib/table';
+import {getFieldTypes} from './helpers';
+import {Icon} from "../../lib/bootstrap-components";
+import {tableAddDeleteButton, tableRestActionDialogInit, tableRestActionDialogRender} from "../../lib/modals";
+import {withComponentMixins} from "../../lib/decorator-helpers";
+
+@withComponentMixins([
+ withTranslation,
+ withErrorHandling,
+ withPageHelpers,
+ requiresAuthenticatedUser
+])
+export default class List extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {};
+ tableRestActionDialogInit(this);
+
+ this.fieldTypes = getFieldTypes(props.t);
+ }
+
+ static propTypes = {
+ list: PropTypes.object
+ }
+
+ componentDidMount() {
+ }
+
+ render() {
+ const t = this.props.t;
+
+ const columns = [
+ { data: 4, title: "#" },
+ { data: 1, title: t('name'),
+ render: (data, cmd, rowData) => rowData[5] !== null ? {data} : data
+ },
+ { data: 2, title: t('type'), render: data => this.fieldTypes[data].label, sortable: false, searchable: false },
+ { data: 3, title: t('mergeTag') },
+ {
+ actions: data => {
+ const actions = [];
+
+ if (this.props.list.permissions.includes('manageFields')) {
+ actions.push({
+ label: ,
+ link: `/lists/${this.props.list.id}/fields/${data[0]}/edit`
+ });
+
+ tableAddDeleteButton(actions, this, null, `rest/fields/${this.props.list.id}/${data[0]}`, data[1], t('deletingField'), t('fieldDeleted'));
+ }
+
+ return actions;
+ }
+ }
+ ];
+
+ return (
+
+ {tableRestActionDialogRender(this)}
+ {this.props.list.permissions.includes('manageFields') &&
+
+
+
+ }
+
+
{t('fields')}
+
+
this.table = node} withHeader dataUrl={`rest/fields-table/${this.props.list.id}`} columns={columns} />
+
+ );
+ }
+}
\ No newline at end of file
diff --git a/client/src/lists/fields/helpers.js b/client/src/lists/fields/helpers.js
new file mode 100644
index 00000000..07c84ba7
--- /dev/null
+++ b/client/src/lists/fields/helpers.js
@@ -0,0 +1,54 @@
+'use strict';
+
+import React from 'react';
+
+export function getFieldTypes(t) {
+
+ const fieldTypes = {
+ text: {
+ label: t('text'),
+ },
+ website: {
+ label: t('website'),
+ },
+ longtext: {
+ label: t('multilineText'),
+ },
+ gpg: {
+ label: t('gpgPublicKey'),
+ },
+ number: {
+ label: t('number'),
+ },
+ 'checkbox-grouped': {
+ label: t('checkboxesFromOptionFields'),
+ },
+ 'radio-grouped': {
+ label: t('radioButtonsFromOptionFields')
+ },
+ 'dropdown-grouped': {
+ label: t('dropDownFromOptionFields')
+ },
+ 'radio-enum': {
+ label: t('radioButtonsEnumerated')
+ },
+ 'dropdown-enum': {
+ label: t('dropDownEnumerated')
+ },
+ 'date': {
+ label: t('date')
+ },
+ 'birthday': {
+ label: t('birthday')
+ },
+ json: {
+ label: t('jsonValueForCustomRendering')
+ },
+ option: {
+ label: t('option')
+ }
+ };
+
+ return fieldTypes;
+}
+
diff --git a/client/src/lists/forms/CUD.js b/client/src/lists/forms/CUD.js
new file mode 100644
index 00000000..6604ecba
--- /dev/null
+++ b/client/src/lists/forms/CUD.js
@@ -0,0 +1,592 @@
+'use strict';
+
+import React, {Component} from 'react';
+import PropTypes from 'prop-types';
+import {Trans} from 'react-i18next';
+import {withTranslation} from '../../lib/i18n';
+import {LinkButton, requiresAuthenticatedUser, Title, withPageHelpers} from '../../lib/page';
+import {
+ ACEEditor,
+ AlignedRow,
+ Button,
+ ButtonRow,
+ CheckBox,
+ Dropdown,
+ Fieldset,
+ filterData,
+ Form,
+ FormSendMethod,
+ InputField,
+ TableSelect,
+ TextArea,
+ withForm,
+ withFormErrorHandlers
+} from '../../lib/form';
+import {withErrorHandling} from '../../lib/error-handling';
+import {getDefaultNamespace, NamespaceSelect, validateNamespace} from '../../lib/namespace';
+import {DeleteModalDialog} from "../../lib/modals";
+import mailtrainConfig from 'mailtrainConfig';
+import {getTrustedUrl, getUrl} from "../../lib/urls";
+import {ActionLink, Icon} from "../../lib/bootstrap-components";
+import styles from "../../lib/styles.scss";
+import formsStyles from "./styles.scss";
+import axios from "../../lib/axios";
+import {withComponentMixins} from "../../lib/decorator-helpers";
+
+@withComponentMixins([
+ withTranslation,
+ withForm,
+ withErrorHandling,
+ withPageHelpers,
+ requiresAuthenticatedUser
+])
+export default class CUD extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ previewContents: null,
+ previewFullscreen: false
+ };
+
+ 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',
+ 'web_privacy_policy_notice'
+ ];
+
+ this.initForm({
+ serverValidation: {
+ url: 'rest/forms-validate',
+ changed: this.serverValidatedFields
+ },
+ onChange: {
+ previewList: (newState, key, oldValue, newValue) => {
+ newState.formState.setIn(['data', 'previewContents', 'value'], null);
+ }
+ }
+ });
+
+
+ const t = props.t;
+
+ const helpEmailText = t('thePlaintextVersionForThisEmail');
+ const helpMjmlGeneral = Custom forms use MJML for formatting. See the MJML documentation here ;
+
+ this.templateSettings = {
+ layout: {
+ label: t('layout'),
+ mode: 'html',
+ help: helpMjmlGeneral,
+ isLayout: true
+ },
+ form_input_style: {
+ label: t('formInputStyle'),
+ mode: 'css',
+ help: t('thisCssStylesheetDefinesTheAppearanceOf')
+ },
+ web_subscribe: {
+ label: t('webSubscribe'),
+ mode: 'html',
+ help: helpMjmlGeneral
+ },
+ web_confirm_subscription_notice: {
+ label: t('webConfirmSubscriptionNotice'),
+ mode: 'html',
+ help: helpMjmlGeneral
+ },
+ mail_confirm_subscription_html: {
+ label: t('mailConfirmSubscriptionMjml'),
+ mode: 'html',
+ help: helpMjmlGeneral
+ },
+ mail_confirm_subscription_text: {
+ label: t('mailConfirmSubscriptionText'),
+ mode: 'text',
+ help: helpEmailText
+ },
+ mail_already_subscribed_html: {
+ label: t('mailAlreadySubscribedMjml'),
+ mode: 'html',
+ help: helpMjmlGeneral
+ },
+ mail_already_subscribed_text: {
+ label: t('mailAlreadySubscribedText'),
+ mode: 'text',
+ help: helpEmailText
+ },
+ web_subscribed_notice: {
+ label: t('webSubscribedNotice'),
+ mode: 'html',
+ help: helpMjmlGeneral
+ },
+ mail_subscription_confirmed_html: {
+ label: t('mailSubscriptionConfirmedMjml'),
+ mode: 'html',
+ help: helpMjmlGeneral
+ },
+ mail_subscription_confirmed_text: {
+ label: t('mailSubscriptionConfirmedText'),
+ mode: 'text',
+ help: helpEmailText
+ },
+ web_manage: {
+ label: t('webManagePreferences'),
+ mode: 'html',
+ help: helpMjmlGeneral
+ },
+ web_manage_address: {
+ label: t('webManageAddress'),
+ mode: 'html',
+ help: helpMjmlGeneral
+ },
+ mail_confirm_address_change_html: {
+ label: t('mailConfirmAddressChangeMjml'),
+ mode: 'html',
+ help: helpMjmlGeneral
+ },
+ mail_confirm_address_change_text: {
+ label: t('mailConfirmAddressChangeText'),
+ mode: 'text',
+ help: helpEmailText
+ },
+ web_updated_notice: {
+ label: t('webUpdatedNotice'),
+ mode: 'html',
+ help: helpMjmlGeneral
+ },
+ web_unsubscribe: {
+ label: t('webUnsubscribe'),
+ mode: 'html',
+ help: helpMjmlGeneral
+ },
+ web_confirm_unsubscription_notice: {
+ label: t('webConfirmUnsubscriptionNotice'),
+ mode: 'html',
+ help: helpMjmlGeneral
+ },
+ mail_confirm_unsubscription_html: {
+ label: t('mailConfirmUnsubscriptionMjml'),
+ mode: 'html',
+ help: helpMjmlGeneral
+ },
+ mail_confirm_unsubscription_text: {
+ label: t('mailConfirmUnsubscriptionText'),
+ mode: 'text',
+ help: helpEmailText
+ },
+ web_unsubscribed_notice: {
+ label: t('webUnsubscribedNotice'),
+ mode: 'html',
+ help: helpMjmlGeneral
+ },
+ mail_unsubscription_confirmed_html: {
+ label: t('mailUnsubscriptionConfirmedMjml'),
+ mode: 'html',
+ help: helpMjmlGeneral
+ },
+ mail_unsubscription_confirmed_text: {
+ label: t('mailUnsubscriptionConfirmedText'),
+ mode: 'text',
+ help: helpEmailText
+ },
+ web_manual_unsubscribe_notice: {
+ label: t('webManualUnsubscribeNotice'),
+ mode: 'html',
+ help: helpMjmlGeneral
+ },
+ web_privacy_policy_notice: {
+ label: t('privacyPolicy'),
+ 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'
+ ]
+ },
+ gdpr: {
+ label: t('dataProtection'),
+ options: [
+ 'web_privacy_policy_notice'
+ ]
+ },
+ };
+
+ }
+
+ static propTypes = {
+ action: PropTypes.string.isRequired,
+ entity: PropTypes.object,
+ permissions: PropTypes.object
+ }
+
+
+ supplyDefaults(data) {
+ for (const key in mailtrainConfig.defaultCustomFormValues) {
+ if (!data[key]) {
+ data[key] = mailtrainConfig.defaultCustomFormValues[key];
+ }
+ }
+ }
+
+ getFormValuesMutator(data, originalData) {
+ this.supplyDefaults(data);
+ data.selectedTemplate = (originalData && originalData.selectedTemplate) || 'layout';
+ }
+
+ submitFormValuesMutator(data) {
+ return filterData(data, ['name', 'description', 'namespace',
+ 'fromExistingEntity', 'existingEntity',
+
+ 'layout', 'form_input_style',
+ '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', 'web_privacy_policy_notice'
+ ]);
+ }
+
+ componentDidMount() {
+ if (this.props.entity) {
+ this.getFormValuesFromEntity(this.props.entity);
+
+ } else {
+ const data = {
+ name: '',
+ description: '',
+ fromExistingEntity: false,
+ existingEntity: null,
+ selectedTemplate: 'layout',
+ namespace: getDefaultNamespace(this.props.permissions)
+ };
+ this.supplyDefaults(data);
+
+ this.populateFormValues(data);
+ }
+ }
+
+ localValidateFormValues(state) {
+ const t = this.props.t;
+
+ if (!state.getIn(['name', 'value'])) {
+ state.setIn(['name', 'error'], t('nameMustNotBeEmpty'));
+ } else {
+ state.setIn(['name', 'error'], null);
+ }
+
+ validateNamespace(t, state);
+
+ if (state.getIn(['fromExistingEntity', 'value']) && !state.getIn(['existingEntity', 'value'])) {
+ state.setIn(['existingEntity', 'error'], t('sourceCustomFormsMustNotBeEmpty'));
+ } else {
+ state.setIn(['existingEntity', 'error'], null);
+ }
+
+
+ let formsServerValidationRunning = false;
+ const formsErrors = [];
+
+ for (const fld of this.serverValidatedFields) {
+ const serverValidation = state.getIn([fld, 'serverValidation']);
+
+ if (serverValidation && serverValidation.errors) {
+ formsErrors.push(...serverValidation.errors.map(x => {this.templateSettings[fld].label} {' '}–{' '}{x}
));
+ } else if (!serverValidation) {
+ formsServerValidationRunning = true;
+ }
+ }
+
+ if (!formsErrors.length && formsServerValidationRunning) {
+ formsErrors.push(t('validationIsInProgress'));
+ }
+
+ if (formsErrors.length) {
+ state.setIn(['selectedTemplate', 'error'],
+ {t('listOfErrorsInTemplates') + ':'}
+
+ {formsErrors.map((msg, idx) => {msg} )}
+
+
);
+ } else {
+ state.setIn(['selectedTemplate', 'error'], null);
+ }
+ }
+
+ @withFormErrorHandlers
+ async submitHandler(submitAndLeave) {
+ const t = this.props.t;
+
+ let sendMethod, url;
+ if (this.props.entity) {
+ sendMethod = FormSendMethod.PUT;
+ url = `rest/forms/${this.props.entity.id}`;
+ } else {
+ sendMethod = FormSendMethod.POST;
+ url = 'rest/forms';
+ }
+
+ this.disableForm();
+ this.setFormStatusMessage('info', t('saving'));
+
+ const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url);
+
+ if (submitResult) {
+ if (this.props.entity) {
+ if (submitAndLeave) {
+ this.navigateToWithFlashMessage('/lists/forms', 'success', t('customFormsUpdated'));
+ } else {
+ await this.getFormValuesFromURL(`rest/forms/${this.props.entity.id}`);
+ this.enableForm();
+ this.setFormStatusMessage('success', t('customFormsUpdated'));
+ }
+ } else {
+ if (submitAndLeave) {
+ this.navigateToWithFlashMessage('/lists/forms', 'success', t('customFormsCreated'));
+ } else {
+ this.navigateToWithFlashMessage(`/lists/forms/${submitResult}/edit`, 'success', t('customFormsCreated'));
+ }
+ }
+ } else {
+ this.enableForm();
+ this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd'));
+ }
+ }
+
+ async preview(formKey) {
+ const data = {
+ formKey,
+ template: this.getFormValue(formKey),
+ layout: this.getFormValue('layout'),
+ formInputStyle: this.getFormValue('form_input_style'),
+ listId: this.getFormValue('previewList')
+ }
+
+ const response = await axios.post(getUrl('rest/forms-preview'), data);
+
+ this.setState({
+ previewKey: formKey,
+ previewContents: response.data.content,
+ previewLabel: this.templateSettings[formKey].label
+ });
+ }
+
+ render() {
+ const t = this.props.t;
+ const isEdit = !!this.props.entity;
+ const canDelete = isEdit && this.props.entity.permissions.includes('delete');
+
+ 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 customFormsColumns = [
+ { data: 1, title: t('name') },
+ { data: 2, title: t('description') },
+ { data: 3, title: t('namespace') }
+ ];
+
+ const listsColumns = [
+ { data: 0, title: "#" },
+ { data: 1, title: t('name') },
+ { data: 2, title: t('id'), render: data => {data}
},
+ { data: 5, title: t('namespace') }
+ ];
+
+ const previewListId = this.getFormValue('previewList');
+ const selectedTemplate = this.getFormValue('selectedTemplate');
+
+ return (
+
+ {canDelete &&
+
+ }
+
+ {isEdit ? t('editCustomForms') : t('createCustomForms')}
+
+
+
+
+
+
+
+
+ {!isEdit &&
+
+ }
+
+ {this.getFormValue('fromExistingEntity') ?
+
+ :
+ <>
+
+
+
+ { previewListId &&
+
+
+
+
+ {t('noteTheseLinksAreSolelyForAQuickPreview')}
+
+
+
+ await this.preview('web_subscribe')}>Subscribe
+ {' | '}
+ await this.preview('web_confirm_subscription_notice')}>Confirm Subscription Notice
+ {' | '}
+ await this.preview('web_confirm_unsubscription_notice')}>Confirm Unsubscription Notice
+ {' | '}
+ await this.preview('web_subscribed_notice')}>Subscribed Notice
+ {' | '}
+ await this.preview('web_updated_notice')}>Updated Notice
+ {' | '}
+ await this.preview('web_unsubscribed_notice')}>Unsubscribed Notice
+ {' | '}
+ await this.preview('web_manual_unsubscribe_notice')}>Manual Unsubscribe Notice
+ {' | '}
+ await this.preview('web_unsubscribe')}>Unsubscribe
+ {' | '}
+ await this.preview('web_manage')}>Manage
+ {' | '}
+ await this.preview('web_manage_address')}>Manage Address
+ {' | '}
+ await this.preview('web_privacy_policy_notice')}>Privacy Policy
+
+
+ {this.state.previewContents &&
+
+
+
+ {this.state.fullscreen &&
}
+
{t('formPreview') + ' ' + this.state.previewLabel}
+
+
+
+
+
+ }
+
+ }
+
+
+ { selectedTemplate &&
+
+
+
+
+ }
+ >
+ }
+
+
+
+ await this.submitHandler(true)}/>
+ {canDelete && }
+
+
+
+ );
+ }
+}
\ No newline at end of file
diff --git a/client/src/lists/forms/List.js b/client/src/lists/forms/List.js
new file mode 100644
index 00000000..5b128ae8
--- /dev/null
+++ b/client/src/lists/forms/List.js
@@ -0,0 +1,81 @@
+'use strict';
+
+import React, {Component} from 'react';
+import {withTranslation} from '../../lib/i18n';
+import {LinkButton, requiresAuthenticatedUser, Title, Toolbar, withPageHelpers} from '../../lib/page';
+import {withErrorHandling} from '../../lib/error-handling';
+import {Table} from '../../lib/table';
+import {Icon} from "../../lib/bootstrap-components";
+import {tableAddDeleteButton, tableRestActionDialogInit, tableRestActionDialogRender} from "../../lib/modals";
+import {withComponentMixins} from "../../lib/decorator-helpers";
+import PropTypes from 'prop-types';
+
+@withComponentMixins([
+ withTranslation,
+ withErrorHandling,
+ withPageHelpers,
+ requiresAuthenticatedUser
+])
+export default class List extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {};
+ tableRestActionDialogInit(this);
+ }
+
+ static propTypes = {
+ permissions: PropTypes.object
+ }
+
+ render() {
+ const t = this.props.t;
+
+ const permissions = this.props.permissions;
+ const createPermitted = permissions.createCustomForm;
+
+ const columns = [
+ { data: 1, title: t('name') },
+ { data: 2, title: t('description') },
+ { data: 3, title: t('namespace') },
+ {
+ actions: data => {
+ const actions = [];
+ const perms = data[4];
+
+ if (perms.includes('edit')) {
+ actions.push({
+ label: ,
+ link: `/lists/forms/${data[0]}/edit`
+ });
+ }
+ if (perms.includes('share')) {
+ actions.push({
+ label: ,
+ link: `/lists/forms/${data[0]}/share`
+ });
+ }
+
+ tableAddDeleteButton(actions, this, perms, `rest/forms/${data[0]}`, data[1], t('deletingForm'), t('formDeleted'));
+
+ return actions;
+ }
+ }
+ ];
+
+ return (
+
+ {tableRestActionDialogRender(this)}
+ {createPermitted &&
+
+
+
+ }
+
+
{t('forms')}
+
+
this.table = node} withHeader dataUrl="rest/forms-table" columns={columns} />
+
+ );
+ }
+}
\ No newline at end of file
diff --git a/client/src/lists/forms/styles.scss b/client/src/lists/forms/styles.scss
new file mode 100644
index 00000000..45d818cb
--- /dev/null
+++ b/client/src/lists/forms/styles.scss
@@ -0,0 +1,11 @@
+$editorNormalHeight: 400px;
+@import "../../lib/sandbox-common";
+
+.editor {
+ margin-bottom: 15px;
+}
+
+.host {
+ border: none;
+ width: 100%;
+}
diff --git a/client/src/lists/imports/CUD.js b/client/src/lists/imports/CUD.js
new file mode 100644
index 00000000..887df141
--- /dev/null
+++ b/client/src/lists/imports/CUD.js
@@ -0,0 +1,472 @@
+'use strict';
+
+import React, {Component} from 'react';
+import PropTypes from 'prop-types';
+import {withTranslation} from '../../lib/i18n';
+import {LinkButton, requiresAuthenticatedUser, Title, withPageHelpers} from '../../lib/page';
+import {
+ AlignedRow,
+ Button,
+ ButtonRow,
+ CheckBox,
+ Dropdown,
+ Fieldset,
+ filterData,
+ Form,
+ FormSendMethod,
+ InputField,
+ StaticField,
+ TextArea,
+ withForm,
+ withFormErrorHandlers
+} from '../../lib/form';
+import {withAsyncErrorHandler, withErrorHandling} from '../../lib/error-handling';
+import {DeleteModalDialog} from "../../lib/modals";
+import {getImportLabels} from './helpers';
+import {ImportSource, inProgress, MappingType, prepInProgress, prepFinished} from '../../../../shared/imports';
+import axios from "../../lib/axios";
+import {getUrl} from "../../lib/urls";
+import listStyles from "../styles.scss";
+import styles from "../../lib/styles.scss";
+import interoperableErrors from "../../../../shared/interoperable-errors";
+import {withComponentMixins} from "../../lib/decorator-helpers";
+
+
+function truncate(str, len, ending = '...') {
+ str = str.trim();
+
+ if (str.length > len) {
+ return str.substring(0, len - ending.length) + ending;
+ } else {
+ return str;
+ }
+}
+
+
+@withComponentMixins([
+ withTranslation,
+ withForm,
+ withErrorHandling,
+ withPageHelpers,
+ requiresAuthenticatedUser
+])
+export default class CUD extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {};
+
+ const {importSourceLabels, mappingTypeLabels} = getImportLabels(props.t);
+
+ this.importSourceLabels = importSourceLabels;
+
+ this.importSourceOptions = [
+ {key: ImportSource.CSV_FILE, label: importSourceLabels[ImportSource.CSV_FILE]},
+ // {key: ImportSource.LIST, label: importSourceLabels[ImportSource.LIST]}
+ ];
+
+ this.mappingOptions = [
+ {key: MappingType.BASIC_SUBSCRIBE, label: mappingTypeLabels[MappingType.BASIC_SUBSCRIBE]},
+ {key: MappingType.BASIC_UNSUBSCRIBE, label: mappingTypeLabels[MappingType.BASIC_UNSUBSCRIBE]},
+ ];
+
+ this.refreshTimeoutHandler = ::this.refreshEntity;
+ this.refreshTimeoutId = 0;
+
+ this.initForm();
+ }
+
+ static propTypes = {
+ action: PropTypes.string.isRequired,
+ list: PropTypes.object,
+ fieldsGrouped: PropTypes.array,
+ entity: PropTypes.object
+ }
+
+ getFormValuesMutator(data) {
+ data.settings = data.settings || {};
+ const mapping = data.mapping || {};
+
+ if (data.source === ImportSource.CSV_FILE) {
+ data.csvFileName = data.settings.csv.originalname;
+ data.csvDelimiter = data.settings.csv.delimiter;
+ }
+
+ const mappingSettings = mapping.settings || {};
+ data.mapping_settings_checkEmails = 'checkEmails' in mappingSettings ? !!mappingSettings.checkEmails : true;
+
+ const mappingFlds = mapping.fields || {};
+ for (const field of this.props.fieldsGrouped) {
+ if (field.column) {
+ const colMapping = mappingFlds[field.column] || {};
+ data['mapping_fields_' + field.column + '_column'] = colMapping.column || '';
+ } else {
+ for (const option of field.settings.options) {
+ const col = field.groupedOptions[option.key].column;
+ const colMapping = mappingFlds[col] || {};
+ data['mapping_fields_' + col + '_column'] = colMapping.column || '';
+ }
+ }
+ }
+
+ const emailMapping = mappingFlds.email || {};
+ data.mapping_fields_email_column = emailMapping.column || '';
+ }
+
+ submitFormValuesMutator(data, isSubmit) {
+ const isEdit = !!this.props.entity;
+
+ data.source = Number.parseInt(data.source);
+ data.settings = {};
+
+ let formData, csvFileSelected = false;
+ if (isSubmit) {
+ formData = new FormData();
+
+ }
+
+ if (!isEdit) {
+ if (data.source === ImportSource.CSV_FILE) {
+ data.settings.csv = {};
+
+ // This test needs to be here because this function is also called by the form change detection mechanism
+ if (this.csvFile && this.csvFile.files && this.csvFile.files.length > 0) {
+ if (isSubmit) {
+ formData.append('csvFile', this.csvFile.files[0]);
+ } else {
+ csvFileSelected = true;
+ }
+ }
+
+ data.settings.csv.delimiter = data.csvDelimiter.trim();
+ }
+
+ } else {
+ data.mapping_type = Number.parseInt(data.mapping_type);
+ const mapping = {
+ fields: {},
+ settings: {}
+ };
+
+ if (data.mapping_type === MappingType.BASIC_SUBSCRIBE) {
+ mapping.settings.checkEmails = data.mapping_settings_checkEmails;
+
+ for (const field of this.props.fieldsGrouped) {
+ if (field.column) {
+ const colMapping = data['mapping_fields_' + field.column + '_column'];
+ if (colMapping) {
+ mapping.fields[field.column] = {
+ column: colMapping
+ };
+ }
+ } else {
+ for (const option of field.settings.options) {
+ const col = field.groupedOptions[option.key].column;
+ const colMapping = data['mapping_fields_' + col + '_column'];
+ if (colMapping) {
+ mapping.fields[col] = {
+ column: colMapping
+ };
+ }
+ }
+ }
+ }
+ }
+
+ if (data.mapping_type === MappingType.BASIC_SUBSCRIBE || data.mapping_type === MappingType.BASIC_UNSUBSCRIBE) {
+ mapping.fields.email = {
+ column: data.mapping_fields_email_column
+ };
+ }
+
+
+ data.mapping = mapping;
+ }
+
+ if (isSubmit) {
+ formData.append('entity', JSON.stringify(
+ filterData(data, ['name', 'description', 'source', 'settings', 'mapping_type', 'mapping'])
+ ));
+
+ return formData;
+
+ } else {
+ const filteredData = filterData(data, ['name', 'description', 'source', 'settings', 'mapping_type', 'mapping']);
+ if (csvFileSelected) {
+ filteredData.csvFileSelected = true;
+ }
+
+ return filteredData;
+ }
+ }
+
+ initFromEntity(entity) {
+ this.getFormValuesFromEntity(entity);
+
+ if (inProgress(entity.status)) {
+ this.refreshTimeoutId = setTimeout(this.refreshTimeoutHandler, 1000);
+ }
+ }
+
+ componentDidMount() {
+ if (this.props.entity) {
+ this.initFromEntity(this.props.entity);
+ } else {
+ this.populateFormValues({
+ name: '',
+ description: '',
+ source: ImportSource.CSV_FILE,
+ csvFileName: '',
+ csvDelimiter: ',',
+ });
+ }
+ }
+
+ componentWillUnmount() {
+ clearTimeout(this.refreshTimeoutId);
+ }
+
+ @withAsyncErrorHandler
+ async refreshEntity() {
+ const resp = await axios.get(getUrl(`rest/imports/${this.props.list.id}/${this.props.entity.id}`));
+ this.initFromEntity(resp.data);
+ }
+
+ localValidateFormValues(state) {
+ const t = this.props.t;
+ const isEdit = !!this.props.entity;
+ const source = Number.parseInt(state.getIn(['source', 'value']));
+
+ for (const key of state.keys()) {
+ state.setIn([key, 'error'], null);
+ }
+
+ if (!state.getIn(['name', 'value'])) {
+ state.setIn(['name', 'error'], t('nameMustNotBeEmpty'));
+ }
+
+ if (!isEdit) {
+ if (source === ImportSource.CSV_FILE) {
+ if (!this.csvFile || this.csvFile.files.length === 0) {
+ state.setIn(['csvFileName', 'error'], t('fileMustBeSelected'));
+ }
+
+ if (!state.getIn(['csvDelimiter', 'value']).trim()) {
+ state.setIn(['csvDelimiter', 'error'], t('csvDelimiterMustNotBeEmpty'));
+ }
+ }
+ } else {
+ const mappingType = Number.parseInt(state.getIn(['mapping_type', 'value']));
+
+ if (mappingType === MappingType.BASIC_SUBSCRIBE || mappingType === MappingType.BASIC_UNSUBSCRIBE) {
+ if (!state.getIn(['mapping_fields_email_column', 'value'])) {
+ state.setIn(['mapping_fields_email_column', 'error'], t('emailMappingHasToBeProvided'));
+ }
+ }
+ }
+ }
+
+ async submitHandler() {
+ await this.save();
+ }
+
+ @withFormErrorHandlers
+ async save(runAfterSave) {
+ const t = this.props.t;
+ const isEdit = !!this.props.entity;
+
+
+ let sendMethod, url;
+ if (this.props.entity) {
+ sendMethod = FormSendMethod.PUT;
+ url = `rest/imports/${this.props.list.id}/${this.props.entity.id}`
+ } else {
+ sendMethod = FormSendMethod.POST;
+ url = `rest/imports/${this.props.list.id}`
+ }
+
+ try {
+ this.disableForm();
+ this.setFormStatusMessage('info', t('saving'));
+
+ const submitResponse = await this.validateAndSendFormValuesToURL(sendMethod, url);
+
+ if (submitResponse) {
+ if (!isEdit) {
+ this.navigateTo(`/lists/${this.props.list.id}/imports/${submitResponse}/edit`);
+ } else {
+ if (runAfterSave) {
+ try {
+ await axios.post(getUrl(`rest/import-start/${this.props.list.id}/${this.props.entity.id}`));
+ } catch (err) {
+ if (err instanceof interoperableErrors.InvalidStateError) {
+ // Just mask the fact that it's not possible to start anything and refresh instead.
+ } else {
+ throw err;
+ }
+ }
+ }
+
+ this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/imports/${this.props.entity.id}/status`, 'success', t('importSaved'));
+ }
+
+ } else {
+ this.enableForm();
+ this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd'));
+ }
+ } catch (error) {
+ throw error;
+ }
+ }
+
+ onFileSelected(evt, x) {
+ if (!this.getFormValue('name') && this.csvFile.files.length > 0) {
+ this.updateFormValue('name', this.csvFile.files[0].name);
+ }
+
+ this.scheduleFormRevalidate();
+ }
+
+ render() {
+ const t = this.props.t;
+ const isEdit = !!this.props.entity;
+
+ const source = Number.parseInt(this.getFormValue('source'));
+ const status = this.getFormValue('status');
+ const settings = this.getFormValue('settings');
+
+ let settingsEdit = null;
+ if (source === ImportSource.CSV_FILE) {
+ if (isEdit) {
+ settingsEdit =
+
+ {this.getFormValue('csvFileName')}
+ {this.getFormValue('csvDelimiter')}
+
;
+ } else {
+ settingsEdit =
+ ;
+ }
+ }
+
+ let mappingEdit;
+ if (isEdit) {
+ if (prepInProgress(status)) {
+ mappingEdit = (
+ {t('preparationInProgressPleaseWaitTillItIs')}
+ );
+
+ } else {
+ let mappingSettings = null;
+ const mappingType = Number.parseInt(this.getFormValue('mapping_type'));
+
+ if (mappingType === MappingType.BASIC_SUBSCRIBE || mappingType === MappingType.BASIC_UNSUBSCRIBE) {
+ const sampleRow = this.getFormValue('sampleRow');
+ const sourceOpts = [];
+ sourceOpts.push({key: '', label: t('––Select ––')});
+ if (source === ImportSource.CSV_FILE) {
+ for (const csvCol of settings.csv.columns) {
+ let help = '';
+ if (sampleRow) {
+ help = ' (' + t('eg', {keySeparator: '>', nsSeparator: '|'}) + ' ' + truncate(sampleRow[csvCol.column], 50) + ')';
+ }
+
+ sourceOpts.push({key: csvCol.column, label: csvCol.name + help});
+ }
+ }
+
+ const settingsRows = [];
+ const mappingRows = [
+
+ ];
+
+ if (mappingType === MappingType.BASIC_SUBSCRIBE) {
+ settingsRows.push()
+
+ for (const field of this.props.fieldsGrouped) {
+ if (field.column) {
+ mappingRows.push(
+
+ );
+ } else {
+ for (const option of field.settings.options) {
+ const col = field.groupedOptions[option.key].column;
+ mappingRows.push(
+
+ );
+ }
+ }
+ }
+ }
+
+ mappingSettings = (
+
+ {settingsRows}
+
+ {mappingRows}
+
+
+ );
+ }
+
+ mappingEdit = (
+
+
+ {mappingSettings}
+
+ );
+ }
+ }
+
+ const saveButtons = []
+ if (!isEdit) {
+ saveButtons.push();
+ } else {
+ if (prepFinished(status)) {
+ saveButtons.push();
+ saveButtons.push( await this.save(true)}/>);
+ }
+ }
+
+ return (
+
+ {isEdit &&
+
+ }
+
+ {isEdit ? t('editImport') : t('createImport')}
+
+
+
+
+
+ {isEdit ?
+ {this.importSourceLabels[this.getFormValue('source')]}
+ :
+
+ }
+
+ {settingsEdit}
+
+ {mappingEdit}
+
+
+
+ {saveButtons}
+ {isEdit && }
+
+
+
+ );
+ }
+}
\ No newline at end of file
diff --git a/client/src/lists/imports/List.js b/client/src/lists/imports/List.js
new file mode 100644
index 00000000..7e077748
--- /dev/null
+++ b/client/src/lists/imports/List.js
@@ -0,0 +1,98 @@
+'use strict';
+
+import React, {Component} from 'react';
+import PropTypes from 'prop-types';
+import {withTranslation} from '../../lib/i18n';
+import {LinkButton, requiresAuthenticatedUser, Title, Toolbar, withPageHelpers} from '../../lib/page';
+import {withErrorHandling} from '../../lib/error-handling';
+import {Table} from '../../lib/table';
+import {getImportLabels} from './helpers';
+import {Icon} from "../../lib/bootstrap-components";
+import mailtrainConfig from 'mailtrainConfig';
+import moment from "moment";
+import {inProgress} from '../../../../shared/imports';
+import {tableAddDeleteButton, tableRestActionDialogInit, tableRestActionDialogRender} from "../../lib/modals";
+import {withComponentMixins} from "../../lib/decorator-helpers";
+
+@withComponentMixins([
+ withTranslation,
+ withErrorHandling,
+ withPageHelpers,
+ requiresAuthenticatedUser
+])
+export default class List extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {};
+ tableRestActionDialogInit(this);
+
+ const {importSourceLabels, importStatusLabels} = getImportLabels(props.t);
+ this.importSourceLabels = importSourceLabels;
+ this.importStatusLabels = importStatusLabels;
+ }
+
+ static propTypes = {
+ list: PropTypes.object
+ }
+
+ componentDidMount() {
+ }
+
+ render() {
+ const t = this.props.t;
+
+ const columns = [
+ { data: 1, title: t('name') },
+ { data: 2, title: t('description') },
+ { data: 3, title: t('source'), render: data => this.importSourceLabels[data], sortable: false, searchable: false },
+ { data: 4, title: t('status'), render: data => this.importStatusLabels[data], sortable: false, searchable: false },
+ { data: 5, title: t('lastRun'), render: data => data ? moment(data).fromNow() : t('never') },
+ {
+ actions: data => {
+ const actions = [];
+ const status = data[4];
+
+ let refreshTimeout;
+
+ if (inProgress(status)) {
+ refreshTimeout = 1000;
+ }
+
+ if (mailtrainConfig.globalPermissions.setupAutomation && this.props.list.permissions.includes('manageImports')) {
+ actions.push({
+ label: ,
+ link: `/lists/${this.props.list.id}/imports/${data[0]}/edit`
+ });
+ }
+
+ actions.push({
+ label: ,
+ link: `/lists/${this.props.list.id}/imports/${data[0]}/status`
+ });
+
+ if (this.props.list.permissions.includes('manageImports')) {
+ tableAddDeleteButton(actions, this, null, `rest/imports/${this.props.list.id}/${data[0]}`, data[1], t('deletingImport'), t('importDeleted'));
+ }
+
+ return { refreshTimeout, actions };
+ }
+ }
+ ];
+
+ return (
+
+ {tableRestActionDialogRender(this)}
+ {mailtrainConfig.globalPermissions.setupAutomation && this.props.list.permissions.includes('manageImports') &&
+
+
+
+ }
+
+
{t('imports')}
+
+
this.table = node} withHeader dataUrl={`rest/imports-table/${this.props.list.id}`} columns={columns} />
+
+ );
+ }
+}
\ No newline at end of file
diff --git a/client/src/lists/imports/RunStatus.js b/client/src/lists/imports/RunStatus.js
new file mode 100644
index 00000000..60add547
--- /dev/null
+++ b/client/src/lists/imports/RunStatus.js
@@ -0,0 +1,109 @@
+'use strict';
+
+import React, {Component} from 'react';
+import PropTypes from 'prop-types';
+import {withTranslation} from '../../lib/i18n';
+import {requiresAuthenticatedUser, Title, withPageHelpers} from '../../lib/page';
+import {AlignedRow} from '../../lib/form';
+import {withAsyncErrorHandler, withErrorHandling} from '../../lib/error-handling';
+import {getImportLabels} from './helpers';
+import axios from "../../lib/axios";
+import {getUrl} from "../../lib/urls";
+import moment from "moment";
+import {runStatusInProgress} from "../../../../shared/imports";
+import {Table} from "../../lib/table";
+import {withComponentMixins} from "../../lib/decorator-helpers";
+
+@withComponentMixins([
+ withTranslation,
+ withErrorHandling,
+ withPageHelpers,
+ requiresAuthenticatedUser
+])
+export default class Status extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ entity: props.entity
+ };
+
+ const {importSourceLabels, importStatusLabels, runStatusLabels} = getImportLabels(props.t);
+ this.importSourceLabels = importSourceLabels;
+ this.importStatusLabels = importStatusLabels;
+ this.runStatusLabels = runStatusLabels;
+
+ this.refreshTimeoutHandler = ::this.periodicRefreshTask;
+ this.refreshTimeoutId = 0;
+ }
+
+ static propTypes = {
+ entity: PropTypes.object,
+ imprt: PropTypes.object,
+ list: PropTypes.object
+ }
+
+ @withAsyncErrorHandler
+ async refreshEntity() {
+ const resp = await axios.get(getUrl(`rest/import-runs/${this.props.list.id}/${this.props.imprt.id}/${this.props.entity.id}`));
+ this.setState({
+ entity: resp.data
+ });
+
+ if (this.failedTableNode) {
+ this.failedTableNode.refresh();
+ }
+ }
+
+ async periodicRefreshTask() {
+ if (runStatusInProgress(this.state.entity.status)) {
+ await this.refreshEntity();
+ if (this.refreshTimeoutHandler) { // For some reason the task gets rescheduled if server is restarted while the page is shown. That why we have this check here.
+ this.refreshTimeoutId = setTimeout(this.refreshTimeoutHandler, 2000);
+ }
+ }
+ }
+
+ componentDidMount() {
+ // noinspection JSIgnoredPromiseFromCall
+ this.periodicRefreshTask();
+ }
+
+ componentWillUnmount() {
+ clearTimeout(this.refreshTimeoutId);
+ this.refreshTimeoutHandler = null;
+ }
+
+ render() {
+ const t = this.props.t;
+ const entity = this.state.entity;
+ const imprt = this.props.imprt;
+
+ const columns = [
+ { data: 1, title: t('row') },
+ { data: 2, title: t('email') },
+ { data: 3, title: t('reason'), render: data => t(...JSON.parse(data)) }
+ ];
+
+ return (
+
+
{t('importRunStatus')}
+
+
{imprt.name}
+
{this.importSourceLabels[imprt.source]}
+
{moment(entity.created).fromNow()}
+ {entity.finished &&
{moment(entity.finished).fromNow()} }
+
{this.runStatusLabels[entity.status]}
+
{entity.processed}
+
{entity.new}
+
{entity.failed}
+ {entity.error &&
{entity.error} }
+
+
+
{t('failedRows')}
+
this.failedTableNode = node} withHeader dataUrl={`rest/import-run-failed-table/${this.props.list.id}/${this.props.imprt.id}/${this.props.entity.id}`} columns={columns} />
+
+
+ );
+ }
+}
\ No newline at end of file
diff --git a/client/src/lists/imports/Status.js b/client/src/lists/imports/Status.js
new file mode 100644
index 00000000..3e1af710
--- /dev/null
+++ b/client/src/lists/imports/Status.js
@@ -0,0 +1,161 @@
+'use strict';
+
+import React, {Component} from 'react';
+import PropTypes from 'prop-types';
+import {withTranslation} from '../../lib/i18n';
+import {requiresAuthenticatedUser, Title, withPageHelpers} from '../../lib/page';
+import {AlignedRow, ButtonRow} from '../../lib/form';
+import {withAsyncErrorHandler, withErrorHandling} from '../../lib/error-handling';
+import {getImportLabels} from './helpers';
+import {prepFinishedAndNotInProgress, runInProgress, runStatusInProgress} from '../../../../shared/imports';
+import {Table} from "../../lib/table";
+import {Button, Icon} from "../../lib/bootstrap-components";
+import axios from "../../lib/axios";
+import {getUrl} from "../../lib/urls";
+import moment from "moment";
+import interoperableErrors from '../../../../shared/interoperable-errors';
+import {withComponentMixins} from "../../lib/decorator-helpers";
+
+@withComponentMixins([
+ withTranslation,
+ withErrorHandling,
+ withPageHelpers,
+ requiresAuthenticatedUser
+])
+export default class Status extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ entity: props.entity
+ };
+
+ const {importSourceLabels, importStatusLabels, runStatusLabels} = getImportLabels(props.t);
+ this.importSourceLabels = importSourceLabels;
+ this.importStatusLabels = importStatusLabels;
+ this.runStatusLabels = runStatusLabels;
+
+ this.refreshTimeoutHandler = ::this.periodicRefreshTask;
+ this.refreshTimeoutId = 0;
+ }
+
+ static propTypes = {
+ entity: PropTypes.object,
+ list: PropTypes.object
+ }
+
+ @withAsyncErrorHandler
+ async refreshEntity() {
+ const resp = await axios.get(getUrl(`rest/imports/${this.props.list.id}/${this.props.entity.id}`));
+ this.setState({
+ entity: resp.data
+ });
+ }
+
+ async periodicRefreshTask() {
+ // The periodic task runs all the time, so that we don't have to worry about starting/stopping it as a reaction to the buttons.
+ await this.refreshEntity();
+ if (this.refreshTimeoutHandler) { // For some reason the task gets rescheduled if server is restarted while the page is shown. That why we have this check here.
+ this.refreshTimeoutId = setTimeout(this.refreshTimeoutHandler, 2000);
+ }
+ }
+
+ componentDidMount() {
+ // noinspection JSIgnoredPromiseFromCall
+ this.periodicRefreshTask();
+ }
+
+ componentWillUnmount() {
+ clearTimeout(this.refreshTimeoutId);
+ this.refreshTimeoutHandler = null;
+ }
+
+ async startRunAsync() {
+ try {
+ await axios.post(getUrl(`rest/import-start/${this.props.list.id}/${this.props.entity.id}`));
+ } catch (err) {
+ if (err instanceof interoperableErrors.InvalidStateError) {
+ // Just mask the fact that it's not possible to start anything and refresh instead.
+ } else {
+ throw err;
+ }
+ }
+
+ await this.refreshEntity();
+
+ if (this.runsTableNode) {
+ this.runsTableNode.refresh();
+ }
+ }
+
+ async stopRunAsync() {
+ try {
+ await axios.post(getUrl(`rest/import-stop/${this.props.list.id}/${this.props.entity.id}`));
+ } catch (err) {
+ if (err instanceof interoperableErrors.InvalidStateError) {
+ // Just mask the fact that it's not possible to stop anything and refresh instead.
+ } else {
+ throw err;
+ }
+ }
+
+ await this.refreshEntity();
+
+ if (this.runsTableNode) {
+ this.runsTableNode.refresh();
+ }
+ }
+
+ render() {
+ const t = this.props.t;
+ const entity = this.state.entity;
+
+ const columns = [
+ { data: 1, title: t('started'), render: data => moment(data).fromNow() },
+ { data: 2, title: t('finished'), render: data => data ? moment(data).fromNow() : '' },
+ { data: 3, title: t('status'), render: data => this.runStatusLabels[data], sortable: false, searchable: false },
+ { data: 4, title: t('processed') },
+ { data: 5, title: t('new') },
+ { data: 6, title: t('failed') },
+ {
+ actions: data => {
+ const actions = [];
+ const status = data[3];
+
+ let refreshTimeout;
+
+ if (runStatusInProgress(status)) {
+ refreshTimeout = 1000;
+ }
+
+ actions.push({
+ label: ,
+ link: `/lists/${this.props.list.id}/imports/${this.props.entity.id}/status/${data[0]}`
+ });
+
+ return { refreshTimeout, actions };
+ }
+ }
+ ];
+
+ return (
+
+
{t('importStatus')}
+
+
{entity.name}
+
{this.importSourceLabels[entity.source]}
+
{this.importStatusLabels[entity.status]}
+ {entity.error &&
{entity.error} }
+
+
+ {prepFinishedAndNotInProgress(entity.status) && }
+ {runInProgress(entity.status) && }
+
+
+
+
{t('importRuns')}
+
this.runsTableNode = node} withHeader dataUrl={`rest/import-runs-table/${this.props.list.id}/${this.props.entity.id}`} columns={columns} />
+
+ );
+ }
+}
\ No newline at end of file
diff --git a/client/src/lists/imports/helpers.js b/client/src/lists/imports/helpers.js
new file mode 100644
index 00000000..b0bc5a1f
--- /dev/null
+++ b/client/src/lists/imports/helpers.js
@@ -0,0 +1,46 @@
+'use strict';
+
+import React from 'react';
+import {ImportSource, ImportStatus, MappingType, RunStatus} from '../../../../shared/imports';
+
+export function getImportLabels(t) {
+
+ const importSourceLabels = {
+ [ImportSource.CSV_FILE]: t('csvFile'),
+ [ImportSource.LIST]: t('list'),
+ };
+
+ const importStatusLabels = {
+ [ImportStatus.PREP_SCHEDULED]: t('created'),
+ [ImportStatus.PREP_RUNNING]: t('preparing'),
+ [ImportStatus.PREP_STOPPING]: t('stopping'),
+ [ImportStatus.PREP_FINISHED]: t('ready'),
+ [ImportStatus.PREP_FAILED]: t('preparationFailed'),
+ [ImportStatus.RUN_SCHEDULED]: t('scheduled'),
+ [ImportStatus.RUN_RUNNING]: t('running'),
+ [ImportStatus.RUN_STOPPING]: t('stopping'),
+ [ImportStatus.RUN_FINISHED]: t('finished'),
+ [ImportStatus.RUN_FAILED]: t('failed')
+ };
+
+ const runStatusLabels = {
+ [RunStatus.SCHEDULED]: t('starting'),
+ [RunStatus.RUNNING]: t('running'),
+ [RunStatus.STOPPING]: t('stopping'),
+ [RunStatus.FINISHED]: t('finished'),
+ [RunStatus.FAILED]: t('failed')
+ };
+
+ const mappingTypeLabels = {
+ [MappingType.BASIC_SUBSCRIBE]: t('basicImportOfSubscribers'),
+ [MappingType.BASIC_UNSUBSCRIBE]: t('unsubscribeEmails'),
+ }
+
+ return {
+ importStatusLabels,
+ mappingTypeLabels,
+ importSourceLabels,
+ runStatusLabels
+ };
+}
+
diff --git a/client/src/lists/root.js b/client/src/lists/root.js
new file mode 100644
index 00000000..6a20410e
--- /dev/null
+++ b/client/src/lists/root.js
@@ -0,0 +1,255 @@
+'use strict';
+
+import React from 'react';
+import qs from 'querystringify';
+import ListsList from './List';
+import ListsCUD from './CUD';
+import FormsList from './forms/List';
+import FormsCUD from './forms/CUD';
+import FieldsList from './fields/List';
+import FieldsCUD from './fields/CUD';
+import SubscriptionsList from './subscriptions/List';
+import SubscriptionsCUD from './subscriptions/CUD';
+import SegmentsList from './segments/List';
+import SegmentsCUD from './segments/CUD';
+import ImportsList from './imports/List';
+import ImportsCUD from './imports/CUD';
+import ImportsStatus from './imports/Status';
+import ImportRunsStatus from './imports/RunStatus';
+import Share from '../shares/Share';
+import TriggersList from './TriggersList';
+import {ellipsizeBreadcrumbLabel} from "../lib/helpers";
+import {namespaceCheckPermissions} from "../lib/namespace";
+
+function getMenus(t) {
+ return {
+ 'lists': {
+ title: t('lists'),
+ link: '/lists',
+ checkPermissions: {
+ createList: {
+ entityTypeId: 'namespace',
+ requiredOperations: ['createList']
+ },
+ createCustomForm: {
+ entityTypeId: 'namespace',
+ requiredOperations: ['createCustomForm']
+ },
+ viewCustomForm: {
+ entityTypeId: 'customForm',
+ requiredOperations: ['view']
+ },
+ ...namespaceCheckPermissions('createList')
+ },
+ panelRender: props => ,
+ children: {
+ ':listId([0-9]+)': {
+ title: resolved => t('listName', {name: ellipsizeBreadcrumbLabel(resolved.list.name)}),
+ resolve: {
+ list: params => `rest/lists/${params.listId}`
+ },
+ link: params => `/lists/${params.listId}/subscriptions`,
+ navs: {
+ subscriptions: {
+ title: t('subscribers'),
+ resolve: {
+ segments: params => `rest/segments/${params.listId}`,
+ },
+ link: params => `/lists/${params.listId}/subscriptions`,
+ visible: resolved => resolved.list.permissions.includes('viewSubscriptions'),
+ panelRender: props => ,
+ children: {
+ ':subscriptionId([0-9]+)': {
+ title: resolved => resolved.subscription.email,
+ resolve: {
+ subscription: params => `rest/subscriptions/${params.listId}/${params.subscriptionId}`,
+ fieldsGrouped: params => `rest/fields-grouped/${params.listId}`
+ },
+ link: params => `/lists/${params.listId}/subscriptions/${params.subscriptionId}/edit`,
+ navs: {
+ ':action(edit|delete)': {
+ title: t('edit'),
+ link: params => `/lists/${params.listId}/subscriptions/${params.subscriptionId}/edit`,
+ panelRender: props =>
+ }
+ }
+ },
+ create: {
+ title: t('create'),
+ resolve: {
+ fieldsGrouped: params => `rest/fields-grouped/${params.listId}`
+ },
+ panelRender: props =>
+ }
+ } },
+ ':action(edit|delete)': {
+ title: t('edit'),
+ link: params => `/lists/${params.listId}/edit`,
+ visible: resolved => resolved.list.permissions.includes('edit'),
+ panelRender: props =>
+ },
+ fields: {
+ title: t('fields'),
+ link: params => `/lists/${params.listId}/fields/`,
+ visible: resolved => resolved.list.permissions.includes('viewFields'),
+ panelRender: props => ,
+ children: {
+ ':fieldId([0-9]+)': {
+ title: resolved => t('fieldName-1', {name: ellipsizeBreadcrumbLabel(resolved.field.name)}),
+ resolve: {
+ field: params => `rest/fields/${params.listId}/${params.fieldId}`,
+ fields: params => `rest/fields/${params.listId}`
+ },
+ link: params => `/lists/${params.listId}/fields/${params.fieldId}/edit`,
+ navs: {
+ ':action(edit|delete)': {
+ title: t('edit'),
+ link: params => `/lists/${params.listId}/fields/${params.fieldId}/edit`,
+ panelRender: props =>
+ }
+ }
+ },
+ create: {
+ title: t('create'),
+ resolve: {
+ fields: params => `rest/fields/${params.listId}`
+ },
+ panelRender: props =>
+ }
+ }
+ },
+ segments: {
+ title: t('segments'),
+ link: params => `/lists/${params.listId}/segments`,
+ visible: resolved => resolved.list.permissions.includes('viewSegments'),
+ panelRender: props => ,
+ children: {
+ ':segmentId([0-9]+)': {
+ title: resolved => t('segmentName', {name: ellipsizeBreadcrumbLabel(resolved.segment.name)}),
+ resolve: {
+ segment: params => `rest/segments/${params.listId}/${params.segmentId}`,
+ fields: params => `rest/fields/${params.listId}`
+ },
+ link: params => `/lists/${params.listId}/segments/${params.segmentId}/edit`,
+ navs: {
+ ':action(edit|delete)': {
+ title: t('edit'),
+ link: params => `/lists/${params.listId}/segments/${params.segmentId}/edit`,
+ panelRender: props =>
+ }
+ }
+ },
+ create: {
+ title: t('create'),
+ resolve: {
+ fields: params => `rest/fields/${params.listId}`
+ },
+ panelRender: props =>
+ }
+ }
+ },
+ imports: {
+ title: t('imports'),
+ link: params => `/lists/${params.listId}/imports/`,
+ visible: resolved => resolved.list.permissions.includes('viewImports'),
+ panelRender: props => ,
+ children: {
+ ':importId([0-9]+)': {
+ title: resolved => t('importName-1', {name: ellipsizeBreadcrumbLabel(resolved.import.name)}),
+ resolve: {
+ import: params => `rest/imports/${params.listId}/${params.importId}`,
+ },
+ link: params => `/lists/${params.listId}/imports/${params.importId}/status`,
+ navs: {
+ ':action(edit|delete)': {
+ title: t('edit'),
+ resolve: {
+ fieldsGrouped: params => `rest/fields-grouped/${params.listId}`
+ },
+ link: params => `/lists/${params.listId}/imports/${params.importId}/edit`,
+ panelRender: props =>
+ },
+ 'status': {
+ title: t('status'),
+ link: params => `/lists/${params.listId}/imports/${params.importId}/status`,
+ panelRender: props => ,
+ children: {
+ ':importRunId([0-9]+)': {
+ title: resolved => t('run'),
+ resolve: {
+ importRun: params => `rest/import-runs/${params.listId}/${params.importId}/${params.importRunId}`,
+ },
+ link: params => `/lists/${params.listId}/imports/${params.importId}/status/${params.importRunId}`,
+ panelRender: props =>
+ }
+ }
+ }
+ }
+ },
+ create: {
+ title: t('create'),
+ panelRender: props =>
+ }
+ }
+ },
+ triggers: {
+ title: t('triggers'),
+ link: params => `/lists/${params.listId}/triggers`,
+ panelRender: props =>
+ },
+ share: {
+ title: t('share'),
+ link: params => `/lists/${params.listId}/share`,
+ visible: resolved => resolved.list.permissions.includes('share'),
+ panelRender: props =>
+ }
+ }
+ },
+ create: {
+ title: t('create'),
+ panelRender: props =>
+ },
+ forms: {
+ title: t('customForms-1'),
+ link: '/lists/forms',
+ checkPermissions: {
+ ...namespaceCheckPermissions('createCustomForm')
+ },
+ panelRender: props => ,
+ children: {
+ ':formsId([0-9]+)': {
+ title: resolved => t('customFormsName', {name: ellipsizeBreadcrumbLabel(resolved.forms.name)}),
+ resolve: {
+ forms: params => `rest/forms/${params.formsId}`
+ },
+ link: params => `/lists/forms/${params.formsId}/edit`,
+ navs: {
+ ':action(edit|delete)': {
+ title: t('edit'),
+ link: params => `/lists/forms/${params.formsId}/edit`,
+ visible: resolved => resolved.forms.permissions.includes('edit'),
+ panelRender: props =>
+ },
+ share: {
+ title: t('share'),
+ link: params => `/lists/forms/${params.formsId}/share`,
+ visible: resolved => resolved.forms.permissions.includes('share'),
+ panelRender: props =>
+ }
+ }
+ },
+ create: {
+ title: t('create'),
+ panelRender: props =>
+ }
+ }
+ }
+ }
+ }
+ };
+}
+
+
+export default {
+ getMenus
+}
diff --git a/client/src/lists/segments/CUD.js b/client/src/lists/segments/CUD.js
new file mode 100644
index 00000000..8a04cc47
--- /dev/null
+++ b/client/src/lists/segments/CUD.js
@@ -0,0 +1,404 @@
+'use strict';
+
+import React, {Component} from "react";
+import PropTypes from "prop-types";
+import {withTranslation} from '../../lib/i18n';
+import {LinkButton, requiresAuthenticatedUser, Title, Toolbar, withPageHelpers} from "../../lib/page";
+import {
+ ButtonRow,
+ Dropdown,
+ filterData,
+ Form,
+ FormSendMethod,
+ InputField,
+ withForm,
+ withFormErrorHandlers
+} from "../../lib/form";
+import {withErrorHandling} from "../../lib/error-handling";
+import {DeleteModalDialog} from "../../lib/modals";
+
+import styles from "./CUD.scss";
+import {DragDropContext} from "react-dnd";
+import HTML5Backend from "react-dnd-html5-backend";
+import TouchBackend from "react-dnd-touch-backend";
+import SortableTree from "react-sortable-tree";
+import 'react-sortable-tree/style.css';
+import {ActionLink, Button, Icon} from "../../lib/bootstrap-components";
+import {getRuleHelpers} from "./helpers";
+import RuleSettingsPane from "./RuleSettingsPane";
+import {withComponentMixins} from "../../lib/decorator-helpers";
+import clone from "clone";
+
+// https://stackoverflow.com/a/4819886/1601953
+const isTouchDevice = !!('ontouchstart' in window || navigator.maxTouchPoints);
+
+@DragDropContext(isTouchDevice ? TouchBackend : HTML5Backend)
+@withComponentMixins([
+ withTranslation,
+ withForm,
+ withErrorHandling,
+ withPageHelpers,
+ requiresAuthenticatedUser
+])
+export default class CUD extends Component {
+ // The code below keeps the segment settings in form value. However, it uses it as a mutable datastructure.
+ // After initilization, segment settings is never set using setState. This is OK since we update the state.rulesTree
+ // from the segment settings on relevant events (changes in the tree and closing the rule settings pane).
+
+ constructor(props) {
+ super(props);
+
+ this.ruleHelpers = getRuleHelpers(props.t, props.fields);
+
+ this.state = {
+ rulesTree: this.getTreeFromRules([])
+ // There is no ruleOptionsVisible here. We have 3 state logic for the visibility:
+ // Undef - not shown, True - shown with entry animation, False - hidden with exit animation
+ };
+
+ this.initForm();
+
+ this.onRuleSettingsPaneUpdatedHandler = ::this.onRuleSettingsPaneUpdated;
+ this.onRuleSettingsPaneCloseHandler = ::this.onRuleSettingsPaneClose;
+ this.onRuleSettingsPaneDeleteHandler = ::this.onRuleSettingsPaneDelete;
+ }
+
+ static propTypes = {
+ action: PropTypes.string.isRequired,
+ list: PropTypes.object,
+ fields: PropTypes.array,
+ entity: PropTypes.object
+ }
+
+ getRulesFromTree(tree) {
+ const rules = [];
+
+ for (const node of tree) {
+ const rule = node.rule;
+
+ if (this.ruleHelpers.isCompositeRuleType(rule.type)) {
+ rule.rules = this.getRulesFromTree(node.children);
+ }
+
+ rules.push(rule);
+ }
+
+ return rules;
+ }
+
+ getTreeFromRules(rules) {
+ const ruleHelpers = this.ruleHelpers;
+
+ const tree = [];
+ for (const rule of rules) {
+ const ruleTypeSettings = ruleHelpers.getRuleTypeSettings(rule);
+ const title = ruleTypeSettings ? ruleTypeSettings.treeLabel(rule) : this.props.t('newRule');
+
+ tree.push({
+ rule,
+ title,
+ expanded: true,
+ children: this.getTreeFromRules(rule.rules || [])
+ });
+ }
+
+ return tree;
+ }
+
+ getFormValuesMutator(data, originalData) {
+ data.rootRuleType = data.settings.rootRule.type;
+ data.selectedRule = (originalData && originalData.selectedRule) || null; // Validation errors of the selected rule are attached to this which makes sure we don't submit the segment if the opened rule has errors
+
+ this.setState({
+ rulesTree: this.getTreeFromRules(data.settings.rootRule.rules)
+ });
+ }
+
+ submitFormValuesMutator(data) {
+ data.settings.rootRule.type = data.rootRuleType;
+
+ // We have to clone the data here otherwise the form change detection doesn't work. This is because we use the state as a mutable structure.
+ data = clone(data);
+
+ return filterData(data, ['name', 'settings']);
+ }
+
+ componentDidMount() {
+ if (this.props.entity) {
+ this.getFormValuesFromEntity(this.props.entity);
+
+ } else {
+ this.populateFormValues({
+ name: '',
+ settings: {
+ rootRule: {
+ type: 'all',
+ rules: []
+ }
+ },
+ rootRuleType: 'all',
+ selectedRule: null
+ });
+ }
+ }
+
+ localValidateFormValues(state) {
+ const t = this.props.t;
+
+ if (!state.getIn(['name', 'value'])) {
+ state.setIn(['name', 'error'], t('nameMustNotBeEmpty'));
+ } else {
+ state.setIn(['name', 'error'], null);
+ }
+
+ if (state.getIn(['selectedRule', 'value']) === null) {
+ state.setIn(['selectedRule', 'error'], null);
+ }
+ }
+
+ @withFormErrorHandlers
+ async submitHandler(submitAndLeave) {
+ const t = this.props.t;
+
+ let sendMethod, url;
+ if (this.props.entity) {
+ sendMethod = FormSendMethod.PUT;
+ url = `rest/segments/${this.props.list.id}/${this.props.entity.id}`
+ } else {
+ sendMethod = FormSendMethod.POST;
+ url = `rest/segments/${this.props.list.id}`
+ }
+
+ try {
+ this.disableForm();
+ this.setFormStatusMessage('info', t('saving'));
+
+ const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url);
+
+ if (submitResult) {
+ if (this.props.entity) {
+ if (submitAndLeave) {
+ this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/segments`, 'success', t('segmentUpdated'));
+ } else {
+ await this.getFormValuesFromURL(`rest/segments/${this.props.list.id}/${this.props.entity.id}`);
+
+ this.enableForm();
+ this.setFormStatusMessage('success', t('segmentUpdated'));
+ }
+ } else {
+ if (submitAndLeave) {
+ this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/segments`, 'success', t('segmentCreated'));
+ } else {
+ this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/segments/${submitResult}/edit`, 'success', t('segmentCreated'));
+ }
+ }
+ } else {
+ this.enableForm();
+ this.setFormStatusMessage('warning', t('thereAreErrorsInTheFormPleaseFixThemAnd'));
+ }
+ } catch (error) {
+ throw error;
+ }
+ }
+
+ onRulesChanged(rulesTree) {
+ // This assumes that !this.state.ruleOptionsVisible
+ this.getFormValue('settings').rootRule.rules = this.getRulesFromTree(rulesTree);
+
+ this.setState({
+ rulesTree
+ })
+ }
+
+ showRuleOptions(rule) {
+ this.updateFormValue('selectedRule', rule);
+
+ this.setState({
+ ruleOptionsVisible: true
+ });
+ }
+
+ onRuleSettingsPaneClose() {
+ this.updateFormValue('selectedRule', null);
+
+ this.setState({
+ ruleOptionsVisible: false,
+ rulesTree: this.getTreeFromRules(this.getFormValue('settings').rootRule.rules)
+ });
+ }
+
+ onRuleSettingsPaneDelete() {
+ const selectedRule = this.getFormValue('selectedRule');
+ this.updateFormValue('selectedRule', null);
+
+ this.setState({
+ ruleOptionsVisible: false,
+ });
+
+ this.deleteRule(selectedRule);
+ }
+
+ onRuleSettingsPaneUpdated(hasErrors) {
+ this.setState(previousState => ({
+ formState: previousState.formState.setIn(['data', 'selectedRule', 'error'], hasErrors)
+ }));
+ }
+
+ addRule(rule) {
+ if (!this.state.ruleOptionsVisible) {
+ const rules = this.getFormValue('settings').rootRule.rules;
+ rules.push(rule);
+
+ this.updateFormValue('selectedRule', rule);
+
+ this.setState({
+ ruleOptionsVisible: true,
+ rulesTree: this.getTreeFromRules(rules)
+ });
+ }
+ }
+
+ async addCompositeRule() {
+ this.addRule({
+ type: 'all',
+ rules: []
+ });
+ }
+
+ async addPrimitiveRule() {
+ this.addRule({
+ type: null // Null type means a primitive rule where the type has to be chosen based on the chosen column
+ });
+ }
+
+ deleteRule(ruleToDelete) {
+ let finishedSearching = false;
+
+ function childrenWithoutRule(rules) {
+ const newRules = [];
+
+ for (const rule of rules) {
+ if (finishedSearching) {
+ newRules.push(rule);
+
+ } else if (rule !== ruleToDelete) {
+ const newRule = Object.assign({}, rule);
+
+ if (rule.rules) {
+ newRule.rules = childrenWithoutRule(rule.rules);
+ }
+
+ newRules.push(newRule);
+
+ } else {
+ finishedSearching = true;
+ }
+ }
+
+ return newRules;
+ }
+
+ const rules = childrenWithoutRule(this.getFormValue('settings').rootRule.rules);
+
+ this.getFormValue('settings').rootRule.rules = rules;
+
+ this.setState({
+ rulesTree: this.getTreeFromRules(rules)
+ });
+ }
+
+
+ render() {
+ const t = this.props.t;
+ const isEdit = !!this.props.entity;
+ const selectedRule = this.getFormValue('selectedRule');
+ const ruleHelpers = this.ruleHelpers;
+
+ let ruleOptionsVisibilityClass = '';
+ if ('ruleOptionsVisible' in this.state) {
+ if (this.state.ruleOptionsVisible) {
+ ruleOptionsVisibilityClass = ' ' + styles.ruleOptionsVisible;
+ } else {
+ ruleOptionsVisibilityClass = ' ' + styles.ruleOptionsHidden;
+ }
+ }
+
+ return (
+
+
+ {isEdit &&
+
+ }
+
+ {isEdit ? t('editSegment') : t('createSegment')}
+
+
+ {t('segmentOptions')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{t('rules')}
+
+
+
+
+
this.onRulesChanged(rulesTree)}
+ isVirtualized={false}
+ canDrop={ data => !data.nextParent || (ruleHelpers.isCompositeRuleType(data.nextParent.rule.type)) }
+ generateNodeProps={data => ({
+ buttons: [
+ !this.state.ruleOptionsVisible && this.showRuleOptions(data.node.rule)} className={styles.ruleActionLink}> ,
+ !this.state.ruleOptionsVisible && this.deleteRule(data.node.rule)} className={styles.ruleActionLink}>
+ ]
+ })}
+ />
+
+
+
+
+
+
+
+
+
+ {selectedRule &&
+ }
+
+
+
+
+
+
+ await this.submitHandler(false)}/>
+ await this.submitHandler(true)}/>
+
+ {isEdit && }
+
+
+ );
+ }
+}
\ No newline at end of file
diff --git a/client/src/lists/segments/CUD.scss b/client/src/lists/segments/CUD.scss
new file mode 100644
index 00000000..442b4ce4
--- /dev/null
+++ b/client/src/lists/segments/CUD.scss
@@ -0,0 +1,152 @@
+$desktopMinWidth: 768px;
+
+$mobileLeftPaneResidualWidth: 0px;
+$mobileAnimationStartPosition: 100px;
+
+$desktopLeftPaneResidualWidth: 200px;
+$desktopAnimationStartPosition: 300px;
+
+@mixin optionsHidden {
+ transform: translateX($mobileAnimationStartPosition);
+ @media (min-width: $desktopMinWidth) {
+ transform: translateX($desktopAnimationStartPosition);
+ }
+}
+
+@mixin optionsVisible {
+ transform: translateX($mobileLeftPaneResidualWidth);
+ @media (min-width: $desktopMinWidth) {
+ transform: translateX($desktopLeftPaneResidualWidth);
+ }
+}
+
+.toolbar {
+ text-align: right;
+}
+
+.ruleActionLink {
+ padding-right: 5px;
+}
+
+.rulePane {
+ position: relative;
+ width: 100%;
+ overflow: hidden;
+
+ .leftPane {
+ display: inline-block;
+ width: 100%;
+ margin-right: -100%;
+
+ .leftPaneInner {
+ .ruleTree {
+ background: #fbfbfb;
+ border: #cfcfcf 1px solid;
+ border-radius: 4px;
+ padding: 10px 0px;
+ margin-top: 15px;
+ margin-bottom: 30px;
+
+ // Without this, the placeholders when rearranging the tree are not shown
+ position: relative;
+ z-index: 0;
+ }
+ }
+
+ .leftPaneOverlay {
+ display: none;
+ position: absolute;
+ left: 0px;
+ top: 0px;
+ height: 100%;
+ z-index: 1;
+
+ width: $mobileLeftPaneResidualWidth;
+ @media (min-width: $desktopMinWidth) {
+ width: $desktopLeftPaneResidualWidth;
+ }
+ }
+
+ .paneDivider {
+ display: block;
+ position: absolute;
+ left: 0px;
+ top: 0px;
+ width: 100%;
+ height: 100%;
+ background: url('./divider.png') repeat-y;
+
+ @include optionsHidden;
+
+ padding-left: 50px;
+ z-index: 1;
+
+ opacity: 0;
+ visibility: hidden;
+
+ .paneDividerSolidBackground {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ background: white;
+ }
+ }
+ }
+
+ .rightPane {
+ display: inline-block;
+ width: 100%;
+ vertical-align: top;
+ z-index: 2;
+ position: relative;
+
+ @include optionsHidden;
+
+ opacity: 0;
+ visibility: hidden;
+
+ .rightPaneInner {
+ margin-right: $mobileLeftPaneResidualWidth;
+ @media (min-width: $desktopMinWidth) {
+ margin-right: $desktopLeftPaneResidualWidth;
+ }
+
+ .ruleOptions {
+ margin-left: 60px;
+ }
+ }
+ }
+
+ &.ruleOptionsVisible {
+ .leftPaneOverlay {
+ display: block;
+ }
+
+ .paneDivider {
+ transition: transform 300ms ease-out, opacity 100ms ease-out;
+ opacity: 1;
+ visibility: visible;
+
+ @include optionsVisible;
+ }
+
+ .rightPane {
+ transition: transform 300ms ease-out, opacity 100ms ease-out;
+ opacity: 1;
+ visibility: visible;
+
+ @include optionsVisible;
+ }
+ }
+
+ &.ruleOptionsHidden {
+ .paneDivider {
+ transition: visibility 0s linear 300ms, transform 300ms ease-in, opacity 100ms ease-in 200ms;
+ }
+
+ .rightPane {
+ transition: visibility 0s linear 300ms, transform 300ms ease-in, opacity 100ms ease-in 200ms;
+ }
+ }
+
+}
diff --git a/client/src/lists/segments/List.js b/client/src/lists/segments/List.js
new file mode 100644
index 00000000..b95c134c
--- /dev/null
+++ b/client/src/lists/segments/List.js
@@ -0,0 +1,72 @@
+'use strict';
+
+import React, {Component} from 'react';
+import PropTypes from 'prop-types';
+import {withTranslation} from '../../lib/i18n';
+import {LinkButton, requiresAuthenticatedUser, Title, Toolbar, withPageHelpers} from '../../lib/page';
+import {withErrorHandling} from '../../lib/error-handling';
+import {Table} from '../../lib/table';
+import {Icon} from "../../lib/bootstrap-components";
+import {tableAddDeleteButton, tableRestActionDialogInit, tableRestActionDialogRender} from "../../lib/modals";
+import {withComponentMixins} from "../../lib/decorator-helpers";
+
+@withComponentMixins([
+ withTranslation,
+ withErrorHandling,
+ withPageHelpers,
+ requiresAuthenticatedUser
+])
+export default class List extends Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {};
+ tableRestActionDialogInit(this);
+ }
+
+ static propTypes = {
+ list: PropTypes.object
+ }
+
+ componentDidMount() {
+ }
+
+ render() {
+ const t = this.props.t;
+
+ const columns = [
+ { data: 1, title: t('name') },
+ {
+ actions: data => {
+ const actions = [];
+
+ if (this.props.list.permissions.includes('manageSegments')) {
+ actions.push({
+ label: ,
+ link: `/lists/${this.props.list.id}/segments/${data[0]}/edit`
+ });
+
+ tableAddDeleteButton(actions, this, null, `rest/segments/${this.props.list.id}/${data[0]}`, data[1], t('deletingSegment'), t('segmentDeleted'));
+ }
+
+ return actions;
+ }
+ }
+ ];
+
+ return (
+
+ {tableRestActionDialogRender(this)}
+ {this.props.list.permissions.includes('manageSegments') &&
+
+
+
+ }
+
+
{t('segments')}
+
+
this.table = node} withHeader dataUrl={`rest/segments-table/${this.props.list.id}`} columns={columns} />
+
+ );
+ }
+}
\ No newline at end of file
diff --git a/client/src/lists/segments/RuleSettingsPane.js b/client/src/lists/segments/RuleSettingsPane.js
new file mode 100644
index 00000000..a1b2b754
--- /dev/null
+++ b/client/src/lists/segments/RuleSettingsPane.js
@@ -0,0 +1,231 @@
+'use strict';
+
+import React, {PureComponent} from "react";
+import PropTypes from "prop-types";
+import {withTranslation} from '../../lib/i18n';
+import {requiresAuthenticatedUser, withPageHelpers} from "../../lib/page";
+import {Button, ButtonRow, Dropdown, Form, TableSelect, withForm} from "../../lib/form";
+import {withErrorHandling} from "../../lib/error-handling";
+import {getRuleHelpers} from "./helpers";
+import {getFieldTypes} from "../fields/helpers";
+
+import styles from "./CUD.scss";
+import {withComponentMixins} from "../../lib/decorator-helpers";
+
+@withComponentMixins([
+ withTranslation,
+ withForm,
+ withErrorHandling,
+ withPageHelpers,
+ requiresAuthenticatedUser
+])
+export default class RuleSettingsPane extends PureComponent {
+ constructor(props) {
+ super(props);
+
+ const t = props.t;
+ this.ruleHelpers = getRuleHelpers(t, props.fields);
+ this.fieldTypes = getFieldTypes(t);
+
+ this.state = {};
+
+ this.initForm({
+ leaveConfirmation: false,
+ onChangeBeforeValidation: ::this.populateRuleDefaults
+ });
+ }
+
+ static propTypes = {
+ rule: PropTypes.object.isRequired,
+ fields: PropTypes.array.isRequired,
+ onChange: PropTypes.func.isRequired,
+ onClose: PropTypes.func.isRequired,
+ onDelete: PropTypes.func.isRequired,
+ forceShowValidation: PropTypes.bool.isRequired
+ }
+
+ updateStateFromProps(populateForm) {
+ const props = this.props;
+ if (populateForm) {
+ const rule = props.rule;
+ const ruleHelpers = this.ruleHelpers;
+
+ let data;
+ if (!ruleHelpers.isCompositeRuleType(rule.type)) { // rule.type === null signifies primitive rule where the type has not been determined yet
+ data = ruleHelpers.primitiveRuleTypesFormDataDefaults;
+
+ const settings = ruleHelpers.getRuleTypeSettings(rule);
+ if (settings) {
+ Object.assign(data, settings.getFormData(rule));
+ }
+
+ data.type = rule.type || ''; // On '', we display label "--SELECT--" in the type dropdown. Null would not be accepted by React.
+ data.column = rule.column;
+
+ } else {
+ data = {
+ type: rule.type
+ };
+ }
+
+ this.populateFormValues(data);
+ }
+
+ if (props.forceShowValidation) {
+ this.showFormValidation();
+ }
+ }
+
+ componentDidMount() {
+ this.updateStateFromProps(true);
+ }
+
+ componentDidUpdate(prevProps) {
+ this.updateStateFromProps(this.props.rule !== prevProps.rule);
+
+ if (this.isFormWithoutErrors()) {
+ const rule = this.props.rule;
+ const ruleHelpers = this.ruleHelpers;
+
+ rule.type = this.getFormValue('type');
+
+ if (!ruleHelpers.isCompositeRuleType(rule.type)) {
+ rule.column = this.getFormValue('column');
+
+ const settings = this.ruleHelpers.getRuleTypeSettings(rule);
+ settings.assignRuleSettings(rule, key => this.getFormValue(key));
+ }
+
+ this.props.onChange(false);
+ } else {
+ this.props.onChange(true);
+ }
+ }
+
+ localValidateFormValues(state) {
+ const t = this.props.t;
+ const ruleHelpers = this.ruleHelpers;
+
+ for (const key of state.keys()) {
+ state.setIn([key, 'error'], null);
+ }
+
+ const ruleType = state.getIn(['type', 'value']);
+ if (!ruleHelpers.isCompositeRuleType(ruleType)) {
+ if (!ruleType) {
+ state.setIn(['type', 'error'], t('typeMustBeSelected'));
+ }
+
+ const column = state.getIn(['column', 'value']);
+ if (column) {
+ const colType = ruleHelpers.getColumnType(column);
+
+ if (ruleType) {
+ const settings = ruleHelpers.primitiveRuleTypes[colType][ruleType];
+ settings.validate(state);
+ }
+ } else {
+ state.setIn(['column', 'error'], t('fieldMustBeSelected'));
+ }
+ }
+ }
+
+ populateRuleDefaults(mutStateData) {
+ const ruleHelpers = this.ruleHelpers;
+ const type = mutStateData.getIn(['type','value']);
+
+ if (!ruleHelpers.isCompositeRuleType(type)) {
+ const column = mutStateData.getIn(['column', 'value']);
+
+ if (column) {
+ const colType = ruleHelpers.getColumnType(column);
+
+ if (type) {
+ const settings = ruleHelpers.primitiveRuleTypes[colType][type];
+ if (!settings) {
+ // The existing rule type does not fit the newly changed column. This resets the rule type chooser to "--- Select ---"
+ mutStateData.setIn(['type', 'value'], '');
+ }
+ }
+ }
+ }
+ }
+
+ async closeForm() {
+ if (this.isFormWithoutErrors()) {
+ this.props.onClose();
+ } else {
+ this.showFormValidation();
+ }
+ }
+
+ async deleteRule() {
+ this.props.onDelete();
+ }
+
+ render() {
+ const t = this.props.t;
+ const rule = this.props.rule;
+ const ruleHelpers = this.ruleHelpers;
+
+ let ruleOptions = null;
+ if (ruleHelpers.isCompositeRuleType(rule.type)) {
+ ruleOptions =
+
+ } else {
+ const ruleColumnOptionsColumns = [
+ { data: 1, title: t('name') },
+ { data: 2, title: t('type') },
+ { data: 3, title: t('mergeTag') }
+ ];
+
+ const ruleColumnOptions = ruleHelpers.fields.map(fld => [ fld.column, fld.name, this.fieldTypes[fld.type].label, fld.key || '' ]);
+
+ const ruleColumnSelect = ;
+ let ruleTypeSelect = null;
+ let ruleSettings = null;
+
+ const ruleColumn = this.getFormValue('column');
+ if (ruleColumn) {
+ const colType = ruleHelpers.getColumnType(ruleColumn);
+ if (colType) {
+ const ruleTypeOptions = ruleHelpers.getPrimitiveRuleTypeOptions(colType);
+ ruleTypeOptions.unshift({ key: '', label: t('select-1')});
+
+ if (ruleTypeOptions) {
+ ruleTypeSelect =
+
+ const ruleType = this.getFormValue('type');
+ if (ruleType) {
+ ruleSettings = ruleHelpers.primitiveRuleTypes[colType][ruleType].getForm();
+ }
+ }
+ }
+ }
+
+ ruleOptions =
+
+ {ruleColumnSelect}
+ {ruleTypeSelect}
+ {ruleSettings}
+
;
+ }
+
+ return (
+
+
{t('ruleOptions')}
+
+
+
+ {ruleOptions}
+
+
+
+
+
+
+
+
+ );
+ }
+}
\ No newline at end of file
diff --git a/client/src/lists/segments/divider.ai b/client/src/lists/segments/divider.ai
new file mode 100644
index 00000000..22388652
--- /dev/null
+++ b/client/src/lists/segments/divider.ai
@@ -0,0 +1,1539 @@
+%PDF-1.5
%âãÏÓ
+1 0 obj
<>/OCGs[5 0 R]>>/Pages 3 0 R/Type/Catalog>>
endobj
2 0 obj
<>stream
+
+
+
+
+ application/pdf
+
+
+ divider
+
+
+
+
+ 2017-08-16T17:46:10+02:00
+ 2017-08-16T17:46:10+02:00
+ 2017-08-16T17:46:10+02:00
+ Adobe Illustrator CS6 (Windows)
+
+
+
+ 256
+ 180
+ JPEG
+ /9j/4AAQSkZJRgABAgEASABIAAD/7QAsUGhvdG9zaG9wIDMuMAA4QklNA+0AAAAAABAASAAAAAEA
AQBIAAAAAQAB/+4ADkFkb2JlAGTAAAAAAf/bAIQABgQEBAUEBgUFBgkGBQYJCwgGBggLDAoKCwoK
DBAMDAwMDAwQDA4PEA8ODBMTFBQTExwbGxscHx8fHx8fHx8fHwEHBwcNDA0YEBAYGhURFRofHx8f
Hx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8f/8AAEQgAtAEAAwER
AAIRAQMRAf/EAaIAAAAHAQEBAQEAAAAAAAAAAAQFAwIGAQAHCAkKCwEAAgIDAQEBAQEAAAAAAAAA
AQACAwQFBgcICQoLEAACAQMDAgQCBgcDBAIGAnMBAgMRBAAFIRIxQVEGE2EicYEUMpGhBxWxQiPB
UtHhMxZi8CRygvElQzRTkqKyY3PCNUQnk6OzNhdUZHTD0uIIJoMJChgZhJRFRqS0VtNVKBry4/PE
1OT0ZXWFlaW1xdXl9WZ2hpamtsbW5vY3R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo+Ck5SVlpeYmZ
qbnJ2en5KjpKWmp6ipqqusra6voRAAICAQIDBQUEBQYECAMDbQEAAhEDBCESMUEFURNhIgZxgZEy
obHwFMHR4SNCFVJicvEzJDRDghaSUyWiY7LCB3PSNeJEgxdUkwgJChgZJjZFGidkdFU38qOzwygp
0+PzhJSktMTU5PRldYWVpbXF1eX1RlZmdoaWprbG1ub2R1dnd4eXp7fH1+f3OEhYaHiImKi4yNjo
+DlJWWl5iZmpucnZ6fkqOkpaanqKmqq6ytrq+v/aAAwDAQACEQMRAD8Anf5uflH6freYvLsP7veT
UNPjH2e7SxKO38y9uoxV4virsVdirsVdirsVeiflZ+adx5YuF0zU2abQZm92a3Zju6Dup/aX6Rv1
VfR1tc291bx3NtIs1vMoeKVCGVlYVBBHUHFVTFXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq
7FXYq7FXYq7FXYq7FXYq8R/Nz8o/T9bzF5dh/d7yahp8Y+z3aWJR2/mXt1GKvF8VdirsVdirsVdi
r0T8rPzTuPLFwumamzTaDM3uzW7Md3Qd1P7S/SN+qr6Otrm3ureO5tpFmt5lDxSoQysrCoII6g4q
qYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXiP5uflH6freYvLs
P7veTUNPjH2e7SxKO38y9uoxV4virsVdirsVdirsVeiflZ+adx5YuF0zU2abQZm92a3Zju6Dup/a
X6Rv1VfR1tc291bx3NtIs1vMoeKVCGVlYVBBHUHFVTFXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq
7FXYq7FXYq7FXYq7FXYq7FXYq8R/Nz8o/T9bzF5dh/d7yahp8Y+z3aWJR2/mXt1GKvF8VdirsVdi
rsVdir0T8rPzTuPLFwumamzTaDM3uzW7Md3Qd1P7S/SN+qr6Otrm3ureO5tpFmt5lDxSoQysrCoI
I6g4qqYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXiP5uflH6fr
eYvLsP7veTUNPjH2e7SxKO38y9uoxV4virsVdirsVdirsVeiflZ+adx5YuF0zU2abQZm92a3Zju6
Dup/aX6Rv1VfR1tc291bx3NtIs1vMoeKVCGVlYVBBHUHFVTFXYq7FXYq7FXYq7FXYq7FXYq7FXYq
7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq8R/Nz8o/T9bzF5dh/d7yahp8Y+z3aWJR2/mXt1GKvF8Vdi
rsVdirsVdir0T8rPzTuPLFwumamzTaDM3uzW7Md3Qd1P7S/SN+qr6Otrm3ureO5tpFmt5lDxSoQy
srCoII6g4qqYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXiP5uf
lH6freYvLsP7veTUNPjH2e7SxKO38y9uoxV4virsVdirsVdirsVeiflZ+adx5YuF0zU2abQZm92a
3Zju6Dup/aX6Rv1VfR1tc291bx3NtIs1vMoeKVCGVlYVBBHUHFVTFXYq7FXYq7FXYq7FXYq7FXYq
7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq8R/Nz8o/T9bzF5dh/d7yahp8Y+z3aWJR2/mXt1GKv
F8VdirsVdirsVdir0T8rPzTuPLFwumamzTaDM3uzW7Md3Qd1P7S/SN+qr6Otrm3ureO5tpFmt5lD
xSoQysrCoII6g4qqYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FX
iP5uflH6freYvLsP7veTUNPjH2e7SxKO38y9uoxV4virsVdirsVdirsVeiflZ+adx5YuF0zU2abQ
Zm92a3Zju6Dup/aX6Rv1VfR1tc291bx3NtIs1vMoeKVCGVlYVBBHUHFVTFXYq7FXYq7FXYq7FXYq
7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq8R/Nz8o/T9bzF5dh/d7yahp8Y+z3aWJR2/mX
t1GKvF8VdirsVdirsVdir0T8rPzTuPLFwumamzTaDM3uzW7Md3Qd1P7S/SN+qr6Otrm3ureO5tpF
mt5lDxSoQysrCoII6g4qqYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FX
Yq7FXiP5uflH6freYvLsP7veTUNPjH2e7SxKO38y9uoxV4virsVdirsVdirsVeiflZ+adx5YuF0z
U2abQZm92a3Zju6Dup/aX6Rv1VfR1tc291bx3NtIs1vMoeKVCGVlYVBBHUHFVTFXYq7FXYq7FXYq
7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXj35t/m39U9by95em/0veO/v4z/ddjFER+
3/M37PQb9FXhWKuxV2KuxV2KuxVnP5ZflleebLwXV0Gt9Dt2pPONmlYf7qi9/E9vnir6WsLCz0+z
hsrKFYLW3UJDCgoqqMVV8VdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirx
782/zb+qet5e8vTf6XvHf38Z/uuxiiI/b/mb9noN+irwrFXYq7FXYq7FXYqzn8svyyvPNl4Lq6DW
+h27UnnGzSsP91Re/ie3zxV9LWFhZ6fZw2VlCtva26hIYUFFVRiqvirsVdirsVdirsVdirsVdirs
VdirsVdirsVdirsVdirsVdirsVdirsVePfm3+bf1T1vL3l6b/S947+/jP912MURH7f8AM37PQb9F
XhWKuxV2KuxV2KuxVnP5ZflleebLwXV0Gt9Dt2pPONmlYf7qi9/E9vnir6WsLCz0+zhsrKFbe1t1
CQwoKKqjFVfFXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FXYq8e/Nv8ANv6p
63l7y9N/pe8d/fxn+67GKIj9v+Zv2eg36KvCsVdirsVdirsVdirOfyy/LK882XguroNb6HbtSecb
NKw/3VF7+J7fPFX0tYWFnp9nDZWUK29rbqEhhQUVVGKq+KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2K
uxV2KuxV2KuxV2KuxV2KuxV49+bf5t/VPW8veXpv9L3jv7+M/wB12MURH7f8zfs9Bv0VeFYq7FXY
q7FXYq7FWc/ll+WV55svBdXQa30O3ak842aVh/uqL38T2+eKvpawsLPT7OGysoVt7W3UJDCgoqqM
VV8VdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirx782/zb+qet5e8vTf6X
vHf38Z/uuxiiI/b/AJm/Z6Dfoq8KxV2KuxV2KuxV2Ks5/LL8srzzZeC6ug1vodu1J5xs0rD/AHVF
7+J7fPFX0tYWFnp9nDZWUK29rbqEhhQUVVGKq+KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2Kux
V2KuxV2KuxV2KuxV49+bf5t/VPW8veXpv9L3jv7+M/3XYxREft/zN+z0G/RV4VirsVdirsVdirsV
Zz+WX5ZXnmy8F1dBrfQ7dqTzjZpWH+6ovfxPb54q+lrCws9Ps4bKyhW3tbdQkMKCiqoxVXxV2Kux
V2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KvHvzb/Nv6p63l7y9N/pe8d/fxn+6
7GKIj9v+Zv2eg36KvCsVdirsVdirsVdirOfyy/LK882XguroNb6HbtSecbNKw/3VF7+J7fPFX0tY
WFnp9nDZWUK29rbqEhhQUVVGKq+KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV2KuxV
2KuxV49+bf5t/VPW8veXpv8AS947+/jP912MURH7f8zfs9Bv0VeFYq7FXYq7FXYq7FWc/ll+WV55
svBdXQa30O3ak842aVh/uqL38T2+eKvpawsLPT7OGysoVt7W3UJDCgoqqMVV8VdirsVdirsVdirs
VdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirx782/zb+qet5e8vTf6XvHf38Z/uuxiiI/b/mb
9noN+irwrFXYq7FXYq7FXYqzn8svyyvPNl4Lq6DW+h27UnnGzSsP91Re/ie3zxV9LWFhZ6fZw2Vl
Ctva26hIYUFFVRiqvirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVePfm3
+bf1T1vL3l6b/S947+/jP912MURH7f8AM37PQb9FXhWKuxV2KuxV2KuxVnP5ZflleebLwXV0Gt9D
t2pPONmlYf7qi9/E9vnir6WsLCz0+zhsrKFbe1t1CQwoKKqjFVfFXYq7FXYq7FXYq7FXYq7FXYq7
FXYq7FXYq7FXYq7FXYq7FXYq7FXYq7FWLn/lWNd/0JXv/vJirv8AkGH/AGpP+nTFXf8AIMP+1J/0
6Yq7/kGH/ak/6dMVd/yDD/tSf9OmKu/5Bh/2pP8Ap0xV3/IMP+1J/wBOmKsg079HfUov0b6P1Gh9
H6tx9KlTXjw+Hr4YqiMVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVdirsVd
ir//2Q==
+
+
+
+
+
+ uuid:faafa340-73e9-4492-854f-531599fe8c9b
+ xmp.did:D09A7EFD9982E711A911B3A23FD23E4E
+ uuid:5D20892493BFDB11914A8590D31508C8
+ proof:pdf
+
+ uuid:1e9fe3cf-bac1-48ac-8341-771a4bda1c27
+ xmp.did:0980117407206811822A897E387FE54C
+ uuid:5D20892493BFDB11914A8590D31508C8
+ proof:pdf
+
+
+
+
+ saved
+ xmp.iid:D09A7EFD9982E711A911B3A23FD23E4E
+ 2017-08-16T17:45:55+02:00
+ Adobe Illustrator CS6 (Windows)
+ /
+
+
+
+
+
+ Document
+ Print
+
+
+ False
+ False
+ 1
+
+ 60.000000
+ 40.000000
+ Pixels
+
+
+
+ Cyan
+ Magenta
+ Yellow
+ Black
+
+
+
+
+
+ Default Swatch Group
+ 0
+
+
+
+ White
+ RGB
+ PROCESS
+ 255
+ 255
+ 255
+
+
+ Black
+ RGB
+ PROCESS
+ 29
+ 29
+ 27
+
+
+ CMYK Red
+ RGB
+ PROCESS
+ 227
+ 6
+ 19
+
+
+ CMYK Yellow
+ RGB
+ PROCESS
+ 255
+ 237
+ 0
+
+
+ CMYK Green
+ RGB
+ PROCESS
+ 0
+ 150
+ 64
+
+
+ CMYK Cyan
+ RGB
+ PROCESS
+ 0
+ 159
+ 227
+
+
+ CMYK Blue
+ RGB
+ PROCESS
+ 49
+ 39
+ 131
+
+
+ CMYK Magenta
+ RGB
+ PROCESS
+ 230
+ 0
+ 126
+
+
+ C=15 M=100 Y=90 K=10
+ RGB
+ PROCESS
+ 190
+ 22
+ 34
+
+
+ C=0 M=90 Y=85 K=0
+ RGB
+ PROCESS
+ 230
+ 51
+ 42
+
+
+ C=0 M=80 Y=95 K=0
+ RGB
+ PROCESS
+ 233
+ 78
+ 27
+
+
+ C=0 M=50 Y=100 K=0
+ RGB
+ PROCESS
+ 243
+ 146
+ 0
+
+
+ C=0 M=35 Y=85 K=0
+ RGB
+ PROCESS
+ 249
+ 178
+ 51
+
+
+ C=5 M=0 Y=90 K=0
+ RGB
+ PROCESS
+ 252
+ 234
+ 16
+
+
+ C=20 M=0 Y=100 K=0
+ RGB
+ PROCESS
+ 222
+ 220
+ 0
+
+
+ C=50 M=0 Y=100 K=0
+ RGB
+ PROCESS
+ 149
+ 193
+ 31
+
+
+ C=75 M=0 Y=100 K=0
+ RGB
+ PROCESS
+ 58
+ 170
+ 53
+
+
+ C=85 M=10 Y=100 K=10
+ RGB
+ PROCESS
+ 0
+ 141
+ 54
+
+
+ C=90 M=30 Y=95 K=30
+ RGB
+ PROCESS
+ 0
+ 102
+ 51
+
+
+ C=75 M=0 Y=75 K=0
+ RGB
+ PROCESS
+ 47
+ 172
+ 102
+
+
+ C=80 M=10 Y=45 K=0
+ RGB
+ PROCESS
+ 0
+ 161
+ 154
+
+
+ C=70 M=15 Y=0 K=0
+ RGB
+ PROCESS
+ 54
+ 169
+ 225
+
+
+ C=85 M=50 Y=0 K=0
+ RGB
+ PROCESS
+ 29
+ 113
+ 184
+
+
+ C=100 M=95 Y=5 K=0
+ RGB
+ PROCESS
+ 45
+ 46
+ 131
+
+
+ C=100 M=100 Y=25 K=25
+ RGB
+ PROCESS
+ 41
+ 35
+ 92
+
+
+ C=75 M=100 Y=0 K=0
+ RGB
+ PROCESS
+ 102
+ 36
+ 131
+
+
+ C=50 M=100 Y=0 K=0
+ RGB
+ PROCESS
+ 149
+ 27
+ 129
+
+
+ C=35 M=100 Y=35 K=10
+ RGB
+ PROCESS
+ 163
+ 25
+ 91
+
+
+ C=10 M=100 Y=50 K=0
+ RGB
+ PROCESS
+ 214
+ 11
+ 82
+
+
+ C=0 M=95 Y=20 K=0
+ RGB
+ PROCESS
+ 231
+ 29
+ 115
+
+
+ C=25 M=25 Y=40 K=0
+ RGB
+ PROCESS
+ 203
+ 187
+ 160
+
+
+ C=40 M=45 Y=50 K=5
+ RGB
+ PROCESS
+ 164
+ 138
+ 123
+
+
+ C=50 M=50 Y=60 K=25
+ RGB
+ PROCESS
+ 123
+ 106
+ 88
+
+
+ C=55 M=60 Y=65 K=40
+ RGB
+ PROCESS
+ 99
+ 78
+ 66
+
+
+ C=25 M=40 Y=65 K=0
+ RGB
+ PROCESS
+ 202
+ 158
+ 103
+
+
+ C=30 M=50 Y=75 K=10
+ RGB
+ PROCESS
+ 177
+ 127
+ 74
+
+
+ C=35 M=60 Y=80 K=25
+ RGB
+ PROCESS
+ 147
+ 96
+ 55
+
+
+ C=40 M=65 Y=90 K=35
+ RGB
+ PROCESS
+ 125
+ 78
+ 36
+
+
+ C=40 M=70 Y=100 K=50
+ RGB
+ PROCESS
+ 104
+ 60
+ 17
+
+
+ C=50 M=70 Y=80 K=70
+ RGB
+ PROCESS
+ 67
+ 41
+ 24
+
+
+
+
+
+ Grays
+ 1
+
+
+
+ C=0 M=0 Y=0 K=100
+ RGB
+ PROCESS
+ 29
+ 29
+ 27
+
+
+ C=0 M=0 Y=0 K=90
+ RGB
+ PROCESS
+ 60
+ 60
+ 59
+
+
+ C=0 M=0 Y=0 K=80
+ RGB
+ PROCESS
+ 87
+ 87
+ 86
+
+
+ C=0 M=0 Y=0 K=70
+ RGB
+ PROCESS
+ 112
+ 111
+ 111
+
+
+ C=0 M=0 Y=0 K=60
+ RGB
+ PROCESS
+ 135
+ 135
+ 135
+
+
+ C=0 M=0 Y=0 K=50
+ RGB
+ PROCESS
+ 157
+ 157
+ 156
+
+
+ C=0 M=0 Y=0 K=40
+ RGB
+ PROCESS
+ 178
+ 178
+ 178
+
+
+ C=0 M=0 Y=0 K=30
+ RGB
+ PROCESS
+ 198
+ 198
+ 198
+
+
+ C=0 M=0 Y=0 K=20
+ RGB
+ PROCESS
+ 218
+ 218
+ 218
+
+
+ C=0 M=0 Y=0 K=10
+ RGB
+ PROCESS
+ 237
+ 237
+ 237
+
+
+ C=0 M=0 Y=0 K=5
+ RGB
+ PROCESS
+ 246
+ 246
+ 246
+
+
+
+
+
+ Brights
+ 1
+
+
+
+ C=0 M=100 Y=100 K=0
+ RGB
+ PROCESS
+ 227
+ 6
+ 19
+
+
+ C=0 M=75 Y=100 K=0
+ RGB
+ PROCESS
+ 234
+ 91
+ 12
+
+
+ C=0 M=10 Y=95 K=0
+ RGB
+ PROCESS
+ 255
+ 222
+ 0
+
+
+ C=85 M=10 Y=100 K=0
+ RGB
+ PROCESS
+ 0
+ 152
+ 58
+
+
+ C=100 M=90 Y=0 K=0
+ RGB
+ PROCESS
+ 39
+ 52
+ 139
+
+
+ C=60 M=90 Y=0 K=0
+ RGB
+ PROCESS
+ 130
+ 54
+ 140
+
+
+
+
+
+
+
+
+ Adobe PDF library 10.01
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+endstream
endobj
3 0 obj
<>
endobj
7 0 obj
<>/Resources<>/ExtGState<>/Properties<>>>/Thumb 12 0 R/TrimBox[0.0 0.0 60.0 40.0]/Type/Page>>
endobj
8 0 obj
<>stream
+H‰ŒOË
+ƒ@¼ç+æÜÍ•æÚµô$¥ô´‡Z(ûÿ`VôXB^30“øG„Ÿ"ã:F/FÊ{ §/ù»AK¦ŸlaDï˜Y êTu@Z©+µŒÆòckÓ•ÞõÇPÚ›fz;纊îåý vI¨‹He+zÞ&{m` Æ@.„
+endstream
endobj
12 0 obj
<>stream
+8;RZ]$bHlM@465bFr3OS+M[u+-l&f~>
+endstream
endobj
13 0 obj
[/Indexed/DeviceRGB 255 14 0 R]
endobj
14 0 obj
<>stream
+8;X]O>EqN@%''O_@%e@?J;%+8(9e>X=MR6S?i^YgA3=].HDXF.R$lIL@"pJ+EP(%0
+b]6ajmNZn*!='OQZeQ^Y*,=]?C.B+\Ulg9dhD*"iC[;*=3`oP1[!S^)?1)IZ4dup`
+E1r!/,*0[*9.aFIR2&b-C#soRZ7Dl%MLY\.?d>Mn
+6%Q2oYfNRF$$+ON<+]RUJmC0InDZ4OTs0S!saG>GGKUlQ*Q?45:CI&4J'_2j $XKrcYp0n+Xl_nU*O(
+l[$6Nn+Z_Nq0]s7hs]`XX1nZ8&94a\~>
+endstream
endobj
5 0 obj
<>
endobj
15 0 obj
[/View/Design]
endobj
16 0 obj
<>>>
endobj
11 0 obj
<>
endobj
10 0 obj
[/ICCBased 17 0 R]
endobj
17 0 obj
<>stream
+H‰œ–yTSwÇoÉž•°Ãc
[€°5la‘QIBHØADED„ª•2ÖmtFOE.®cÖ}êÒõ0êè8´×Ž8GNg¦Óïï÷9÷wïïÝß½÷ó '¥ªµÕ0 Ö ÏJŒÅb¤
+ 2y.-;!à’ÆK°ZÜ ü‹ž^i½"LÊÀ0ðÿ‰-×é
@8(”µrœ;q®ª7èLöœy¥•&†Qëñq¶4±jž½ç|æ9ÚÄ
+V³)gB£0ñiœWו8#©8wÕ©•õ8_Å٥ʨQãüÜ«QÊj@é&»A)/ÇÙgº>'K‚ó ÈtÕ;\ú”
Ó¥$ÕºF½ZUnÀÜå˜(4TŒ%)ë«”ƒ0C&¯”阤Z£“i˜¿óœ8¦Úbx‘ƒE¡ÁÁBÑ;…ú¯›¿P¦ÞÎӓ̹žAüom?çW=
+€x¯Íú·¶Ò- Œ¯Àòæ[›Ëû 0ñ¾¾øÎ}ø¦y)7ta¾¾õõõ>j¥ÜÇTÐ7úŸ¿@ï¼ÏÇtÜ›ò`qÊ2™±Ê€™ê&¯®ª6ê±ZL®Ä„?â_øóyxg)Ë”z¥ÈçLUáíÖ*ÔuµSkÿSeØO4?׸¸c¯¯Ø°.ò ò· åÒ R´
ßÞô-•’2ð5ßáÞüÜÏ ú÷Sá>Ó£Vš‹“då`r£¾n~ÏôY &à+`œ;ÂA4ˆÉ 䀰ÈA9Ð =¨- t°lÃ`;»Á~pŒƒÁ ðGp| ®[`Lƒ‡`<¯ "AˆYA+äùCb(ЇR¡,¨ *T2B-Ð
+¨ê‡†¡Ðnè÷ÐQètº}MA ï —0Óal»Á¾°ŽSàx ¬‚kà&¸^Á£ð>ø0|>_ƒ'á‡ð,ÂG!"F$H:Rˆ”!z¤éF‘Qd?r9‹\A&‘GÈ”ˆrQ¢áhš‹ÊÑ´íE‡Ñ]èaô4zBgÐ×Á–àE#H ‹*B=¡‹0HØIøˆp†p0MxJ$ùD1„˜D, V›‰½ÄÄÄãÄKÄ»ÄY‰dEò"EÒI2’ÔEÚBÚGúŒt™4MzN¦‘Èþär!YKî ’÷?%_&ß#¿¢°(®”0J:EAi¤ôQÆ(Ç()Ó”WT6U@ æP+¨íÔ!ê~êêmêæD¥eÒÔ´å´!ÚïhŸÓ¦h/èº']B/¢éëèÒÓ¿¢?a0nŒhF!ÃÀXÇØÍ8ÅøšñÜŒkæc&5S˜µ™˜6»lö˜Iaº2c˜K™MÌAæ!æEæ#…寒°d¬VÖë(ëk–Íe‹Øél
»—½‡}Ž}ŸCâ¸qâ9
+N'çÎ)Î].ÂuæJ¸rî
+î÷wšGä xR^¯‡÷[ÞoÆœchžgÞ`>bþ‰ù$á»ñ¥ü*~ÿ ÿ:ÿ¥…EŒ…ÒbÅ~‹ËÏ,m,£-•–Ý–,¯Y¾´Â¬â*6X[ݱF=3ë·YŸ±~dó ·‘ÛtÛ´¹iÛzÚfÙ6Û~`{ÁvÖÎÞ.ÑNg·Åî”Ý#{¾}´}…ý€ý§ö¸‘j‡‡ÏþŠ™c1X6„Æfm“Ž;'_9 œr:œ8Ýq¦:‹ËœœO:ϸ8¸¤¹´¸ìu¹éJq»–»nv=ëúÌMà–ï¶ÊmÜí¾ÀR 4 ö
+n»3Ü£ÜkÜGݯz=Ä•[=¾ô„=ƒ<Ë=GTB(É/ÙSòƒ,]6*›-•–¾W:#—È7Ë*¢ŠÊe¿ò^YDYÙ}U„j£êAyTù`ù#µD=¬þ¶"©b{ųÊôÊ+¬Ê¯: !kJ4Gµm¥ötµ}uCõ%—®K7YV³©fFŸ¢ßYÕ.©=bàá?SŒîƕƩºÈº‘ºçõyõ‡Ø
Ú†žkï5%4ý¦m–7Ÿlqlio™Z³lG+ÔZÚz²Í¹³mzyâò]íÔöÊö?uøuôw|¿"űN»ÎåwW&®ÜÛe֥ﺱ*|ÕöÕèjõê‰5k¶¬yÝèþ¢Ç¯g°ç‡^yïkEk‡Öþ¸®lÝD_pß¶õÄõÚõ×7DmØÕÏîoê¿»1mãál {àûMÅ›Î
nßLÝlÜ<9”úO ¤[þ˜¸™$™™üšhšÕ›B›¯œœ‰œ÷dÒž@ž®ŸŸ‹Ÿú i Ø¡G¡¶¢&¢–££v£æ¤V¤Ç¥8¥©¦¦‹¦ý§n§à¨R¨Ä©7©©ªª««u«é¬\¬ÐD¸®-®¡¯¯‹° °u°ê±`±Ö²K²Â³8³®´%´œµµŠ¶¶y¶ð·h·à¸Y¸Ñ¹J¹Âº;ºµ».»§¼!¼›½½¾
+¾„¾ÿ¿z¿õÀpÀìÁgÁãÂ_ÂÛÃXÃÔÄQÄÎÅKÅÈÆFÆÃÇAÇ¿È=ȼÉ:ɹÊ8Ê·Ë6˶Ì5̵Í5͵Î6ζÏ7ϸÐ9кÑ<ѾÒ?ÒÁÓDÓÆÔIÔËÕNÕÑÖUÖØ×\×àØdØèÙlÙñÚvÚûÛ€ÜÜŠÝÝ–ÞÞ¢ß)߯à6à½áDáÌâSâÛãcãëäsäü儿
æ–çç©è2è¼éFéÐê[êåëpëûì†ííœî(î´ï@ïÌðXðåñrñÿòŒóó§ô4ôÂõPõÞömöû÷Šøø¨ù8ùÇúWúçûwüü˜ý)ýºþKþÜÿmÿÿ ÷„óû
+endstream
endobj
9 0 obj
<>
endobj
18 0 obj
<>
endobj
19 0 obj
<>stream
+%!PS-Adobe-3.0
+%%Creator: Adobe Illustrator(R) 15.0
+%%AI8_CreatorVersion: 16.0.3
+%%For: (Tomas Bures) ()
+%%Title: (Untitled-1)
+%%CreationDate: 8/16/2017 5:46 PM
+%%Canvassize: 16383
+%%BoundingBox: 2 -40 60 0
+%%HiResBoundingBox: 2 -40 60 0
+%%DocumentProcessColors: Cyan Magenta Yellow Black
+%AI5_FileFormat 11.0
+%AI12_BuildNumber: 691
+%AI3_ColorUsage: Color
+%AI7_ImageSettings: 0
+%%RGBProcessColor: 0 0 0 ([Registration])
+%AI3_Cropmarks: 0 -40 60 0
+%AI3_TemplateBox: 20.5 -20.5 20.5 -20.5
+%AI3_TileBox: -376.7998 -305.6602 436.6797 265.54
+%AI3_DocumentPreview: None
+%AI5_ArtSize: 14400 14400
+%AI5_RulerUnits: 6
+%AI9_ColorModel: 1
+%AI5_ArtFlags: 0 0 0 1 0 0 1 0 0
+%AI5_TargetResolution: 800
+%AI5_NumLayers: 1
+%AI9_OpenToView: -32 18 16 1793 1016 90 1 0 -2036 276 0 0 0 1 1 0 1 1 0
+%AI5_OpenViewLayers: 7
+%%PageOrigin:-286 -416
+%AI7_GridSettings: 72 8 72 8 1 0 0.8 0.8 0.8 0.9 0.9 0.9
+%AI9_Flatten: 1
+%AI12_CMSettings: 00.MS
+%%EndComments
+
+endstream
endobj
20 0 obj
<>stream
+%%BoundingBox: 2 -40 60 0
+%%HiResBoundingBox: 2 -40 60 0
+%AI7_Thumbnail: 128 88 8
+%%BeginData: 5407 Hex Bytes
+%0000330000660000990000CC0033000033330033660033990033CC0033FF
+%0066000066330066660066990066CC0066FF009900009933009966009999
+%0099CC0099FF00CC0000CC3300CC6600CC9900CCCC00CCFF00FF3300FF66
+%00FF9900FFCC3300003300333300663300993300CC3300FF333300333333
+%3333663333993333CC3333FF3366003366333366663366993366CC3366FF
+%3399003399333399663399993399CC3399FF33CC0033CC3333CC6633CC99
+%33CCCC33CCFF33FF0033FF3333FF6633FF9933FFCC33FFFF660000660033
+%6600666600996600CC6600FF6633006633336633666633996633CC6633FF
+%6666006666336666666666996666CC6666FF669900669933669966669999
+%6699CC6699FF66CC0066CC3366CC6666CC9966CCCC66CCFF66FF0066FF33
+%66FF6666FF9966FFCC66FFFF9900009900339900669900999900CC9900FF
+%9933009933339933669933999933CC9933FF996600996633996666996699
+%9966CC9966FF9999009999339999669999999999CC9999FF99CC0099CC33
+%99CC6699CC9999CCCC99CCFF99FF0099FF3399FF6699FF9999FFCC99FFFF
+%CC0000CC0033CC0066CC0099CC00CCCC00FFCC3300CC3333CC3366CC3399
+%CC33CCCC33FFCC6600CC6633CC6666CC6699CC66CCCC66FFCC9900CC9933
+%CC9966CC9999CC99CCCC99FFCCCC00CCCC33CCCC66CCCC99CCCCCCCCCCFF
+%CCFF00CCFF33CCFF66CCFF99CCFFCCCCFFFFFF0033FF0066FF0099FF00CC
+%FF3300FF3333FF3366FF3399FF33CCFF33FFFF6600FF6633FF6666FF6699
+%FF66CCFF66FFFF9900FF9933FF9966FF9999FF99CCFF99FFFFCC00FFCC33
+%FFCC66FFCC99FFCCCCFFCCFFFFFF33FFFF66FFFF99FFFFCC110000001100
+%000011111111220000002200000022222222440000004400000044444444
+%550000005500000055555555770000007700000077777777880000008800
+%000088888888AA000000AA000000AAAAAAAABB000000BB000000BBBBBBBB
+%DD000000DD000000DDDDDDDDEE000000EE000000EEEEEEEE0000000000FF
+%00FF0000FFFFFF0000FF00FFFFFF00FFFFFF
+%524C45A8FD1C52A8FD63FFA8275227525252275252522752525227525252
+%275252522752525227527DFD63FFA827FD19522752A8FD63FFA827522752
+%2752275227522752275227522752275227522752275227527DFD63FFA827
+%FD1B527DFD63FFA827525252275252522752525227525252275252522752
+%5252275227527DFD63FFA827FD1B527DFD63FFA827522752275227522752
+%2752275227522752275227522752275227527DFD63FFA8FD1C527DFD63FF
+%A8275227525252275252522752525227525252275252522752525227527D
+%FD63FFA8FD1C527DFD63FFA8275227522752275227522752275227522752
+%275227522752275227527DFD63FFA8FD1C527DFD63FFA827525252275252
+%5227525252275252522752525227525252275252527DFD63FFA8FD1C527D
+%FD63FFA82752275227522752275227522752275227522752275227522752
+%275252FD64FFFD1C527DFD64FF5252275252522752525227525252275252
+%52275252522752525227527DFD64FFFD1C527DFD64FF5252275227522752
+%275227522752275227522752275227522752275252FD64FFFD1C527DFD64
+%FFFD0452275252522752525227525252275252522752525227FD0452FD64
+%FFFD1D52FD64FF5252275227522752275227522752275227522752275227
+%522752275252FD64FFFD1D52FD64FF525227525252275252522752525227
+%5252522752525227525252275252FD64FFFD1D52FD64FF52522752275227
+%52275227522752275227522752275227522752275252FD64FFFD1D52FD64
+%FFFD0452275252522752525227525252275252522752525227FD0452FD64
+%FFFD1D52FD64FF5252275227522752275227522752275227522752275227
+%522752275252FD64FF7DFD1C52FD64FF5252275252522752525227525252
+%275252522752525227525252275252FD64FF7DFD1C52FD64FF5252275227
+%522752275227522752275227522752275227522752275252A8FD63FF7DFD
+%1C52FD64FF7D525252275252522752525227525252275252522752525227
+%52525227A8FD63FF7DFD1C52FD64FF7D5227522752275227522752275227
+%5227522752275227522752275227A8FD63FF7DFD1C52A8FD63FF7D522752
+%52522752525227525252275252522752525227525252275227A8FD63FF7D
+%FD1C52A8FD62FFA827522752275227522752275227522752275227522752
+%2752275227527DFD61FFA8FD1C527DFD61FFA82752525227525252275252
+%52275252522752525227525252275227527DFD61FFA8FD1C527DFD61FFA8
+%275227522752275227522752275227522752275227522752275227527DFD
+%61FFA827FD1B527DFD61FFA8275227525252275252522752525227525252
+%275252522752525227527DFD61FFA827FD1B527DFD61FFA8275227522752
+%275227522752275227522752275227522752275227527DFD61FFA8FD1C52
+%A8FD61FF7D27525252275252522752525227525252275252522752525227
+%5227527DFD61FFA827FD19522752A8FD61FF7D2752275227522752275227
+%5227522752275227522752275227522752A8FD61FF7D27FD1B52A8FD61FF
+%7D27522752525227525252275252522752525227525252275252522752A8
+%FD61FF7D27FD1B52A8FD61FF7D2752275227522752275227522752275227
+%5227522752275227522752A8FD61FF7D27FD1B52A8FD61FF7D2752525227
+%5252522752525227525252275252522752525227522752A8FD61FF7D27FD
+%19522752A8FD61FF7D275227522752275227522752275227522752275227
+%52275227522752A8FD61FF7D27FD1B52A8FD61FF7D275227525252275252
+%52275252522752525227525252275252522752A8FD61FF7D27FD1B52A8FD
+%60FFA87D2752275227522752275227522752275227522752275227522752
+%2752A8FD61FF7DFD1A52277DFD62FF522752525227525252275252522752
+%5252275252522752525227522752A8FD61FF7D27FD1952277DFD61FFA852
+%27522752275227522752275227522752275227522752275227522752A8FD
+%61FF7D27FD1A527DFD61FFA8522752275252522752525227525252275252
+%522752525227525252277DFD62FF5227FD1952277DFD61FFA85227522752
+%27522752275227522752275227522752275227522752277DFD61FFA87DFD
+%1A52277DFD61FFA852275252522752525227525252275252522752525227
+%5252522752277DFD61FFA8FD1B52277DFD61FFA852275227522752275227
+%5227522752275227522752275227522752277DFD61FFA85227FD1952277D
+%FD61FFA85227522752525227525252275252522752525227525252275252
+%52277DFD61FFA85227FD1952277DFD61FFA8522752275227522752275227
+%522752275227522752275227522752277DFD61FFA8FD1B5227A8FD61FF7D
+%522752275227522752275227522752275227522752275227522752277DFD
+%62FFA87DA87DA8A8A87DA8A8A87DA8A8A87DA8A8A87DA8A8A87DA8A8A87D
+%A8FDE2FFFF
+%%EndData
+
+endstream
endobj
21 0 obj
<>stream
+%AI12_CompressedDataxœì½éŽ%Ç•&øŸ