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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import { Col, Row, notification, Card, Tooltip, Icon, Spin } from "antd"
import React from "react";
import { Chart, Axis, Geom, Tooltip as BizTooltip } from 'bizcharts';
import { Col, Row, notification, Card, Spin } from "antd"
import React from "react"
import { Chart, Geom, Tooltip as BizTooltip } from "bizcharts"
import * as cs from "./constants"
@ -10,13 +10,23 @@ class Dashboard extends React.PureComponent {
loading: true
}
campaignTypes = ["running", "finished", "paused", "draft", "scheduled", "cancelled"]
campaignTypes = [
"running",
"finished",
"paused",
"draft",
"scheduled",
"cancelled"
]
componentDidMount = () => {
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 })
}).catch(e => {
})
.catch(e => {
notification["error"]({ message: "Error", description: e.message })
this.setState({ loading: false })
})
@ -28,42 +38,54 @@ class Dashboard extends React.PureComponent {
render() {
return (
<section className = "dashboard">
<section className="dashboard">
<h1>Welcome</h1>
<hr />
<Spin spinning={ this.state.loading }>
{ this.state.stats &&
<Spin spinning={this.state.loading}>
{this.state.stats && (
<div className="stats">
<Row>
<Col span={ 16 }>
<Row gutter={ 24 }>
<Col span={ 8 }>
<Card title="Active subscribers" bordered={ false }>
<h1 className="count">{ this.orZero(this.state.stats.subscribers.enabled) }</h1>
<Col span={16}>
<Row gutter={24}>
<Col span={8}>
<Card title="Active subscribers" bordered={false}>
<h1 className="count">
{this.orZero(this.state.stats.subscribers.enabled)}
</h1>
</Card>
</Col>
<Col span={ 8 }>
<Card title="Blacklisted subscribers" bordered={ false }>
<h1 className="count">{ this.orZero(this.state.stats.subscribers.blacklisted) }</h1>
<Col span={8}>
<Card title="Blacklisted subscribers" bordered={false}>
<h1 className="count">
{this.orZero(
this.state.stats.subscribers.blacklisted
)}
</h1>
</Card>
</Col>
<Col span={ 8 }>
<Card title="Orphaned subscribers" bordered={ false }>
<h1 className="count">{ this.orZero(this.state.stats.orphan_subscribers) }</h1>
<Col span={8}>
<Card title="Orphaned subscribers" bordered={false}>
<h1 className="count">
{this.orZero(this.state.stats.orphan_subscribers)}
</h1>
</Card>
</Col>
</Row>
</Col>
<Col span={ 6 } offset={ 2 }>
<Row gutter={ 24 }>
<Col span={ 12 }>
<Card title="Public lists" bordered={ false }>
<h1 className="count">{ this.orZero(this.state.stats.lists.public) }</h1>
<Col span={6} offset={2}>
<Row gutter={24}>
<Col span={12}>
<Card title="Public lists" bordered={false}>
<h1 className="count">
{this.orZero(this.state.stats.lists.public)}
</h1>
</Card>
</Col>
<Col span={ 12 }>
<Card title="Private lists" bordered={ false }>
<h1 className="count">{ this.orZero(this.state.stats.lists.private) }</h1>
<Col span={12}>
<Card title="Private lists" bordered={false}>
<h1 className="count">
{this.orZero(this.state.stats.lists.private)}
</h1>
</Card>
</Col>
</Row>
@ -71,61 +93,98 @@ class Dashboard extends React.PureComponent {
</Row>
<hr />
<Row>
<Col span={ 16 }>
<Row gutter={ 24 }>
<Col span={ 12 }>
<Card title="Campaign views (last 3 months)" bordered={ false }>
<Col span={16}>
<Row gutter={24}>
<Col span={12}>
<Card
title="Campaign views (last 3 months)"
bordered={false}
>
<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
</h1>
<Chart height={ 220 } padding={ [0, 0, 0, 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
height={220}
padding={[0, 0, 0, 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>
</Card>
</Col>
<Col span={ 12 }>
<Card title="Link clicks (last 3 months)" bordered={ false }>
<Col span={12}>
<Card
title="Link clicks (last 3 months)"
bordered={false}
>
<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
</h1>
<Chart height={ 220 } padding={ [0, 0, 0, 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
height={220}
padding={[0, 0, 0, 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>
</Card>
</Col>
</Row>
</Col>
<Col span={ 6 } offset={ 2 }>
<Card title="Campaigns" bordered={ false } className="campaign-counts">
{ this.campaignTypes.map((key) =>
<Row key={ `stats-campaigns-${ key }` }>
<Col span={ 18 }><h1 className="name">{ key }</h1></Col>
<Col span={ 6 }>
<Col span={6} offset={2}>
<Card
title="Campaigns"
bordered={false}
className="campaign-counts"
>
{this.campaignTypes.map(key => (
<Row key={`stats-campaigns-${key}`}>
<Col span={18}>
<h1 className="name">{key}</h1>
</Col>
<Col span={6}>
<h1 className="count">
{ this.state.stats.campaigns.hasOwnProperty(key) ?
this.state.stats.campaigns[key] : 0 }
{this.state.stats.campaigns.hasOwnProperty(key)
? this.state.stats.campaigns[key]
: 0}
</h1>
</Col>
</Row>
)}
))}
</Card>
</Col>
</Row>
</div>
}
)}
</Spin>
</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 { 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"
const StatusNone = "none"
@ -22,9 +37,10 @@ class TheFormDef extends React.PureComponent {
}
// Handle create / edit form submission.
handleSubmit = (e) => {
handleSubmit = e => {
e.preventDefault()
var err = null, values = {}
var err = null,
values = {}
this.props.form.validateFields((e, v) => {
err = e
values = v
@ -33,10 +49,12 @@ class TheFormDef extends React.PureComponent {
return
}
if(this.state.fileList.length < 1) {
notification["error"]({ placement: cs.MsgPosition,
if (this.state.fileList.length < 1) {
notification["error"]({
placement: cs.MsgPosition,
message: "Error",
description: "Select a valid file to upload" })
description: "Select a valid file to upload"
})
return
}
@ -44,24 +62,33 @@ class TheFormDef extends React.PureComponent {
let params = new FormData()
params.set("params", JSON.stringify(values))
params.append("file", this.state.fileList[0])
this.props.request(cs.Routes.UploadRouteImport, cs.MethodPost, params).then(() => {
notification["info"]({ placement: cs.MsgPosition,
this.props
.request(cs.Routes.UploadRouteImport, cs.MethodPost, params)
.then(() => {
notification["info"]({
placement: cs.MsgPosition,
message: "File uploaded",
description: "Please wait while the import is running" })
description: "Please wait while the import is running"
})
this.props.fetchimportState()
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 })
})
}
handleConfirmBlur = (e) => {
handleConfirmBlur = e => {
const value = e.target.value
this.setState({ confirmDirty: this.state.confirmDirty || !!value })
}
onFileChange = (f) => {
onFileChange = f => {
let fileList = [f]
this.setState({ fileList })
return false
@ -80,67 +107,98 @@ class TheFormDef extends React.PureComponent {
}
return (
<Spin spinning={ this.state.formLoading }>
<Spin spinning={this.state.formLoading}>
<Form onSubmit={this.handleSubmit}>
<Form.Item {...formItemLayout} label="Mode">
{getFieldDecorator("mode", { rules: [{ required: true }], initialValue: "subscribe" })(
<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>
{getFieldDecorator("mode", {
rules: [{ required: true }],
initialValue: "subscribe"
})(
<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>
)}
</Form.Item>
{ this.state.mode === "subscribe" &&
{this.state.mode === "subscribe" && (
<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 }] })(
<Select mode="multiple">
{[...this.props.lists].map((v, i) =>
<Select.Option value={v["id"]} key={v["id"]}>{v["name"]}</Select.Option>
)}
{[...this.props.lists].map((v, i) => (
<Select.Option value={v["id"]} key={v["id"]}>
{v["name"]}
</Select.Option>
))}
</Select>
)}
</Form.Item>
</React.Fragment>
}
{ this.state.mode === "blacklist" &&
)}
{this.state.mode === "blacklist" && (
<Form.Item {...formItemTailLayout}>
<p className="ant-form-extra">
All existing subscribers found in the import will be marked as 'blacklisted' and will be
unsubscribed from their existing subscriptions. New subscribers will be imported and marked as 'blacklisted'.
All existing subscribers found in the import will be marked as
'blacklisted' and will be unsubscribed from their existing
subscriptions. New subscribers will be imported and marked as
'blacklisted'.
</p>
</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", {
initialValue: ","
})(<Input maxLength="1" style={{ maxWidth: 40 }} />)}
</Form.Item>
<Form.Item
{...formItemLayout}
label="CSV or ZIP file">
<Form.Item {...formItemLayout} label="CSV or ZIP file">
<div className="dropbox">
{getFieldDecorator("file", {
valuePropName: "file",
getValueFromEvent: this.normFile,
rules: [{ required: true }]
})(
<Upload.Dragger name="files"
multiple={ false }
fileList={ this.state.fileList }
beforeUpload={ this.onFileChange }
accept=".zip,.csv">
<Upload.Dragger
name="files"
multiple={false}
fileList={this.state.fileList}
beforeUpload={this.onFileChange}
accept=".zip,.csv"
>
<p className="ant-upload-drag-icon">
<Icon type="inbox" />
</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>
)}
</div>
</Form.Item>
<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>
<Button type="primary" htmlType="submit"><Icon type="upload" /> Upload</Button>
<p className="ant-form-extra">
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>
</Spin>
@ -157,10 +215,17 @@ class Importing extends React.PureComponent {
stopImport = () => {
// 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()
}).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(() => {
this.props.fetchimportState()
this.fetchLogs()
if( this.props.importState.status === StatusFinished ||
this.props.importState.status === StatusFailed ) {
if (
this.props.importState.status === StatusFinished ||
this.props.importState.status === StatusFailed
) {
window.clearInterval(this.state.pollID)
}
}, 1000)
@ -182,82 +249,104 @@ class Importing extends React.PureComponent {
}
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 })
let t = document.querySelector("#log-textarea")
t.scrollTop = t.scrollHeight;
}).catch(e => {
notification["error"]({ placement: cs.MsgPosition, message: "Error", description: e.message })
t.scrollTop = t.scrollHeight
})
.catch(e => {
notification["error"]({
placement: cs.MsgPosition,
message: "Error",
description: e.message
})
})
}
render() {
let progressPercent = 0
if( this.props.importState.status === StatusFinished ) {
if (this.props.importState.status === StatusFinished) {
progressPercent = 100
} 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">
<h1>Importing &mdash; { this.props.importState.name }</h1>
{ this.props.importState.status === StatusImporting &&
<p>Import is in progress. It is safe to navigate away from this page.</p>
}
<h1>Importing &mdash; {this.props.importState.name}</h1>
{this.props.importState.status === StatusImporting && (
<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>
}
)}
<Row className="import-container">
<Col span="10" offset="3">
<div className="stats center">
<div>
<Progress type="line" percent={ progressPercent } />
<Progress type="line" percent={progressPercent} />
</div>
<div>
<h3>{ this.props.importState.imported } records</h3>
<h3>{this.props.importState.imported} records</h3>
<br />
{ this.props.importState.status === StatusImporting &&
<Popconfirm title="Are you sure?" onConfirm={() => this.stopImport()}>
<p><Icon type="loading" /></p>
{this.props.importState.status === StatusImporting && (
<Popconfirm
title="Are you sure?"
onConfirm={() => this.stopImport()}
>
<p>
<Icon type="loading" />
</p>
<Button type="primary">Stop import</Button>
</Popconfirm>
}
{ this.props.importState.status === StatusStopping &&
)}
{this.props.importState.status === StatusStopping && (
<div>
<p><Icon type="loading" /></p>
<p>
<Icon type="loading" />
</p>
<h4>Stopping</h4>
</div>
}
{ this.props.importState.status !== StatusImporting &&
this.props.importState.status !== StatusStopping &&
)}
{this.props.importState.status !== StatusImporting &&
this.props.importState.status !== StatusStopping && (
<div>
{ this.props.importState.status !== StatusFinished &&
{this.props.importState.status !== StatusFinished && (
<div>
<Tag color="red">{ this.props.importState.status }</Tag>
<Tag color="red">{this.props.importState.status}</Tag>
<br />
</div>
}
)}
<br />
<Button type="primary" onClick={() => this.stopImport()}>Done</Button>
<Button type="primary" onClick={() => this.stopImport()}>
Done
</Button>
</div>
}
)}
</div>
</div>
<div className="logs">
<h3>Import log</h3>
<Spin spinning={ this.state.logs === "" }>
<Input.TextArea placeholder="Import logs"
<Spin spinning={this.state.logs === ""}>
<Input.TextArea
placeholder="Import logs"
id="log-textarea"
rows={10}
value={ this.state.logs }
autosize={{ minRows: 2, maxRows: 10 }} />
value={this.state.logs}
autosize={{ minRows: 2, maxRows: 10 }}
/>
</Spin>
</div>
</Col>
@ -269,15 +358,22 @@ class Importing extends React.PureComponent {
class Import extends React.PureComponent {
state = {
importState: { "status": "" }
importState: { status: "" }
}
fetchimportState = () => {
// 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 })
}).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()
}
render() {
if( this.state.importState.status === "" ) {
if (this.state.importState.status === "") {
// Fetching the status.
return (
<section className="content center">
<Spin />
</section>
)
} else if ( this.state.importState.status !== StatusNone ) {
} else if (this.state.importState.status !== StatusNone) {
// There's an import state
return <Importing { ...this.props }
importState={ this.state.importState }
fetchimportState={ this.fetchimportState } />
return (
<Importing
{...this.props}
importState={this.state.importState}
fetchimportState={this.fetchimportState}
/>
)
}
return (
<section className="content import">
<Row>
<Col span={22}><h1>Import subscribers</h1></Col>
<Col span={2}>
<Col span={22}>
<h1>Import subscribers</h1>
</Col>
<Col span={2} />
</Row>
<TheForm { ...this.props }
fetchimportState={ this.fetchimportState }
lists={ this.props.data[cs.ModelLists] }>
</TheForm>
<TheForm
{...this.props}
fetchimportState={this.fetchimportState}
lists={this.props.data[cs.ModelLists]}
/>
<hr />
<div className="help">
<h2>Instructions</h2>
<p>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 with the exact column names.
{" "}
<code>attributes</code> (optional) should be a valid JSON string with double escaped quotes.
<p>
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
with the exact column names. <code>attributes</code> (optional)
should be a valid JSON string with double escaped quotes.
</p>
<blockquote className="csv-example">
@ -345,13 +446,15 @@ class Import extends React.PureComponent {
<span>user1@mail.com,</span>
<span>"User One",</span>
<span>enabled,</span>
<span>{ '"{""age"": 32, ""city"": ""Bangalore""}"' }</span>
<span>{'"{""age"": 32, ""city"": ""Bangalore""}"'}</span>
</code>
<code className="csv-row">
<span>user2@mail.com,</span>
<span>"User Two",</span>
<span>blacklisted,</span>
<span>{ '"{""age"": 25, ""occupation"": ""Time Traveller""}"' }</span>
<span>
{'"{""age"": 25, ""occupation"": ""Time Traveller""}"'}
</span>
</code>
</blockquote>
</div>

View File

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

View File

@ -1,13 +1,28 @@
import React from "react"
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 * as cs from "./constants"
const tagColors = {
"private": "orange",
"public": "green"
private: "orange",
public: "green"
}
class CreateFormDef extends React.PureComponent {
@ -17,7 +32,7 @@ class CreateFormDef extends React.PureComponent {
}
// Handle create / edit form submission.
handleSubmit = (e) => {
handleSubmit = e => {
e.preventDefault()
this.props.form.validateFields((err, values) => {
if (err) {
@ -27,24 +42,50 @@ class CreateFormDef extends React.PureComponent {
this.setState({ modalWaiting: true })
if (this.props.formType === cs.FormCreate) {
// Create a new list.
this.props.modelRequest(cs.ModelLists, cs.Routes.CreateList, cs.MethodPost, values).then(() => {
notification["success"]({ placement: cs.MsgPosition, message: "List created", description: `"${values["name"]}" created` })
this.props
.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.onClose()
this.setState({ modalWaiting: false })
}).catch(e => {
})
.catch(e => {
notification["error"]({ message: "Error", description: e.message })
this.setState({ modalWaiting: false })
})
} else {
// Edit a list.
this.props.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
.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.onClose()
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 })
})
}
@ -52,17 +93,24 @@ class CreateFormDef extends React.PureComponent {
}
modalTitle(formType, record) {
if(formType === cs.FormCreate) {
if (formType === cs.FormCreate) {
return "Create a list"
}
return (
<div>
<Tag color={ tagColors.hasOwnProperty(record.type) ? tagColors[record.type] : "" }>{ record.type }</Tag>
{" "}
{ record.name }
<Tag
color={
tagColors.hasOwnProperty(record.type) ? tagColors[record.type] : ""
}
>
{record.type}
</Tag>{" "}
{record.name}
<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>
)
}
@ -81,15 +129,19 @@ class CreateFormDef extends React.PureComponent {
}
return (
<Modal visible={ true } title={ this.modalTitle(this.state.form, record) }
okText={ this.state.form === cs.FormCreate ? "Create" : "Save" }
confirmLoading={ this.state.modalWaiting }
onCancel={ onClose }
onOk={ this.handleSubmit }>
<Modal
visible={true}
title={this.modalTitle(this.state.form, record)}
okText={this.state.form === cs.FormCreate ? "Create" : "Save"}
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.Item {...formItemLayout} label="Name">
{getFieldDecorator("name", {
@ -97,17 +149,29 @@ class CreateFormDef extends React.PureComponent {
rules: [{ required: true }]
})(<Input autoFocus maxLength="200" />)}
</Form.Item>
<Form.Item {...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 }] })(
<Form.Item
{...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.Option value="private">Private</Select.Option>
<Select.Option value="public">Public</Select.Option>
</Select>
)}
</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 })(
<Select mode="tags"></Select>
<Select mode="tags" />
)}
</Form.Item>
</Form>
@ -128,20 +192,23 @@ class Lists extends React.PureComponent {
constructor(props) {
super(props)
this.columns = [{
this.columns = [
{
title: "Name",
dataIndex: "name",
sorter: true,
width: "40%",
render: (text, record) => {
const out = [];
const out = []
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++) {
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%",
align: "center",
render: (text, record) => {
return(
<div className="name" key={`name-${record.id}`}><Link to={ `/subscribers/lists/${record.id}` }>{ text }</Link></div>
return (
<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) => {
return (
<div className="actions">
<Tooltip title="Send a campaign"><a role="button"><Icon type="rocket" /></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>
<Tooltip title="Send a campaign">
<a role="button">
<Icon type="rocket" />
</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>
</div>
)
}
}]
}
]
}
componentDidMount() {
@ -209,15 +297,27 @@ class Lists extends React.PureComponent {
this.props.modelRequest(cs.ModelLists, cs.Routes.GetLists, cs.MethodGet)
}
deleteRecord = (record) => {
this.props.modelRequest(cs.ModelLists, cs.Routes.DeleteList, cs.MethodDelete, { id: record.id })
deleteRecord = record => {
this.props
.modelRequest(cs.ModelLists, cs.Routes.DeleteList, cs.MethodDelete, {
id: record.id
})
.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.
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: {} })
}
handleShowEditForm = (record) => {
handleShowEditForm = record => {
this.setState({ formType: cs.FormEdit, record: record })
}
@ -237,27 +337,36 @@ class Lists extends React.PureComponent {
return (
<section className="content">
<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}>
<Button type="primary" icon="plus" onClick={this.handleShowCreateForm}>Create list</Button>
<Button
type="primary"
icon="plus"
onClick={this.handleShowCreateForm}
>
Create list
</Button>
</Col>
</Row>
<br />
<Table
className="lists"
columns={ this.columns }
rowKey={ record => record.uuid }
dataSource={ this.props.data[cs.ModelLists] }
loading={ this.props.reqStates[cs.ModelLists] !== cs.StateDone }
pagination={ false }
columns={this.columns}
rowKey={record => record.uuid}
dataSource={this.props.data[cs.ModelLists]}
loading={this.props.reqStates[cs.ModelLists] !== cs.StateDone}
pagination={false}
/>
<CreateForm { ...this.props }
formType={ this.state.formType }
record={ this.state.record }
onClose={ this.handleHideForm }
fetchRecords = { this.fetchRecords }
<CreateForm
{...this.props}
formType={this.state.formType}
record={this.state.record}
onClose={this.handleHideForm}
fetchRecords={this.fetchRecords}
/>
</section>
)

View File

@ -1,5 +1,15 @@
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"
class TheFormDef extends React.PureComponent {
@ -16,21 +26,33 @@ class TheFormDef extends React.PureComponent {
this.props.modelRequest(cs.ModelMedia, cs.Routes.GetMedia, cs.MethodGet)
}
handleDeleteRecord = (record) => {
this.props.modelRequest(cs.ModelMedia, cs.Routes.DeleteMedia, cs.MethodDelete, { id: record.id })
handleDeleteRecord = record => {
this.props
.modelRequest(cs.ModelMedia, cs.Routes.DeleteMedia, cs.MethodDelete, {
id: record.id
})
.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.
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)
if(!this.props.insertMedia) {
if (!this.props.insertMedia) {
return false
}
@ -38,12 +60,18 @@ class TheFormDef extends React.PureComponent {
return false
}
onFileChange = (f) => {
if(f.file.error && f.file.response && f.file.response.hasOwnProperty("message")) {
notification["error"]({ placement: cs.MsgPosition,
onFileChange = f => {
if (
f.file.error &&
f.file.response &&
f.file.response.hasOwnProperty("message")
) {
notification["error"]({
placement: cs.MsgPosition,
message: "Error uploading file",
description: f.file.response.message })
} else if(f.file.status === "done") {
description: f.file.response.message
})
} else if (f.file.status === "done") {
this.fetchRecords()
}
@ -60,9 +88,7 @@ class TheFormDef extends React.PureComponent {
return (
<Spin spinning={false}>
<Form>
<Form.Item
{...formItemLayout}
label="Upload images">
<Form.Item {...formItemLayout} label="Upload images">
<div className="dropbox">
{getFieldDecorator("file", {
valuePropName: "file",
@ -72,10 +98,11 @@ class TheFormDef extends React.PureComponent {
<Upload.Dragger
name="file"
action="/api/media"
multiple={ true }
multiple={true}
listType="picture"
onChange={ this.onFileChange }
accept=".gif, .jpg, .jpeg, .png">
onChange={this.onFileChange}
accept=".gif, .jpg, .jpeg, .png"
>
<p className="ant-upload-drag-icon">
<Icon type="inbox" />
</p>
@ -87,23 +114,41 @@ class TheFormDef extends React.PureComponent {
</Form>
<section className="gallery">
{this.props.media && this.props.media.map((record, i) =>
<div key={ i } className="image">
<a onClick={ () => {
this.handleInsertMedia(record);
if( this.props.onCancel ) {
this.props.onCancel();
{this.props.media &&
this.props.media.map((record, i) => (
<div key={i} className="image">
<a
onClick={() => {
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">
<Tooltip title="View" placement="bottom"><a role="button" href={ record.uri } target="_blank"><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>
<Tooltip title="View" placement="bottom">
<a role="button" href={record.uri} target="_blank">
<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>
</div>
<div className="name" title={ record.filename }>{ record.filename }</div>
<div className="name" title={record.filename}>
{record.filename}
</div>
)}
</div>
))}
</section>
</Spin>
)
@ -116,14 +161,13 @@ class Media extends React.PureComponent {
return (
<section className="content media">
<Row>
<Col span={22}><h1>Images</h1></Col>
<Col span={2}>
<Col span={22}>
<h1>Images</h1>
</Col>
<Col span={2} />
</Row>
<TheForm { ...this.props }
media={ this.props.data[cs.ModelMedia] }>
</TheForm>
<TheForm {...this.props} media={this.props.data[cs.ModelMedia]} />
</section>
)
}

View File

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

View File

@ -1,11 +1,22 @@
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"
const tagColors = {
"enabled": "green",
"blacklisted": "red"
enabled: "green",
blacklisted: "red"
}
const formItemLayoutModal = {
labelCol: { xs: { span: 24 }, sm: { span: 4 } },
@ -28,32 +39,38 @@ class CreateFormDef extends React.PureComponent {
// Handle create / edit form submission.
handleSubmit = (e, cb) => {
e.preventDefault()
if(!cb) {
if (!cb) {
// Set a fake callback.
cb = () => {}
}
var err = null, values = {}
var err = null,
values = {}
this.props.form.validateFields((e, v) => {
err = e
values = v
})
if(err) {
if (err) {
return
}
let a = values["attribs"]
values["attribs"] = {}
if(a && a.length > 0) {
if (a && a.length > 0) {
try {
values["attribs"] = JSON.parse(a)
if(values["attribs"] instanceof Array) {
notification["error"]({ message: "Invalid JSON type",
description: "Attributes should be a map {} and not an array []" })
if (values["attribs"] instanceof Array) {
notification["error"]({
message: "Invalid JSON type",
description: "Attributes should be a map {} and not an array []"
})
return
}
} catch(e) {
notification["error"]({ message: "Invalid JSON in attributes", description: e.toString() })
} catch (e) {
notification["error"]({
message: "Invalid JSON in attributes",
description: e.toString()
})
return
}
}
@ -61,30 +78,52 @@ class CreateFormDef extends React.PureComponent {
this.setState({ loading: true })
if (this.props.formType === cs.FormCreate) {
// Add a subscriber.
this.props.modelRequest(cs.ModelSubscribers, cs.Routes.CreateSubscriber, cs.MethodPost, values).then(() => {
notification["success"]({ message: "Subscriber added", description: `${values["email"]} added` })
if(!this.props.isModal) {
this.props
.modelRequest(
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)
}
cb(true)
this.setState({ loading: false })
}).catch(e => {
})
.catch(e => {
notification["error"]({ message: "Error", description: e.message })
cb(false)
this.setState({ loading: false })
})
} else {
// Edit a subscriber.
delete(values["keys"])
delete(values["vals"])
this.props.modelRequest(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) {
delete values["keys"]
delete values["vals"]
this.props
.modelRequest(
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)
}
cb(true)
this.setState({ loading: false })
}).catch(e => {
})
.catch(e => {
notification["error"]({ message: "Error", description: e.message })
cb(false)
this.setState({ loading: false })
@ -92,21 +131,31 @@ class CreateFormDef extends React.PureComponent {
}
}
handleDeleteRecord = (record) => {
this.props.modelRequest(cs.ModelSubscribers, cs.Routes.DeleteSubscriber, cs.MethodDelete, { id: record.id })
handleDeleteRecord = record => {
this.props
.modelRequest(
cs.ModelSubscribers,
cs.Routes.DeleteSubscriber,
cs.MethodDelete,
{ id: record.id }
)
.then(() => {
notification["success"]({ message: "Subscriber deleted", description: `${record.email} deleted` })
notification["success"]({
message: "Subscriber deleted",
description: `${record.email} deleted`
})
this.props.route.history.push({
pathname: cs.Routes.ViewSubscribers,
pathname: cs.Routes.ViewSubscribers
})
}).catch(e => {
})
.catch(e => {
notification["error"]({ message: "Error", description: e.message })
})
}
render() {
const { formType, record } = this.props;
const { formType, record } = this.props
const { getFieldDecorator } = this.props.form
if (formType === null) {
@ -115,82 +164,123 @@ class CreateFormDef extends React.PureComponent {
let subListIDs = []
let subStatuses = {}
if(this.props.record && this.props.record.lists) {
subListIDs = this.props.record.lists.map((v) => { return v["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 ]
if (this.props.record && this.props.record.lists) {
subListIDs = this.props.record.lists.map(v => {
return v["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 (
<Spin spinning={ this.state.loading }>
<Form onSubmit={ this.handleSubmit }>
<Form.Item { ...layout } label="E-mail">
<Spin spinning={this.state.loading}>
<Form onSubmit={this.handleSubmit}>
<Form.Item {...layout} label="E-mail">
{getFieldDecorator("email", {
initialValue: record.email,
rules: [{ required: true }]
})(<Input autoFocus pattern="(.+?)@(.+?)" maxLength="200" />)}
</Form.Item>
<Form.Item { ...layout } label="Name">
<Form.Item {...layout} label="Name">
{getFieldDecorator("name", {
initialValue: record.name,
rules: [{ required: true }]
})(<Input maxLength="200" />)}
</Form.Item>
<Form.Item { ...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" }] })(
<Form.Item
{...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.Option value="enabled">Enabled</Select.Option>
<Select.Option value="blacklisted">Blacklisted</Select.Option>
</Select>
)}
</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 })(
<Select mode="multiple">
{[...this.props.lists].map((v, i) =>
<Select.Option value={ v.id } key={ v.id } disabled={ subStatuses[v.id] === cs.SubscriptionStatusUnsubscribed }>
<span>{ v.name }
{ subStatuses[v.id] &&
<sup className={ "subscription-status " + subStatuses[v.id] }> { subStatuses[v.id] }</sup>
{[...this.props.lists].map((v, i) => (
<Select.Option
value={v.id}
key={v.id}
disabled={
subStatuses[v.id] === cs.SubscriptionStatusUnsubscribed
}
>
<span>
{v.name}
{subStatuses[v.id] && (
<sup
className={"subscription-status " + subStatuses[v.id]}
>
{" "}
{subStatuses[v.id]}
</sup>
)}
</span>
</Select.Option>
)}
))}
</Select>
)}
</Form.Item>
<Form.Item { ...layout } label="Attributes" colon={ false }>
<Form.Item {...layout} label="Attributes" colon={false}>
<div>
{getFieldDecorator("attribs", {
initialValue: record.attribs ? JSON.stringify(record.attribs, null, 4) : ""
initialValue: record.attribs
? JSON.stringify(record.attribs, null, 4)
: ""
})(
<Input.TextArea
placeholder="{}"
rows={10}
readOnly={false}
autosize={{ minRows: 5, maxRows: 10 }} />
autosize={{ minRows: 5, maxRows: 10 }}
/>
)}
</div>
<p className="ant-form-extra">Attributes are defined as a JSON map, for example:
{' {"age": 30, "color": "red", "is_user": true}'}. <a href="">More info</a>.</p>
<p className="ant-form-extra">
Attributes are defined as a JSON map, for example:
{' {"age": 30, "color": "red", "is_user": true}'}.{" "}
<a href="">More info</a>.
</p>
</Form.Item>
{ !this.props.isModal &&
<Form.Item { ...formItemTailLayout }>
<Button type="primary" htmlType="submit" icon={ this.props.formType === cs.FormCreate ? "plus" : "save" }>
{ this.props.formType === cs.FormCreate ? "Add" : "Save" }
</Button>
{" "}
{ this.props.formType === cs.FormEdit &&
<Popconfirm title="Are you sure?" onConfirm={() => {
{!this.props.isModal && (
<Form.Item {...formItemTailLayout}>
<Button
type="primary"
htmlType="submit"
icon={this.props.formType === cs.FormCreate ? "plus" : "save"}
>
{this.props.formType === cs.FormCreate ? "Add" : "Save"}
</Button>{" "}
{this.props.formType === cs.FormEdit && (
<Popconfirm
title="Are you sure?"
onConfirm={() => {
this.handleDeleteRecord(record)
}}>
}}
>
<Button icon="delete">Delete</Button>
</Popconfirm>
}
)}
</Form.Item>
}
)}
</Form>
</Spin>
)
@ -204,13 +294,15 @@ class Subscriber extends React.PureComponent {
loading: true,
formRef: null,
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() {
// 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.
if(!this.props.isModal) {
if (!this.props.isModal) {
// Fetch lists.
this.props.modelRequest(cs.ModelLists, cs.Routes.GetLists, cs.MethodGet)
@ -221,20 +313,27 @@ class Subscriber extends React.PureComponent {
}
}
fetchRecord = (id) => {
this.props.request(cs.Routes.GetSubscriber, cs.MethodGet, { id: id }).then((r) => {
fetchRecord = id => {
this.props
.request(cs.Routes.GetSubscriber, cs.MethodGet, { id: id })
.then(r => {
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 })
}
submitForm = (e, cb) => {
if(this.state.formRef) {
if (this.state.formRef) {
this.state.formRef.handleSubmit(e, cb)
}
}
@ -244,35 +343,41 @@ class Subscriber extends React.PureComponent {
<section className="content">
<header className="header">
<Row>
<Col span={ 20 }>
{ !this.state.record.id &&
<h1>Add subscriber</h1>
}
{ this.state.record.id &&
<Col span={20}>
{!this.state.record.id && <h1>Add subscriber</h1>}
{this.state.record.id && (
<div>
<h1>
<Tag color={ tagColors.hasOwnProperty(this.state.record.status) ? tagColors[this.state.record.status] : "" }>{ 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>
<Tag
color={
tagColors.hasOwnProperty(this.state.record.status)
? tagColors[this.state.record.status]
: ""
}
>
{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 span={ 2 }>
</Col>
<Col span={2} />
</Row>
</header>
<div>
<Spin spinning={ this.state.loading }>
<Spin spinning={this.state.loading}>
<CreateForm
{...this.props}
formType={ this.props.formType ? this.props.formType : cs.FormEdit }
record={ this.state.record }
fetchRecord={ this.fetchRecord }
lists={ this.props.data[cs.ModelLists] }
wrappedComponentRef={ (r) => {
if(!r) {
formType={this.props.formType ? this.props.formType : cs.FormEdit}
record={this.state.record}
fetchRecord={this.fetchRecord}
lists={this.props.data[cs.ModelLists]}
wrappedComponentRef={r => {
if (!r) {
return
}

View File

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

View File

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

View File

@ -1,8 +1,7 @@
import React from 'react';
import ReactDOM from 'react-dom';
import React from "react"
import ReactDOM from "react-dom"
import './index.css';
import App from './App.js'
import "./index.css"
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 ReactDOM from 'react-dom';
import { Alert } from 'antd';
import React from "react"
import ReactDOM from "react-dom"
import { Alert } from "antd"
class Utils {
static months = [ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" ]
static days = [ "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" ]
static months = [
"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.
static DateString = (stamp, showTime) => {
if(!stamp) {
if (!stamp) {
return ""
}
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()
}
@ -25,34 +44,38 @@ class Utils {
}
// HttpError takes an axios error and returns an error dict after some sanity checks.
static HttpError = (err) => {
static HttpError = err => {
if (!err.response) {
return err
}
if(!err.response.data || !err.response.data.message) {
if (!err.response.data || !err.response.data.message) {
return {
"message": err.message + " - " + err.response.request.responseURL,
"data": {}
message: err.message + " - " + err.response.request.responseURL,
data: {}
}
}
return {
"message": err.response.data.message,
"data": err.response.data.data
message: err.response.data.message,
data: err.response.data.data
}
}
// Shows a flash message.
static Alert = (msg, msgType) => {
document.getElementById('alert-container').classList.add('visible')
ReactDOM.render(<Alert message={ msg } type={ msgType } showIcon />,
document.getElementById('alert-container'))
document.getElementById("alert-container").classList.add("visible")
ReactDOM.render(
<Alert message={msg} type={msgType} showIcon />,
document.getElementById("alert-container")
)
}
static ModalAlert = (msg, msgType) => {
document.getElementById('modal-alert-container').classList.add('visible')
ReactDOM.render(<Alert message={ msg } type={ msgType } showIcon />,
document.getElementById('modal-alert-container'))
document.getElementById("modal-alert-container").classList.add("visible")
ReactDOM.render(
<Alert message={msg} type={msgType} showIcon />,
document.getElementById("modal-alert-container")
)
}
}

View File

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