Added preview component with preview support for campaigns and templates
This commit is contained in:
parent
2121c250ff
commit
a1b5a39cfb
23
campaigns.go
23
campaigns.go
|
@ -89,26 +89,26 @@ func handlePreviewCampaign(c echo.Context) error {
|
|||
var (
|
||||
app = c.Get("app").(*App)
|
||||
id, _ = strconv.Atoi(c.Param("id"))
|
||||
camps models.Campaigns
|
||||
body = c.FormValue("body")
|
||||
|
||||
camp models.Campaign
|
||||
)
|
||||
|
||||
if id < 1 {
|
||||
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 == sql.ErrNoRows {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.")
|
||||
}
|
||||
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
fmt.Sprintf("Error fetching campaign: %s", pqErrMsg(err)))
|
||||
} else if len(camps) == 0 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Campaign not found.")
|
||||
}
|
||||
|
||||
var (
|
||||
camp = camps[0]
|
||||
sub models.Subscriber
|
||||
)
|
||||
|
||||
var sub models.Subscriber
|
||||
// Get a random subscriber from the campaign.
|
||||
if err := app.Queries.GetOneCampaignSubscriber.Get(&sub, camp.ID); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
|
@ -126,7 +126,10 @@ func handlePreviewCampaign(c echo.Context) error {
|
|||
}
|
||||
|
||||
// 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 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Error compiling template: %v", err))
|
||||
}
|
||||
|
|
|
@ -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 * as cs from "./constants"
|
||||
import Media from "./Media"
|
||||
import ModalPreview from "./ModalPreview"
|
||||
|
||||
import moment from 'moment'
|
||||
import ReactQuill from "react-quill"
|
||||
|
@ -352,6 +353,7 @@ class Campaign extends React.PureComponent {
|
|||
record: {},
|
||||
contentType: "richtext",
|
||||
messengers: [],
|
||||
previewRecord: null,
|
||||
body: "",
|
||||
currentTab: "form",
|
||||
editor: null,
|
||||
|
@ -405,6 +407,10 @@ class Campaign extends React.PureComponent {
|
|||
this.setState({ currentTab: tab })
|
||||
}
|
||||
|
||||
handlePreview = (record) => {
|
||||
this.setState({ previewRecord: record })
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<section className="content campaign">
|
||||
|
@ -457,7 +463,7 @@ class Campaign extends React.PureComponent {
|
|||
formDisabled={ this.state.formDisabled }
|
||||
/>
|
||||
<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>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
|
@ -471,8 +477,19 @@ class Campaign extends React.PureComponent {
|
|||
insertMedia: this.state.editor ? this.state.editor.insertMedia : null,
|
||||
onCancel: this.toggleMedia,
|
||||
onOk: this.toggleMedia
|
||||
} } />
|
||||
}} />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import { Row, Col, Button, Table, Icon, Tooltip, Tag, Popconfirm, Progress, Moda
|
|||
import dayjs from "dayjs"
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
|
||||
import ModalPreview from "./ModalPreview"
|
||||
import * as cs from "./constants"
|
||||
|
||||
class Campaigns extends React.PureComponent {
|
||||
|
@ -15,6 +16,7 @@ class Campaigns extends React.PureComponent {
|
|||
queryParams: "",
|
||||
stats: {},
|
||||
record: null,
|
||||
previewRecord: null,
|
||||
cloneName: "",
|
||||
modalWaiting: false
|
||||
}
|
||||
|
@ -110,10 +112,16 @@ class Campaigns extends React.PureComponent {
|
|||
title: "",
|
||||
dataIndex: "actions",
|
||||
className: "actions",
|
||||
width: "10%",
|
||||
width: "20%",
|
||||
render: (text, record) => {
|
||||
return (
|
||||
<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">
|
||||
<a role="button" onClick={() => {
|
||||
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() {
|
||||
const pagination = {
|
||||
...this.paginationOptions,
|
||||
|
@ -377,18 +389,15 @@ class Campaigns extends React.PureComponent {
|
|||
pagination={ pagination }
|
||||
/>
|
||||
|
||||
{ this.state.record &&
|
||||
<Modal visible={ this.state.record } width="500px"
|
||||
className="clone-campaign-modal"
|
||||
title={ "Clone " + this.state.record.name}
|
||||
okText="Clone"
|
||||
confirmLoading={ this.state.modalWaiting }
|
||||
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> }
|
||||
{ this.state.previewRecord &&
|
||||
<ModalPreview
|
||||
title={ this.state.previewRecord.name }
|
||||
previewURL={ cs.Routes.PreviewCampaign.replace(":id", this.state.previewRecord.id) }
|
||||
onCancel={() => {
|
||||
this.setState({ previewRecord: null })
|
||||
}}
|
||||
/>
|
||||
}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
|
@ -1,13 +1,16 @@
|
|||
import React from "react"
|
||||
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 * as cs from "./constants"
|
||||
|
||||
class CreateFormDef extends React.PureComponent {
|
||||
state = {
|
||||
confirmDirty: false,
|
||||
modalWaiting: false
|
||||
modalWaiting: false,
|
||||
previewName: "",
|
||||
previewBody: ""
|
||||
}
|
||||
|
||||
// Handle create / edit form submission.
|
||||
|
@ -50,6 +53,10 @@ class CreateFormDef extends React.PureComponent {
|
|||
this.setState({ confirmDirty: this.state.confirmDirty || !!value })
|
||||
}
|
||||
|
||||
handlePreview = (name, body) => {
|
||||
this.setState({ previewName: name, previewBody: body })
|
||||
}
|
||||
|
||||
render() {
|
||||
const { formType, record, onClose } = this.props
|
||||
const { getFieldDecorator } = this.props.form
|
||||
|
@ -64,37 +71,54 @@ class CreateFormDef extends React.PureComponent {
|
|||
}
|
||||
|
||||
return (
|
||||
<Modal visible={ true } title={ formType === cs.FormCreate ? "Add template" : record.name }
|
||||
okText={ this.state.form === cs.FormCreate ? "Add" : "Save" }
|
||||
width="90%"
|
||||
height={ 900 }
|
||||
confirmLoading={ this.state.modalWaiting }
|
||||
onCancel={ onClose }
|
||||
onOk={ this.handleSubmit }>
|
||||
<div>
|
||||
<Modal visible={ true } title={ formType === cs.FormCreate ? "Add template" : record.name }
|
||||
okText={ this.state.form === cs.FormCreate ? "Add" : "Save" }
|
||||
width="90%"
|
||||
height={ 900 }
|
||||
confirmLoading={ this.state.modalWaiting }
|
||||
onCancel={ onClose }
|
||||
onOk={ this.handleSubmit }>
|
||||
|
||||
<Spin spinning={ this.props.reqStates[cs.ModelTemplates] === cs.StatePending }>
|
||||
<Form onSubmit={this.handleSubmit}>
|
||||
<Form.Item {...formItemLayout} label="Name">
|
||||
{getFieldDecorator("name", {
|
||||
initialValue: record.name,
|
||||
rules: [{ required: true }]
|
||||
})(<Input autoFocus maxLength="200" />)}
|
||||
</Form.Item>
|
||||
<Form.Item {...formItemLayout} name="body" label="Raw HTML">
|
||||
{getFieldDecorator("body", { initialValue: record.body ? record.body : "", rules: [{ required: true }] })(
|
||||
<Input.TextArea autosize={{ minRows: 10, maxRows: 30 }}>
|
||||
</Input.TextArea>
|
||||
)}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Spin>
|
||||
<Row>
|
||||
<Col span="4"></Col>
|
||||
<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>
|
||||
<Spin spinning={ this.props.reqStates[cs.ModelTemplates] === cs.StatePending }>
|
||||
<Form onSubmit={this.handleSubmit}>
|
||||
<Form.Item {...formItemLayout} label="Name">
|
||||
{getFieldDecorator("name", {
|
||||
initialValue: record.name,
|
||||
rules: [{ required: true }]
|
||||
})(<Input autoFocus maxLength="200" />)}
|
||||
</Form.Item>
|
||||
<Form.Item {...formItemLayout} name="body" label="Raw HTML">
|
||||
{getFieldDecorator("body", { initialValue: record.body ? record.body : "", rules: [{ required: true }] })(
|
||||
<Input.TextArea autosize={{ minRows: 10, maxRows: 30 }} />
|
||||
)}
|
||||
</Form.Item>
|
||||
<Form.Item {...formItemLayout} colon={ false } label=" ">
|
||||
<Button icon="search" onClick={ () =>
|
||||
this.handlePreview(this.props.form.getFieldValue("name"), this.props.form.getFieldValue("body"))
|
||||
}>Preview</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Spin>
|
||||
<Row>
|
||||
<Col span="4"></Col>
|
||||
<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 }
|
||||
/>
|
||||
|
||||
<Modal visible={ this.state.previewRecord !== null } title={ this.state.previewRecord ? this.state.previewRecord.name : "" }
|
||||
className="template-preview-modal"
|
||||
width="90%"
|
||||
height={ 900 }
|
||||
onOk={ () => { this.setState({ previewRecord: null }) } }>
|
||||
{ this.state.previewRecord !== null &&
|
||||
<iframe title="Template preview"
|
||||
className="template-preview"
|
||||
src={ cs.Routes.PreviewTemplate.replace(":id", this.state.previewRecord.id) }>
|
||||
</iframe> }
|
||||
</Modal>
|
||||
{ this.state.previewRecord &&
|
||||
<ModalPreview
|
||||
title={ this.state.previewRecord.name }
|
||||
previewURL={ cs.Routes.PreviewTemplate.replace(":id", this.state.previewRecord.id) }
|
||||
onCancel={() => {
|
||||
this.setState({ previewRecord: null })
|
||||
}}
|
||||
/>
|
||||
}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -60,6 +60,7 @@ export const Routes = {
|
|||
|
||||
GetSubscribers: "/api/subscribers",
|
||||
GetSubscribersByList: "/api/subscribers/lists/:listID",
|
||||
PreviewCampaign: "/api/campaigns/:id/preview",
|
||||
CreateSubscriber: "/api/subscribers",
|
||||
UpdateSubscriber: "/api/subscribers/:id",
|
||||
DeleteSubscriber: "/api/subscribers/:id",
|
||||
|
@ -81,6 +82,7 @@ export const Routes = {
|
|||
|
||||
GetTemplates: "/api/templates",
|
||||
PreviewTemplate: "/api/templates/:id/preview",
|
||||
PreviewNewTemplate: "/api/templates/preview",
|
||||
CreateTemplate: "/api/templates",
|
||||
UpdateTemplate: "/api/templates/:id",
|
||||
SetDefaultTemplate: "/api/templates/:id/default",
|
||||
|
|
|
@ -253,12 +253,15 @@ td.actions {
|
|||
.templates .template-body {
|
||||
margin-top: 30px;
|
||||
}
|
||||
.template-preview {
|
||||
.preview-iframe-container {
|
||||
min-height: 500px;
|
||||
}
|
||||
.preview-iframe {
|
||||
border: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 500px;
|
||||
}
|
||||
.template-preview-modal .ant-modal-footer button:first-child {
|
||||
.preview-modal .ant-modal-footer button:first-child {
|
||||
display: none;
|
||||
}
|
2
main.go
2
main.go
|
@ -103,6 +103,7 @@ func registerHandlers(e *echo.Echo) {
|
|||
e.GET("/api/campaigns/messengers", handleGetCampaignMessengers)
|
||||
e.GET("/api/campaigns/:id", handleGetCampaigns)
|
||||
e.GET("/api/campaigns/:id/preview", handlePreviewCampaign)
|
||||
e.POST("/api/campaigns/:id/preview", handlePreviewCampaign)
|
||||
e.POST("/api/campaigns", handleCreateCampaign)
|
||||
e.PUT("/api/campaigns/:id", handleUpdateCampaign)
|
||||
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/:id", handleGetTemplates)
|
||||
e.GET("/api/templates/:id/preview", handlePreviewTemplate)
|
||||
e.POST("/api/templates/preview", handlePreviewTemplate)
|
||||
e.POST("/api/templates", handleCreateTemplate)
|
||||
e.PUT("/api/templates/:id", handleUpdateTemplate)
|
||||
e.PUT("/api/templates/:id/default", handleTemplateSetDefault)
|
||||
|
|
|
@ -27,6 +27,7 @@ type Queries struct {
|
|||
|
||||
CreateCampaign *sqlx.Stmt `query:"create-campaign"`
|
||||
GetCampaigns *sqlx.Stmt `query:"get-campaigns"`
|
||||
GetCampaignForPreview *sqlx.Stmt `query:"get-campaign-for-preview"`
|
||||
GetCampaignStats *sqlx.Stmt `query:"get-campaign-stats"`
|
||||
NextCampaigns *sqlx.Stmt `query:"next-campaigns"`
|
||||
NextCampaignSubscribers *sqlx.Stmt `query:"next-campaign-subscribers"`
|
||||
|
|
|
@ -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
|
||||
FROM campaigns
|
||||
LEFT JOIN templates ON (templates.id = campaigns.template_id)
|
||||
WHERE (status='running' OR (status='scheduled' AND campaigns.send_at >= NOW()))
|
||||
AND NOT(campaigns.id = ANY($1::INT[]))
|
||||
WHERE campaigns.id = $1;
|
||||
|
||||
-- name: get-campaign-stats
|
||||
SELECT id, status, to_send, sent, started_at, updated_at
|
||||
|
|
|
@ -49,7 +49,6 @@ func handleGetSubscriber(c echo.Context) error {
|
|||
id, _ = strconv.Atoi(c.Param("id"))
|
||||
)
|
||||
|
||||
// Fetch one list.
|
||||
if id < 1 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid subscriber ID.")
|
||||
}
|
||||
|
|
30
templates.go
30
templates.go
|
@ -66,26 +66,30 @@ func handlePreviewTemplate(c echo.Context) error {
|
|||
var (
|
||||
app = c.Get("app").(*App)
|
||||
id, _ = strconv.Atoi(c.Param("id"))
|
||||
tpls []models.Template
|
||||
body = c.FormValue("body")
|
||||
|
||||
tpls []models.Template
|
||||
)
|
||||
|
||||
if id < 1 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
|
||||
}
|
||||
if body == "" {
|
||||
if id < 1 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid ID.")
|
||||
}
|
||||
|
||||
err := app.Queries.GetTemplates.Select(&tpls, id, false)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
fmt.Sprintf("Error fetching templates: %s", pqErrMsg(err)))
|
||||
}
|
||||
err := app.Queries.GetTemplates.Select(&tpls, id, false)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError,
|
||||
fmt.Sprintf("Error fetching templates: %s", pqErrMsg(err)))
|
||||
}
|
||||
|
||||
if len(tpls) == 0 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Template not found.")
|
||||
if len(tpls) == 0 {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Template not found.")
|
||||
}
|
||||
body = tpls[0].Body
|
||||
}
|
||||
t := tpls[0]
|
||||
|
||||
// Compile the template.
|
||||
tpl, err := runner.CompileMessageTemplate(t.Body, dummyTpl)
|
||||
tpl, err := runner.CompileMessageTemplate(body, dummyTpl)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Error compiling template: %v", err))
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue