Added preview component with preview support for campaigns and templates

This commit is contained in:
Kailash Nadh 2018-10-26 11:18:17 +05:30
parent 2121c250ff
commit a1b5a39cfb
12 changed files with 200 additions and 85 deletions

View File

@ -89,26 +89,26 @@ func handlePreviewCampaign(c echo.Context) error {
var ( var (
app = c.Get("app").(*App) app = c.Get("app").(*App)
id, _ = strconv.Atoi(c.Param("id")) id, _ = strconv.Atoi(c.Param("id"))
camps models.Campaigns body = c.FormValue("body")
camp models.Campaign
) )
if id < 1 { if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.") return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
} }
err := app.Queries.GetCampaigns.Select(&camps, id, "", 0, 1) err := app.Queries.GetCampaignForPreview.Get(&camp, id)
if err != nil { if err != nil {
if err == sql.ErrNoRows {
return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.")
}
return echo.NewHTTPError(http.StatusInternalServerError, return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching campaign: %s", pqErrMsg(err))) fmt.Sprintf("Error fetching campaign: %s", pqErrMsg(err)))
} else if len(camps) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.")
} }
var ( var sub models.Subscriber
camp = camps[0]
sub models.Subscriber
)
// Get a random subscriber from the campaign. // Get a random subscriber from the campaign.
if err := app.Queries.GetOneCampaignSubscriber.Get(&sub, camp.ID); err != nil { if err := app.Queries.GetOneCampaignSubscriber.Get(&sub, camp.ID); err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
@ -126,7 +126,10 @@ func handlePreviewCampaign(c echo.Context) error {
} }
// Compile the template. // Compile the template.
tpl, err := runner.CompileMessageTemplate(`{{ template "content" . }}`, camp.Body) if body == "" {
body = camp.Body
}
tpl, err := runner.CompileMessageTemplate(camp.TemplateBody, body)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Error compiling template: %v", err)) return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Error compiling template: %v", err))
} }

View File

@ -2,6 +2,7 @@ import React from "react"
import { Modal, Tabs, Row, Col, Form, Switch, Select, Radio, Tag, Input, Button, Icon, Spin, DatePicker, Popconfirm, notification } from "antd" import { Modal, Tabs, Row, Col, Form, Switch, Select, Radio, Tag, Input, Button, Icon, Spin, DatePicker, Popconfirm, notification } from "antd"
import * as cs from "./constants" import * as cs from "./constants"
import Media from "./Media" import Media from "./Media"
import ModalPreview from "./ModalPreview"
import moment from 'moment' import moment from 'moment'
import ReactQuill from "react-quill" import ReactQuill from "react-quill"
@ -352,6 +353,7 @@ class Campaign extends React.PureComponent {
record: {}, record: {},
contentType: "richtext", contentType: "richtext",
messengers: [], messengers: [],
previewRecord: null,
body: "", body: "",
currentTab: "form", currentTab: "form",
editor: null, editor: null,
@ -405,6 +407,10 @@ class Campaign extends React.PureComponent {
this.setState({ currentTab: tab }) this.setState({ currentTab: tab })
} }
handlePreview = (record) => {
this.setState({ previewRecord: record })
}
render() { render() {
return ( return (
<section className="content campaign"> <section className="content campaign">
@ -457,7 +463,7 @@ class Campaign extends React.PureComponent {
formDisabled={ this.state.formDisabled } formDisabled={ this.state.formDisabled }
/> />
<div className="content-actions"> <div className="content-actions">
<p><Button icon="search">Preview</Button></p> <p><Button icon="search" onClick={() => this.handlePreview(this.state.record)}>Preview</Button></p>
</div> </div>
</Tabs.TabPane> </Tabs.TabPane>
</Tabs> </Tabs>
@ -471,8 +477,19 @@ class Campaign extends React.PureComponent {
insertMedia: this.state.editor ? this.state.editor.insertMedia : null, insertMedia: this.state.editor ? this.state.editor.insertMedia : null,
onCancel: this.toggleMedia, onCancel: this.toggleMedia,
onOk: this.toggleMedia onOk: this.toggleMedia
} } /> }} />
</Modal> </Modal>
{ this.state.previewRecord &&
<ModalPreview
title={ this.state.previewRecord.name }
body={ this.state.body }
previewURL={ cs.Routes.PreviewCampaign.replace(":id", this.state.previewRecord.id) }
onCancel={() => {
this.setState({ previewRecord: null })
}}
/>
}
</section> </section>
) )
} }

View File

@ -4,6 +4,7 @@ import { Row, Col, Button, Table, Icon, Tooltip, Tag, Popconfirm, Progress, Moda
import dayjs from "dayjs" import dayjs from "dayjs"
import relativeTime from 'dayjs/plugin/relativeTime' import relativeTime from 'dayjs/plugin/relativeTime'
import ModalPreview from "./ModalPreview"
import * as cs from "./constants" import * as cs from "./constants"
class Campaigns extends React.PureComponent { class Campaigns extends React.PureComponent {
@ -15,6 +16,7 @@ class Campaigns extends React.PureComponent {
queryParams: "", queryParams: "",
stats: {}, stats: {},
record: null, record: null,
previewRecord: null,
cloneName: "", cloneName: "",
modalWaiting: false modalWaiting: false
} }
@ -110,10 +112,16 @@ class Campaigns extends React.PureComponent {
title: "", title: "",
dataIndex: "actions", dataIndex: "actions",
className: "actions", className: "actions",
width: "10%", width: "20%",
render: (text, record) => { render: (text, record) => {
return ( return (
<div className="actions"> <div className="actions">
<Tooltip title="Preview campaign" placement="bottom">
<a role="button" onClick={() => {
this.handlePreview(record)
}}><Icon type="search" /></a>
</Tooltip>
<Tooltip title="Clone campaign" placement="bottom"> <Tooltip title="Clone campaign" placement="bottom">
<a role="button" onClick={() => { <a role="button" onClick={() => {
let r = { ...record, lists: record.lists.map((i) => { return i.id }) } let r = { ...record, lists: record.lists.map((i) => { return i.id }) }
@ -352,6 +360,10 @@ class Campaigns extends React.PureComponent {
}) })
} }
handlePreview = (record) => {
this.setState({ previewRecord: record })
}
render() { render() {
const pagination = { const pagination = {
...this.paginationOptions, ...this.paginationOptions,
@ -377,18 +389,15 @@ class Campaigns extends React.PureComponent {
pagination={ pagination } pagination={ pagination }
/> />
{ this.state.record && { this.state.previewRecord &&
<Modal visible={ this.state.record } width="500px" <ModalPreview
className="clone-campaign-modal" title={ this.state.previewRecord.name }
title={ "Clone " + this.state.record.name} previewURL={ cs.Routes.PreviewCampaign.replace(":id", this.state.previewRecord.id) }
okText="Clone" onCancel={() => {
confirmLoading={ this.state.modalWaiting } this.setState({ previewRecord: null })
onCancel={ this.handleToggleCloneForm } }}
onOk={() => { this.handleCloneCampaign({ ...this.state.record, name: this.state.cloneName }) }}> />
<Input autoFocus defaultValue={ this.state.record.name } style={{ width: "100%" }} onChange={(e) => { }
this.setState({ cloneName: e.target.value })
}} />
</Modal> }
</section> </section>
) )
} }

View File

@ -0,0 +1,54 @@
import React from "react"
import { Modal } from "antd"
import * as cs from "./constants"
class ModalPreview extends React.PureComponent {
makeForm(body) {
let form = document.createElement("form")
form.method = cs.MethodPost
form.action = this.props.previewURL
form.target = "preview-iframe"
let input = document.createElement("input")
input.type = "hidden"
input.name = "body"
input.value = body
form.appendChild(input)
document.body.appendChild(form)
form.submit()
}
render () {
return (
<Modal visible={ true } title={ this.props.title }
className="preview-modal"
width="90%"
height={ 900 }
onCancel={ this.props.onCancel }
onOk={ this.props.onCancel }>
<div className="preview-iframe-container">
<iframe title={ this.props.title ? this.props.title : "Preview" }
name="preview-iframe"
id="preview-iframe"
className="preview-iframe"
ref={(o) => {
if(o) {
// When the DOM reference for the iframe is ready,
// see if there's a body to post with the form hack.
if(this.props.body !== undefined
&& this.props.body !== null) {
this.makeForm(this.props.body)
}
}
}}
src={ this.props.previewURL ? this.props.previewURL : "about:blank" }>
</iframe>
</div>
</Modal>
)
}
}
export default ModalPreview

View File

@ -1,13 +1,16 @@
import React from "react" import React from "react"
import { Row, Col, Modal, Form, Input, Button, Table, Icon, Tooltip, Tag, Popconfirm, Spin, notification } from "antd" import { Row, Col, Modal, Form, Input, Button, Table, Icon, Tooltip, Tag, Popconfirm, Spin, notification } from "antd"
import ModalPreview from "./ModalPreview"
import Utils from "./utils" import Utils from "./utils"
import * as cs from "./constants" import * as cs from "./constants"
class CreateFormDef extends React.PureComponent { class CreateFormDef extends React.PureComponent {
state = { state = {
confirmDirty: false, confirmDirty: false,
modalWaiting: false modalWaiting: false,
previewName: "",
previewBody: ""
} }
// Handle create / edit form submission. // Handle create / edit form submission.
@ -50,6 +53,10 @@ class CreateFormDef extends React.PureComponent {
this.setState({ confirmDirty: this.state.confirmDirty || !!value }) this.setState({ confirmDirty: this.state.confirmDirty || !!value })
} }
handlePreview = (name, body) => {
this.setState({ previewName: name, previewBody: body })
}
render() { render() {
const { formType, record, onClose } = this.props const { formType, record, onClose } = this.props
const { getFieldDecorator } = this.props.form const { getFieldDecorator } = this.props.form
@ -64,37 +71,54 @@ class CreateFormDef extends React.PureComponent {
} }
return ( return (
<Modal visible={ true } title={ formType === cs.FormCreate ? "Add template" : record.name } <div>
okText={ this.state.form === cs.FormCreate ? "Add" : "Save" } <Modal visible={ true } title={ formType === cs.FormCreate ? "Add template" : record.name }
width="90%" okText={ this.state.form === cs.FormCreate ? "Add" : "Save" }
height={ 900 } width="90%"
confirmLoading={ this.state.modalWaiting } height={ 900 }
onCancel={ onClose } confirmLoading={ this.state.modalWaiting }
onOk={ this.handleSubmit }> onCancel={ onClose }
onOk={ this.handleSubmit }>
<Spin spinning={ this.props.reqStates[cs.ModelTemplates] === cs.StatePending }> <Spin spinning={ this.props.reqStates[cs.ModelTemplates] === cs.StatePending }>
<Form onSubmit={this.handleSubmit}> <Form onSubmit={this.handleSubmit}>
<Form.Item {...formItemLayout} label="Name"> <Form.Item {...formItemLayout} label="Name">
{getFieldDecorator("name", { {getFieldDecorator("name", {
initialValue: record.name, initialValue: record.name,
rules: [{ required: true }] rules: [{ required: true }]
})(<Input autoFocus maxLength="200" />)} })(<Input autoFocus maxLength="200" />)}
</Form.Item> </Form.Item>
<Form.Item {...formItemLayout} name="body" label="Raw HTML"> <Form.Item {...formItemLayout} name="body" label="Raw HTML">
{getFieldDecorator("body", { initialValue: record.body ? record.body : "", rules: [{ required: true }] })( {getFieldDecorator("body", { initialValue: record.body ? record.body : "", rules: [{ required: true }] })(
<Input.TextArea autosize={{ minRows: 10, maxRows: 30 }}> <Input.TextArea autosize={{ minRows: 10, maxRows: 30 }} />
</Input.TextArea> )}
)} </Form.Item>
</Form.Item> <Form.Item {...formItemLayout} colon={ false } label="&nbsp;">
</Form> <Button icon="search" onClick={ () =>
</Spin> this.handlePreview(this.props.form.getFieldValue("name"), this.props.form.getFieldValue("body"))
<Row> }>Preview</Button>
<Col span="4"></Col> </Form.Item>
<Col span="18" className="text-grey text-small"> </Form>
The placeholder <code>{'{'}{'{'} template "content" . {'}'}{'}'}</code> should appear in the template. <a href="" target="_blank">Read more on templating</a>. </Spin>
</Col> <Row>
</Row> <Col span="4"></Col>
</Modal> <Col span="18" className="text-grey text-small">
The placeholder <code>{'{'}{'{'} template "content" . {'}'}{'}'}</code> should appear in the template. <a href="" target="_blank">Read more on templating</a>.
</Col>
</Row>
</Modal>
{ this.state.previewBody &&
<ModalPreview
title={ this.state.previewName ? this.state.previewName : "Template preview" }
previewURL={ cs.Routes.PreviewTemplate }
body={ this.state.previewBody }
onCancel={() => {
this.setState({ previewBody: null, previewName: null })
}}
/>
}
</div>
) )
} }
} }
@ -243,17 +267,15 @@ class Templates extends React.PureComponent {
fetchRecords = { this.fetchRecords } fetchRecords = { this.fetchRecords }
/> />
<Modal visible={ this.state.previewRecord !== null } title={ this.state.previewRecord ? this.state.previewRecord.name : "" } { this.state.previewRecord &&
className="template-preview-modal" <ModalPreview
width="90%" title={ this.state.previewRecord.name }
height={ 900 } previewURL={ cs.Routes.PreviewTemplate.replace(":id", this.state.previewRecord.id) }
onOk={ () => { this.setState({ previewRecord: null }) } }> onCancel={() => {
{ this.state.previewRecord !== null && this.setState({ previewRecord: null })
<iframe title="Template preview" }}
className="template-preview" />
src={ cs.Routes.PreviewTemplate.replace(":id", this.state.previewRecord.id) }> }
</iframe> }
</Modal>
</section> </section>
) )
} }

View File

@ -60,6 +60,7 @@ export const Routes = {
GetSubscribers: "/api/subscribers", GetSubscribers: "/api/subscribers",
GetSubscribersByList: "/api/subscribers/lists/:listID", GetSubscribersByList: "/api/subscribers/lists/:listID",
PreviewCampaign: "/api/campaigns/:id/preview",
CreateSubscriber: "/api/subscribers", CreateSubscriber: "/api/subscribers",
UpdateSubscriber: "/api/subscribers/:id", UpdateSubscriber: "/api/subscribers/:id",
DeleteSubscriber: "/api/subscribers/:id", DeleteSubscriber: "/api/subscribers/:id",
@ -81,6 +82,7 @@ export const Routes = {
GetTemplates: "/api/templates", GetTemplates: "/api/templates",
PreviewTemplate: "/api/templates/:id/preview", PreviewTemplate: "/api/templates/:id/preview",
PreviewNewTemplate: "/api/templates/preview",
CreateTemplate: "/api/templates", CreateTemplate: "/api/templates",
UpdateTemplate: "/api/templates/:id", UpdateTemplate: "/api/templates/:id",
SetDefaultTemplate: "/api/templates/:id/default", SetDefaultTemplate: "/api/templates/:id/default",

View File

@ -253,12 +253,15 @@ td.actions {
.templates .template-body { .templates .template-body {
margin-top: 30px; margin-top: 30px;
} }
.template-preview { .preview-iframe-container {
min-height: 500px;
}
.preview-iframe {
border: 0; border: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
min-height: 500px; min-height: 500px;
} }
.template-preview-modal .ant-modal-footer button:first-child { .preview-modal .ant-modal-footer button:first-child {
display: none; display: none;
} }

View File

@ -103,6 +103,7 @@ func registerHandlers(e *echo.Echo) {
e.GET("/api/campaigns/messengers", handleGetCampaignMessengers) e.GET("/api/campaigns/messengers", handleGetCampaignMessengers)
e.GET("/api/campaigns/:id", handleGetCampaigns) e.GET("/api/campaigns/:id", handleGetCampaigns)
e.GET("/api/campaigns/:id/preview", handlePreviewCampaign) e.GET("/api/campaigns/:id/preview", handlePreviewCampaign)
e.POST("/api/campaigns/:id/preview", handlePreviewCampaign)
e.POST("/api/campaigns", handleCreateCampaign) e.POST("/api/campaigns", handleCreateCampaign)
e.PUT("/api/campaigns/:id", handleUpdateCampaign) e.PUT("/api/campaigns/:id", handleUpdateCampaign)
e.PUT("/api/campaigns/:id/status", handleUpdateCampaignStatus) e.PUT("/api/campaigns/:id/status", handleUpdateCampaignStatus)
@ -115,6 +116,7 @@ func registerHandlers(e *echo.Echo) {
e.GET("/api/templates", handleGetTemplates) e.GET("/api/templates", handleGetTemplates)
e.GET("/api/templates/:id", handleGetTemplates) e.GET("/api/templates/:id", handleGetTemplates)
e.GET("/api/templates/:id/preview", handlePreviewTemplate) e.GET("/api/templates/:id/preview", handlePreviewTemplate)
e.POST("/api/templates/preview", handlePreviewTemplate)
e.POST("/api/templates", handleCreateTemplate) e.POST("/api/templates", handleCreateTemplate)
e.PUT("/api/templates/:id", handleUpdateTemplate) e.PUT("/api/templates/:id", handleUpdateTemplate)
e.PUT("/api/templates/:id/default", handleTemplateSetDefault) e.PUT("/api/templates/:id/default", handleTemplateSetDefault)

View File

@ -27,6 +27,7 @@ type Queries struct {
CreateCampaign *sqlx.Stmt `query:"create-campaign"` CreateCampaign *sqlx.Stmt `query:"create-campaign"`
GetCampaigns *sqlx.Stmt `query:"get-campaigns"` GetCampaigns *sqlx.Stmt `query:"get-campaigns"`
GetCampaignForPreview *sqlx.Stmt `query:"get-campaign-for-preview"`
GetCampaignStats *sqlx.Stmt `query:"get-campaign-stats"` GetCampaignStats *sqlx.Stmt `query:"get-campaign-stats"`
NextCampaigns *sqlx.Stmt `query:"next-campaigns"` NextCampaigns *sqlx.Stmt `query:"next-campaigns"`
NextCampaignSubscribers *sqlx.Stmt `query:"next-campaign-subscribers"` NextCampaignSubscribers *sqlx.Stmt `query:"next-campaign-subscribers"`

View File

@ -161,8 +161,7 @@ ORDER BY created_at DESC OFFSET $3 LIMIT $4;
SELECT campaigns.*, COALESCE(templates.body, (SELECT body FROM templates WHERE is_default = true LIMIT 1)) AS template_body SELECT campaigns.*, COALESCE(templates.body, (SELECT body FROM templates WHERE is_default = true LIMIT 1)) AS template_body
FROM campaigns FROM campaigns
LEFT JOIN templates ON (templates.id = campaigns.template_id) LEFT JOIN templates ON (templates.id = campaigns.template_id)
WHERE (status='running' OR (status='scheduled' AND campaigns.send_at >= NOW())) WHERE campaigns.id = $1;
AND NOT(campaigns.id = ANY($1::INT[]))
-- name: get-campaign-stats -- name: get-campaign-stats
SELECT id, status, to_send, sent, started_at, updated_at SELECT id, status, to_send, sent, started_at, updated_at

View File

@ -49,7 +49,6 @@ func handleGetSubscriber(c echo.Context) error {
id, _ = strconv.Atoi(c.Param("id")) id, _ = strconv.Atoi(c.Param("id"))
) )
// Fetch one list.
if id < 1 { if id < 1 {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid subscriber ID.") return echo.NewHTTPError(http.StatusBadRequest, "Invalid subscriber ID.")
} }

View File

@ -66,26 +66,30 @@ func handlePreviewTemplate(c echo.Context) error {
var ( var (
app = c.Get("app").(*App) app = c.Get("app").(*App)
id, _ = strconv.Atoi(c.Param("id")) id, _ = strconv.Atoi(c.Param("id"))
tpls []models.Template body = c.FormValue("body")
tpls []models.Template
) )
if id < 1 { if body == "" {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.") if id < 1 {
} return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
}
err := app.Queries.GetTemplates.Select(&tpls, id, false) err := app.Queries.GetTemplates.Select(&tpls, id, false)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error fetching templates: %s", pqErrMsg(err))) fmt.Sprintf("Error fetching templates: %s", pqErrMsg(err)))
} }
if len(tpls) == 0 { if len(tpls) == 0 {
return echo.NewHTTPError(http.StatusBadRequest, "Template not found.") return echo.NewHTTPError(http.StatusBadRequest, "Template not found.")
}
body = tpls[0].Body
} }
t := tpls[0]
// Compile the template. // Compile the template.
tpl, err := runner.CompileMessageTemplate(t.Body, dummyTpl) tpl, err := runner.CompileMessageTemplate(body, dummyTpl)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Error compiling template: %v", err)) return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Error compiling template: %v", err))
} }