email templates editing, syntax help and preview modals and actually rendering on submit.

This commit is contained in:
fiatjaf 2018-09-15 00:57:07 +00:00 committed by Cole
parent 0a2f727a3a
commit ef0a3ddd67
9 changed files with 287 additions and 85 deletions

View File

@ -202,17 +202,23 @@ def custom_template_set(hashid):
if not form.controlled_by(current_user):
return jsonerror(401, {'error': "You do not control this form."})
# TODO catch render exception before deploying
template = form.template
if not template:
template = EmailTemplate(form_id=form.id)
template.from_name = request.get_json()['from_name']
template.subject = request.get_json()['subject']
template.style = request.get_json()['style']
template.body = request.get_json()['body']
try:
pass
except:
template.sample()
except Exception as e:
print(e)
return jsonerror(406, {'error': "Failed to render. The template has errors."})
print(form.template)
DB.session.add(form)
DB.session.add(template)
DB.session.commit()
return jsonify({'ok': True})
@ -221,22 +227,14 @@ def custom_template_preview_render():
if not current_user.has_feature('whitelabel'):
return jsonerror(402, {'error': "Please upgrade your account."})
template = EmailTemplate.temporary(
body, _ = EmailTemplate.make_sample(
from_name=request.get_json()['from_name'],
subject=request.get_json()['subject'],
style=request.get_json()['style'],
body=request.get_json()['body']
body=request.get_json()['body'],
)
return template.render_body(
data={
'name': 'Irwin Jones',
'_replyto': 'i.jones@example.com',
'message': 'Hello!\n\nThis is a preview message!'
},
host='example.com/',
keys=['name', '_replyto', 'message'],
now=datetime.datetime.utcnow().strftime('%I:%M %p UTC - %d %B %Y'),
unconfirm_url='#'
)
return body
@login_required

View File

@ -139,7 +139,7 @@ class Form(DB.Model):
'counter': self.counter,
'email': self.email,
'host': self.host,
'template': self.template,
'template': self.template.serialize() if self.template else None,
'features': {f: True for f in self.features},
'confirm_sent': self.confirm_sent,
'confirmed': self.confirmed,
@ -193,6 +193,7 @@ class Form(DB.Model):
next = next_url(referrer, data.get('_next'))
spam = data.get('_gotcha', None)
format = data.get('_format', None)
fromname = None
# turn cc emails into array
if cc:
@ -291,10 +292,11 @@ class Form(DB.Model):
unconfirm_url=unconfirm)
# if there's a custom email template we should use it
if self.template and self.owner.has_feature('whitelabel'):
html = self.template.render_body(
if self.owner.has_feature('whitelabel') and self.template:
html, subject = self.template.render_body_and_subject(
data=data, host=self.host, keys=keys, now=now,
unconfirm_url=unconfirm)
fromname = self.template.from_name
# check if the user wants a new or old version of the email
if format == 'plain':
@ -330,6 +332,7 @@ class Form(DB.Model):
text=text,
html=html,
sender=settings.DEFAULT_SENDER,
fromname=fromname,
reply_to=reply_to,
cc=cc,
headers={
@ -532,26 +535,49 @@ class EmailTemplate(DB.Model):
(self.id or 'with an id to be assigned', self.form_id)
@classmethod
def temporary(cls, style, body):
def make_sample(cls, style, body,
from_name='Formspree Team',
subject='New submission from {{ _host }}'):
t = cls(0)
t.from_name = from_name
t.subject = subject
t.style = style
t.body = body
return t
return t.sample()
def render_subject(self, data):
return pystache.render(self.subject, data)
def sample(self):
return self.render_body_and_subject(
data={
'name': 'Irwin Jones',
'_replyto': 'i.jones@example.com',
'message': 'Hello!\n\nThis is a preview message!'
},
host='example.com/',
keys=['name', '_replyto', 'message'],
now=datetime.datetime.utcnow().strftime('%I:%M %p UTC - %d %B %Y'),
unconfirm_url='#'
)
def render_body(self, data, host, keys, now, unconfirm_url):
def serialize(self):
return {
'subject': self.subject,
'from_name': self.from_name,
'style': self.style,
'body': self.body
}
def render_body_and_subject(self, data, host, keys, now, unconfirm_url):
data.update({
'_fields': [{'field_name': f, 'field_value': data[f]} for f in keys],
'_fields': [{'_name': f, '_value': data[f]} for f in keys],
'_time': now,
'_host': host
})
html = pystache.render(self.body, data)
styled = '<style>' + self.style + '</style>' + html
inlined = transform(styled)
subject = pystache.render(self.subject, data)
html = pystache.render('<style>' + self.style + '</style>' + self.body, data)
print(html)
inlined = transform(html)
suffixed = inlined + '''<table width="100%"><tr><td>You are receiving this because you confirmed this email address on <a href="{service_url}">{service_name}</a>. If you don't remember doing that, or no longer wish to receive these emails, please remove the form on {host} or <a href="{unconfirm_url}">click here to unsubscribe</a> from this endpoint.</td></tr></table>'''.format(service_url=settings.SERVICE_URL, service_name=settings.SERVICE_NAME, host=host, unconfirm_url=unconfirm_url)
return suffixed
return suffixed, subject
class Submission(DB.Model):

View File

@ -4,6 +4,7 @@ const toastr = window.toastr
const fetch = window.fetch
const React = require('react')
const CodeMirror = require('react-codemirror2')
const Modal = require('react-modal')
require('codemirror/mode/xml/xml')
require('codemirror/mode/css/css')
@ -13,48 +14,53 @@ module.exports = class FormSettings extends React.Component {
constructor(props) {
super(props)
this.changeFrom = this.changeFrom.bind(this)
this.changeFromName = this.changeFromName.bind(this)
this.changeSubject = this.changeSubject.bind(this)
this.changeStyle = this.changeStyle.bind(this)
this.changeBody = this.changeBody.bind(this)
this.preview = this.preview.bind(this)
this.closePreview = this.closePreview.bind(this)
this.showSyntax = this.showSyntax.bind(this)
this.closeModal = this.closeModal.bind(this)
this.revert = this.revert.bind(this)
this.deploy = this.deploy.bind(this)
this.defaultValues = {
from: 'Team Formspree',
from_name: 'Team Formspree',
subject: 'New submission from {{ host }}',
style: `h1 {
color: black;
}
.content {
font-size: 20pt;
font-size: 15pt;
}`,
body: `<div class="content">
<h1>You've got a new submissions from {{ _replyto }}</h1>
<h1>You've got a new submission from {{ _replyto }} on {{ _host }}</h1>
<table>
{{# _fields }}
<tr>
<th>{{ field_name }}</th>
<td>{{ field_value }}</td>
<th>{{ _name }}</th>
<td>{{ _value }}</td>
</tr>
{{/ _fields }}
</table>
</div>`
</div>
<p>This submission was sent on {{ _time }}.</p>
<br>
<hr>`
}
this.state = {
previewing: null,
changes: {}
changes: {},
modal: null,
previewHTML: null
}
}
render() {
let {form} = this.props
let {from, subject, style, body} = {
let {from_name, subject, style, body} = {
...this.defaultValues,
...form.template,
...this.state.changes
@ -66,10 +72,14 @@ module.exports = class FormSettings extends React.Component {
<FormDescription prefix="Whitelabel settings for" form={form} />
<div className="container">
<div className="col-1-6">
<label htmlFor="from">From</label>
<label htmlFor="from_name">From</label>
</div>
<div className="col-5-6">
<input id="from" onChange={this.changeFrom} value={from} />
<input
id="from_name"
onChange={this.changeFromName}
value={from_name}
/>
</div>
</div>
<div className="container">
@ -88,9 +98,9 @@ module.exports = class FormSettings extends React.Component {
</div>
</div>
<div className="container">
<div className="col-1-1">
<label>
Style
<label className="row">
<div className="col-1-1">Style</div>
<div className="col-1-1">
<CodeMirror.Controlled
value={style}
options={{
@ -100,13 +110,20 @@ module.exports = class FormSettings extends React.Component {
}}
onBeforeChange={this.changeStyle}
/>
</label>
</div>
</div>
</label>
</div>
<div className="container">
<div className="col-1-1">
<label>
Body
<label className="row">
<div className="row">
<div className="col-1-2">Body</div>
<div className="col-1-2 right">
<a href="#" onClick={this.showSyntax}>
syntax quick reference
</a>
</div>
</div>
<div className="col-1-1">
<CodeMirror.Controlled
value={body}
options={{
@ -116,15 +133,14 @@ module.exports = class FormSettings extends React.Component {
}}
onBeforeChange={this.changeBody}
/>
</label>
</div>
</div>
</label>
</div>
<div className="container">
<div className="col-1-6">
<button onClick={this.preview}>Preview</button>
</div>
<div className="col-2-6" />
<div className="col-1-6">
<div className="col-2-3 right">
{Object.keys(this.state.changes).length > 0
? 'changes pending'
: null}
@ -132,7 +148,7 @@ module.exports = class FormSettings extends React.Component {
<div className="col-1-6">
<button
onClick={this.revert}
disabled={Object.keys(this.state.changes).length > 0}
disabled={Object.keys(this.state.changes).length === 0}
>
Revert
</button>
@ -140,21 +156,86 @@ module.exports = class FormSettings extends React.Component {
<div className="col-1-6">
<button
onClick={this.deploy}
disabled={Object.keys(this.state.changes).length > 0}
disabled={Object.keys(this.state.changes).length === 0}
>
Deploy
</button>
</div>
</div>
</div>
<Modal
contentLabel="Preview"
isOpen={this.state.modal === 'preview'}
onRequestClose={this.closeModal}
className="dummy"
overlayClassName="dummy"
>
<div id="whitelabel-preview-modal">
<div
className="container preview"
dangerouslySetInnerHTML={{__html: this.state.previewHTML}}
/>
<div className="container right">
<button onClick={this.closeModal}>OK</button>
</div>
</div>
</Modal>
<Modal
contentLabel="Email Syntax"
isOpen={this.state.modal === 'syntax'}
onRequestClose={this.closeModal}
className="dummy"
overlayClassName="dummy"
>
<div>
<div>
<h2>Email Syntax</h2>
<p>
the email body can contain simple HTML that's valid in an email.
No <span className="code">&lt;script&gt;</span> or{' '}
<span className="code">&lt;style&gt;</span>
tags can be included. For a list of recommended HTML tags see{' '}
<a href="" target="_blank">
the ContantContact guide to HTML in email
</a>
.
</p>
<p>
The following special variables are recognized by Formspree,
using the{' '}
<a
href="https://mustache.github.io/mustache.5.html"
target="_blank"
>
mustache
</a>{' '}
template language.
</p>
<pre>
{`
{{ _time }} The formatted date and time of the submission.
{{ _host }} The URL (without "https://") of where the form was submitted.
{{ <fieldname> }} Any named input value in your form will be displayed.
{{# _fields }} A list of all fields can be included.
{{ _name }} Within the _fields block you can access the current field name
{{ _value }} and field value.
{{/ _fields }} Closes the _fields block.
`.trim()}
</pre>
</div>
<div className="container right">
<button onClick={this.closeModal}>OK</button>
</div>
</div>
</Modal>
</>
)
}
changeFrom(e) {
changeFromName(e) {
let value = e.target.value
this.setState(state => {
state.changes.from = value
state.changes.from_name = value
return state
})
}
@ -181,11 +262,15 @@ module.exports = class FormSettings extends React.Component {
})
}
closePreview() {
this.setState({previewing: null})
closeModal() {
this.setState({modal: null})
}
async preview() {
async preview(e) {
e.preventDefault()
this.setState({modal: 'preview'})
let template = {
...this.defaultValues,
...this.props.form.template,
@ -204,7 +289,7 @@ module.exports = class FormSettings extends React.Component {
})
let html = await resp.text()
this.setState({previewing: html})
this.setState({previewHTML: html})
} catch (e) {
console.error(e)
toastr.error(
@ -213,6 +298,57 @@ module.exports = class FormSettings extends React.Component {
}
}
revert() {}
deploy() {}
revert(e) {
e.preventDefault()
this.setState({changes: {}})
}
showSyntax(e) {
e.preventDefault()
this.setState({modal: 'syntax'})
}
async deploy(e) {
e.preventDefault()
try {
let resp = await fetch(
`/api-int/forms/${this.props.form.hashid}/whitelabel`,
{
method: 'PUT',
body: JSON.stringify({
...this.defaultValues,
...this.props.form.template,
...this.state.changes
}),
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
}
}
)
let r = await resp.json()
if (!resp.ok || r.error) {
toastr.warning(
r.error
? `Failed to save custom template: ${r.error}`
: 'Failed to save custom template.'
)
return
}
toastr.success('Custom template saved.')
this.props.onUpdate().then(() => {
this.setState({changes: {}})
})
} catch (e) {
console.error(e)
toastr.error(
'Failed to save custom template. See the console for more details.'
)
}
}
}

View File

@ -2,10 +2,14 @@
const render = require('react-dom').render
const React = require('react') // eslint-disable-line no-unused-vars
const Modal = require('react-modal')
const Dashboard = require('./Dashboard')
if (document.querySelector('body.forms.dashboard')) {
let el = document.querySelector('.container.block')
Modal.setAppElement(el)
document.querySelector('.menu .item:nth-child(2)').innerHTML = ''
render(<Dashboard />, document.querySelector('.container.block'))
render(<Dashboard />, el)
}

View File

@ -50,6 +50,7 @@ def configure_routes(app):
app.add_url_rule('/api-int/forms/<hashid>', view_func=fa.delete, methods=['DELETE'])
app.add_url_rule('/api-int/forms/sitewide-check', view_func=fa.sitewide_check, methods=['POST'])
app.add_url_rule('/api-int/forms/<hashid>/submissions/<submissionid>', view_func=fa.submission_delete, methods=['DELETE'])
app.add_url_rule('/api-int/forms/<hashid>/whitelabel', view_func=fa.custom_template_set, methods=['PUT'])
app.add_url_rule('/api-int/forms/whitelabel/preview', view_func=fa.custom_template_preview_render, methods=['POST'])
# Webhooks

View File

@ -190,6 +190,13 @@
overflow-x: auto;
}
a.button.export {
width: 11em;
text-align: center;
display: inline-block;
margin: 0 0 20px 20px;
}
table {
text-align: left;
width: 100%;
@ -375,14 +382,8 @@
top: -100%;
z-index: 11;
width: 770px;
-webkit-transform: translate(0, -500%);
-ms-transform: translate(0, -500%);
transform: translate(0, -500%);
-webkit-transition: -webkit-transform 0.3s ease-out;
-moz-transition: -moz-transform 0.3s ease-out;
-o-transition: -o-transform 0.3s ease-out;
transition: transform 0.3s ease-out;
transition: transform 0.5s ease-out;
padding: 0.3em 1.3em 1.3em 1.3em;
.x a {
@ -500,20 +501,50 @@
align-items: baseline;
}
#from::after {
#from_name::after {
content: ' submissions@formspree.io';
color: grey;
}
}
a.button.export {
width: 11em;
text-align: center;
display: inline-block;
margin: 0 0 20px 20px;
#whitelabel-preview-modal {
.preview {
padding: 12px;
margin: 12px;
border: 5px dotted #444;
}
}
.ReactModal__Overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(255, 255, 255, 0.5);
display: flex;
justify-content: center;
.ReactModal__Content {
z-index: 11;
position: absolute;
top: 80px;
background: #fefefe;
border: #333333 solid 1px;
border-radius: 5px;
padding: 1.4em 1.4em 0.5em 1.4em;
max-width: 900px;
transform: translate(0, -500%);
transition: transform 0.6s ease-out;
}
&.ReactModal__Overlay--after-open .ReactModal__Content {
transform: translate(0, 0);
}
}
.CodeMirror {
z-index: 0;
padding: 10px;
height: auto;
font-size: 90%;

View File

@ -1,5 +1,5 @@
import os
from premailer import transform
from premailer import Premailer
TEMPLATES_DIR = 'formspree/templates/email/pre_inline_style/'
@ -8,7 +8,8 @@ def generate_templates():
for filename in os.listdir(TEMPLATES_DIR):
if filename.endswith('.html'):
with open(os.path.join(TEMPLATES_DIR, filename), 'r') as html:
transformed_template = transform(html.read())
p = Premailer(html.read(), remove_classes=True)
transformed_template = p.transform()
# weird issue with jinja templates beforehand so we use this hack
# see https://github.com/peterbe/premailer/issues/72

View File

@ -118,7 +118,8 @@ def next_url(referrer=None, next=None):
def send_email(to=None, subject=None, text=None, html=None,
sender=None, cc=None, reply_to=None, headers=None):
sender=None, cc=None, reply_to=None, headers=None,
fromname=None):
g.log = g.log.new(to=to, sender=sender)
if None in [to, subject, text, sender]:
@ -142,6 +143,9 @@ def send_email(to=None, subject=None, text=None, html=None,
except ValueError:
data.update({'from': sender})
if fromname:
data.update({'fromname': fromname})
if headers:
data.update({'headers': json.dumps(headers)})

View File

@ -6,6 +6,7 @@
"react": "^16.4.2",
"react-codemirror2": "^5.1.0",
"react-dom": "^16.4.2",
"react-modal": "^3.5.1",
"react-router-dom": "^4.3.1",
"url": "^0.11.0",
"valid-url": "^1.0.9"