Reformat all JS to 'prettier' style

This commit is contained in:
Kailash Nadh 2019-03-09 13:16:47 +05:30
parent 5b42e8659f
commit 917cb8aeed
20 changed files with 4639 additions and 3253 deletions

View File

@ -1,17 +1,18 @@
const {injectBabelPlugin} = require("react-app-rewired"); const { injectBabelPlugin } = require("react-app-rewired")
const rewireLess = require("react-app-rewire-less"); const rewireLess = require("react-app-rewire-less")
module.exports = function override(config, env) { module.exports = function override(config, env) {
config = injectBabelPlugin( config = injectBabelPlugin(
[ [
"import", { "import",
{
libraryName: "antd", libraryName: "antd",
libraryDirectory: "es", libraryDirectory: "es",
style: true style: true
} }
], // change importing css to less ], // change importing css to less
config, config
); )
config = rewireLess.withLoaderOptions({ config = rewireLess.withLoaderOptions({
modifyVars: { modifyVars: {
"@font-family": "@font-family":
@ -24,7 +25,7 @@ module.exports = function override(config, env) {
"@shadow-1-right": "2px 0 3px @shadow-color", "@shadow-1-right": "2px 0 3px @shadow-color",
"@shadow-2": "0 2px 6px @shadow-color" "@shadow-2": "0 2px 6px @shadow-color"
}, },
javascriptEnabled: true, javascriptEnabled: true
})(config, env); })(config, env)
return config; return config
}; }

View File

@ -27,6 +27,8 @@
}, },
"devDependencies": { "devDependencies": {
"babel-plugin-import": "^1.11.0", "babel-plugin-import": "^1.11.0",
"less-plugin-npm-import": "^2.1.0" "eslint-plugin-prettier": "^3.0.1",
"less-plugin-npm-import": "^2.1.0",
"prettier": "1.15.3"
} }
} }

View File

@ -1,13 +1,13 @@
import React from 'react' import React from "react"
import Utils from './utils' import Utils from "./utils"
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from "react-router-dom"
import { Icon, notification } from "antd" import { Icon, notification } from "antd"
import axios from 'axios' import axios from "axios"
import qs from 'qs' import qs from "qs"
import logo from "./static/listmonk.svg" import logo from "./static/listmonk.svg"
import Layout from './Layout' import Layout from "./Layout"
import * as cs from './constants' import * as cs from "./constants"
/* /*
App acts as a an "automagic" wrapper for all sub components. It is also the central App acts as a an "automagic" wrapper for all sub components. It is also the central
@ -26,22 +26,29 @@ import * as cs from './constants'
*/ */
class App extends React.PureComponent { class App extends React.PureComponent {
models = [cs.ModelUsers, models = [
cs.ModelUsers,
cs.ModelSubscribers, cs.ModelSubscribers,
cs.ModelLists, cs.ModelLists,
cs.ModelCampaigns, cs.ModelCampaigns,
cs.ModelTemplates] cs.ModelTemplates
]
state = { state = {
// Initialize empty states. // Initialize empty states.
reqStates: this.models.reduce((map, obj) => (map[obj] = cs.StatePending, map), {}), reqStates: this.models.reduce(
data: this.models.reduce((map, obj) => (map[obj] = [], map), {}), // eslint-disable-next-line
(map, obj) => ((map[obj] = cs.StatePending), map),
{}
),
// eslint-disable-next-line
data: this.models.reduce((map, obj) => ((map[obj] = []), map), {}),
modStates: {} modStates: {}
} }
componentDidMount = () => { componentDidMount = () => {
axios.defaults.paramsSerializer = params => { axios.defaults.paramsSerializer = params => {
return qs.stringify(params, {arrayFormat: "repeat"}); return qs.stringify(params, { arrayFormat: "repeat" })
} }
} }
@ -50,11 +57,13 @@ class App extends React.PureComponent {
modelRequest = async (model, route, method, params) => { modelRequest = async (model, route, method, params) => {
let url = replaceParams(route, params) let url = replaceParams(route, params)
this.setState({ reqStates: { ...this.state.reqStates, [model]: cs.StatePending } }) this.setState({
reqStates: { ...this.state.reqStates, [model]: cs.StatePending }
})
try { try {
let req = { let req = {
method: method, method: method,
url: url, url: url
} }
if (method === cs.MethodGet || method === cs.MethodDelete) { if (method === cs.MethodGet || method === cs.MethodDelete) {
@ -63,27 +72,30 @@ class App extends React.PureComponent {
req.data = params ? params : {} req.data = params ? params : {}
} }
let res = await axios(req) let res = await axios(req)
this.setState({ reqStates: { ...this.state.reqStates, [model]: cs.StateDone } }) this.setState({
reqStates: { ...this.state.reqStates, [model]: cs.StateDone }
})
// If it's a GET call, set the response as the data state. // If it's a GET call, set the response as the data state.
if (method === cs.MethodGet) { if (method === cs.MethodGet) {
this.setState({ data: { ...this.state.data, [model]: res.data.data } }) this.setState({ data: { ...this.state.data, [model]: res.data.data } })
} }
return res return res
} catch (e) { } catch (e) {
// If it's a GET call, throw a global notification. // If it's a GET call, throw a global notification.
if (method === cs.MethodGet) { if (method === cs.MethodGet) {
notification["error"]({ placement: cs.MsgPosition, notification["error"]({
placement: cs.MsgPosition,
message: "Error fetching data", message: "Error fetching data",
description: Utils.HttpError(e).message description: Utils.HttpError(e).message
}) })
} }
// Set states and show the error on the layout. // Set states and show the error on the layout.
this.setState({ reqStates: { ...this.state.reqStates, [model]: cs.StateDone } }) this.setState({
reqStates: { ...this.state.reqStates, [model]: cs.StateDone }
})
throw Utils.HttpError(e) throw Utils.HttpError(e)
} }
} }
@ -92,7 +104,9 @@ class App extends React.PureComponent {
request = async (url, method, params, headers) => { request = async (url, method, params, headers) => {
url = replaceParams(url, params) url = replaceParams(url, params)
this.setState({ reqStates: { ...this.state.reqStates, [url]: cs.StatePending } }) this.setState({
reqStates: { ...this.state.reqStates, [url]: cs.StatePending }
})
try { try {
let req = { let req = {
method: method, method: method,
@ -100,7 +114,7 @@ class App extends React.PureComponent {
headers: headers ? headers : {} headers: headers ? headers : {}
} }
if(method === cs.MethodGet || method === cs.MethodDelete) { if (method === cs.MethodGet || method === cs.MethodDelete) {
req.params = params ? params : {} req.params = params ? params : {}
} else { } else {
req.data = params ? params : {} req.data = params ? params : {}
@ -108,31 +122,38 @@ class App extends React.PureComponent {
let res = await axios(req) let res = await axios(req)
this.setState({ reqStates: { ...this.state.reqStates, [url]: cs.StateDone } }) this.setState({
reqStates: { ...this.state.reqStates, [url]: cs.StateDone }
})
return res return res
} catch (e) { } catch (e) {
this.setState({ reqStates: { ...this.state.reqStates, [url]: cs.StateDone } }) this.setState({
reqStates: { ...this.state.reqStates, [url]: cs.StateDone }
})
throw Utils.HttpError(e) throw Utils.HttpError(e)
} }
} }
pageTitle = title => {
pageTitle = (title) => {
document.title = title document.title = title
} }
render() { render() {
if(!window.CONFIG) { if (!window.CONFIG) {
return( return (
<div className="broken"> <div className="broken">
<p> <p>
<img src={logo} alt="listmonk logo" /> <img src={logo} alt="listmonk logo" />
</p> </p>
<hr /> <hr />
<h1><Icon type="warning" /> Something's not right</h1> <h1>
<p>The app configuration could not be loaded. <Icon type="warning" /> Something's not right
Please ensure that the app is running and then refresh this page.</p> </h1>
<p>
The app configuration could not be loaded. Please ensure that the
app is running and then refresh this page.
</p>
</div> </div>
) )
} }
@ -140,24 +161,25 @@ class App extends React.PureComponent {
return ( return (
<BrowserRouter> <BrowserRouter>
<Layout <Layout
modelRequest={ this.modelRequest } modelRequest={this.modelRequest}
request={ this.request } request={this.request}
reqStates={ this.state.reqStates } reqStates={this.state.reqStates}
pageTitle={ this.pageTitle } pageTitle={this.pageTitle}
config={ window.CONFIG } config={window.CONFIG}
data={ this.state.data } /> data={this.state.data}
/>
</BrowserRouter> </BrowserRouter>
) )
} }
} }
function replaceParams (route, params) { function replaceParams(route, params) {
// Replace :params in the URL with params in the array. // Replace :params in the URL with params in the array.
let uriParams = route.match(/:([a-z0-9\-_]+)/ig) let uriParams = route.match(/:([a-z0-9\-_]+)/gi)
if(uriParams && uriParams.length > 0) { if (uriParams && uriParams.length > 0) {
uriParams.forEach((p) => { uriParams.forEach(p => {
let pName = p.slice(1) // Lose the ":" prefix let pName = p.slice(1) // Lose the ":" prefix
if(params && params.hasOwnProperty(pName)) { if (params && params.hasOwnProperty(pName)) {
route = route.replace(p, params[pName]) route = route.replace(p, params[pName])
} }
}) })

View File

@ -1,10 +1,27 @@
import React from "react" 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 ModalPreview from "./ModalPreview"
import moment from 'moment' import moment from "moment"
import ReactQuill from "react-quill" import ReactQuill from "react-quill"
import Delta from "quill-delta" import Delta from "quill-delta"
import "react-quill/dist/quill.snow.css" import "react-quill/dist/quill.snow.css"
@ -14,10 +31,6 @@ const formItemLayout = {
wrapperCol: { xs: { span: 16 }, sm: { span: 10 } } wrapperCol: { xs: { span: 16 }, sm: { span: 10 } }
} }
const formItemTailLayout = {
wrapperCol: { xs: { span: 24, offset: 0 }, sm: { span: 10, offset: 4 } }
}
class Editor extends React.PureComponent { class Editor extends React.PureComponent {
state = { state = {
editor: null, editor: null,
@ -31,16 +44,26 @@ class Editor extends React.PureComponent {
quillModules = { quillModules = {
toolbar: { toolbar: {
container: [ container: [
[{"header": [1, 2, 3, false] }], [{ header: [1, 2, 3, false] }],
["bold", "italic", "underline", "strike", "blockquote", "code"], ["bold", "italic", "underline", "strike", "blockquote", "code"],
[{ "color": [] }, { "background": [] }, { 'size': [] }], [{ color: [] }, { background: [] }, { size: [] }],
[{"list": "ordered"}, {"list": "bullet"}, {"indent": "-1"}, {"indent": "+1"}], [
[{"align": ""}, { "align": "center" }, { "align": "right" }, { "align": "justify" }], { list: "ordered" },
{ list: "bullet" },
{ indent: "-1" },
{ indent: "+1" }
],
[
{ align: "" },
{ align: "center" },
{ align: "right" },
{ align: "justify" }
],
["link", "image"], ["link", "image"],
["clean", "font"] ["clean", "font"]
], ],
handlers: { handlers: {
"image": () => { image: () => {
this.props.toggleMedia() this.props.toggleMedia()
} }
} }
@ -50,7 +73,7 @@ class Editor extends React.PureComponent {
componentDidMount = () => { componentDidMount = () => {
// The editor component will only load once the individual campaign metadata // The editor component will only load once the individual campaign metadata
// has loaded, i.e., record.body is guaranteed to be available here. // has loaded, i.e., record.body is guaranteed to be available here.
if(this.props.record && this.props.record.id) { if (this.props.record && this.props.record.id) {
this.setState({ this.setState({
body: this.props.record.body, body: this.props.record.body,
contentType: this.props.record.content_type, contentType: this.props.record.content_type,
@ -60,14 +83,16 @@ class Editor extends React.PureComponent {
} }
// Custom handler for inserting images from the media popup. // Custom handler for inserting images from the media popup.
insertMedia = (uri) => { insertMedia = uri => {
const quill = this.state.quill.getEditor() const quill = this.state.quill.getEditor()
let range = quill.getSelection(true); let range = quill.getSelection(true)
quill.updateContents(new Delta() quill.updateContents(
new Delta()
.retain(range.index) .retain(range.index)
.delete(range.length) .delete(range.length)
.insert({ image: this.props.config.rootURL + uri }) .insert({ image: this.props.config.rootURL + uri }),
, null); null
)
} }
handleSelContentType = (_, e) => { handleSelContentType = (_, e) => {
@ -76,16 +101,17 @@ class Editor extends React.PureComponent {
handleSwitchContentType = () => { handleSwitchContentType = () => {
this.setState({ contentType: this.state.selContentType }) this.setState({ contentType: this.state.selContentType })
if(!this.state.quill || !this.state.quill.editor || !this.state.rawInput) { if (!this.state.quill || !this.state.quill.editor || !this.state.rawInput) {
return return
} }
// Switching from richtext to html. // Switching from richtext to html.
let body = "" let body = ""
if(this.state.selContentType === "html") { if (this.state.selContentType === "html") {
body = this.state.quill.editor.container.firstChild.innerHTML body = this.state.quill.editor.container.firstChild.innerHTML
// eslint-disable-next-line
this.state.rawInput.value = body this.state.rawInput.value = body
} else if(this.state.selContentType === "richtext") { } else if (this.state.selContentType === "richtext") {
body = this.state.rawInput.value body = this.state.rawInput.value
this.state.quill.editor.clipboard.dangerouslyPasteHTML(body, "raw") this.state.quill.editor.clipboard.dangerouslyPasteHTML(body, "raw")
} }
@ -97,69 +123,83 @@ class Editor extends React.PureComponent {
return ( return (
<div> <div>
<header className="header"> <header className="header">
{ !this.props.formDisabled && {!this.props.formDisabled && (
<Row> <Row>
<Col span={ 20 }> <Col span={20}>
<div className="content-type"> <div className="content-type">
<p>Content format</p> <p>Content format</p>
<Select name="content_type" onChange={ this.handleSelContentType } style={{ minWidth: 200 }} <Select
value={ this.state.selContentType }> name="content_type"
onChange={this.handleSelContentType}
style={{ minWidth: 200 }}
value={this.state.selContentType}
>
<Select.Option value="richtext">Rich Text</Select.Option> <Select.Option value="richtext">Rich Text</Select.Option>
<Select.Option value="html">Raw HTML</Select.Option> <Select.Option value="html">Raw HTML</Select.Option>
</Select> </Select>
{ this.state.contentType !== this.state.selContentType && {this.state.contentType !== this.state.selContentType && (
<div className="actions"> <div className="actions">
<Popconfirm title="The content may lose its formatting. Are you sure?" <Popconfirm
onConfirm={ this.handleSwitchContentType }> title="The content may lose its formatting. Are you sure?"
onConfirm={this.handleSwitchContentType}
>
<Button> <Button>
<Icon type="save" /> Switch format <Icon type="save" /> Switch format
</Button> </Button>
</Popconfirm> </Popconfirm>
</div>} </div>
)}
</div> </div>
</Col> </Col>
<Col span={ 4 }></Col> <Col span={4} />
</Row> </Row>
} )}
</header> </header>
<ReactQuill <ReactQuill
readOnly={ this.props.formDisabled } readOnly={this.props.formDisabled}
style={{ display: this.state.contentType === "richtext" ? "block" : "none" }} style={{
modules={ this.quillModules } display: this.state.contentType === "richtext" ? "block" : "none"
defaultValue={ this.props.record.body } }}
ref={ (o) => { modules={this.quillModules}
if(!o) { defaultValue={this.props.record.body}
ref={o => {
if (!o) {
return return
} }
this.setState({ quill: o }) this.setState({ quill: o })
document.querySelector(".ql-editor").focus() document.querySelector(".ql-editor").focus()
}} }}
onChange={ () => { onChange={() => {
if(!this.state.quill) { if (!this.state.quill) {
return return
} }
this.props.setContent(this.state.contentType, this.state.quill.editor.root.innerHTML) this.props.setContent(
} } this.state.contentType,
this.state.quill.editor.root.innerHTML
)
}}
/> />
<Input.TextArea <Input.TextArea
readOnly={ this.props.formDisabled } readOnly={this.props.formDisabled}
placeholder="Your message here" placeholder="Your message here"
style={{ display: this.state.contentType === "html" ? "block" : "none" }} style={{
display: this.state.contentType === "html" ? "block" : "none"
}}
id="html-body" id="html-body"
rows={ 10 } rows={10}
autosize={ { minRows: 2, maxRows: 10 } } autosize={{ minRows: 2, maxRows: 10 }}
defaultValue={ this.props.record.body } defaultValue={this.props.record.body}
ref={ (o) => { ref={o => {
if(!o) { if (!o) {
return return
} }
this.setState({ rawInput: o.textAreaRef }) this.setState({ rawInput: o.textAreaRef })
}} }}
onChange={ (e) => { onChange={e => {
this.props.setContent(this.state.contentType, e.target.value) this.props.setContent(this.state.contentType, e.target.value)
}} }}
/> />
@ -177,34 +217,34 @@ class TheFormDef extends React.PureComponent {
componentWillReceiveProps(nextProps) { componentWillReceiveProps(nextProps) {
const has = nextProps.isSingle && nextProps.record.send_at !== null const has = nextProps.isSingle && nextProps.record.send_at !== null
if(!has) { if (!has) {
return return
} }
if(this.state.sendLater !== has) { if (this.state.sendLater !== has) {
this.setState({ sendLater: has }) this.setState({ sendLater: has })
} }
} }
validateEmail = (rule, value, callback) => { validateEmail = (rule, value, callback) => {
if(!value.match(/(.+?)\s<(.+?)@(.+?)>/)) { if (!value.match(/(.+?)\s<(.+?)@(.+?)>/)) {
return callback("Format should be: Your Name <email@address.com>") return callback("Format should be: Your Name <email@address.com>")
} }
callback() callback()
} }
handleSendLater = (e) => { handleSendLater = e => {
this.setState({ sendLater: e }) this.setState({ sendLater: e })
} }
// Handle create / edit form submission. // Handle create / edit form submission.
handleSubmit = (cb) => { handleSubmit = cb => {
if(this.state.loading) { if (this.state.loading) {
return return
} }
if(!cb) { if (!cb) {
// Set a fake callback. // Set a fake callback.
cb = () => {} cb = () => {}
} }
@ -214,7 +254,7 @@ class TheFormDef extends React.PureComponent {
return return
} }
if(!values.tags) { if (!values.tags) {
values.tags = [] values.tags = []
} }
@ -223,31 +263,62 @@ class TheFormDef extends React.PureComponent {
// Create a new campaign. // Create a new campaign.
this.setState({ loading: true }) this.setState({ loading: true })
if(!this.props.isSingle) { if (!this.props.isSingle) {
this.props.modelRequest(cs.ModelCampaigns, cs.Routes.CreateCampaign, cs.MethodPost, values).then((resp) => { this.props
notification["success"]({ placement: cs.MsgPosition, .modelRequest(
cs.ModelCampaigns,
cs.Routes.CreateCampaign,
cs.MethodPost,
values
)
.then(resp => {
notification["success"]({
placement: cs.MsgPosition,
message: "Campaign created", message: "Campaign created",
description: `"${values["name"]}" created` }) description: `"${values["name"]}" created`
})
this.props.route.history.push({ this.props.route.history.push({
pathname: cs.Routes.ViewCampaign.replace(":id", resp.data.data.id), pathname: cs.Routes.ViewCampaign.replace(
":id",
resp.data.data.id
),
hash: "content-tab" hash: "content-tab"
}) })
cb(true) cb(true)
}).catch(e => { })
notification["error"]({ placement: cs.MsgPosition, message: "Error", description: e.message }) .catch(e => {
notification["error"]({
placement: cs.MsgPosition,
message: "Error",
description: e.message
})
this.setState({ loading: false }) this.setState({ loading: false })
cb(false) cb(false)
}) })
} else { } else {
this.props.modelRequest(cs.ModelCampaigns, cs.Routes.UpdateCampaign, cs.MethodPut, { ...values, id: this.props.record.id }).then((resp) => { this.props
notification["success"]({ placement: cs.MsgPosition, .modelRequest(
cs.ModelCampaigns,
cs.Routes.UpdateCampaign,
cs.MethodPut,
{ ...values, id: this.props.record.id }
)
.then(resp => {
notification["success"]({
placement: cs.MsgPosition,
message: "Campaign updated", message: "Campaign updated",
description: `"${values["name"]}" updated` }) description: `"${values["name"]}" updated`
})
this.setState({ loading: false }) this.setState({ loading: false })
cb(true) cb(true)
}).catch(e => { })
notification["error"]({ placement: cs.MsgPosition, message: "Error", description: e.message }) .catch(e => {
notification["error"]({
placement: cs.MsgPosition,
message: "Error",
description: e.message
})
this.setState({ loading: false }) this.setState({ loading: false })
cb(false) cb(false)
}) })
@ -255,15 +326,14 @@ class TheFormDef extends React.PureComponent {
}) })
} }
handleTestCampaign = e => {
handleTestCampaign = (e) => {
e.preventDefault() e.preventDefault()
this.props.form.validateFields((err, values) => { this.props.form.validateFields((err, values) => {
if (err) { if (err) {
return return
} }
if(!values.tags) { if (!values.tags) {
values.tags = [] values.tags = []
} }
@ -272,28 +342,41 @@ class TheFormDef extends React.PureComponent {
values.content_type = this.props.contentType values.content_type = this.props.contentType
this.setState({ loading: true }) this.setState({ loading: true })
this.props.request(cs.Routes.TestCampaign, cs.MethodPost, values).then((resp) => { this.props
.request(cs.Routes.TestCampaign, cs.MethodPost, values)
.then(resp => {
this.setState({ loading: false }) this.setState({ loading: false })
notification["success"]({ placement: cs.MsgPosition, notification["success"]({
placement: cs.MsgPosition,
message: "Test sent", message: "Test sent",
description: `Test messages sent` }) description: `Test messages sent`
}).catch(e => { })
})
.catch(e => {
this.setState({ loading: false }) this.setState({ loading: false })
notification["error"]({ placement: cs.MsgPosition, message: "Error", description: e.message }) notification["error"]({
placement: cs.MsgPosition,
message: "Error",
description: e.message
})
}) })
}) })
} }
render() { render() {
const { record } = this.props; const { record } = this.props
const { getFieldDecorator } = this.props.form const { getFieldDecorator } = this.props.form
let subLists = [] let subLists = []
if(this.props.isSingle && record.lists) { if (this.props.isSingle && record.lists) {
subLists = record.lists.map((v) => { return v.id !== 0 ? v.id : null }).filter(v => v !== null) subLists = record.lists
.map(v => {
return v.id !== 0 ? v.id : null
})
.filter(v => v !== null)
} }
if(this.record) { if (this.record) {
this.props.pageTitle(record.name + " / Campaigns") this.props.pageTitle(record.name + " / Campaigns")
} else { } else {
this.props.pageTitle("New campaign") this.props.pageTitle("New campaign")
@ -301,62 +384,111 @@ class TheFormDef extends React.PureComponent {
return ( return (
<div> <div>
<Spin spinning={ this.state.loading }> <Spin spinning={this.state.loading}>
<Form onSubmit={ this.handleSubmit }> <Form onSubmit={this.handleSubmit}>
<Form.Item {...formItemLayout} label="Campaign name"> <Form.Item {...formItemLayout} label="Campaign name">
{getFieldDecorator("name", { {getFieldDecorator("name", {
extra: "This is internal and will not be visible to subscribers", extra:
"This is internal and will not be visible to subscribers",
initialValue: record.name, initialValue: record.name,
rules: [{ required: true }] rules: [{ required: true }]
})(<Input disabled={ this.props.formDisabled }autoFocus maxLength="200" />)} })(
<Input
disabled={this.props.formDisabled}
autoFocus
maxLength="200"
/>
)}
</Form.Item> </Form.Item>
<Form.Item {...formItemLayout} label="Subject"> <Form.Item {...formItemLayout} label="Subject">
{getFieldDecorator("subject", { {getFieldDecorator("subject", {
initialValue: record.subject, initialValue: record.subject,
rules: [{ required: true }] rules: [{ required: true }]
})(<Input disabled={ this.props.formDisabled } maxLength="500" />)} })(<Input disabled={this.props.formDisabled} maxLength="500" />)}
</Form.Item> </Form.Item>
<Form.Item {...formItemLayout} label="From address"> <Form.Item {...formItemLayout} label="From address">
{getFieldDecorator("from_email", { {getFieldDecorator("from_email", {
initialValue: record.from_email ? record.from_email : this.props.config.fromEmail, initialValue: record.from_email
? record.from_email
: this.props.config.fromEmail,
rules: [{ required: true }, { validator: this.validateEmail }] rules: [{ required: true }, { validator: this.validateEmail }]
})(<Input disabled={ this.props.formDisabled } placeholder="Company Name <email@company.com>" maxLength="200" />)} })(
<Input
disabled={this.props.formDisabled}
placeholder="Company Name <email@company.com>"
maxLength="200"
/>
)}
</Form.Item> </Form.Item>
<Form.Item {...formItemLayout} label="Lists" extra="Lists to subscribe to"> <Form.Item
{...formItemLayout}
label="Lists"
extra="Lists to subscribe to"
>
{getFieldDecorator("lists", { {getFieldDecorator("lists", {
initialValue: subLists.length > 0 ? subLists : (this.props.data[cs.ModelLists].length === 1 ? [this.props.data[cs.ModelLists][0].id] : undefined), initialValue:
subLists.length > 0
? subLists
: this.props.data[cs.ModelLists].length === 1
? [this.props.data[cs.ModelLists][0].id]
: undefined,
rules: [{ required: true }] rules: [{ required: true }]
})( })(
<Select disabled={ this.props.formDisabled } mode="multiple"> <Select disabled={this.props.formDisabled} mode="multiple">
{[...this.props.data[cs.ModelLists]].map((v, i) => {[...this.props.data[cs.ModelLists]].map((v, i) => (
<Select.Option value={ v["id"] } key={ v["id"] }>{ v["name"] }</Select.Option> <Select.Option value={v["id"]} key={v["id"]}>
)} {v["name"]}
</Select.Option>
))}
</Select> </Select>
)} )}
</Form.Item> </Form.Item>
<Form.Item {...formItemLayout} label="Template" extra="Template"> <Form.Item {...formItemLayout} label="Template" extra="Template">
{getFieldDecorator("template_id", { {getFieldDecorator("template_id", {
initialValue: record.template_id ? record.template_id : (this.props.data[cs.ModelTemplates].length > 0 ? this.props.data[cs.ModelTemplates].filter(t => t.is_default)[0].id : undefined), initialValue: record.template_id
? record.template_id
: this.props.data[cs.ModelTemplates].length > 0
? this.props.data[cs.ModelTemplates].filter(
t => t.is_default
)[0].id
: undefined,
rules: [{ required: true }] rules: [{ required: true }]
})( })(
<Select disabled={ this.props.formDisabled }> <Select disabled={this.props.formDisabled}>
{this.props.data[cs.ModelTemplates].map((v, i) => {this.props.data[cs.ModelTemplates].map((v, i) => (
<Select.Option value={ v["id"] } key={ v["id"] }>{ v["name"] }</Select.Option> <Select.Option value={v["id"]} key={v["id"]}>
)} {v["name"]}
</Select.Option>
))}
</Select> </Select>
)} )}
</Form.Item> </Form.Item>
<Form.Item {...formItemLayout} label="Tags" extra="Hit Enter after typing a word to add multiple tags"> <Form.Item
{...formItemLayout}
label="Tags"
extra="Hit Enter after typing a word to add multiple tags"
>
{getFieldDecorator("tags", { initialValue: record.tags })( {getFieldDecorator("tags", { initialValue: record.tags })(
<Select disabled={ this.props.formDisabled } mode="tags"></Select> <Select disabled={this.props.formDisabled} mode="tags" />
)} )}
</Form.Item> </Form.Item>
<Form.Item {...formItemLayout} label="Messenger" style={{ display: this.props.config.messengers.length === 1 ? "none" : "block" }}> <Form.Item
{getFieldDecorator("messenger", { initialValue: record.messenger ? record.messenger : "email" })( {...formItemLayout}
label="Messenger"
style={{
display:
this.props.config.messengers.length === 1 ? "none" : "block"
}}
>
{getFieldDecorator("messenger", {
initialValue: record.messenger ? record.messenger : "email"
})(
<Radio.Group className="messengers"> <Radio.Group className="messengers">
{[...this.props.config.messengers].map((v, i) => {[...this.props.config.messengers].map((v, i) => (
<Radio disabled={ this.props.formDisabled } value={v} key={v}>{ v }</Radio> <Radio disabled={this.props.formDisabled} value={v} key={v}>
)} {v}
</Radio>
))}
</Radio.Group> </Radio.Group>
)} )}
</Form.Item> </Form.Item>
@ -364,18 +496,29 @@ class TheFormDef extends React.PureComponent {
<hr /> <hr />
<Form.Item {...formItemLayout} label="Send later?"> <Form.Item {...formItemLayout} label="Send later?">
<Row> <Row>
<Col span={ 2 }> <Col span={2}>
{getFieldDecorator("send_later", { defaultChecked: this.props.isSingle })( {getFieldDecorator("send_later", {
<Switch disabled={ this.props.formDisabled } defaultChecked: this.props.isSingle
checked={ this.state.sendLater } })(
onChange={ this.handleSendLater } /> <Switch
disabled={this.props.formDisabled}
checked={this.state.sendLater}
onChange={this.handleSendLater}
/>
)} )}
</Col> </Col>
<Col span={ 12 }> <Col span={12}>
{this.state.sendLater && getFieldDecorator("send_at", {this.state.sendLater &&
{ initialValue: (record && typeof(record.send_at) === "string") ? moment(record.send_at) : moment(new Date()).add(1, "days").startOf("day") })( getFieldDecorator("send_at", {
initialValue:
record && typeof record.send_at === "string"
? moment(record.send_at)
: moment(new Date())
.add(1, "days")
.startOf("day")
})(
<DatePicker <DatePicker
disabled={ this.props.formDisabled } disabled={this.props.formDisabled}
showTime showTime
format="YYYY-MM-DD HH:mm:ss" format="YYYY-MM-DD HH:mm:ss"
placeholder="Select a date and time" placeholder="Select a date and time"
@ -385,32 +528,38 @@ class TheFormDef extends React.PureComponent {
</Row> </Row>
</Form.Item> </Form.Item>
{ this.props.isSingle && {this.props.isSingle && (
<div> <div>
<hr /> <hr />
<Form.Item {...formItemLayout} label="Send test messages" extra="Hit Enter after typing an address to add multiple recipients. The addresses must belong to existing subscribers."> <Form.Item
{...formItemLayout}
label="Send test messages"
extra="Hit Enter after typing an address to add multiple recipients. The addresses must belong to existing subscribers."
>
{getFieldDecorator("subscribers")( {getFieldDecorator("subscribers")(
<Select mode="tags" style={{ width: "100%" }}></Select> <Select mode="tags" style={{ width: "100%" }} />
)} )}
</Form.Item> </Form.Item>
<Form.Item {...formItemLayout} label="&nbsp;" colon={ false }> <Form.Item {...formItemLayout} label="&nbsp;" colon={false}>
<Button onClick={ this.handleTestCampaign }><Icon type="mail" /> Send test</Button> <Button onClick={this.handleTestCampaign}>
<Icon type="mail" /> Send test
</Button>
</Form.Item> </Form.Item>
</div> </div>
} )}
</Form> </Form>
</Spin> </Spin>
</div> </div>
) )
} }
} }
const TheForm = Form.create()(TheFormDef) const TheForm = Form.create()(TheFormDef)
class Campaign extends React.PureComponent { class Campaign extends React.PureComponent {
state = { state = {
campaignID: this.props.route.match.params ? parseInt(this.props.route.match.params.campaignID, 10) : 0, campaignID: this.props.route.match.params
? parseInt(this.props.route.match.params.campaignID, 10)
: 0,
record: {}, record: {},
formRef: null, formRef: null,
contentType: "richtext", contentType: "richtext",
@ -428,33 +577,46 @@ class Campaign extends React.PureComponent {
this.props.modelRequest(cs.ModelLists, cs.Routes.GetLists, cs.MethodGet) this.props.modelRequest(cs.ModelLists, cs.Routes.GetLists, cs.MethodGet)
// Fetch templates. // Fetch templates.
this.props.modelRequest(cs.ModelTemplates, cs.Routes.GetTemplates, cs.MethodGet) this.props.modelRequest(
cs.ModelTemplates,
cs.Routes.GetTemplates,
cs.MethodGet
)
// Fetch campaign. // Fetch campaign.
if(this.state.campaignID) { if (this.state.campaignID) {
this.fetchRecord(this.state.campaignID) this.fetchRecord(this.state.campaignID)
} else { } else {
this.setState({ loading: false }) this.setState({ loading: false })
} }
// Content tab? // Content tab?
if(document.location.hash === "#content-tab") { if (document.location.hash === "#content-tab") {
this.setCurrentTab("content") this.setCurrentTab("content")
} }
} }
fetchRecord = (id) => { fetchRecord = id => {
this.props.request(cs.Routes.GetCampaign, cs.MethodGet, { id: id }).then((r) => { this.props
.request(cs.Routes.GetCampaign, cs.MethodGet, { id: id })
.then(r => {
const record = r.data.data const record = r.data.data
this.setState({ record: record, loading: false }) this.setState({ record: record, loading: false })
// The form for non draft and scheduled campaigns should be locked. // The form for non draft and scheduled campaigns should be locked.
if(record.status !== cs.CampaignStatusDraft && if (
record.status !== cs.CampaignStatusScheduled) { record.status !== cs.CampaignStatusDraft &&
record.status !== cs.CampaignStatusScheduled
) {
this.setState({ formDisabled: true }) this.setState({ formDisabled: true })
} }
}).catch(e => { })
notification["error"]({ placement: cs.MsgPosition, message: "Error", description: e.message }) .catch(e => {
notification["error"]({
placement: cs.MsgPosition,
message: "Error",
description: e.message
})
}) })
} }
@ -466,11 +628,11 @@ class Campaign extends React.PureComponent {
this.setState({ mediaVisible: !this.state.mediaVisible }) this.setState({ mediaVisible: !this.state.mediaVisible })
} }
setCurrentTab = (tab) => { setCurrentTab = tab => {
this.setState({ currentTab: tab }) this.setState({ currentTab: tab })
} }
handlePreview = (record) => { handlePreview = record => {
this.setState({ previewRecord: record }) this.setState({ previewRecord: record })
} }
@ -478,138 +640,189 @@ class Campaign extends React.PureComponent {
return ( return (
<section className="content campaign"> <section className="content campaign">
<Row> <Row>
<Col span={ 16 }> <Col span={16}>
{ !this.state.record.id && <h1>Create a campaign</h1> } {!this.state.record.id && <h1>Create a campaign</h1>}
{ this.state.record.id && {this.state.record.id && (
<div> <div>
<h1> <h1>
<Tag color={ cs.CampaignStatusColors[this.state.record.status] }>{ this.state.record.status }</Tag> <Tag
{ this.state.record.name } color={cs.CampaignStatusColors[this.state.record.status]}
>
{this.state.record.status}
</Tag>
{this.state.record.name}
</h1> </h1>
<span className="text-tiny text-grey">ID { this.state.record.id } &mdash; UUID { this.state.record.uuid }</span> <span className="text-tiny text-grey">
ID {this.state.record.id} &mdash; UUID{" "}
{this.state.record.uuid}
</span>
</div> </div>
} )}
</Col> </Col>
<Col span={ 8 } className="right"> <Col span={8} className="right">
{ !this.state.formDisabled && !this.state.loading && {!this.state.formDisabled && !this.state.loading && (
<div> <div>
<Button type="primary" icon="save" onClick={() => { <Button
type="primary"
icon="save"
onClick={() => {
this.state.formRef.handleSubmit() this.state.formRef.handleSubmit()
}}>{ !this.state.record.id ? "Continue" : "Save changes" }</Button> }}
{" "} >
{!this.state.record.id ? "Continue" : "Save changes"}
{ ( this.state.record.status === cs.CampaignStatusDraft && this.state.record.send_at) && </Button>{" "}
<Popconfirm title="The campaign will start automatically at the scheduled date and time. Schedule now?" {this.state.record.status === cs.CampaignStatusDraft &&
this.state.record.send_at && (
<Popconfirm
title="The campaign will start automatically at the scheduled date and time. Schedule now?"
onConfirm={() => { onConfirm={() => {
this.state.formRef.handleSubmit(() => { this.state.formRef.handleSubmit(() => {
this.props.route.history.push({ this.props.route.history.push({
pathname: cs.Routes.ViewCampaigns, pathname: cs.Routes.ViewCampaigns,
state: { campaign: this.state.record, campaignStatus: cs.CampaignStatusScheduled } state: {
}) campaign: this.state.record,
}) campaignStatus: cs.CampaignStatusScheduled
}}>
<Button icon="clock-circle" type="primary">Schedule campaign</Button>
</Popconfirm>
} }
})
{ ( this.state.record.status === cs.CampaignStatusDraft && !this.state.record.send_at) && })
<Popconfirm title="Campaign properties cannot be changed once it starts. Save changes and start now?" }}
>
<Button icon="clock-circle" type="primary">
Schedule campaign
</Button>
</Popconfirm>
)}
{this.state.record.status === cs.CampaignStatusDraft &&
!this.state.record.send_at && (
<Popconfirm
title="Campaign properties cannot be changed once it starts. Save changes and start now?"
onConfirm={() => { onConfirm={() => {
this.state.formRef.handleSubmit(() => { this.state.formRef.handleSubmit(() => {
this.props.route.history.push({ this.props.route.history.push({
pathname: cs.Routes.ViewCampaigns, pathname: cs.Routes.ViewCampaigns,
state: { campaign: this.state.record, campaignStatus: cs.CampaignStatusRunning } state: {
}) campaign: this.state.record,
}) campaignStatus: cs.CampaignStatusRunning
}}>
<Button icon="rocket" type="primary">Start campaign</Button>
</Popconfirm>
} }
})
})
}}
>
<Button icon="rocket" type="primary">
Start campaign
</Button>
</Popconfirm>
)}
</div> </div>
} )}
</Col> </Col>
</Row> </Row>
<br /> <br />
<Tabs type="card" <Tabs
activeKey={ this.state.currentTab } type="card"
onTabClick={ (t) => { activeKey={this.state.currentTab}
onTabClick={t => {
this.setState({ currentTab: t }) this.setState({ currentTab: t })
}}> }}
>
<Tabs.TabPane tab="Campaign" key="form"> <Tabs.TabPane tab="Campaign" key="form">
<Spin spinning={ this.state.loading }> <Spin spinning={this.state.loading}>
<TheForm { ...this.props } <TheForm
wrappedComponentRef={ (r) => { {...this.props}
if(!r) { wrappedComponentRef={r => {
if (!r) {
return return
} }
// Take the editor's reference and save it in the state // Take the editor's reference and save it in the state
// so that it's insertMedia() function can be passed to <Media /> // so that it's insertMedia() function can be passed to <Media />
this.setState({ formRef: r }) this.setState({ formRef: r })
}} }}
record={ this.state.record } record={this.state.record}
isSingle={ this.state.record.id ? true : false } isSingle={this.state.record.id ? true : false}
body={ this.state.body ? this.state.body : this.state.record.body } body={
contentType={ this.state.contentType } this.state.body ? this.state.body : this.state.record.body
formDisabled={ this.state.formDisabled } }
fetchRecord={ this.fetchRecord } contentType={this.state.contentType}
setCurrentTab={ this.setCurrentTab } formDisabled={this.state.formDisabled}
fetchRecord={this.fetchRecord}
setCurrentTab={this.setCurrentTab}
/> />
</Spin> </Spin>
</Tabs.TabPane> </Tabs.TabPane>
<Tabs.TabPane tab="Content" disabled={ this.state.record.id ? false : true } key="content"> <Tabs.TabPane
{ this.state.record.id && tab="Content"
disabled={this.state.record.id ? false : true}
key="content"
>
{this.state.record.id && (
<div> <div>
<Editor { ...this.props } <Editor
ref={ (r) => { {...this.props}
if(!r) { ref={r => {
if (!r) {
return return
} }
// Take the editor's reference and save it in the state // Take the editor's reference and save it in the state
// so that it's insertMedia() function can be passed to <Media /> // so that it's insertMedia() function can be passed to <Media />
this.setState({ editor: r }) this.setState({ editor: r })
}} }}
isSingle={ this.state.record.id ? true : false } isSingle={this.state.record.id ? true : false}
record={ this.state.record } record={this.state.record}
visible={ this.state.editorVisible } visible={this.state.editorVisible}
toggleMedia={ this.toggleMedia } toggleMedia={this.toggleMedia}
setContent={ this.setContent } setContent={this.setContent}
formDisabled={ this.state.formDisabled } formDisabled={this.state.formDisabled}
/> />
<div className="content-actions"> <div className="content-actions">
<p> <p>
<Button icon="search" onClick={() => this.handlePreview(this.state.record)}>Preview</Button> <Button
icon="search"
onClick={() => this.handlePreview(this.state.record)}
>
Preview
</Button>
</p> </p>
</div> </div>
</div> </div>
} )}
{ !this.state.record.id && {!this.state.record.id && <Spin className="empty-spinner" />}
<Spin className="empty-spinner"></Spin>
}
</Tabs.TabPane> </Tabs.TabPane>
</Tabs> </Tabs>
<Modal visible={ this.state.mediaVisible } width="900px" <Modal
visible={this.state.mediaVisible}
width="900px"
title="Media" title="Media"
okText={ "Ok" } okText={"Ok"}
onCancel={ this.toggleMedia } onCancel={this.toggleMedia}
onOk={ this.toggleMedia }> onOk={this.toggleMedia}
<Media { ...{ ...this.props, >
insertMedia: this.state.editor ? this.state.editor.insertMedia : null, <Media
{...{
...this.props,
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 && {this.state.previewRecord && (
<ModalPreview <ModalPreview
title={ this.state.previewRecord.name } title={this.state.previewRecord.name}
body={ this.state.body } body={this.state.body}
previewURL={ cs.Routes.PreviewCampaign.replace(":id", this.state.previewRecord.id) } previewURL={cs.Routes.PreviewCampaign.replace(
":id",
this.state.previewRecord.id
)}
onCancel={() => { onCancel={() => {
this.setState({ previewRecord: null }) this.setState({ previewRecord: null })
}} }}
/> />
} )}
</section> </section>
) )
} }

View File

@ -1,8 +1,21 @@
import React from "react" import React from "react"
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
import { Row, Col, Button, Table, Icon, Tooltip, Tag, Popconfirm, Progress, Modal, Select, notification, Input } from "antd" import {
Row,
Col,
Button,
Table,
Icon,
Tooltip,
Tag,
Popconfirm,
Progress,
Modal,
notification,
Input
} from "antd"
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 ModalPreview from "./ModalPreview"
import * as cs from "./constants" import * as cs from "./constants"
@ -42,24 +55,26 @@ class Campaigns extends React.PureComponent {
constructor(props) { constructor(props) {
super(props) super(props)
this.columns = [{ this.columns = [
{
title: "Name", title: "Name",
dataIndex: "name", dataIndex: "name",
sorter: true, sorter: true,
width: "20%", width: "20%",
vAlign: "top", vAlign: "top",
render: (text, record) => { render: (text, record) => {
const out = []; const out = []
out.push( out.push(
<div className="name" key={`name-${record.id}`}> <div className="name" key={`name-${record.id}`}>
<Link to={ `/campaigns/${record.id}` }>{ text }</Link><br /> <Link to={`/campaigns/${record.id}`}>{text}</Link>
<span className="text-tiny">{ record.subject }</span> <br />
<span className="text-tiny">{record.subject}</span>
</div> </div>
) )
if(record.tags.length > 0) { if (record.tags.length > 0) {
for (let i = 0; i < record.tags.length; i++) { for (let i = 0; i < record.tags.length; i++) {
out.push(<Tag key={`tag-${i}`}>{ record.tags[i] }</Tag>); out.push(<Tag key={`tag-${i}`}>{record.tags[i]}</Tag>)
} }
} }
@ -72,13 +87,18 @@ class Campaigns extends React.PureComponent {
className: "status", className: "status",
width: "10%", width: "10%",
render: (status, record) => { render: (status, record) => {
let color = cs.CampaignStatusColors.hasOwnProperty(status) ? cs.CampaignStatusColors[status] : "" let color = cs.CampaignStatusColors.hasOwnProperty(status)
? cs.CampaignStatusColors[status]
: ""
return ( return (
<div> <div>
<Tag color={color}>{status}</Tag> <Tag color={color}>{status}</Tag>
{record.send_at && {record.send_at && (
<span className="text-tiny date">Scheduled &mdash; { dayjs(record.send_at).format(cs.DateFormat) }</span> <span className="text-tiny date">
} Scheduled &mdash;{" "}
{dayjs(record.send_at).format(cs.DateFormat)}
</span>
)}
</div> </div>
) )
} }
@ -91,9 +111,11 @@ class Campaigns extends React.PureComponent {
className: "lists", className: "lists",
render: (lists, record) => { render: (lists, record) => {
const out = [] const out = []
lists.forEach((l) => { lists.forEach(l => {
out.push( out.push(
<span className="name" key={`name-${l.id}`}><Link to={ `/subscribers/lists/${l.id}` }>{ l.name }</Link></span> <span className="name" key={`name-${l.id}`}>
<Link to={`/subscribers/lists/${l.id}`}>{l.name}</Link>
</span>
) )
}) })
@ -105,7 +127,10 @@ class Campaigns extends React.PureComponent {
className: "stats", className: "stats",
width: "30%", width: "30%",
render: (text, record) => { render: (text, record) => {
if(record.status !== cs.CampaignStatusDraft && record.status !== cs.CampaignStatusScheduled) { if (
record.status !== cs.CampaignStatusDraft &&
record.status !== cs.CampaignStatusScheduled
) {
return this.renderStats(record) return this.renderStats(record)
} }
} }
@ -118,82 +143,161 @@ class Campaigns extends React.PureComponent {
render: (text, record) => { render: (text, record) => {
return ( return (
<div className="actions"> <div className="actions">
{ ( record.status === cs.CampaignStatusPaused ) && {record.status === cs.CampaignStatusPaused && (
<Popconfirm title="Are you sure?" onConfirm={() => this.handleUpdateStatus(record, cs.CampaignStatusRunning)}> <Popconfirm
<Tooltip title="Resume campaign" placement="bottom"><a role="button"><Icon type="rocket" /></a></Tooltip> title="Are you sure?"
</Popconfirm> onConfirm={() =>
this.handleUpdateStatus(record, cs.CampaignStatusRunning)
} }
>
<Tooltip title="Resume campaign" placement="bottom">
<a role="button">
<Icon type="rocket" />
</a>
</Tooltip>
</Popconfirm>
)}
{ ( record.status === cs.CampaignStatusRunning ) && {record.status === cs.CampaignStatusRunning && (
<Popconfirm title="Are you sure?" onConfirm={() => this.handleUpdateStatus(record, cs.CampaignStatusPaused)}> <Popconfirm
<Tooltip title="Pause campaign" placement="bottom"><a role="button"><Icon type="pause-circle-o" /></a></Tooltip> title="Are you sure?"
</Popconfirm> onConfirm={() =>
this.handleUpdateStatus(record, cs.CampaignStatusPaused)
} }
>
<Tooltip title="Pause campaign" placement="bottom">
<a role="button">
<Icon type="pause-circle-o" />
</a>
</Tooltip>
</Popconfirm>
)}
{/* Draft with send_at */} {/* Draft with send_at */}
{ ( record.status === cs.CampaignStatusDraft && record.send_at) && {record.status === cs.CampaignStatusDraft && record.send_at && (
<Popconfirm title="The campaign will start automatically at the scheduled date and time. Schedule now?" onConfirm={() => this.handleUpdateStatus(record, cs.CampaignStatusScheduled) }> <Popconfirm
<Tooltip title="Schedule campaign" placement="bottom"><a role="button"><Icon type="clock-circle" /></a></Tooltip> title="The campaign will start automatically at the scheduled date and time. Schedule now?"
</Popconfirm> onConfirm={() =>
this.handleUpdateStatus(record, cs.CampaignStatusScheduled)
} }
>
<Tooltip title="Schedule campaign" placement="bottom">
<a role="button">
<Icon type="clock-circle" />
</a>
</Tooltip>
</Popconfirm>
)}
{ ( record.status === cs.CampaignStatusDraft && !record.send_at) && {record.status === cs.CampaignStatusDraft && !record.send_at && (
<Popconfirm title="Campaign properties cannot be changed once it starts. Start now?" onConfirm={() => this.handleUpdateStatus(record, cs.CampaignStatusRunning) }> <Popconfirm
<Tooltip title="Start campaign" placement="bottom"><a role="button"><Icon type="rocket" /></a></Tooltip> title="Campaign properties cannot be changed once it starts. Start now?"
</Popconfirm> onConfirm={() =>
this.handleUpdateStatus(record, cs.CampaignStatusRunning)
} }
>
<Tooltip title="Start campaign" placement="bottom">
<a role="button">
<Icon type="rocket" />
</a>
</Tooltip>
</Popconfirm>
)}
{ ( record.status === cs.CampaignStatusPaused || record.status === cs.CampaignStatusRunning) && {(record.status === cs.CampaignStatusPaused ||
<Popconfirm title="Are you sure?" onConfirm={() => this.handleUpdateStatus(record, cs.CampaignStatusCancelled)}> record.status === cs.CampaignStatusRunning) && (
<Tooltip title="Cancel campaign" placement="bottom"><a role="button"><Icon type="close-circle-o" /></a></Tooltip> <Popconfirm
</Popconfirm> title="Are you sure?"
onConfirm={() =>
this.handleUpdateStatus(record, cs.CampaignStatusCancelled)
} }
>
<Tooltip title="Cancel campaign" placement="bottom">
<a role="button">
<Icon type="close-circle-o" />
</a>
</Tooltip>
</Popconfirm>
)}
<Tooltip title="Preview campaign" placement="bottom"> <Tooltip title="Preview campaign" placement="bottom">
<a role="button" onClick={() => { <a
role="button"
onClick={() => {
this.handlePreview(record) this.handlePreview(record)
}}><Icon type="search" /></a> }}
>
<Icon type="search" />
</a>
</Tooltip> </Tooltip>
<Tooltip title="Clone campaign" placement="bottom"> <Tooltip title="Clone campaign" placement="bottom">
<a role="button" onClick={() => { <a
let r = { ...record, lists: record.lists.map((i) => { return i.id }) } role="button"
onClick={() => {
let r = {
...record,
lists: record.lists.map(i => {
return i.id
})
}
this.handleToggleCloneForm(r) this.handleToggleCloneForm(r)
}}><Icon type="copy" /></a> }}
>
<Icon type="copy" />
</a>
</Tooltip> </Tooltip>
{ ( record.status === cs.CampaignStatusDraft || record.status === cs.CampaignStatusScheduled ) && {(record.status === cs.CampaignStatusDraft ||
<Popconfirm title="Are you sure?" onConfirm={() => this.handleDeleteRecord(record)}> record.status === cs.CampaignStatusScheduled) && (
<Tooltip title="Delete campaign" placement="bottom"><a role="button"><Icon type="delete" /></a></Tooltip> <Popconfirm
title="Are you sure?"
onConfirm={() => this.handleDeleteRecord(record)}
>
<Tooltip title="Delete campaign" placement="bottom">
<a role="button">
<Icon type="delete" />
</a>
</Tooltip>
</Popconfirm> </Popconfirm>
} )}
</div> </div>
) )
} }
}] }
]
} }
progressPercent(record) { progressPercent(record) {
return Math.round(this.getStatsField("sent", record) / this.getStatsField("to_send", record) * 100, 2) return Math.round(
(this.getStatsField("sent", record) /
this.getStatsField("to_send", record)) *
100,
2
)
} }
isDone(record) { isDone(record) {
return this.getStatsField("status", record) === cs.CampaignStatusFinished || return (
this.getStatsField("status", record) === cs.CampaignStatusFinished ||
this.getStatsField("status", record) === cs.CampaignStatusCancelled this.getStatsField("status", record) === cs.CampaignStatusCancelled
)
} }
// getStatsField returns a stats field value of a given record if it // getStatsField returns a stats field value of a given record if it
// exists in the stats state, or the value from the record itself. // exists in the stats state, or the value from the record itself.
getStatsField = (field, record) => { getStatsField = (field, record) => {
if(this.state.stats.hasOwnProperty(record.id)) { if (this.state.stats.hasOwnProperty(record.id)) {
return this.state.stats[record.id][field] return this.state.stats[record.id][field]
} }
return record[field] return record[field]
} }
renderStats = (record) => { renderStats = record => {
let color = cs.CampaignStatusColors.hasOwnProperty(record.status) ? cs.CampaignStatusColors[record.status] : "" let color = cs.CampaignStatusColors.hasOwnProperty(record.status)
? cs.CampaignStatusColors[record.status]
: ""
const startedAt = this.getStatsField("started_at", record) const startedAt = this.getStatsField("started_at", record)
const updatedAt = this.getStatsField("updated_at", record) const updatedAt = this.getStatsField("updated_at", record)
const sent = this.getStatsField("sent", record) const sent = this.getStatsField("sent", record)
@ -205,45 +309,87 @@ class Campaigns extends React.PureComponent {
return ( return (
<div> <div>
{ !isDone && {!isDone && (
<Progress strokeColor={ color } status="active" <Progress
type="line" percent={ this.progressPercent(record) } /> strokeColor={color}
} status="active"
<Row><Col className="label" span={10}>Sent</Col><Col span={12}> type="line"
{ sent >= toSend && percent={this.progressPercent(record)}
<span>{ toSend }</span> />
} )}
{ sent < toSend && <Row>
<span>{ sent } / { toSend }</span> <Col className="label" span={10}>
} Sent
</Col>
<Col span={12}>
{sent >= toSend && <span>{toSend}</span>}
{sent < toSend && (
<span>
{sent} / {toSend}
</span>
)}
&nbsp; &nbsp;
{ record.status === cs.CampaignStatusRunning && {record.status === cs.CampaignStatusRunning && (
<Icon type="loading" style={{ fontSize: 12 }} spin /> <Icon type="loading" style={{ fontSize: 12 }} spin />
} )}
</Col></Row> </Col>
</Row>
{ rate > 0 && {rate > 0 && (
<Row><Col className="label" span={10}>Rate</Col><Col span={12}>{ Math.round(rate, 2) } / min</Col></Row> <Row>
} <Col className="label" span={10}>
Rate
</Col>
<Col span={12}>{Math.round(rate, 2)} / min</Col>
</Row>
)}
<Row><Col className="label" span={10}>Views</Col><Col span={12}>{ record.views }</Col></Row> <Row>
<Row><Col className="label" span={10}>Clicks</Col><Col span={12}>{ record.clicks }</Col></Row> <Col className="label" span={10}>
Views
</Col>
<Col span={12}>{record.views}</Col>
</Row>
<Row>
<Col className="label" span={10}>
Clicks
</Col>
<Col span={12}>{record.clicks}</Col>
</Row>
<br /> <br />
<Row><Col className="label" span={10}>Created</Col><Col span={12}>{ dayjs(record.created_at).format(cs.DateFormat) }</Col></Row> <Row>
<Col className="label" span={10}>
Created
</Col>
<Col span={12}>{dayjs(record.created_at).format(cs.DateFormat)}</Col>
</Row>
{ startedAt && {startedAt && (
<Row><Col className="label" span={10}>Started</Col><Col span={12}>{ dayjs(startedAt).format(cs.DateFormat) }</Col></Row> <Row>
} <Col className="label" span={10}>
{ isDone && Started
<Row><Col className="label" span={10}>Ended</Col><Col span={12}> </Col>
{ dayjs(updatedAt).format(cs.DateFormat) } <Col span={12}>{dayjs(startedAt).format(cs.DateFormat)}</Col>
</Col></Row> </Row>
} )}
{ startedAt && updatedAt && {isDone && (
<Row><Col className="label" span={10}>Duration</Col><Col className="duration" span={12}> <Row>
{ dayjs(updatedAt).from(dayjs(startedAt), true) } <Col className="label" span={10}>
</Col></Row> Ended
} </Col>
<Col span={12}>{dayjs(updatedAt).format(cs.DateFormat)}</Col>
</Row>
)}
{startedAt && updatedAt && (
<Row>
<Col className="label" span={10}>
Duration
</Col>
<Col className="duration" span={12}>
{dayjs(updatedAt).from(dayjs(startedAt), true)}
</Col>
</Row>
)}
</div> </div>
) )
} }
@ -256,7 +402,7 @@ class Campaigns extends React.PureComponent {
// Did we land here to start a campaign? // Did we land here to start a campaign?
let loc = this.props.route.location let loc = this.props.route.location
let state = loc.state let state = loc.state
if(state && state.hasOwnProperty("campaign")) { if (state && state.hasOwnProperty("campaign")) {
this.handleUpdateStatus(state.campaign, state.campaignStatus) this.handleUpdateStatus(state.campaign, state.campaignStatus)
delete state.campaign delete state.campaign
delete state.campaignStatus delete state.campaignStatus
@ -268,60 +414,70 @@ class Campaigns extends React.PureComponent {
window.clearInterval(this.state.pollID) window.clearInterval(this.state.pollID)
} }
fetchRecords = (params) => { fetchRecords = params => {
let qParams = { let qParams = {
page: this.state.queryParams.page, page: this.state.queryParams.page,
per_page: this.state.queryParams.per_page per_page: this.state.queryParams.per_page
} }
// The records are for a specific list. // The records are for a specific list.
if(this.state.queryParams.listID) { if (this.state.queryParams.listID) {
qParams.listID = this.state.queryParams.listID qParams.listID = this.state.queryParams.listID
} }
if(params) { if (params) {
qParams = { ...qParams, ...params } qParams = { ...qParams, ...params }
} }
this.props.modelRequest(cs.ModelCampaigns, cs.Routes.GetCampaigns, cs.MethodGet, qParams).then((r) => { this.props
.modelRequest(
cs.ModelCampaigns,
cs.Routes.GetCampaigns,
cs.MethodGet,
qParams
)
.then(r => {
this.startStatsPoll() this.startStatsPoll()
}) })
} }
startStatsPoll = () => { startStatsPoll = () => {
window.clearInterval(this.state.pollID) window.clearInterval(this.state.pollID)
this.setState({ "stats": {} }) this.setState({ stats: {} })
// If there's at least one running campaign, start polling. // If there's at least one running campaign, start polling.
let hasRunning = false let hasRunning = false
this.props.data[cs.ModelCampaigns].forEach((c) => { this.props.data[cs.ModelCampaigns].forEach(c => {
if(c.status === cs.CampaignStatusRunning) { if (c.status === cs.CampaignStatusRunning) {
hasRunning = true hasRunning = true
return return
} }
}) })
if(!hasRunning) { if (!hasRunning) {
return return
} }
// Poll for campaign stats. // Poll for campaign stats.
let pollID = window.setInterval(() => { let pollID = window.setInterval(() => {
this.props.request(cs.Routes.GetRunningCampaignStats, cs.MethodGet).then((r) => { this.props
.request(cs.Routes.GetRunningCampaignStats, cs.MethodGet)
.then(r => {
// No more running campaigns. // No more running campaigns.
if(r.data.data.length === 0) { if (r.data.data.length === 0) {
window.clearInterval(this.state.pollID) window.clearInterval(this.state.pollID)
this.fetchRecords() this.fetchRecords()
return return
} }
let stats = {} let stats = {}
r.data.data.forEach((s) => { r.data.data.forEach(s => {
stats[s.id] = s stats[s.id] = s
}) })
this.setState({ stats: stats }) this.setState({ stats: stats })
}).catch(e => { })
.catch(e => {
console.log(e.message) console.log(e.message)
}) })
}, 3000) }, 3000)
@ -330,49 +486,99 @@ class Campaigns extends React.PureComponent {
} }
handleUpdateStatus = (record, status) => { handleUpdateStatus = (record, status) => {
this.props.modelRequest(cs.ModelCampaigns, cs.Routes.UpdateCampaignStatus, cs.MethodPut, { id: record.id, status: status }) this.props
.modelRequest(
cs.ModelCampaigns,
cs.Routes.UpdateCampaignStatus,
cs.MethodPut,
{ id: record.id, status: status }
)
.then(() => { .then(() => {
notification["success"]({ placement: cs.MsgPosition, message: `Campaign ${status}`, description: `"${record.name}" ${status}` }) notification["success"]({
placement: cs.MsgPosition,
message: `Campaign ${status}`,
description: `"${record.name}" ${status}`
})
// Reload the table. // Reload the table.
this.fetchRecords() this.fetchRecords()
}).catch(e => { })
notification["error"]({ placement: cs.MsgPosition, message: "Error", description: e.message }) .catch(e => {
notification["error"]({
placement: cs.MsgPosition,
message: "Error",
description: e.message
})
}) })
} }
handleDeleteRecord = (record) => { handleDeleteRecord = record => {
this.props.modelRequest(cs.ModelCampaigns, cs.Routes.DeleteCampaign, cs.MethodDelete, { id: record.id }) this.props
.modelRequest(
cs.ModelCampaigns,
cs.Routes.DeleteCampaign,
cs.MethodDelete,
{ id: record.id }
)
.then(() => { .then(() => {
notification["success"]({ placement: cs.MsgPosition, message: "Campaign deleted", description: `"${record.name}" deleted` }) notification["success"]({
placement: cs.MsgPosition,
message: "Campaign deleted",
description: `"${record.name}" deleted`
})
// Reload the table. // Reload the table.
this.fetchRecords() this.fetchRecords()
}).catch(e => { })
notification["error"]({ placement: cs.MsgPosition, message: "Error", description: e.message }) .catch(e => {
notification["error"]({
placement: cs.MsgPosition,
message: "Error",
description: e.message
})
}) })
} }
handleToggleCloneForm = (record) => { handleToggleCloneForm = record => {
this.setState({ cloneModalVisible: !this.state.cloneModalVisible, record: record, cloneName: record.name }) this.setState({
cloneModalVisible: !this.state.cloneModalVisible,
record: record,
cloneName: record.name
})
} }
handleCloneCampaign = (record) => { handleCloneCampaign = record => {
this.setState({ modalWaiting: true }) this.setState({ modalWaiting: true })
this.props.modelRequest(cs.ModelCampaigns, cs.Routes.CreateCampaign, cs.MethodPost, record).then((resp) => { this.props
notification["success"]({ placement: cs.MsgPosition, .modelRequest(
cs.ModelCampaigns,
cs.Routes.CreateCampaign,
cs.MethodPost,
record
)
.then(resp => {
notification["success"]({
placement: cs.MsgPosition,
message: "Campaign created", message: "Campaign created",
description: `${record.name} created` }) description: `${record.name} created`
})
this.setState({ record: null, modalWaiting: false }) this.setState({ record: null, modalWaiting: false })
this.props.route.history.push(cs.Routes.ViewCampaign.replace(":id", resp.data.data.id)) this.props.route.history.push(
}).catch(e => { cs.Routes.ViewCampaign.replace(":id", resp.data.data.id)
notification["error"]({ placement: cs.MsgPosition, message: "Error", description: e.message }) )
})
.catch(e => {
notification["error"]({
placement: cs.MsgPosition,
message: "Error",
description: e.message
})
this.setState({ modalWaiting: false }) this.setState({ modalWaiting: false })
}) })
} }
handlePreview = (record) => { handlePreview = record => {
this.setState({ previewRecord: record }) this.setState({ previewRecord: record })
} }
@ -385,44 +591,67 @@ class Campaigns extends React.PureComponent {
return ( return (
<section className="content campaigns"> <section className="content campaigns">
<Row> <Row>
<Col span={ 22 }><h1>Campaigns</h1></Col> <Col span={22}>
<Col span={ 2 }> <h1>Campaigns</h1>
<Link to="/campaigns/new"><Button type="primary" icon="plus" role="link">New campaign</Button></Link> </Col>
<Col span={2}>
<Link to="/campaigns/new">
<Button type="primary" icon="plus" role="link">
New campaign
</Button>
</Link>
</Col> </Col>
</Row> </Row>
<br /> <br />
<Table <Table
className="subscribers" className="subscribers"
columns={ this.columns } columns={this.columns}
rowKey={ record => record.uuid } rowKey={record => record.uuid}
dataSource={ this.props.data[cs.ModelCampaigns] } dataSource={this.props.data[cs.ModelCampaigns]}
loading={ this.props.reqStates[cs.ModelCampaigns] !== cs.StateDone } loading={this.props.reqStates[cs.ModelCampaigns] !== cs.StateDone}
pagination={ pagination } pagination={pagination}
/> />
{ this.state.previewRecord && {this.state.previewRecord && (
<ModalPreview <ModalPreview
title={ this.state.previewRecord.name } title={this.state.previewRecord.name}
previewURL={ cs.Routes.PreviewCampaign.replace(":id", this.state.previewRecord.id) } previewURL={cs.Routes.PreviewCampaign.replace(
":id",
this.state.previewRecord.id
)}
onCancel={() => { onCancel={() => {
this.setState({ previewRecord: null }) this.setState({ previewRecord: null })
}} }}
/> />
} )}
{ this.state.cloneModalVisible && this.state.record && {this.state.cloneModalVisible && this.state.record && (
<Modal visible={ this.state.record !== null } width="500px" <Modal
visible={this.state.record !== null}
width="500px"
className="clone-campaign-modal" className="clone-campaign-modal"
title={ "Clone " + this.state.record.name} title={"Clone " + this.state.record.name}
okText="Clone" okText="Clone"
confirmLoading={ this.state.modalWaiting } confirmLoading={this.state.modalWaiting}
onCancel={ this.handleToggleCloneForm } onCancel={this.handleToggleCloneForm}
onOk={() => { this.handleCloneCampaign({ ...this.state.record, name: this.state.cloneName }) }}> onOk={() => {
<Input autoFocus defaultValue={ this.state.record.name } style={{ width: "100%" }} onChange={(e) => { 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 }) this.setState({ cloneName: e.target.value })
}} /> }}
</Modal> } />
</Modal>
)}
</section> </section>
) )
} }

View File

@ -1,6 +1,6 @@
import { Col, Row, notification, Card, Tooltip, Icon, Spin } from "antd" import { Col, Row, notification, Card, Spin } from "antd"
import React from "react"; import React from "react"
import { Chart, Axis, Geom, Tooltip as BizTooltip } from 'bizcharts'; import { Chart, Geom, Tooltip as BizTooltip } from "bizcharts"
import * as cs from "./constants" import * as cs from "./constants"
@ -10,13 +10,23 @@ class Dashboard extends React.PureComponent {
loading: true loading: true
} }
campaignTypes = ["running", "finished", "paused", "draft", "scheduled", "cancelled"] campaignTypes = [
"running",
"finished",
"paused",
"draft",
"scheduled",
"cancelled"
]
componentDidMount = () => { componentDidMount = () => {
this.props.pageTitle("Dashboard") this.props.pageTitle("Dashboard")
this.props.request(cs.Routes.GetDashboarcStats, cs.MethodGet).then((resp) => { this.props
.request(cs.Routes.GetDashboarcStats, cs.MethodGet)
.then(resp => {
this.setState({ stats: resp.data.data, loading: false }) this.setState({ stats: resp.data.data, loading: false })
}).catch(e => { })
.catch(e => {
notification["error"]({ message: "Error", description: e.message }) notification["error"]({ message: "Error", description: e.message })
this.setState({ loading: false }) this.setState({ loading: false })
}) })
@ -28,42 +38,54 @@ class Dashboard extends React.PureComponent {
render() { render() {
return ( return (
<section className = "dashboard"> <section className="dashboard">
<h1>Welcome</h1> <h1>Welcome</h1>
<hr /> <hr />
<Spin spinning={ this.state.loading }> <Spin spinning={this.state.loading}>
{ this.state.stats && {this.state.stats && (
<div className="stats"> <div className="stats">
<Row> <Row>
<Col span={ 16 }> <Col span={16}>
<Row gutter={ 24 }> <Row gutter={24}>
<Col span={ 8 }> <Col span={8}>
<Card title="Active subscribers" bordered={ false }> <Card title="Active subscribers" bordered={false}>
<h1 className="count">{ this.orZero(this.state.stats.subscribers.enabled) }</h1> <h1 className="count">
{this.orZero(this.state.stats.subscribers.enabled)}
</h1>
</Card> </Card>
</Col> </Col>
<Col span={ 8 }> <Col span={8}>
<Card title="Blacklisted subscribers" bordered={ false }> <Card title="Blacklisted subscribers" bordered={false}>
<h1 className="count">{ this.orZero(this.state.stats.subscribers.blacklisted) }</h1> <h1 className="count">
{this.orZero(
this.state.stats.subscribers.blacklisted
)}
</h1>
</Card> </Card>
</Col> </Col>
<Col span={ 8 }> <Col span={8}>
<Card title="Orphaned subscribers" bordered={ false }> <Card title="Orphaned subscribers" bordered={false}>
<h1 className="count">{ this.orZero(this.state.stats.orphan_subscribers) }</h1> <h1 className="count">
{this.orZero(this.state.stats.orphan_subscribers)}
</h1>
</Card> </Card>
</Col> </Col>
</Row> </Row>
</Col> </Col>
<Col span={ 6 } offset={ 2 }> <Col span={6} offset={2}>
<Row gutter={ 24 }> <Row gutter={24}>
<Col span={ 12 }> <Col span={12}>
<Card title="Public lists" bordered={ false }> <Card title="Public lists" bordered={false}>
<h1 className="count">{ this.orZero(this.state.stats.lists.public) }</h1> <h1 className="count">
{this.orZero(this.state.stats.lists.public)}
</h1>
</Card> </Card>
</Col> </Col>
<Col span={ 12 }> <Col span={12}>
<Card title="Private lists" bordered={ false }> <Card title="Private lists" bordered={false}>
<h1 className="count">{ this.orZero(this.state.stats.lists.private) }</h1> <h1 className="count">
{this.orZero(this.state.stats.lists.private)}
</h1>
</Card> </Card>
</Col> </Col>
</Row> </Row>
@ -71,61 +93,98 @@ class Dashboard extends React.PureComponent {
</Row> </Row>
<hr /> <hr />
<Row> <Row>
<Col span={ 16 }> <Col span={16}>
<Row gutter={ 24 }> <Row gutter={24}>
<Col span={ 12 }> <Col span={12}>
<Card title="Campaign views (last 3 months)" bordered={ false }> <Card
title="Campaign views (last 3 months)"
bordered={false}
>
<h1 className="count"> <h1 className="count">
{ this.state.stats.campaign_views.reduce((total, v) => total + v.count, 0) } {this.state.stats.campaign_views.reduce(
{' '} (total, v) => total + v.count,
0
)}{" "}
views views
</h1> </h1>
<Chart height={ 220 } padding={ [0, 0, 0, 0] } data={ this.state.stats.campaign_views } forceFit> <Chart
<BizTooltip crosshairs={{ type : "y" }} /> height={220}
<Geom type="area" position="date*count" size={ 0 } color="#7f2aff" /> padding={[0, 0, 0, 0]}
<Geom type='point' position="date*count" size={ 0 } /> data={this.state.stats.campaign_views}
forceFit
>
<BizTooltip crosshairs={{ type: "y" }} />
<Geom
type="area"
position="date*count"
size={0}
color="#7f2aff"
/>
<Geom type="point" position="date*count" size={0} />
</Chart> </Chart>
</Card> </Card>
</Col> </Col>
<Col span={ 12 }> <Col span={12}>
<Card title="Link clicks (last 3 months)" bordered={ false }> <Card
title="Link clicks (last 3 months)"
bordered={false}
>
<h1 className="count"> <h1 className="count">
{ this.state.stats.link_clicks.reduce((total, v) => total + v.count, 0) } {this.state.stats.link_clicks.reduce(
{' '} (total, v) => total + v.count,
0
)}{" "}
clicks clicks
</h1> </h1>
<Chart height={ 220 } padding={ [0, 0, 0, 0] } data={ this.state.stats.link_clicks } forceFit> <Chart
<BizTooltip crosshairs={{ type : "y" }} /> height={220}
<Geom type="area" position="date*count" size={ 0 } color="#7f2aff" /> padding={[0, 0, 0, 0]}
<Geom type='point' position="date*count" size={ 0 } /> data={this.state.stats.link_clicks}
forceFit
>
<BizTooltip crosshairs={{ type: "y" }} />
<Geom
type="area"
position="date*count"
size={0}
color="#7f2aff"
/>
<Geom type="point" position="date*count" size={0} />
</Chart> </Chart>
</Card> </Card>
</Col> </Col>
</Row> </Row>
</Col> </Col>
<Col span={ 6 } offset={ 2 }> <Col span={6} offset={2}>
<Card title="Campaigns" bordered={ false } className="campaign-counts"> <Card
{ this.campaignTypes.map((key) => title="Campaigns"
<Row key={ `stats-campaigns-${ key }` }> bordered={false}
<Col span={ 18 }><h1 className="name">{ key }</h1></Col> className="campaign-counts"
<Col span={ 6 }> >
{this.campaignTypes.map(key => (
<Row key={`stats-campaigns-${key}`}>
<Col span={18}>
<h1 className="name">{key}</h1>
</Col>
<Col span={6}>
<h1 className="count"> <h1 className="count">
{ this.state.stats.campaigns.hasOwnProperty(key) ? {this.state.stats.campaigns.hasOwnProperty(key)
this.state.stats.campaigns[key] : 0 } ? this.state.stats.campaigns[key]
: 0}
</h1> </h1>
</Col> </Col>
</Row> </Row>
)} ))}
</Card> </Card>
</Col> </Col>
</Row> </Row>
</div> </div>
} )}
</Spin> </Spin>
</section> </section>
); )
} }
} }
export default Dashboard; export default Dashboard

View File

@ -1,9 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
it('renders without crashing', () => {
const div = document.createElement('div');
ReactDOM.render(<App />, div);
ReactDOM.unmountComponentAtNode(div);
});

View File

@ -1,5 +1,20 @@
import React from "react" import React from "react"
import { Row, Col, Form, Select, Input, Upload, Button, Radio, Icon, Spin, Progress, Popconfirm, Tag, notification } from "antd" import {
Row,
Col,
Form,
Select,
Input,
Upload,
Button,
Radio,
Icon,
Spin,
Progress,
Popconfirm,
Tag,
notification
} from "antd"
import * as cs from "./constants" import * as cs from "./constants"
const StatusNone = "none" const StatusNone = "none"
@ -22,9 +37,10 @@ class TheFormDef extends React.PureComponent {
} }
// Handle create / edit form submission. // Handle create / edit form submission.
handleSubmit = (e) => { handleSubmit = e => {
e.preventDefault() e.preventDefault()
var err = null, values = {} var err = null,
values = {}
this.props.form.validateFields((e, v) => { this.props.form.validateFields((e, v) => {
err = e err = e
values = v values = v
@ -33,10 +49,12 @@ class TheFormDef extends React.PureComponent {
return return
} }
if(this.state.fileList.length < 1) { if (this.state.fileList.length < 1) {
notification["error"]({ placement: cs.MsgPosition, notification["error"]({
placement: cs.MsgPosition,
message: "Error", message: "Error",
description: "Select a valid file to upload" }) description: "Select a valid file to upload"
})
return return
} }
@ -44,24 +62,33 @@ class TheFormDef extends React.PureComponent {
let params = new FormData() let params = new FormData()
params.set("params", JSON.stringify(values)) params.set("params", JSON.stringify(values))
params.append("file", this.state.fileList[0]) params.append("file", this.state.fileList[0])
this.props.request(cs.Routes.UploadRouteImport, cs.MethodPost, params).then(() => { this.props
notification["info"]({ placement: cs.MsgPosition, .request(cs.Routes.UploadRouteImport, cs.MethodPost, params)
.then(() => {
notification["info"]({
placement: cs.MsgPosition,
message: "File uploaded", message: "File uploaded",
description: "Please wait while the import is running" }) description: "Please wait while the import is running"
})
this.props.fetchimportState() this.props.fetchimportState()
this.setState({ formLoading: false }) this.setState({ formLoading: false })
}).catch(e => { })
notification["error"]({ placement: cs.MsgPosition, message: "Error", description: e.message }) .catch(e => {
notification["error"]({
placement: cs.MsgPosition,
message: "Error",
description: e.message
})
this.setState({ formLoading: false }) this.setState({ formLoading: false })
}) })
} }
handleConfirmBlur = (e) => { handleConfirmBlur = e => {
const value = e.target.value const value = e.target.value
this.setState({ confirmDirty: this.state.confirmDirty || !!value }) this.setState({ confirmDirty: this.state.confirmDirty || !!value })
} }
onFileChange = (f) => { onFileChange = f => {
let fileList = [f] let fileList = [f]
this.setState({ fileList }) this.setState({ fileList })
return false return false
@ -80,67 +107,98 @@ class TheFormDef extends React.PureComponent {
} }
return ( return (
<Spin spinning={ this.state.formLoading }> <Spin spinning={this.state.formLoading}>
<Form onSubmit={this.handleSubmit}> <Form onSubmit={this.handleSubmit}>
<Form.Item {...formItemLayout} label="Mode"> <Form.Item {...formItemLayout} label="Mode">
{getFieldDecorator("mode", { rules: [{ required: true }], initialValue: "subscribe" })( {getFieldDecorator("mode", {
<Radio.Group className="mode" onChange={(e) => { this.setState({ mode: e.target.value }) }}> rules: [{ required: true }],
<Radio disabled={ this.props.formDisabled } value="subscribe">Subscribe</Radio> initialValue: "subscribe"
<Radio disabled={ this.props.formDisabled } value="blacklist">Blacklist</Radio> })(
<Radio.Group
className="mode"
onChange={e => {
this.setState({ mode: e.target.value })
}}
>
<Radio disabled={this.props.formDisabled} value="subscribe">
Subscribe
</Radio>
<Radio disabled={this.props.formDisabled} value="blacklist">
Blacklist
</Radio>
</Radio.Group> </Radio.Group>
)} )}
</Form.Item> </Form.Item>
{ this.state.mode === "subscribe" && {this.state.mode === "subscribe" && (
<React.Fragment> <React.Fragment>
<Form.Item {...formItemLayout} label="Lists" extra="Lists to subscribe to"> <Form.Item
{...formItemLayout}
label="Lists"
extra="Lists to subscribe to"
>
{getFieldDecorator("lists", { rules: [{ required: true }] })( {getFieldDecorator("lists", { rules: [{ required: true }] })(
<Select mode="multiple"> <Select mode="multiple">
{[...this.props.lists].map((v, i) => {[...this.props.lists].map((v, i) => (
<Select.Option value={v["id"]} key={v["id"]}>{v["name"]}</Select.Option> <Select.Option value={v["id"]} key={v["id"]}>
)} {v["name"]}
</Select.Option>
))}
</Select> </Select>
)} )}
</Form.Item> </Form.Item>
</React.Fragment> </React.Fragment>
} )}
{ this.state.mode === "blacklist" && {this.state.mode === "blacklist" && (
<Form.Item {...formItemTailLayout}> <Form.Item {...formItemTailLayout}>
<p className="ant-form-extra"> <p className="ant-form-extra">
All existing subscribers found in the import will be marked as 'blacklisted' and will be All existing subscribers found in the import will be marked as
unsubscribed from their existing subscriptions. New subscribers will be imported and marked as 'blacklisted'. 'blacklisted' and will be unsubscribed from their existing
subscriptions. New subscribers will be imported and marked as
'blacklisted'.
</p> </p>
</Form.Item> </Form.Item>
} )}
<Form.Item {...formItemLayout} label="CSV column delimiter" extra="Default delimiter is comma"> <Form.Item
{...formItemLayout}
label="CSV column delimiter"
extra="Default delimiter is comma"
>
{getFieldDecorator("delim", { {getFieldDecorator("delim", {
initialValue: "," initialValue: ","
})(<Input maxLength="1" style={{ maxWidth: 40 }} />)} })(<Input maxLength="1" style={{ maxWidth: 40 }} />)}
</Form.Item> </Form.Item>
<Form.Item <Form.Item {...formItemLayout} label="CSV or ZIP file">
{...formItemLayout}
label="CSV or ZIP file">
<div className="dropbox"> <div className="dropbox">
{getFieldDecorator("file", { {getFieldDecorator("file", {
valuePropName: "file", valuePropName: "file",
getValueFromEvent: this.normFile, getValueFromEvent: this.normFile,
rules: [{ required: true }] rules: [{ required: true }]
})( })(
<Upload.Dragger name="files" <Upload.Dragger
multiple={ false } name="files"
fileList={ this.state.fileList } multiple={false}
beforeUpload={ this.onFileChange } fileList={this.state.fileList}
accept=".zip,.csv"> beforeUpload={this.onFileChange}
accept=".zip,.csv"
>
<p className="ant-upload-drag-icon"> <p className="ant-upload-drag-icon">
<Icon type="inbox" /> <Icon type="inbox" />
</p> </p>
<p className="ant-upload-text">Click or drag a CSV or ZIP file here</p> <p className="ant-upload-text">
Click or drag a CSV or ZIP file here
</p>
</Upload.Dragger> </Upload.Dragger>
)} )}
</div> </div>
</Form.Item> </Form.Item>
<Form.Item {...formItemTailLayout}> <Form.Item {...formItemTailLayout}>
<p className="ant-form-extra">For existing subscribers, the names and attributes will be overwritten with the values in the CSV.</p> <p className="ant-form-extra">
<Button type="primary" htmlType="submit"><Icon type="upload" /> Upload</Button> For existing subscribers, the names and attributes will be
overwritten with the values in the CSV.
</p>
<Button type="primary" htmlType="submit">
<Icon type="upload" /> Upload
</Button>
</Form.Item> </Form.Item>
</Form> </Form>
</Spin> </Spin>
@ -157,10 +215,17 @@ class Importing extends React.PureComponent {
stopImport = () => { stopImport = () => {
// Get the import status. // Get the import status.
this.props.request(cs.Routes.UploadRouteImport, cs.MethodDelete).then((r) => { this.props
.request(cs.Routes.UploadRouteImport, cs.MethodDelete)
.then(r => {
this.props.fetchimportState() this.props.fetchimportState()
}).catch(e => { })
notification["error"]({ placement: cs.MsgPosition, message: "Error", description: e.message }) .catch(e => {
notification["error"]({
placement: cs.MsgPosition,
message: "Error",
description: e.message
})
}) })
} }
@ -169,8 +234,10 @@ class Importing extends React.PureComponent {
let pollID = window.setInterval(() => { let pollID = window.setInterval(() => {
this.props.fetchimportState() this.props.fetchimportState()
this.fetchLogs() this.fetchLogs()
if( this.props.importState.status === StatusFinished || if (
this.props.importState.status === StatusFailed ) { this.props.importState.status === StatusFinished ||
this.props.importState.status === StatusFailed
) {
window.clearInterval(this.state.pollID) window.clearInterval(this.state.pollID)
} }
}, 1000) }, 1000)
@ -182,82 +249,104 @@ class Importing extends React.PureComponent {
} }
fetchLogs() { fetchLogs() {
this.props.request(cs.Routes.GetRouteImportLogs, cs.MethodGet).then((r) => { this.props
.request(cs.Routes.GetRouteImportLogs, cs.MethodGet)
.then(r => {
this.setState({ logs: r.data.data }) this.setState({ logs: r.data.data })
let t = document.querySelector("#log-textarea") let t = document.querySelector("#log-textarea")
t.scrollTop = t.scrollHeight; t.scrollTop = t.scrollHeight
}).catch(e => { })
notification["error"]({ placement: cs.MsgPosition, message: "Error", description: e.message }) .catch(e => {
notification["error"]({
placement: cs.MsgPosition,
message: "Error",
description: e.message
})
}) })
} }
render() { render() {
let progressPercent = 0 let progressPercent = 0
if( this.props.importState.status === StatusFinished ) { if (this.props.importState.status === StatusFinished) {
progressPercent = 100 progressPercent = 100
} else { } else {
progressPercent = Math.floor(this.props.importState.imported / this.props.importState.total * 100) progressPercent = Math.floor(
(this.props.importState.imported / this.props.importState.total) * 100
)
} }
return( return (
<section className="content import"> <section className="content import">
<h1>Importing &mdash; { this.props.importState.name }</h1> <h1>Importing &mdash; {this.props.importState.name}</h1>
{ this.props.importState.status === StatusImporting && {this.props.importState.status === StatusImporting && (
<p>Import is in progress. It is safe to navigate away from this page.</p> <p>
} Import is in progress. It is safe to navigate away from this page.
</p>
)}
{ this.props.importState.status !== StatusImporting && {this.props.importState.status !== StatusImporting && (
<p>Import has finished.</p> <p>Import has finished.</p>
} )}
<Row className="import-container"> <Row className="import-container">
<Col span="10" offset="3"> <Col span="10" offset="3">
<div className="stats center"> <div className="stats center">
<div> <div>
<Progress type="line" percent={ progressPercent } /> <Progress type="line" percent={progressPercent} />
</div> </div>
<div> <div>
<h3>{ this.props.importState.imported } records</h3> <h3>{this.props.importState.imported} records</h3>
<br /> <br />
{ this.props.importState.status === StatusImporting && {this.props.importState.status === StatusImporting && (
<Popconfirm title="Are you sure?" onConfirm={() => this.stopImport()}> <Popconfirm
<p><Icon type="loading" /></p> title="Are you sure?"
onConfirm={() => this.stopImport()}
>
<p>
<Icon type="loading" />
</p>
<Button type="primary">Stop import</Button> <Button type="primary">Stop import</Button>
</Popconfirm> </Popconfirm>
} )}
{ this.props.importState.status === StatusStopping && {this.props.importState.status === StatusStopping && (
<div> <div>
<p><Icon type="loading" /></p> <p>
<Icon type="loading" />
</p>
<h4>Stopping</h4> <h4>Stopping</h4>
</div> </div>
} )}
{ this.props.importState.status !== StatusImporting && {this.props.importState.status !== StatusImporting &&
this.props.importState.status !== StatusStopping && this.props.importState.status !== StatusStopping && (
<div> <div>
{ this.props.importState.status !== StatusFinished && {this.props.importState.status !== StatusFinished && (
<div> <div>
<Tag color="red">{ this.props.importState.status }</Tag> <Tag color="red">{this.props.importState.status}</Tag>
<br /> <br />
</div> </div>
} )}
<br /> <br />
<Button type="primary" onClick={() => this.stopImport()}>Done</Button> <Button type="primary" onClick={() => this.stopImport()}>
Done
</Button>
</div> </div>
} )}
</div> </div>
</div> </div>
<div className="logs"> <div className="logs">
<h3>Import log</h3> <h3>Import log</h3>
<Spin spinning={ this.state.logs === "" }> <Spin spinning={this.state.logs === ""}>
<Input.TextArea placeholder="Import logs" <Input.TextArea
placeholder="Import logs"
id="log-textarea" id="log-textarea"
rows={10} rows={10}
value={ this.state.logs } value={this.state.logs}
autosize={{ minRows: 2, maxRows: 10 }} /> autosize={{ minRows: 2, maxRows: 10 }}
/>
</Spin> </Spin>
</div> </div>
</Col> </Col>
@ -269,15 +358,22 @@ class Importing extends React.PureComponent {
class Import extends React.PureComponent { class Import extends React.PureComponent {
state = { state = {
importState: { "status": "" } importState: { status: "" }
} }
fetchimportState = () => { fetchimportState = () => {
// Get the import status. // Get the import status.
this.props.request(cs.Routes.GetRouteImportStats, cs.MethodGet).then((r) => { this.props
.request(cs.Routes.GetRouteImportStats, cs.MethodGet)
.then(r => {
this.setState({ importState: r.data.data }) this.setState({ importState: r.data.data })
}).catch(e => { })
notification["error"]({ placement: cs.MsgPosition, message: "Error", description: e.message }) .catch(e => {
notification["error"]({
placement: cs.MsgPosition,
message: "Error",
description: e.message
})
}) })
} }
@ -286,42 +382,47 @@ class Import extends React.PureComponent {
this.fetchimportState() this.fetchimportState()
} }
render() { render() {
if( this.state.importState.status === "" ) { if (this.state.importState.status === "") {
// Fetching the status. // Fetching the status.
return ( return (
<section className="content center"> <section className="content center">
<Spin /> <Spin />
</section> </section>
) )
} else if ( this.state.importState.status !== StatusNone ) { } else if (this.state.importState.status !== StatusNone) {
// There's an import state // There's an import state
return <Importing { ...this.props } return (
importState={ this.state.importState } <Importing
fetchimportState={ this.fetchimportState } /> {...this.props}
importState={this.state.importState}
fetchimportState={this.fetchimportState}
/>
)
} }
return ( return (
<section className="content import"> <section className="content import">
<Row> <Row>
<Col span={22}><h1>Import subscribers</h1></Col> <Col span={22}>
<Col span={2}> <h1>Import subscribers</h1>
</Col> </Col>
<Col span={2} />
</Row> </Row>
<TheForm { ...this.props } <TheForm
fetchimportState={ this.fetchimportState } {...this.props}
lists={ this.props.data[cs.ModelLists] }> fetchimportState={this.fetchimportState}
</TheForm> lists={this.props.data[cs.ModelLists]}
/>
<hr /> <hr />
<div className="help"> <div className="help">
<h2>Instructions</h2> <h2>Instructions</h2>
<p>Upload a CSV file or a ZIP file with a single CSV file in it <p>
to bulk import subscribers. Upload a CSV file or a ZIP file with a single CSV file in it to bulk
{" "} import subscribers. The CSV file should have the following headers
The CSV file should have the following headers with the exact column names. with the exact column names. <code>attributes</code> (optional)
{" "} should be a valid JSON string with double escaped quotes.
<code>attributes</code> (optional) should be a valid JSON string with double escaped quotes.
</p> </p>
<blockquote className="csv-example"> <blockquote className="csv-example">
@ -345,13 +446,15 @@ class Import extends React.PureComponent {
<span>user1@mail.com,</span> <span>user1@mail.com,</span>
<span>"User One",</span> <span>"User One",</span>
<span>enabled,</span> <span>enabled,</span>
<span>{ '"{""age"": 32, ""city"": ""Bangalore""}"' }</span> <span>{'"{""age"": 32, ""city"": ""Bangalore""}"'}</span>
</code> </code>
<code className="csv-row"> <code className="csv-row">
<span>user2@mail.com,</span> <span>user2@mail.com,</span>
<span>"User Two",</span> <span>"User Two",</span>
<span>blacklisted,</span> <span>blacklisted,</span>
<span>{ '"{""age"": 25, ""occupation"": ""Time Traveller""}"' }</span> <span>
{'"{""age"": 25, ""occupation"": ""Time Traveller""}"'}
</span>
</code> </code>
</blockquote> </blockquote>
</div> </div>

View File

@ -12,11 +12,9 @@ import Subscribers from "./Subscribers"
import Subscriber from "./Subscriber" import Subscriber from "./Subscriber"
import Templates from "./Templates" import Templates from "./Templates"
import Import from "./Import" import Import from "./Import"
import Test from "./Test" import Campaigns from "./Campaigns"
import Campaigns from "./Campaigns"; import Campaign from "./Campaign"
import Campaign from "./Campaign"; import Media from "./Media"
import Media from "./Media";
const { Content, Footer, Sider } = Layout const { Content, Footer, Sider } = Layout
const SubMenu = Menu.SubMenu const SubMenu = Menu.SubMenu
@ -27,9 +25,9 @@ class Base extends React.Component {
basePath: "/" + window.location.pathname.split("/")[1], basePath: "/" + window.location.pathname.split("/")[1],
error: null, error: null,
collapsed: false collapsed: false
}; }
onCollapse = (collapsed) => { onCollapse = collapsed => {
this.setState({ collapsed }) this.setState({ collapsed })
} }
@ -43,68 +41,211 @@ class Base extends React.Component {
theme="light" theme="light"
> >
<div className="logo"> <div className="logo">
<Link to="/"><img src={logo} alt="listmonk logo" /></Link> <Link to="/">
<img src={logo} alt="listmonk logo" />
</Link>
</div> </div>
<Menu defaultSelectedKeys={["/"]} <Menu
defaultSelectedKeys={["/"]}
selectedKeys={[window.location.pathname]} selectedKeys={[window.location.pathname]}
defaultOpenKeys={[this.state.basePath]} defaultOpenKeys={[this.state.basePath]}
mode="inline"> mode="inline"
>
<Menu.Item key="/"><Link to="/"><Icon type="dashboard" /><span>Dashboard</span></Link></Menu.Item> <Menu.Item key="/">
<Menu.Item key="/lists"><Link to="/lists"><Icon type="bars" /><span>Lists</span></Link></Menu.Item> <Link to="/">
<Icon type="dashboard" />
<span>Dashboard</span>
</Link>
</Menu.Item>
<Menu.Item key="/lists">
<Link to="/lists">
<Icon type="bars" />
<span>Lists</span>
</Link>
</Menu.Item>
<SubMenu <SubMenu
key="/subscribers" key="/subscribers"
title={<span><Icon type="team" /><span>Subscribers</span></span>}> title={
<Menu.Item key="/subscribers"><Link to="/subscribers"><Icon type="team" /> All subscribers</Link></Menu.Item> <span>
<Menu.Item key="/subscribers/import"><Link to="/subscribers/import"><Icon type="upload" /> Import</Link></Menu.Item> <Icon type="team" />
<span>Subscribers</span>
</span>
}
>
<Menu.Item key="/subscribers">
<Link to="/subscribers">
<Icon type="team" /> All subscribers
</Link>
</Menu.Item>
<Menu.Item key="/subscribers/import">
<Link to="/subscribers/import">
<Icon type="upload" /> Import
</Link>
</Menu.Item>
</SubMenu> </SubMenu>
<SubMenu <SubMenu
key="/campaigns" key="/campaigns"
title={<span><Icon type="rocket" /><span>Campaigns</span></span>}> title={
<Menu.Item key="/campaigns"><Link to="/campaigns"><Icon type="rocket" /> All campaigns</Link></Menu.Item> <span>
<Menu.Item key="/campaigns/new"><Link to="/campaigns/new"><Icon type="plus" /> Create new</Link></Menu.Item> <Icon type="rocket" />
<Menu.Item key="/campaigns/media"><Link to="/campaigns/media"><Icon type="picture" /> Media</Link></Menu.Item> <span>Campaigns</span>
<Menu.Item key="/campaigns/templates"><Link to="/campaigns/templates"><Icon type="code-o" /> Templates</Link></Menu.Item> </span>
}
>
<Menu.Item key="/campaigns">
<Link to="/campaigns">
<Icon type="rocket" /> All campaigns
</Link>
</Menu.Item>
<Menu.Item key="/campaigns/new">
<Link to="/campaigns/new">
<Icon type="plus" /> Create new
</Link>
</Menu.Item>
<Menu.Item key="/campaigns/media">
<Link to="/campaigns/media">
<Icon type="picture" /> Media
</Link>
</Menu.Item>
<Menu.Item key="/campaigns/templates">
<Link to="/campaigns/templates">
<Icon type="code-o" /> Templates
</Link>
</Menu.Item>
</SubMenu> </SubMenu>
<SubMenu <SubMenu
key="/settings" key="/settings"
title={<span><Icon type="setting" /><span>Settings</span></span>}> title={
<Menu.Item key="9"><Icon type="user" /> Users</Menu.Item> <span>
<Menu.Item key="10"><Icon type="setting" />Settings</Menu.Item> <Icon type="setting" />
<span>Settings</span>
</span>
}
>
<Menu.Item key="9">
<Icon type="user" /> Users
</Menu.Item>
<Menu.Item key="10">
<Icon type="setting" />
Settings
</Menu.Item>
</SubMenu> </SubMenu>
<Menu.Item key="11"><Icon type="logout" /><span>Logout</span></Menu.Item> <Menu.Item key="11">
<Icon type="logout" />
<span>Logout</span>
</Menu.Item>
</Menu> </Menu>
</Sider> </Sider>
<Layout> <Layout>
<Content style={{ margin: "0 16px" }}> <Content style={{ margin: "0 16px" }}>
<div className="content-body"> <div className="content-body">
<div id="alert-container"></div> <div id="alert-container" />
<Switch> <Switch>
<Route exact key="/" path="/" render={(props) => <Dashboard { ...{ ...this.props, route: props } } />} /> <Route
<Route exact key="/lists" path="/lists" render={(props) => <Lists { ...{ ...this.props, route: props } } />} /> exact
<Route exact key="/subscribers" path="/subscribers" render={(props) => <Subscribers { ...{ ...this.props, route: props } } />} /> key="/"
<Route exact key="/subscribers/lists/:listID" path="/subscribers/lists/:listID" render={(props) => <Subscribers { ...{ ...this.props, route: props } } />} /> path="/"
<Route exact key="/subscribers/import" path="/subscribers/import" render={(props) => <Import { ...{ ...this.props, route: props } } />} /> render={props => (
<Route exact key="/subscribers/:subID" path="/subscribers/:subID" render={(props) => <Subscriber { ...{ ...this.props, route: props } } />} /> <Dashboard {...{ ...this.props, route: props }} />
<Route exact key="/campaigns" path="/campaigns" render={(props) => <Campaigns { ...{ ...this.props, route: props } } />} /> )}
<Route exact key="/campaigns/new" path="/campaigns/new" render={(props) => <Campaign { ...{ ...this.props, route: props } } />} /> />
<Route exact key="/campaigns/media" path="/campaigns/media" render={(props) => <Media { ...{ ...this.props, route: props } } />} /> <Route
<Route exact key="/campaigns/templates" path="/campaigns/templates" render={(props) => <Templates { ...{ ...this.props, route: props } } />} /> exact
<Route exact key="/campaigns/:campaignID" path="/campaigns/:campaignID" render={(props) => <Campaign { ...{ ...this.props, route: props } } />} /> key="/lists"
<Route exact key="/test" path="/test" render={(props) => <Test { ...{ ...this.props, route: props } } />} /> path="/lists"
render={props => (
<Lists {...{ ...this.props, route: props }} />
)}
/>
<Route
exact
key="/subscribers"
path="/subscribers"
render={props => (
<Subscribers {...{ ...this.props, route: props }} />
)}
/>
<Route
exact
key="/subscribers/lists/:listID"
path="/subscribers/lists/:listID"
render={props => (
<Subscribers {...{ ...this.props, route: props }} />
)}
/>
<Route
exact
key="/subscribers/import"
path="/subscribers/import"
render={props => (
<Import {...{ ...this.props, route: props }} />
)}
/>
<Route
exact
key="/subscribers/:subID"
path="/subscribers/:subID"
render={props => (
<Subscriber {...{ ...this.props, route: props }} />
)}
/>
<Route
exact
key="/campaigns"
path="/campaigns"
render={props => (
<Campaigns {...{ ...this.props, route: props }} />
)}
/>
<Route
exact
key="/campaigns/new"
path="/campaigns/new"
render={props => (
<Campaign {...{ ...this.props, route: props }} />
)}
/>
<Route
exact
key="/campaigns/media"
path="/campaigns/media"
render={props => (
<Media {...{ ...this.props, route: props }} />
)}
/>
<Route
exact
key="/campaigns/templates"
path="/campaigns/templates"
render={props => (
<Templates {...{ ...this.props, route: props }} />
)}
/>
<Route
exact
key="/campaigns/:campaignID"
path="/campaigns/:campaignID"
render={props => (
<Campaign {...{ ...this.props, route: props }} />
)}
/>
</Switch> </Switch>
</div> </div>
</Content> </Content>
<Footer> <Footer>
<span className="text-small"> <span className="text-small">
<a href="https://listmonk.app" rel="noreferrer noopener" target="_blank">listmonk</a> <a
{" "} href="https://listmonk.app"
&copy; 2019 { year != 2019 ? " - " + year : "" } rel="noreferrer noopener"
target="_blank"
>
listmonk
</a>{" "}
&copy; 2019 {year !== 2019 ? " - " + year : ""}
</span> </span>
</Footer> </Footer>
</Layout> </Layout>

View File

@ -1,13 +1,28 @@
import React from "react" import React from "react"
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
import { Row, Col, Modal, Form, Input, Select, Button, Table, Icon, Tooltip, Tag, Popconfirm, Spin, notification } from "antd" import {
Row,
Col,
Modal,
Form,
Input,
Select,
Button,
Table,
Icon,
Tooltip,
Tag,
Popconfirm,
Spin,
notification
} from "antd"
import Utils from "./utils" import Utils from "./utils"
import * as cs from "./constants" import * as cs from "./constants"
const tagColors = { const tagColors = {
"private": "orange", private: "orange",
"public": "green" public: "green"
} }
class CreateFormDef extends React.PureComponent { class CreateFormDef extends React.PureComponent {
@ -17,7 +32,7 @@ class CreateFormDef extends React.PureComponent {
} }
// Handle create / edit form submission. // Handle create / edit form submission.
handleSubmit = (e) => { handleSubmit = e => {
e.preventDefault() e.preventDefault()
this.props.form.validateFields((err, values) => { this.props.form.validateFields((err, values) => {
if (err) { if (err) {
@ -27,24 +42,50 @@ class CreateFormDef extends React.PureComponent {
this.setState({ modalWaiting: true }) this.setState({ modalWaiting: true })
if (this.props.formType === cs.FormCreate) { if (this.props.formType === cs.FormCreate) {
// Create a new list. // Create a new list.
this.props.modelRequest(cs.ModelLists, cs.Routes.CreateList, cs.MethodPost, values).then(() => { this.props
notification["success"]({ placement: cs.MsgPosition, message: "List created", description: `"${values["name"]}" created` }) .modelRequest(
cs.ModelLists,
cs.Routes.CreateList,
cs.MethodPost,
values
)
.then(() => {
notification["success"]({
placement: cs.MsgPosition,
message: "List created",
description: `"${values["name"]}" created`
})
this.props.fetchRecords() this.props.fetchRecords()
this.props.onClose() this.props.onClose()
this.setState({ modalWaiting: false }) this.setState({ modalWaiting: false })
}).catch(e => { })
.catch(e => {
notification["error"]({ message: "Error", description: e.message }) notification["error"]({ message: "Error", description: e.message })
this.setState({ modalWaiting: false }) this.setState({ modalWaiting: false })
}) })
} else { } else {
// Edit a list. // Edit a list.
this.props.modelRequest(cs.ModelLists, cs.Routes.UpdateList, cs.MethodPut, { ...values, id: this.props.record.id }).then(() => { this.props
notification["success"]({ placement: cs.MsgPosition, message: "List modified", description: `"${values["name"]}" modified` }) .modelRequest(cs.ModelLists, cs.Routes.UpdateList, cs.MethodPut, {
...values,
id: this.props.record.id
})
.then(() => {
notification["success"]({
placement: cs.MsgPosition,
message: "List modified",
description: `"${values["name"]}" modified`
})
this.props.fetchRecords() this.props.fetchRecords()
this.props.onClose() this.props.onClose()
this.setState({ modalWaiting: false }) this.setState({ modalWaiting: false })
}).catch(e => { })
notification["error"]({ placement: cs.MsgPosition, message: "Error", description: e.message }) .catch(e => {
notification["error"]({
placement: cs.MsgPosition,
message: "Error",
description: e.message
})
this.setState({ modalWaiting: false }) this.setState({ modalWaiting: false })
}) })
} }
@ -52,17 +93,24 @@ class CreateFormDef extends React.PureComponent {
} }
modalTitle(formType, record) { modalTitle(formType, record) {
if(formType === cs.FormCreate) { if (formType === cs.FormCreate) {
return "Create a list" return "Create a list"
} }
return ( return (
<div> <div>
<Tag color={ tagColors.hasOwnProperty(record.type) ? tagColors[record.type] : "" }>{ record.type }</Tag> <Tag
{" "} color={
{ record.name } tagColors.hasOwnProperty(record.type) ? tagColors[record.type] : ""
}
>
{record.type}
</Tag>{" "}
{record.name}
<br /> <br />
<span className="text-tiny text-grey">ID { record.id } / UUID { record.uuid }</span> <span className="text-tiny text-grey">
ID {record.id} / UUID {record.uuid}
</span>
</div> </div>
) )
} }
@ -81,15 +129,19 @@ class CreateFormDef extends React.PureComponent {
} }
return ( return (
<Modal visible={ true } title={ this.modalTitle(this.state.form, record) } <Modal
okText={ this.state.form === cs.FormCreate ? "Create" : "Save" } visible={true}
confirmLoading={ this.state.modalWaiting } title={this.modalTitle(this.state.form, record)}
onCancel={ onClose } okText={this.state.form === cs.FormCreate ? "Create" : "Save"}
onOk={ this.handleSubmit }> confirmLoading={this.state.modalWaiting}
onCancel={onClose}
onOk={this.handleSubmit}
>
<div id="modal-alert-container" />
<div id="modal-alert-container"></div> <Spin
spinning={this.props.reqStates[cs.ModelLists] === cs.StatePending}
<Spin spinning={ this.props.reqStates[cs.ModelLists] === cs.StatePending }> >
<Form onSubmit={this.handleSubmit}> <Form onSubmit={this.handleSubmit}>
<Form.Item {...formItemLayout} label="Name"> <Form.Item {...formItemLayout} label="Name">
{getFieldDecorator("name", { {getFieldDecorator("name", {
@ -97,17 +149,29 @@ class CreateFormDef extends React.PureComponent {
rules: [{ required: true }] rules: [{ required: true }]
})(<Input autoFocus maxLength="200" />)} })(<Input autoFocus maxLength="200" />)}
</Form.Item> </Form.Item>
<Form.Item {...formItemLayout} name="type" label="Type" extra="Public lists are open to the world to subscribe"> <Form.Item
{getFieldDecorator("type", { initialValue: record.type ? record.type : "private", rules: [{ required: true }] })( {...formItemLayout}
name="type"
label="Type"
extra="Public lists are open to the world to subscribe"
>
{getFieldDecorator("type", {
initialValue: record.type ? record.type : "private",
rules: [{ required: true }]
})(
<Select style={{ maxWidth: 120 }}> <Select style={{ maxWidth: 120 }}>
<Select.Option value="private">Private</Select.Option> <Select.Option value="private">Private</Select.Option>
<Select.Option value="public">Public</Select.Option> <Select.Option value="public">Public</Select.Option>
</Select> </Select>
)} )}
</Form.Item> </Form.Item>
<Form.Item {...formItemLayout} label="Tags" extra="Hit Enter after typing a word to add multiple tags"> <Form.Item
{...formItemLayout}
label="Tags"
extra="Hit Enter after typing a word to add multiple tags"
>
{getFieldDecorator("tags", { initialValue: record.tags })( {getFieldDecorator("tags", { initialValue: record.tags })(
<Select mode="tags"></Select> <Select mode="tags" />
)} )}
</Form.Item> </Form.Item>
</Form> </Form>
@ -128,20 +192,23 @@ class Lists extends React.PureComponent {
constructor(props) { constructor(props) {
super(props) super(props)
this.columns = [{ this.columns = [
{
title: "Name", title: "Name",
dataIndex: "name", dataIndex: "name",
sorter: true, sorter: true,
width: "40%", width: "40%",
render: (text, record) => { render: (text, record) => {
const out = []; const out = []
out.push( out.push(
<div className="name" key={`name-${record.id}`}><Link to={ `/subscribers/lists/${record.id}` }>{ text }</Link></div> <div className="name" key={`name-${record.id}`}>
<Link to={`/subscribers/lists/${record.id}`}>{text}</Link>
</div>
) )
if(record.tags.length > 0) { if (record.tags.length > 0) {
for (let i = 0; i < record.tags.length; i++) { for (let i = 0; i < record.tags.length; i++) {
out.push(<Tag key={`tag-${i}`}>{ record.tags[i] }</Tag>); out.push(<Tag key={`tag-${i}`}>{record.tags[i]}</Tag>)
} }
} }
@ -163,8 +230,10 @@ class Lists extends React.PureComponent {
width: "15%", width: "15%",
align: "center", align: "center",
render: (text, record) => { render: (text, record) => {
return( return (
<div className="name" key={`name-${record.id}`}><Link to={ `/subscribers/lists/${record.id}` }>{ text }</Link></div> <div className="name" key={`name-${record.id}`}>
<Link to={`/subscribers/lists/${record.id}`}>{text}</Link>
</div>
) )
} }
}, },
@ -189,15 +258,34 @@ class Lists extends React.PureComponent {
render: (text, record) => { render: (text, record) => {
return ( return (
<div className="actions"> <div className="actions">
<Tooltip title="Send a campaign"><a role="button"><Icon type="rocket" /></a></Tooltip> <Tooltip title="Send a campaign">
<Tooltip title="Edit list"><a role="button" onClick={() => this.handleShowEditForm(record)}><Icon type="edit" /></a></Tooltip> <a role="button">
<Popconfirm title="Are you sure?" onConfirm={() => this.deleteRecord(record)}> <Icon type="rocket" />
<Tooltip title="Delete list" placement="bottom"><a role="button"><Icon type="delete" /></a></Tooltip> </a>
</Tooltip>
<Tooltip title="Edit list">
<a
role="button"
onClick={() => this.handleShowEditForm(record)}
>
<Icon type="edit" />
</a>
</Tooltip>
<Popconfirm
title="Are you sure?"
onConfirm={() => this.deleteRecord(record)}
>
<Tooltip title="Delete list" placement="bottom">
<a role="button">
<Icon type="delete" />
</a>
</Tooltip>
</Popconfirm> </Popconfirm>
</div> </div>
) )
} }
}] }
]
} }
componentDidMount() { componentDidMount() {
@ -209,15 +297,27 @@ class Lists extends React.PureComponent {
this.props.modelRequest(cs.ModelLists, cs.Routes.GetLists, cs.MethodGet) this.props.modelRequest(cs.ModelLists, cs.Routes.GetLists, cs.MethodGet)
} }
deleteRecord = (record) => { deleteRecord = record => {
this.props.modelRequest(cs.ModelLists, cs.Routes.DeleteList, cs.MethodDelete, { id: record.id }) this.props
.modelRequest(cs.ModelLists, cs.Routes.DeleteList, cs.MethodDelete, {
id: record.id
})
.then(() => { .then(() => {
notification["success"]({ placement: cs.MsgPosition, message: "List deleted", description: `"${record.name}" deleted` }) notification["success"]({
placement: cs.MsgPosition,
message: "List deleted",
description: `"${record.name}" deleted`
})
// Reload the table. // Reload the table.
this.fetchRecords() this.fetchRecords()
}).catch(e => { })
notification["error"]({ placement: cs.MsgPosition, message: "Error", description: e.message }) .catch(e => {
notification["error"]({
placement: cs.MsgPosition,
message: "Error",
description: e.message
})
}) })
} }
@ -229,7 +329,7 @@ class Lists extends React.PureComponent {
this.setState({ formType: cs.FormCreate, record: {} }) this.setState({ formType: cs.FormCreate, record: {} })
} }
handleShowEditForm = (record) => { handleShowEditForm = record => {
this.setState({ formType: cs.FormEdit, record: record }) this.setState({ formType: cs.FormEdit, record: record })
} }
@ -237,27 +337,36 @@ class Lists extends React.PureComponent {
return ( return (
<section className="content"> <section className="content">
<Row> <Row>
<Col span={22}><h1>Lists ({this.props.data[cs.ModelLists].length}) </h1></Col> <Col span={22}>
<h1>Lists ({this.props.data[cs.ModelLists].length}) </h1>
</Col>
<Col span={2}> <Col span={2}>
<Button type="primary" icon="plus" onClick={this.handleShowCreateForm}>Create list</Button> <Button
type="primary"
icon="plus"
onClick={this.handleShowCreateForm}
>
Create list
</Button>
</Col> </Col>
</Row> </Row>
<br /> <br />
<Table <Table
className="lists" className="lists"
columns={ this.columns } columns={this.columns}
rowKey={ record => record.uuid } rowKey={record => record.uuid}
dataSource={ this.props.data[cs.ModelLists] } dataSource={this.props.data[cs.ModelLists]}
loading={ this.props.reqStates[cs.ModelLists] !== cs.StateDone } loading={this.props.reqStates[cs.ModelLists] !== cs.StateDone}
pagination={ false } pagination={false}
/> />
<CreateForm { ...this.props } <CreateForm
formType={ this.state.formType } {...this.props}
record={ this.state.record } formType={this.state.formType}
onClose={ this.handleHideForm } record={this.state.record}
fetchRecords = { this.fetchRecords } onClose={this.handleHideForm}
fetchRecords={this.fetchRecords}
/> />
</section> </section>
) )

View File

@ -1,5 +1,15 @@
import React from "react" import React from "react"
import { Row, Col, Form, Upload, Icon, Spin, Popconfirm, Tooltip, notification } from "antd" import {
Row,
Col,
Form,
Upload,
Icon,
Spin,
Popconfirm,
Tooltip,
notification
} from "antd"
import * as cs from "./constants" import * as cs from "./constants"
class TheFormDef extends React.PureComponent { class TheFormDef extends React.PureComponent {
@ -16,21 +26,33 @@ class TheFormDef extends React.PureComponent {
this.props.modelRequest(cs.ModelMedia, cs.Routes.GetMedia, cs.MethodGet) this.props.modelRequest(cs.ModelMedia, cs.Routes.GetMedia, cs.MethodGet)
} }
handleDeleteRecord = (record) => { handleDeleteRecord = record => {
this.props.modelRequest(cs.ModelMedia, cs.Routes.DeleteMedia, cs.MethodDelete, { id: record.id }) this.props
.modelRequest(cs.ModelMedia, cs.Routes.DeleteMedia, cs.MethodDelete, {
id: record.id
})
.then(() => { .then(() => {
notification["success"]({ placement: cs.MsgPosition, message: "Image deleted", description: `"${record.filename}" deleted` }) notification["success"]({
placement: cs.MsgPosition,
message: "Image deleted",
description: `"${record.filename}" deleted`
})
// Reload the table. // Reload the table.
this.fetchRecords() this.fetchRecords()
}).catch(e => { })
notification["error"]({ placement: cs.MsgPosition, message: "Error", description: e.message }) .catch(e => {
notification["error"]({
placement: cs.MsgPosition,
message: "Error",
description: e.message
})
}) })
} }
handleInsertMedia = (record) => { handleInsertMedia = record => {
// The insertMedia callback may be passed down by the invoker (Campaign) // The insertMedia callback may be passed down by the invoker (Campaign)
if(!this.props.insertMedia) { if (!this.props.insertMedia) {
return false return false
} }
@ -38,12 +60,18 @@ class TheFormDef extends React.PureComponent {
return false return false
} }
onFileChange = (f) => { onFileChange = f => {
if(f.file.error && f.file.response && f.file.response.hasOwnProperty("message")) { if (
notification["error"]({ placement: cs.MsgPosition, f.file.error &&
f.file.response &&
f.file.response.hasOwnProperty("message")
) {
notification["error"]({
placement: cs.MsgPosition,
message: "Error uploading file", message: "Error uploading file",
description: f.file.response.message }) description: f.file.response.message
} else if(f.file.status === "done") { })
} else if (f.file.status === "done") {
this.fetchRecords() this.fetchRecords()
} }
@ -60,9 +88,7 @@ class TheFormDef extends React.PureComponent {
return ( return (
<Spin spinning={false}> <Spin spinning={false}>
<Form> <Form>
<Form.Item <Form.Item {...formItemLayout} label="Upload images">
{...formItemLayout}
label="Upload images">
<div className="dropbox"> <div className="dropbox">
{getFieldDecorator("file", { {getFieldDecorator("file", {
valuePropName: "file", valuePropName: "file",
@ -72,10 +98,11 @@ class TheFormDef extends React.PureComponent {
<Upload.Dragger <Upload.Dragger
name="file" name="file"
action="/api/media" action="/api/media"
multiple={ true } multiple={true}
listType="picture" listType="picture"
onChange={ this.onFileChange } onChange={this.onFileChange}
accept=".gif, .jpg, .jpeg, .png"> accept=".gif, .jpg, .jpeg, .png"
>
<p className="ant-upload-drag-icon"> <p className="ant-upload-drag-icon">
<Icon type="inbox" /> <Icon type="inbox" />
</p> </p>
@ -87,23 +114,41 @@ class TheFormDef extends React.PureComponent {
</Form> </Form>
<section className="gallery"> <section className="gallery">
{this.props.media && this.props.media.map((record, i) => {this.props.media &&
<div key={ i } className="image"> this.props.media.map((record, i) => (
<a onClick={ () => { <div key={i} className="image">
this.handleInsertMedia(record); <a
if( this.props.onCancel ) { onClick={() => {
this.props.onCancel(); this.handleInsertMedia(record)
if (this.props.onCancel) {
this.props.onCancel()
} }
} }><img alt={ record.filename } src={ record.thumb_uri } /></a> }}
>
<img alt={record.filename} src={record.thumb_uri} />
</a>
<div className="actions"> <div className="actions">
<Tooltip title="View" placement="bottom"><a role="button" href={ record.uri } target="_blank"><Icon type="login" /></a></Tooltip> <Tooltip title="View" placement="bottom">
<Popconfirm title="Are you sure?" onConfirm={() => this.handleDeleteRecord(record)}> <a role="button" href={record.uri} target="_blank">
<Tooltip title="Delete" placement="bottom"><a role="button"><Icon type="delete" /></a></Tooltip> <Icon type="login" />
</a>
</Tooltip>
<Popconfirm
title="Are you sure?"
onConfirm={() => this.handleDeleteRecord(record)}
>
<Tooltip title="Delete" placement="bottom">
<a role="button">
<Icon type="delete" />
</a>
</Tooltip>
</Popconfirm> </Popconfirm>
</div> </div>
<div className="name" title={ record.filename }>{ record.filename }</div> <div className="name" title={record.filename}>
{record.filename}
</div> </div>
)} </div>
))}
</section> </section>
</Spin> </Spin>
) )
@ -116,14 +161,13 @@ class Media extends React.PureComponent {
return ( return (
<section className="content media"> <section className="content media">
<Row> <Row>
<Col span={22}><h1>Images</h1></Col> <Col span={22}>
<Col span={2}> <h1>Images</h1>
</Col> </Col>
<Col span={2} />
</Row> </Row>
<TheForm { ...this.props } <TheForm {...this.props} media={this.props.data[cs.ModelMedia]} />
media={ this.props.data[cs.ModelMedia] }>
</TheForm>
</section> </section>
) )
} }

View File

@ -20,50 +20,54 @@ class ModalPreview extends React.PureComponent {
form.submit() form.submit()
} }
render () { render() {
return ( return (
<Modal visible={ true } title={ this.props.title } <Modal
visible={true}
title={this.props.title}
className="preview-modal" className="preview-modal"
width="90%" width="90%"
height={ 900 } height={900}
onCancel={ this.props.onCancel } onCancel={this.props.onCancel}
onOk={ this.props.onCancel }> onOk={this.props.onCancel}
>
<div className="preview-iframe-container"> <div className="preview-iframe-container">
<Spin className="preview-iframe-spinner"></Spin> <Spin className="preview-iframe-spinner" />
<iframe key="preview-iframe" onLoad={() => { <iframe
key="preview-iframe"
onLoad={() => {
// If state is used to manage the spinner, it causes // If state is used to manage the spinner, it causes
// the iframe to re-render and reload everything. // the iframe to re-render and reload everything.
// Hack the spinner away from the DOM directly instead. // Hack the spinner away from the DOM directly instead.
let spin = document.querySelector(".preview-iframe-spinner") let spin = document.querySelector(".preview-iframe-spinner")
if(spin) { if (spin) {
spin.parentNode.removeChild(spin) spin.parentNode.removeChild(spin)
} }
// this.setState({ loading: false }) // this.setState({ loading: false })
}} title={ this.props.title ? this.props.title : "Preview" } }}
title={this.props.title ? this.props.title : "Preview"}
name="preview-iframe" name="preview-iframe"
id="preview-iframe" id="preview-iframe"
className="preview-iframe" className="preview-iframe"
ref={(o) => { ref={o => {
if(!o) { if (!o) {
return return
} }
// When the DOM reference for the iframe is ready, // When the DOM reference for the iframe is ready,
// see if there's a body to post with the form hack. // see if there's a body to post with the form hack.
if(this.props.body !== undefined if (this.props.body !== undefined && this.props.body !== null) {
&& this.props.body !== null) {
this.makeForm(this.props.body) this.makeForm(this.props.body)
} else { } else {
if(this.props.previewURL) { if (this.props.previewURL) {
o.src = this.props.previewURL o.src = this.props.previewURL
} }
} }
}} }}
src="about:blank"> src="about:blank"
</iframe> />
</div> </div>
</Modal> </Modal>
) )
} }
} }

View File

@ -1,11 +1,22 @@
import React from "react" import React from "react"
import { Row, Col, Form, Input, Select, Button, Tag, Spin, Popconfirm, notification } from "antd" import {
Row,
Col,
Form,
Input,
Select,
Button,
Tag,
Spin,
Popconfirm,
notification
} from "antd"
import * as cs from "./constants" import * as cs from "./constants"
const tagColors = { const tagColors = {
"enabled": "green", enabled: "green",
"blacklisted": "red" blacklisted: "red"
} }
const formItemLayoutModal = { const formItemLayoutModal = {
labelCol: { xs: { span: 24 }, sm: { span: 4 } }, labelCol: { xs: { span: 24 }, sm: { span: 4 } },
@ -28,32 +39,38 @@ class CreateFormDef extends React.PureComponent {
// Handle create / edit form submission. // Handle create / edit form submission.
handleSubmit = (e, cb) => { handleSubmit = (e, cb) => {
e.preventDefault() e.preventDefault()
if(!cb) { if (!cb) {
// Set a fake callback. // Set a fake callback.
cb = () => {} cb = () => {}
} }
var err = null, values = {} var err = null,
values = {}
this.props.form.validateFields((e, v) => { this.props.form.validateFields((e, v) => {
err = e err = e
values = v values = v
}) })
if(err) { if (err) {
return return
} }
let a = values["attribs"] let a = values["attribs"]
values["attribs"] = {} values["attribs"] = {}
if(a && a.length > 0) { if (a && a.length > 0) {
try { try {
values["attribs"] = JSON.parse(a) values["attribs"] = JSON.parse(a)
if(values["attribs"] instanceof Array) { if (values["attribs"] instanceof Array) {
notification["error"]({ message: "Invalid JSON type", notification["error"]({
description: "Attributes should be a map {} and not an array []" }) message: "Invalid JSON type",
description: "Attributes should be a map {} and not an array []"
})
return return
} }
} catch(e) { } catch (e) {
notification["error"]({ message: "Invalid JSON in attributes", description: e.toString() }) notification["error"]({
message: "Invalid JSON in attributes",
description: e.toString()
})
return return
} }
} }
@ -61,30 +78,52 @@ class CreateFormDef extends React.PureComponent {
this.setState({ loading: true }) this.setState({ loading: true })
if (this.props.formType === cs.FormCreate) { if (this.props.formType === cs.FormCreate) {
// Add a subscriber. // Add a subscriber.
this.props.modelRequest(cs.ModelSubscribers, cs.Routes.CreateSubscriber, cs.MethodPost, values).then(() => { this.props
notification["success"]({ message: "Subscriber added", description: `${values["email"]} added` }) .modelRequest(
if(!this.props.isModal) { cs.ModelSubscribers,
cs.Routes.CreateSubscriber,
cs.MethodPost,
values
)
.then(() => {
notification["success"]({
message: "Subscriber added",
description: `${values["email"]} added`
})
if (!this.props.isModal) {
this.props.fetchRecord(this.props.record.id) this.props.fetchRecord(this.props.record.id)
} }
cb(true) cb(true)
this.setState({ loading: false }) this.setState({ loading: false })
}).catch(e => { })
.catch(e => {
notification["error"]({ message: "Error", description: e.message }) notification["error"]({ message: "Error", description: e.message })
cb(false) cb(false)
this.setState({ loading: false }) this.setState({ loading: false })
}) })
} else { } else {
// Edit a subscriber. // Edit a subscriber.
delete(values["keys"]) delete values["keys"]
delete(values["vals"]) delete values["vals"]
this.props.modelRequest(cs.ModelSubscribers, cs.Routes.UpdateSubscriber, cs.MethodPut, { ...values, id: this.props.record.id }).then((resp) => { this.props
notification["success"]({ message: "Subscriber modified", description: `${values["email"]} modified` }) .modelRequest(
if(!this.props.isModal) { cs.ModelSubscribers,
cs.Routes.UpdateSubscriber,
cs.MethodPut,
{ ...values, id: this.props.record.id }
)
.then(resp => {
notification["success"]({
message: "Subscriber modified",
description: `${values["email"]} modified`
})
if (!this.props.isModal) {
this.props.fetchRecord(this.props.record.id) this.props.fetchRecord(this.props.record.id)
} }
cb(true) cb(true)
this.setState({ loading: false }) this.setState({ loading: false })
}).catch(e => { })
.catch(e => {
notification["error"]({ message: "Error", description: e.message }) notification["error"]({ message: "Error", description: e.message })
cb(false) cb(false)
this.setState({ loading: false }) this.setState({ loading: false })
@ -92,21 +131,31 @@ class CreateFormDef extends React.PureComponent {
} }
} }
handleDeleteRecord = (record) => { handleDeleteRecord = record => {
this.props.modelRequest(cs.ModelSubscribers, cs.Routes.DeleteSubscriber, cs.MethodDelete, { id: record.id }) this.props
.modelRequest(
cs.ModelSubscribers,
cs.Routes.DeleteSubscriber,
cs.MethodDelete,
{ id: record.id }
)
.then(() => { .then(() => {
notification["success"]({ message: "Subscriber deleted", description: `${record.email} deleted` }) notification["success"]({
message: "Subscriber deleted",
description: `${record.email} deleted`
})
this.props.route.history.push({ this.props.route.history.push({
pathname: cs.Routes.ViewSubscribers, pathname: cs.Routes.ViewSubscribers
}) })
}).catch(e => { })
.catch(e => {
notification["error"]({ message: "Error", description: e.message }) notification["error"]({ message: "Error", description: e.message })
}) })
} }
render() { render() {
const { formType, record } = this.props; const { formType, record } = this.props
const { getFieldDecorator } = this.props.form const { getFieldDecorator } = this.props.form
if (formType === null) { if (formType === null) {
@ -115,82 +164,123 @@ class CreateFormDef extends React.PureComponent {
let subListIDs = [] let subListIDs = []
let subStatuses = {} let subStatuses = {}
if(this.props.record && this.props.record.lists) { if (this.props.record && this.props.record.lists) {
subListIDs = this.props.record.lists.map((v) => { return v["id"] }) subListIDs = this.props.record.lists.map(v => {
subStatuses = this.props.record.lists.reduce((o, item) => ({ ...o, [item.id]: item.subscription_status}), {}) return v["id"]
} else if(this.props.list) { })
subListIDs = [ this.props.list.id ] subStatuses = this.props.record.lists.reduce(
(o, item) => ({ ...o, [item.id]: item.subscription_status }),
{}
)
} else if (this.props.list) {
subListIDs = [this.props.list.id]
} }
const layout = this.props.isModal ? formItemLayoutModal : formItemLayout; const layout = this.props.isModal ? formItemLayoutModal : formItemLayout
return ( return (
<Spin spinning={ this.state.loading }> <Spin spinning={this.state.loading}>
<Form onSubmit={ this.handleSubmit }> <Form onSubmit={this.handleSubmit}>
<Form.Item { ...layout } label="E-mail"> <Form.Item {...layout} label="E-mail">
{getFieldDecorator("email", { {getFieldDecorator("email", {
initialValue: record.email, initialValue: record.email,
rules: [{ required: true }] rules: [{ required: true }]
})(<Input autoFocus pattern="(.+?)@(.+?)" maxLength="200" />)} })(<Input autoFocus pattern="(.+?)@(.+?)" maxLength="200" />)}
</Form.Item> </Form.Item>
<Form.Item { ...layout } label="Name"> <Form.Item {...layout} label="Name">
{getFieldDecorator("name", { {getFieldDecorator("name", {
initialValue: record.name, initialValue: record.name,
rules: [{ required: true }] rules: [{ required: true }]
})(<Input maxLength="200" />)} })(<Input maxLength="200" />)}
</Form.Item> </Form.Item>
<Form.Item { ...layout } name="status" label="Status" extra="Blacklisted users will not receive any e-mails ever"> <Form.Item
{getFieldDecorator("status", { initialValue: record.status ? record.status : "enabled", rules: [{ required: true, message: "Type is required" }] })( {...layout}
name="status"
label="Status"
extra="Blacklisted users will not receive any e-mails ever"
>
{getFieldDecorator("status", {
initialValue: record.status ? record.status : "enabled",
rules: [{ required: true, message: "Type is required" }]
})(
<Select style={{ maxWidth: 120 }}> <Select style={{ maxWidth: 120 }}>
<Select.Option value="enabled">Enabled</Select.Option> <Select.Option value="enabled">Enabled</Select.Option>
<Select.Option value="blacklisted">Blacklisted</Select.Option> <Select.Option value="blacklisted">Blacklisted</Select.Option>
</Select> </Select>
)} )}
</Form.Item> </Form.Item>
<Form.Item { ...layout } label="Lists" extra="Lists to subscribe to. Lists from which subscribers have unsubscribed themselves cannot be removed."> <Form.Item
{...layout}
label="Lists"
extra="Lists to subscribe to. Lists from which subscribers have unsubscribed themselves cannot be removed."
>
{getFieldDecorator("lists", { initialValue: subListIDs })( {getFieldDecorator("lists", { initialValue: subListIDs })(
<Select mode="multiple"> <Select mode="multiple">
{[...this.props.lists].map((v, i) => {[...this.props.lists].map((v, i) => (
<Select.Option value={ v.id } key={ v.id } disabled={ subStatuses[v.id] === cs.SubscriptionStatusUnsubscribed }> <Select.Option
<span>{ v.name } value={v.id}
{ subStatuses[v.id] && key={v.id}
<sup className={ "subscription-status " + subStatuses[v.id] }> { subStatuses[v.id] }</sup> disabled={
subStatuses[v.id] === cs.SubscriptionStatusUnsubscribed
} }
>
<span>
{v.name}
{subStatuses[v.id] && (
<sup
className={"subscription-status " + subStatuses[v.id]}
>
{" "}
{subStatuses[v.id]}
</sup>
)}
</span> </span>
</Select.Option> </Select.Option>
)} ))}
</Select> </Select>
)} )}
</Form.Item> </Form.Item>
<Form.Item { ...layout } label="Attributes" colon={ false }> <Form.Item {...layout} label="Attributes" colon={false}>
<div> <div>
{getFieldDecorator("attribs", { {getFieldDecorator("attribs", {
initialValue: record.attribs ? JSON.stringify(record.attribs, null, 4) : "" initialValue: record.attribs
? JSON.stringify(record.attribs, null, 4)
: ""
})( })(
<Input.TextArea <Input.TextArea
placeholder="{}" placeholder="{}"
rows={10} rows={10}
readOnly={false} readOnly={false}
autosize={{ minRows: 5, maxRows: 10 }} /> autosize={{ minRows: 5, maxRows: 10 }}
/>
)} )}
</div> </div>
<p className="ant-form-extra">Attributes are defined as a JSON map, for example: <p className="ant-form-extra">
{' {"age": 30, "color": "red", "is_user": true}'}. <a href="">More info</a>.</p> Attributes are defined as a JSON map, for example:
{' {"age": 30, "color": "red", "is_user": true}'}.{" "}
<a href="">More info</a>.
</p>
</Form.Item> </Form.Item>
{ !this.props.isModal && {!this.props.isModal && (
<Form.Item { ...formItemTailLayout }> <Form.Item {...formItemTailLayout}>
<Button type="primary" htmlType="submit" icon={ this.props.formType === cs.FormCreate ? "plus" : "save" }> <Button
{ this.props.formType === cs.FormCreate ? "Add" : "Save" } type="primary"
</Button> htmlType="submit"
{" "} icon={this.props.formType === cs.FormCreate ? "plus" : "save"}
{ this.props.formType === cs.FormEdit && >
<Popconfirm title="Are you sure?" onConfirm={() => { {this.props.formType === cs.FormCreate ? "Add" : "Save"}
</Button>{" "}
{this.props.formType === cs.FormEdit && (
<Popconfirm
title="Are you sure?"
onConfirm={() => {
this.handleDeleteRecord(record) this.handleDeleteRecord(record)
}}> }}
>
<Button icon="delete">Delete</Button> <Button icon="delete">Delete</Button>
</Popconfirm> </Popconfirm>
} )}
</Form.Item> </Form.Item>
} )}
</Form> </Form>
</Spin> </Spin>
) )
@ -204,13 +294,15 @@ class Subscriber extends React.PureComponent {
loading: true, loading: true,
formRef: null, formRef: null,
record: {}, record: {},
subID: this.props.route.match.params ? parseInt(this.props.route.match.params.subID, 10) : 0, subID: this.props.route.match.params
? parseInt(this.props.route.match.params.subID, 10)
: 0
} }
componentDidMount() { componentDidMount() {
// When this component is invoked within a modal from the subscribers list page, // When this component is invoked within a modal from the subscribers list page,
// the necessary context is supplied and there's no need to fetch anything. // the necessary context is supplied and there's no need to fetch anything.
if(!this.props.isModal) { if (!this.props.isModal) {
// Fetch lists. // Fetch lists.
this.props.modelRequest(cs.ModelLists, cs.Routes.GetLists, cs.MethodGet) this.props.modelRequest(cs.ModelLists, cs.Routes.GetLists, cs.MethodGet)
@ -221,20 +313,27 @@ class Subscriber extends React.PureComponent {
} }
} }
fetchRecord = (id) => { fetchRecord = id => {
this.props.request(cs.Routes.GetSubscriber, cs.MethodGet, { id: id }).then((r) => { this.props
.request(cs.Routes.GetSubscriber, cs.MethodGet, { id: id })
.then(r => {
this.setState({ record: r.data.data, loading: false }) this.setState({ record: r.data.data, loading: false })
}).catch(e => { })
notification["error"]({ placement: cs.MsgPosition, message: "Error", description: e.message }) .catch(e => {
notification["error"]({
placement: cs.MsgPosition,
message: "Error",
description: e.message
})
}) })
} }
setFormRef = (r) => { setFormRef = r => {
this.setState({ formRef: r }) this.setState({ formRef: r })
} }
submitForm = (e, cb) => { submitForm = (e, cb) => {
if(this.state.formRef) { if (this.state.formRef) {
this.state.formRef.handleSubmit(e, cb) this.state.formRef.handleSubmit(e, cb)
} }
} }
@ -244,35 +343,41 @@ class Subscriber extends React.PureComponent {
<section className="content"> <section className="content">
<header className="header"> <header className="header">
<Row> <Row>
<Col span={ 20 }> <Col span={20}>
{ !this.state.record.id && {!this.state.record.id && <h1>Add subscriber</h1>}
<h1>Add subscriber</h1> {this.state.record.id && (
}
{ this.state.record.id &&
<div> <div>
<h1> <h1>
<Tag color={ tagColors.hasOwnProperty(this.state.record.status) ? tagColors[this.state.record.status] : "" }>{ this.state.record.status }</Tag> <Tag
{" "} color={
{ this.state.record.name } ({ this.state.record.email }) tagColors.hasOwnProperty(this.state.record.status)
</h1> ? tagColors[this.state.record.status]
<span className="text-small text-grey">ID { this.state.record.id } / UUID { this.state.record.uuid }</span> : ""
</div>
} }
>
{this.state.record.status}
</Tag>{" "}
{this.state.record.name} ({this.state.record.email})
</h1>
<span className="text-small text-grey">
ID {this.state.record.id} / UUID {this.state.record.uuid}
</span>
</div>
)}
</Col> </Col>
<Col span={ 2 }> <Col span={2} />
</Col>
</Row> </Row>
</header> </header>
<div> <div>
<Spin spinning={ this.state.loading }> <Spin spinning={this.state.loading}>
<CreateForm <CreateForm
{...this.props} {...this.props}
formType={ this.props.formType ? this.props.formType : cs.FormEdit } formType={this.props.formType ? this.props.formType : cs.FormEdit}
record={ this.state.record } record={this.state.record}
fetchRecord={ this.fetchRecord } fetchRecord={this.fetchRecord}
lists={ this.props.data[cs.ModelLists] } lists={this.props.data[cs.ModelLists]}
wrappedComponentRef={ (r) => { wrappedComponentRef={r => {
if(!r) { if (!r) {
return return
} }

View File

@ -1,15 +1,29 @@
import React from "react" import React from "react"
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
import { Row, Col, Modal, Form, Input, Select, Button, Table, Icon, Tooltip, Tag, Popconfirm, Spin, notification, Radio } from "antd" import {
Row,
Col,
Modal,
Form,
Input,
Select,
Button,
Table,
Icon,
Tooltip,
Tag,
Popconfirm,
notification,
Radio
} from "antd"
import Utils from "./utils" import Utils from "./utils"
import Subscriber from "./Subscriber" import Subscriber from "./Subscriber"
import * as cs from "./constants" import * as cs from "./constants"
const tagColors = { const tagColors = {
"enabled": "green", enabled: "green",
"blacklisted": "red" blacklisted: "red"
} }
class ListsFormDef extends React.PureComponent { class ListsFormDef extends React.PureComponent {
@ -18,19 +32,20 @@ class ListsFormDef extends React.PureComponent {
} }
// Handle create / edit form submission. // Handle create / edit form submission.
handleSubmit = (e) => { handleSubmit = e => {
e.preventDefault() e.preventDefault()
var err = null, values = {} var err = null,
values = {}
this.props.form.validateFields((e, v) => { this.props.form.validateFields((e, v) => {
err = e err = e
values = v values = v
}) })
if(err) { if (err) {
return return
} }
if(this.props.allRowsSelected) { if (this.props.allRowsSelected) {
values["list_ids"] = this.props.listIDs values["list_ids"] = this.props.listIDs
values["query"] = this.props.query values["query"] = this.props.query
} else { } else {
@ -38,15 +53,25 @@ class ListsFormDef extends React.PureComponent {
} }
this.setState({ modalWaiting: true }) this.setState({ modalWaiting: true })
this.props.request(!this.props.allRowsSelected ? cs.Routes.AddSubscribersToLists : cs.Routes.AddSubscribersToListsByQuery, this.props
cs.MethodPut, values).then(() => { .request(
notification["success"]({ message: "Lists changed", !this.props.allRowsSelected
description: `Lists changed for selected subscribers` }) ? cs.Routes.AddSubscribersToLists
: cs.Routes.AddSubscribersToListsByQuery,
cs.MethodPut,
values
)
.then(() => {
notification["success"]({
message: "Lists changed",
description: `Lists changed for selected subscribers`
})
this.props.clearSelectedRows() this.props.clearSelectedRows()
this.props.fetchRecords() this.props.fetchRecords()
this.setState({ modalWaiting: false }) this.setState({ modalWaiting: false })
this.props.onClose() this.props.onClose()
}).catch(e => { })
.catch(e => {
notification["error"]({ message: "Error", description: e.message }) notification["error"]({ message: "Error", description: e.message })
this.setState({ modalWaiting: false }) this.setState({ modalWaiting: false })
}) })
@ -60,14 +85,17 @@ class ListsFormDef extends React.PureComponent {
} }
return ( return (
<Modal visible={ true } width="750px" <Modal
visible={true}
width="750px"
className="subscriber-lists-modal" className="subscriber-lists-modal"
title="Manage lists" title="Manage lists"
okText="Ok" okText="Ok"
confirmLoading={ this.state.modalWaiting } confirmLoading={this.state.modalWaiting}
onCancel={ this.props.onClose } onCancel={this.props.onClose}
onOk={ this.handleSubmit }> onOk={this.handleSubmit}
<Form onSubmit={ this.handleSubmit }> >
<Form onSubmit={this.handleSubmit}>
<Form.Item {...formItemLayout} label="Action"> <Form.Item {...formItemLayout} label="Action">
{getFieldDecorator("action", { {getFieldDecorator("action", {
initialValue: "add", initialValue: "add",
@ -81,13 +109,15 @@ class ListsFormDef extends React.PureComponent {
)} )}
</Form.Item> </Form.Item>
<Form.Item {...formItemLayout} label="Lists"> <Form.Item {...formItemLayout} label="Lists">
{getFieldDecorator("target_list_ids", { rules:[{ required: true }] })( {getFieldDecorator("target_list_ids", {
rules: [{ required: true }]
})(
<Select mode="multiple"> <Select mode="multiple">
{[...this.props.lists].map((v, i) => {[...this.props.lists].map((v, i) => (
<Select.Option value={ v.id } key={ v.id }> <Select.Option value={v.id} key={v.id}>
{ v.name } {v.name}
</Select.Option> </Select.Option>
)} ))}
</Select> </Select>
)} )}
</Form.Item> </Form.Item>
@ -111,7 +141,9 @@ class Subscribers extends React.PureComponent {
page: 1, page: 1,
total: 0, total: 0,
perPage: this.defaultPerPage, perPage: this.defaultPerPage,
listID: this.props.route.match.params.listID ? parseInt(this.props.route.match.params.listID, 10) : 0, listID: this.props.route.match.params.listID
? parseInt(this.props.route.match.params.listID, 10)
: 0,
list: null, list: null,
query: null, query: null,
targetLists: [] targetLists: []
@ -142,32 +174,53 @@ class Subscribers extends React.PureComponent {
super(props) super(props)
// Table layout. // Table layout.
this.columns = [{ this.columns = [
{
title: "E-mail", title: "E-mail",
dataIndex: "email", dataIndex: "email",
sorter: true, sorter: true,
width: "25%", width: "25%",
render: (text, record) => { render: (text, record) => {
const out = []; const out = []
out.push( out.push(
<div key={`sub-email-${ record.id }`} className="sub-name"> <div key={`sub-email-${record.id}`} className="sub-name">
<Link to={ `/subscribers/${record.id}` } onClick={(e) => { <Link
to={`/subscribers/${record.id}`}
onClick={e => {
// Open the individual subscriber page on ctrl+click // Open the individual subscriber page on ctrl+click
// and the modal otherwise. // and the modal otherwise.
if(!e.ctrlKey) { if (!e.ctrlKey) {
this.handleShowEditForm(record) this.handleShowEditForm(record)
e.preventDefault() e.preventDefault()
} }
}}>{ text }</Link> }}
>
{text}
</Link>
</div> </div>
) )
if(record.lists.length > 0) { if (record.lists.length > 0) {
for (let i = 0; i < record.lists.length; i++) { for (let i = 0; i < record.lists.length; i++) {
out.push(<Tag className="list" key={`sub-${ record.id }-list-${ record.lists[i].id }`}> out.push(
<Link to={ `/subscribers/lists/${ record.lists[i].id }` }>{ record.lists[i].name }</Link> <Tag
<sup className={ "subscription-status " + record.lists[i].subscription_status }> { record.lists[i].subscription_status }</sup> className="list"
</Tag>) key={`sub-${record.id}-list-${record.lists[i].id}`}
>
<Link to={`/subscribers/lists/${record.lists[i].id}`}>
{record.lists[i].name}
</Link>
<sup
className={
"subscription-status " +
record.lists[i].subscription_status
}
>
{" "}
{record.lists[i].subscription_status}
</sup>
</Tag>
)
} }
} }
@ -181,14 +234,19 @@ class Subscribers extends React.PureComponent {
width: "15%", width: "15%",
render: (text, record) => { render: (text, record) => {
return ( return (
<Link to={ `/subscribers/${record.id}` } onClick={(e) => { <Link
to={`/subscribers/${record.id}`}
onClick={e => {
// Open the individual subscriber page on ctrl+click // Open the individual subscriber page on ctrl+click
// and the modal otherwise. // and the modal otherwise.
if(!e.ctrlKey) { if (!e.ctrlKey) {
this.handleShowEditForm(record) this.handleShowEditForm(record)
e.preventDefault() e.preventDefault()
} }
}}>{ text }</Link> }}
>
{text}
</Link>
) )
} }
}, },
@ -197,7 +255,13 @@ class Subscribers extends React.PureComponent {
dataIndex: "status", dataIndex: "status",
width: "5%", width: "5%",
render: (status, _) => { render: (status, _) => {
return <Tag color={ tagColors.hasOwnProperty(status) ? tagColors[status] : "" }>{ status }</Tag> return (
<Tag
color={tagColors.hasOwnProperty(status) ? tagColors[status] : ""}
>
{status}
</Tag>
)
} }
}, },
{ {
@ -206,7 +270,19 @@ class Subscribers extends React.PureComponent {
width: "10%", width: "10%",
align: "center", align: "center",
render: (lists, _) => { render: (lists, _) => {
return <span>{ lists.reduce((def, item) => def + (item.subscription_status !== cs.SubscriptionStatusUnsubscribed ? 1 : 0), 0) }</span> return (
<span>
{lists.reduce(
(def, item) =>
def +
(item.subscription_status !==
cs.SubscriptionStatusUnsubscribed
? 1
: 0),
0
)}
</span>
)
} }
}, },
{ {
@ -233,9 +309,23 @@ class Subscribers extends React.PureComponent {
return ( return (
<div className="actions"> <div className="actions">
{/* <Tooltip title="Send an e-mail"><a role="button"><Icon type="rocket" /></a></Tooltip> */} {/* <Tooltip title="Send an e-mail"><a role="button"><Icon type="rocket" /></a></Tooltip> */}
<Tooltip title="Edit subscriber"><a role="button" onClick={() => this.handleShowEditForm(record)}><Icon type="edit" /></a></Tooltip> <Tooltip title="Edit subscriber">
<Popconfirm title="Are you sure?" onConfirm={() => this.handleDeleteRecord(record)}> <a
<Tooltip title="Delete subscriber" placement="bottom"><a role="button"><Icon type="delete" /></a></Tooltip> role="button"
onClick={() => this.handleShowEditForm(record)}
>
<Icon type="edit" />
</a>
</Tooltip>
<Popconfirm
title="Are you sure?"
onConfirm={() => this.handleDeleteRecord(record)}
>
<Tooltip title="Delete subscriber" placement="bottom">
<a role="button">
<Icon type="delete" />
</a>
</Tooltip>
</Popconfirm> </Popconfirm>
</div> </div>
) )
@ -246,12 +336,16 @@ class Subscribers extends React.PureComponent {
componentDidMount() { componentDidMount() {
// Load lists on boot. // Load lists on boot.
this.props.modelRequest(cs.ModelLists, cs.Routes.GetLists, cs.MethodGet).then(() => { this.props
.modelRequest(cs.ModelLists, cs.Routes.GetLists, cs.MethodGet)
.then(() => {
// If this is an individual list's view, pick up that list. // If this is an individual list's view, pick up that list.
if(this.state.queryParams.listID) { if (this.state.queryParams.listID) {
this.props.data[cs.ModelLists].forEach((l) => { this.props.data[cs.ModelLists].forEach(l => {
if(l.id === this.state.queryParams.listID) { if (l.id === this.state.queryParams.listID) {
this.setState({ queryParams: { ...this.state.queryParams, list: l }}) this.setState({
queryParams: { ...this.state.queryParams, list: l }
})
return false return false
} }
}) })
@ -261,7 +355,7 @@ class Subscribers extends React.PureComponent {
this.fetchRecords() this.fetchRecords()
} }
fetchRecords = (params) => { fetchRecords = params => {
let qParams = { let qParams = {
page: this.state.queryParams.page, page: this.state.queryParams.page,
per_page: this.state.queryParams.per_page, per_page: this.state.queryParams.per_page,
@ -270,84 +364,136 @@ class Subscribers extends React.PureComponent {
} }
// The records are for a specific list. // The records are for a specific list.
if(this.state.queryParams.listID) { if (this.state.queryParams.listID) {
qParams.list_id = this.state.queryParams.listID qParams.list_id = this.state.queryParams.listID
} }
if(params) { if (params) {
qParams = { ...qParams, ...params } qParams = { ...qParams, ...params }
} }
this.props.modelRequest(cs.ModelSubscribers, cs.Routes.GetSubscribers, cs.MethodGet, qParams).then(() => { this.props
this.setState({ queryParams: { .modelRequest(
cs.ModelSubscribers,
cs.Routes.GetSubscribers,
cs.MethodGet,
qParams
)
.then(() => {
this.setState({
queryParams: {
...this.state.queryParams, ...this.state.queryParams,
total: this.props.data[cs.ModelSubscribers].total, total: this.props.data[cs.ModelSubscribers].total,
perPage: this.props.data[cs.ModelSubscribers].per_page, perPage: this.props.data[cs.ModelSubscribers].per_page,
page: this.props.data[cs.ModelSubscribers].page, page: this.props.data[cs.ModelSubscribers].page,
query: this.props.data[cs.ModelSubscribers].query, query: this.props.data[cs.ModelSubscribers].query
}}) }
})
}) })
} }
handleDeleteRecord = (record) => { handleDeleteRecord = record => {
this.props.modelRequest(cs.ModelSubscribers, cs.Routes.DeleteSubscriber, cs.MethodDelete, { id: record.id }) this.props
.modelRequest(
cs.ModelSubscribers,
cs.Routes.DeleteSubscriber,
cs.MethodDelete,
{ id: record.id }
)
.then(() => { .then(() => {
notification["success"]({ message: "Subscriber deleted", description: `${record.email} deleted` }) notification["success"]({
message: "Subscriber deleted",
description: `${record.email} deleted`
})
// Reload the table. // Reload the table.
this.fetchRecords() this.fetchRecords()
}).catch(e => { })
.catch(e => {
notification["error"]({ message: "Error", description: e.message }) notification["error"]({ message: "Error", description: e.message })
}) })
} }
handleDeleteRecords = (records) => { handleDeleteRecords = records => {
this.props.modelRequest(cs.ModelSubscribers, cs.Routes.DeleteSubscribers, cs.MethodDelete, { id: records.map(r => r.id) }) this.props
.modelRequest(
cs.ModelSubscribers,
cs.Routes.DeleteSubscribers,
cs.MethodDelete,
{ id: records.map(r => r.id) }
)
.then(() => { .then(() => {
notification["success"]({ message: "Subscriber(s) deleted", description: "Selected subscribers deleted" }) notification["success"]({
message: "Subscriber(s) deleted",
description: "Selected subscribers deleted"
})
// Reload the table. // Reload the table.
this.fetchRecords() this.fetchRecords()
}).catch(e => { })
.catch(e => {
notification["error"]({ message: "Error", description: e.message }) notification["error"]({ message: "Error", description: e.message })
}) })
} }
handleBlacklistSubscribers = (records) => { handleBlacklistSubscribers = records => {
this.props.request(cs.Routes.BlacklistSubscribers, cs.MethodPut, { ids: records.map(r => r.id) }) this.props
.request(cs.Routes.BlacklistSubscribers, cs.MethodPut, {
ids: records.map(r => r.id)
})
.then(() => { .then(() => {
notification["success"]({ message: "Subscriber(s) blacklisted", description: "Selected subscribers blacklisted" }) notification["success"]({
message: "Subscriber(s) blacklisted",
description: "Selected subscribers blacklisted"
})
// Reload the table. // Reload the table.
this.fetchRecords() this.fetchRecords()
}).catch(e => { })
.catch(e => {
notification["error"]({ message: "Error", description: e.message }) notification["error"]({ message: "Error", description: e.message })
}) })
} }
// Arbitrary query based calls. // Arbitrary query based calls.
handleDeleteRecordsByQuery = (listIDs, query) => { handleDeleteRecordsByQuery = (listIDs, query) => {
this.props.modelRequest(cs.ModelSubscribers, cs.Routes.DeleteSubscribersByQuery, cs.MethodPost, this.props
{ list_ids: listIDs, query: query }) .modelRequest(
cs.ModelSubscribers,
cs.Routes.DeleteSubscribersByQuery,
cs.MethodPost,
{ list_ids: listIDs, query: query }
)
.then(() => { .then(() => {
notification["success"]({ message: "Subscriber(s) deleted", description: "Selected subscribers have been deleted" }) notification["success"]({
message: "Subscriber(s) deleted",
description: "Selected subscribers have been deleted"
})
// Reload the table. // Reload the table.
this.fetchRecords() this.fetchRecords()
}).catch(e => { })
.catch(e => {
notification["error"]({ message: "Error", description: e.message }) notification["error"]({ message: "Error", description: e.message })
}) })
} }
handleBlacklistSubscribersByQuery = (listIDs, query) => { handleBlacklistSubscribersByQuery = (listIDs, query) => {
this.props.request(cs.Routes.BlacklistSubscribersByQuery, cs.MethodPut, this.props
{ list_ids: listIDs, query: query }) .request(cs.Routes.BlacklistSubscribersByQuery, cs.MethodPut, {
list_ids: listIDs,
query: query
})
.then(() => { .then(() => {
notification["success"]({ message: "Subscriber(s) blacklisted", description: "Selected subscribers have been blacklisted" }) notification["success"]({
message: "Subscriber(s) blacklisted",
description: "Selected subscribers have been blacklisted"
})
// Reload the table. // Reload the table.
this.fetchRecords() this.fetchRecords()
}).catch(e => { })
.catch(e => {
notification["error"]({ message: "Error", description: e.message }) notification["error"]({ message: "Error", description: e.message })
}) })
} }
@ -359,10 +505,16 @@ class Subscribers extends React.PureComponent {
target_lists: targetLists target_lists: targetLists
} }
this.props.request(cs.Routes.QuerySubscribersIntoLists, cs.MethodPost, params).then((res) => { this.props
notification["success"]({ message: "Subscriber(s) added", description: `${ res.data.data.count } added` }) .request(cs.Routes.QuerySubscribersIntoLists, cs.MethodPost, params)
.then(res => {
notification["success"]({
message: "Subscriber(s) added",
description: `${res.data.data.count} added`
})
this.handleToggleListModal() this.handleToggleListModal()
}).catch(e => { })
.catch(e => {
notification["error"]({ message: "Error", description: e.message }) notification["error"]({ message: "Error", description: e.message })
}) })
} }
@ -375,7 +527,7 @@ class Subscribers extends React.PureComponent {
this.setState({ formType: cs.FormCreate, attribs: [], record: {} }) this.setState({ formType: cs.FormCreate, attribs: [], record: {} })
} }
handleShowEditForm = (record) => { handleShowEditForm = record => {
this.setState({ formType: cs.FormEdit, record: record }) this.setState({ formType: cs.FormEdit, record: record })
} }
@ -383,9 +535,9 @@ class Subscribers extends React.PureComponent {
this.setState({ listsFormVisible: !this.state.listsFormVisible }) this.setState({ listsFormVisible: !this.state.listsFormVisible })
} }
handleSearch = (q) => { handleSearch = q => {
q = q.trim().toLowerCase() q = q.trim().toLowerCase()
if(q === "") { if (q === "") {
this.fetchRecords({ query: null }) this.fetchRecords({ query: null })
return return
} }
@ -400,8 +552,10 @@ class Subscribers extends React.PureComponent {
} }
handleSelectAllRows = () => { handleSelectAllRows = () => {
this.setState({ allRowsSelected: true, this.setState({
selectedRows: this.props.data[cs.ModelSubscribers].results }) allRowsSelected: true,
selectedRows: this.props.data[cs.ModelSubscribers].results
})
} }
clearSelectedRows = (_, records) => { clearSelectedRows = (_, records) => {
@ -422,7 +576,7 @@ class Subscribers extends React.PureComponent {
...this.state.queryParams ...this.state.queryParams
} }
if(this.state.queryParams.list) { if (this.state.queryParams.list) {
this.props.pageTitle(this.state.queryParams.list.name + " / Subscribers") this.props.pageTitle(this.state.queryParams.list.name + " / Subscribers")
} else { } else {
this.props.pageTitle("Subscribers") this.props.pageTitle("Subscribers")
@ -432,17 +586,25 @@ class Subscribers extends React.PureComponent {
<section className="content"> <section className="content">
<header className="header"> <header className="header">
<Row> <Row>
<Col span={ 20 }> <Col span={20}>
<h1> <h1>
Subscribers Subscribers
{ this.props.data[cs.ModelSubscribers].total > 0 && {this.props.data[cs.ModelSubscribers].total > 0 && (
<span> ({ this.props.data[cs.ModelSubscribers].total })</span> } <span> ({this.props.data[cs.ModelSubscribers].total})</span>
{ this.state.queryParams.list && )}
<span> &raquo; { this.state.queryParams.list.name }</span> } {this.state.queryParams.list && (
<span> &raquo; {this.state.queryParams.list.name}</span>
)}
</h1> </h1>
</Col> </Col>
<Col span={ 2 }> <Col span={2}>
<Button type="primary" icon="plus" onClick={ this.handleShowCreateForm }>Add subscriber</Button> <Button
type="primary"
icon="plus"
onClick={this.handleShowCreateForm}
>
Add subscriber
</Button>
</Col> </Col>
</Row> </Row>
</header> </header>
@ -451,155 +613,224 @@ class Subscribers extends React.PureComponent {
<Row> <Row>
<Col span={10}> <Col span={10}>
<Row> <Row>
<Col span={ 15 }> <Col span={15}>
<label>Search subscribers</label> <label>Search subscribers</label>
<Input.Search <Input.Search
name="name" name="name"
placeholder="Name or e-mail" placeholder="Name or e-mail"
enterButton enterButton
onSearch={ this.handleSearch } /> onSearch={this.handleSearch}
{" "} />{" "}
</Col> </Col>
<Col span={ 8 } offset={ 1 }> <Col span={8} offset={1}>
<label>&nbsp;</label><br /> <label>&nbsp;</label>
<a role="button" onClick={ this.handleToggleQueryForm }> <br />
<Icon type="setting" /> Advanced</a> <a role="button" onClick={this.handleToggleQueryForm}>
<Icon type="setting" /> Advanced
</a>
</Col> </Col>
</Row> </Row>
{ this.state.queryFormVisible && {this.state.queryFormVisible && (
<div className="advanced-query"> <div className="advanced-query">
<p> <p>
<label>Advanced query</label> <label>Advanced query</label>
<Input.TextArea placeholder="subscribers.name LIKE '%user%' or subscribers.status='blacklisted'" <Input.TextArea
placeholder="subscribers.name LIKE '%user%' or subscribers.status='blacklisted'"
id="subscriber-query" id="subscriber-query"
rows={ 10 } rows={10}
onChange={(e) => { onChange={e => {
this.setState({ queryParams: { ...this.state.queryParams, query: e.target.value } }) this.setState({
queryParams: {
...this.state.queryParams,
query: e.target.value
}
})
}} }}
value={ this.state.queryParams.query } value={this.state.queryParams.query}
autosize={{ minRows: 2, maxRows: 10 }} /> autosize={{ minRows: 2, maxRows: 10 }}
/>
<span className="text-tiny text-small"> <span className="text-tiny text-small">
Write a partial SQL expression to query the subscribers based on their primary information or attributes. Learn more. Write a partial SQL expression to query the subscribers
based on their primary information or attributes. Learn
more.
</span> </span>
</p> </p>
<p> <p>
<Button <Button
disabled={ this.state.queryParams.query === "" } disabled={this.state.queryParams.query === ""}
type="primary" type="primary"
icon="search" icon="search"
onClick={ () => { this.fetchRecords() } }>Query</Button> onClick={() => {
{" "} this.fetchRecords()
}}
>
Query
</Button>{" "}
<Button <Button
disabled={ this.state.queryParams.query === "" } disabled={this.state.queryParams.query === ""}
icon="refresh" icon="refresh"
onClick={ () => { this.fetchRecords({ query: null }) } }>Reset</Button> onClick={() => {
this.fetchRecords({ query: null })
}}
>
Reset
</Button>
</p> </p>
</div> </div>
} )}
</Col> </Col>
<Col span={14}> <Col span={14}>
{ this.state.selectedRows.length > 0 && {this.state.selectedRows.length > 0 && (
<nav className="table-options"> <nav className="table-options">
<p> <p>
<strong>{ this.state.allRowsSelected ? this.state.queryParams.total : this.state.selectedRows.length }</strong> <strong>
{" "} subscriber(s) selected {this.state.allRowsSelected
{ !this.state.allRowsSelected && this.state.queryParams.total > this.state.queryParams.perPage && ? this.state.queryParams.total
<span> &mdash; <a role="button" onClick={ this.handleSelectAllRows }> : this.state.selectedRows.length}
Select all { this.state.queryParams.total }?</a> </strong>{" "}
subscriber(s) selected
{!this.state.allRowsSelected &&
this.state.queryParams.total >
this.state.queryParams.perPage && (
<span>
{" "}
&mdash;{" "}
<a role="button" onClick={this.handleSelectAllRows}>
Select all {this.state.queryParams.total}?
</a>
</span> </span>
} )}
</p> </p>
<p> <p>
<a role="button" onClick={ this.handleToggleListsForm }> <a role="button" onClick={this.handleToggleListsForm}>
<Icon type="bars" /> Manage lists <Icon type="bars" /> Manage lists
</a> </a>
<a role="button"><Icon type="rocket" /> Send campaign</a> <a role="button">
<Popconfirm title="Are you sure?" onConfirm={() => { <Icon type="rocket" /> Send campaign
if(this.state.allRowsSelected) { </a>
this.handleDeleteRecordsByQuery(this.state.queryParams.listID ? [this.state.queryParams.listID] : [], this.state.queryParams.query) <Popconfirm
title="Are you sure?"
onConfirm={() => {
if (this.state.allRowsSelected) {
this.handleDeleteRecordsByQuery(
this.state.queryParams.listID
? [this.state.queryParams.listID]
: [],
this.state.queryParams.query
)
this.clearSelectedRows() this.clearSelectedRows()
} else { } else {
this.handleDeleteRecords(this.state.selectedRows) this.handleDeleteRecords(this.state.selectedRows)
this.clearSelectedRows() this.clearSelectedRows()
} }
}}> }}
<a role="button"><Icon type="delete" /> Delete</a> >
<a role="button">
<Icon type="delete" /> Delete
</a>
</Popconfirm> </Popconfirm>
<Popconfirm title="Are you sure?" onConfirm={() => { <Popconfirm
if(this.state.allRowsSelected) { title="Are you sure?"
this.handleBlacklistSubscribersByQuery(this.state.queryParams.listID ? [this.state.queryParams.listID] : [], this.state.queryParams.query) onConfirm={() => {
if (this.state.allRowsSelected) {
this.handleBlacklistSubscribersByQuery(
this.state.queryParams.listID
? [this.state.queryParams.listID]
: [],
this.state.queryParams.query
)
this.clearSelectedRows() this.clearSelectedRows()
} else { } else {
this.handleBlacklistSubscribers(this.state.selectedRows) this.handleBlacklistSubscribers(
this.state.selectedRows
)
this.clearSelectedRows() this.clearSelectedRows()
} }
}}> }}
<a role="button"><Icon type="close" /> Blacklist</a> >
<a role="button">
<Icon type="close" /> Blacklist
</a>
</Popconfirm> </Popconfirm>
</p> </p>
</nav> </nav>
} )}
</Col> </Col>
</Row> </Row>
</div> </div>
<Table <Table
columns={ this.columns } columns={this.columns}
rowKey={ record => `sub-${record.id}` } rowKey={record => `sub-${record.id}`}
dataSource={ this.props.data[cs.ModelSubscribers].results } dataSource={this.props.data[cs.ModelSubscribers].results}
loading={ this.props.reqStates[cs.ModelSubscribers] !== cs.StateDone } loading={this.props.reqStates[cs.ModelSubscribers] !== cs.StateDone}
pagination={ pagination } pagination={pagination}
rowSelection = {{ rowSelection={{
columnWidth: "5%", columnWidth: "5%",
onChange: this.handleSelectRow, onChange: this.handleSelectRow,
selectedRowKeys: this.state.selectedRows.map(r => `sub-${r.id}`) selectedRowKeys: this.state.selectedRows.map(r => `sub-${r.id}`)
}} }}
/> />
{ this.state.formType !== null && {this.state.formType !== null && (
<Modal visible={ true } width="750px" <Modal
visible={true}
width="750px"
className="subscriber-modal" className="subscriber-modal"
okText={ this.state.form === cs.FormCreate ? "Add" : "Save" } okText={this.state.form === cs.FormCreate ? "Add" : "Save"}
confirmLoading={ this.state.modalWaiting } confirmLoading={this.state.modalWaiting}
onOk={(e) => { onOk={e => {
if(!this.state.modalForm) { if (!this.state.modalForm) {
return; return
} }
// This submits the form embedded in the Subscriber component. // This submits the form embedded in the Subscriber component.
this.state.modalForm.submitForm(e, (ok) => { this.state.modalForm.submitForm(e, ok => {
if(ok) { if (ok) {
this.handleHideForm() this.handleHideForm()
this.fetchRecords() this.fetchRecords()
} }
}) })
}} }}
onCancel={ this.handleHideForm } onCancel={this.handleHideForm}
okButtonProps={{ disabled: this.props.reqStates[cs.ModelSubscribers] === cs.StatePending }}> okButtonProps={{
<Subscriber {...this.props} disabled:
isModal={ true } this.props.reqStates[cs.ModelSubscribers] === cs.StatePending
formType={ this.state.formType } }}
record={ this.state.record } >
ref={ (r) => { <Subscriber
if(!r) { {...this.props}
isModal={true}
formType={this.state.formType}
record={this.state.record}
ref={r => {
if (!r) {
return return
} }
this.setState({ modalForm: r }) this.setState({ modalForm: r })
}}/> }}
/>
</Modal> </Modal>
} )}
{ this.state.listsFormVisible && <ListsForm {...this.props} {this.state.listsFormVisible && (
lists={ this.props.data[cs.ModelLists] } <ListsForm
allRowsSelected={ this.state.allRowsSelected } {...this.props}
selectedRows={ this.state.selectedRows } lists={this.props.data[cs.ModelLists]}
selectedLists={ this.state.queryParams.listID ? [this.state.queryParams.listID] : []} allRowsSelected={this.state.allRowsSelected}
clearSelectedRows={ this.clearSelectedRows } selectedRows={this.state.selectedRows}
query={ this.state.queryParams.query } selectedLists={
fetchRecords={ this.fetchRecords } this.state.queryParams.listID
onClose={ this.handleToggleListsForm } /> ? [this.state.queryParams.listID]
: []
} }
clearSelectedRows={this.clearSelectedRows}
query={this.state.queryParams.query}
fetchRecords={this.fetchRecords}
onClose={this.handleToggleListsForm}
/>
)}
</section> </section>
) )
} }

View File

@ -1,5 +1,19 @@
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 ModalPreview from "./ModalPreview"
import Utils from "./utils" import Utils from "./utils"
@ -14,7 +28,7 @@ class CreateFormDef extends React.PureComponent {
} }
// Handle create / edit form submission. // Handle create / edit form submission.
handleSubmit = (e) => { handleSubmit = e => {
e.preventDefault() e.preventDefault()
this.props.form.validateFields((err, values) => { this.props.form.validateFields((err, values) => {
if (err) { if (err) {
@ -24,31 +38,63 @@ class CreateFormDef extends React.PureComponent {
this.setState({ modalWaiting: true }) this.setState({ modalWaiting: true })
if (this.props.formType === cs.FormCreate) { if (this.props.formType === cs.FormCreate) {
// Create a new list. // Create a new list.
this.props.modelRequest(cs.ModelTemplates, cs.Routes.CreateTemplate, cs.MethodPost, values).then(() => { this.props
notification["success"]({ placement: cs.MsgPosition, message: "Template added", description: `"${values["name"]}" added` }) .modelRequest(
cs.ModelTemplates,
cs.Routes.CreateTemplate,
cs.MethodPost,
values
)
.then(() => {
notification["success"]({
placement: cs.MsgPosition,
message: "Template added",
description: `"${values["name"]}" added`
})
this.props.fetchRecords() this.props.fetchRecords()
this.props.onClose() this.props.onClose()
this.setState({ modalWaiting: false }) this.setState({ modalWaiting: false })
}).catch(e => { })
notification["error"]({ placement: cs.MsgPosition, message: "Error", description: e.message }) .catch(e => {
notification["error"]({
placement: cs.MsgPosition,
message: "Error",
description: e.message
})
this.setState({ modalWaiting: false }) this.setState({ modalWaiting: false })
}) })
} else { } else {
// Edit a list. // Edit a list.
this.props.modelRequest(cs.ModelTemplates, cs.Routes.UpdateTemplate, cs.MethodPut, { ...values, id: this.props.record.id }).then(() => { this.props
notification["success"]({ placement: cs.MsgPosition, message: "Template updated", description: `"${values["name"]}" modified` }) .modelRequest(
cs.ModelTemplates,
cs.Routes.UpdateTemplate,
cs.MethodPut,
{ ...values, id: this.props.record.id }
)
.then(() => {
notification["success"]({
placement: cs.MsgPosition,
message: "Template updated",
description: `"${values["name"]}" modified`
})
this.props.fetchRecords() this.props.fetchRecords()
this.props.onClose() this.props.onClose()
this.setState({ modalWaiting: false }) this.setState({ modalWaiting: false })
}).catch(e => { })
notification["error"]({ placement: cs.MsgPosition, message: "Error", description: e.message }) .catch(e => {
notification["error"]({
placement: cs.MsgPosition,
message: "Error",
description: e.message
})
this.setState({ modalWaiting: false }) this.setState({ modalWaiting: false })
}) })
} }
}) })
} }
handleConfirmBlur = (e) => { handleConfirmBlur = e => {
const value = e.target.value const value = e.target.value
this.setState({ confirmDirty: this.state.confirmDirty || !!value }) this.setState({ confirmDirty: this.state.confirmDirty || !!value })
} }
@ -72,15 +118,21 @@ class CreateFormDef extends React.PureComponent {
return ( return (
<div> <div>
<Modal visible={ true } title={ formType === cs.FormCreate ? "Add template" : record.name } <Modal
okText={ this.state.form === cs.FormCreate ? "Add" : "Save" } visible={true}
title={formType === cs.FormCreate ? "Add template" : record.name}
okText={this.state.form === cs.FormCreate ? "Add" : "Save"}
width="90%" width="90%"
height={ 900 } height={900}
confirmLoading={ this.state.modalWaiting } confirmLoading={this.state.modalWaiting}
onCancel={ onClose } onCancel={onClose}
onOk={ this.handleSubmit }> 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", {
@ -89,37 +141,60 @@ class CreateFormDef extends React.PureComponent {
})(<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", {
<Input.TextArea autosize={{ minRows: 10, maxRows: 30 }} /> initialValue: record.body ? record.body : "",
)} rules: [{ required: true }]
</Form.Item> })(<Input.TextArea autosize={{ minRows: 10, maxRows: 30 }} />)}
{ this.props.form.getFieldValue("body") !== "" &&
<Form.Item {...formItemLayout} colon={ false } label="&nbsp;">
<Button icon="search" onClick={ () =>
this.handlePreview(this.props.form.getFieldValue("name"), this.props.form.getFieldValue("body"))
}>Preview</Button>
</Form.Item> </Form.Item>
{this.props.form.getFieldValue("body") !== "" && (
<Form.Item {...formItemLayout} colon={false} label="&nbsp;">
<Button
icon="search"
onClick={() =>
this.handlePreview(
this.props.form.getFieldValue("name"),
this.props.form.getFieldValue("body")
)
} }
>
Preview
</Button>
</Form.Item>
)}
</Form> </Form>
</Spin> </Spin>
<Row> <Row>
<Col span="4"></Col> <Col span="4" />
<Col span="18" className="text-grey text-small"> <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>. The placeholder{" "}
<code>
{"{"}
{"{"} template "content" . {"}"}
{"}"}
</code>{" "}
should appear in the template.{" "}
<a href="" target="_blank">
Read more on templating
</a>
.
</Col> </Col>
</Row> </Row>
</Modal> </Modal>
{ this.state.previewBody && {this.state.previewBody && (
<ModalPreview <ModalPreview
title={ this.state.previewName ? this.state.previewName : "Template preview" } title={
previewURL={ cs.Routes.PreviewNewTemplate } this.state.previewName
body={ this.state.previewBody } ? this.state.previewName
: "Template preview"
}
previewURL={cs.Routes.PreviewNewTemplate}
body={this.state.previewBody}
onCancel={() => { onCancel={() => {
this.setState({ previewBody: null, previewName: null }) this.setState({ previewBody: null, previewName: null })
}} }}
/> />
} )}
</div> </div>
) )
} }
@ -137,7 +212,8 @@ class Templates extends React.PureComponent {
constructor(props) { constructor(props) {
super(props) super(props)
this.columns = [{ this.columns = [
{
title: "Name", title: "Name",
dataIndex: "name", dataIndex: "name",
sorter: true, sorter: true,
@ -145,9 +221,14 @@ class Templates extends React.PureComponent {
render: (text, record) => { render: (text, record) => {
return ( return (
<div className="name"> <div className="name">
<a role="button" onClick={() => this.handleShowEditForm(record)}>{ text }</a> <a role="button" onClick={() => this.handleShowEditForm(record)}>
{ record.is_default && {text}
<div><Tag>Default</Tag></div>} </a>
{record.is_default && (
<div>
<Tag>Default</Tag>
</div>
)}
</div> </div>
) )
} }
@ -174,25 +255,54 @@ class Templates extends React.PureComponent {
render: (text, record) => { render: (text, record) => {
return ( return (
<div className="actions"> <div className="actions">
<Tooltip title="Preview template" onClick={() => this.handlePreview(record)}><a role="button"><Icon type="search" /></a></Tooltip> <Tooltip
title="Preview template"
onClick={() => this.handlePreview(record)}
>
<a role="button">
<Icon type="search" />
</a>
</Tooltip>
{ !record.is_default && {!record.is_default && (
<Popconfirm title="Are you sure?" onConfirm={() => this.handleSetDefault(record)}> <Popconfirm
<Tooltip title="Set as default" placement="bottom"><a role="button"><Icon type="check" /></a></Tooltip> title="Are you sure?"
onConfirm={() => this.handleSetDefault(record)}
>
<Tooltip title="Set as default" placement="bottom">
<a role="button">
<Icon type="check" />
</a>
</Tooltip>
</Popconfirm> </Popconfirm>
} )}
<Tooltip title="Edit template"><a role="button" onClick={() => this.handleShowEditForm(record)}><Icon type="edit" /></a></Tooltip> <Tooltip title="Edit template">
<a
role="button"
onClick={() => this.handleShowEditForm(record)}
>
<Icon type="edit" />
</a>
</Tooltip>
{ record.id !== 1 && {record.id !== 1 && (
<Popconfirm title="Are you sure?" onConfirm={() => this.handleDeleteRecord(record)}> <Popconfirm
<Tooltip title="Delete template" placement="bottom"><a role="button"><Icon type="delete" /></a></Tooltip> title="Are you sure?"
onConfirm={() => this.handleDeleteRecord(record)}
>
<Tooltip title="Delete template" placement="bottom">
<a role="button">
<Icon type="delete" />
</a>
</Tooltip>
</Popconfirm> </Popconfirm>
} )}
</div> </div>
) )
} }
}] }
]
} }
componentDidMount() { componentDidMount() {
@ -201,34 +311,64 @@ class Templates extends React.PureComponent {
} }
fetchRecords = () => { fetchRecords = () => {
this.props.modelRequest(cs.ModelTemplates, cs.Routes.GetTemplates, cs.MethodGet) this.props.modelRequest(
cs.ModelTemplates,
cs.Routes.GetTemplates,
cs.MethodGet
)
} }
handleDeleteRecord = (record) => { handleDeleteRecord = record => {
this.props.modelRequest(cs.ModelTemplates, cs.Routes.DeleteTemplate, cs.MethodDelete, { id: record.id }) this.props
.modelRequest(
cs.ModelTemplates,
cs.Routes.DeleteTemplate,
cs.MethodDelete,
{ id: record.id }
)
.then(() => { .then(() => {
notification["success"]({ placement: cs.MsgPosition, message: "Template deleted", description: `"${record.name}" deleted` }) notification["success"]({
placement: cs.MsgPosition,
message: "Template deleted",
description: `"${record.name}" deleted`
})
// Reload the table. // Reload the table.
this.fetchRecords() this.fetchRecords()
}).catch(e => { })
.catch(e => {
notification["error"]({ message: "Error", description: e.message }) notification["error"]({ message: "Error", description: e.message })
}) })
} }
handleSetDefault = (record) => { handleSetDefault = record => {
this.props.modelRequest(cs.ModelTemplates, cs.Routes.SetDefaultTemplate, cs.MethodPut, { id: record.id }) this.props
.modelRequest(
cs.ModelTemplates,
cs.Routes.SetDefaultTemplate,
cs.MethodPut,
{ id: record.id }
)
.then(() => { .then(() => {
notification["success"]({ placement: cs.MsgPosition, message: "Template updated", description: `"${record.name}" set as default` }) notification["success"]({
placement: cs.MsgPosition,
message: "Template updated",
description: `"${record.name}" set as default`
})
// Reload the table. // Reload the table.
this.fetchRecords() this.fetchRecords()
}).catch(e => { })
notification["error"]({ placement: cs.MsgPosition, message: "Error", description: e.message }) .catch(e => {
notification["error"]({
placement: cs.MsgPosition,
message: "Error",
description: e.message
})
}) })
} }
handlePreview = (record) => { handlePreview = record => {
this.setState({ previewRecord: record }) this.setState({ previewRecord: record })
} }
@ -240,7 +380,7 @@ class Templates extends React.PureComponent {
this.setState({ formType: cs.FormCreate, record: {} }) this.setState({ formType: cs.FormCreate, record: {} })
} }
handleShowEditForm = (record) => { handleShowEditForm = record => {
this.setState({ formType: cs.FormEdit, record: record }) this.setState({ formType: cs.FormEdit, record: record })
} }
@ -248,37 +388,49 @@ class Templates extends React.PureComponent {
return ( return (
<section className="content templates"> <section className="content templates">
<Row> <Row>
<Col span={22}><h1>Templates ({this.props.data[cs.ModelTemplates].length}) </h1></Col> <Col span={22}>
<h1>Templates ({this.props.data[cs.ModelTemplates].length}) </h1>
</Col>
<Col span={2}> <Col span={2}>
<Button type="primary" icon="plus" onClick={ this.handleShowCreateForm }>Add template</Button> <Button
type="primary"
icon="plus"
onClick={this.handleShowCreateForm}
>
Add template
</Button>
</Col> </Col>
</Row> </Row>
<br /> <br />
<Table <Table
columns={ this.columns } columns={this.columns}
rowKey={ record => record.id } rowKey={record => record.id}
dataSource={ this.props.data[cs.ModelTemplates] } dataSource={this.props.data[cs.ModelTemplates]}
loading={ this.props.reqStates[cs.ModelTemplates] !== cs.StateDone } loading={this.props.reqStates[cs.ModelTemplates] !== cs.StateDone}
pagination={ false } pagination={false}
/> />
<CreateForm { ...this.props } <CreateForm
formType={ this.state.formType } {...this.props}
record={ this.state.record } formType={this.state.formType}
onClose={ this.hideForm } record={this.state.record}
fetchRecords = { this.fetchRecords } onClose={this.hideForm}
fetchRecords={this.fetchRecords}
/> />
{ this.state.previewRecord && {this.state.previewRecord && (
<ModalPreview <ModalPreview
title={ this.state.previewRecord.name } title={this.state.previewRecord.name}
previewURL={ cs.Routes.PreviewTemplate.replace(":id", this.state.previewRecord.id) } previewURL={cs.Routes.PreviewTemplate.replace(
":id",
this.state.previewRecord.id
)}
onCancel={() => { onCancel={() => {
this.setState({ previewRecord: null }) this.setState({ previewRecord: null })
}} }}
/> />
} )}
</section> </section>
) )
} }

View File

@ -1,41 +0,0 @@
import React from "react";
import ReactQuill from "react-quill"
import "react-quill/dist/quill.snow.css"
const quillModules = {
toolbar: {
container: [
[{"header": [1, 2, 3, false] }],
["bold", "italic", "underline", "strike", "blockquote", "code"],
[{ "color": [] }, { "background": [] }, { 'size': [] }],
[{"list": "ordered"}, {"list": "bullet"}, {"indent": "-1"}, {"indent": "+1"}],
[{"align": ""}, { "align": "center" }, { "align": "right" }, { "align": "justify" }],
["link", "gallery"],
["clean", "font"]
],
handlers: {
"gallery": function() {
}
}
}
}
class QuillEditor extends React.Component {
componentDidMount() {
}
render() {
return (
<div>
<ReactQuill
modules={ quillModules }
value="<h2>Welcome</h2>"
/>
</div>
)
}
}
export default QuillEditor;

View File

@ -36,7 +36,7 @@ export const CampaignStatusColors = {
running: "blue", running: "blue",
paused: "orange", paused: "orange",
finished: "green", finished: "green",
cancelled: "red", cancelled: "red"
} }
export const CampaignStatusDraft = "draft" export const CampaignStatusDraft = "draft"

View File

@ -1,8 +1,7 @@
import React from 'react'; import React from "react"
import ReactDOM from 'react-dom'; import ReactDOM from "react-dom"
import './index.css'; import "./index.css"
import App from './App.js' import App from "./App.js"
ReactDOM.render(<App />, document.getElementById("root"))
ReactDOM.render((<App />), document.getElementById('root'))

View File

@ -1,23 +1,42 @@
import React from 'react' import React from "react"
import ReactDOM from 'react-dom'; import ReactDOM from "react-dom"
import { Alert } from 'antd';
import { Alert } from "antd"
class Utils { class Utils {
static months = [ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" ] static months = [
static days = [ "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" ] "Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec"
]
static days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
// Converts the ISO date format to a simpler form. // Converts the ISO date format to a simpler form.
static DateString = (stamp, showTime) => { static DateString = (stamp, showTime) => {
if(!stamp) { if (!stamp) {
return "" return ""
} }
let d = new Date(stamp) let d = new Date(stamp)
let out =
Utils.days[d.getDay()] +
", " +
d.getDate() +
" " +
Utils.months[d.getMonth()] +
" " +
d.getFullYear()
let out = Utils.days[d.getDay()] + ", " + d.getDate() + " " + Utils.months[d.getMonth()] + " " + d.getFullYear() if (showTime) {
if(showTime) {
out += " " + d.getHours() + ":" + d.getMinutes() out += " " + d.getHours() + ":" + d.getMinutes()
} }
@ -25,34 +44,38 @@ class Utils {
} }
// HttpError takes an axios error and returns an error dict after some sanity checks. // HttpError takes an axios error and returns an error dict after some sanity checks.
static HttpError = (err) => { static HttpError = err => {
if (!err.response) { if (!err.response) {
return err return err
} }
if(!err.response.data || !err.response.data.message) { if (!err.response.data || !err.response.data.message) {
return { return {
"message": err.message + " - " + err.response.request.responseURL, message: err.message + " - " + err.response.request.responseURL,
"data": {} data: {}
} }
} }
return { return {
"message": err.response.data.message, message: err.response.data.message,
"data": err.response.data.data data: err.response.data.data
} }
} }
// Shows a flash message. // Shows a flash message.
static Alert = (msg, msgType) => { static Alert = (msg, msgType) => {
document.getElementById('alert-container').classList.add('visible') document.getElementById("alert-container").classList.add("visible")
ReactDOM.render(<Alert message={ msg } type={ msgType } showIcon />, ReactDOM.render(
document.getElementById('alert-container')) <Alert message={msg} type={msgType} showIcon />,
document.getElementById("alert-container")
)
} }
static ModalAlert = (msg, msgType) => { static ModalAlert = (msg, msgType) => {
document.getElementById('modal-alert-container').classList.add('visible') document.getElementById("modal-alert-container").classList.add("visible")
ReactDOM.render(<Alert message={ msg } type={ msgType } showIcon />, ReactDOM.render(
document.getElementById('modal-alert-container')) <Alert message={msg} type={msgType} showIcon />,
document.getElementById("modal-alert-container")
)
} }
} }

View File

@ -5,9 +5,8 @@ import (
"database/sql" "database/sql"
"fmt" "fmt"
"github.com/lib/pq"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/lib/pq"
) )
// Queries contains all prepared SQL queries. // Queries contains all prepared SQL queries.