From ad9f5d16bf556dc29d2ae6a2505af7b3d69287c1 Mon Sep 17 00:00:00 2001 From: Tomas Bures Date: Wed, 3 Apr 2019 12:13:49 +0200 Subject: [PATCH] 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. --- .gitmodules | 2 +- client/package.json | 6 +- client/src/campaigns/Content.js | 30 +- client/src/lib/bootstrap-components.js | 28 +- client/src/lib/decorator-helpers.js | 8 +- client/src/lib/form.js | 71 ++- client/src/lib/mjml-mosaico.js | 81 ---- client/src/lib/mjml.js | 77 +++ client/src/lib/modals.js | 89 +++- client/src/lib/page-common.js | 416 +++++++++++++---- client/src/lib/page.js | 254 ++++------ client/src/lib/sandboxed-ckeditor.js | 7 +- client/src/lib/sandboxed-codeeditor-root.js | 51 +- client/src/lib/sandboxed-codeeditor.js | 8 +- client/src/lib/sandboxed-grapesjs-root.js | 31 +- client/src/lib/sandboxed-grapesjs.js | 10 +- client/src/lib/sandboxed-mosaico-root.js | 47 +- client/src/lib/sandboxed-mosaico.js | 8 +- client/src/lib/styles.scss | 2 +- client/src/lib/untrusted.js | 3 +- client/src/root.js | 6 +- client/src/templates/CUD.js | 69 +-- client/src/templates/helpers.js | 43 +- client/src/templates/mosaico/CUD.js | 22 +- client/src/templates/mosaico/helpers.js | 79 +++- client/src/templates/mosaico/mjml-mosaico.js | 467 +++++++++++++++++++ mvis/ivis-core | 2 +- server/routes/sandboxed-mosaico.js | 2 +- 28 files changed, 1381 insertions(+), 538 deletions(-) delete mode 100644 client/src/lib/mjml-mosaico.js create mode 100644 client/src/lib/mjml.js create mode 100644 client/src/templates/mosaico/mjml-mosaico.js diff --git a/.gitmodules b/.gitmodules index 01b4e6d7..a3472fb0 100644 --- a/.gitmodules +++ b/.gitmodules @@ -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 diff --git a/client/package.json b/client/package.json index 8530c2f0..c1b2a734 100644 --- a/client/package.json +++ b/client/package.json @@ -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": { diff --git a/client/src/campaigns/Content.js b/client/src/campaigns/Content.js index 64b00bac..b8354d1c 100644 --- a/client/src/campaigns/Content.js +++ b/client/src/campaigns/Content.js @@ -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} /> + this.setState({showExportModal: false})} + getContentAsync={this.exportModalGetContentHandler} + /> {t('editCustomContent')} diff --git a/client/src/lib/bootstrap-components.js b/client/src/lib/bootstrap-components.js index 2410b4f0..de13ee41 100644 --- a/client/src/lib/bootstrap-components.js +++ b/client/src/lib/bootstrap-components.js @@ -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 =
{this.props.children}
-
- {buttons} -
+ {buttons && +
+ {buttons} +
+ } diff --git a/client/src/lib/decorator-helpers.js b/client/src/lib/decorator-helpers.js index 7ac9ac24..628cb1ee 100644 --- a/client/src/lib/decorator-helpers.js +++ b/client/src/lib/decorator-helpers.js @@ -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 diff --git a/client/src/lib/form.js b/client/src/lib/form.js index 747a4801..4e9350f6 100644 --- a/client/src/lib/form.js +++ b/client/src/lib/form.js @@ -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 { {!props.noStatus && statusMessageText && -

{statusMessageText}

+
{statusMessageText}
} @@ -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, -
+ <>
owner.updateFormValue(id, evt.target.value)}/>
@@ -680,7 +662,7 @@ class DatePicker extends Component { />
} -
+ ); } } @@ -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) { diff --git a/client/src/lib/mjml-mosaico.js b/client/src/lib/mjml-mosaico.js deleted file mode 100644 index 0fa1ae3f..00000000 --- a/client/src/lib/mjml-mosaico.js +++ /dev/null @@ -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 ` -
-

- - - ${this.getContent()} - - -

-
- ` - } -} - - -export function registerComponents() { - registerComponent(MjBasicComponent) -} - diff --git a/client/src/lib/mjml.js b/client/src/lib/mjml.js new file mode 100644 index 00000000..ec42c035 --- /dev/null +++ b/client/src/lib/mjml.js @@ -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); +} + + + + diff --git a/client/src/lib/modals.js b/client/src/lib/modals.js index 438be7d2..4055cac3 100644 --- a/client/src/lib/modals.js +++ b/client/src/lib/modals.js @@ -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 (

{t('cannoteDeleteNameDueToTheFollowing', {name})}

-
    +
      {err.data.dependencies.map(dep => dep.link ?
    • {entityTypeLabels[dep.entityTypeId](t)}: {dep.name}
    • @@ -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 ( + + ); + } +} diff --git a/client/src/lib/page-common.js b/client/src/lib/page-common.js index aac29542..13fe2248 100644 --- a/client/src/lib/page-common.js +++ b/client/src/lib/page-common.js @@ -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 ; + } +} + + +@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 + }; + + return ( + {routes.map(x => _renderRoute(x))} + ); + + } else { + return this.props.loadingMessageFn(); + } + }; + + return ; + } +} + +export function renderRoute(route, panelRouteCtor, loadingMessageFn, flashMessage, props) { + if (route.structure) { + return ; + + } else if (!route.panelRender && !route.panelComponent && route.link) { + return ; + + } else { + const PanelRoute = panelRouteCtor; + return ; + } + } export const SectionContentContext = React.createContext(null); diff --git a/client/src/lib/page.js b/client/src/lib/page.js index 995b58d7..4aed1370 100644 --- a/client/src/lib/page.js +++ b/client/src/lib/page.js @@ -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 ( +
      + {t('loading')} +
      + ); +} + +function renderFrameWithContent(t, panelInFullScreen, showSidebar, primaryMenu, secondaryMenu, content) { + if (panelInFullScreen) { + return ( +
      +
      +
      + {content} +
      +
      +
      + ); + + } else { + return ( +
      +
      + +
      + +
      + {showSidebar && +
      + {secondaryMenu} +
      + } +
      + {content} +
      +
      + + +
      + ); + } +} + + @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 ; - - } else { + const render = resolved => { let primaryMenu = null; let secondaryMenu = null; let content = null; @@ -331,71 +319,21 @@ class RouteContent extends Component { } } else { - content = ( -
      - {t('loading')} -
      - ); + content = getLoadingMessage(t); } - if (panelInFullScreen) { - return ( -
      -
      -
      - {content} -
      -
      -
      - ); + return renderFrameWithContent(t, panelInFullScreen, showSidebar, primaryMenu, secondaryMenu, content); + }; - } else { - return ( -
      -
      - -
      - -
      - {showSidebar && -
      - {secondaryMenu} -
      - } -
      - {content} -
      -
      - - -
      - ); - } - } + return ; } } @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 = {this.state.flashMessageText}; - } + const t = this.props.t; - const render = props => ; + const render = props => { + let flashMessage; + if (this.state.flashMessageText) { + flashMessage = {this.state.flashMessageText}; + } - return + return renderRoute( + route, + PanelRoute, + () => renderFrameWithContent(t,false, false, null, null, getLoadingMessage(this.props.t)), + flashMessage, + props + ); + }; + + return } render() { - let routes = getRoutes('', {}, [], this.props.structure, [], null, null); + const routes = getRoutes(this.props.structure); return ( diff --git a/client/src/lib/sandboxed-ckeditor.js b/client/src/lib/sandboxed-ckeditor.js index 19259dd2..9e1229d9 100644 --- a/client/src/lib/sandboxed-ckeditor.js +++ b/client/src/lib/sandboxed-ckeditor.js @@ -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 {
- this.contentNode = node} className={styles.host} singleToken={true} contentProps={editorData} contentSrc="ckeditor/editor" tokenMethod="ckeditor" tokenParams={editorData}/> +
); } diff --git a/client/src/lib/sandboxed-codeeditor-root.js b/client/src/lib/sandboxed-codeeditor-root.js index 9d8e7528..e0803641 100644 --- a/client/src/lib/sandboxed-codeeditor-root.js +++ b/client/src/lib/sandboxed-codeeditor-root.js @@ -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) { diff --git a/client/src/lib/sandboxed-codeeditor.js b/client/src/lib/sandboxed-codeeditor.js index 7bb21fe0..4f82144f 100644 --- a/client/src/lib/sandboxed-codeeditor.js +++ b/client/src/lib/sandboxed-codeeditor.js @@ -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 { {this.state.wrap ? 'WRAP': 'NOWRAP'} {this.props.canSave ? : } + this.props.onShowExport('html', 'HTML')} title={t('Show HTML')}> - this.contentNode = node} className={styles.host} singleToken={true} contentProps={editorData} contentSrc="codeeditor/editor" tokenMethod="codeeditor" tokenParams={tokenData}/> + ); } diff --git a/client/src/lib/sandboxed-grapesjs-root.js b/client/src/lib/sandboxed-grapesjs-root.js index e0ad2b6c..bbb6608e 100644 --- a/client/src/lib/sandboxed-grapesjs-root.js +++ b/client/src/lib/sandboxed-grapesjs-root.js @@ -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"; diff --git a/client/src/lib/sandboxed-grapesjs.js b/client/src/lib/sandboxed-grapesjs.js index b7e4bdb9..df627e3d 100644 --- a/client/src/lib/sandboxed-grapesjs.js +++ b/client/src/lib/sandboxed-grapesjs.js @@ -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 { - this.contentNode = node} className={styles.host} singleToken={true} contentProps={editorData} contentSrc="grapesjs/editor" tokenMethod="grapesjs" tokenParams={tokenData}/> + ); } diff --git a/client/src/lib/sandboxed-mosaico-root.js b/client/src/lib/sandboxed-mosaico-root.js index 6e47375d..5483d86b 100644 --- a/client/src/lib/sandboxed-mosaico-root.js +++ b/client/src/lib/sandboxed-mosaico-root.js @@ -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 + + + ... + +
+ ... +
+

XXX

+
+ ... +
+ */ + 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) }; diff --git a/client/src/lib/sandboxed-mosaico.js b/client/src/lib/sandboxed-mosaico.js index 6a9281fe..1380ee41 100644 --- a/client/src/lib/sandboxed-mosaico.js +++ b/client/src/lib/sandboxed-mosaico.js @@ -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 { - this.contentNode = node} className={styles.host} singleToken={true} contentProps={editorData} contentSrc="mosaico/editor" tokenMethod="mosaico" tokenParams={tokenData}/> + ); } diff --git a/client/src/lib/styles.scss b/client/src/lib/styles.scss index d506ff33..8d2daff0 100644 --- a/client/src/lib/styles.scss +++ b/client/src/lib/styles.scss @@ -168,7 +168,7 @@ text-decoration: $link-decoration; } -.dependenciesList { +.errorsList { margin-bottom: 0px; } diff --git a/client/src/lib/untrusted.js b/client/src/lib/untrusted.js index 10293a1d..2e8ebc5a 100644 --- a/client/src/lib/untrusted.js +++ b/client/src/lib/untrusted.js @@ -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 - + ); } } diff --git a/client/src/root.js b/client/src/root.js index 1bc11f89..5c855a05 100644 --- a/client/src/root.js +++ b/client/src/root.js @@ -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, diff --git a/client/src/templates/CUD.js b/client/src/templates/CUD.js index 89830fdf..b625caab 100644 --- a/client/src/templates/CUD.js +++ b/client/src/templates/CUD.js @@ -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 && + this.setState({showExportModal: false})} + getContentAsync={this.exportModalGetContentHandler}/> + } {canDelete && 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} /> , @@ -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 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} /> , @@ -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 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} /> , @@ -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 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} /> , @@ -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 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} /> , @@ -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={To extract the text from HTML click here. Please note that your existing plaintext in the field above will be overwritten. This feature uses the Premailer API, a third party service. Their Terms of Service and Privacy Policy apply.} + help={To extract the text from HTML click here. Please note that your existing plaintext in the field above will be overwritten. This feature uses the Premailer API, a third party service. Their Terms of Service and Privacy Policy apply.} /> ); diff --git a/client/src/templates/mosaico/CUD.js b/client/src/templates/mosaico/CUD.js index a7e169cc..776dc184 100644 --- a/client/src/templates/mosaico/CUD.js +++ b/client/src/templates/mosaico/CUD.js @@ -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 (
@@ -186,15 +183,22 @@ export default class CUD extends Component {