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
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue