Added support to define mosaico templates in MJML. (A wizard that shows how to do this is TODO.)
Adopted some core features (router, etc.) from IVIS.
This commit is contained in:
parent
3b20ac5ce7
commit
ad9f5d16bf
28 changed files with 1381 additions and 538 deletions
2
.gitmodules
vendored
2
.gitmodules
vendored
|
@ -1,3 +1,3 @@
|
|||
[submodule "mvis/ivis-core"]
|
||||
path = mvis/ivis-core
|
||||
url = https://gitlab.d3s.mff.cuni.cz/evif/ivis-core.git
|
||||
url = https://github.com/smartarch/ivis-core.git
|
||||
|
|
|
@ -30,11 +30,13 @@
|
|||
"grapesjs": "^0.14.49",
|
||||
"grapesjs-mjml": "0.0.31",
|
||||
"grapesjs-preset-newsletter": "^0.2.20",
|
||||
"htmlparser2": "^3.10.1",
|
||||
"i18next": "^13.1.0",
|
||||
"i18next-browser-languagedetector": "^2.2.4",
|
||||
"immutable": "^4.0.0-rc.12",
|
||||
"juice": "^5.1.0",
|
||||
"mjml4-in-browser": "^1.1.0",
|
||||
"lodash": "^4.17.11",
|
||||
"mjml4-in-browser": "^1.1.1",
|
||||
"moment": "^2.23.0",
|
||||
"moment-timezone": "^0.5.23",
|
||||
"popper.js": "^1.14.6",
|
||||
|
@ -55,6 +57,8 @@
|
|||
"react-router-dom": "^4.3.1",
|
||||
"react-sortable-tree": "^2.6.0",
|
||||
"slugify": "^1.3.4",
|
||||
"shallowequal": "^1.1.0",
|
||||
"shortid": "^2.2.14",
|
||||
"url-parse": "^1.4.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
@ -33,6 +33,7 @@ import styles
|
|||
import {getUrl} from "../lib/urls";
|
||||
import {TestSendModalDialog} from "./TestSendModalDialog";
|
||||
import {withComponentMixins} from "../lib/decorator-helpers";
|
||||
import {ContentModalDialog} from "../lib/modals";
|
||||
|
||||
|
||||
@withComponentMixins([
|
||||
|
@ -58,12 +59,20 @@ export default class CustomContent extends Component {
|
|||
this.state = {
|
||||
showMergeTagReference: false,
|
||||
elementInFullscreen: false,
|
||||
showTestSendModal: false
|
||||
showTestSendModal: false,
|
||||
showExportModal: false,
|
||||
exportModalContentType: null,
|
||||
exportModalTitle: ''
|
||||
};
|
||||
|
||||
this.initForm();
|
||||
|
||||
this.sendModalGetDataHandler = ::this.sendModalGetData;
|
||||
this.exportModalGetContentHandler = ::this.exportModalGetContent;
|
||||
|
||||
// This is needed here because if this is passed as an anonymous function, it will reset the editorNode to null with each render.
|
||||
// This becomes a problem when Show HTML button is pressed because that one tries to access the editorNode while it is null.
|
||||
this.editorNodeRefHandler = node => this.editorNode = node;
|
||||
}
|
||||
|
||||
static propTypes = {
|
||||
|
@ -204,6 +213,19 @@ export default class CustomContent extends Component {
|
|||
};
|
||||
}
|
||||
|
||||
showExportModal(contentType, title) {
|
||||
this.setState({
|
||||
showExportModal: true,
|
||||
exportModalContentType: contentType,
|
||||
exportModalTitle: title
|
||||
});
|
||||
}
|
||||
|
||||
async exportModalGetContent() {
|
||||
const customTemplateTypeKey = this.getFormValue('data_sourceCustom_type');
|
||||
return await this.templateTypes[customTemplateTypeKey].exportContent(this, this.state.exportModalContentType);
|
||||
}
|
||||
|
||||
render() {
|
||||
const t = this.props.t;
|
||||
|
||||
|
@ -221,6 +243,12 @@ export default class CustomContent extends Component {
|
|||
getDataAsync={this.sendModalGetDataHandler}
|
||||
entity={this.props.entity}
|
||||
/>
|
||||
<ContentModalDialog
|
||||
title={this.state.exportModalTitle}
|
||||
visible={this.state.showExportModal}
|
||||
onHide={() => this.setState({showExportModal: false})}
|
||||
getContentAsync={this.exportModalGetContentHandler}
|
||||
/>
|
||||
|
||||
<Title>{t('editCustomContent')}</Title>
|
||||
|
||||
|
|
28
client/src/lib/bootstrap-components.js
vendored
28
client/src/lib/bootstrap-components.js
vendored
|
@ -224,10 +224,6 @@ export class ModalDialog extends Component {
|
|||
super(props);
|
||||
|
||||
const t = props.t;
|
||||
|
||||
this.state = {
|
||||
buttons: this.props.buttons || [ { label: t('close'), className: 'btn-secondary', onClickAsync: null } ]
|
||||
};
|
||||
}
|
||||
|
||||
static propTypes = {
|
||||
|
@ -289,7 +285,7 @@ export class ModalDialog extends Component {
|
|||
}
|
||||
|
||||
async onButtonClick(idx) {
|
||||
const buttonSpec = this.state.buttons[idx];
|
||||
const buttonSpec = this.props.buttons[idx];
|
||||
if (buttonSpec.onClickAsync) {
|
||||
await buttonSpec.onClickAsync(idx);
|
||||
}
|
||||
|
@ -299,11 +295,15 @@ export class ModalDialog extends Component {
|
|||
const props = this.props;
|
||||
const t = props.t;
|
||||
|
||||
const buttons = [];
|
||||
for (let idx = 0; idx < this.state.buttons.length; idx++) {
|
||||
const buttonSpec = this.state.buttons[idx];
|
||||
const button = <Button key={idx} label={buttonSpec.label} className={buttonSpec.className} onClickAsync={async () => this.onButtonClick(idx)} />
|
||||
buttons.push(button);
|
||||
let buttons;
|
||||
|
||||
if (this.props.buttons) {
|
||||
buttons = [];
|
||||
for (let idx = 0; idx < this.props.buttons.length; idx++) {
|
||||
const buttonSpec = this.props.buttons[idx];
|
||||
const button = <Button key={idx} label={buttonSpec.label} className={buttonSpec.className} onClickAsync={async () => this.onButtonClick(idx)} />
|
||||
buttons.push(button);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -319,9 +319,11 @@ export class ModalDialog extends Component {
|
|||
<button type="button" className="close" aria-label={t('close')} onClick={::this.onClose}><span aria-hidden="true">×</span></button>
|
||||
</div>
|
||||
<div className="modal-body">{this.props.children}</div>
|
||||
<div className="modal-footer">
|
||||
{buttons}
|
||||
</div>
|
||||
{buttons &&
|
||||
<div className="modal-footer">
|
||||
{buttons}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -47,6 +47,7 @@ export function withComponentMixins(mixins, delegateFuns) {
|
|||
|
||||
return self;
|
||||
}
|
||||
TargetClassWithCtors.displayName = TargetClass.name;
|
||||
|
||||
TargetClassWithCtors.prototype = TargetClass.prototype;
|
||||
|
||||
|
@ -88,11 +89,16 @@ export function withComponentMixins(mixins, delegateFuns) {
|
|||
}
|
||||
|
||||
class ComponentMixinsOuter extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this._decoratorInnerInstanceRefFn = node => this._decoratorInnerInstance = node
|
||||
}
|
||||
render() {
|
||||
let innerFn = parentProps => {
|
||||
const props = {
|
||||
...parentProps,
|
||||
_decoratorInnerInstanceRefFn: node => this._decoratorInnerInstance = node
|
||||
_decoratorInnerInstanceRefFn: this._decoratorInnerInstanceRefFn
|
||||
};
|
||||
|
||||
return <DecoratedInner {...props}/>
|
||||
|
|
|
@ -3,39 +3,21 @@
|
|||
import React, {Component} from 'react';
|
||||
import {withTranslation} from './i18n';
|
||||
import axios, {HTTPMethod} from './axios';
|
||||
import Immutable
|
||||
from 'immutable';
|
||||
import PropTypes
|
||||
from 'prop-types';
|
||||
import interoperableErrors
|
||||
from '../../../shared/interoperable-errors';
|
||||
import Immutable from 'immutable';
|
||||
import PropTypes from 'prop-types';
|
||||
import interoperableErrors from '../../../shared/interoperable-errors';
|
||||
import {withPageHelpers} from './page'
|
||||
import {
|
||||
ParentErrorHandlerContext,
|
||||
withAsyncErrorHandler,
|
||||
withErrorHandling
|
||||
} from './error-handling';
|
||||
import {
|
||||
TreeSelectMode,
|
||||
TreeTable
|
||||
} from './tree';
|
||||
import {
|
||||
Table,
|
||||
TableSelectMode
|
||||
} from './table';
|
||||
import {
|
||||
Button,
|
||||
Icon
|
||||
} from "./bootstrap-components";
|
||||
import { SketchPicker } from 'react-color';
|
||||
import {withAsyncErrorHandler, withErrorHandling} from './error-handling';
|
||||
import {TreeSelectMode, TreeTable} from './tree';
|
||||
import {Table, TableSelectMode} from './table';
|
||||
import {Button} from "./bootstrap-components";
|
||||
import {SketchPicker} from 'react-color';
|
||||
|
||||
import ACEEditorRaw
|
||||
from 'react-ace';
|
||||
import ACEEditorRaw from 'react-ace';
|
||||
import 'brace/theme/github';
|
||||
import 'brace/ext/searchbox';
|
||||
|
||||
import DayPicker
|
||||
from 'react-day-picker';
|
||||
import DayPicker from 'react-day-picker';
|
||||
import 'react-day-picker/lib/style.css';
|
||||
import {
|
||||
birthdayYear,
|
||||
|
@ -48,15 +30,10 @@ import {
|
|||
parseDate
|
||||
} from '../../../shared/date';
|
||||
|
||||
import styles
|
||||
from "./styles.scss";
|
||||
import moment
|
||||
from "moment";
|
||||
import styles from "./styles.scss";
|
||||
import moment from "moment";
|
||||
import {getUrl} from "./urls";
|
||||
import {
|
||||
createComponentMixin,
|
||||
withComponentMixins
|
||||
} from "./decorator-helpers";
|
||||
import {createComponentMixin, withComponentMixins} from "./decorator-helpers";
|
||||
|
||||
|
||||
const FormState = {
|
||||
|
@ -133,7 +110,7 @@ class Form extends Component {
|
|||
</fieldset>
|
||||
{!props.noStatus && statusMessageText &&
|
||||
<AlignedRow format={props.format} htmlId="form-status-message">
|
||||
<p className={`alert alert-${statusMessageSeverity} ${styles.formStatus}`} role="alert">{statusMessageText}</p>
|
||||
<div className={`alert alert-${statusMessageSeverity} ${styles.formStatus}`} role="alert">{statusMessageText}</div>
|
||||
</AlignedRow>
|
||||
}
|
||||
</FormStateOwnerContext.Provider>
|
||||
|
@ -149,7 +126,7 @@ class Form extends Component {
|
|||
class Fieldset extends Component {
|
||||
static propTypes = {
|
||||
id: PropTypes.string,
|
||||
label: PropTypes.string,
|
||||
label: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
|
||||
help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
|
||||
flat: PropTypes.bool,
|
||||
className: PropTypes.string
|
||||
|
@ -282,6 +259,11 @@ class StaticField extends Component {
|
|||
const htmlId = 'form_' + id;
|
||||
|
||||
let className = 'form-control';
|
||||
|
||||
if (props.withValidation) {
|
||||
className = owner.addFormValidationClass(className, id);
|
||||
}
|
||||
|
||||
if (props.className) {
|
||||
className += ' ' + props.className;
|
||||
}
|
||||
|
@ -336,7 +318,7 @@ class InputField extends Component {
|
|||
class CheckBox extends Component {
|
||||
static propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
text: PropTypes.string.isRequired,
|
||||
text: PropTypes.string,
|
||||
label: PropTypes.string,
|
||||
help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
|
||||
format: PropTypes.string,
|
||||
|
@ -661,7 +643,7 @@ class DatePicker extends Component {
|
|||
const className = owner.addFormValidationClass('form-control', id);
|
||||
|
||||
return wrapInput(id, htmlId, owner, props.format, '', props.label, props.help,
|
||||
<div>
|
||||
<>
|
||||
<div className="input-group">
|
||||
<input type="text" value={selectedDateStr} placeholder={placeholder} id={htmlId} className={className} aria-describedby={htmlId + '_help'} onChange={evt => owner.updateFormValue(id, evt.target.value)}/>
|
||||
<div className="input-group-append">
|
||||
|
@ -680,7 +662,7 @@ class DatePicker extends Component {
|
|||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -995,7 +977,12 @@ const withForm = createComponentMixin([], [], (TargetClass, InnerClass) => {
|
|||
let payloadNotEmpty = false;
|
||||
|
||||
for (const attr of settings.serverValidation.extra || []) {
|
||||
payload[attr] = mutState.getIn(['data', attr, 'value']);
|
||||
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) {
|
||||
|
|
|
@ -1,81 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
import {registerDependencies, registerComponent, BodyComponent} from "mjml4-in-browser";
|
||||
|
||||
registerDependencies({
|
||||
'mj-column': ['mj-basic-component'],
|
||||
'mj-basic-component': []
|
||||
});
|
||||
|
||||
class MjBasicComponent extends BodyComponent {
|
||||
// Tell the parser that our component won't contain other mjml tags
|
||||
static endingTag = true
|
||||
|
||||
// Tells the validator which attributes are allowed for mj-layout
|
||||
static allowedAttributes = {
|
||||
'stars-color': 'color',
|
||||
'color': 'color',
|
||||
'font-size': 'unit(px)',
|
||||
'align': 'enum(left,right,center)',
|
||||
}
|
||||
|
||||
// What the name suggests. Fallback value for this.getAttribute('attribute-name').
|
||||
static defaultAttributes = {
|
||||
'stars-color': 'yellow',
|
||||
color: 'black',
|
||||
'font-size': '12px',
|
||||
'align': 'center',
|
||||
}
|
||||
|
||||
// This functions allows to define styles that can be used when rendering (see render() below)
|
||||
getStyles() {
|
||||
return {
|
||||
wrapperDiv: {
|
||||
color: this.getAttribute('stars-color'), // this.getAttribute(attrName) is the recommended way to access the attributes our component received in the mjml
|
||||
'font-size': this.getAttribute('font-size'),
|
||||
},
|
||||
contentP: {
|
||||
'text-align': this.getAttribute('align'),
|
||||
'font-size': '20px'
|
||||
},
|
||||
contentSpan: {
|
||||
color: this.getAttribute('color')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Render is the only required function in a component.
|
||||
It must return an html string.
|
||||
*/
|
||||
render() {
|
||||
return `
|
||||
<div
|
||||
${this.htmlAttributes({ // this.htmlAttributes() is the recommended way to pass attributes to html tags
|
||||
class: this.getAttribute('css-class'),
|
||||
style: 'wrapperDiv' // This will add the 'wrapperDiv' attributes from getStyles() as inline style
|
||||
})}
|
||||
>
|
||||
<p ${this.htmlAttributes({
|
||||
style: 'contentP' // This will add the 'contentP' attributes from getStyles() as inline style
|
||||
})}>
|
||||
<span>★</span>
|
||||
<span
|
||||
${this.htmlAttributes({
|
||||
style: 'contentSpan' // This will add the 'contentSpan' attributes from getStyles() as inline style
|
||||
})}
|
||||
>
|
||||
${this.getContent()}
|
||||
</span>
|
||||
<span>★</span>
|
||||
</p>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function registerComponents() {
|
||||
registerComponent(MjBasicComponent)
|
||||
}
|
||||
|
77
client/src/lib/mjml.js
Normal file
77
client/src/lib/mjml.js
Normal file
|
@ -0,0 +1,77 @@
|
|||
'use strict';
|
||||
|
||||
import { mergeWith, isArray } from 'lodash';
|
||||
import kebabCase from 'lodash/kebabCase';
|
||||
import mjml2html, {defaultSkeleton, BodyComponent, HeadComponent, components, dependencies} 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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
@ -3,20 +3,16 @@
|
|||
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 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 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,
|
||||
|
@ -119,7 +115,7 @@ function _getDependencyErrorMessage(err, t, name) {
|
|||
return (
|
||||
<div>
|
||||
<p>{t('cannoteDeleteNameDueToTheFollowing', {name})}</p>
|
||||
<ul className={styles.dependenciesList}>
|
||||
<ul className={styles.errorsList}>
|
||||
{err.data.dependencies.map(dep =>
|
||||
dep.link ?
|
||||
<li key={dep.link}><Link to={dep.link}>{entityTypeLabels[dep.entityTypeId](t)}: {dep.name}</Link></li>
|
||||
|
@ -304,3 +300,74 @@ export function tableRestActionDialogRender(owner) {
|
|||
/>
|
||||
|
||||
}
|
||||
|
||||
|
||||
@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 (
|
||||
<ModalDialog hidden={!this.props.visible} title={this.props.title} onCloseAsync={() => this.props.onHide()}>
|
||||
{this.props.visible && this.state.content &&
|
||||
<ACEEditorRaw
|
||||
mode='xml'
|
||||
theme="github"
|
||||
fontSize={12}
|
||||
width="100%"
|
||||
height="600px"
|
||||
showPrintMargin={false}
|
||||
value={this.state.content}
|
||||
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)
|
||||
readOnly={true}
|
||||
/>
|
||||
}
|
||||
</ModalDialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,127 +1,357 @@
|
|||
'use strict';
|
||||
|
||||
import React
|
||||
from "react";
|
||||
import React, {Component} from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import {Redirect, Route, Switch} from "react-router-dom";
|
||||
import {withRouter} from "react-router";
|
||||
import {withErrorHandling} from "./error-handling";
|
||||
import axios
|
||||
from "../lib/axios";
|
||||
import {withAsyncErrorHandler, withErrorHandling} from "./error-handling";
|
||||
import axios from "../lib/axios";
|
||||
import {getUrl} from "./urls";
|
||||
import {createComponentMixin} from "./decorator-helpers";
|
||||
import {createComponentMixin, withComponentMixins} from "./decorator-helpers";
|
||||
import {withTranslation} from "./i18n";
|
||||
import shallowEqual from "shallowequal";
|
||||
|
||||
export function needsResolve(route, nextRoute, match, nextMatch) {
|
||||
const resolve = route.resolve;
|
||||
const nextResolve = nextRoute.resolve;
|
||||
async function resolve(route, match, prevResolvedByUrl) {
|
||||
const resolved = {};
|
||||
const resolvedByUrl = {};
|
||||
const keysToGo = new Set(Object.keys(route.resolve));
|
||||
|
||||
// This compares whether two objects have the same content and returns TRUE if they don't
|
||||
if (Object.keys(resolve).length === Object.keys(nextResolve).length) {
|
||||
for (const key in nextResolve) {
|
||||
if (!(key in resolve) ||
|
||||
resolve[key](match.params) !== nextResolve[key](nextMatch.params)) {
|
||||
return true;
|
||||
prevResolvedByUrl = prevResolvedByUrl || {};
|
||||
|
||||
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 prevResolvedByUrl) {
|
||||
const entity = prevResolvedByUrl[url];
|
||||
resolved[key] = entity;
|
||||
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;
|
||||
resolvedByUrl[urlsToResolveByRest[idx]] = resolvedArr[idx].data;
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of keysToResolve) {
|
||||
keysToGo.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
return { resolved, resolvedByUrl };
|
||||
}
|
||||
|
||||
export function getRoutes(structure, parentRoute) {
|
||||
function _getRoutes(urlPrefix, resolve, 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 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,
|
||||
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, childrenParents, { [navKey]: nav }, childNavs, route.primaryMenuComponent, route.secondaryMenuComponent));
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.children) {
|
||||
routes = routes.concat(_getRoutes(path + '/', entryResolve, 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.parents, { [parentRoute.routeKey]: extStructure }, parentRoute.siblingNavs, parentRoute.primaryMenuComponent, parentRoute.secondaryMenuComponent);
|
||||
|
||||
} else {
|
||||
return true;
|
||||
return _getRoutes('', {}, [], { "": structure }, [], null, null);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function resolve(route, match) {
|
||||
const keys = Object.keys(route.resolve);
|
||||
|
||||
const promises = keys.map(key => {
|
||||
const url = route.resolve[key](match.params);
|
||||
if (url) {
|
||||
return axios.get(getUrl(url));
|
||||
} else {
|
||||
return Promise.resolve({data: null});
|
||||
@withComponentMixins([
|
||||
withErrorHandling
|
||||
])
|
||||
export class Resolver extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
resolved: null,
|
||||
resolvedByUrl: null
|
||||
};
|
||||
|
||||
if (Object.keys(props.route.resolve).length === 0) {
|
||||
this.state.resolved = {};
|
||||
}
|
||||
});
|
||||
const resolvedArr = await Promise.all(promises);
|
||||
|
||||
const resolved = {};
|
||||
for (let idx = 0; idx < keys.length; idx++) {
|
||||
resolved[keys[idx]] = resolvedArr[idx].data;
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
static propTypes = {
|
||||
route: PropTypes.object.isRequired,
|
||||
render: PropTypes.func.isRequired,
|
||||
location: PropTypes.object,
|
||||
match: PropTypes.object
|
||||
}
|
||||
|
||||
export function getRoutes(urlPrefix, resolve, parents, structure, navs, primaryMenuComponent, secondaryMenuComponent) {
|
||||
let routes = [];
|
||||
for (let routeKey in structure) {
|
||||
const entry = structure[routeKey];
|
||||
@withAsyncErrorHandler
|
||||
async resolve(prevMatch) {
|
||||
const props = this.props;
|
||||
|
||||
let path = urlPrefix + routeKey;
|
||||
let pathWithParams = path;
|
||||
if (Object.keys(props.route.resolve).length === 0) {
|
||||
this.setState({
|
||||
resolved: {},
|
||||
resolvedByUrl: {}
|
||||
});
|
||||
|
||||
if (entry.extraParams) {
|
||||
pathWithParams = pathWithParams + '/' + entry.extraParams.join('/');
|
||||
}
|
||||
|
||||
let entryResolve;
|
||||
if (entry.resolve) {
|
||||
entryResolve = Object.assign({}, resolve, entry.resolve);
|
||||
} else {
|
||||
entryResolve = resolve;
|
||||
}
|
||||
const prevResolvedByUrl = this.state.resolvedByUrl;
|
||||
|
||||
let navKeys;
|
||||
const entryNavs = [];
|
||||
if (entry.navs) {
|
||||
navKeys = Object.keys(entry.navs);
|
||||
if (this.state.resolved) {
|
||||
this.setState({
|
||||
resolved: null,
|
||||
resolvedByUrl: null
|
||||
});
|
||||
}
|
||||
|
||||
for (const navKey of navKeys) {
|
||||
const nav = entry.navs[navKey];
|
||||
const {resolved, resolvedByUrl} = await resolve(props.route, props.match, prevResolvedByUrl);
|
||||
|
||||
entryNavs.push({
|
||||
title: nav.title,
|
||||
visible: nav.visible,
|
||||
link: nav.link,
|
||||
externalLink: nav.externalLink
|
||||
if (!this.disregardResolve) { // This is to prevent the warning about setState on discarded component when we immediatelly redirect.
|
||||
this.setState({
|
||||
resolved,
|
||||
resolvedByUrl
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const route = {
|
||||
path: (pathWithParams === '' ? '/' : pathWithParams),
|
||||
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,
|
||||
parents,
|
||||
navs: [...navs, ...entryNavs]
|
||||
};
|
||||
componentDidMount() {
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
this.resolve();
|
||||
}
|
||||
|
||||
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, childrenParents, { [navKey]: nav }, childNavs, route.primaryMenuComponent, route.secondaryMenuComponent));
|
||||
}
|
||||
}
|
||||
|
||||
if (entry.children) {
|
||||
routes = routes.concat(getRoutes(path + '/', entryResolve, childrenParents, entry.children, entryNavs, route.primaryMenuComponent, route.secondaryMenuComponent));
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return routes;
|
||||
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.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 <Redirect to={link}/>;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@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 => {
|
||||
if (resolved) {
|
||||
const subStructure = route.structure(resolved, 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 <Route key={route.path} exact={route.exact} path={route.path} render={render} />
|
||||
};
|
||||
|
||||
return (
|
||||
<Switch>{routes.map(x => _renderRoute(x))}</Switch>
|
||||
);
|
||||
|
||||
} else {
|
||||
return this.props.loadingMessageFn();
|
||||
}
|
||||
};
|
||||
|
||||
return <Resolver route={route} render={render} location={this.props.location} match={this.props.match} />;
|
||||
}
|
||||
}
|
||||
|
||||
export function renderRoute(route, panelRouteCtor, loadingMessageFn, flashMessage, props) {
|
||||
if (route.structure) {
|
||||
return <SubRoute route={route} flashMessage={flashMessage} panelRouteCtor={panelRouteCtor} loadingMessageFn={loadingMessageFn} {...props}/>;
|
||||
|
||||
} else if (!route.panelRender && !route.panelComponent && route.link) {
|
||||
return <RedirectRoute route={route} {...props}/>;
|
||||
|
||||
} else {
|
||||
const PanelRoute = panelRouteCtor;
|
||||
return <PanelRoute route={route} flashMessage={flashMessage} {...props}/>;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const SectionContentContext = React.createContext(null);
|
||||
|
|
|
@ -2,45 +2,17 @@
|
|||
|
||||
import React, {Component} from "react";
|
||||
import i18n, {withTranslation} from './i18n';
|
||||
import PropTypes
|
||||
from "prop-types";
|
||||
import PropTypes from "prop-types";
|
||||
import {withRouter} from "react-router";
|
||||
import {
|
||||
BrowserRouter as Router,
|
||||
Link,
|
||||
Redirect,
|
||||
Route,
|
||||
Switch
|
||||
} from "react-router-dom";
|
||||
import {
|
||||
withAsyncErrorHandler,
|
||||
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,
|
||||
needsResolve,
|
||||
resolve,
|
||||
SectionContentContext,
|
||||
withPageHelpers
|
||||
} from "./page-common";
|
||||
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 {createComponentMixin, withComponentMixins} from "./decorator-helpers";
|
||||
import {getLang} from "../../../shared/langs";
|
||||
|
||||
export { withPageHelpers }
|
||||
|
@ -182,21 +154,81 @@ class TertiaryNavBar extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
function getLoadingMessage(t) {
|
||||
return (
|
||||
<div className="container-fluid my-3">
|
||||
{t('loading')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderFrameWithContent(t, panelInFullScreen, showSidebar, primaryMenu, secondaryMenu, content) {
|
||||
if (panelInFullScreen) {
|
||||
return (
|
||||
<div key="app" className="app panel-in-fullscreen">
|
||||
<div key="appBody" className="app-body">
|
||||
<main key="main" className="main">
|
||||
{content}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
} else {
|
||||
return (
|
||||
<div key="app" className={"app " + (showSidebar ? 'sidebar-lg-show' : '')}>
|
||||
<header key="appHeader" className="app-header">
|
||||
<nav className="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
{showSidebar &&
|
||||
<button className="navbar-toggler sidebar-toggler" data-toggle="sidebar-show" type="button">
|
||||
<span className="navbar-toggler-icon"/>
|
||||
</button>
|
||||
}
|
||||
|
||||
<Link className="navbar-brand" to="/"><div><Icon icon="envelope"/> Mailtrain</div></Link>
|
||||
|
||||
<button className="navbar-toggler" type="button" data-toggle="collapse" data-target="#mtMainNavbar" aria-controls="navbarColor01" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span className="navbar-toggler-icon"/>
|
||||
</button>
|
||||
|
||||
<div className="collapse navbar-collapse" id="mtMainNavbar">
|
||||
{primaryMenu}
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div key="appBody" className="app-body">
|
||||
{showSidebar &&
|
||||
<div key="sidebar" className="sidebar">
|
||||
{secondaryMenu}
|
||||
</div>
|
||||
}
|
||||
<main key="main" className="main">
|
||||
{content}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<footer key="appFooter" className="app-footer">
|
||||
<div className="text-muted">© 2018 <a href="https://mailtrain.org">Mailtrain.org</a>, <a href="mailto:info@mailtrain.org">info@mailtrain.org</a>. <a href="https://github.com/Mailtrain-org/mailtrain">{t('sourceOnGitHub')}</a></div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@withComponentMixins([
|
||||
withTranslation,
|
||||
withErrorHandling
|
||||
withTranslation
|
||||
])
|
||||
class RouteContent extends Component {
|
||||
class PanelRoute extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
panelInFullScreen: props.route.panelInFullScreen
|
||||
};
|
||||
|
||||
if (Object.keys(props.route.resolve).length === 0) {
|
||||
this.state.resolved = {};
|
||||
}
|
||||
|
||||
this.sidebarAnimationNodeListener = evt => {
|
||||
if (evt.propertyName === 'left') {
|
||||
this.forceUpdate();
|
||||
|
@ -208,33 +240,11 @@ class RouteContent extends Component {
|
|||
|
||||
static propTypes = {
|
||||
route: PropTypes.object.isRequired,
|
||||
location: PropTypes.object.isRequired,
|
||||
match: PropTypes.object.isRequired,
|
||||
flashMessage: PropTypes.object
|
||||
}
|
||||
|
||||
@withAsyncErrorHandler
|
||||
async resolve() {
|
||||
const props = this.props;
|
||||
|
||||
if (Object.keys(props.route.resolve).length === 0) {
|
||||
this.setState({
|
||||
resolved: {}
|
||||
});
|
||||
|
||||
} else {
|
||||
this.setState({
|
||||
resolved: null
|
||||
});
|
||||
|
||||
const resolved = await resolve(props.route, props.match);
|
||||
|
||||
if (!this.disregardResolve) { // This is to prevent the warning about setState on discarded component when we immediatelly redirect.
|
||||
this.setState({
|
||||
resolved
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerSidebarAnimationListener() {
|
||||
if (this.sidebarAnimationNode) {
|
||||
this.sidebarAnimationNode.addEventListener("transitionend", this.sidebarAnimationNodeListener);
|
||||
|
@ -242,45 +252,23 @@ class RouteContent extends Component {
|
|||
}
|
||||
|
||||
componentDidMount() {
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
this.resolve();
|
||||
this.registerSidebarAnimationListener();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
this.registerSidebarAnimationListener();
|
||||
|
||||
if (this.props.location.state !== prevProps.location.state || (this.props.match.params !== prevProps.match.params && needsResolve(prevProps.route, this.props.route, prevProps.match, this.props.match))) {
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
this.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.disregardResolve = true; // This is to prevent the warning about setState on discarded component when we immediatelly redirect.
|
||||
}
|
||||
|
||||
render() {
|
||||
const t = this.props.t;
|
||||
const route = this.props.route;
|
||||
const params = this.props.match.params;
|
||||
const resolved = this.state.resolved;
|
||||
|
||||
const showSidebar = !!route.secondaryMenuComponent;
|
||||
|
||||
const panelInFullScreen = this.state.panelInFullScreen;
|
||||
|
||||
if (!route.panelRender && !route.panelComponent && route.link) {
|
||||
let link;
|
||||
if (typeof route.link === 'function') {
|
||||
link = route.link(params);
|
||||
} else {
|
||||
link = route.link;
|
||||
}
|
||||
|
||||
return <Redirect to={link}/>;
|
||||
|
||||
} else {
|
||||
const render = resolved => {
|
||||
let primaryMenu = null;
|
||||
let secondaryMenu = null;
|
||||
let content = null;
|
||||
|
@ -331,71 +319,21 @@ class RouteContent extends Component {
|
|||
}
|
||||
|
||||
} else {
|
||||
content = (
|
||||
<div className="container-fluid my-3">
|
||||
{t('loading')}
|
||||
</div>
|
||||
);
|
||||
content = getLoadingMessage(t);
|
||||
}
|
||||
|
||||
if (panelInFullScreen) {
|
||||
return (
|
||||
<div key="app" className="app panel-in-fullscreen">
|
||||
<div key="appBody" className="app-body">
|
||||
<main key="main" className="main">
|
||||
{content}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return renderFrameWithContent(t, panelInFullScreen, showSidebar, primaryMenu, secondaryMenu, content);
|
||||
};
|
||||
|
||||
} else {
|
||||
return (
|
||||
<div key="app" className={"app " + (showSidebar ? 'sidebar-lg-show' : '')}>
|
||||
<header key="appHeader" className="app-header">
|
||||
<nav className="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
{showSidebar &&
|
||||
<button className="navbar-toggler sidebar-toggler" data-toggle="sidebar-show" type="button">
|
||||
<span className="navbar-toggler-icon"/>
|
||||
</button>
|
||||
}
|
||||
|
||||
<Link className="navbar-brand" to="/"><div><Icon icon="envelope"/> Mailtrain</div></Link>
|
||||
|
||||
<button className="navbar-toggler" type="button" data-toggle="collapse" data-target="#mtMainNavbar" aria-controls="navbarColor01" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span className="navbar-toggler-icon"/>
|
||||
</button>
|
||||
|
||||
<div className="collapse navbar-collapse" id="mtMainNavbar">
|
||||
{primaryMenu}
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div key="appBody" className="app-body">
|
||||
{showSidebar &&
|
||||
<div key="sidebar" className="sidebar">
|
||||
{secondaryMenu}
|
||||
</div>
|
||||
}
|
||||
<main key="main" className="main">
|
||||
{content}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<footer key="appFooter" className="app-footer">
|
||||
<div className="text-muted">© 2018 <a href="https://mailtrain.org">Mailtrain.org</a>, <a href="mailto:info@mailtrain.org">info@mailtrain.org</a>. <a href="https://github.com/Mailtrain-org/mailtrain">{t('sourceOnGitHub')}</a></div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
return <Resolver route={route} render={render} location={this.props.location} match={this.props.match}/>;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@withRouter
|
||||
@withComponentMixins([
|
||||
withTranslation,
|
||||
withErrorHandling
|
||||
])
|
||||
export class SectionContent extends Component {
|
||||
|
@ -465,18 +403,28 @@ export class SectionContent extends Component {
|
|||
}
|
||||
|
||||
renderRoute(route) {
|
||||
let flashMessage;
|
||||
if (this.state.flashMessageText) {
|
||||
flashMessage = <DismissibleAlert severity={this.state.flashMessageSeverity} onCloseAsync={::this.closeFlashMessage}>{this.state.flashMessageText}</DismissibleAlert>;
|
||||
}
|
||||
const t = this.props.t;
|
||||
|
||||
const render = props => <RouteContent route={route} flashMessage={flashMessage} {...props}/>;
|
||||
const render = props => {
|
||||
let flashMessage;
|
||||
if (this.state.flashMessageText) {
|
||||
flashMessage = <DismissibleAlert severity={this.state.flashMessageSeverity} onCloseAsync={::this.closeFlashMessage}>{this.state.flashMessageText}</DismissibleAlert>;
|
||||
}
|
||||
|
||||
return <Route key={route.path} exact path={route.path} render={render} />
|
||||
return renderRoute(
|
||||
route,
|
||||
PanelRoute,
|
||||
() => renderFrameWithContent(t,false, false, null, null, getLoadingMessage(this.props.t)),
|
||||
flashMessage,
|
||||
props
|
||||
);
|
||||
};
|
||||
|
||||
return <Route key={route.path} exact={route.exact} path={route.path} render={render} />
|
||||
}
|
||||
|
||||
render() {
|
||||
let routes = getRoutes('', {}, [], this.props.structure, [], null, null);
|
||||
const routes = getRoutes(this.props.structure);
|
||||
|
||||
return (
|
||||
<SectionContentContext.Provider value={this}>
|
||||
|
|
|
@ -25,9 +25,10 @@ export class CKEditorHost extends Component {
|
|||
|
||||
this.state = {
|
||||
fullscreen: false
|
||||
}
|
||||
};
|
||||
|
||||
this.onWindowResizeHandler = ::this.onWindowResize;
|
||||
this.contentNodeRefHandler = node => this.contentNode = node;
|
||||
}
|
||||
|
||||
static propTypes = {
|
||||
|
@ -38,6 +39,7 @@ export class CKEditorHost extends Component {
|
|||
onSave: PropTypes.func,
|
||||
canSave: PropTypes.bool,
|
||||
onTestSend: PropTypes.func,
|
||||
onShowExport: PropTypes.func,
|
||||
onFullscreenAsync: PropTypes.func
|
||||
}
|
||||
|
||||
|
@ -101,10 +103,11 @@ export class CKEditorHost extends Component {
|
|||
<div className={styles.navbarRight}>
|
||||
{this.props.canSave ? <a className={styles.btn} onClick={this.props.onSave} title={t('Save')}><Icon icon="save"/></a> : <span className={styles.btnDisabled}><Icon icon="save"/></span>}
|
||||
<a className={styles.btn} onClick={this.props.onTestSend} title={t('Send test e-mail')}><Icon icon="at"/></a>
|
||||
<a className={styles.btn} onClick={() => this.props.onShowExport('html', 'HTML')} title={t('Show HTML')}><Icon icon="file-code"/></a>
|
||||
<a className={styles.btn} onClick={::this.toggleFullscreenAsync} title={t('Maximize editor')}><Icon icon="window-maximize"/></a>
|
||||
</div>
|
||||
</div>
|
||||
<UntrustedContentHost ref={node => this.contentNode = node} className={styles.host} singleToken={true} contentProps={editorData} contentSrc="ckeditor/editor" tokenMethod="ckeditor" tokenParams={editorData}/>
|
||||
<UntrustedContentHost ref={this.contentNodeRefHandler} className={styles.host} singleToken={true} contentProps={editorData} contentSrc="ckeditor/editor" tokenMethod="ckeditor" tokenParams={editorData}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -3,47 +3,28 @@
|
|||
import './public-path';
|
||||
|
||||
import React, {Component} from 'react';
|
||||
import ReactDOM
|
||||
from 'react-dom';
|
||||
import ReactDOM from 'react-dom';
|
||||
import {I18nextProvider} from 'react-i18next';
|
||||
import i18n, {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 {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 "mjml4-in-browser";
|
||||
import mjml2html from "./mjml";
|
||||
|
||||
import {registerComponents} from "./mjml-mosaico";
|
||||
import juice from "juice";
|
||||
|
||||
import juice
|
||||
from "juice";
|
||||
import {withComponentMixins} from "./decorator-helpers";
|
||||
|
||||
const refreshTimeout = 1000;
|
||||
|
||||
registerComponents();
|
||||
|
||||
@withComponentMixins([
|
||||
withTranslation
|
||||
])
|
||||
|
@ -148,15 +129,19 @@ class CodeEditorSandbox extends Component {
|
|||
}
|
||||
|
||||
getHtml() {
|
||||
let previewContents;
|
||||
let contents;
|
||||
if (this.props.sourceType === CodeEditorSourceType.MJML) {
|
||||
const res = mjml2html(this.state.source);
|
||||
previewContents = res.html;
|
||||
try {
|
||||
const res = mjml2html(this.state.source);
|
||||
contents = res.html;
|
||||
} catch (err) {
|
||||
contents = '';
|
||||
}
|
||||
} else if (this.props.sourceType === CodeEditorSourceType.HTML) {
|
||||
previewContents = juice(this.state.source);
|
||||
contents = juice(this.state.source);
|
||||
}
|
||||
|
||||
return previewContents;
|
||||
return contents;
|
||||
}
|
||||
|
||||
onCodeChanged(data) {
|
||||
|
|
|
@ -23,7 +23,9 @@ export class CodeEditorHost extends Component {
|
|||
fullscreen: false,
|
||||
preview: true,
|
||||
wrap: true
|
||||
}
|
||||
};
|
||||
|
||||
this.contentNodeRefHandler = node => this.contentNode = node;
|
||||
}
|
||||
|
||||
static propTypes = {
|
||||
|
@ -35,6 +37,7 @@ export class CodeEditorHost extends Component {
|
|||
onSave: PropTypes.func,
|
||||
canSave: PropTypes.bool,
|
||||
onTestSend: PropTypes.func,
|
||||
onShowExport: PropTypes.func,
|
||||
onFullscreenAsync: PropTypes.func
|
||||
}
|
||||
|
||||
|
@ -96,11 +99,12 @@ export class CodeEditorHost extends Component {
|
|||
<a className={styles.btn} onClick={::this.toggleWrapAsync} title={this.state.wrap ? t('Disable word wrap') : t('Enable word wrap')}>{this.state.wrap ? 'WRAP': 'NOWRAP'}</a>
|
||||
{this.props.canSave ? <a className={styles.btn} onClick={this.props.onSave} title={t('Save')}><Icon icon="save"/></a> : <span className={styles.btnDisabled}><Icon icon="floppy-disk"/></span>}
|
||||
<a className={styles.btn} onClick={this.props.onTestSend} title={t('Send test e-mail')}><Icon icon="at"/></a>
|
||||
<a className={styles.btn} onClick={() => this.props.onShowExport('html', 'HTML')} title={t('Show HTML')}><Icon icon="file-code"/></a>
|
||||
<a className={styles.btn} onClick={::this.togglePreviewAsync} title={this.state.preview ? t('Hide preview'): t('Show preview')}><Icon icon={this.state.preview ? 'eye-slash': 'eye'}/></a>
|
||||
<a className={styles.btn} onClick={::this.toggleFullscreenAsync} title={t('Maximize editor')}><Icon icon="window-maximize"/></a>
|
||||
</div>
|
||||
</div>
|
||||
<UntrustedContentHost ref={node => this.contentNode = node} className={styles.host} singleToken={true} contentProps={editorData} contentSrc="codeeditor/editor" tokenMethod="codeeditor" tokenParams={tokenData}/>
|
||||
<UntrustedContentHost ref={this.contentNodeRefHandler} className={styles.host} singleToken={true} contentProps={editorData} contentSrc="codeeditor/editor" tokenMethod="codeeditor" tokenParams={tokenData}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -3,31 +3,17 @@
|
|||
import './public-path';
|
||||
|
||||
import React, {Component} from 'react';
|
||||
import ReactDOM
|
||||
from 'react-dom';
|
||||
import ReactDOM from 'react-dom';
|
||||
import {I18nextProvider} from 'react-i18next';
|
||||
import i18n, {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 "mjml4-in-browser";
|
||||
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 from 'grapesjs';
|
||||
|
||||
import 'grapesjs-mjml';
|
||||
|
||||
|
@ -36,8 +22,7 @@ import 'grapesjs-preset-newsletter/dist/grapesjs-preset-newsletter.css';
|
|||
|
||||
import "./sandboxed-grapesjs.scss";
|
||||
|
||||
import axios
|
||||
from './axios';
|
||||
import axios from './axios';
|
||||
import {GrapesJSSourceType} from "./sandboxed-grapesjs-shared";
|
||||
import {withComponentMixins} from "./decorator-helpers";
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@ 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
|
||||
|
@ -21,7 +22,9 @@ export class GrapesJSHost extends Component {
|
|||
|
||||
this.state = {
|
||||
fullscreen: false
|
||||
}
|
||||
};
|
||||
|
||||
this.contentNodeRefHandler = node => this.contentNode = node;
|
||||
}
|
||||
|
||||
static propTypes = {
|
||||
|
@ -34,6 +37,7 @@ export class GrapesJSHost extends Component {
|
|||
onSave: PropTypes.func,
|
||||
canSave: PropTypes.bool,
|
||||
onTestSend: PropTypes.func,
|
||||
onShowExport: PropTypes.func,
|
||||
onFullscreenAsync: PropTypes.func
|
||||
}
|
||||
|
||||
|
@ -75,10 +79,12 @@ export class GrapesJSHost extends Component {
|
|||
<div className={styles.navbarRight}>
|
||||
{this.props.canSave ? <a className={styles.btn} onClick={this.props.onSave} title={t('Save')}><Icon icon="save"/></a> : <span className={styles.btnDisabled}><Icon icon="save"/></span>}
|
||||
<a className={styles.btn} onClick={this.props.onTestSend} title={t('Send test e-mail')}><Icon icon="at"/></a>
|
||||
<a className={styles.btn} onClick={() => this.props.onShowExport('html', 'HTML')} title={t('Show HTML')}><Icon icon="file-code"/></a>
|
||||
{this.props.sourceType === GrapesJSSourceType.MJML && <a className={styles.btn} onClick={() => this.props.onShowExport('mjml', 'MJML')} title={t('Show MJML')}>MJML</a>}
|
||||
<a className={styles.btn} onClick={::this.toggleFullscreenAsync} title={t('Maximize editor')}><Icon icon="window-maximize"/></a>
|
||||
</div>
|
||||
</div>
|
||||
<UntrustedContentHost ref={node => this.contentNode = node} className={styles.host} singleToken={true} contentProps={editorData} contentSrc="grapesjs/editor" tokenMethod="grapesjs" tokenParams={tokenData}/>
|
||||
<UntrustedContentHost ref={this.contentNodeRefHandler} className={styles.host} singleToken={true} contentProps={editorData} contentSrc="grapesjs/editor" tokenMethod="grapesjs" tokenParams={tokenData}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -3,27 +3,15 @@
|
|||
import './public-path';
|
||||
|
||||
import React, {Component} from 'react';
|
||||
import ReactDOM
|
||||
from 'react-dom';
|
||||
import ReactDOM from 'react-dom';
|
||||
import {I18nextProvider} from 'react-i18next';
|
||||
import i18n, {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 {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
|
||||
|
@ -49,8 +37,29 @@ class MosaicoSandbox extends Component {
|
|||
const trustedUrlBase = getTrustedUrl();
|
||||
const sandboxUrlBase = getSandboxUrl();
|
||||
const publicUrlBase = getPublicUrl();
|
||||
|
||||
|
||||
/* juice is called to inline css styles of situations like this
|
||||
<style type="text/css" data-inline="true">
|
||||
[data-ko-block=introBlock] .text p {
|
||||
font-family: merriweather,georgia,times new roman,serif; font-size: 14px; text-align: justify; line-height: 150%; color: #3A3A3A; margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
...
|
||||
|
||||
<div style="Margin:0px auto;max-width:600px;" data-ko-block="introBlock">
|
||||
...
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1;text-align:left;color:#000000;" data-ko-editable="text" class="text">
|
||||
<p>XXX</p>
|
||||
</div>
|
||||
...
|
||||
</div>
|
||||
*/
|
||||
const html = juice(this.viewModel.exportHTML());
|
||||
|
||||
return {
|
||||
html: unbase(this.viewModel.exportHTML(), trustedUrlBase, sandboxUrlBase, publicUrlBase, true),
|
||||
html: unbase(html, trustedUrlBase, sandboxUrlBase, publicUrlBase, true),
|
||||
model: unbase(this.viewModel.exportJSON(), trustedUrlBase, sandboxUrlBase, publicUrlBase),
|
||||
metadata: unbase(this.viewModel.exportMetadata(), trustedUrlBase, sandboxUrlBase, publicUrlBase)
|
||||
};
|
||||
|
|
|
@ -22,7 +22,9 @@ export class MosaicoHost extends Component {
|
|||
|
||||
this.state = {
|
||||
fullscreen: false
|
||||
}
|
||||
};
|
||||
|
||||
this.contentNodeRefHandler = node => this.contentNode = node;
|
||||
}
|
||||
|
||||
static propTypes = {
|
||||
|
@ -32,6 +34,7 @@ export class MosaicoHost extends Component {
|
|||
onSave: PropTypes.func,
|
||||
canSave: PropTypes.bool,
|
||||
onTestSend: PropTypes.func,
|
||||
onShowExport: PropTypes.func,
|
||||
onFullscreenAsync: PropTypes.func,
|
||||
templateId: PropTypes.number,
|
||||
templatePath: PropTypes.string,
|
||||
|
@ -78,10 +81,11 @@ export class MosaicoHost extends Component {
|
|||
<div className={styles.navbarRight}>
|
||||
{this.props.canSave ? <a className={styles.btn} onClick={this.props.onSave} title={t('Save')}><Icon icon="save"/></a> : <span className={styles.btnDisabled}><Icon icon="save"/></span>}
|
||||
<a className={styles.btn} onClick={this.props.onTestSend} title={t('Send test e-mail')}><Icon icon="at"/></a>
|
||||
<a className={styles.btn} onClick={() => this.props.onShowExport('html', 'HTML')} title={t('Show HTML')}><Icon icon="file-code"/></a>
|
||||
<a className={styles.btn} onClick={::this.toggleFullscreenAsync} title={t('Maximize editor')}><Icon icon="window-maximize"/></a>
|
||||
</div>
|
||||
</div>
|
||||
<UntrustedContentHost ref={node => this.contentNode = node} className={styles.host} singleToken={true} contentProps={editorData} contentSrc="mosaico/editor" tokenMethod="mosaico" tokenParams={tokenData}/>
|
||||
<UntrustedContentHost ref={this.contentNodeRefHandler} className={styles.host} singleToken={true} contentProps={editorData} contentSrc="mosaico/editor" tokenMethod="mosaico" tokenParams={tokenData}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -168,7 +168,7 @@
|
|||
text-decoration: $link-decoration;
|
||||
}
|
||||
|
||||
.dependenciesList {
|
||||
.errorsList {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
|
|
|
@ -42,6 +42,7 @@ export class UntrustedContentHost extends Component {
|
|||
};
|
||||
|
||||
this.receiveMessageHandler = ::this.receiveMessage;
|
||||
this.contentNodeRefHandler = node => this.contentNode = node;
|
||||
|
||||
this.rpcCounter = 0;
|
||||
this.rpcResolves = new Map();
|
||||
|
@ -176,7 +177,7 @@ export class UntrustedContentHost extends Component {
|
|||
render() {
|
||||
return (
|
||||
// The 40 px below corresponds to the height in .sandbox-loading-message
|
||||
<iframe className={styles.untrustedContent + ' ' + this.props.className} height="40px" ref={node => this.contentNode = node} src={getSandboxUrl(this.props.contentSrc)} onLoad={::this.contentNodeLoaded}></iframe>
|
||||
<iframe className={styles.untrustedContent + ' ' + this.props.className} height="40px" ref={this.contentNodeRefHandler} src={getSandboxUrl(this.props.contentSrc)} onLoad={::this.contentNodeLoaded}></iframe>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -70,7 +70,7 @@ class Root extends Component {
|
|||
render() {
|
||||
const t = this.props.t;
|
||||
|
||||
const structure = {};
|
||||
let structure;
|
||||
|
||||
// The MainMenu component is defined here in order to avoid recreating menu structure on every change in the main menu
|
||||
// This is because Root component depends only on the language, thus it is redrawn (and the structure is recomputed) only when the language changes
|
||||
|
@ -87,7 +87,7 @@ class Root extends Component {
|
|||
render() {
|
||||
const path = this.props.location.pathname;
|
||||
|
||||
const topLevelItems = structure[""].children;
|
||||
const topLevelItems = structure.children;
|
||||
|
||||
const topLevelMenu = [];
|
||||
|
||||
|
@ -137,7 +137,7 @@ class Root extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
structure[''] ={
|
||||
structure = {
|
||||
title: t('home'),
|
||||
link: '/',
|
||||
panelComponent: Home,
|
||||
|
|
|
@ -1,48 +1,33 @@
|
|||
'use strict';
|
||||
|
||||
import React, {Component} from 'react';
|
||||
import PropTypes
|
||||
from 'prop-types';
|
||||
import PropTypes from 'prop-types';
|
||||
import {withTranslation} from '../lib/i18n';
|
||||
import {
|
||||
LinkButton,
|
||||
requiresAuthenticatedUser,
|
||||
Title,
|
||||
withPageHelpers
|
||||
} from '../lib/page'
|
||||
import {LinkButton, requiresAuthenticatedUser, Title, withPageHelpers} from '../lib/page'
|
||||
import {
|
||||
Button,
|
||||
ButtonRow, CheckBox,
|
||||
ButtonRow,
|
||||
CheckBox,
|
||||
Dropdown,
|
||||
Form,
|
||||
FormSendMethod,
|
||||
InputField,
|
||||
StaticField, TableSelect,
|
||||
StaticField,
|
||||
TableSelect,
|
||||
TextArea,
|
||||
withForm
|
||||
} from '../lib/form';
|
||||
import {withErrorHandling} from '../lib/error-handling';
|
||||
import {
|
||||
NamespaceSelect,
|
||||
validateNamespace
|
||||
} from '../lib/namespace';
|
||||
import {DeleteModalDialog} from "../lib/modals";
|
||||
import mailtrainConfig
|
||||
from 'mailtrainConfig';
|
||||
import {
|
||||
getEditForm,
|
||||
getTemplateTypes,
|
||||
getTypeForm
|
||||
} from './helpers';
|
||||
import axios
|
||||
from '../lib/axios';
|
||||
import styles
|
||||
from "../lib/styles.scss";
|
||||
import {NamespaceSelect, validateNamespace} from '../lib/namespace';
|
||||
import {ContentModalDialog, DeleteModalDialog} from "../lib/modals";
|
||||
import mailtrainConfig from 'mailtrainConfig';
|
||||
import {getEditForm, getTemplateTypes, getTypeForm} from './helpers';
|
||||
import axios from '../lib/axios';
|
||||
import styles from "../lib/styles.scss";
|
||||
import {getUrl} from "../lib/urls";
|
||||
import {TestSendModalDialog} from "./TestSendModalDialog";
|
||||
import {withComponentMixins} from "../lib/decorator-helpers";
|
||||
import moment
|
||||
from 'moment';
|
||||
import moment from 'moment';
|
||||
|
||||
|
||||
@withComponentMixins([
|
||||
|
@ -62,6 +47,9 @@ export default class CUD extends Component {
|
|||
showMergeTagReference: false,
|
||||
elementInFullscreen: false,
|
||||
showTestSendModal: false,
|
||||
showExportModal: false,
|
||||
exportModalContentType: null,
|
||||
exportModalTitle: ''
|
||||
};
|
||||
|
||||
this.initForm({
|
||||
|
@ -71,6 +59,11 @@ export default class CUD extends Component {
|
|||
});
|
||||
|
||||
this.sendModalGetDataHandler = ::this.sendModalGetData;
|
||||
this.exportModalGetContentHandler = ::this.exportModalGetContent;
|
||||
|
||||
// This is needed here because if this is passed as an anonymous function, it will reset the editorNode to null with each render.
|
||||
// This becomes a problem when Show HTML button is pressed because that one tries to access the editorNode while it is null.
|
||||
this.editorNodeRefHandler = node => this.editorNode = node;
|
||||
}
|
||||
|
||||
static propTypes = {
|
||||
|
@ -244,6 +237,19 @@ export default class CUD extends Component {
|
|||
};
|
||||
}
|
||||
|
||||
showExportModal(contentType, title) {
|
||||
this.setState({
|
||||
showExportModal: true,
|
||||
exportModalContentType: contentType,
|
||||
exportModalTitle: title
|
||||
});
|
||||
}
|
||||
|
||||
async exportModalGetContent() {
|
||||
const typeKey = this.getFormValue('type');
|
||||
return await this.templateTypes[typeKey].exportContent(this, this.state.exportModalContentType);
|
||||
}
|
||||
|
||||
render() {
|
||||
const t = this.props.t;
|
||||
const isEdit = !!this.props.entity;
|
||||
|
@ -282,6 +288,13 @@ export default class CUD extends Component {
|
|||
onHide={() => this.setState({showTestSendModal: false})}
|
||||
getDataAsync={this.sendModalGetDataHandler}/>
|
||||
}
|
||||
{isEdit &&
|
||||
<ContentModalDialog
|
||||
title={this.state.exportModalTitle}
|
||||
visible={this.state.showExportModal}
|
||||
onHide={() => this.setState({showExportModal: false})}
|
||||
getContentAsync={this.exportModalGetContentHandler}/>
|
||||
}
|
||||
{canDelete &&
|
||||
<DeleteModalDialog
|
||||
stateOwner={this}
|
||||
|
|
|
@ -114,7 +114,7 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
|
|||
<AlignedRow
|
||||
label={t('templateContentHtml')}>
|
||||
<MosaicoHost
|
||||
ref={node => owner.editorNode = node}
|
||||
ref={owner.editorNodeRefHandler}
|
||||
entity={owner.props.entity}
|
||||
initialModel={owner.getFormValue(prefix + 'mosaicoData').model}
|
||||
initialMetadata={owner.getFormValue(prefix + 'mosaicoData').metadata}
|
||||
|
@ -124,6 +124,7 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
|
|||
onSave={::owner.save}
|
||||
canSave={owner.isFormWithoutErrors()}
|
||||
onTestSend={::owner.showTestSendModal}
|
||||
onShowExport={::owner.showExportModal}
|
||||
onFullscreenAsync={::owner.setElementInFullscreen}
|
||||
/>
|
||||
</AlignedRow>,
|
||||
|
@ -137,6 +138,11 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
|
|||
}
|
||||
};
|
||||
},
|
||||
exportContent: async (owner, contentType) => {
|
||||
const {html, metadata, model} = await owner.editorNode.exportState();
|
||||
if (contentType === 'html') return html;
|
||||
return null;
|
||||
},
|
||||
initData: () => ({
|
||||
[prefix + 'mosaicoTemplate']: '',
|
||||
[prefix + 'mosaicoData']: {}
|
||||
|
@ -189,7 +195,7 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
|
|||
<AlignedRow
|
||||
label={t('templateContentHtml')}>
|
||||
<MosaicoHost
|
||||
ref={node => owner.editorNode = node}
|
||||
ref={owner.editorNodeRefHandler}
|
||||
entity={owner.props.entity}
|
||||
initialModel={owner.getFormValue(prefix + 'mosaicoData').model}
|
||||
initialMetadata={owner.getFormValue(prefix + 'mosaicoData').metadata}
|
||||
|
@ -199,6 +205,7 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
|
|||
onSave={::owner.save}
|
||||
canSave={owner.isFormWithoutErrors()}
|
||||
onTestSend={::owner.showTestSendModal}
|
||||
onShowExport={::owner.showExportModal}
|
||||
onFullscreenAsync={::owner.setElementInFullscreen}
|
||||
/>
|
||||
</AlignedRow>,
|
||||
|
@ -212,6 +219,11 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
|
|||
}
|
||||
};
|
||||
},
|
||||
exportContent: async (owner, contentType) => {
|
||||
const {html, metadata, model} = await owner.editorNode.exportState();
|
||||
if (contentType === 'html') return html;
|
||||
return null;
|
||||
},
|
||||
initData: () => ({
|
||||
[prefix + 'mosaicoFsTemplate']: mailtrainConfig.mosaico.fsTemplates[0].key,
|
||||
[prefix + 'mosaicoData']: {}
|
||||
|
@ -267,7 +279,7 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
|
|||
<AlignedRow
|
||||
label={t('templateContentHtml')}>
|
||||
<GrapesJSHost
|
||||
ref={node => owner.editorNode = node}
|
||||
ref={owner.editorNodeRefHandler}
|
||||
entity={owner.props.entity}
|
||||
entityTypeId={entityTypeId}
|
||||
initialSource={owner.getFormValue(prefix + 'grapesJSData').source}
|
||||
|
@ -277,6 +289,7 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
|
|||
onSave={::owner.save}
|
||||
canSave={owner.isFormWithoutErrors()}
|
||||
onTestSend={::owner.showTestSendModal}
|
||||
onShowExport={::owner.showExportModal}
|
||||
onFullscreenAsync={::owner.setElementInFullscreen}
|
||||
/>
|
||||
</AlignedRow>,
|
||||
|
@ -290,6 +303,12 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
|
|||
}
|
||||
};
|
||||
},
|
||||
exportContent: async (owner, contentType) => {
|
||||
const {html, source, style} = await owner.editorNode.exportState();
|
||||
if (contentType === 'html') return html;
|
||||
if (contentType === 'mjml') return source;
|
||||
return null;
|
||||
},
|
||||
initData: () => ({
|
||||
[prefix + 'grapesJSSourceType']: GrapesJSSourceType.MJML,
|
||||
[prefix + 'grapesJSData']: {}
|
||||
|
@ -323,7 +342,7 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
|
|||
<AlignedRow
|
||||
label={t('templateContentHtml')}>
|
||||
<CKEditorHost
|
||||
ref={node => owner.editorNode = node}
|
||||
ref={owner.editorNodeRefHandler}
|
||||
entity={owner.props.entity}
|
||||
initialSource={owner.getFormValue(prefix + 'ckeditor4Data').source}
|
||||
entityTypeId={entityTypeId}
|
||||
|
@ -331,6 +350,7 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
|
|||
onSave={::owner.save}
|
||||
canSave={owner.isFormWithoutErrors()}
|
||||
onTestSend={::owner.showTestSendModal}
|
||||
onShowExport={::owner.showExportModal}
|
||||
onFullscreenAsync={::owner.setElementInFullscreen}
|
||||
/>
|
||||
</AlignedRow>,
|
||||
|
@ -343,6 +363,11 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
|
|||
}
|
||||
};
|
||||
},
|
||||
exportContent: async (owner, contentType) => {
|
||||
const {html, source} = await owner.editorNode.exportState();
|
||||
if (contentType === 'html') return html;
|
||||
return null;
|
||||
},
|
||||
initData: () => ({
|
||||
[prefix + 'ckeditor4Data']: {}
|
||||
}),
|
||||
|
@ -394,7 +419,7 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
|
|||
<AlignedRow
|
||||
label={t('templateContentHtml')}>
|
||||
<CodeEditorHost
|
||||
ref={node => owner.editorNode = node}
|
||||
ref={owner.editorNodeRefHandler}
|
||||
entity={owner.props.entity}
|
||||
entityTypeId={entityTypeId}
|
||||
initialSource={owner.getFormValue(prefix + 'codeEditorData').source}
|
||||
|
@ -403,6 +428,7 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
|
|||
onSave={::owner.save}
|
||||
canSave={owner.isFormWithoutErrors()}
|
||||
onTestSend={::owner.showTestSendModal}
|
||||
onShowExport={::owner.showExportModal}
|
||||
onFullscreenAsync={::owner.setElementInFullscreen}
|
||||
/>
|
||||
</AlignedRow>,
|
||||
|
@ -415,6 +441,11 @@ export function getTemplateTypes(t, prefix = '', entityTypeId = ResourceType.TEM
|
|||
}
|
||||
};
|
||||
},
|
||||
exportContent: async (owner, contentType) => {
|
||||
const {html, source} = await owner.editorNode.exportState();
|
||||
if (contentType === 'html') return html;
|
||||
return null;
|
||||
},
|
||||
initData: () => ({
|
||||
[prefix + 'codeEditorSourceType']: CodeEditorSourceType.HTML,
|
||||
[prefix + 'codeEditorData']: {}
|
||||
|
@ -607,7 +638,7 @@ export function getEditForm(owner, typeKey, prefix = '') {
|
|||
height="400px"
|
||||
mode="text"
|
||||
label={t('templateContentPlainText')}
|
||||
help={<Trans i18nKey="toExtractTheTextFromHtmlClickHerePlease">To extract the text from HTML click <ActionLink onClickAsync={::owner.extractPlainText}>here</ActionLink>. Please note that your existing plaintext in the field above will be overwritten. This feature uses the <a href="http://premailer.dialect.ca/api">Premailer API</a>, a third party service. Their Terms of Service and Privacy Policy apply.</Trans>}
|
||||
help={<Trans i18nKey="toExtractTheTextFromHtmlClickHerePlease">To extract the text from HTML click <ActionLink onClickAsync={::owner.extractPlainText}>here</ActionLink>. Please note that your existing plaintext in the field above will be overwritten. This feature uses the <a href="http://premailer.dialect.ca/api">Premailer API</a>, a third party service. Their Terms of Service and Privacy Policy apply.</Trans>}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -16,7 +16,7 @@ import {
|
|||
Dropdown,
|
||||
Form,
|
||||
FormSendMethod,
|
||||
InputField,
|
||||
InputField, StaticField,
|
||||
TextArea,
|
||||
withForm
|
||||
} from '../../lib/form';
|
||||
|
@ -33,6 +33,7 @@ import {
|
|||
getTemplateTypesOrder
|
||||
} from "./helpers";
|
||||
import {withComponentMixins} from "../../lib/decorator-helpers";
|
||||
import styles from "../../lib/styles.scss";
|
||||
|
||||
@withComponentMixins([
|
||||
withTranslation,
|
||||
|
@ -67,7 +68,7 @@ export default class CUD extends Component {
|
|||
}
|
||||
|
||||
getFormValuesMutator(data) {
|
||||
this.templateTypes[data.type].afterLoad(data);
|
||||
this.templateTypes[data.type].afterLoad(this, data);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
|
@ -132,7 +133,7 @@ export default class CUD extends Component {
|
|||
this.setFormStatusMessage('info', t('saving'));
|
||||
|
||||
const submitResult = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
|
||||
this.templateTypes[data.type].beforeSave(data);
|
||||
this.templateTypes[data.type].beforeSave(this, data);
|
||||
});
|
||||
|
||||
if (submitResult) {
|
||||
|
@ -163,10 +164,6 @@ export default class CUD extends Component {
|
|||
const canDelete = isEdit && this.props.entity.permissions.includes('delete');
|
||||
|
||||
const typeKey = this.getFormValue('type');
|
||||
let form = null;
|
||||
if (typeKey) {
|
||||
form = this.templateTypes[typeKey].getForm(this);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
@ -186,15 +183,22 @@ export default class CUD extends Component {
|
|||
<Form stateOwner={this} onSubmitAsync={::this.submitHandler}>
|
||||
<InputField id="name" label={t('name')}/>
|
||||
<TextArea id="description" label={t('description')}/>
|
||||
<Dropdown id="type" label={t('type')} options={this.typeOptions}/>
|
||||
{isEdit ?
|
||||
<StaticField id="type" className={styles.formDisabled} label={t('type')}>
|
||||
{typeKey && this.templateTypes[typeKey].typeName}
|
||||
</StaticField>
|
||||
:
|
||||
<Dropdown id="type" label={t('type')} options={this.typeOptions}/>
|
||||
}
|
||||
<NamespaceSelect/>
|
||||
|
||||
{form}
|
||||
{isEdit && typeKey && this.templateTypes[typeKey].getForm(this)}
|
||||
|
||||
<ButtonRow>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('save')}/>
|
||||
<Button type="submit" className="btn-primary" icon="check" label={t('Save and leave')} onClickAsync={async () => this.submitHandler(true)}/>
|
||||
{canDelete && <LinkButton className="btn-danger" icon="trash-alt" label={t('delete')} to={`/templates/mosaico/${this.props.entity.id}/delete`}/>}
|
||||
{isEdit && typeKey && this.templateTypes[typeKey].getButtons(this)}
|
||||
</ButtonRow>
|
||||
</Form>
|
||||
</div>
|
||||
|
|
|
@ -1,17 +1,67 @@
|
|||
'use strict';
|
||||
|
||||
import React from "react";
|
||||
import {ACEEditor} from "../../lib/form";
|
||||
import {ACEEditor, Button} from "../../lib/form";
|
||||
import 'brace/mode/html'
|
||||
import 'brace/mode/xml'
|
||||
import {ContentModalDialog} from "../../lib/modals";
|
||||
import mjml2html from "./mjml-mosaico";
|
||||
import styles from "../../lib/styles.scss";
|
||||
|
||||
|
||||
export function getTemplateTypesOrder() {
|
||||
return [/* 'mjml' , */ 'html'];
|
||||
return [ 'mjml' , 'html'];
|
||||
}
|
||||
|
||||
export function getTemplateTypes(t) {
|
||||
const templateTypes = {};
|
||||
|
||||
function getMjml(owner) {
|
||||
return owner.getFormValue('mjml') || '';
|
||||
}
|
||||
|
||||
function generateHtmlFromMjml(owner) {
|
||||
const mjml = getMjml(owner);
|
||||
|
||||
try {
|
||||
const res = mjml2html(mjml);
|
||||
return res.html;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function validateMjml(owner) {
|
||||
const mjml = getMjml(owner);
|
||||
|
||||
try {
|
||||
const res = mjml2html(mjml);
|
||||
|
||||
if (res.errors.length > 0) {
|
||||
const msg = (
|
||||
<div>
|
||||
<p>{t('Invalid MJML')}</p>
|
||||
<ul className={styles.errorsList}>
|
||||
{res.errors.map((err, idx) => <li key={idx}>Line {err.line}: {err.message}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
||||
owner.setFormStatusMessage('danger', msg);
|
||||
} else {
|
||||
owner.setFormStatusMessage('success', t('MJML is valid.'));
|
||||
}
|
||||
} catch (err) {
|
||||
owner.setFormStatusMessage('danger', t('Invalid MJML.'));
|
||||
}
|
||||
}
|
||||
|
||||
function setExportModalVisibility(owner, visible) {
|
||||
owner.setState({
|
||||
exportModalVisible: visible
|
||||
})
|
||||
}
|
||||
|
||||
function clearBeforeSend(data) {
|
||||
delete data.html;
|
||||
delete data.mjml;
|
||||
|
@ -20,31 +70,44 @@ export function getTemplateTypes(t) {
|
|||
templateTypes.html = {
|
||||
typeName: t('html'),
|
||||
getForm: owner => <ACEEditor id="html" height="700px" mode="html" label={t('templateContent')}/>,
|
||||
afterLoad: data => {
|
||||
afterLoad: (owner, data) => {
|
||||
data.html = data.data.html;
|
||||
},
|
||||
beforeSave: (data) => {
|
||||
beforeSave: (owner, data) => {
|
||||
data.data = {
|
||||
html: data.html
|
||||
};
|
||||
|
||||
clearBeforeSend(data);
|
||||
},
|
||||
getButtons: owner => null
|
||||
};
|
||||
|
||||
templateTypes.mjml = {
|
||||
typeName: t('mjml'),
|
||||
getForm: owner => <ACEEditor id="html" height="700px" mode="xml" label={t('templateContent')}/>,
|
||||
afterLoad: data => {
|
||||
getForm: owner => (
|
||||
<>
|
||||
<ContentModalDialog visible={!!owner.state.exportModalVisible} title={t('HTML')} getContentAsync={async () => generateHtmlFromMjml(owner)} onHide={() => setExportModalVisibility(owner, false)}/>
|
||||
<ACEEditor id="mjml" height="700px" mode="xml" label={t('templateContent')}/>
|
||||
</>
|
||||
),
|
||||
afterLoad: (owner, data) => {
|
||||
data.mjml = data.data.mjml;
|
||||
},
|
||||
beforeSave: (data) => {
|
||||
beforeSave: (owner, data) => {
|
||||
data.data = {
|
||||
mjml: data.mjml
|
||||
mjml: data.mjml,
|
||||
html: generateHtmlFromMjml(owner)
|
||||
};
|
||||
|
||||
clearBeforeSend(data);
|
||||
},
|
||||
getButtons: owner => (
|
||||
<>
|
||||
<Button className="btn-success" icon="check-circle" label={t('Validate')} onClickAsync={async () => validateMjml(owner)}/>
|
||||
<Button className="btn-success" icon="file-code" label={t('Show HTML')} onClickAsync={async () => setExportModalVisibility(owner, true)}/>
|
||||
</>
|
||||
)
|
||||
};
|
||||
|
||||
return templateTypes;
|
||||
|
|
467
client/src/templates/mosaico/mjml-mosaico.js
Normal file
467
client/src/templates/mosaico/mjml-mosaico.js
Normal file
|
@ -0,0 +1,467 @@
|
|||
'use strict';
|
||||
|
||||
import htmlparser from 'htmlparser2'
|
||||
import min from 'lodash/min';
|
||||
import mjml, {MJML, BodyComponent, HeadComponent} from "../../lib/mjml";
|
||||
import shortid from "shortid";
|
||||
|
||||
function getId() {
|
||||
return shortid.generate();
|
||||
}
|
||||
|
||||
const parents = [];
|
||||
|
||||
function pushParent(parent) {
|
||||
parents.push(parent);
|
||||
}
|
||||
|
||||
function popParent() {
|
||||
parents.pop();
|
||||
}
|
||||
|
||||
function getParent() {
|
||||
if (parents.length > 0) {
|
||||
return parents[parents.length - 1];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function handleMosaicoEditable(block, src) {
|
||||
let newSrc = src;
|
||||
let offset = 0;
|
||||
|
||||
const parser = new htmlparser.Parser(
|
||||
{
|
||||
onopentag: (name, attrs) => {
|
||||
const fragment = src.substring(parser.startIndex, parser.endIndex);
|
||||
|
||||
const tagAttrsRe = RegExp(`(<\\s*${name})((?:\\s+[a-z0-9-_]+\\s*=\\s*"[^"]*")*)`, 'i');
|
||||
const [ , tagStr, attrsStr] = fragment.match(tagAttrsRe);
|
||||
|
||||
const attrsRe = new RegExp(/([a-z0-9-_]+)\s*=\s*"([^"]*)"/g);
|
||||
const attrsMatches = attrsStr.matchAll(attrsRe);
|
||||
|
||||
let newFragment = tagStr;
|
||||
|
||||
|
||||
for (const attrMatch of attrsMatches) {
|
||||
if (attrMatch[1] === 'mosaico-editable') {
|
||||
const propertyId = attrMatch[2];
|
||||
block.addMosaicoProperty(propertyId);
|
||||
|
||||
newFragment += ` data-ko-editable="${propertyId}"`;
|
||||
} else {
|
||||
newFragment += ` ${attrMatch[0]}`;
|
||||
}
|
||||
}
|
||||
|
||||
newSrc = newSrc.substring(0, parser.startIndex + offset) + newFragment + newSrc.substring(parser.endIndex + offset);
|
||||
offset += newFragment.length - fragment.length;
|
||||
}
|
||||
},
|
||||
{
|
||||
recognizeCDATA: true,
|
||||
decodeEntities: false,
|
||||
recognizeSelfClosing: true,
|
||||
lowerCaseAttributeNames: false,
|
||||
}
|
||||
);
|
||||
|
||||
parser.write(src);
|
||||
parser.end();
|
||||
|
||||
return newSrc;
|
||||
}
|
||||
|
||||
|
||||
class MjMosaicoProperty extends HeadComponent {
|
||||
static endingTag = true
|
||||
|
||||
static allowedAttributes = {
|
||||
'property-id': 'string',
|
||||
label: 'string',
|
||||
widget: 'string',
|
||||
}
|
||||
|
||||
handler() {
|
||||
const { add } = this.context;
|
||||
|
||||
add('style', ` @supports -ko-blockdefs { ${this.getAttribute('property-id')} { label: ${this.getAttribute('label')}; widget: ${this.getAttribute('widget')} } }`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class MjMosaicoContainer extends BodyComponent {
|
||||
static endingTag = false
|
||||
|
||||
render() {
|
||||
return `
|
||||
<div data-ko-container="main" data-ko-wrap="false">
|
||||
${this.renderChildren()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class MjMosaicoBlock extends BodyComponent {
|
||||
constructor(initialDatas = {}) {
|
||||
super(initialDatas);
|
||||
|
||||
const blockId = this.getAttribute('block-id');
|
||||
this.blockId = blockId || `block_${getId()}`;
|
||||
this.mosaicoProperties = [];
|
||||
}
|
||||
|
||||
componentHeadStyle = breakpoint => {
|
||||
const propertiesOut = this.mosaicoProperties.length > 0 ? `; properties: ${this.mosaicoProperties.join(' ')}`: '';
|
||||
|
||||
return `
|
||||
@supports -ko-blockdefs {
|
||||
${this.blockId} { label: ${this.getAttribute('label')}${propertiesOut} }
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
static endingTag = false
|
||||
|
||||
static allowedAttributes = {
|
||||
'block-id': 'string',
|
||||
'label': 'string'
|
||||
}
|
||||
|
||||
addMosaicoProperty(property) {
|
||||
this.mosaicoProperties.push(property);
|
||||
}
|
||||
|
||||
render() {
|
||||
pushParent(this);
|
||||
const result = `
|
||||
<div
|
||||
${this.htmlAttributes({
|
||||
class: this.getAttribute('css-class'),
|
||||
'data-ko-block': this.blockId
|
||||
})}
|
||||
>
|
||||
${this.renderChildren()}
|
||||
</div>
|
||||
`;
|
||||
popParent();
|
||||
|
||||
return handleMosaicoEditable(this, result);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class MjMosaicoInnerBlock extends BodyComponent {
|
||||
constructor(initialDatas = {}) {
|
||||
super(initialDatas);
|
||||
|
||||
const blockId = this.getAttribute('block-id');
|
||||
this.blockId = blockId || `block_${getId()}`;
|
||||
this.mosaicoProperties = [];
|
||||
}
|
||||
|
||||
componentHeadStyle = breakpoint => {
|
||||
const propertiesOut = this.mosaicoProperties.length > 0 ? `; properties: ${this.mosaicoProperties.join(' ')}`: '';
|
||||
|
||||
return `
|
||||
@supports -ko-blockdefs {
|
||||
${this.blockId} { label: ${this.getAttribute('label')}${propertiesOut} }
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
static endingTag = false
|
||||
|
||||
static allowedAttributes = {
|
||||
'block-id': 'string',
|
||||
'label': 'string'
|
||||
}
|
||||
|
||||
addMosaicoProperty(property) {
|
||||
this.mosaicoProperties.push(property);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { children } = this.props;
|
||||
|
||||
pushParent(this);
|
||||
const result = `
|
||||
<div
|
||||
${this.htmlAttributes({
|
||||
class: this.getAttribute('css-class'),
|
||||
'data-ko-block': this.blockId
|
||||
})}
|
||||
>
|
||||
<table
|
||||
${this.htmlAttributes({
|
||||
border: '0',
|
||||
cellpadding: '0',
|
||||
cellspacing: '0',
|
||||
role: 'presentation',
|
||||
style: 'table',
|
||||
width: '100%',
|
||||
})}
|
||||
>
|
||||
${this.renderChildren(children, {
|
||||
renderer: component => component.constructor.isRawElement() ? component.render() : `
|
||||
<tr>
|
||||
<td
|
||||
${component.htmlAttributes({
|
||||
align: component.getAttribute('align'),
|
||||
'vertical-align': component.getAttribute('vertical-align'),
|
||||
class: component.getAttribute('css-class'),
|
||||
style: {
|
||||
background: component.getAttribute('container-background-color'),
|
||||
'font-size': '0px',
|
||||
padding: component.getAttribute('padding'),
|
||||
'padding-top': component.getAttribute('padding-top'),
|
||||
'padding-right': component.getAttribute('padding-right'),
|
||||
'padding-bottom': component.getAttribute('padding-bottom'),
|
||||
'padding-left': component.getAttribute('padding-left'),
|
||||
'word-break': 'break-word',
|
||||
},
|
||||
})}
|
||||
>
|
||||
${component.render()}
|
||||
</td>
|
||||
</tr>
|
||||
`,
|
||||
})}
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
popParent();
|
||||
|
||||
return handleMosaicoEditable(this, result);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Adapted from https://github.com/mjmlio/mjml/blob/master/packages/mjml-image/src/index.js
|
||||
class MjMosaicoImage extends BodyComponent {
|
||||
constructor(initialDatas = {}) {
|
||||
super(initialDatas);
|
||||
|
||||
const propertyId = this.getAttribute('property-id');
|
||||
this.propertyId = propertyId || `image_${getId()}`;
|
||||
|
||||
getParent().addMosaicoProperty(this.propertyId);
|
||||
}
|
||||
|
||||
static tagOmission = true
|
||||
|
||||
static allowedAttributes = {
|
||||
'property-id': 'string',
|
||||
'placeholder-height': 'integer',
|
||||
'href-editable': 'boolean',
|
||||
alt: 'string',
|
||||
href: 'string',
|
||||
name: 'string',
|
||||
title: 'string',
|
||||
rel: 'string',
|
||||
align: 'enum(left,center,right)',
|
||||
border: 'string',
|
||||
'border-bottom': 'string',
|
||||
'border-left': 'string',
|
||||
'border-right': 'string',
|
||||
'border-top': 'string',
|
||||
'border-radius': 'unit(px,%){1,4}',
|
||||
'container-background-color': 'color',
|
||||
'fluid-on-mobile': 'boolean',
|
||||
padding: 'unit(px,%){1,4}',
|
||||
'padding-bottom': 'unit(px,%)',
|
||||
'padding-left': 'unit(px,%)',
|
||||
'padding-right': 'unit(px,%)',
|
||||
'padding-top': 'unit(px,%)',
|
||||
target: 'string',
|
||||
width: 'unit(px)',
|
||||
height: 'unit(px)',
|
||||
}
|
||||
|
||||
static defaultAttributes = {
|
||||
align: 'center',
|
||||
border: '0',
|
||||
height: 'auto',
|
||||
padding: '10px 25px',
|
||||
target: '_blank',
|
||||
'placeholder-height': '400',
|
||||
'href-editable': false
|
||||
}
|
||||
|
||||
getStyles() {
|
||||
const width = this.getContentWidth();
|
||||
const fullWidth = this.getAttribute('full-width') === 'full-width';
|
||||
|
||||
return {
|
||||
img: {
|
||||
border: this.getAttribute('border'),
|
||||
'border-left': this.getAttribute('left'),
|
||||
'border-right': this.getAttribute('right'),
|
||||
'border-top': this.getAttribute('top'),
|
||||
'border-bottom': this.getAttribute('bottom'),
|
||||
'border-radius': this.getAttribute('border-radius'),
|
||||
display: 'block',
|
||||
outline: 'none',
|
||||
'text-decoration': 'none',
|
||||
height: this.getAttribute('height'),
|
||||
'min-width': fullWidth ? '100%' : null,
|
||||
width: '100%',
|
||||
'max-width': fullWidth ? '100%' : null,
|
||||
},
|
||||
td: {
|
||||
width: fullWidth ? null : width,
|
||||
},
|
||||
table: {
|
||||
'min-width': fullWidth ? '100%' : null,
|
||||
'max-width': fullWidth ? '100%' : null,
|
||||
width: fullWidth ? width : null,
|
||||
'border-collapse': 'collapse',
|
||||
'border-spacing': '0px',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
getContentWidth() {
|
||||
const width = this.getAttribute('width')
|
||||
? parseInt(this.getAttribute('width'), 10)
|
||||
: Infinity;
|
||||
|
||||
const {box} = this.getBoxWidths();
|
||||
|
||||
return min([box, width])
|
||||
}
|
||||
|
||||
renderImage() {
|
||||
const height = this.getAttribute('height');
|
||||
|
||||
const img = `
|
||||
<img
|
||||
${this.htmlAttributes({
|
||||
alt: this.getAttribute('alt'),
|
||||
height: height && (height === 'auto' ? undefined : parseInt(height, 10)),
|
||||
style: 'img',
|
||||
title: this.getAttribute('title'),
|
||||
width: this.getContentWidth(),
|
||||
|
||||
'data-ko-editable': this.propertyId + '.src',
|
||||
'data-ko-placeholder-height': this.getAttribute('placeholder-height'),
|
||||
src: "[PLACEHOLDER]"
|
||||
})}
|
||||
/>
|
||||
`;
|
||||
|
||||
if (this.getAttribute('href-editable')) {
|
||||
return `
|
||||
<a
|
||||
${this.htmlAttributes({
|
||||
'data-ko-link': this.propertyId + '.url',
|
||||
href: this.getAttribute('href') || '',
|
||||
target: this.getAttribute('target'),
|
||||
rel: this.getAttribute('rel'),
|
||||
name: this.getAttribute('name'),
|
||||
})}
|
||||
>
|
||||
${img}
|
||||
</a>
|
||||
`;
|
||||
} else if (this.getAttribute('href')) {
|
||||
return `
|
||||
<a
|
||||
${this.htmlAttributes({
|
||||
href: this.getAttribute('href'),
|
||||
target: this.getAttribute('target'),
|
||||
rel: this.getAttribute('rel'),
|
||||
name: this.getAttribute('name'),
|
||||
})}
|
||||
>
|
||||
${img}
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
return img
|
||||
}
|
||||
|
||||
headStyle = breakpoint => `
|
||||
@media only screen and (max-width:${breakpoint}) {
|
||||
table.full-width-mobile { width: 100% !important; }
|
||||
td.full-width-mobile { width: auto !important; }
|
||||
}
|
||||
`
|
||||
|
||||
render() {
|
||||
return `
|
||||
<table
|
||||
${this.htmlAttributes({
|
||||
border: '0',
|
||||
cellpadding: '0',
|
||||
cellspacing: '0',
|
||||
role: 'presentation',
|
||||
style: 'table',
|
||||
class:
|
||||
this.getAttribute('fluid-on-mobile')
|
||||
? 'full-width-mobile'
|
||||
: null,
|
||||
})}
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td ${this.htmlAttributes({
|
||||
style: 'td',
|
||||
class:
|
||||
this.getAttribute('fluid-on-mobile')
|
||||
? 'full-width-mobile'
|
||||
: null,
|
||||
})}>
|
||||
${this.renderImage()}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
const mjmlInstance = new MJML();
|
||||
|
||||
mjmlInstance.registerComponent(MjMosaicoContainer);
|
||||
mjmlInstance.registerComponent(MjMosaicoBlock);
|
||||
mjmlInstance.registerComponent(MjMosaicoInnerBlock);
|
||||
mjmlInstance.registerComponent(MjMosaicoImage);
|
||||
mjmlInstance.registerComponent(MjMosaicoProperty);
|
||||
|
||||
mjmlInstance.registerDependencies({
|
||||
'mj-mosaico-container': ['mj-mosaico-block', 'mj-mosaico-inner-block'],
|
||||
'mj-body': ['mj-mosaico-container', 'mj-mosaico-block'],
|
||||
'mj-section': ['mj-mosaico-container', 'mj-mosaico-block'],
|
||||
'mj-column': ['mj-mosaico-container', 'mj-mosaico-inner-block', 'mj-mosaico-image'],
|
||||
'mj-mosaico-block': ['mj-section', 'mj-column'],
|
||||
'mj-mosaico-inner-block': ['mj-mosaico-image', 'mj-divider', 'mj-text', 'mj-image', 'mj-table']
|
||||
});
|
||||
|
||||
mjmlInstance.addToHeader(`
|
||||
<style type="text/css">
|
||||
@supports -ko-blockdefs {
|
||||
text { label: Paragraph; widget: text }
|
||||
image { label: Image; properties: src url alt }
|
||||
link { label: Link; properties: text url }
|
||||
url { label: Link; widget: url }
|
||||
src { label: Image; widget: src }
|
||||
alt {
|
||||
label: Alternative Text;
|
||||
widget: text;
|
||||
help: Alternative text will be shown on email clients that does not download image automatically;
|
||||
}
|
||||
|
||||
template { label: Page }
|
||||
}
|
||||
</style>
|
||||
`);
|
||||
|
||||
export default function mjml2html(src) {
|
||||
return mjmlInstance.mjml2html(src);
|
||||
}
|
|
@ -1 +1 @@
|
|||
Subproject commit 4d53d4b030273f25062fcc3c2328987d5a39cffc
|
||||
Subproject commit 04e595584cf3d78e954475d4afcd94396d53980a
|
|
@ -229,7 +229,7 @@ function getRouter(appType) {
|
|||
}
|
||||
|
||||
} else {
|
||||
width = sanitizeSize(width, 1, 2048, 600, false);
|
||||
width = sanitizeSize(width, 1, 2048, 600, true);
|
||||
height = sanitizeSize(height, 1, 2048, 300, true);
|
||||
|
||||
let filePath;
|
||||
|
|
Loading…
Reference in a new issue