From 0a2f727a3a7b0f5ef15951ab7d4d38f4d7fca20d Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Fri, 14 Sep 2018 02:43:20 +0000 Subject: [PATCH] basic custom email template editor, model and preview endpoint. --- Pipfile | 1 + Pipfile.lock | 7 + formspree/forms/api.py | 54 +- formspree/forms/models.py | 68 ++ formspree/forms/views.py | 4 +- formspree/js/forms/FormPage.js | 675 ------------------ .../js/forms/FormPage/FormDescription.js | 42 ++ formspree/js/forms/FormPage/Integration.js | 118 +++ formspree/js/forms/FormPage/Settings.js | 269 +++++++ formspree/js/forms/FormPage/Submissions.js | 150 ++++ formspree/js/forms/FormPage/Whitelabel.js | 218 ++++++ formspree/js/forms/FormPage/index.js | 132 ++++ formspree/routes.py | 1 + formspree/scss/dashboard.scss | 12 + 14 files changed, 1069 insertions(+), 682 deletions(-) delete mode 100644 formspree/js/forms/FormPage.js create mode 100644 formspree/js/forms/FormPage/FormDescription.js create mode 100644 formspree/js/forms/FormPage/Integration.js create mode 100644 formspree/js/forms/FormPage/Settings.js create mode 100644 formspree/js/forms/FormPage/Submissions.js create mode 100644 formspree/js/forms/FormPage/Whitelabel.js create mode 100644 formspree/js/forms/FormPage/index.js diff --git a/Pipfile b/Pipfile index 5006b17..2c27f94 100644 --- a/Pipfile +++ b/Pipfile @@ -34,6 +34,7 @@ unicodecsv = "==0.14.1" flask-migrate = "==2.2.1" premailer = "==3.2.0" ptvsd = "==4.1.2" +pystache = "*" [pipenv] allow_prereleases = true diff --git a/Pipfile.lock b/Pipfile.lock index 59be317..4f12d9b 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -292,6 +292,13 @@ "index": "pypi", "version": "==4.1.2" }, + "pystache": { + "hashes": [ + "sha256:f7bbc265fb957b4d6c7c042b336563179444ab313fb93a719759111eabd3b85a" + ], + "index": "pypi", + "version": "==0.5.4" + }, "python-dateutil": { "hashes": [ "sha256:1adb80e7a782c12e52ef9a8182bebeb73f1d7e24e374397af06fb4956c8dc5c0", diff --git a/formspree/forms/api.py b/formspree/forms/api.py index f5784e2..bd3e68b 100644 --- a/formspree/forms/api.py +++ b/formspree/forms/api.py @@ -1,3 +1,5 @@ +import datetime + from urllib.parse import urljoin from flask import request, jsonify, g @@ -8,7 +10,7 @@ from formspree.stuff import DB from formspree.utils import jsonerror, IS_VALID_EMAIL from .helpers import referrer_to_path, sitewide_file_check, remove_www, \ referrer_to_baseurl -from .models import Form, Submission +from .models import Form, Submission, EmailTemplate @login_required @@ -104,9 +106,7 @@ def get(hashid): if not form: return jsonerror(404, {'error': "Form not found."}) - for cont in form.controllers: - if cont.id == current_user.id: break - else: + if not form.controlled_by(current_user): return jsonerror(401, {'error': "You do not control this form."}) submissions, fields = form.submissions_with_fields() @@ -193,6 +193,52 @@ def submission_delete(hashid, submissionid): return jsonify({'ok': True}) +@login_required +def custom_template_set(hashid): + form = Form.get_with_hashid(hashid) + if not form: + return jsonerror(404, {'error': "Form not found."}) + + if not form.controlled_by(current_user): + return jsonerror(401, {'error': "You do not control this form."}) + + # TODO catch render exception before deploying + try: + pass + except: + return jsonerror(406, {'error': "Failed to render. The template has errors."}) + + print(form.template) + + DB.session.add(form) + DB.session.commit() + + return jsonify({'ok': True}) + + +@login_required +def custom_template_preview_render(): + if not current_user.has_feature('whitelabel'): + return jsonerror(402, {'error': "Please upgrade your account."}) + + template = EmailTemplate.temporary( + style=request.get_json()['style'], + 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='#' + ) + + @login_required def sitewide_check(): email = request.get_json().get('email') diff --git a/formspree/forms/models.py b/formspree/forms/models.py index 40641dd..d1cf330 100644 --- a/formspree/forms/models.py +++ b/formspree/forms/models.py @@ -2,6 +2,7 @@ import hmac import random import hashlib import datetime +import pystache from flask import url_for, render_template, render_template_string, g from sqlalchemy.sql import table @@ -11,6 +12,7 @@ from sqlalchemy.ext.mutable import MutableDict from sqlalchemy import func from werkzeug.datastructures import ImmutableMultiDict, \ ImmutableOrderedMultiDict +from premailer import transform from formspree import settings from formspree.stuff import DB, redis_store, TEMPLATES @@ -43,6 +45,7 @@ class Form(DB.Model): owner = DB.relationship('User') # direct owner, defined by 'owner_id' # this property is basically useless. use .controllers + template = DB.relationship('EmailTemplate', uselist=False, back_populates='form') submissions = DB.relationship('Submission', backref='form', lazy='dynamic', order_by=lambda: Submission.id.desc()) @@ -106,6 +109,16 @@ class Form(DB.Model): .filter(Form.id == self.id) return by_email.union(by_creation) + @property + def features(self): + return set.union(*[cont.features for cont in self.controllers]) + + def controlled_by(self, user): + for cont in self.controllers: + if cont.id == user.id: + return True + return False + def has_feature(self, feature): c = [user for user in self.controllers if user.has_feature(feature)] return len(c) > 0 @@ -126,6 +139,8 @@ class Form(DB.Model): 'counter': self.counter, 'email': self.email, 'host': self.host, + 'template': self.template, + 'features': {f: True for f in self.features}, 'confirm_sent': self.confirm_sent, 'confirmed': self.confirmed, 'disabled': self.disabled, @@ -274,6 +289,13 @@ class Form(DB.Model): text = render_template('email/form.txt', data=data, host=self.host, keys=keys, now=now, 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( + data=data, host=self.host, keys=keys, now=now, + unconfirm_url=unconfirm) + # check if the user wants a new or old version of the email if format == 'plain': html = render_template('email/plain_form.html', @@ -486,6 +508,52 @@ class Form(DB.Model): return True +class EmailTemplate(DB.Model): + __tablename__ = 'email_templates' + + id = DB.Column(DB.Integer, primary_key=True) + form_id = DB.Column( + DB.Integer, DB.ForeignKey('forms.id'), + unique=True, nullable=False + ) + subject = DB.Column(DB.Text, nullable=False) + from_name = DB.Column(DB.Text, nullable=False) + style = DB.Column(DB.Text, nullable=False) + body = DB.Column(DB.Text, nullable=False) + + form = DB.relationship('Form', back_populates='template') + + def __init__(self, form_id): + self.submitted_at = datetime.datetime.utcnow() + self.form_id = form_id + + def __repr__(self): + return '' % \ + (self.id or 'with an id to be assigned', self.form_id) + + @classmethod + def temporary(cls, style, body): + t = cls(0) + t.style = style + t.body = body + return t + + def render_subject(self, data): + return pystache.render(self.subject, data) + + def render_body(self, data, host, keys, now, unconfirm_url): + data.update({ + '_fields': [{'field_name': f, 'field_value': data[f]} for f in keys], + '_time': now, + '_host': host + }) + html = pystache.render(self.body, data) + styled = '' + html + inlined = transform(styled) + suffixed = inlined + '''
You are receiving this because you confirmed this email address on {service_name}. If you don't remember doing that, or no longer wish to receive these emails, please remove the form on {host} or click here to unsubscribe from this endpoint.
'''.format(service_url=settings.SERVICE_URL, service_name=settings.SERVICE_NAME, host=host, unconfirm_url=unconfirm_url) + return suffixed + + class Submission(DB.Model): __tablename__ = 'submissions' diff --git a/formspree/forms/views.py b/formspree/forms/views.py index f8893b7..42f7ccf 100644 --- a/formspree/forms/views.py +++ b/formspree/forms/views.py @@ -496,9 +496,7 @@ def export_submissions(hashid, format=None): return jsonerror(402, {'error': "Please upgrade your account."}) form = Form.get_with_hashid(hashid) - for cont in form.controllers: - if cont.id == current_user.id: break - else: + if not form.controlled_by(current_user): return abort(401) submissions, fields = form.submissions_with_fields() diff --git a/formspree/js/forms/FormPage.js b/formspree/js/forms/FormPage.js deleted file mode 100644 index bb81230..0000000 --- a/formspree/js/forms/FormPage.js +++ /dev/null @@ -1,675 +0,0 @@ -/** @format */ - -const toastr = window.toastr -const fetch = window.fetch -const React = require('react') -const {Route, Link, NavLink, Redirect} = require('react-router-dom') -const CodeMirror = require('react-codemirror2') -const cs = require('class-set') -require('codemirror/mode/xml/xml') -require('codemirror/mode/javascript/javascript') - -const Portal = require('../Portal') - -module.exports = class FormPage extends React.Component { - constructor(props) { - super(props) - - this.state = { - form: null - } - - this.fetchForm = this.fetchForm.bind(this) - } - - async componentDidMount() { - this.fetchForm() - } - - render() { - let hashid = this.props.match.params.hashid - - return ( - <> - - Your forms - - -

Form Details

-
- } - /> - {this.state.form && ( - <> -

- - Integration - - - Submissions - - - Settings - -

- ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - - )} - - ) - } - - async fetchForm() { - let hashid = this.props.match.params.hashid - - try { - let resp = await fetch(`/api-int/forms/${hashid}`, { - credentials: 'same-origin', - headers: {Accept: 'application/json'} - }) - let r = await resp.json() - - if (!resp.ok || r.error) { - toastr.warning( - r.error ? `Error fetching form: ${r.error}` : 'Error fetching form.' - ) - return - } - - this.setState({form: r}) - } catch (e) { - console.error(e) - toastr.error(`Failed to fetch form, see the console for more details.`) - } - } -} - -class FormIntegration extends React.Component { - constructor(props) { - super(props) - - this.changeTab = this.changeTab.bind(this) - - this.state = { - activeTab: 'HTML', - availableTabs: ['HTML', 'AJAX'] - } - } - - render() { - let {form} = this.props - - var codeSample - var modeSample - switch (this.state.activeTab) { - case 'HTML': - modeSample = 'xml' - codeSample = `
- - - - - - -
` - break - case 'AJAX': - modeSample = 'javascript' - codeSample = `// There should be an HTML form elsewhere on the page. See the "HTML" tab. -var form = document.querySelector('form') -var data = new FormData(form) -var req = new XMLHttpRequest() -req.open(form.method, form.action) -req.send(data)` - break - } - - var integrationSnippet - if (this.state.activeTab === 'AJAX' && !form.captcha_disabled) { - integrationSnippet = ( -
-

Want to submit your form through AJAX?

-

- Disable reCAPTCHA{' '} - for this form to make it possible! -

-
- ) - } else { - integrationSnippet = ( - - ) - } - - return ( - <> -
-
-
-

- Paste this code in your HTML, modifying it according to your - needs: -

-
- {this.state.availableTabs.map(tabName => ( -
- {tabName} -
- ))} -
- {integrationSnippet} -
-
-
- - ) - } - - changeTab(e) { - e.preventDefault() - - this.setState({activeTab: e.target.dataset.tab}) - } -} - -class FormSubmissions extends React.Component { - constructor(props) { - super(props) - - this.deleteSubmission = this.deleteSubmission.bind(this) - this.showExportButtons = this.showExportButtons.bind(this) - - this.state = { - exporting: false - } - } - - render() { - let {form} = this.props - - return ( -
- - {form.submissions.length ? ( - <> - - - - - {form.fields - .slice(1 /* the first field is 'date' */) - .map(f => ( - - ))} - - - - {form.submissions.map(s => ( - - - {form.fields - .slice(1 /* the first field is 'date' */) - .map(f => ( - - ))} - - - ))} - -
Submitted at{f} -
- {new Date(Date.parse(s.date)) - .toString() - .split(' ') - .slice(0, 5) - .join(' ')} - -
{s[f]}
-
- -
-
-
- {this.state.exporting ? ( - - ) : ( -
- -
- )} -
-
- - ) : ( -

No submissions archived yet.

- )} -
- ) - } - - showExportButtons(e) { - e.preventDefault() - this.setState({exporting: true}) - } - - async deleteSubmission(e) { - e.preventDefault() - - let subid = e.currentTarget.dataset.sub - - try { - let resp = await fetch( - `/api-int/forms/${this.props.form.hashid}/submissions/${subid}`, - { - method: 'DELETE', - credentials: 'same-origin', - headers: {Accept: 'application/json'} - } - ) - let r = await resp.json() - - if (!resp.ok || r.error) { - toastr.warning( - r.error - ? `Failed to delete submission: ${r.error}` - : `Failed to delete submission.` - ) - return - } - - toastr.success('Submission deleted.') - this.props.onUpdate() - } catch (e) { - console.error(e) - toastr.error( - 'Failed to delete submission, see the console for more details.' - ) - } - } -} - -class FormSettings extends React.Component { - constructor(props) { - super(props) - - this.update = this.update.bind(this) - this.deleteForm = this.deleteForm.bind(this) - this.cancelDelete = this.cancelDelete.bind(this) - - this.state = { - deleting: false, - temporaryFormChanges: {} - } - } - - render() { - let {form} = this.props - let tmp = this.state.temporaryFormChanges - - return ( - <> -
- -
-
-
-

Form Enabled

-

- You can disable this form to cause it to stop receiving new - submissions temporarily or permanently. -

-
-
- -
-
-
-
-
-

reCAPTCHA

-
-

- reCAPTCHA provides vital spam protection, but you can turn it - off if you need. -

-
-
-
- -
-
-
-
-
-

Email Notifications

-

- You can disable the emails Formspree sends if you just want to - download the submissions from the dashboard. -

-
-
- -
-
-
-
-

Submission Archive

-

- You can disable the submission archive if you don't want - Formspree to store your submissions. -

-
-
- -
-
-
-
-

- {this.state.deleting - ? 'Are you sure you want to delete?' - : 'Delete Form'} -

-

- {this.state.deleting ? ( - - This will delete the form on {form.host} targeting{' '} - {form.email} and all its submissions? This action{' '} - cannot be undone. - - ) : ( - - Deleting the form will erase all traces of this form on - our databases, including all the submissions. - - )} -

-
-
- {this.state.deleting ? ( - <> - - - - ) : ( - - - - )} -
-
-
-
- - ) - } - - async update(e) { - let attr = e.currentTarget.name - let val = !e.currentTarget.checked - - this.setState(state => { - state.temporaryFormChanges[attr] = val - return state - }) - - try { - let resp = await fetch(`/api-int/forms/${this.props.form.hashid}`, { - method: 'PATCH', - body: JSON.stringify({ - [attr]: val - }), - 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 settings: ${r.error}` - : 'Failed to save settings.' - ) - return - } - - toastr.success('Settings saved.') - this.props.onUpdate().then(() => { - this.setState({temporaryFormChanges: {}}) - }) - } catch (e) { - console.error(e) - toastr.error('Failed to update form. See the console for more details.') - this.setState({temporaryFormChanges: {}}) - } - } - - cancelDelete(e) { - e.preventDefault() - this.setState({deleting: false}) - } - - async deleteForm(e) { - e.preventDefault() - - if (this.props.form.counter > 0 && !this.state.deleting) { - // double-check the user intentions to delete, - // but only if the form has been used already. - this.setState({deleting: true}) - return - } - - this.setState({deleting: false}) - try { - let resp = await fetch(`/api-int/forms/${this.props.form.hashid}`, { - method: 'DELETE', - credentials: 'same-origin', - headers: { - Accept: 'application/json' - } - }) - let r = await resp.json() - - if (resp.error || r.error) { - toastr.warning( - r.error - ? `failed to delete form: ${r.error}` - : 'failed to delete form.' - ) - return - } - - toastr.success('Form successfully deleted.') - this.props.history.push('/forms') - } catch (e) { - console.error(e) - toastr.error('Failed to delete form. See the console for more details.') - } - } -} - -function FormDescription({prefix, form}) { - return ( -

- {prefix}{' '} - {!form.hash ? ( - /{form.hashid} - ) : ( - /{form.email} - )}{' '} - {form.host ? ( - <> - at {form.host} - {form.sitewide ? ' and all its subpaths.' : null} - - ) : ( - '' - )} - {form.hash ? ( - <> -
- - you can now replace the email in the URL with{' '} - {`/${form.hashid}`} - - - ) : ( - <> -
- - targeting {form.email} - - - )} -

- ) -} diff --git a/formspree/js/forms/FormPage/FormDescription.js b/formspree/js/forms/FormPage/FormDescription.js new file mode 100644 index 0000000..cf1de65 --- /dev/null +++ b/formspree/js/forms/FormPage/FormDescription.js @@ -0,0 +1,42 @@ +/** @format */ + +const React = require('react') // eslint-disable-line no-unused-vars + +module.exports = FormDescription + +function FormDescription({prefix, form}) { + return ( +

+ {prefix}{' '} + {!form.hash ? ( + /{form.hashid} + ) : ( + /{form.email} + )}{' '} + {form.host ? ( + <> + at {form.host} + {form.sitewide ? ' and all its subpaths.' : null} + + ) : ( + '' + )} + {form.hash ? ( + <> +
+ + you can now replace the email in the URL with{' '} + {`/${form.hashid}`} + + + ) : ( + <> +
+ + targeting {form.email} + + + )} +

+ ) +} diff --git a/formspree/js/forms/FormPage/Integration.js b/formspree/js/forms/FormPage/Integration.js new file mode 100644 index 0000000..0baf24d --- /dev/null +++ b/formspree/js/forms/FormPage/Integration.js @@ -0,0 +1,118 @@ +/** @format */ + +const React = require('react') +const CodeMirror = require('react-codemirror2') +const cs = require('class-set') +require('codemirror/mode/xml/xml') +require('codemirror/mode/javascript/javascript') + +const {Link} = require('react-router-dom') + +module.exports = class FormIntegration extends React.Component { + constructor(props) { + super(props) + + this.changeTab = this.changeTab.bind(this) + + this.state = { + activeTab: 'HTML', + availableTabs: ['HTML', 'AJAX'] + } + } + + render() { + let {form} = this.props + + var codeSample + var modeSample + switch (this.state.activeTab) { + case 'HTML': + modeSample = 'xml' + codeSample = `
+ + + + + + +
` + break + case 'AJAX': + modeSample = 'javascript' + codeSample = `// There should be an HTML form elsewhere on the page. See the "HTML" tab. +var form = document.querySelector('form') +var data = new FormData(form) +var req = new XMLHttpRequest() +req.open(form.method, form.action) +req.send(data)` + break + } + + var integrationSnippet + if (this.state.activeTab === 'AJAX' && !form.captcha_disabled) { + integrationSnippet = ( +
+

Want to submit your form through AJAX?

+

+ Disable reCAPTCHA{' '} + for this form to make it possible! +

+
+ ) + } else { + integrationSnippet = ( + + ) + } + + return ( + <> +
+
+
+

+ Paste this code in your HTML, modifying it according to your + needs: +

+
+ {this.state.availableTabs.map(tabName => ( +
+ {tabName} +
+ ))} +
+ {integrationSnippet} +
+
+
+ + ) + } + + changeTab(e) { + e.preventDefault() + + this.setState({activeTab: e.target.dataset.tab}) + } +} diff --git a/formspree/js/forms/FormPage/Settings.js b/formspree/js/forms/FormPage/Settings.js new file mode 100644 index 0000000..39334f4 --- /dev/null +++ b/formspree/js/forms/FormPage/Settings.js @@ -0,0 +1,269 @@ +/** @format */ + +const toastr = window.toastr +const fetch = window.fetch +const React = require('react') + +const FormDescription = require('./FormDescription') + +module.exports = class FormSettings extends React.Component { + constructor(props) { + super(props) + + this.update = this.update.bind(this) + this.deleteForm = this.deleteForm.bind(this) + this.cancelDelete = this.cancelDelete.bind(this) + + this.state = { + deleting: false, + temporaryFormChanges: {} + } + } + + render() { + let {form} = this.props + let tmp = this.state.temporaryFormChanges + + return ( + <> +
+ +
+
+
+

Form Enabled

+

+ You can disable this form to cause it to stop receiving new + submissions temporarily or permanently. +

+
+
+ +
+
+
+
+
+

reCAPTCHA

+
+

+ reCAPTCHA provides vital spam protection, but you can turn it + off if you need. +

+
+
+
+ +
+
+
+
+
+

Email Notifications

+

+ You can disable the emails Formspree sends if you just want to + download the submissions from the dashboard. +

+
+
+ +
+
+
+
+

Submission Archive

+

+ You can disable the submission archive if you don't want + Formspree to store your submissions. +

+
+
+ +
+
+
+
+

+ {this.state.deleting + ? 'Are you sure you want to delete?' + : 'Delete Form'} +

+

+ {this.state.deleting ? ( + + This will delete the form on {form.host} targeting{' '} + {form.email} and all its submissions? This action{' '} + cannot be undone. + + ) : ( + + Deleting the form will erase all traces of this form on + our databases, including all the submissions. + + )} +

+
+
+ {this.state.deleting ? ( + <> + + + + ) : ( + + + + )} +
+
+
+
+ + ) + } + + async update(e) { + let attr = e.currentTarget.name + let val = !e.currentTarget.checked + + this.setState(state => { + state.temporaryFormChanges[attr] = val + return state + }) + + try { + let resp = await fetch(`/api-int/forms/${this.props.form.hashid}`, { + method: 'PATCH', + body: JSON.stringify({ + [attr]: val + }), + 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 settings: ${r.error}` + : 'Failed to save settings.' + ) + return + } + + toastr.success('Settings saved.') + this.props.onUpdate().then(() => { + this.setState({temporaryFormChanges: {}}) + }) + } catch (e) { + console.error(e) + toastr.error('Failed to update form. See the console for more details.') + this.setState({temporaryFormChanges: {}}) + } + } + + cancelDelete(e) { + e.preventDefault() + this.setState({deleting: false}) + } + + async deleteForm(e) { + e.preventDefault() + + if (this.props.form.counter > 0 && !this.state.deleting) { + // double-check the user intentions to delete, + // but only if the form has been used already. + this.setState({deleting: true}) + return + } + + this.setState({deleting: false}) + try { + let resp = await fetch(`/api-int/forms/${this.props.form.hashid}`, { + method: 'DELETE', + credentials: 'same-origin', + headers: { + Accept: 'application/json' + } + }) + let r = await resp.json() + + if (resp.error || r.error) { + toastr.warning( + r.error + ? `failed to delete form: ${r.error}` + : 'failed to delete form.' + ) + return + } + + toastr.success('Form successfully deleted.') + this.props.history.push('/forms') + } catch (e) { + console.error(e) + toastr.error('Failed to delete form. See the console for more details.') + } + } +} diff --git a/formspree/js/forms/FormPage/Submissions.js b/formspree/js/forms/FormPage/Submissions.js new file mode 100644 index 0000000..6460a48 --- /dev/null +++ b/formspree/js/forms/FormPage/Submissions.js @@ -0,0 +1,150 @@ +/** @format */ + +const toastr = window.toastr +const fetch = window.fetch +const React = require('react') + +const FormDescription = require('./FormDescription') + +module.exports = class FormSubmissions extends React.Component { + constructor(props) { + super(props) + + this.deleteSubmission = this.deleteSubmission.bind(this) + this.showExportButtons = this.showExportButtons.bind(this) + + this.state = { + exporting: false + } + } + + render() { + let {form} = this.props + + return ( +
+ + {form.submissions.length ? ( + <> + + + + + {form.fields + .slice(1 /* the first field is 'date' */) + .map(f => ( + + ))} + + + + {form.submissions.map(s => ( + + + {form.fields + .slice(1 /* the first field is 'date' */) + .map(f => ( + + ))} + + + ))} + +
Submitted at{f} +
+ {new Date(Date.parse(s.date)) + .toString() + .split(' ') + .slice(0, 5) + .join(' ')} + +
{s[f]}
+
+ +
+
+
+ {this.state.exporting ? ( + + ) : ( +
+ +
+ )} +
+
+ + ) : ( +

No submissions archived yet.

+ )} +
+ ) + } + + showExportButtons(e) { + e.preventDefault() + this.setState({exporting: true}) + } + + async deleteSubmission(e) { + e.preventDefault() + + let subid = e.currentTarget.dataset.sub + + try { + let resp = await fetch( + `/api-int/forms/${this.props.form.hashid}/submissions/${subid}`, + { + method: 'DELETE', + credentials: 'same-origin', + headers: {Accept: 'application/json'} + } + ) + let r = await resp.json() + + if (!resp.ok || r.error) { + toastr.warning( + r.error + ? `Failed to delete submission: ${r.error}` + : `Failed to delete submission.` + ) + return + } + + toastr.success('Submission deleted.') + this.props.onUpdate() + } catch (e) { + console.error(e) + toastr.error( + 'Failed to delete submission, see the console for more details.' + ) + } + } +} diff --git a/formspree/js/forms/FormPage/Whitelabel.js b/formspree/js/forms/FormPage/Whitelabel.js new file mode 100644 index 0000000..fddad9c --- /dev/null +++ b/formspree/js/forms/FormPage/Whitelabel.js @@ -0,0 +1,218 @@ +/** @format */ + +const toastr = window.toastr +const fetch = window.fetch +const React = require('react') +const CodeMirror = require('react-codemirror2') +require('codemirror/mode/xml/xml') +require('codemirror/mode/css/css') + +const FormDescription = require('./FormDescription') + +module.exports = class FormSettings extends React.Component { + constructor(props) { + super(props) + + this.changeFrom = this.changeFrom.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.revert = this.revert.bind(this) + this.deploy = this.deploy.bind(this) + + this.defaultValues = { + from: 'Team Formspree', + subject: 'New submission from {{ host }}', + style: `h1 { + color: black; +} +.content { + font-size: 20pt; +}`, + body: `
+

You've got a new submissions from {{ _replyto }}

+ + + {{# _fields }} + + + + + {{/ _fields }} +
{{ field_name }}{{ field_value }}
+
` + } + + this.state = { + previewing: null, + changes: {} + } + } + + render() { + let {form} = this.props + + let {from, subject, style, body} = { + ...this.defaultValues, + ...form.template, + ...this.state.changes + } + + return ( + <> +
+ +
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+ Overrides _subject field +
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+ {Object.keys(this.state.changes).length > 0 + ? 'changes pending' + : null} +
+
+ +
+
+ +
+
+
+ + ) + } + + changeFrom(e) { + let value = e.target.value + this.setState(state => { + state.changes.from = value + return state + }) + } + + changeSubject(e) { + let value = e.target.value + this.setState(state => { + state.changes.subject = value + return state + }) + } + + changeStyle(_, __, value) { + this.setState(state => { + state.changes.style = value + return state + }) + } + + changeBody(_, __, value) { + this.setState(state => { + state.changes.body = value + return state + }) + } + + closePreview() { + this.setState({previewing: null}) + } + + async preview() { + let template = { + ...this.defaultValues, + ...this.props.form.template, + ...this.state.changes + } + + try { + let resp = await fetch('/api-int/forms/whitelabel/preview', { + method: 'POST', + body: JSON.stringify(template), + credentials: 'same-origin', + headers: { + Accept: 'text/html', + 'Content-Type': 'application/json' + } + }) + let html = await resp.text() + + this.setState({previewing: html}) + } catch (e) { + console.error(e) + toastr.error( + 'Failed to see render preview. See the console for more details.' + ) + } + } + + revert() {} + deploy() {} +} diff --git a/formspree/js/forms/FormPage/index.js b/formspree/js/forms/FormPage/index.js new file mode 100644 index 0000000..3e0d169 --- /dev/null +++ b/formspree/js/forms/FormPage/index.js @@ -0,0 +1,132 @@ +/** @format */ + +const toastr = window.toastr +const fetch = window.fetch +const React = require('react') +const {Route, Link, NavLink, Redirect} = require('react-router-dom') + +const Portal = require('../../Portal') +const Integration = require('./Integration') +const Submissions = require('./Submissions') +const Settings = require('./Settings') +const Whitelabel = require('./Whitelabel') + +module.exports = class FormPage extends React.Component { + constructor(props) { + super(props) + + this.state = { + form: null + } + + this.fetchForm = this.fetchForm.bind(this) + } + + async componentDidMount() { + this.fetchForm() + } + + render() { + let hashid = this.props.match.params.hashid + + return ( + <> + + Your forms + + +

Form Details

+
+ } + /> + {this.state.form && ( + <> +

+ + Integration + + + Submissions + + + Settings + + {this.state.form.features.whitelabel && ( + + Whitelabel + + )} +

+ ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + + )} + + ) + } + + async fetchForm() { + let hashid = this.props.match.params.hashid + + try { + let resp = await fetch(`/api-int/forms/${hashid}`, { + credentials: 'same-origin', + headers: {Accept: 'application/json'} + }) + let r = await resp.json() + + if (!resp.ok || r.error) { + toastr.warning( + r.error ? `Error fetching form: ${r.error}` : 'Error fetching form.' + ) + return + } + + this.setState({form: r}) + } catch (e) { + console.error(e) + toastr.error(`Failed to fetch form, see the console for more details.`) + } + } +} diff --git a/formspree/routes.py b/formspree/routes.py index acd419c..c839be6 100644 --- a/formspree/routes.py +++ b/formspree/routes.py @@ -50,6 +50,7 @@ def configure_routes(app): app.add_url_rule('/api-int/forms/', 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//submissions/', view_func=fa.submission_delete, methods=['DELETE']) + app.add_url_rule('/api-int/forms/whitelabel/preview', view_func=fa.custom_template_preview_render, methods=['POST']) # Webhooks app.add_url_rule('/webhooks/stripe', view_func=uv.stripe_webhook, methods=['POST']) diff --git a/formspree/scss/dashboard.scss b/formspree/scss/dashboard.scss index 6b56a8f..9efad0b 100644 --- a/formspree/scss/dashboard.scss +++ b/formspree/scss/dashboard.scss @@ -494,6 +494,18 @@ } } +#whitelabel { + .container { + display: flex; + align-items: baseline; + } + + #from::after { + content: ' submissions@formspree.io'; + color: grey; + } +} + a.button.export { width: 11em; text-align: center;