diff --git a/package.json b/package.json
index 16e630b1..7d211aad 100644
--- a/package.json
+++ b/package.json
@@ -40,12 +40,15 @@
"codemirror": "^5.49.2",
"electron-debug": "^3.0.1",
"electron-json-storage": "^4.1.8",
+ "erb": "^1.3.0-hf.1",
"electron-updater": "4.3.9",
"es6-promisify": "^6.0.2",
"fix-path": "^2.1.0",
"fuse.js": "^3.4.5",
+ "handlebars": "4.7.7",
"immutable": "^4.0.0-rc.12",
"js-beautify": "^1.10.2",
+ "js-yaml": "^3.14.0",
"mjml": "^4.12.0",
"mjml-migrate": "^4.12.0",
"ncp": "^2.0.0",
diff --git a/src/actions/settings.js b/src/actions/settings.js
index b242561f..54be4ada 100644
--- a/src/actions/settings.js
+++ b/src/actions/settings.js
@@ -60,6 +60,7 @@ export function loadSettings() {
desktop: 650,
},
snippets: [],
+ templating: [],
})
// clean old format for TargetEmails
diff --git a/src/components/FilesList/FilePreview.js b/src/components/FilesList/FilePreview.js
index 6c211dd3..1029ff9b 100644
--- a/src/components/FilesList/FilePreview.js
+++ b/src/components/FilesList/FilePreview.js
@@ -2,24 +2,66 @@ import React, { Component } from 'react'
import cx from 'classnames'
import { Motion, spring } from 'react-motion'
import { connect } from 'react-redux'
+import isEqual from 'lodash/isEqual'
+import find from 'lodash/find'
import Button from 'components/Button'
import Iframe from 'components/Iframe'
import { updateSettings } from 'actions/settings'
+import { addAlert } from 'reducers/alerts'
+import { compile } from 'helpers/preview-content'
export default connect(
state => ({
preview: state.preview,
previewSize: state.settings.get('previewSize'),
+ templating: state.settings.get('templating'),
}),
{
updateSettings,
+ addAlert,
},
)(
class FilePreview extends Component {
+ state = {
+ content: '',
+ }
+
+ componentDidUpdate(prevProps) {
+ const prev = {
+ engine: this.getProjectVariables(prevProps).engine,
+ variables: this.getProjectVariables(prevProps).variables,
+ raw: prevProps.preview ? prevProps.preview.content : '',
+ }
+
+ const current = {
+ engine: this.getProjectVariables(this.props).engine,
+ variables: this.getProjectVariables(this.props).variables,
+ raw: this.props.preview ? this.props.preview.content : '',
+ }
+
+ !isEqual(prev, current) && this.updateContent(current)
+ }
+
+ getProjectVariables = props => {
+ const { templating, iframeBase } = props
+ return find(templating, { projectPath: iframeBase }) || {}
+ }
+
+ updateContent = async params => {
+ try {
+ const content = await compile(params)
+ this.setState({ content })
+ } catch (err) {
+ this.props.addAlert(`[Template Compiler Error] ${err.message}`, 'error')
+ throw new Error(err)
+ }
+ }
+
render() {
const { preview, disablePointer, previewSize, onSetSize, iframeBase } = this.props
+ const { content } = this.state
return (
@@ -55,7 +97,7 @@ export default connect(
{preview ? (
preview.type === 'html' ? (
-
+
) : preview.type === 'image' ? (
) : null
diff --git a/src/components/FilesList/styles.scss b/src/components/FilesList/styles.scss
index 0b37ae36..d3e67efb 100644
--- a/src/components/FilesList/styles.scss
+++ b/src/components/FilesList/styles.scss
@@ -163,3 +163,22 @@
color: $yellow;
padding: 20px;
}
+
+.FilePreview--settings {
+ &-button {
+ position: absolute;
+ top: 0;
+ right: 10px;
+ pointer-events: auto;
+ }
+
+ &-modal {
+ .bordered {
+ border: 1px solid $evenLighterGrey;
+ }
+
+ .yaml-invalid {
+ color: $red;
+ }
+ }
+}
diff --git a/src/helpers/preview-content.js b/src/helpers/preview-content.js
new file mode 100644
index 00000000..c161990c
--- /dev/null
+++ b/src/helpers/preview-content.js
@@ -0,0 +1,20 @@
+import erb from 'erb'
+import Handlebars from 'handlebars'
+
+export const compile = async ({ raw, engine, variables = {} }) => {
+ if (engine === 'erb') {
+ const res = await erb({
+ timeout: 5000,
+ data: { values: variables },
+ template: raw,
+ })
+
+ return res
+ }
+ if (engine === 'handlebars') {
+ const res = Handlebars.compile(raw)(variables)
+ return res
+ }
+
+ return raw
+}
diff --git a/src/helpers/takeScreenshot.js b/src/helpers/takeScreenshot.js
index 3d81b765..c56de7a9 100644
--- a/src/helpers/takeScreenshot.js
+++ b/src/helpers/takeScreenshot.js
@@ -20,19 +20,19 @@ export function takeScreenshot(html, deviceWidth, workingDirectory) {
win.webContents.on('did-finish-load', () => {
// Window is not fully loaded after this event, hence setTimeout()...
- win.webContents.executeJavaScript(
- "document.querySelector('body').getBoundingClientRect().height"
- ).then(height => {
- win.setSize(deviceWidth, height + 50)
- const takeShot = () => {
- win.webContents.capturePage().then(img => {
- // eslint-disable-line
- win.close()
- resolve(img.toPNG())
- })
- }
- setTimeout(takeShot, 500)
- })
+ win.webContents
+ .executeJavaScript("document.querySelector('body').getBoundingClientRect().height")
+ .then(height => {
+ win.setSize(deviceWidth, height + 50)
+ const takeShot = () => {
+ win.webContents.capturePage().then(img => {
+ // eslint-disable-line
+ win.close()
+ resolve(img.toPNG())
+ })
+ }
+ setTimeout(takeShot, 500)
+ })
})
})
}
diff --git a/src/pages/Project/PreviewSettings.js b/src/pages/Project/PreviewSettings.js
new file mode 100644
index 00000000..38652906
--- /dev/null
+++ b/src/pages/Project/PreviewSettings.js
@@ -0,0 +1,205 @@
+import React, { Component } from 'react'
+import { connect } from 'react-redux'
+import debounce from 'lodash/debounce'
+import find from 'lodash/find'
+import yaml from 'js-yaml'
+
+import CodeMirror from 'codemirror/lib/codemirror'
+import 'codemirror/mode/yaml/yaml'
+
+import Modal from 'components/Modal'
+
+import { updateSettings } from 'actions/settings'
+import { addAlert } from 'reducers/alerts'
+
+const defaultTemplatingSettings = projectPath => ({
+ variables: {},
+ engine: 'html',
+ editorMode: 'json',
+ projectPath,
+})
+
+export default connect(
+ state => ({
+ templating: state.settings.get('templating'),
+ lightTheme: state.settings.getIn(['editor', 'lightTheme'], false),
+ }),
+ {
+ addAlert,
+ updateSettings,
+ },
+)(
+ class PreviewSettings extends Component {
+ state = {
+ variables: defaultTemplatingSettings().variables,
+ engine: defaultTemplatingSettings().engine,
+ editorMode: defaultTemplatingSettings().editorMode,
+ valid: true,
+ }
+
+ componentDidUpdate(prevProps) {
+ if (!prevProps.isOpened && this.props.isOpened) {
+ this.initEditor()
+ }
+
+ if (
+ prevProps.templating !== this.props.templating ||
+ (!prevProps.isOpened && this.props.isOpened)
+ ) {
+ this.setState({
+ ...defaultTemplatingSettings(this.props.currentProjectPath),
+ ...this.currentProjectTemplating(),
+ })
+ }
+ }
+
+ currentProjectTemplating() {
+ const { currentProjectPath, templating } = this.props
+ const projectTemplating = find(templating, { projectPath: currentProjectPath })
+
+ return projectTemplating || defaultTemplatingSettings(currentProjectPath)
+ }
+
+ handleChangeEngine = event => {
+ this.setState({
+ engine: event.target.value,
+ })
+ }
+
+ handleEditorMode = event => {
+ const { value } = event.target
+ this.setState({
+ editorMode: value,
+ })
+ this._codeMirror.setOption('mode', value)
+ this.handleChangeVars()
+ }
+
+ handleChangeVars = debounce(() => {
+ const raw = this._codeMirror.getValue()
+ try {
+ switch (this.state.editorMode) {
+ case 'yaml':
+ this.setState({ variables: yaml.safeLoad(raw) || {}, valid: true })
+ break
+ case 'json':
+ this.setState({ variables: JSON.parse(raw) || {}, valid: true })
+ break
+ default:
+ this.setState({ variables: raw || {}, valid: true })
+ break
+ }
+ } catch (err) {
+ this.setState({ valid: false })
+ }
+ }, 200)
+
+ saveVars = () => {
+ if (!this.state.valid) {
+ this.props.addAlert('Invalid variables syntax, couldn’t be saved', 'error')
+ return
+ }
+
+ const { templating, currentProjectPath } = this.props
+ const otherProjectVariables = templating.filter(v => v.projectPath !== currentProjectPath)
+ const updatedVariables = [
+ ...otherProjectVariables,
+ {
+ projectPath: currentProjectPath,
+ variables: this.state.variables,
+ engine: this.state.engine,
+ editorMode: this.state.editorMode,
+ },
+ ]
+
+ this.props.updateSettings(settings => {
+ return settings.set('templating', updatedVariables)
+ })
+ }
+
+ initEditor() {
+ if (!this._textarea) return
+
+ const { lightTheme } = this.props
+ const { variables, editorMode } = this.currentProjectTemplating()
+
+ if (this._codeMirror) {
+ this._codeMirror.toTextArea()
+ this._codeMirror = null
+ }
+
+ let content = ''
+
+ try {
+ if (Object.keys(variables).length > 0) {
+ if (editorMode === 'yaml') content = yaml.safeDump(variables)
+ if (editorMode === 'json') content = JSON.stringify(variables, null, 2)
+ }
+
+ this.setState({ variables, valid: true })
+ } catch (err) {
+ this.props.addAlert('Initial variables cannot be serialized', 'error')
+ }
+
+ this._codeMirror = CodeMirror.fromTextArea(this._textarea, {
+ tabSize: 2,
+ dragDrop: false,
+ mode: editorMode,
+ lineNumbers: false,
+ theme: lightTheme ? 'neo' : 'one-dark',
+ })
+
+ this._codeMirror.setValue(content)
+
+ this._codeMirror.on('change', this.handleChangeVars)
+ }
+
+ handleClose() {
+ this.saveVars()
+ this.props.onClose()
+ }
+
+ render() {
+ const { isOpened, onClose } = this.props
+ const { valid, engine, editorMode } = this.state
+
+ return (
+ this.handleClose()}
+ className="FilePreview--settings-modal p-10 d-f fd-c"
+ >
+ {'Preview settings'}
+
+ Define templating variables for this project :
+
+ {'Treat MJML output as '}
+
+
+
+
+
+ {'Define variables using '}
+
+
+
+
+
+
{`Template variables (${editorMode}):`}
+
{!valid && `× Invalid ${editorMode}`}
+
+
+
+
+ )
+ }
+ },
+)
diff --git a/src/pages/Project/SendModal.js b/src/pages/Project/SendModal.js
index 7c2362bd..88edac51 100644
--- a/src/pages/Project/SendModal.js
+++ b/src/pages/Project/SendModal.js
@@ -2,6 +2,7 @@ import React, { Component } from 'react'
import get from 'lodash/get'
import { connect } from 'react-redux'
import debounce from 'lodash/debounce'
+import find from 'lodash/find'
import { Creatable as Select } from 'react-select'
import uniq from 'lodash/uniq'
import uniqBy from 'lodash/uniqBy'
@@ -9,6 +10,7 @@ import uniqBy from 'lodash/uniqBy'
import { MdAdd as IconAdd } from 'react-icons/md'
import sendEmail from 'helpers/sendEmail'
+import { compile } from 'helpers/preview-content'
import { isModalOpened, closeModal } from 'reducers/modals'
import { addAlert } from 'reducers/alerts'
@@ -25,6 +27,7 @@ export default connect(
const TargetEmails = state.settings.getIn(['api', 'TargetEmails'], [])
const LastEmails = state.settings.getIn(['api', 'LastEmails'], [])
const Subject = state.settings.getIn(['api', 'Subject'], '')
+
return {
content: get(state, 'preview.content', ''),
isOpened: isModalOpened(state, 'send'),
@@ -37,6 +40,7 @@ export default connect(
emails: uniq([...(SenderEmail ? [SenderEmail] : []), ...TargetEmails, ...LastEmails]).map(
email => ({ label: email, value: email }),
),
+ templating: state.settings.get('templating'),
}
},
{
@@ -96,9 +100,22 @@ export default connect(
e.stopPropagation()
e.preventDefault()
- const { addAlert, content } = this.props
-
+ const { addAlert, content: raw, templating, currentProjectPath } = this.props
const { Subject, APIKey, APISecret, SenderName, SenderEmail, TargetEmails } = this.state
+ const projectTemplating = find(templating, { projectPath: currentProjectPath }) || {}
+
+ let content = raw
+
+ try {
+ content = await compile({
+ raw,
+ engine: projectTemplating.engine,
+ variables: projectTemplating.variables,
+ })
+ } catch (err) {
+ this.props.addAlert(`[Template Compiler Error] ${err.message}`, 'error')
+ throw new Error(err)
+ }
try {
await sendEmail({
diff --git a/src/pages/Project/index.js b/src/pages/Project/index.js
index 6f441336..5d35a88f 100644
--- a/src/pages/Project/index.js
+++ b/src/pages/Project/index.js
@@ -2,7 +2,6 @@ import React, { Component } from 'react'
import pathModule from 'path'
import trash from 'trash'
import { connect } from 'react-redux'
-
import { FaCog, FaFolderOpen } from 'react-icons/fa'
import {
MdContentCopy as IconCopy,
@@ -12,6 +11,7 @@ import {
MdNoteAdd as IconAdd,
MdAutorenew as IconBeautify,
MdSave as IconSave,
+ MdBuild as IconBuild,
} from 'react-icons/md'
import fs from 'fs'
@@ -35,6 +35,7 @@ import BackButton from './BackButton'
import SendModal from './SendModal'
import AddFileModal from './AddFileModal'
import RemoveFileModal from './RemoveFileModal'
+import PreviewSettings from './PreviewSettings'
export default connect(
state => ({
@@ -54,6 +55,7 @@ export default connect(
state = {
path: this.props.location.query.path,
activeFile: null,
+ showSettings: false,
}
componentDidMount() {
@@ -101,11 +103,11 @@ export default connect(
try {
const trashed = await trash(fileName)
const stillExists = await fileExists(fileName)
-
+
if (stillExists) {
throw new Error('File still exists')
}
-
+
this.props.addAlert('File successfully removed', 'success')
} catch (e) {
this.props.addAlert('Could not delete file', 'error')
@@ -187,6 +189,9 @@ export default connect(
openAddFileModal = () => this.props.openModal('addFile')
+ handleOpenSettings = () => this.setState({ showSettings: true })
+ handleCloseSettings = () => this.setState({ showSettings: false })
+
checkForRelativePaths() {
const { preview } = this.props
const relativePathsRegex = new RegExp(/(?:href|src)=(["'])(?!mailto|https|http|data:).*?\1/g)
@@ -213,7 +218,7 @@ export default connect(
render() {
const { preview, preventAutoSave } = this.props
- const { path, activeFile } = this.state
+ const { path, activeFile, showSettings } = this.state
const rootPath = this.props.location.query.path
const projectName = pathModule.basename(rootPath)
@@ -242,6 +247,10 @@ export default connect(
{'Beautify'}
,
]}
+