Pārlūkot izejas kodu

Reformat all JS to 'prettier' style

Kailash Nadh 6 gadi atpakaļ
vecāks
revīzija
917cb8aeed

+ 17 - 16
frontend/my/config-overrides.js

@@ -1,21 +1,22 @@
-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", {
-          libraryName: "antd",
-          libraryDirectory: "es",
-          style: true
-        }
-      ],  // change importing css to less
-      config,
-  );
+    [
+      "import",
+      {
+        libraryName: "antd",
+        libraryDirectory: "es",
+        style: true
+      }
+    ], // change importing css to less
+    config
+  )
   config = rewireLess.withLoaderOptions({
     modifyVars: {
       "@font-family":
-          '"IBM Plex Sans", "Helvetica Neueue", "Segoe UI", "sans-serif"',
+        '"IBM Plex Sans", "Helvetica Neueue", "Segoe UI", "sans-serif"',
       "@font-size-base": "15px",
       "@primary-color": "#7f2aff",
       "@shadow-1-up": "0 -2px 3px @shadow-color",
@@ -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
+}

+ 3 - 1
frontend/my/package.json

@@ -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"
   }
 }

+ 159 - 137
frontend/my/src/App.js

@@ -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,144 +26,166 @@ import * as cs from './constants'
 */
 
 class App extends React.PureComponent {
-    models = [cs.ModelUsers,
-              cs.ModelSubscribers,
-              cs.ModelLists,
-              cs.ModelCampaigns,
-              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), {}),
-        modStates: {}
+  models = [
+    cs.ModelUsers,
+    cs.ModelSubscribers,
+    cs.ModelLists,
+    cs.ModelCampaigns,
+    cs.ModelTemplates
+  ]
+
+  state = {
+    // Initialize empty states.
+    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" })
     }
+  }
+
+  // modelRequest is an opinionated wrapper for model specific HTTP requests,
+  // including setting model states.
+  modelRequest = async (model, route, method, params) => {
+    let url = replaceParams(route, params)
+
+    this.setState({
+      reqStates: { ...this.state.reqStates, [model]: cs.StatePending }
+    })
+    try {
+      let req = {
+        method: method,
+        url: url
+      }
+
+      if (method === cs.MethodGet || method === cs.MethodDelete) {
+        req.params = params ? params : {}
+      } else {
+        req.data = params ? params : {}
+      }
+
+      let res = await axios(req)
+      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,
+          message: "Error fetching data",
+          description: Utils.HttpError(e).message
+        })
+      }
 
-    componentDidMount = () => {
-        axios.defaults.paramsSerializer = params => {
-            return qs.stringify(params, {arrayFormat: "repeat"});
-        }
+      // Set states and show the error on the layout.
+      this.setState({
+        reqStates: { ...this.state.reqStates, [model]: cs.StateDone }
+      })
+      throw Utils.HttpError(e)
     }
-
-    // modelRequest is an opinionated wrapper for model specific HTTP requests,
-    // including setting model states.
-    modelRequest = async (model, route, method, params) => {
-        let url = replaceParams(route, params)
-
-        this.setState({ reqStates: { ...this.state.reqStates, [model]: cs.StatePending } })
-        try {
-            let req = {
-                method: method,
-                url: url,
-            }
-
-            if (method === cs.MethodGet || method === cs.MethodDelete) {
-                req.params = params ? params : {}
-            } else {
-                req.data = params ? params : {}
-            }
-
-            
-            let res = await axios(req)
-            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,
-                                        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 } })
-            throw Utils.HttpError(e)
-        }
+  }
+
+  // request is a wrapper for generic HTTP requests.
+  request = async (url, method, params, headers) => {
+    url = replaceParams(url, params)
+
+    this.setState({
+      reqStates: { ...this.state.reqStates, [url]: cs.StatePending }
+    })
+    try {
+      let req = {
+        method: method,
+        url: url,
+        headers: headers ? headers : {}
+      }
+
+      if (method === cs.MethodGet || method === cs.MethodDelete) {
+        req.params = params ? params : {}
+      } else {
+        req.data = params ? params : {}
+      }
+
+      let res = await axios(req)
+
+      this.setState({
+        reqStates: { ...this.state.reqStates, [url]: cs.StateDone }
+      })
+      return res
+    } catch (e) {
+      this.setState({
+        reqStates: { ...this.state.reqStates, [url]: cs.StateDone }
+      })
+      throw Utils.HttpError(e)
     }
-
-    // request is a wrapper for generic HTTP requests.
-    request = async (url, method, params, headers) => {
-        url = replaceParams(url, params)
-
-        this.setState({ reqStates: { ...this.state.reqStates, [url]: cs.StatePending } })
-        try {
-            let req = {
-                method: method,
-                url: url,
-                headers: headers ? headers : {}
-            }
-            
-            if(method === cs.MethodGet || method === cs.MethodDelete) {
-                req.params =  params ? params : {}
-            } else {
-                req.data =  params ? params : {}
-            }
-
-            let res = await axios(req)
-
-            this.setState({ reqStates: { ...this.state.reqStates, [url]: cs.StateDone } })
-            return res
-        } catch (e) {
-            this.setState({ reqStates: { ...this.state.reqStates, [url]: cs.StateDone } })
-            throw Utils.HttpError(e)
-        }
-    }
-
-
-    pageTitle = (title) => {
-        document.title = title
+  }
+
+  pageTitle = title => {
+    document.title = title
+  }
+
+  render() {
+    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>
+        </div>
+      )
     }
 
-    render() {
-        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>
-                </div>
-            )
-        }
-
-        return (
-            <BrowserRouter>
-                <Layout
-                    modelRequest={ this.modelRequest }
-                    request={ this.request }
-                    reqStates={ this.state.reqStates }
-                    pageTitle={ this.pageTitle }
-                    config={ window.CONFIG }
-                    data={ this.state.data } />
-            </BrowserRouter>
-        )
-    }
+    return (
+      <BrowserRouter>
+        <Layout
+          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) {
-    // 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 pName = p.slice(1) // Lose the ":" prefix
-            if(params && params.hasOwnProperty(pName)) {
-                route = route.replace(p, params[pName])
-            }
-        })
-    }
-
-    return route
+function replaceParams(route, params) {
+  // Replace :params in the URL with params in the array.
+  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)) {
+        route = route.replace(p, params[pName])
+      }
+    })
+  }
+
+  return route
 }
 
 export default App

+ 776 - 563
frontend/my/src/Campaign.js

@@ -1,618 +1,831 @@
 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"
 
 const formItemLayout = {
-    labelCol: { xs: { span: 16 }, sm: { span: 4 } },
-    wrapperCol: { xs: { span: 16 }, sm: { span: 10 } }
-}
-
-const formItemTailLayout = {
-    wrapperCol: { xs: { span: 24, offset: 0 }, sm: { span: 10, offset: 4 } }
+  labelCol: { xs: { span: 16 }, sm: { span: 4 } },
+  wrapperCol: { xs: { span: 16 }, sm: { span: 10 } }
 }
 
 class Editor extends React.PureComponent {
-    state = {
-        editor: null,
-        quill: null,
-        rawInput: null,
-        selContentType: "richtext",
-        contentType: "richtext",
-        body: ""
-    }
-
-    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", "image"],
-                ["clean", "font"]
-            ],
-            handlers: {
-                "image": () => {
-                    this.props.toggleMedia()
-                }
-            }
+  state = {
+    editor: null,
+    quill: null,
+    rawInput: null,
+    selContentType: "richtext",
+    contentType: "richtext",
+    body: ""
+  }
+
+  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", "image"],
+        ["clean", "font"]
+      ],
+      handlers: {
+        image: () => {
+          this.props.toggleMedia()
         }
+      }
     }
-
-    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) {
-            this.setState({
-                body: this.props.record.body,
-                contentType: this.props.record.content_type,
-                selContentType: this.props.record.content_type
-            })
-        }
+  }
+
+  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) {
+      this.setState({
+        body: this.props.record.body,
+        contentType: this.props.record.content_type,
+        selContentType: this.props.record.content_type
+      })
     }
-
-    // Custom handler for inserting images from the media popup.
-    insertMedia = (uri) => {
-        const quill = this.state.quill.getEditor()
-        let range = quill.getSelection(true);
-        quill.updateContents(new Delta()
-          .retain(range.index)
-          .delete(range.length)
-          .insert({ image: this.props.config.rootURL + uri })
-        , null);
+  }
+
+  // Custom handler for inserting images from the media popup.
+  insertMedia = uri => {
+    const quill = this.state.quill.getEditor()
+    let range = quill.getSelection(true)
+    quill.updateContents(
+      new Delta()
+        .retain(range.index)
+        .delete(range.length)
+        .insert({ image: this.props.config.rootURL + uri }),
+      null
+    )
+  }
+
+  handleSelContentType = (_, e) => {
+    this.setState({ selContentType: e.props.value })
+  }
+
+  handleSwitchContentType = () => {
+    this.setState({ contentType: this.state.selContentType })
+    if (!this.state.quill || !this.state.quill.editor || !this.state.rawInput) {
+      return
     }
 
-    handleSelContentType = (_, e) => {
-        this.setState({ selContentType: e.props.value })
+    // Switching from richtext to html.
+    let body = ""
+    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") {
+      body = this.state.rawInput.value
+      this.state.quill.editor.clipboard.dangerouslyPasteHTML(body, "raw")
     }
 
-    handleSwitchContentType = () => {
-        this.setState({ contentType: this.state.selContentType })
-        if(!this.state.quill || !this.state.quill.editor || !this.state.rawInput) {
-            return
-        }
-
-        // Switching from richtext to html.
-        let body = ""
-        if(this.state.selContentType === "html") {
-            body = this.state.quill.editor.container.firstChild.innerHTML
-            this.state.rawInput.value = body
-        } else if(this.state.selContentType === "richtext") {
-            body = this.state.rawInput.value
-            this.state.quill.editor.clipboard.dangerouslyPasteHTML(body, "raw")
-        }
+    this.props.setContent(this.state.selContentType, body)
+  }
+
+  render() {
+    return (
+      <div>
+        <header className="header">
+          {!this.props.formDisabled && (
+            <Row>
+              <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.Option value="richtext">Rich Text</Select.Option>
+                    <Select.Option value="html">Raw HTML</Select.Option>
+                  </Select>
+                  {this.state.contentType !== this.state.selContentType && (
+                    <div className="actions">
+                      <Popconfirm
+                        title="The content may lose its formatting. Are you sure?"
+                        onConfirm={this.handleSwitchContentType}
+                      >
+                        <Button>
+                          <Icon type="save" /> Switch format
+                        </Button>
+                      </Popconfirm>
+                    </div>
+                  )}
+                </div>
+              </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) {
+              return
+            }
 
-        this.props.setContent(this.state.selContentType, body)
-    }
+            this.setState({ quill: o })
+            document.querySelector(".ql-editor").focus()
+          }}
+          onChange={() => {
+            if (!this.state.quill) {
+              return
+            }
 
-    render() {
-        return (
-            <div>
-                <header className="header">
-                    { !this.props.formDisabled &&
-                        <Row>
-                            <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.Option value="richtext">Rich Text</Select.Option>
-                                        <Select.Option value="html">Raw HTML</Select.Option>
-                                    </Select>
-                                    { this.state.contentType !== this.state.selContentType &&
-                                    <div className="actions">
-                                        <Popconfirm title="The content may lose its formatting. Are you sure?"
-                                            onConfirm={ this.handleSwitchContentType }>
-                                            <Button>
-                                                <Icon type="save" /> Switch format
-                                            </Button>
-                                        </Popconfirm>
-                                    </div>}
-                                </div>
-                            </Col>
-                            <Col span={ 4 }></Col>
-                        </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) {
-                            return
-                        }
-
-                        this.setState({ quill: o })
-                        document.querySelector(".ql-editor").focus()
-                    }}
-                    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}
+          placeholder="Your message here"
+          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) {
+              return
+            }
 
-                <Input.TextArea
-                    readOnly={ this.props.formDisabled }
-                    placeholder="Your message here"
-                    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) {
-                            return
-                        }
-                        
-                        this.setState({ rawInput: o.textAreaRef })
-                    }}
-                    onChange={ (e) => {
-                        this.props.setContent(this.state.contentType, e.target.value)
-                    }}
-                />
-            </div>
-        )
-    }
+            this.setState({ rawInput: o.textAreaRef })
+          }}
+          onChange={e => {
+            this.props.setContent(this.state.contentType, e.target.value)
+          }}
+        />
+      </div>
+    )
+  }
 }
 
 class TheFormDef extends React.PureComponent {
-    state = {
-        editorVisible: false,
-        sendLater: false,
-        loading: false
+  state = {
+    editorVisible: false,
+    sendLater: false,
+    loading: false
+  }
+
+  componentWillReceiveProps(nextProps) {
+    const has = nextProps.isSingle && nextProps.record.send_at !== null
+    if (!has) {
+      return
     }
 
-    componentWillReceiveProps(nextProps) {
-        const has = nextProps.isSingle && nextProps.record.send_at !== null
-        if(!has) {
-            return
-        }
-
-        if(this.state.sendLater !== has) {
-            this.setState({ sendLater: has })
-        }
+    if (this.state.sendLater !== has) {
+      this.setState({ sendLater: has })
     }
+  }
 
-    validateEmail = (rule, value, callback) => {
-        if(!value.match(/(.+?)\s<(.+?)@(.+?)>/)) {
-            return callback("Format should be: Your Name <email@address.com>")
-        }
-
-        callback()
+  validateEmail = (rule, value, callback) => {
+    if (!value.match(/(.+?)\s<(.+?)@(.+?)>/)) {
+      return callback("Format should be: Your Name <email@address.com>")
     }
 
-    handleSendLater = (e) => {
-        this.setState({ sendLater: e })
-    }
+    callback()
+  }
 
-    // Handle create / edit form submission.
-    handleSubmit = (cb) => {
-        if(this.state.loading) {
-            return
-        }
+  handleSendLater = e => {
+    this.setState({ sendLater: e })
+  }
 
-        if(!cb) {
-            // Set a fake callback.
-            cb = () => {}
-        }
-
-        this.props.form.validateFields((err, values) => {
-            if (err) {
-                return
-            }
-            
-            if(!values.tags) {
-                values.tags = []
-            }
-
-            values.body = this.props.body
-            values.content_type = this.props.contentType
-            
-            // 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,
-                        message: "Campaign created",
-                        description: `"${values["name"]}" created` })
-
-                    this.props.route.history.push({
-                        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 })
-                    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,
-                    message: "Campaign updated",
-                    description: `"${values["name"]}" updated` })
-                    this.setState({ loading: false })
-                    cb(true)
-                }).catch(e => {
-                    notification["error"]({ placement: cs.MsgPosition, message: "Error", description: e.message })
-                    this.setState({ loading: false })
-                    cb(false)
-                })
-            }
-        })
+  // Handle create / edit form submission.
+  handleSubmit = cb => {
+    if (this.state.loading) {
+      return
     }
 
+    if (!cb) {
+      // Set a fake callback.
+      cb = () => {}
+    }
 
-    handleTestCampaign = (e) => {
-        e.preventDefault()
-        this.props.form.validateFields((err, values) => {
-            if (err) {
-                return
-            }
-
-            if(!values.tags) {
-                values.tags = []
-            }
+    this.props.form.validateFields((err, values) => {
+      if (err) {
+        return
+      }
+
+      if (!values.tags) {
+        values.tags = []
+      }
+
+      values.body = this.props.body
+      values.content_type = this.props.contentType
+
+      // 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,
+              message: "Campaign created",
+              description: `"${values["name"]}" created`
+            })
 
-            values.id = this.props.record.id
-            values.body = this.props.body
-            values.content_type = this.props.contentType
-
-            this.setState({ loading: true })
-            this.props.request(cs.Routes.TestCampaign, cs.MethodPost, values).then((resp) => {
-                this.setState({ loading: false })
-                notification["success"]({ placement: cs.MsgPosition,
-                message: "Test sent",
-                description: `Test messages sent` })
-            }).catch(e => {
-                this.setState({ loading: false })
-                notification["error"]({ placement: cs.MsgPosition, message: "Error", description: e.message })
+            this.props.route.history.push({
+              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
+            })
+            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,
+              message: "Campaign updated",
+              description: `"${values["name"]}" updated`
             })
+            this.setState({ loading: false })
+            cb(true)
+          })
+          .catch(e => {
+            notification["error"]({
+              placement: cs.MsgPosition,
+              message: "Error",
+              description: e.message
+            })
+            this.setState({ loading: false })
+            cb(false)
+          })
+      }
+    })
+  }
+
+  handleTestCampaign = e => {
+    e.preventDefault()
+    this.props.form.validateFields((err, values) => {
+      if (err) {
+        return
+      }
+
+      if (!values.tags) {
+        values.tags = []
+      }
+
+      values.id = this.props.record.id
+      values.body = this.props.body
+      values.content_type = this.props.contentType
+
+      this.setState({ loading: true })
+      this.props
+        .request(cs.Routes.TestCampaign, cs.MethodPost, values)
+        .then(resp => {
+          this.setState({ loading: false })
+          notification["success"]({
+            placement: cs.MsgPosition,
+            message: "Test sent",
+            description: `Test messages sent`
+          })
+        })
+        .catch(e => {
+          this.setState({ loading: false })
+          notification["error"]({
+            placement: cs.MsgPosition,
+            message: "Error",
+            description: e.message
+          })
+        })
+    })
+  }
+
+  render() {
+    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)
     }
 
-    render() {
-        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.record) {
-            this.props.pageTitle(record.name + " / Campaigns")
-        } else {
-            this.props.pageTitle("New campaign")
-        }
-
-        return (
-            <div>
-                <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",
-                                initialValue: record.name,
-                                rules: [{ required: true }]
-                            })(<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" />)}
-                        </Form.Item>
-                        <Form.Item {...formItemLayout} label="From address">
-                            {getFieldDecorator("from_email", {
-                                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" />)}
-                        </Form.Item>
-                        <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),
-                                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>
-                            )}
-                        </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),
-                                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>
-                            )}
-                        </Form.Item>
-                        <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>
-                                )}
-                        </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" })(
-                                <Radio.Group className="messengers">
-                                    {[...this.props.config.messengers].map((v, i) =>
-                                        <Radio disabled={ this.props.formDisabled } value={v} key={v}>{ v }</Radio>
-                                    )}
-                                </Radio.Group>
-                            )}
-                        </Form.Item>
-
-                        <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>
-                                <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 }
-                                            showTime
-                                            format="YYYY-MM-DD HH:mm:ss"
-                                            placeholder="Select a date and time"
-                                        />
-                                    )}
-                                </Col>
-                            </Row>
-                        </Form.Item>
-
-                        { 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.">
-                                    {getFieldDecorator("subscribers")(
-                                        <Select mode="tags" style={{ width: "100%" }}></Select>
-                                    )}
-                                </Form.Item>
-                                <Form.Item {...formItemLayout} label="&nbsp;" colon={ false }>
-                                    <Button onClick={ this.handleTestCampaign }><Icon type="mail" /> Send test</Button>
-                                </Form.Item>
-                            </div>
-                        }
-                    </Form>
-                </Spin>
-            </div>
-
-        )
+    if (this.record) {
+      this.props.pageTitle(record.name + " / Campaigns")
+    } else {
+      this.props.pageTitle("New campaign")
     }
+
+    return (
+      <div>
+        <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",
+                initialValue: record.name,
+                rules: [{ required: true }]
+              })(
+                <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" />)}
+            </Form.Item>
+            <Form.Item {...formItemLayout} label="From address">
+              {getFieldDecorator("from_email", {
+                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"
+                />
+              )}
+            </Form.Item>
+            <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,
+                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>
+              )}
+            </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,
+                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>
+              )}
+            </Form.Item>
+            <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" />
+              )}
+            </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"
+              })(
+                <Radio.Group className="messengers">
+                  {[...this.props.config.messengers].map((v, i) => (
+                    <Radio disabled={this.props.formDisabled} value={v} key={v}>
+                      {v}
+                    </Radio>
+                  ))}
+                </Radio.Group>
+              )}
+            </Form.Item>
+
+            <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>
+                <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}
+                        showTime
+                        format="YYYY-MM-DD HH:mm:ss"
+                        placeholder="Select a date and time"
+                      />
+                    )}
+                </Col>
+              </Row>
+            </Form.Item>
+
+            {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."
+                >
+                  {getFieldDecorator("subscribers")(
+                    <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>
+              </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,
-        record: {},
-        formRef: null,
-        contentType: "richtext",
-        previewRecord: null,
-        body: "",
-        currentTab: "form",
-        editor: null,
-        loading: true,
-        mediaVisible: false,
-        formDisabled: false
+  state = {
+    campaignID: this.props.route.match.params
+      ? parseInt(this.props.route.match.params.campaignID, 10)
+      : 0,
+    record: {},
+    formRef: null,
+    contentType: "richtext",
+    previewRecord: null,
+    body: "",
+    currentTab: "form",
+    editor: null,
+    loading: true,
+    mediaVisible: false,
+    formDisabled: false
+  }
+
+  componentDidMount = () => {
+    // Fetch lists.
+    this.props.modelRequest(cs.ModelLists, cs.Routes.GetLists, cs.MethodGet)
+
+    // Fetch templates.
+    this.props.modelRequest(
+      cs.ModelTemplates,
+      cs.Routes.GetTemplates,
+      cs.MethodGet
+    )
+
+    // Fetch campaign.
+    if (this.state.campaignID) {
+      this.fetchRecord(this.state.campaignID)
+    } else {
+      this.setState({ loading: false })
     }
-    
-    componentDidMount = () => {
-        // Fetch lists.
-        this.props.modelRequest(cs.ModelLists, cs.Routes.GetLists, cs.MethodGet)
-        
-        // Fetch templates.
-        this.props.modelRequest(cs.ModelTemplates, cs.Routes.GetTemplates, cs.MethodGet)
-
-        // Fetch campaign.
-        if(this.state.campaignID) {
-            this.fetchRecord(this.state.campaignID)
-        } else {
-            this.setState({ loading: false })
-        }
 
-        // Content tab?
-        if(document.location.hash === "#content-tab") {
-            this.setCurrentTab("content")
-        }
+    // 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) => {
-            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) {
-                this.setState({ formDisabled: true })
-            }
-        }).catch(e => {
-            notification["error"]({ placement: cs.MsgPosition, message: "Error", description: e.message })
+  }
+
+  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
+        ) {
+          this.setState({ formDisabled: true })
+        }
+      })
+      .catch(e => {
+        notification["error"]({
+          placement: cs.MsgPosition,
+          message: "Error",
+          description: e.message
         })
-    }
-
-    setContent = (contentType, body) => {
-        this.setState({ contentType: contentType, body: body })
-    }
-    
-    toggleMedia = () => {
-        this.setState({ mediaVisible: !this.state.mediaVisible })
-    }
-    
-    setCurrentTab = (tab) => {
-        this.setState({ currentTab: tab })
-    }
-
-    handlePreview = (record) => {
-        this.setState({ previewRecord: record })
-    }
-
-    render() {
-        return (
-            <section className="content campaign">
-                <Row>
-                    <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 }
-                                </h1>
-                                <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 &&
-                            <div>
-                                <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?"
-                                        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>
-                                }
-
-                                { ( 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>
-                                }
-                            </div>
-                        }
-                    </Col>
-                </Row>
-                <br />
-
-                <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) {
-                                        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 }
-                            />
-                        </Spin>
-                    </Tabs.TabPane>
-                    <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) {
-                                            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 }
-                                />
-                                <div className="content-actions">
-                                    <p>
-                                        <Button icon="search" onClick={() => this.handlePreview(this.state.record)}>Preview</Button>
-                                    </p>
-                                </div>
-                            </div>
-                        }
-                        { !this.state.record.id &&
-                            <Spin className="empty-spinner"></Spin>
-                        }
-                    </Tabs.TabPane>
-                </Tabs>
-
-                <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,
-                        onCancel: this.toggleMedia,
-                        onOk: this.toggleMedia }} />
-                </Modal>
-
-                { this.state.previewRecord &&
-                    <ModalPreview
-                        title={ this.state.previewRecord.name }
-                        body={ this.state.body }
-                        previewURL={ cs.Routes.PreviewCampaign.replace(":id", this.state.previewRecord.id) }
-                        onCancel={() => {
-                            this.setState({ previewRecord: null })
-                        }}
-                    />
+      })
+  }
+
+  setContent = (contentType, body) => {
+    this.setState({ contentType: contentType, body: body })
+  }
+
+  toggleMedia = () => {
+    this.setState({ mediaVisible: !this.state.mediaVisible })
+  }
+
+  setCurrentTab = tab => {
+    this.setState({ currentTab: tab })
+  }
+
+  handlePreview = record => {
+    this.setState({ previewRecord: record })
+  }
+
+  render() {
+    return (
+      <section className="content campaign">
+        <Row>
+          <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}
+                </h1>
+                <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 && (
+              <div>
+                <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?"
+                      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>
+                  )}
+                {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>
+                  )}
+              </div>
+            )}
+          </Col>
+        </Row>
+        <br />
+
+        <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) {
+                    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
                 }
-            </section>
-        )
-    }
+                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 && (
+              <div>
+                <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}
+                />
+                <div className="content-actions">
+                  <p>
+                    <Button
+                      icon="search"
+                      onClick={() => this.handlePreview(this.state.record)}
+                    >
+                      Preview
+                    </Button>
+                  </p>
+                </div>
+              </div>
+            )}
+            {!this.state.record.id && <Spin className="empty-spinner" />}
+          </Tabs.TabPane>
+        </Tabs>
+
+        <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,
+              onCancel: this.toggleMedia,
+              onOk: this.toggleMedia
+            }}
+          />
+        </Modal>
+
+        {this.state.previewRecord && (
+          <ModalPreview
+            title={this.state.previewRecord.name}
+            body={this.state.body}
+            previewURL={cs.Routes.PreviewCampaign.replace(
+              ":id",
+              this.state.previewRecord.id
+            )}
+            onCancel={() => {
+              this.setState({ previewRecord: null })
+            }}
+          />
+        )}
+      </section>
+    )
+  }
 }
 
 export default Campaign

+ 620 - 391
frontend/my/src/Campaigns.js

@@ -1,431 +1,660 @@
 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"
 
 class Campaigns extends React.PureComponent {
-    defaultPerPage = 20
-
-    state = {
-        formType: null,
-        pollID: -1,
-        queryParams: "",
-        stats: {},
-        record: null,
-        previewRecord: null,
-        cloneName: "",
-        cloneModalVisible: false,
-        modalWaiting: false
+  defaultPerPage = 20
+
+  state = {
+    formType: null,
+    pollID: -1,
+    queryParams: "",
+    stats: {},
+    record: null,
+    previewRecord: null,
+    cloneName: "",
+    cloneModalVisible: false,
+    modalWaiting: false
+  }
+
+  // Pagination config.
+  paginationOptions = {
+    hideOnSinglePage: true,
+    showSizeChanger: true,
+    showQuickJumper: true,
+    defaultPageSize: this.defaultPerPage,
+    pageSizeOptions: ["20", "50", "70", "100"],
+    position: "both",
+    showTotal: (total, range) => `${range[0]} to ${range[1]} of ${total}`,
+    onChange: (page, perPage) => {
+      this.fetchRecords({ page: page, per_page: perPage })
+    },
+    onShowSizeChange: (page, perPage) => {
+      this.fetchRecords({ page: page, per_page: perPage })
     }
+  }
+
+  constructor(props) {
+    super(props)
+
+    this.columns = [
+      {
+        title: "Name",
+        dataIndex: "name",
+        sorter: true,
+        width: "20%",
+        vAlign: "top",
+        render: (text, record) => {
+          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>
+            </div>
+          )
 
-    // Pagination config.
-    paginationOptions = {
-        hideOnSinglePage: true,
-        showSizeChanger: true,
-        showQuickJumper: true,
-        defaultPageSize: this.defaultPerPage,
-        pageSizeOptions: ["20", "50", "70", "100"],
-        position: "both",
-        showTotal: (total, range) => `${range[0]} to ${range[1]} of ${total}`,
-        onChange: (page, perPage) => {
-            this.fetchRecords({ page: page, per_page: perPage })
-        },
-        onShowSizeChange: (page, perPage) => {
-            this.fetchRecords({ page: page, per_page: perPage })
-        }
-    }
-
-    constructor(props) {
-        super(props)
-
-        this.columns = [{
-            title: "Name",
-            dataIndex: "name",
-            sorter: true,
-            width: "20%",
-            vAlign: "top",
-            render: (text, record) => {
-                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>
-                    </div>
-                )
-
-                if(record.tags.length > 0) {
-                    for (let i = 0; i < record.tags.length; i++) {
-                        out.push(<Tag key={`tag-${i}`}>{ record.tags[i] }</Tag>);
-                    }
-                }
-
-                return out
-            }
-        },
-        {
-            title: "Status",
-            dataIndex: "status",
-            className: "status",
-            width: "10%",
-            render: (status, record) => {
-                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>
-                        }
-                    </div>
-                )
-            }
-        },
-        {
-            title: "Lists",
-            dataIndex: "lists",
-            width: "25%",
-            align: "left",
-            className: "lists",
-            render: (lists, record) => {
-                const out = []
-                lists.forEach((l) => {
-                    out.push(
-                        <span className="name" key={`name-${l.id}`}><Link to={ `/subscribers/lists/${l.id}` }>{ l.name }</Link></span>
-                        )
-                    })
-                    
-                    return out
-                }
-            },
-        {
-            title: "Stats",
-            className: "stats",
-            width: "30%",
-            render: (text, record) => {
-                if(record.status !== cs.CampaignStatusDraft && record.status !== cs.CampaignStatusScheduled) {
-                    return this.renderStats(record)
-                }
+          if (record.tags.length > 0) {
+            for (let i = 0; i < record.tags.length; i++) {
+              out.push(<Tag key={`tag-${i}`}>{record.tags[i]}</Tag>)
             }
-        },
-        {
-            title: "",
-            dataIndex: "actions",
-            className: "actions",
-            width: "15%",
-            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.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="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>
-                        }
-
-                        <Tooltip title="Preview campaign" placement="bottom">
-                            <a role="button" onClick={() => {
-                                this.handlePreview(record)
-                            }}><Icon type="search" /></a>
-                        </Tooltip>
-
-                        <Tooltip title="Clone campaign" placement="bottom">
-                            <a role="button" onClick={() => {
-                                let r = { ...record, lists: record.lists.map((i) => { return i.id }) }
-                                this.handleToggleCloneForm(r)
-                            }}><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>
-                            </Popconfirm>
-                        }
-                    </div>
-                )
-            }
-        }]
-    }
-
-    progressPercent(record) {
-        return Math.round(this.getStatsField("sent", record) / this.getStatsField("to_send", record) * 100, 2)
-    }
+          }
 
-    isDone(record) {
-        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)) {
-            return this.state.stats[record.id][field]
+          return out
         }
-
-        return record[field]
-    }
-
-    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)
-        const toSend = this.getStatsField("to_send", record)
-        const isDone = this.isDone(record)
-        
-        const r = this.getStatsField("rate", record)
-        const rate = r ? r : 0
-
-        return (
+      },
+      {
+        title: "Status",
+        dataIndex: "status",
+        className: "status",
+        width: "10%",
+        render: (status, record) => {
+          let color = cs.CampaignStatusColors.hasOwnProperty(status)
+            ? cs.CampaignStatusColors[status]
+            : ""
+          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>
-                    }
-                    &nbsp;
-                    { record.status === cs.CampaignStatusRunning && 
-                        <Icon type="loading" style={{ fontSize: 12 }} spin />
-                    }
-                </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>
-                <br />
-                <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>
-                }
+              <Tag color={color}>{status}</Tag>
+              {record.send_at && (
+                <span className="text-tiny date">
+                  Scheduled &mdash;{" "}
+                  {dayjs(record.send_at).format(cs.DateFormat)}
+                </span>
+              )}
             </div>
-        )
-    }
-
-    componentDidMount() {
-        this.props.pageTitle("Campaigns")
-        dayjs.extend(relativeTime)
-        this.fetchRecords()
-
-        // Did we land here to start a campaign?
-        let loc = this.props.route.location
-        let state = loc.state
-        if(state && state.hasOwnProperty("campaign")) {
-            this.handleUpdateStatus(state.campaign, state.campaignStatus)
-            delete state.campaign
-            delete state.campaignStatus
-            this.props.route.history.replace({ ...loc, state })
+          )
         }
-    }
-
-    componentWillUnmount() {
-        window.clearInterval(this.state.pollID)
-    }
-
-    fetchRecords = (params) => {
-        let qParams = {
-            page: this.state.queryParams.page,
-            per_page: this.state.queryParams.per_page
+      },
+      {
+        title: "Lists",
+        dataIndex: "lists",
+        width: "25%",
+        align: "left",
+        className: "lists",
+        render: (lists, record) => {
+          const out = []
+          lists.forEach(l => {
+            out.push(
+              <span className="name" key={`name-${l.id}`}>
+                <Link to={`/subscribers/lists/${l.id}`}>{l.name}</Link>
+              </span>
+            )
+          })
+
+          return out
         }
-
-        // The records are for a specific list.
-        if(this.state.queryParams.listID) {
-            qParams.listID = this.state.queryParams.listID
+      },
+      {
+        title: "Stats",
+        className: "stats",
+        width: "30%",
+        render: (text, record) => {
+          if (
+            record.status !== cs.CampaignStatusDraft &&
+            record.status !== cs.CampaignStatusScheduled
+          ) {
+            return this.renderStats(record)
+          }
         }
-
-        if(params) {
-            qParams = { ...qParams, ...params }
+      },
+      {
+        title: "",
+        dataIndex: "actions",
+        className: "actions",
+        width: "15%",
+        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.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="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>
+              )}
+
+              <Tooltip title="Preview campaign" placement="bottom">
+                <a
+                  role="button"
+                  onClick={() => {
+                    this.handlePreview(record)
+                  }}
+                >
+                  <Icon type="search" />
+                </a>
+              </Tooltip>
+
+              <Tooltip title="Clone campaign" placement="bottom">
+                <a
+                  role="button"
+                  onClick={() => {
+                    let r = {
+                      ...record,
+                      lists: record.lists.map(i => {
+                        return i.id
+                      })
+                    }
+                    this.handleToggleCloneForm(r)
+                  }}
+                >
+                  <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>
+                </Popconfirm>
+              )}
+            </div>
+          )
         }
+      }
+    ]
+  }
+
+  progressPercent(record) {
+    return Math.round(
+      (this.getStatsField("sent", record) /
+        this.getStatsField("to_send", record)) *
+        100,
+      2
+    )
+  }
+
+  isDone(record) {
+    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)) {
+      return this.state.stats[record.id][field]
+    }
 
-        this.props.modelRequest(cs.ModelCampaigns, cs.Routes.GetCampaigns, cs.MethodGet, qParams).then((r) => {
-            this.startStatsPoll()
-        })
+    return record[field]
+  }
+
+  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)
+    const toSend = this.getStatsField("to_send", record)
+    const isDone = this.isDone(record)
+
+    const r = this.getStatsField("rate", record)
+    const rate = r ? r : 0
+
+    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>
+            )}
+            &nbsp;
+            {record.status === cs.CampaignStatusRunning && (
+              <Icon type="loading" style={{ fontSize: 12 }} spin />
+            )}
+          </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>
+        <br />
+        <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>
+        )}
+      </div>
+    )
+  }
+
+  componentDidMount() {
+    this.props.pageTitle("Campaigns")
+    dayjs.extend(relativeTime)
+    this.fetchRecords()
+
+    // Did we land here to start a campaign?
+    let loc = this.props.route.location
+    let state = loc.state
+    if (state && state.hasOwnProperty("campaign")) {
+      this.handleUpdateStatus(state.campaign, state.campaignStatus)
+      delete state.campaign
+      delete state.campaignStatus
+      this.props.route.history.replace({ ...loc, state })
     }
+  }
 
-    startStatsPoll = () => {
-        window.clearInterval(this.state.pollID)
-        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) {
-                hasRunning = true
-                return
-            }
-        })
+  componentWillUnmount() {
+    window.clearInterval(this.state.pollID)
+  }
 
-        if(!hasRunning) {
-            return
-        }
+  fetchRecords = params => {
+    let qParams = {
+      page: this.state.queryParams.page,
+      per_page: this.state.queryParams.per_page
+    }
 
-        // Poll for campaign stats.
-        let pollID = window.setInterval(() => {
-            this.props.request(cs.Routes.GetRunningCampaignStats, cs.MethodGet).then((r) => {
-                // No more running campaigns.
-                if(r.data.data.length === 0) {
-                    window.clearInterval(this.state.pollID)
-                    this.fetchRecords()
-                    return
-                }
-
-                let stats = {}
-                r.data.data.forEach((s) => {
-                    stats[s.id] = s
-                })
-
-                this.setState({ stats: stats })
-            }).catch(e => {
-                console.log(e.message)
-            })
-        }, 3000)
-
-        this.setState({ pollID: pollID })
+    // The records are for a specific list.
+    if (this.state.queryParams.listID) {
+      qParams.listID = this.state.queryParams.listID
     }
 
-    handleUpdateStatus = (record, 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}` })
+    if (params) {
+      qParams = { ...qParams, ...params }
+    }
 
-                // Reload the table.
-                this.fetchRecords()
-            }).catch(e => {
-                notification["error"]({ placement: cs.MsgPosition, message: "Error", description: e.message })
-            })
+    this.props
+      .modelRequest(
+        cs.ModelCampaigns,
+        cs.Routes.GetCampaigns,
+        cs.MethodGet,
+        qParams
+      )
+      .then(r => {
+        this.startStatsPoll()
+      })
+  }
+
+  startStatsPoll = () => {
+    window.clearInterval(this.state.pollID)
+    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) {
+        hasRunning = true
+        return
+      }
+    })
+
+    if (!hasRunning) {
+      return
     }
 
-    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` })
+    // Poll for campaign stats.
+    let pollID = window.setInterval(() => {
+      this.props
+        .request(cs.Routes.GetRunningCampaignStats, cs.MethodGet)
+        .then(r => {
+          // No more running campaigns.
+          if (r.data.data.length === 0) {
+            window.clearInterval(this.state.pollID)
+            this.fetchRecords()
+            return
+          }
 
-                // Reload the table.
-                this.fetchRecords()
-            }).catch(e => {
-                notification["error"]({ placement: cs.MsgPosition, message: "Error", description: e.message })
-            })
-    }
+          let stats = {}
+          r.data.data.forEach(s => {
+            stats[s.id] = s
+          })
 
-    handleToggleCloneForm = (record) => {
-        this.setState({ cloneModalVisible: !this.state.cloneModalVisible, record: record, cloneName: record.name })
-    }
+          this.setState({ stats: stats })
+        })
+        .catch(e => {
+          console.log(e.message)
+        })
+    }, 3000)
+
+    this.setState({ pollID: pollID })
+  }
+
+  handleUpdateStatus = (record, 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}`
+        })
 
-    handleCloneCampaign = (record) => {
-        this.setState({ modalWaiting: true })
-        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` })
-
-            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.setState({ modalWaiting: false })
+        // Reload the table.
+        this.fetchRecords()
+      })
+      .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 }
+      )
+      .then(() => {
+        notification["success"]({
+          placement: cs.MsgPosition,
+          message: "Campaign deleted",
+          description: `"${record.name}" deleted`
         })
-    }
 
-    handlePreview = (record) => {
-        this.setState({ previewRecord: record })
-    }
+        // Reload the table.
+        this.fetchRecords()
+      })
+      .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
+    })
+  }
+
+  handleCloneCampaign = record => {
+    this.setState({ modalWaiting: true })
+    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`
+        })
 
-    render() {
-        const pagination = {
-            ...this.paginationOptions,
-            ...this.state.queryParams
-        }
-        
-        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>
-                </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 }
-                />
-
-                { this.state.previewRecord &&
-                    <ModalPreview
-                        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"
-                        className="clone-campaign-modal"
-                        title={ "Clone " + this.state.record.name}
-                        okText="Clone"
-                        confirmLoading={ this.state.modalWaiting }
-                        onCancel={ this.handleToggleCloneForm }
-                        onOk={() => { this.handleCloneCampaign({ ...this.state.record, name: this.state.cloneName }) }}>
-                            <Input autoFocus defaultValue={ this.state.record.name } style={{ width: "100%" }} onChange={(e) => {
-                                this.setState({ cloneName: e.target.value })
-                            }} />
-                    </Modal> }
-            </section>
+        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.setState({ modalWaiting: false })
+      })
+  }
+
+  handlePreview = record => {
+    this.setState({ previewRecord: record })
+  }
+
+  render() {
+    const pagination = {
+      ...this.paginationOptions,
+      ...this.state.queryParams
     }
+
+    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>
+        </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}
+        />
+
+        {this.state.previewRecord && (
+          <ModalPreview
+            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"
+            className="clone-campaign-modal"
+            title={"Clone " + this.state.record.name}
+            okText="Clone"
+            confirmLoading={this.state.modalWaiting}
+            onCancel={this.handleToggleCloneForm}
+            onOk={() => {
+              this.handleCloneCampaign({
+                ...this.state.record,
+                name: this.state.cloneName
+              })
+            }}
+          >
+            <Input
+              autoFocus
+              defaultValue={this.state.record.name}
+              style={{ width: "100%" }}
+              onChange={e => {
+                this.setState({ cloneName: e.target.value })
+              }}
+            />
+          </Modal>
+        )}
+      </section>
+    )
+  }
 }
 
 export default Campaigns

+ 180 - 121
frontend/my/src/Dashboard.js

@@ -1,131 +1,190 @@
-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"
 
 class Dashboard extends React.PureComponent {
-    state = {
-        stats: null,
-        loading: true
-    }
+  state = {
+    stats: null,
+    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.setState({ stats: resp.data.data, loading: false })
-        }).catch(e => {
-            notification["error"]({ message: "Error", description: e.message })
-            this.setState({ loading: false })
-        })
-    }
+  componentDidMount = () => {
+    this.props.pageTitle("Dashboard")
+    this.props
+      .request(cs.Routes.GetDashboarcStats, cs.MethodGet)
+      .then(resp => {
+        this.setState({ stats: resp.data.data, loading: false })
+      })
+      .catch(e => {
+        notification["error"]({ message: "Error", description: e.message })
+        this.setState({ loading: false })
+      })
+  }
 
-    orZero(v) {
-        return v ? v : 0
-    }
-    
-    render() {
-        return (
-            <section className = "dashboard">
-                <h1>Welcome</h1>
-                <hr />
-                <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>
-                                        </Card>
-                                    </Col>
-                                    <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>
-                                        </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>
-                                        </Card>
-                                    </Col>
-                                    <Col span={ 12 }>
-                                        <Card title="Private lists" bordered={ false }>
-                                            <h1 className="count">{ this.orZero(this.state.stats.lists.private) }</h1>
-                                        </Card>
-                                    </Col>
-                                </Row>
-                            </Col>
-                        </Row>
-                        <hr />
-                        <Row>
-                            <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) }
-                                                {' '}
-                                                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>
-                                        </Card>
-                                    </Col>
-                                    <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) }
-                                                {' '}
-                                                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>
-                                        </Card>
-                                    </Col>
-                                </Row>
-                            </Col>
+  orZero(v) {
+    return v ? v : 0
+  }
 
-                            <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 }
-                                                </h1>
-                                            </Col>
-                                        </Row>
-                                    )}
-                                </Card>
-                            </Col>
-                        </Row>
-                    </div>
-                }
-                </Spin>
-            </section>
-        );
-    }
+  render() {
+    return (
+      <section className="dashboard">
+        <h1>Welcome</h1>
+        <hr />
+        <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>
+                      </Card>
+                    </Col>
+                    <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>
+                      </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>
+                      </Card>
+                    </Col>
+                    <Col span={12}>
+                      <Card title="Private lists" bordered={false}>
+                        <h1 className="count">
+                          {this.orZero(this.state.stats.lists.private)}
+                        </h1>
+                      </Card>
+                    </Col>
+                  </Row>
+                </Col>
+              </Row>
+              <hr />
+              <Row>
+                <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
+                          )}{" "}
+                          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>
+                      </Card>
+                    </Col>
+                    <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
+                          )}{" "}
+                          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>
+                      </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}>
+                          <h1 className="count">
+                            {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

+ 0 - 9
frontend/my/src/Dashboard.test.js

@@ -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);
-});

+ 431 - 328
frontend/my/src/Import.js

@@ -1,363 +1,466 @@
 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"
+const StatusNone = "none"
 const StatusImporting = "importing"
-const StatusStopping  = "stopping"
-const StatusFinished  = "finished"
-const StatusFailed  = "failed"
+const StatusStopping = "stopping"
+const StatusFinished = "finished"
+const StatusFailed = "failed"
 
 class TheFormDef extends React.PureComponent {
-    state = {
-        confirmDirty: false,
-        fileList: [],
-        formLoading: false,
-        mode: "subscribe"
+  state = {
+    confirmDirty: false,
+    fileList: [],
+    formLoading: false,
+    mode: "subscribe"
+  }
+
+  componentDidMount() {
+    // Fetch lists.
+    this.props.modelRequest(cs.ModelLists, cs.Routes.GetLists, cs.MethodGet)
+  }
+
+  // Handle create / edit form submission.
+  handleSubmit = e => {
+    e.preventDefault()
+    var err = null,
+      values = {}
+    this.props.form.validateFields((e, v) => {
+      err = e
+      values = v
+    })
+    if (err) {
+      return
     }
 
-    componentDidMount() {
-        // Fetch lists.
-        this.props.modelRequest(cs.ModelLists, cs.Routes.GetLists, cs.MethodGet)
+    if (this.state.fileList.length < 1) {
+      notification["error"]({
+        placement: cs.MsgPosition,
+        message: "Error",
+        description: "Select a valid file to upload"
+      })
+      return
     }
 
-    // Handle create / edit form submission.
-    handleSubmit = (e) => {
-        e.preventDefault()
-        var err = null, values = {}
-        this.props.form.validateFields((e, v) => {
-            err = e
-            values = v
+    this.setState({ formLoading: true })
+    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,
+          message: "File uploaded",
+          description: "Please wait while the import is running"
         })
-        if (err) {
-            return
-        }
-
-        if(this.state.fileList.length < 1) {
-            notification["error"]({ placement: cs.MsgPosition,
-                                    message: "Error",
-                                    description: "Select a valid file to upload" })
-            return
-        }
-
-        this.setState({ formLoading: true })
-        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,
-                                    message: "File uploaded",
-                                    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 })
-            this.setState({ formLoading: false })
+        this.props.fetchimportState()
+        this.setState({ formLoading: false })
+      })
+      .catch(e => {
+        notification["error"]({
+          placement: cs.MsgPosition,
+          message: "Error",
+          description: e.message
         })
+        this.setState({ formLoading: false })
+      })
+  }
+
+  handleConfirmBlur = e => {
+    const value = e.target.value
+    this.setState({ confirmDirty: this.state.confirmDirty || !!value })
+  }
+
+  onFileChange = f => {
+    let fileList = [f]
+    this.setState({ fileList })
+    return false
+  }
+
+  render() {
+    const { getFieldDecorator } = this.props.form
+
+    const formItemLayout = {
+      labelCol: { xs: { span: 16 }, sm: { span: 4 } },
+      wrapperCol: { xs: { span: 16 }, sm: { span: 10 } }
     }
 
-    handleConfirmBlur = (e) => {
-        const value = e.target.value
-        this.setState({ confirmDirty: this.state.confirmDirty || !!value })
+    const formItemTailLayout = {
+      wrapperCol: { xs: { span: 24, offset: 0 }, sm: { span: 10, offset: 4 } }
     }
 
-    onFileChange = (f) => {
-        let fileList = [f]
-        this.setState({ fileList })
-        return false
-    }
-
-    render() {
-        const { getFieldDecorator } = this.props.form
-
-        const formItemLayout = {
-            labelCol: { xs: { span: 16 }, sm: { span: 4 } },
-            wrapperCol: { xs: { span: 16 }, sm: { span: 10 } }
-        }
-
-        const formItemTailLayout = {
-            wrapperCol: { xs: { span: 24, offset: 0 }, sm: { span: 10, offset: 4 } }
-        }
-
-        return (
-            <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>
-                            </Radio.Group>
-                        )}
-                    </Form.Item>
-                    { this.state.mode === "subscribe" &&
-                        <React.Fragment>
-                            <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>
-                                        )}
-                                    </Select>
-                                )}
-                            </Form.Item>
-                        </React.Fragment>
-                    }
-                    { 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'.
-                            </p>
-                        </Form.Item>
-                    }
-                    <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">
-                        <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">
-                                    <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>
-                                </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>
-                    </Form.Item>
-                </Form>
-            </Spin>
-        )
-    }
+    return (
+      <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>
+              </Radio.Group>
+            )}
+          </Form.Item>
+          {this.state.mode === "subscribe" && (
+            <React.Fragment>
+              <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>
+                    ))}
+                  </Select>
+                )}
+              </Form.Item>
+            </React.Fragment>
+          )}
+          {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'.
+              </p>
+            </Form.Item>
+          )}
+          <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">
+            <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"
+                >
+                  <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>
+                </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>
+          </Form.Item>
+        </Form>
+      </Spin>
+    )
+  }
 }
 const TheForm = Form.create()(TheFormDef)
 
 class Importing extends React.PureComponent {
-    state = {
-        pollID: -1,
-        logs: ""
-    }
-
-    stopImport = () => {
-        // Get the import status.
-        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 })
+  state = {
+    pollID: -1,
+    logs: ""
+  }
+
+  stopImport = () => {
+    // Get the import status.
+    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
         })
-    }
-
-    componentDidMount() {
-        // Poll for stats until it's finished or failed.
-        let pollID = window.setInterval(() => {
-            this.props.fetchimportState()
-            this.fetchLogs()
-            if( this.props.importState.status === StatusFinished ||
-                this.props.importState.status === StatusFailed ) {
-                window.clearInterval(this.state.pollID)
-            }    
-        }, 1000)
-
-        this.setState({ pollID: pollID })
-    }
-    componentWillUnmount() {
+      })
+  }
+
+  componentDidMount() {
+    // Poll for stats until it's finished or failed.
+    let pollID = window.setInterval(() => {
+      this.props.fetchimportState()
+      this.fetchLogs()
+      if (
+        this.props.importState.status === StatusFinished ||
+        this.props.importState.status === StatusFailed
+      ) {
         window.clearInterval(this.state.pollID)
-    }
-
-    fetchLogs() {
-        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 })
+      }
+    }, 1000)
+
+    this.setState({ pollID: pollID })
+  }
+  componentWillUnmount() {
+    window.clearInterval(this.state.pollID)
+  }
+
+  fetchLogs() {
+    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
         })
+      })
+  }
+
+  render() {
+    let progressPercent = 0
+    if (this.props.importState.status === StatusFinished) {
+      progressPercent = 100
+    } else {
+      progressPercent = Math.floor(
+        (this.props.importState.imported / this.props.importState.total) * 100
+      )
     }
 
-    render() {
-        let progressPercent = 0
-        if( this.props.importState.status === StatusFinished ) {
-            progressPercent = 100
-        } else {
-            progressPercent = Math.floor(this.props.importState.imported / this.props.importState.total * 100)
-        }
-
-        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>
-                }
-
-                { 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 } />
-                            </div>
-
-                            <div>
-                                <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>
-                                        <Button type="primary">Stop import</Button>
-                                    </Popconfirm>
-                                }
-                                { this.props.importState.status === StatusStopping &&
-                                    <div>
-                                        <p><Icon type="loading" /></p>
-                                        <h4>Stopping</h4>
-                                    </div>
-                                }
-                                { this.props.importState.status !== StatusImporting &&
-                                  this.props.importState.status !== StatusStopping &&
-                                    <div>
-                                        { this.props.importState.status !== StatusFinished &&
-                                            <div>
-                                                <Tag color="red">{ this.props.importState.status }</Tag>
-                                                <br />
-                                            </div>
-                                        }
-                                        
-                                        <br />
-                                        <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"
-                                    id="log-textarea"
-                                    rows={10}
-                                    value={ this.state.logs }
-                                    autosize={{ minRows: 2, maxRows: 10 }} />
-                            </Spin>
+    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>
+        )}
+
+        {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} />
+              </div>
+
+              <div>
+                <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>
+                    <Button type="primary">Stop import</Button>
+                  </Popconfirm>
+                )}
+                {this.props.importState.status === StatusStopping && (
+                  <div>
+                    <p>
+                      <Icon type="loading" />
+                    </p>
+                    <h4>Stopping</h4>
+                  </div>
+                )}
+                {this.props.importState.status !== StatusImporting &&
+                  this.props.importState.status !== StatusStopping && (
+                    <div>
+                      {this.props.importState.status !== StatusFinished && (
+                        <div>
+                          <Tag color="red">{this.props.importState.status}</Tag>
+                          <br />
                         </div>
-                    </Col>
-                </Row>
-            </section>
-        )
-    }
+                      )}
+
+                      <br />
+                      <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"
+                  id="log-textarea"
+                  rows={10}
+                  value={this.state.logs}
+                  autosize={{ minRows: 2, maxRows: 10 }}
+                />
+              </Spin>
+            </div>
+          </Col>
+        </Row>
+      </section>
+    )
+  }
 }
 
 class Import extends React.PureComponent {
-    state = {
-        importState: { "status": "" }
-    }
-
-    fetchimportState = () => {
-        // Get the import status.
-        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 })
+  state = {
+    importState: { status: "" }
+  }
+
+  fetchimportState = () => {
+    // Get the import status.
+    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
         })
+      })
+  }
+
+  componentDidMount() {
+    this.props.pageTitle("Import subscribers")
+    this.fetchimportState()
+  }
+  render() {
+    if (this.state.importState.status === "") {
+      // Fetching the status.
+      return (
+        <section className="content center">
+          <Spin />
+        </section>
+      )
+    } else if (this.state.importState.status !== StatusNone) {
+      // There's an import state
+      return (
+        <Importing
+          {...this.props}
+          importState={this.state.importState}
+          fetchimportState={this.fetchimportState}
+        />
+      )
     }
 
-    componentDidMount() {
-        this.props.pageTitle("Import subscribers")
-        this.fetchimportState()
-    }
-    render() {
-        if( this.state.importState.status === "" ) {
-            // Fetching the status.
-            return (
-                <section className="content center">
-                    <Spin />
-                </section>
-            )
-        } else if ( this.state.importState.status !== StatusNone ) {
-            // There's an import state
-            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>
-                </Row>
-
-                <TheForm { ...this.props }
-                    fetchimportState={ this.fetchimportState }
-                    lists={ this.props.data[cs.ModelLists] }>
-                </TheForm>
-
-                <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>
-
-                    <blockquote className="csv-example">
-                        <code className="csv-headers">
-                            <span>email,</span>
-                            <span>name,</span>
-                            <span>status,</span>
-                            <span>attributes</span>
-                        </code>
-                    </blockquote>
-
-                    <h3>Example raw CSV</h3>
-                    <blockquote className="csv-example">
-                        <code className="csv-headers">
-                            <span>email,</span>
-                            <span>name,</span>
-                            <span>status,</span>
-                            <span>attributes</span>
-                        </code>
-                        <code className="csv-row">
-                            <span>user1@mail.com,</span>
-                            <span>"User One",</span>
-                            <span>enabled,</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>
-                        </code>
-                    </blockquote>
-                </div>
-            </section>
-        )
-    }
+    return (
+      <section className="content import">
+        <Row>
+          <Col span={22}>
+            <h1>Import subscribers</h1>
+          </Col>
+          <Col span={2} />
+        </Row>
+
+        <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>
+
+          <blockquote className="csv-example">
+            <code className="csv-headers">
+              <span>email,</span>
+              <span>name,</span>
+              <span>status,</span>
+              <span>attributes</span>
+            </code>
+          </blockquote>
+
+          <h3>Example raw CSV</h3>
+          <blockquote className="csv-example">
+            <code className="csv-headers">
+              <span>email,</span>
+              <span>name,</span>
+              <span>status,</span>
+              <span>attributes</span>
+            </code>
+            <code className="csv-row">
+              <span>user1@mail.com,</span>
+              <span>"User One",</span>
+              <span>enabled,</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>
+            </code>
+          </blockquote>
+        </div>
+      </section>
+    )
+  }
 }
 
 export default Import

+ 228 - 87
frontend/my/src/Layout.js

@@ -12,105 +12,246 @@ 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
 const year = new Date().getUTCFullYear()
 
 class Base extends React.Component {
-    state = {
-        basePath: "/" + window.location.pathname.split("/")[1],
-        error: null,
-        collapsed: false
-    };
-
-    onCollapse = (collapsed) => {
-        this.setState({ collapsed })
-    }
-
-    render() {
-        return (
-            <Layout style={{ minHeight: "100vh" }}>
-                <Sider
-                    collapsible
-                    collapsed={this.state.collapsed}
-                    onCollapse={this.onCollapse}
-                    theme="light"
-                >
-                    <div className="logo">
-                        <Link to="/"><img src={logo} alt="listmonk logo" /></Link>
-                    </div>
+  state = {
+    basePath: "/" + window.location.pathname.split("/")[1],
+    error: null,
+    collapsed: false
+  }
 
-                    <Menu defaultSelectedKeys={["/"]}
-                        selectedKeys={[window.location.pathname]}
-                        defaultOpenKeys={[this.state.basePath]}
-                        mode="inline">
+  onCollapse = collapsed => {
+    this.setState({ collapsed })
+  }
 
-                        <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>
-                        </SubMenu>
+  render() {
+    return (
+      <Layout style={{ minHeight: "100vh" }}>
+        <Sider
+          collapsible
+          collapsed={this.state.collapsed}
+          onCollapse={this.onCollapse}
+          theme="light"
+        >
+          <div className="logo">
+            <Link to="/">
+              <img src={logo} alt="listmonk logo" />
+            </Link>
+          </div>
 
-                        <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>
-                        </SubMenu>
+          <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>
+            <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>
+            </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>
-                        </SubMenu>
-                        <Menu.Item key="11"><Icon type="logout" /><span>Logout</span></Menu.Item>
-                    </Menu>
-                </Sider>
+            <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>
+            </SubMenu>
 
-                <Layout>
-                    <Content style={{ margin: "0 16px" }}>
-                        <div className="content-body">
-                            <div id="alert-container"></div>
-                            <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 } } />} />
-                            </Switch>
+            <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>
+            </SubMenu>
+            <Menu.Item key="11">
+              <Icon type="logout" />
+              <span>Logout</span>
+            </Menu.Item>
+          </Menu>
+        </Sider>
 
-                        </div>
-                    </Content>
-                    <Footer>
-                        <span className="text-small">
-                            <a href="https://listmonk.app" rel="noreferrer noopener" target="_blank">listmonk</a>
-                            {" "}
-                            &copy; 2019 { year != 2019 ? " - " + year : "" }
-                        </span>
-                    </Footer>
-                </Layout>
-            </Layout>
-        )
-    }
+        <Layout>
+          <Content style={{ margin: "0 16px" }}>
+            <div className="content-body">
+              <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 }} />
+                  )}
+                />
+              </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 : ""}
+            </span>
+          </Footer>
+        </Layout>
+      </Layout>
+    )
+  }
 }
 
 export default Base

+ 336 - 227
frontend/my/src/Lists.js

@@ -1,267 +1,376 @@
 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 {
-    state = {
-        confirmDirty: false,
-        modalWaiting: false
-    }
-
-    // Handle create / edit form submission.
-    handleSubmit = (e) => {
-        e.preventDefault()
-        this.props.form.validateFields((err, values) => {
-            if (err) {
-                return
-            }
+  state = {
+    confirmDirty: false,
+    modalWaiting: false
+  }
 
-            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.fetchRecords()
-                    this.props.onClose()
-                    this.setState({ modalWaiting: false })
-                }).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.fetchRecords()
-                    this.props.onClose()
-                    this.setState({ modalWaiting: false })
-                }).catch(e => {
-                    notification["error"]({ placement: cs.MsgPosition, message: "Error", description: e.message })
-                    this.setState({ modalWaiting: false })
-                })
-            }
-        })
-    }
+  // Handle create / edit form submission.
+  handleSubmit = e => {
+    e.preventDefault()
+    this.props.form.validateFields((err, values) => {
+      if (err) {
+        return
+      }
 
-    modalTitle(formType, record) {
-        if(formType === cs.FormCreate) {
-            return "Create a list"
-        }
+      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.fetchRecords()
+            this.props.onClose()
+            this.setState({ modalWaiting: false })
+          })
+          .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.fetchRecords()
+            this.props.onClose()
+            this.setState({ modalWaiting: false })
+          })
+          .catch(e => {
+            notification["error"]({
+              placement: cs.MsgPosition,
+              message: "Error",
+              description: e.message
+            })
+            this.setState({ modalWaiting: false })
+          })
+      }
+    })
+  }
 
-        return (
-            <div>
-                <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>
-            </div>
-        )
+  modalTitle(formType, record) {
+    if (formType === cs.FormCreate) {
+      return "Create a list"
     }
 
-    render() {
-        const { formType, record, onClose } = this.props
-        const { getFieldDecorator } = this.props.form
-
-        const formItemLayout = {
-            labelCol: { xs: { span: 16 }, sm: { span: 4 } },
-            wrapperCol: { xs: { span: 16 }, sm: { span: 18 } }
-        }
+    return (
+      <div>
+        <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>
+      </div>
+    )
+  }
 
-        if (formType === null) {
-            return null
-        }
+  render() {
+    const { formType, record, onClose } = this.props
+    const { getFieldDecorator } = this.props.form
 
-        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 }>
-                
-                <div id="modal-alert-container"></div>
+    const formItemLayout = {
+      labelCol: { xs: { span: 16 }, sm: { span: 4 } },
+      wrapperCol: { xs: { span: 16 }, sm: { span: 18 } }
+    }
 
-                <Spin spinning={ this.props.reqStates[cs.ModelLists] === cs.StatePending }>
-                    <Form onSubmit={this.handleSubmit}>
-                        <Form.Item {...formItemLayout} label="Name">
-                            {getFieldDecorator("name", {
-                                initialValue: record.name,
-                                rules: [{ required: true }]
-                            })(<Input autoFocus maxLength="200" />)}
-                        </Form.Item>
-                        <Form.Item {...formItemLayout} name="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">
-                            {getFieldDecorator("tags", { initialValue: record.tags })(
-                                <Select mode="tags"></Select>
-                            )}
-                        </Form.Item>
-                    </Form>
-                </Spin>
-            </Modal>
-        )
+    if (formType === null) {
+      return null
     }
+
+    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}
+      >
+        <div id="modal-alert-container" />
+
+        <Spin
+          spinning={this.props.reqStates[cs.ModelLists] === cs.StatePending}
+        >
+          <Form onSubmit={this.handleSubmit}>
+            <Form.Item {...formItemLayout} label="Name">
+              {getFieldDecorator("name", {
+                initialValue: record.name,
+                rules: [{ required: true }]
+              })(<Input autoFocus maxLength="200" />)}
+            </Form.Item>
+            <Form.Item
+              {...formItemLayout}
+              name="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"
+            >
+              {getFieldDecorator("tags", { initialValue: record.tags })(
+                <Select mode="tags" />
+              )}
+            </Form.Item>
+          </Form>
+        </Spin>
+      </Modal>
+    )
+  }
 }
 
 const CreateForm = Form.create()(CreateFormDef)
 
 class Lists extends React.PureComponent {
-    state = {
-        formType: null,
-        record: {}
-    }
+  state = {
+    formType: null,
+    record: {}
+  }
 
-    constructor(props) {
-        super(props)
+  constructor(props) {
+    super(props)
 
-        this.columns = [{
-            title: "Name",
-            dataIndex: "name",
-            sorter: true,
-            width: "40%",
-            render: (text, record) => {
-                const out = [];
-                out.push(
-                    <div className="name" key={`name-${record.id}`}><Link to={ `/subscribers/lists/${record.id}` }>{ text }</Link></div>
-                )
-
-                if(record.tags.length > 0) {
-                    for (let i = 0; i < record.tags.length; i++) {
-                        out.push(<Tag key={`tag-${i}`}>{ record.tags[i] }</Tag>);
-                    }
-                }
+    this.columns = [
+      {
+        title: "Name",
+        dataIndex: "name",
+        sorter: true,
+        width: "40%",
+        render: (text, record) => {
+          const out = []
+          out.push(
+            <div className="name" key={`name-${record.id}`}>
+              <Link to={`/subscribers/lists/${record.id}`}>{text}</Link>
+            </div>
+          )
 
-                return out
-            }
-        },
-        {
-            title: "Type",
-            dataIndex: "type",
-            width: "10%",
-            render: (type, _) => {
-                let color = type === "private" ? "orange" : "green"
-                return <Tag color={color}>{type}</Tag>
-            }
-        },
-        {
-            title: "Subscribers",
-            dataIndex: "subscriber_count",
-            width: "15%",
-            align: "center",
-            render: (text, record) => {
-                return(
-                    <div className="name" key={`name-${record.id}`}><Link to={ `/subscribers/lists/${record.id}` }>{ text }</Link></div>
-                )
-            }
-        },
-        {
-            title: "Created",
-            dataIndex: "created_at",
-            render: (date, _) => {
-                return Utils.DateString(date)
-            }
-        },
-        {
-            title: "Updated",
-            dataIndex: "updated_at",
-            render: (date, _) => {
-                return Utils.DateString(date)
+          if (record.tags.length > 0) {
+            for (let i = 0; i < record.tags.length; i++) {
+              out.push(<Tag key={`tag-${i}`}>{record.tags[i]}</Tag>)
             }
-        },
-        {
-            title: "",
-            dataIndex: "actions",
-            width: "10%",
-            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>
-                        </Popconfirm>
-                    </div>
-                )
-            }
-        }]
-    }
+          }
 
-    componentDidMount() {
-        this.props.pageTitle("Lists")
-        this.fetchRecords()
-    }
+          return out
+        }
+      },
+      {
+        title: "Type",
+        dataIndex: "type",
+        width: "10%",
+        render: (type, _) => {
+          let color = type === "private" ? "orange" : "green"
+          return <Tag color={color}>{type}</Tag>
+        }
+      },
+      {
+        title: "Subscribers",
+        dataIndex: "subscriber_count",
+        width: "15%",
+        align: "center",
+        render: (text, record) => {
+          return (
+            <div className="name" key={`name-${record.id}`}>
+              <Link to={`/subscribers/lists/${record.id}`}>{text}</Link>
+            </div>
+          )
+        }
+      },
+      {
+        title: "Created",
+        dataIndex: "created_at",
+        render: (date, _) => {
+          return Utils.DateString(date)
+        }
+      },
+      {
+        title: "Updated",
+        dataIndex: "updated_at",
+        render: (date, _) => {
+          return Utils.DateString(date)
+        }
+      },
+      {
+        title: "",
+        dataIndex: "actions",
+        width: "10%",
+        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>
+              </Popconfirm>
+            </div>
+          )
+        }
+      }
+    ]
+  }
 
-    fetchRecords = () => {
-        this.props.modelRequest(cs.ModelLists, cs.Routes.GetLists, cs.MethodGet)
-    }
+  componentDidMount() {
+    this.props.pageTitle("Lists")
+    this.fetchRecords()
+  }
 
-    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` })
-                
-                // Reload the table.
-                this.fetchRecords()
-            }).catch(e => {
-                notification["error"]({ placement: cs.MsgPosition, message: "Error", description: e.message })
-            })
-    }
+  fetchRecords = () => {
+    this.props.modelRequest(cs.ModelLists, cs.Routes.GetLists, cs.MethodGet)
+  }
 
-    handleHideForm = () => {
-        this.setState({ formType: null })
-    }
+  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`
+        })
 
-    handleShowCreateForm = () => {
-        this.setState({ formType: cs.FormCreate, record: {} })
-    }
+        // Reload the table.
+        this.fetchRecords()
+      })
+      .catch(e => {
+        notification["error"]({
+          placement: cs.MsgPosition,
+          message: "Error",
+          description: e.message
+        })
+      })
+  }
 
-    handleShowEditForm = (record) => {
-        this.setState({ formType: cs.FormEdit, record: record })
-    }
+  handleHideForm = () => {
+    this.setState({ formType: null })
+  }
 
-    render() {
-        return (
-            <section className="content">
-                <Row>
-                    <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>
-                    </Col>
-                </Row>
-                <br />
+  handleShowCreateForm = () => {
+    this.setState({ formType: cs.FormCreate, record: {} })
+  }
 
-                <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 }
-                />
+  handleShowEditForm = record => {
+    this.setState({ formType: cs.FormEdit, record: record })
+  }
 
-                <CreateForm { ...this.props }
-                    formType={ this.state.formType }
-                    record={ this.state.record }
-                    onClose={ this.handleHideForm }
-                    fetchRecords = { this.fetchRecords }
-                />
-            </section>
-        )
-    }
+  render() {
+    return (
+      <section className="content">
+        <Row>
+          <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>
+          </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}
+        />
+
+        <CreateForm
+          {...this.props}
+          formType={this.state.formType}
+          record={this.state.record}
+          onClose={this.handleHideForm}
+          fetchRecords={this.fetchRecords}
+        />
+      </section>
+    )
+  }
 }
 
 export default Lists

+ 152 - 108
frontend/my/src/Media.js

@@ -1,132 +1,176 @@
 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 {
-    state = {
-        confirmDirty: false
-    }
+  state = {
+    confirmDirty: false
+  }
 
-    componentDidMount() {
-        this.props.pageTitle("Media")
-        this.fetchRecords()
-    }
+  componentDidMount() {
+    this.props.pageTitle("Media")
+    this.fetchRecords()
+  }
 
-    fetchRecords = () => {
-        this.props.modelRequest(cs.ModelMedia, cs.Routes.GetMedia, cs.MethodGet)
-    }
+  fetchRecords = () => {
+    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 })
-            .then(() => {
-                notification["success"]({ placement: cs.MsgPosition, message: "Image deleted", description: `"${record.filename}" deleted` })
+  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`
+        })
 
-                // Reload the table.
-                this.fetchRecords()
-            }).catch(e => {
-                notification["error"]({ placement: cs.MsgPosition, message: "Error", description: e.message })
-            })
-    }
+        // Reload the table.
+        this.fetchRecords()
+      })
+      .catch(e => {
+        notification["error"]({
+          placement: cs.MsgPosition,
+          message: "Error",
+          description: e.message
+        })
+      })
+  }
 
-    handleInsertMedia = (record) => {
-        // The insertMedia callback may be passed down by the invoker (Campaign)
-        if(!this.props.insertMedia) {
-            return false
-        }
-        
-        this.props.insertMedia(record.uri)
-        return false
+  handleInsertMedia = record => {
+    // The insertMedia callback may be passed down by the invoker (Campaign)
+    if (!this.props.insertMedia) {
+      return false
     }
 
-    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") {
-            this.fetchRecords()
-        }
+    this.props.insertMedia(record.uri)
+    return false
+  }
 
-        return false
+  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") {
+      this.fetchRecords()
     }
 
-    render() {
-        const { getFieldDecorator } = this.props.form
-        const formItemLayout = {
-            labelCol: { xs: { span: 16 }, sm: { span: 4 } },
-            wrapperCol: { xs: { span: 16 }, sm: { span: 10 } }
-        }
-
-        return (
-            <Spin spinning={false}>
-                <Form>
-                    <Form.Item
-                        {...formItemLayout}
-                        label="Upload images">
-                        <div className="dropbox">
-                            {getFieldDecorator("file", {
-                                valuePropName: "file",
-                                getValueFromEvent: this.normFile,
-                                rules: [{ required: true }]
-                            })(
-                                <Upload.Dragger
-                                    name="file"
-                                    action="/api/media"
-                                    multiple={ true }
-                                    listType="picture"
-                                    onChange={ this.onFileChange }
-                                    accept=".gif, .jpg, .jpeg, .png">
-                                    <p className="ant-upload-drag-icon">
-                                        <Icon type="inbox" />
-                                    </p>
-                                    <p className="ant-upload-text">Click or drag file here</p>
-                                </Upload.Dragger>
-                            )}
-                        </div>
-                    </Form.Item>
-                </Form>
+    return false
+  }
 
-                <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();
-                                }
-                            } }><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>
-                                </Popconfirm>
-                            </div>
-                            <div className="name" title={ record.filename }>{ record.filename }</div>
-                        </div>
-                    )}
-                </section>
-            </Spin>
-        )
+  render() {
+    const { getFieldDecorator } = this.props.form
+    const formItemLayout = {
+      labelCol: { xs: { span: 16 }, sm: { span: 4 } },
+      wrapperCol: { xs: { span: 16 }, sm: { span: 10 } }
     }
+
+    return (
+      <Spin spinning={false}>
+        <Form>
+          <Form.Item {...formItemLayout} label="Upload images">
+            <div className="dropbox">
+              {getFieldDecorator("file", {
+                valuePropName: "file",
+                getValueFromEvent: this.normFile,
+                rules: [{ required: true }]
+              })(
+                <Upload.Dragger
+                  name="file"
+                  action="/api/media"
+                  multiple={true}
+                  listType="picture"
+                  onChange={this.onFileChange}
+                  accept=".gif, .jpg, .jpeg, .png"
+                >
+                  <p className="ant-upload-drag-icon">
+                    <Icon type="inbox" />
+                  </p>
+                  <p className="ant-upload-text">Click or drag file here</p>
+                </Upload.Dragger>
+              )}
+            </div>
+          </Form.Item>
+        </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()
+                    }
+                  }}
+                >
+                  <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>
+                  </Popconfirm>
+                </div>
+                <div className="name" title={record.filename}>
+                  {record.filename}
+                </div>
+              </div>
+            ))}
+        </section>
+      </Spin>
+    )
+  }
 }
 const TheForm = Form.create()(TheFormDef)
 
 class Media extends React.PureComponent {
-    render() {
-        return (
-            <section className="content media">
-                <Row>
-                    <Col span={22}><h1>Images</h1></Col>
-                    <Col span={2}>
-                    </Col>
-                </Row>
+  render() {
+    return (
+      <section className="content media">
+        <Row>
+          <Col span={22}>
+            <h1>Images</h1>
+          </Col>
+          <Col span={2} />
+        </Row>
 
-                <TheForm { ...this.props }
-                    media={ this.props.data[cs.ModelMedia] }>
-                </TheForm>
-            </section>
-        )
-    }
+        <TheForm {...this.props} media={this.props.data[cs.ModelMedia]} />
+      </section>
+    )
+  }
 }
 
 export default Media

+ 62 - 58
frontend/my/src/ModalPreview.js

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

+ 351 - 246
frontend/my/src/Subscriber.js

@@ -1,292 +1,397 @@
 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 } },
-    wrapperCol: { xs: { span: 24 }, sm: { span: 18 } }
+  labelCol: { xs: { span: 24 }, sm: { span: 4 } },
+  wrapperCol: { xs: { span: 24 }, sm: { span: 18 } }
 }
 const formItemLayout = {
-    labelCol: { xs: { span: 16 }, sm: { span: 4 } },
-    wrapperCol: { xs: { span: 16 }, sm: { span: 10 } }
+  labelCol: { xs: { span: 16 }, sm: { span: 4 } },
+  wrapperCol: { xs: { span: 16 }, sm: { span: 10 } }
 }
 const formItemTailLayout = {
-    wrapperCol: { xs: { span: 24, offset: 0 }, sm: { span: 10, offset: 4 } }
+  wrapperCol: { xs: { span: 24, offset: 0 }, sm: { span: 10, offset: 4 } }
 }
 
 class CreateFormDef extends React.PureComponent {
-    state = {
-        confirmDirty: false,
-        loading: false
-    }
+  state = {
+    confirmDirty: false,
+    loading: false
+  }
 
-    // Handle create / edit form submission.
-    handleSubmit = (e, cb) => {
-        e.preventDefault()
-        if(!cb) {
-            // Set a fake callback.
-            cb = () => {}
-        }
+  // Handle create / edit form submission.
+  handleSubmit = (e, cb) => {
+    e.preventDefault()
+    if (!cb) {
+      // Set a fake callback.
+      cb = () => {}
+    }
 
-        var err = null, values = {}
-        this.props.form.validateFields((e, v) => {
-            err = e
-            values = v
-        })
-        if(err) {
-            return
-        }
+    var err = null,
+      values = {}
+    this.props.form.validateFields((e, v) => {
+      err = e
+      values = v
+    })
+    if (err) {
+      return
+    }
 
-        let a = values["attribs"]
-        values["attribs"] = {}
-        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 []" })
-                    return
-                }
-            } catch(e) {
-                notification["error"]({ message: "Invalid JSON in attributes", description: e.toString() })
-                return
-            }
+    let a = values["attribs"]
+    values["attribs"] = {}
+    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 []"
+          })
+          return
         }
+      } catch (e) {
+        notification["error"]({
+          message: "Invalid JSON in attributes",
+          description: e.toString()
+        })
+        return
+      }
+    }
 
-        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.fetchRecord(this.props.record.id)
-                }
-                cb(true)
-                this.setState({ loading: false })
-            }).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) {
-                    this.props.fetchRecord(this.props.record.id)
-                }
-                cb(true)
-                this.setState({ loading: false })
-            }).catch(e => {
-                notification["error"]({ message: "Error", description: e.message })
-                cb(false)
-                this.setState({ loading: false })
-            })
-        }
+    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.fetchRecord(this.props.record.id)
+          }
+          cb(true)
+          this.setState({ loading: false })
+        })
+        .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) {
+            this.props.fetchRecord(this.props.record.id)
+          }
+          cb(true)
+          this.setState({ loading: false })
+        })
+        .catch(e => {
+          notification["error"]({ message: "Error", description: e.message })
+          cb(false)
+          this.setState({ loading: false })
+        })
     }
+  }
 
-    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` })
+  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`
+        })
 
-                this.props.route.history.push({
-                    pathname: cs.Routes.ViewSubscribers,
-                })
-            }).catch(e => {
-                notification["error"]({ message: "Error", description: e.message })
-            })
-    }
+        this.props.route.history.push({
+          pathname: cs.Routes.ViewSubscribers
+        })
+      })
+      .catch(e => {
+        notification["error"]({ message: "Error", description: e.message })
+      })
+  }
 
-    render() {
-        const { formType, record } = this.props;
-        const { getFieldDecorator } = this.props.form
+  render() {
+    const { formType, record } = this.props
+    const { getFieldDecorator } = this.props.form
 
-        if (formType === null) {
-            return null
-        }
+    if (formType === null) {
+      return null
+    }
 
-        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 ]
-        }
+    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]
+    }
 
-        const layout = this.props.isModal ? formItemLayoutModal : formItemLayout;
-        return (
-            <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">
-                        {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" }] })(
-                            <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.">
-                        {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>
-                                            }
-                                        </span>
-                                    </Select.Option>
-                                )}
-                            </Select>
-                        )}
-                    </Form.Item>
-                    <Form.Item { ...layout } label="Attributes" colon={ false }>
-                        <div>
-                            {getFieldDecorator("attribs", {
-                                initialValue: record.attribs ? JSON.stringify(record.attribs, null, 4) : ""
-                            })(
-                                <Input.TextArea
-                                    placeholder="{}"
-                                    rows={10}
-                                    readOnly={false}
-                                    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>
-                    </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.handleDeleteRecord(record)
-                                }}>
-                                    <Button icon="delete">Delete</Button>
-                                </Popconfirm>
-                            }
-                        </Form.Item>
+    const layout = this.props.isModal ? formItemLayoutModal : formItemLayout
+    return (
+      <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">
+            {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" }]
+            })(
+              <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."
+          >
+            {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
                     }
-                </Form>
-            </Spin>
-        )
-    }
+                  >
+                    <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}>
+            <div>
+              {getFieldDecorator("attribs", {
+                initialValue: record.attribs
+                  ? JSON.stringify(record.attribs, null, 4)
+                  : ""
+              })(
+                <Input.TextArea
+                  placeholder="{}"
+                  rows={10}
+                  readOnly={false}
+                  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>
+          </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.handleDeleteRecord(record)
+                  }}
+                >
+                  <Button icon="delete">Delete</Button>
+                </Popconfirm>
+              )}
+            </Form.Item>
+          )}
+        </Form>
+      </Spin>
+    )
+  }
 }
 
 const CreateForm = Form.create()(CreateFormDef)
 
 class Subscriber extends React.PureComponent {
-    state = {
-        loading: true,
-        formRef: null,
-        record: {},
-        subID: this.props.route.match.params ? parseInt(this.props.route.match.params.subID, 10) : 0,
-    }
+  state = {
+    loading: true,
+    formRef: null,
+    record: {},
+    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) {
-            // Fetch lists.
-            this.props.modelRequest(cs.ModelLists, cs.Routes.GetLists, cs.MethodGet)
+  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) {
+      // Fetch lists.
+      this.props.modelRequest(cs.ModelLists, cs.Routes.GetLists, cs.MethodGet)
 
-            // Fetch subscriber.
-            this.fetchRecord(this.state.subID)
-        } else {
-            this.setState({ record: this.props.record, loading: false })
-        }
+      // Fetch subscriber.
+      this.fetchRecord(this.state.subID)
+    } else {
+      this.setState({ record: this.props.record, loading: false })
     }
+  }
 
-    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 })
+  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
         })
-    }
+      })
+  }
 
-    setFormRef = (r) => {
-        this.setState({ formRef: r })
-    }
+  setFormRef = r => {
+    this.setState({ formRef: r })
+  }
 
-    submitForm = (e, cb) => {
-        if(this.state.formRef) {
-            this.state.formRef.handleSubmit(e, cb)
-        }
+  submitForm = (e, cb) => {
+    if (this.state.formRef) {
+      this.state.formRef.handleSubmit(e, cb)
     }
+  }
 
-    render() {
-        return (
-            <section className="content">
-                <header className="header">
-                    <Row>
-                        <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>
-                            }
-                        </Col>
-                        <Col span={ 2 }>
-                        </Col>
-                    </Row>
-                </header>
+  render() {
+    return (
+      <section className="content">
+        <header className="header">
+          <Row>
+            <Col span={20}>
+              {!this.state.record.id && <h1>Add subscriber</h1>}
+              {this.state.record.id && (
                 <div>
-                    <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) {
-                                    return
-                                }
-
-                                // Save the form's reference so that when this component
-                                // is used as a modal, the invoker of the model can submit
-                                // it via submitForm()
-                                this.setState({ formRef: r })
-                            }}
-                        />
-                    </Spin>
+                  <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>
-            </section>
-        )
-    }
+              )}
+            </Col>
+            <Col span={2} />
+          </Row>
+        </header>
+        <div>
+          <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) {
+                  return
+                }
+
+                // Save the form's reference so that when this component
+                // is used as a modal, the invoker of the model can submit
+                // it via submitForm()
+                this.setState({ formRef: r })
+              }}
+            />
+          </Spin>
+        </div>
+      </section>
+    )
+  }
 }
 
 export default Subscriber

+ 781 - 550
frontend/my/src/Subscribers.js

@@ -1,608 +1,839 @@
 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 {
-    state = {
-        modalWaiting: false
+  state = {
+    modalWaiting: false
+  }
+
+  // Handle create / edit form submission.
+  handleSubmit = e => {
+    e.preventDefault()
+
+    var err = null,
+      values = {}
+    this.props.form.validateFields((e, v) => {
+      err = e
+      values = v
+    })
+    if (err) {
+      return
     }
 
-    // Handle create / edit form submission.
-    handleSubmit = (e) => {
-        e.preventDefault()
-
-        var err = null, values = {}
-        this.props.form.validateFields((e, v) => {
-            err = e
-            values = v
-        })
-        if(err) {
-            return
-        }
-
-        if(this.props.allRowsSelected) {
-            values["list_ids"] = this.props.listIDs
-            values["query"] = this.props.query
-        } else {
-            values["ids"] = this.props.selectedRows.map(r => r.id)
-        }
+    if (this.props.allRowsSelected) {
+      values["list_ids"] = this.props.listIDs
+      values["query"] = this.props.query
+    } else {
+      values["ids"] = this.props.selectedRows.map(r => r.id)
+    }
 
-        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.clearSelectedRows()
-            this.props.fetchRecords()
-            this.setState({ modalWaiting: false })
-            this.props.onClose()
-        }).catch(e => {
-            notification["error"]({ message: "Error", description: e.message })
-            this.setState({ modalWaiting: false })
+    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.clearSelectedRows()
+        this.props.fetchRecords()
+        this.setState({ modalWaiting: false })
+        this.props.onClose()
+      })
+      .catch(e => {
+        notification["error"]({ message: "Error", description: e.message })
+        this.setState({ modalWaiting: false })
+      })
+  }
+
+  render() {
+    const { getFieldDecorator } = this.props.form
+    const formItemLayout = {
+      labelCol: { xs: { span: 16 }, sm: { span: 4 } },
+      wrapperCol: { xs: { span: 16 }, sm: { span: 18 } }
     }
 
-    render() {
-        const { getFieldDecorator } = this.props.form
-        const formItemLayout = {
-            labelCol: { xs: { span: 16 }, sm: { span: 4 } },
-            wrapperCol: { xs: { span: 16 }, sm: { span: 18 } }
-        }
-
-        return (
-            <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 }>
-                    <Form.Item {...formItemLayout} label="Action">
-                        {getFieldDecorator("action", {
-                            initialValue: "add",
-                            rules: [{ required: true }]
-                        })(
-                            <Radio.Group>
-                                <Radio value="add">Add</Radio>
-                                <Radio value="remove">Remove</Radio>
-                                <Radio value="unsubscribe">Mark as unsubscribed</Radio>
-                            </Radio.Group>
-                        )}
-                    </Form.Item>
-                    <Form.Item {...formItemLayout} label="Lists">
-                        {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 }
-                                    </Select.Option>
-                                )}
-                            </Select>
-                        )}
-                    </Form.Item>
-                </Form>
-            </Modal>
-        )
-    }
+    return (
+      <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}>
+          <Form.Item {...formItemLayout} label="Action">
+            {getFieldDecorator("action", {
+              initialValue: "add",
+              rules: [{ required: true }]
+            })(
+              <Radio.Group>
+                <Radio value="add">Add</Radio>
+                <Radio value="remove">Remove</Radio>
+                <Radio value="unsubscribe">Mark as unsubscribed</Radio>
+              </Radio.Group>
+            )}
+          </Form.Item>
+          <Form.Item {...formItemLayout} label="Lists">
+            {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}
+                  </Select.Option>
+                ))}
+              </Select>
+            )}
+          </Form.Item>
+        </Form>
+      </Modal>
+    )
+  }
 }
 
 const ListsForm = Form.create()(ListsFormDef)
 
 class Subscribers extends React.PureComponent {
-    defaultPerPage = 20
-
-    state = {
-        formType: null,
-        listsFormVisible: false,
-        modalForm: null,
-        record: {},
-        queryParams: {
-            page: 1,
-            total: 0,
-            perPage: this.defaultPerPage,
-            listID: this.props.route.match.params.listID ? parseInt(this.props.route.match.params.listID, 10) : 0,
-            list: null,
-            query: null,
-            targetLists: []
-        },
-        listModalVisible: false,
-        allRowsSelected: false,
-        selectedRows: []
+  defaultPerPage = 20
+
+  state = {
+    formType: null,
+    listsFormVisible: false,
+    modalForm: null,
+    record: {},
+    queryParams: {
+      page: 1,
+      total: 0,
+      perPage: this.defaultPerPage,
+      listID: this.props.route.match.params.listID
+        ? parseInt(this.props.route.match.params.listID, 10)
+        : 0,
+      list: null,
+      query: null,
+      targetLists: []
+    },
+    listModalVisible: false,
+    allRowsSelected: false,
+    selectedRows: []
+  }
+
+  // Pagination config.
+  paginationOptions = {
+    hideOnSinglePage: true,
+    showSizeChanger: true,
+    showQuickJumper: true,
+    defaultPageSize: this.defaultPerPage,
+    pageSizeOptions: ["20", "50", "70", "100"],
+    position: "both",
+    showTotal: (total, range) => `${range[0]} to ${range[1]} of ${total}`,
+    onChange: (page, perPage) => {
+      this.fetchRecords({ page: page, per_page: perPage })
+    },
+    onShowSizeChange: (page, perPage) => {
+      this.fetchRecords({ page: page, per_page: perPage })
     }
+  }
+
+  constructor(props) {
+    super(props)
+
+    // Table layout.
+    this.columns = [
+      {
+        title: "E-mail",
+        dataIndex: "email",
+        sorter: true,
+        width: "25%",
+        render: (text, record) => {
+          const out = []
+          out.push(
+            <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) {
+                    this.handleShowEditForm(record)
+                    e.preventDefault()
+                  }
+                }}
+              >
+                {text}
+              </Link>
+            </div>
+          )
+
+          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>
+              )
+            }
+          }
 
-    // Pagination config.
-    paginationOptions = {
-        hideOnSinglePage: true,
-        showSizeChanger: true,
-        showQuickJumper: true,
-        defaultPageSize: this.defaultPerPage,
-        pageSizeOptions: ["20", "50", "70", "100"],
-        position: "both",
-        showTotal: (total, range) => `${range[0]} to ${range[1]} of ${total}`,
-        onChange: (page, perPage) => {
-            this.fetchRecords({ page: page, per_page: perPage })
-        },
-        onShowSizeChange: (page, perPage) => {
-            this.fetchRecords({ page: page, per_page: perPage })
+          return out
         }
-    }
-
-    constructor(props) {
-        super(props)
-
-        // Table layout.
-        this.columns = [{
-            title: "E-mail",
-            dataIndex: "email",
-            sorter: true,
-            width: "25%",
-            render: (text, record) => {
-                const out = [];
-                out.push(
-                    <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) {
-                                this.handleShowEditForm(record)
-                                e.preventDefault()
-                            }
-                        }}>{ text }</Link>
-                    </div>
-                )
-                
-                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>)
-                    }
+      },
+      {
+        title: "Name",
+        dataIndex: "name",
+        sorter: true,
+        width: "15%",
+        render: (text, record) => {
+          return (
+            <Link
+              to={`/subscribers/${record.id}`}
+              onClick={e => {
+                // Open the individual subscriber page on ctrl+click
+                // and the modal otherwise.
+                if (!e.ctrlKey) {
+                  this.handleShowEditForm(record)
+                  e.preventDefault()
                 }
-
-                return out
-            }
-        },
-        {
-            title: "Name",
-            dataIndex: "name",
-            sorter: true,
-            width: "15%",
-            render: (text, record) => {
-                return (
-                    <Link to={ `/subscribers/${record.id}` } onClick={(e) => {
-                        // Open the individual subscriber page on ctrl+click
-                        // and the modal otherwise.
-                        if(!e.ctrlKey) {
-                            this.handleShowEditForm(record)
-                            e.preventDefault()
-                        }
-                    }}>{ text }</Link>
-                )
-            }
-        },
-        {
-            title: "Status",
-            dataIndex: "status",
-            width: "5%",
-            render: (status, _) => {
-                return <Tag color={ tagColors.hasOwnProperty(status) ? tagColors[status] : "" }>{ status }</Tag>
-            }
-        },
-        {
-            title: "Lists",
-            dataIndex: "lists",
-            width: "10%",
-            align: "center",
-            render: (lists, _) => {
-                return <span>{ lists.reduce((def, item) => def + (item.subscription_status !== cs.SubscriptionStatusUnsubscribed ? 1 : 0), 0) }</span>
-            }
-        },
-        {
-            title: "Created",
-            width: "10%",
-            dataIndex: "created_at",
-            render: (date, _) => {
-                return Utils.DateString(date)
-            }
-        },
-        {
-            title: "Updated",
-            width: "10%",
-            dataIndex: "updated_at",
-            render: (date, _) => {
-                return Utils.DateString(date)
-            }
-        },
-        {
-            title: "",
-            dataIndex: "actions",
-            width: "10%",
-            render: (text, record) => {
-                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>
-                        </Popconfirm>
-                    </div>
-                )
-            }
+              }}
+            >
+              {text}
+            </Link>
+          )
         }
-        ]
-    }
-
-    componentDidMount() {
-        // Load lists on boot.
-        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 }})
-                        return false
-                    }
-                })
-            }
-        })
-
-        this.fetchRecords()
-    }
-
-    fetchRecords = (params) => {
-        let qParams = {
-            page: this.state.queryParams.page,
-            per_page: this.state.queryParams.per_page,
-            list_id: this.state.queryParams.listID,
-            query: this.state.queryParams.query
+      },
+      {
+        title: "Status",
+        dataIndex: "status",
+        width: "5%",
+        render: (status, _) => {
+          return (
+            <Tag
+              color={tagColors.hasOwnProperty(status) ? tagColors[status] : ""}
+            >
+              {status}
+            </Tag>
+          )
         }
-
-        // The records are for a specific list.
-        if(this.state.queryParams.listID) {
-            qParams.list_id = this.state.queryParams.listID
+      },
+      {
+        title: "Lists",
+        dataIndex: "lists",
+        width: "10%",
+        align: "center",
+        render: (lists, _) => {
+          return (
+            <span>
+              {lists.reduce(
+                (def, item) =>
+                  def +
+                  (item.subscription_status !==
+                  cs.SubscriptionStatusUnsubscribed
+                    ? 1
+                    : 0),
+                0
+              )}
+            </span>
+          )
         }
-
-        if(params) {
-            qParams = { ...qParams, ...params }
+      },
+      {
+        title: "Created",
+        width: "10%",
+        dataIndex: "created_at",
+        render: (date, _) => {
+          return Utils.DateString(date)
         }
+      },
+      {
+        title: "Updated",
+        width: "10%",
+        dataIndex: "updated_at",
+        render: (date, _) => {
+          return Utils.DateString(date)
+        }
+      },
+      {
+        title: "",
+        dataIndex: "actions",
+        width: "10%",
+        render: (text, record) => {
+          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>
+              </Popconfirm>
+            </div>
+          )
+        }
+      }
+    ]
+  }
+
+  componentDidMount() {
+    // Load lists on boot.
+    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 }
+              })
+              return false
+            }
+          })
+        }
+      })
 
-        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,
-            }})
-        })
-    }
-
-    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` })
-
-                // Reload the table.
-                this.fetchRecords()
-            }).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) })
-            .then(() => {
-                notification["success"]({ message: "Subscriber(s) deleted", description: "Selected subscribers deleted" })
-
-                // Reload the table.
-                this.fetchRecords()
-            }).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) })
-            .then(() => {
-                notification["success"]({ message: "Subscriber(s) blacklisted", description: "Selected subscribers blacklisted" })
+    this.fetchRecords()
+  }
 
-                // Reload the table.
-                this.fetchRecords()
-            }).catch(e => {
-                notification["error"]({ message: "Error", description: e.message })
-            })
+  fetchRecords = params => {
+    let qParams = {
+      page: this.state.queryParams.page,
+      per_page: this.state.queryParams.per_page,
+      list_id: this.state.queryParams.listID,
+      query: this.state.queryParams.query
     }
 
-    // Arbitrary query based calls.
-    handleDeleteRecordsByQuery = (listIDs, 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" })
-
-                // Reload the table.
-                this.fetchRecords()
-            }).catch(e => {
-                notification["error"]({ message: "Error", description: e.message })
-            })
+    // The records are for a specific list.
+    if (this.state.queryParams.listID) {
+      qParams.list_id = this.state.queryParams.listID
     }
 
-    handleBlacklistSubscribersByQuery = (listIDs, 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" })
-
-                // Reload the table.
-                this.fetchRecords()
-            }).catch(e => {
-                notification["error"]({ message: "Error", description: e.message })
-            })
+    if (params) {
+      qParams = { ...qParams, ...params }
     }
 
-    handleQuerySubscribersIntoLists = (query, sourceList, targetLists) => {
-        let params = {
-            query: query,
-            source_list: sourceList,
-            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.handleToggleListModal()
-        }).catch(e => {
-            notification["error"]({ message: "Error", description: e.message })
+    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
+          }
+        })
+      })
+  }
+
+  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`
         })
-    }
-
-    handleHideForm = () => {
-        this.setState({ formType: null })
-    }
-
-    handleShowCreateForm = () => {
-        this.setState({ formType: cs.FormCreate, attribs: [], record: {} })
-    }
-
-    handleShowEditForm = (record) => {
-        this.setState({ formType: cs.FormEdit, record: record })
-    }
 
-    handleToggleListsForm = () => {
-        this.setState({ listsFormVisible: !this.state.listsFormVisible })
-    }
+        // Reload the table.
+        this.fetchRecords()
+      })
+      .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) }
+      )
+      .then(() => {
+        notification["success"]({
+          message: "Subscriber(s) deleted",
+          description: "Selected subscribers deleted"
+        })
 
-    handleSearch = (q) => {
-        q = q.trim().toLowerCase()
-        if(q === "") {
-            this.fetchRecords({ query: null })
-            return
-        }
+        // Reload the table.
+        this.fetchRecords()
+      })
+      .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)
+      })
+      .then(() => {
+        notification["success"]({
+          message: "Subscriber(s) blacklisted",
+          description: "Selected subscribers blacklisted"
+        })
 
-        q = q.replace(/'/g, "''")
-        const query = `(name ~* '${q}' OR email ~* '${q}')`
-        this.fetchRecords({ query: query })
-    }
+        // Reload the table.
+        this.fetchRecords()
+      })
+      .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 }
+      )
+      .then(() => {
+        notification["success"]({
+          message: "Subscriber(s) deleted",
+          description: "Selected subscribers have been deleted"
+        })
 
-    handleSelectRow = (_, records) => {
-        this.setState({ allRowsSelected: false, selectedRows: records })
-    }
+        // Reload the table.
+        this.fetchRecords()
+      })
+      .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
+      })
+      .then(() => {
+        notification["success"]({
+          message: "Subscriber(s) blacklisted",
+          description: "Selected subscribers have been blacklisted"
+        })
 
-    handleSelectAllRows = () => {
-        this.setState({ allRowsSelected: true,
-                        selectedRows: this.props.data[cs.ModelSubscribers].results })
+        // Reload the table.
+        this.fetchRecords()
+      })
+      .catch(e => {
+        notification["error"]({ message: "Error", description: e.message })
+      })
+  }
+
+  handleQuerySubscribersIntoLists = (query, sourceList, targetLists) => {
+    let params = {
+      query: query,
+      source_list: sourceList,
+      target_lists: targetLists
     }
 
-    clearSelectedRows = (_, records) => {
-        this.setState({ allRowsSelected: false, selectedRows: [] })
+    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 => {
+        notification["error"]({ message: "Error", description: e.message })
+      })
+  }
+
+  handleHideForm = () => {
+    this.setState({ formType: null })
+  }
+
+  handleShowCreateForm = () => {
+    this.setState({ formType: cs.FormCreate, attribs: [], record: {} })
+  }
+
+  handleShowEditForm = record => {
+    this.setState({ formType: cs.FormEdit, record: record })
+  }
+
+  handleToggleListsForm = () => {
+    this.setState({ listsFormVisible: !this.state.listsFormVisible })
+  }
+
+  handleSearch = q => {
+    q = q.trim().toLowerCase()
+    if (q === "") {
+      this.fetchRecords({ query: null })
+      return
     }
 
-    handleToggleQueryForm = () => {
-        this.setState({ queryFormVisible: !this.state.queryFormVisible })
+    q = q.replace(/'/g, "''")
+    const query = `(name ~* '${q}' OR email ~* '${q}')`
+    this.fetchRecords({ query: query })
+  }
+
+  handleSelectRow = (_, records) => {
+    this.setState({ allRowsSelected: false, selectedRows: records })
+  }
+
+  handleSelectAllRows = () => {
+    this.setState({
+      allRowsSelected: true,
+      selectedRows: this.props.data[cs.ModelSubscribers].results
+    })
+  }
+
+  clearSelectedRows = (_, records) => {
+    this.setState({ allRowsSelected: false, selectedRows: [] })
+  }
+
+  handleToggleQueryForm = () => {
+    this.setState({ queryFormVisible: !this.state.queryFormVisible })
+  }
+
+  handleToggleListModal = () => {
+    this.setState({ listModalVisible: !this.state.listModalVisible })
+  }
+
+  render() {
+    const pagination = {
+      ...this.paginationOptions,
+      ...this.state.queryParams
     }
 
-    handleToggleListModal = () => {
-        this.setState({ listModalVisible: !this.state.listModalVisible })
+    if (this.state.queryParams.list) {
+      this.props.pageTitle(this.state.queryParams.list.name + " / Subscribers")
+    } else {
+      this.props.pageTitle("Subscribers")
     }
 
-    render() {
-        const pagination = {
-            ...this.paginationOptions,
-            ...this.state.queryParams
-        }
-
-        if(this.state.queryParams.list) {
-            this.props.pageTitle(this.state.queryParams.list.name + " / Subscribers")
-        } else {
-            this.props.pageTitle("Subscribers")
-        }
-
-        return (
-            <section className="content">
-                <header className="header">
-                    <Row>
-                        <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> }
-                            </h1>
-                        </Col>
-                        <Col span={ 2 }>
-                            <Button type="primary" icon="plus" onClick={ this.handleShowCreateForm }>Add subscriber</Button>
-                        </Col>
-                    </Row>
-                </header>
-                
-                <div className="subscriber-query">
-                    <Row>
-                        <Col span={10}>
-                            <Row>
-                                <Col span={ 15 }>
-                                    <label>Search subscribers</label>
-                                    <Input.Search
-                                        name="name"
-                                        placeholder="Name or e-mail"
-                                        enterButton
-                                        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>
-                            </Row>
-                            { this.state.queryFormVisible &&
-                                <div className="advanced-query">
-                                    <p>
-                                        <label>Advanced query</label>
-                                        <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 } })
-                                            }}
-                                            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.
-                                        </span>
-                                    </p>
-                                    <p>
-                                        <Button
-                                            disabled={ this.state.queryParams.query === "" }
-                                            type="primary"
-                                            icon="search"
-                                            onClick={ () => { this.fetchRecords() } }>Query</Button>
-                                        {" "}
-                                        <Button
-                                            disabled={ this.state.queryParams.query === "" }
-                                            icon="refresh"
-                                            onClick={ () => { this.fetchRecords({ query: null }) } }>Reset</Button>
-                                    </p>
-                                </div>
-                            }
-                        </Col>
-                        <Col span={14}>
-                            { 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>
-                                            </span>
-                                        }
-                                    </p>
-                                    <p>
-                                        <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)
-                                                    this.clearSelectedRows()
-                                                } else {
-                                                    this.handleDeleteRecords(this.state.selectedRows)
-                                                    this.clearSelectedRows()
-                                                }
-                                            }}>
-                                            <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)
-                                                    this.clearSelectedRows()
-                                                } else {
-                                                    this.handleBlacklistSubscribers(this.state.selectedRows)
-                                                    this.clearSelectedRows()
-                                                }
-                                            }}>
-                                            <a role="button"><Icon type="close" /> Blacklist</a>
-                                        </Popconfirm>
-                                    </p>
-                                </nav>
-                            }
-                        </Col>
-                    </Row>
+    return (
+      <section className="content">
+        <header className="header">
+          <Row>
+            <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>
+                )}
+              </h1>
+            </Col>
+            <Col span={2}>
+              <Button
+                type="primary"
+                icon="plus"
+                onClick={this.handleShowCreateForm}
+              >
+                Add subscriber
+              </Button>
+            </Col>
+          </Row>
+        </header>
+
+        <div className="subscriber-query">
+          <Row>
+            <Col span={10}>
+              <Row>
+                <Col span={15}>
+                  <label>Search subscribers</label>
+                  <Input.Search
+                    name="name"
+                    placeholder="Name or e-mail"
+                    enterButton
+                    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>
+              </Row>
+              {this.state.queryFormVisible && (
+                <div className="advanced-query">
+                  <p>
+                    <label>Advanced query</label>
+                    <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
+                          }
+                        })
+                      }}
+                      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.
+                    </span>
+                  </p>
+                  <p>
+                    <Button
+                      disabled={this.state.queryParams.query === ""}
+                      type="primary"
+                      icon="search"
+                      onClick={() => {
+                        this.fetchRecords()
+                      }}
+                    >
+                      Query
+                    </Button>{" "}
+                    <Button
+                      disabled={this.state.queryParams.query === ""}
+                      icon="refresh"
+                      onClick={() => {
+                        this.fetchRecords({ query: null })
+                      }}
+                    >
+                      Reset
+                    </Button>
+                  </p>
                 </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 = {{
-                        columnWidth: "5%",
-                        onChange: this.handleSelectRow,
-                        selectedRowKeys: this.state.selectedRows.map(r => `sub-${r.id}`)
-                    }}
-                />
-
-                { 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;
-                            }
-
-                            // This submits the form embedded in the Subscriber component.
-                            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) {
-                                    return
-                                }
-        
-                                this.setState({ modalForm: r })
-                            }}/>
-                    </Modal>
+              )}
+            </Col>
+            <Col span={14}>
+              {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>
+                        </span>
+                      )}
+                  </p>
+                  <p>
+                    <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
+                          )
+                          this.clearSelectedRows()
+                        } else {
+                          this.handleDeleteRecords(this.state.selectedRows)
+                          this.clearSelectedRows()
+                        }
+                      }}
+                    >
+                      <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
+                          )
+                          this.clearSelectedRows()
+                        } else {
+                          this.handleBlacklistSubscribers(
+                            this.state.selectedRows
+                          )
+                          this.clearSelectedRows()
+                        }
+                      }}
+                    >
+                      <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={{
+            columnWidth: "5%",
+            onChange: this.handleSelectRow,
+            selectedRowKeys: this.state.selectedRows.map(r => `sub-${r.id}`)
+          }}
+        />
+
+        {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
+              }
+
+              // This submits the form embedded in the Subscriber component.
+              this.state.modalForm.submitForm(e, ok => {
+                if (ok) {
+                  this.handleHideForm()
+                  this.fetchRecords()
                 }
-
-                { 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 } />
+              })
+            }}
+            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
                 }
-            </section>
-        )
-    }
+
+                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}
+          />
+        )}
+      </section>
+    )
+  }
 }
 
 export default Subscribers

+ 417 - 265
frontend/my/src/Templates.js

@@ -1,287 +1,439 @@
 import React from "react"
-import { Row, Col, Modal, Form, Input, Button, Table, Icon, Tooltip, Tag, Popconfirm, Spin, notification } from "antd"
+import {
+  Row,
+  Col,
+  Modal,
+  Form,
+  Input,
+  Button,
+  Table,
+  Icon,
+  Tooltip,
+  Tag,
+  Popconfirm,
+  Spin,
+  notification
+} from "antd"
 
 import ModalPreview from "./ModalPreview"
 import Utils from "./utils"
 import * as cs from "./constants"
 
 class CreateFormDef extends React.PureComponent {
-    state = {
-        confirmDirty: false,
-        modalWaiting: false,
-        previewName: "",
-        previewBody: ""
-    }
-
-    // Handle create / edit form submission.
-    handleSubmit = (e) => {
-        e.preventDefault()
-        this.props.form.validateFields((err, values) => {
-            if (err) {
-                return
-            }
-
-            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.fetchRecords()
-                    this.props.onClose()
-                    this.setState({ modalWaiting: false })
-                }).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.fetchRecords()
-                    this.props.onClose()
-                    this.setState({ modalWaiting: false })
-                }).catch(e => {
-                    notification["error"]({ placement: cs.MsgPosition, message: "Error", description: e.message })
-                    this.setState({ modalWaiting: false })
-                })
-            }
-        })
-    }
-
-    handleConfirmBlur = (e) => {
-        const value = e.target.value
-        this.setState({ confirmDirty: this.state.confirmDirty || !!value })
+  state = {
+    confirmDirty: false,
+    modalWaiting: false,
+    previewName: "",
+    previewBody: ""
+  }
+
+  // Handle create / edit form submission.
+  handleSubmit = e => {
+    e.preventDefault()
+    this.props.form.validateFields((err, values) => {
+      if (err) {
+        return
+      }
+
+      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.fetchRecords()
+            this.props.onClose()
+            this.setState({ modalWaiting: false })
+          })
+          .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.fetchRecords()
+            this.props.onClose()
+            this.setState({ modalWaiting: false })
+          })
+          .catch(e => {
+            notification["error"]({
+              placement: cs.MsgPosition,
+              message: "Error",
+              description: e.message
+            })
+            this.setState({ modalWaiting: false })
+          })
+      }
+    })
+  }
+
+  handleConfirmBlur = e => {
+    const value = e.target.value
+    this.setState({ confirmDirty: this.state.confirmDirty || !!value })
+  }
+
+  handlePreview = (name, body) => {
+    this.setState({ previewName: name, previewBody: body })
+  }
+
+  render() {
+    const { formType, record, onClose } = this.props
+    const { getFieldDecorator } = this.props.form
+
+    const formItemLayout = {
+      labelCol: { xs: { span: 16 }, sm: { span: 4 } },
+      wrapperCol: { xs: { span: 16 }, sm: { span: 18 } }
     }
 
-    handlePreview = (name, body) => {
-        this.setState({ previewName: name, previewBody: body })
+    if (formType === null) {
+      return null
     }
 
-    render() {
-        const { formType, record, onClose } = this.props
-        const { getFieldDecorator } = this.props.form
-
-        const formItemLayout = {
-            labelCol: { xs: { span: 16 }, sm: { span: 4 } },
-            wrapperCol: { xs: { span: 16 }, sm: { span: 18 } }
-        }
-
-        if (formType === null) {
-            return null
-        }
-
-        return (
-            <div>
-                <Modal visible={ true } title={ formType === cs.FormCreate ? "Add template" : record.name }
-                    okText={ this.state.form === cs.FormCreate ? "Add" : "Save" }
-                    width="90%"
-                    height={ 900 }
-                    confirmLoading={ this.state.modalWaiting }
-                    onCancel={ onClose }
-                    onOk={ this.handleSubmit }>
-
-                    <Spin spinning={ this.props.reqStates[cs.ModelTemplates] === cs.StatePending }>
-                        <Form onSubmit={this.handleSubmit}>
-                            <Form.Item {...formItemLayout} label="Name">
-                                {getFieldDecorator("name", {
-                                    initialValue: record.name,
-                                    rules: [{ required: true }]
-                                })(<Input autoFocus maxLength="200" />)}
-                            </Form.Item>
-                            <Form.Item {...formItemLayout} name="body" label="Raw HTML">
-                                {getFieldDecorator("body", { initialValue: record.body ? record.body : "", rules: [{ required: true }] })(
-                                    <Input.TextArea autosize={{ minRows: 10, maxRows: 30 }} />
-                                )}
-                            </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="18" className="text-grey text-small">
-                            The placeholder <code>{'{'}{'{'} template "content" . {'}'}{'}'}</code> should appear in the template. <a href="" target="_blank">Read more on templating</a>.
-                        </Col>
-                    </Row>
-                </Modal>
-
-                { this.state.previewBody &&
-                    <ModalPreview
-                        title={ this.state.previewName ? this.state.previewName : "Template preview" }
-                        previewURL={ cs.Routes.PreviewNewTemplate }
-                        body={ this.state.previewBody }
-                        onCancel={() => {
-                            this.setState({ previewBody: null, previewName: null })
-                        }}
-                    />
-                }
-            </div>
-        )
-    }
+    return (
+      <div>
+        <Modal
+          visible={true}
+          title={formType === cs.FormCreate ? "Add template" : record.name}
+          okText={this.state.form === cs.FormCreate ? "Add" : "Save"}
+          width="90%"
+          height={900}
+          confirmLoading={this.state.modalWaiting}
+          onCancel={onClose}
+          onOk={this.handleSubmit}
+        >
+          <Spin
+            spinning={
+              this.props.reqStates[cs.ModelTemplates] === cs.StatePending
+            }
+          >
+            <Form onSubmit={this.handleSubmit}>
+              <Form.Item {...formItemLayout} label="Name">
+                {getFieldDecorator("name", {
+                  initialValue: record.name,
+                  rules: [{ required: true }]
+                })(<Input autoFocus maxLength="200" />)}
+              </Form.Item>
+              <Form.Item {...formItemLayout} name="body" label="Raw HTML">
+                {getFieldDecorator("body", {
+                  initialValue: record.body ? record.body : "",
+                  rules: [{ required: true }]
+                })(<Input.TextArea autosize={{ minRows: 10, maxRows: 30 }} />)}
+              </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 span="18" className="text-grey text-small">
+              The placeholder{" "}
+              <code>
+                {"{"}
+                {"{"} template "content" . {"}"}
+                {"}"}
+              </code>{" "}
+              should appear in the template.{" "}
+              <a href="" target="_blank">
+                Read more on templating
+              </a>
+              .
+            </Col>
+          </Row>
+        </Modal>
+
+        {this.state.previewBody && (
+          <ModalPreview
+            title={
+              this.state.previewName
+                ? this.state.previewName
+                : "Template preview"
+            }
+            previewURL={cs.Routes.PreviewNewTemplate}
+            body={this.state.previewBody}
+            onCancel={() => {
+              this.setState({ previewBody: null, previewName: null })
+            }}
+          />
+        )}
+      </div>
+    )
+  }
 }
 
 const CreateForm = Form.create()(CreateFormDef)
 
 class Templates extends React.PureComponent {
-    state = {
-        formType: null,
-        record: {},
-        previewRecord: null
-    }
-
-    constructor(props) {
-        super(props)
-
-        this.columns = [{
-            title: "Name",
-            dataIndex: "name",
-            sorter: true,
-            width: "50%",
-            render: (text, record) => {
-                return (
-                    <div className="name">
-                        <a role="button" onClick={() => this.handleShowEditForm(record)}>{ text }</a>
-                        { record.is_default && 
-                            <div><Tag>Default</Tag></div>}
-                    </div>
-                )
-            }
-        },
-        {
-            title: "Created",
-            dataIndex: "created_at",
-            render: (date, _) => {
-                return Utils.DateString(date)
-            }
-        },
-        {
-            title: "Updated",
-            dataIndex: "updated_at",
-            render: (date, _) => {
-                return Utils.DateString(date)
-            }
-        },
-        {
-            title: "",
-            dataIndex: "actions",
-            width: "20%",
-            className: "actions",
-            render: (text, record) => {
-                return (
-                    <div className="actions">
-                        <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>
-                            </Popconfirm>
-                        }
-
-                        <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>
-                            </Popconfirm>
-                        }
-                    </div>
-                )
-            }
-        }]
-    }
+  state = {
+    formType: null,
+    record: {},
+    previewRecord: null
+  }
+
+  constructor(props) {
+    super(props)
+
+    this.columns = [
+      {
+        title: "Name",
+        dataIndex: "name",
+        sorter: true,
+        width: "50%",
+        render: (text, record) => {
+          return (
+            <div className="name">
+              <a role="button" onClick={() => this.handleShowEditForm(record)}>
+                {text}
+              </a>
+              {record.is_default && (
+                <div>
+                  <Tag>Default</Tag>
+                </div>
+              )}
+            </div>
+          )
+        }
+      },
+      {
+        title: "Created",
+        dataIndex: "created_at",
+        render: (date, _) => {
+          return Utils.DateString(date)
+        }
+      },
+      {
+        title: "Updated",
+        dataIndex: "updated_at",
+        render: (date, _) => {
+          return Utils.DateString(date)
+        }
+      },
+      {
+        title: "",
+        dataIndex: "actions",
+        width: "20%",
+        className: "actions",
+        render: (text, record) => {
+          return (
+            <div className="actions">
+              <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>
+                </Popconfirm>
+              )}
+
+              <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>
+                </Popconfirm>
+              )}
+            </div>
+          )
+        }
+      }
+    ]
+  }
+
+  componentDidMount() {
+    this.props.pageTitle("Templates")
+    this.fetchRecords()
+  }
+
+  fetchRecords = () => {
+    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 }
+      )
+      .then(() => {
+        notification["success"]({
+          placement: cs.MsgPosition,
+          message: "Template deleted",
+          description: `"${record.name}" deleted`
+        })
 
-    componentDidMount() {
-        this.props.pageTitle("Templates")
+        // Reload the table.
         this.fetchRecords()
-    }
-
-    fetchRecords = () => {
-        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 })
-            .then(() => {
-                notification["success"]({ placement: cs.MsgPosition, message: "Template deleted", description: `"${record.name}" deleted` })
-
-                // Reload the table.
-                this.fetchRecords()
-            }).catch(e => {
-                notification["error"]({ message: "Error", description: e.message })
-            })
-    }
-
-    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` })
-                
-                // Reload the table.
-                this.fetchRecords()
-            }).catch(e => {
-                notification["error"]({ placement: cs.MsgPosition, message: "Error", description: e.message })
-            })
-    }
-
-    handlePreview = (record) => {
-        this.setState({ previewRecord: record })
-    }
-
-    hideForm = () => {
-        this.setState({ formType: null })
-    }
-
-    handleShowCreateForm = () => {
-        this.setState({ formType: cs.FormCreate, record: {} })
-    }
-
-    handleShowEditForm = (record) => {
-        this.setState({ formType: cs.FormEdit, record: record })
-    }
+      })
+      .catch(e => {
+        notification["error"]({ message: "Error", description: e.message })
+      })
+  }
+
+  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`
+        })
 
-    render() {
-        return (
-            <section className="content templates">
-                <Row>
-                    <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>
-                    </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 }
-                />
-
-                <CreateForm { ...this.props }
-                    formType={ this.state.formType }
-                    record={ this.state.record }
-                    onClose={ this.hideForm }
-                    fetchRecords = { this.fetchRecords }
-                />
-
-                { this.state.previewRecord &&
-                    <ModalPreview
-                        title={ this.state.previewRecord.name }
-                        previewURL={ cs.Routes.PreviewTemplate.replace(":id", this.state.previewRecord.id) }
-                        onCancel={() => {
-                            this.setState({ previewRecord: null })
-                        }}
-                    />
-                }
-            </section>
-        )
-    }
+        // Reload the table.
+        this.fetchRecords()
+      })
+      .catch(e => {
+        notification["error"]({
+          placement: cs.MsgPosition,
+          message: "Error",
+          description: e.message
+        })
+      })
+  }
+
+  handlePreview = record => {
+    this.setState({ previewRecord: record })
+  }
+
+  hideForm = () => {
+    this.setState({ formType: null })
+  }
+
+  handleShowCreateForm = () => {
+    this.setState({ formType: cs.FormCreate, record: {} })
+  }
+
+  handleShowEditForm = record => {
+    this.setState({ formType: cs.FormEdit, record: record })
+  }
+
+  render() {
+    return (
+      <section className="content templates">
+        <Row>
+          <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>
+          </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}
+        />
+
+        <CreateForm
+          {...this.props}
+          formType={this.state.formType}
+          record={this.state.record}
+          onClose={this.hideForm}
+          fetchRecords={this.fetchRecords}
+        />
+
+        {this.state.previewRecord && (
+          <ModalPreview
+            title={this.state.previewRecord.name}
+            previewURL={cs.Routes.PreviewTemplate.replace(
+              ":id",
+              this.state.previewRecord.id
+            )}
+            onCancel={() => {
+              this.setState({ previewRecord: null })
+            }}
+          />
+        )}
+      </section>
+    )
+  }
 }
 
 export default Templates

+ 0 - 41
frontend/my/src/Test.js

@@ -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;

+ 64 - 64
frontend/my/src/constants.js

@@ -31,19 +31,19 @@ export const MsgPosition = "bottomRight"
 
 // Model specific.
 export const CampaignStatusColors = {
-	draft: "",
-	scheduled: "purple",
-	running: "blue",
-	paused: "orange",
-	finished: "green",
-	cancelled: "red",
+  draft: "",
+  scheduled: "purple",
+  running: "blue",
+  paused: "orange",
+  finished: "green",
+  cancelled: "red"
 }
 
-export const CampaignStatusDraft     = "draft"
+export const CampaignStatusDraft = "draft"
 export const CampaignStatusScheduled = "scheduled"
-export const CampaignStatusRunning   = "running"
-export const CampaignStatusPaused    = "paused"
-export const CampaignStatusFinished  = "finished"
+export const CampaignStatusRunning = "running"
+export const CampaignStatusPaused = "paused"
+export const CampaignStatusFinished = "finished"
 export const CampaignStatusCancelled = "cancelled"
 
 export const SubscriptionStatusConfirmed = "confirmed"
@@ -52,62 +52,62 @@ export const SubscriptionStatusUnsubscribed = "unsubscribed"
 
 // API routes.
 export const Routes = {
-    GetDashboarcStats: "/api/dashboard/stats",
-    GetUsers: "/api/users",
-    
-    // Lists.
-    GetLists: "/api/lists",
-    CreateList: "/api/lists",
-    UpdateList: "/api/lists/:id",
-    DeleteList: "/api/lists/:id",
+  GetDashboarcStats: "/api/dashboard/stats",
+  GetUsers: "/api/users",
 
-    // Subscribers.
-    ViewSubscribers: "/subscribers",
-    GetSubscribers: "/api/subscribers",
-    GetSubscriber: "/api/subscribers/:id",
-    GetSubscribersByList: "/api/subscribers/lists/:listID",
-    PreviewCampaign: "/api/campaigns/:id/preview",
-    CreateSubscriber: "/api/subscribers",
-    UpdateSubscriber: "/api/subscribers/:id",
-    DeleteSubscriber: "/api/subscribers/:id",
-    DeleteSubscribers: "/api/subscribers",
-    BlacklistSubscriber: "/api/subscribers/:id/blacklist",
-    BlacklistSubscribers: "/api/subscribers/blacklist",
-    AddSubscriberToLists: "/api/subscribers/lists/:id",
-    AddSubscribersToLists: "/api/subscribers/lists",
-    DeleteSubscribersByQuery: "/api/subscribers/query/delete",
-    BlacklistSubscribersByQuery: "/api/subscribers/query/blacklist",
-    AddSubscribersToListsByQuery: "/api/subscribers/query/lists",
-    
-    // Campaigns.
-    ViewCampaigns: "/campaigns",
-    ViewCampaign: "/campaigns/:id",
-    GetCampaignMessengers: "/api/campaigns/messengers",
-    GetCampaigns: "/api/campaigns",
-    GetCampaign: "/api/campaigns/:id",
-    GetRunningCampaignStats: "/api/campaigns/running/stats",
-    CreateCampaign: "/api/campaigns",
-    TestCampaign: "/api/campaigns/:id/test",
-    UpdateCampaign: "/api/campaigns/:id",
-    UpdateCampaignStatus: "/api/campaigns/:id/status",
-    DeleteCampaign: "/api/campaigns/:id",
+  // Lists.
+  GetLists: "/api/lists",
+  CreateList: "/api/lists",
+  UpdateList: "/api/lists/:id",
+  DeleteList: "/api/lists/:id",
 
-    // Media.
-    GetMedia: "/api/media",
-    AddMedia: "/api/media",
-    DeleteMedia: "/api/media/:id",
+  // Subscribers.
+  ViewSubscribers: "/subscribers",
+  GetSubscribers: "/api/subscribers",
+  GetSubscriber: "/api/subscribers/:id",
+  GetSubscribersByList: "/api/subscribers/lists/:listID",
+  PreviewCampaign: "/api/campaigns/:id/preview",
+  CreateSubscriber: "/api/subscribers",
+  UpdateSubscriber: "/api/subscribers/:id",
+  DeleteSubscriber: "/api/subscribers/:id",
+  DeleteSubscribers: "/api/subscribers",
+  BlacklistSubscriber: "/api/subscribers/:id/blacklist",
+  BlacklistSubscribers: "/api/subscribers/blacklist",
+  AddSubscriberToLists: "/api/subscribers/lists/:id",
+  AddSubscribersToLists: "/api/subscribers/lists",
+  DeleteSubscribersByQuery: "/api/subscribers/query/delete",
+  BlacklistSubscribersByQuery: "/api/subscribers/query/blacklist",
+  AddSubscribersToListsByQuery: "/api/subscribers/query/lists",
 
-    // Templates.
-    GetTemplates: "/api/templates",
-    PreviewTemplate: "/api/templates/:id/preview",
-    PreviewNewTemplate: "/api/templates/preview",
-    CreateTemplate: "/api/templates",
-    UpdateTemplate: "/api/templates/:id",
-    SetDefaultTemplate: "/api/templates/:id/default",
-    DeleteTemplate: "/api/templates/:id",
+  // Campaigns.
+  ViewCampaigns: "/campaigns",
+  ViewCampaign: "/campaigns/:id",
+  GetCampaignMessengers: "/api/campaigns/messengers",
+  GetCampaigns: "/api/campaigns",
+  GetCampaign: "/api/campaigns/:id",
+  GetRunningCampaignStats: "/api/campaigns/running/stats",
+  CreateCampaign: "/api/campaigns",
+  TestCampaign: "/api/campaigns/:id/test",
+  UpdateCampaign: "/api/campaigns/:id",
+  UpdateCampaignStatus: "/api/campaigns/:id/status",
+  DeleteCampaign: "/api/campaigns/:id",
 
-    // Import.
-    UploadRouteImport: "/api/import/subscribers",
-    GetRouteImportStats: "/api/import/subscribers",
-    GetRouteImportLogs: "/api/import/subscribers/logs"
+  // Media.
+  GetMedia: "/api/media",
+  AddMedia: "/api/media",
+  DeleteMedia: "/api/media/:id",
+
+  // Templates.
+  GetTemplates: "/api/templates",
+  PreviewTemplate: "/api/templates/:id/preview",
+  PreviewNewTemplate: "/api/templates/preview",
+  CreateTemplate: "/api/templates",
+  UpdateTemplate: "/api/templates/:id",
+  SetDefaultTemplate: "/api/templates/:id/default",
+  DeleteTemplate: "/api/templates/:id",
+
+  // Import.
+  UploadRouteImport: "/api/import/subscribers",
+  GetRouteImportStats: "/api/import/subscribers",
+  GetRouteImportLogs: "/api/import/subscribers/logs"
 }

+ 5 - 6
frontend/my/src/index.js

@@ -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"))

+ 67 - 44
frontend/my/src/utils.js

@@ -1,59 +1,82 @@
-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" ]
-
-    // Converts the ISO date format to a simpler form.
-    static DateString = (stamp, showTime) => {
-        if(!stamp) {
-            return ""
-        }
+  static months = [
+    "Jan",
+    "Feb",
+    "Mar",
+    "Apr",
+    "May",
+    "Jun",
+    "Jul",
+    "Aug",
+    "Sep",
+    "Oct",
+    "Nov",
+    "Dec"
+  ]
+  static days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
 
-        let d = new Date(stamp)
+  // Converts the ISO date format to a simpler form.
+  static DateString = (stamp, showTime) => {
+    if (!stamp) {
+      return ""
+    }
 
-        let out = Utils.days[d.getDay()] + ", " + d.getDate() + " " + Utils.months[d.getMonth()] + " " + d.getFullYear()
-        if(showTime) {
-            out += " " + d.getHours() + ":" + d.getMinutes()
-        }
+    let d = new Date(stamp)
+    let out =
+      Utils.days[d.getDay()] +
+      ", " +
+      d.getDate() +
+      " " +
+      Utils.months[d.getMonth()] +
+      " " +
+      d.getFullYear()
 
-        return out
+    if (showTime) {
+      out += " " + d.getHours() + ":" + d.getMinutes()
     }
 
-    // HttpError takes an axios error and returns an error dict after some sanity checks.
-    static HttpError = (err) => {
-        if (!err.response) {
-            return err
-        }
-        
-        if(!err.response.data || !err.response.data.message) {
-            return {
-                "message": err.message + " - " + err.response.request.responseURL,
-                "data": {}
-            }            
-        }
-
-        return {
-            "message": err.response.data.message,
-            "data": err.response.data.data
-        }
+    return out
+  }
+
+  // HttpError takes an axios error and returns an error dict after some sanity checks.
+  static HttpError = err => {
+    if (!err.response) {
+      return err
     }
 
-    // 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'))
+    if (!err.response.data || !err.response.data.message) {
+      return {
+        message: err.message + " - " + err.response.request.responseURL,
+        data: {}
+      }
     }
-    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'))
+
+    return {
+      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")
+    )
+  }
+  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")
+    )
+  }
 }
 
 export default Utils

+ 1 - 2
queries.go

@@ -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.