diff --git a/frontend/my/config-overrides.js b/frontend/my/config-overrides.js index da82397..b1dd4e9 100644 --- a/frontend/my/config-overrides.js +++ b/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; -}; \ No newline at end of file + javascriptEnabled: true + })(config, env) + return config +} diff --git a/frontend/my/package.json b/frontend/my/package.json index d39b1b0..ff1f721 100644 --- a/frontend/my/package.json +++ b/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" } } diff --git a/frontend/my/src/App.js b/frontend/my/src/App.js index 3bdb86b..5ff94b3 100644 --- a/frontend/my/src/App.js +++ b/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] + 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: {} + 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 + }) + } + + // 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) + } + } + + pageTitle = title => { + document.title = title + } + + render() { + if (!window.CONFIG) { + return ( +
+

+ listmonk logo +

+
+ +

+ Something's not right +

+

+ The app configuration could not be loaded. Please ensure that the + app is running and then refresh this page. +

+
+ ) } - 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 - }) - } - - // 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) - } - } - - - pageTitle = (title) => { - document.title = title - } - - render() { - if(!window.CONFIG) { - return( -
-

- listmonk logo -

-
- -

Something's not right

-

The app configuration could not be loaded. - Please ensure that the app is running and then refresh this page.

-
- ) - } - - return ( - - - - ) - } + return ( + + + + ) + } } -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]) - } - }) - } +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 + return route } export default App diff --git a/frontend/my/src/Campaign.js b/frontend/my/src/Campaign.js index 9655aca..8a2fe5a 100644 --- a/frontend/my/src/Campaign.js +++ b/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: "" + 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 + }) + } + } + + // 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 } - 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() - } + // 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") + } + + this.props.setContent(this.state.selContentType, body) + } + + render() { + return ( +
+
+ {!this.props.formDisabled && ( + + +
+

Content format

+ + {this.state.contentType !== this.state.selContentType && ( +
+ + + +
+ )} +
+ + +
+ )} +
+ { + if (!o) { + return } - } - } - 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 - }) - } - } + this.setState({ quill: o }) + document.querySelector(".ql-editor").focus() + }} + onChange={() => { + if (!this.state.quill) { + return + } - // 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); - } + this.props.setContent( + this.state.contentType, + this.state.quill.editor.root.innerHTML + ) + }} + /> - handleSelContentType = (_, e) => { - this.setState({ selContentType: e.props.value }) - } + { + if (!o) { + return + } - 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 ( -
-
- { !this.props.formDisabled && - - -
-

Content format

- - { this.state.contentType !== this.state.selContentType && -
- - - -
} -
- - -
- } -
- { - 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) - } } - /> - - { - if(!o) { - return - } - - this.setState({ rawInput: o.textAreaRef }) - }} - onChange={ (e) => { - this.props.setContent(this.state.contentType, e.target.value) - }} - /> -
- ) - } + this.setState({ rawInput: o.textAreaRef }) + }} + onChange={e => { + this.props.setContent(this.state.contentType, e.target.value) + }} + /> +
+ ) + } } 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 ") } - validateEmail = (rule, value, callback) => { - if(!value.match(/(.+?)\s<(.+?)@(.+?)>/)) { - return callback("Format should be: Your Name ") - } + callback() + } - callback() + handleSendLater = e => { + this.setState({ sendLater: e }) + } + + // 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 = () => {} } - // Handle create / edit form submission. - handleSubmit = (cb) => { - if(this.state.loading) { - return - } + this.props.form.validateFields((err, values) => { + if (err) { + return + } - if(!cb) { - // Set a fake callback. - cb = () => {} - } + 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 - 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) - }) - } - }) - } - - - 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 }) + // 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) + }) + } + }) + } + + 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 ( -
- -
- - {getFieldDecorator("name", { - extra: "This is internal and will not be visible to subscribers", - initialValue: record.name, - rules: [{ required: true }] - })()} - - - {getFieldDecorator("subject", { - initialValue: record.subject, - rules: [{ required: true }] - })()} - - - {getFieldDecorator("from_email", { - initialValue: record.from_email ? record.from_email : this.props.config.fromEmail, - rules: [{ required: true }, { validator: this.validateEmail }] - })()} - - - {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 }] - })( - - )} - - - {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 }] - })( - - )} - - - {getFieldDecorator("tags", { initialValue: record.tags })( - - )} - - - {getFieldDecorator("messenger", { initialValue: record.messenger ? record.messenger : "email" })( - - {[...this.props.config.messengers].map((v, i) => - { v } - )} - - )} - - -
- - - - {getFieldDecorator("send_later", { defaultChecked: this.props.isSingle })( - - )} - - - {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") })( - - )} - - - - - { this.props.isSingle && -
-
- - {getFieldDecorator("subscribers")( - - )} - - - - -
- } -
-
-
- - ) + if (this.record) { + this.props.pageTitle(record.name + " / Campaigns") + } else { + this.props.pageTitle("New campaign") } + + return ( +
+ +
+ + {getFieldDecorator("name", { + extra: + "This is internal and will not be visible to subscribers", + initialValue: record.name, + rules: [{ required: true }] + })( + + )} + + + {getFieldDecorator("subject", { + initialValue: record.subject, + rules: [{ required: true }] + })()} + + + {getFieldDecorator("from_email", { + initialValue: record.from_email + ? record.from_email + : this.props.config.fromEmail, + rules: [{ required: true }, { validator: this.validateEmail }] + })( + + )} + + + {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 }] + })( + + )} + + + {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 }] + })( + + )} + + + {getFieldDecorator("tags", { initialValue: record.tags })( + + )} + + + + +
+ )} + + + + ) + } } 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 - } - - 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) + 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 + } - // 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) - // Content tab? - if(document.location.hash === "#content-tab") { - this.setCurrentTab("content") - } + // 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 }) } - 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 }) + // Content tab? + if (document.location.hash === "#content-tab") { + this.setCurrentTab("content") + } + } - // 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 }) - } + setContent = (contentType, body) => { + this.setState({ contentType: contentType, body: body }) + } - handlePreview = (record) => { - this.setState({ previewRecord: record }) - } + toggleMedia = () => { + this.setState({ mediaVisible: !this.state.mediaVisible }) + } - render() { - return ( -
- - - { !this.state.record.id &&

Create a campaign

} - { this.state.record.id && -
-

- { this.state.record.status } - { this.state.record.name } -

- ID { this.state.record.id } — UUID { this.state.record.uuid } -
- } - - - { !this.state.formDisabled && !this.state.loading && -
- - {" "} + setCurrentTab = tab => { + this.setState({ currentTab: tab }) + } - { ( this.state.record.status === cs.CampaignStatusDraft && this.state.record.send_at) && - { - this.state.formRef.handleSubmit(() => { - this.props.route.history.push({ - pathname: cs.Routes.ViewCampaigns, - state: { campaign: this.state.record, campaignStatus: cs.CampaignStatusScheduled } - }) - }) - }}> - - - } + handlePreview = record => { + this.setState({ previewRecord: record }) + } - { ( this.state.record.status === cs.CampaignStatusDraft && !this.state.record.send_at) && - { - this.state.formRef.handleSubmit(() => { - this.props.route.history.push({ - pathname: cs.Routes.ViewCampaigns, - state: { campaign: this.state.record, campaignStatus: cs.CampaignStatusRunning } - }) - }) - }}> - - - } -
- } - -
-
+ render() { + return ( +
+ + + {!this.state.record.id &&

Create a campaign

} + {this.state.record.id && ( +
+

+ + {this.state.record.status} + + {this.state.record.name} +

+ + ID {this.state.record.id} — UUID{" "} + {this.state.record.uuid} + +
+ )} + + + {!this.state.formDisabled && !this.state.loading && ( +
+ {" "} + {this.state.record.status === cs.CampaignStatusDraft && + this.state.record.send_at && ( + { + this.state.formRef.handleSubmit(() => { + this.props.route.history.push({ + pathname: cs.Routes.ViewCampaigns, + state: { + campaign: this.state.record, + campaignStatus: cs.CampaignStatusScheduled + } + }) + }) + }} + > + + + )} + {this.state.record.status === cs.CampaignStatusDraft && + !this.state.record.send_at && ( + { + this.state.formRef.handleSubmit(() => { + this.props.route.history.push({ + pathname: cs.Routes.ViewCampaigns, + state: { + campaign: this.state.record, + campaignStatus: cs.CampaignStatusRunning + } + }) + }) + }} + > + + + )} +
+ )} + +
+
- { - this.setState({ currentTab: t }) - }}> - - - { - if(!r) { - return - } - // Take the editor's reference and save it in the state - // so that it's insertMedia() function can be passed to - 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 } - /> - - - - { this.state.record.id && -
- { - if(!r) { - return - } - // Take the editor's reference and save it in the state - // so that it's insertMedia() function can be passed to - 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 } - /> -
-

- -

-
-
- } - { !this.state.record.id && - - } -
-
- - - - - - { this.state.previewRecord && - { - this.setState({ previewRecord: null }) - }} - /> + { + this.setState({ currentTab: t }) + }} + > + + + { + if (!r) { + return + } + // Take the editor's reference and save it in the state + // so that it's insertMedia() function can be passed to + 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} + /> + + + + {this.state.record.id && ( +
+ { + if (!r) { + return + } + // Take the editor's reference and save it in the state + // so that it's insertMedia() function can be passed to + 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} + /> +
+

+ +

+
+
+ )} + {!this.state.record.id && } +
+ + + + + + + {this.state.previewRecord && ( + { + this.setState({ previewRecord: null }) + }} + /> + )} +
+ ) + } } export default Campaign diff --git a/frontend/my/src/Campaigns.js b/frontend/my/src/Campaigns.js index e82b089..5703ff9 100644 --- a/frontend/my/src/Campaigns.js +++ b/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 + defaultPerPage = 20 - state = { - formType: null, - pollID: -1, - queryParams: "", - stats: {}, - record: null, - previewRecord: null, - cloneName: "", - cloneModalVisible: false, - modalWaiting: false + 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 }) } + } - // 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) - constructor(props) { - super(props) - - this.columns = [{ - title: "Name", - dataIndex: "name", - sorter: true, - width: "20%", - vAlign: "top", - render: (text, record) => { - const out = []; - out.push( -
- { text }
- { record.subject } -
- ) - - if(record.tags.length > 0) { - for (let i = 0; i < record.tags.length; i++) { - out.push({ record.tags[i] }); - } - } - - return out - } - }, - { - title: "Status", - dataIndex: "status", - className: "status", - width: "10%", - render: (status, record) => { - let color = cs.CampaignStatusColors.hasOwnProperty(status) ? cs.CampaignStatusColors[status] : "" - return ( -
- {status} - {record.send_at && - Scheduled — { dayjs(record.send_at).format(cs.DateFormat) } - } -
- ) - } - }, - { - title: "Lists", - dataIndex: "lists", - width: "25%", - align: "left", - className: "lists", - render: (lists, record) => { - const out = [] - lists.forEach((l) => { - out.push( - { l.name } - ) - }) - - return out - } - }, - { - title: "Stats", - className: "stats", - width: "30%", - render: (text, record) => { - if(record.status !== cs.CampaignStatusDraft && record.status !== cs.CampaignStatusScheduled) { - return this.renderStats(record) - } - } - }, - { - title: "", - dataIndex: "actions", - className: "actions", - width: "15%", - render: (text, record) => { - return ( -
- { ( record.status === cs.CampaignStatusPaused ) && - this.handleUpdateStatus(record, cs.CampaignStatusRunning)}> - - - } - - { ( record.status === cs.CampaignStatusRunning ) && - this.handleUpdateStatus(record, cs.CampaignStatusPaused)}> - - - } - - {/* Draft with send_at */} - { ( record.status === cs.CampaignStatusDraft && record.send_at) && - this.handleUpdateStatus(record, cs.CampaignStatusScheduled) }> - - - } - - { ( record.status === cs.CampaignStatusDraft && !record.send_at) && - this.handleUpdateStatus(record, cs.CampaignStatusRunning) }> - - - } - - { ( record.status === cs.CampaignStatusPaused || record.status === cs.CampaignStatusRunning) && - this.handleUpdateStatus(record, cs.CampaignStatusCancelled)}> - - - } - - - { - this.handlePreview(record) - }}> - - - - { - let r = { ...record, lists: record.lists.map((i) => { return i.id }) } - this.handleToggleCloneForm(r) - }}> - - - { ( record.status === cs.CampaignStatusDraft || record.status === cs.CampaignStatusScheduled ) && - this.handleDeleteRecord(record)}> - - - } -
- ) - } - }] - } - - 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 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 ( -
- { !isDone && - - } - Sent - { sent >= toSend && - { toSend } - } - { sent < toSend && - { sent } / { toSend } - } -   - { record.status === cs.CampaignStatusRunning && - - } - - - { rate > 0 && - Rate{ Math.round(rate, 2) } / min - } - - Views{ record.views } - Clicks{ record.clicks } -
- Created{ dayjs(record.created_at).format(cs.DateFormat) } - - { startedAt && - Started{ dayjs(startedAt).format(cs.DateFormat) } - } - { isDone && - Ended - { dayjs(updatedAt).format(cs.DateFormat) } - - } - { startedAt && updatedAt && - Duration - { dayjs(updatedAt).from(dayjs(startedAt), true) } - - } + this.columns = [ + { + title: "Name", + dataIndex: "name", + sorter: true, + width: "20%", + vAlign: "top", + render: (text, record) => { + const out = [] + out.push( +
+ {text} +
+ {record.subject}
- ) - } + ) - 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 - } - - // The records are for a specific list. - if(this.state.queryParams.listID) { - qParams.listID = this.state.queryParams.listID - } - - if(params) { - qParams = { ...qParams, ...params } - } - - 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 (record.tags.length > 0) { + for (let i = 0; i < record.tags.length; i++) { + out.push({record.tags[i]}) } - }) + } - if(!hasRunning) { + return out + } + }, + { + title: "Status", + dataIndex: "status", + className: "status", + width: "10%", + render: (status, record) => { + let color = cs.CampaignStatusColors.hasOwnProperty(status) + ? cs.CampaignStatusColors[status] + : "" + return ( +
+ {status} + {record.send_at && ( + + Scheduled —{" "} + {dayjs(record.send_at).format(cs.DateFormat)} + + )} +
+ ) + } + }, + { + title: "Lists", + dataIndex: "lists", + width: "25%", + align: "left", + className: "lists", + render: (lists, record) => { + const out = [] + lists.forEach(l => { + out.push( + + {l.name} + + ) + }) + + return out + } + }, + { + title: "Stats", + className: "stats", + width: "30%", + render: (text, record) => { + if ( + record.status !== cs.CampaignStatusDraft && + record.status !== cs.CampaignStatusScheduled + ) { + return this.renderStats(record) + } + } + }, + { + title: "", + dataIndex: "actions", + className: "actions", + width: "15%", + render: (text, record) => { + return ( +
+ {record.status === cs.CampaignStatusPaused && ( + + this.handleUpdateStatus(record, cs.CampaignStatusRunning) + } + > + + + + + + + )} + + {record.status === cs.CampaignStatusRunning && ( + + this.handleUpdateStatus(record, cs.CampaignStatusPaused) + } + > + + + + + + + )} + + {/* Draft with send_at */} + {record.status === cs.CampaignStatusDraft && record.send_at && ( + + this.handleUpdateStatus(record, cs.CampaignStatusScheduled) + } + > + + + + + + + )} + + {record.status === cs.CampaignStatusDraft && !record.send_at && ( + + this.handleUpdateStatus(record, cs.CampaignStatusRunning) + } + > + + + + + + + )} + + {(record.status === cs.CampaignStatusPaused || + record.status === cs.CampaignStatusRunning) && ( + + this.handleUpdateStatus(record, cs.CampaignStatusCancelled) + } + > + + + + + + + )} + + + { + this.handlePreview(record) + }} + > + + + + + + { + let r = { + ...record, + lists: record.lists.map(i => { + return i.id + }) + } + this.handleToggleCloneForm(r) + }} + > + + + + + {(record.status === cs.CampaignStatusDraft || + record.status === cs.CampaignStatusScheduled) && ( + this.handleDeleteRecord(record)} + > + + + + + + + )} +
+ ) + } + } + ] + } + + 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 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 ( +
+ {!isDone && ( + + )} + + + Sent + + + {sent >= toSend && {toSend}} + {sent < toSend && ( + + {sent} / {toSend} + + )} +   + {record.status === cs.CampaignStatusRunning && ( + + )} + + + + {rate > 0 && ( + + + Rate + + {Math.round(rate, 2)} / min + + )} + + + + Views + + {record.views} + + + + Clicks + + {record.clicks} + +
+ + + Created + + {dayjs(record.created_at).format(cs.DateFormat)} + + + {startedAt && ( + + + Started + + {dayjs(startedAt).format(cs.DateFormat)} + + )} + {isDone && ( + + + Ended + + {dayjs(updatedAt).format(cs.DateFormat)} + + )} + {startedAt && updatedAt && ( + + + Duration + + + {dayjs(updatedAt).from(dayjs(startedAt), true)} + + + )} +
+ ) + } + + 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 + } + + // The records are for a specific list. + if (this.state.queryParams.listID) { + qParams.listID = this.state.queryParams.listID + } + + if (params) { + qParams = { ...qParams, ...params } + } + + 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 + } + + // 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 - } + } - // 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 + }) - 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 }) - } - - 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}` }) - - // 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` }) - - // 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` }) - - 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 }) + this.setState({ stats: stats }) }) - } + .catch(e => { + console.log(e.message) + }) + }, 3000) - handlePreview = (record) => { - this.setState({ previewRecord: record }) - } + this.setState({ pollID: pollID }) + } - render() { - const pagination = { - ...this.paginationOptions, - ...this.state.queryParams - } - - return ( -
- -

Campaigns

- - - -
-
+ 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}` + }) - record.uuid } - dataSource={ this.props.data[cs.ModelCampaigns] } - loading={ this.props.reqStates[cs.ModelCampaigns] !== cs.StateDone } - pagination={ pagination } - /> + // Reload the table. + this.fetchRecords() + }) + .catch(e => { + notification["error"]({ + placement: cs.MsgPosition, + message: "Error", + description: e.message + }) + }) + } - { this.state.previewRecord && - { - this.setState({ previewRecord: null }) - }} - /> - } + 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` + }) - { this.state.cloneModalVisible && this.state.record && - { this.handleCloneCampaign({ ...this.state.record, name: this.state.cloneName }) }}> - { - this.setState({ cloneName: e.target.value }) - }} /> - } - + // 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` + }) + + 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 ( +
+ +
+

Campaigns

+ + + + + + + +
+ +
record.uuid} + dataSource={this.props.data[cs.ModelCampaigns]} + loading={this.props.reqStates[cs.ModelCampaigns] !== cs.StateDone} + pagination={pagination} + /> + + {this.state.previewRecord && ( + { + this.setState({ previewRecord: null }) + }} + /> + )} + + {this.state.cloneModalVisible && this.state.record && ( + { + this.handleCloneCampaign({ + ...this.state.record, + name: this.state.cloneName + }) + }} + > + { + this.setState({ cloneName: e.target.value }) + }} + /> + + )} + + ) + } } export default Campaigns diff --git a/frontend/my/src/Dashboard.js b/frontend/my/src/Dashboard.js index 20c6e68..b406f4a 100644 --- a/frontend/my/src/Dashboard.js +++ b/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 ( -
-

Welcome

-
- - { this.state.stats && -
- -
- - - -

{ this.orZero(this.state.stats.subscribers.enabled) }

-
- - - -

{ this.orZero(this.state.stats.subscribers.blacklisted) }

-
- - - -

{ this.orZero(this.state.stats.orphan_subscribers) }

-
- - - - - - - -

{ this.orZero(this.state.stats.lists.public) }

-
- - - -

{ this.orZero(this.state.stats.lists.private) }

-
- - - - -
- - - - - -

- { this.state.stats.campaign_views.reduce((total, v) => total + v.count, 0) } - {' '} - views -

- - - - - -
- - - -

- { this.state.stats.link_clicks.reduce((total, v) => total + v.count, 0) } - {' '} - clicks -

- - - - - -
- - - + orZero(v) { + return v ? v : 0 + } - - - { this.campaignTypes.map((key) => - -

{ key }

- -

- { this.state.stats.campaigns.hasOwnProperty(key) ? - this.state.stats.campaigns[key] : 0 } -

- - - )} - - - - - } - - - ); - } + render() { + return ( +
+

Welcome

+
+ + {this.state.stats && ( +
+ +
+ + + +

+ {this.orZero(this.state.stats.subscribers.enabled)} +

+
+ + + +

+ {this.orZero( + this.state.stats.subscribers.blacklisted + )} +

+
+ + + +

+ {this.orZero(this.state.stats.orphan_subscribers)} +

+
+ + + + + + + +

+ {this.orZero(this.state.stats.lists.public)} +

+
+ + + +

+ {this.orZero(this.state.stats.lists.private)} +

+
+ + + + +
+ + + + + +

+ {this.state.stats.campaign_views.reduce( + (total, v) => total + v.count, + 0 + )}{" "} + views +

+ + + + + +
+ + + +

+ {this.state.stats.link_clicks.reduce( + (total, v) => total + v.count, + 0 + )}{" "} + clicks +

+ + + + + +
+ + + + + + + {this.campaignTypes.map(key => ( + + +

{key}

+ + +

+ {this.state.stats.campaigns.hasOwnProperty(key) + ? this.state.stats.campaigns[key] + : 0} +

+ + + ))} + + + + + )} + + + ) + } } -export default Dashboard; +export default Dashboard diff --git a/frontend/my/src/Dashboard.test.js b/frontend/my/src/Dashboard.test.js deleted file mode 100644 index a754b20..0000000 --- a/frontend/my/src/Dashboard.test.js +++ /dev/null @@ -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(, div); - ReactDOM.unmountComponentAtNode(div); -}); diff --git a/frontend/my/src/Import.js b/frontend/my/src/Import.js index 792da4f..f627837 100644 --- a/frontend/my/src/Import.js +++ b/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 ( - -
- - {getFieldDecorator("mode", { rules: [{ required: true }], initialValue: "subscribe" })( - { this.setState({ mode: e.target.value }) }}> - Subscribe - Blacklist - - )} - - { this.state.mode === "subscribe" && - - - {getFieldDecorator("lists", { rules: [{ required: true }] })( - - )} - - - } - { this.state.mode === "blacklist" && - -

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

-
- } - - {getFieldDecorator("delim", { - initialValue: "," - })()} - - -
- {getFieldDecorator("file", { - valuePropName: "file", - getValueFromEvent: this.normFile, - rules: [{ required: true }] - })( - -

- -

-

Click or drag a CSV or ZIP file here

-
- )} -
-
- -

For existing subscribers, the names and attributes will be overwritten with the values in the CSV.

- -
- -
- ) - } + return ( + +
+ + {getFieldDecorator("mode", { + rules: [{ required: true }], + initialValue: "subscribe" + })( + { + this.setState({ mode: e.target.value }) + }} + > + + Subscribe + + + Blacklist + + + )} + + {this.state.mode === "subscribe" && ( + + + {getFieldDecorator("lists", { rules: [{ required: true }] })( + + )} + + + )} + {this.state.mode === "blacklist" && ( + +

+ 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'. +

+
+ )} + + {getFieldDecorator("delim", { + initialValue: "," + })()} + + +
+ {getFieldDecorator("file", { + valuePropName: "file", + getValueFromEvent: this.normFile, + rules: [{ required: true }] + })( + +

+ +

+

+ Click or drag a CSV or ZIP file here +

+
+ )} +
+
+ +

+ For existing subscribers, the names and attributes will be + overwritten with the values in the CSV. +

+ +
+ +
+ ) + } } const TheForm = Form.create()(TheFormDef) class Importing extends React.PureComponent { - state = { - pollID: -1, - logs: "" - } + 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 }) + 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) - } + } + }, 1000) - 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 }) + 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 ( +
+

Importing — {this.props.importState.name}

+ {this.props.importState.status === StatusImporting && ( +

+ Import is in progress. It is safe to navigate away from this page. +

+ )} - return( -
-

Importing — { this.props.importState.name }

- { this.props.importState.status === StatusImporting && -

Import is in progress. It is safe to navigate away from this page.

- } + {this.props.importState.status !== StatusImporting && ( +

Import has finished.

+ )} - { this.props.importState.status !== StatusImporting && -

Import has finished.

- } + +
+
+
+ +
- -
-
-
- -
+
+

{this.props.importState.imported} records

+
-
-

{ this.props.importState.imported } records

-
- - { this.props.importState.status === StatusImporting && - this.stopImport()}> -

- -
- } - { this.props.importState.status === StatusStopping && -
-

-

Stopping

-
- } - { this.props.importState.status !== StatusImporting && - this.props.importState.status !== StatusStopping && -
- { this.props.importState.status !== StatusFinished && -
- { this.props.importState.status } -
-
- } - -
- -
- } -
+ {this.props.importState.status === StatusImporting && ( + this.stopImport()} + > +

+ +

+ +
+ )} + {this.props.importState.status === StatusStopping && ( +
+

+ +

+

Stopping

+
+ )} + {this.props.importState.status !== StatusImporting && + this.props.importState.status !== StatusStopping && ( +
+ {this.props.importState.status !== StatusFinished && ( +
+ {this.props.importState.status} +
+ )} -
-

Import log

- - - -
- - - - ) - } +
+ +
+ )} +
+
+ +
+

Import log

+ + + +
+ + + + ) + } } class Import extends React.PureComponent { - state = { - importState: { "status": "" } - } + 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 }) + 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 ( +
+ +
+ ) + } else if (this.state.importState.status !== StatusNone) { + // There's an import state + return ( + + ) } - componentDidMount() { - this.props.pageTitle("Import subscribers") - this.fetchimportState() - } - render() { - if( this.state.importState.status === "" ) { - // Fetching the status. - return ( -
- -
- ) - } else if ( this.state.importState.status !== StatusNone ) { - // There's an import state - return - } + return ( +
+ +
+

Import subscribers

+ + + - return ( -
- -

Import subscribers

- - - + - - +
+
+

Instructions

+

+ 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. attributes (optional) + should be a valid JSON string with double escaped quotes. +

-
-
-

Instructions

-

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. - {" "} - attributes (optional) should be a valid JSON string with double escaped quotes. -

+
+ + email, + name, + status, + attributes + +
-
- - email, - name, - status, - attributes - -
- -

Example raw CSV

-
- - email, - name, - status, - attributes - - - user1@mail.com, - "User One", - enabled, - { '"{""age"": 32, ""city"": ""Bangalore""}"' } - - - user2@mail.com, - "User Two", - blacklisted, - { '"{""age"": 25, ""occupation"": ""Time Traveller""}"' } - -
-
- - ) - } +

Example raw CSV

+
+ + email, + name, + status, + attributes + + + user1@mail.com, + "User One", + enabled, + {'"{""age"": 32, ""city"": ""Bangalore""}"'} + + + user2@mail.com, + "User Two", + blacklisted, + + {'"{""age"": 25, ""occupation"": ""Time Traveller""}"'} + + +
+
+ + ) + } } export default Import diff --git a/frontend/my/src/Layout.js b/frontend/my/src/Layout.js index 66ae2c4..6a23c28 100644 --- a/frontend/my/src/Layout.js +++ b/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 - }; + state = { + basePath: "/" + window.location.pathname.split("/")[1], + error: null, + collapsed: false + } - onCollapse = (collapsed) => { - this.setState({ collapsed }) - } + onCollapse = collapsed => { + this.setState({ collapsed }) + } - render() { - return ( - - -
- listmonk logo -
+ render() { + return ( + + +
+ + listmonk logo + +
- + + + + + Dashboard + + + + + + Lists + + + + + Subscribers + + } + > + + + All subscribers + + + + + Import + + + - Dashboard - Lists - Subscribers}> - All subscribers - Import - + + + Campaigns + + } + > + + + All campaigns + + + + + Create new + + + + + Media + + + + + Templates + + + - Campaigns}> - All campaigns - Create new - Media - Templates - + + + Settings + + } + > + + Users + + + + Settings + + + + + Logout + + + - Settings}> - Users - Settings - - Logout - -
- - - -
-
- - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - -
-
-
- - listmonk - {" "} - © 2019 { year != 2019 ? " - " + year : "" } - -
-
-
- ) - } + + +
+
+ + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + +
+ +
+ + + listmonk + {" "} + © 2019 {year !== 2019 ? " - " + year : ""} + +
+ + + ) + } } export default Base diff --git a/frontend/my/src/Lists.js b/frontend/my/src/Lists.js index 6dcef4f..b48dcf0 100644 --- a/frontend/my/src/Lists.js +++ b/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 + state = { + confirmDirty: false, + modalWaiting: false + } + + // 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.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 }) + }) + } + }) + } + + modalTitle(formType, record) { + if (formType === cs.FormCreate) { + return "Create a list" } - // Handle create / edit form submission. - handleSubmit = (e) => { - e.preventDefault() - this.props.form.validateFields((err, values) => { - if (err) { - return - } + return ( +
+ + {record.type} + {" "} + {record.name} +
+ + ID {record.id} / UUID {record.uuid} + +
+ ) + } - 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 }) - }) - } - }) + 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 } } } - modalTitle(formType, record) { - if(formType === cs.FormCreate) { - return "Create a list" - } - - return ( -
- { record.type } - {" "} - { record.name } -
- ID { record.id } / UUID { record.uuid } -
- ) + if (formType === null) { + return null } - render() { - const { formType, record, onClose } = this.props - const { getFieldDecorator } = this.props.form + return ( + +
record.uuid} + dataSource={this.props.data[cs.ModelLists]} + loading={this.props.reqStates[cs.ModelLists] !== cs.StateDone} + pagination={false} + /> - render() { - return ( -
- -

Lists ({this.props.data[cs.ModelLists].length})

- - - - -
- -
record.uuid } - dataSource={ this.props.data[cs.ModelLists] } - loading={ this.props.reqStates[cs.ModelLists] !== cs.StateDone } - pagination={ false } - /> - - - - ) - } + + + ) + } } export default Lists diff --git a/frontend/my/src/Media.js b/frontend/my/src/Media.js index 089ea67..c42d7ad 100644 --- a/frontend/my/src/Media.js +++ b/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") + componentDidMount() { + this.props.pageTitle("Media") + this.fetchRecords() + } + + 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` + }) + + // 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 } - fetchRecords = () => { - this.props.modelRequest(cs.ModelMedia, cs.Routes.GetMedia, cs.MethodGet) + this.props.insertMedia(record.uri) + 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() } - 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` }) + return false + } - // Reload the table. - this.fetchRecords() - }).catch(e => { - notification["error"]({ placement: cs.MsgPosition, message: "Error", description: e.message }) - }) + render() { + const { getFieldDecorator } = this.props.form + const formItemLayout = { + labelCol: { xs: { span: 16 }, sm: { span: 4 } }, + wrapperCol: { xs: { span: 16 }, sm: { span: 10 } } } - 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 - } + return ( + + + +
+ {getFieldDecorator("file", { + valuePropName: "file", + getValueFromEvent: this.normFile, + rules: [{ required: true }] + })( + +

+ +

+

Click or drag file here

+
+ )} +
+
+ - 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() - } - - return false - } - - render() { - const { getFieldDecorator } = this.props.form - const formItemLayout = { - labelCol: { xs: { span: 16 }, sm: { span: 4 } }, - wrapperCol: { xs: { span: 16 }, sm: { span: 10 } } - } - - return ( - -
- -
- {getFieldDecorator("file", { - valuePropName: "file", - getValueFromEvent: this.normFile, - rules: [{ required: true }] - })( - -

- -

-

Click or drag file here

-
- )} -
-
- - -
- {this.props.media && this.props.media.map((record, i) => -
- { - this.handleInsertMedia(record); - if( this.props.onCancel ) { - this.props.onCancel(); - } - } }>{ -
- - this.handleDeleteRecord(record)}> - - -
-
{ record.filename }
-
- )} -
-
- ) - } +
+ {this.props.media && + this.props.media.map((record, i) => ( +
+ { + this.handleInsertMedia(record) + if (this.props.onCancel) { + this.props.onCancel() + } + }} + > + {record.filename} + +
+ + + + + + this.handleDeleteRecord(record)} + > + + + + + + +
+
+ {record.filename} +
+
+ ))} +
+
+ ) + } } const TheForm = Form.create()(TheFormDef) class Media extends React.PureComponent { - render() { - return ( -
- -

Images

- - - + render() { + return ( +
+ +
+

Images

+ + + - - - - ) - } + + + ) + } } export default Media diff --git a/frontend/my/src/ModalPreview.js b/frontend/my/src/ModalPreview.js index c92a0f3..d67b386 100644 --- a/frontend/my/src/ModalPreview.js +++ b/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 ( - -
- - -
-
- - ) - } + // 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" + /> + + + ) + } } export default ModalPreview diff --git a/frontend/my/src/Subscriber.js b/frontend/my/src/Subscriber.js index f025896..a8ec567 100644 --- a/frontend/my/src/Subscriber.js +++ b/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 + 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() }) - 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 - } - } - - 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 }) - }) - } + return + } } - 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 }) - }) - } - - render() { - const { formType, record } = this.props; - const { getFieldDecorator } = this.props.form - - 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 ] - } - - const layout = this.props.isModal ? formItemLayoutModal : formItemLayout; - return ( - -
- - {getFieldDecorator("email", { - initialValue: record.email, - rules: [{ required: true }] - })()} - - - {getFieldDecorator("name", { - initialValue: record.name, - rules: [{ required: true }] - })()} - - - {getFieldDecorator("status", { initialValue: record.status ? record.status : "enabled", rules: [{ required: true, message: "Type is required" }] })( - - )} - - - {getFieldDecorator("lists", { initialValue: subListIDs })( - - )} - - -
- {getFieldDecorator("attribs", { - initialValue: record.attribs ? JSON.stringify(record.attribs, null, 4) : "" - })( - - )} -
-

Attributes are defined as a JSON map, for example: - {' {"age": 30, "color": "red", "is_user": true}'}. More info.

-
- { !this.props.isModal && - - - {" "} - { this.props.formType === cs.FormEdit && - { - this.handleDeleteRecord(record) - }}> - - - } - - } - -
+ 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` + }) + + 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 + + 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] + } + + const layout = this.props.isModal ? formItemLayoutModal : formItemLayout + return ( + +
+ + {getFieldDecorator("email", { + initialValue: record.email, + rules: [{ required: true }] + })()} + + + {getFieldDecorator("name", { + initialValue: record.name, + rules: [{ required: true }] + })()} + + + {getFieldDecorator("status", { + initialValue: record.status ? record.status : "enabled", + rules: [{ required: true, message: "Type is required" }] + })( + + )} + + + {getFieldDecorator("lists", { initialValue: subListIDs })( + + )} + + +
+ {getFieldDecorator("attribs", { + initialValue: record.attribs + ? JSON.stringify(record.attribs, null, 4) + : "" + })( + + )} +
+

+ Attributes are defined as a JSON map, for example: + {' {"age": 30, "color": "red", "is_user": true}'}.{" "} + More info. +

+
+ {!this.props.isModal && ( + + {" "} + {this.props.formType === cs.FormEdit && ( + { + this.handleDeleteRecord(record) + }} + > + + + )} + + )} + +
+ ) + } } 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) + + // Fetch subscriber. + this.fetchRecord(this.state.subID) + } else { + this.setState({ record: this.props.record, loading: false }) } + } - 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 }) - } - } - - 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 ( -
-
- -
- { !this.state.record.id && -

Add subscriber

- } - { this.state.record.id && -
-

- { this.state.record.status } - {" "} - { this.state.record.name } ({ this.state.record.email }) -

- ID { this.state.record.id } / UUID { this.state.record.uuid } -
- } - - - - - + render() { + return ( +
+
+ +
+ {!this.state.record.id &&

Add subscriber

} + {this.state.record.id && (
- - { - 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 }) - }} - /> - +

+ + {this.state.record.status} + {" "} + {this.state.record.name} ({this.state.record.email}) +

+ + ID {this.state.record.id} / UUID {this.state.record.uuid} +
- - ) - } + )} + + + + +
+ + { + 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 }) + }} + /> + +
+ + ) + } } export default Subscriber diff --git a/frontend/my/src/Subscribers.js b/frontend/my/src/Subscribers.js index b5415c9..adb12b8 100644 --- a/frontend/my/src/Subscribers.js +++ b/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() + 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) + } - var err = null, values = {} - this.props.form.validateFields((e, v) => { - err = e - values = v + 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` }) - if(err) { - return - } + 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 }) + }) + } - 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 }) - }) + 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 ( - -
- - {getFieldDecorator("action", { - initialValue: "add", - rules: [{ required: true }] - })( - - Add - Remove - Mark as unsubscribed - - )} - - - {getFieldDecorator("target_list_ids", { rules:[{ required: true }] })( - - )} - - -
- ) - } + return ( + +
+ + {getFieldDecorator("action", { + initialValue: "add", + rules: [{ required: true }] + })( + + Add + Remove + Mark as unsubscribed + + )} + + + {getFieldDecorator("target_list_ids", { + rules: [{ required: true }] + })( + + )} + + +
+ ) + } } const ListsForm = Form.create()(ListsFormDef) class Subscribers extends React.PureComponent { - defaultPerPage = 20 + 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: [] + 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 }) } + } - // 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) - constructor(props) { - super(props) + // Table layout. + this.columns = [ + { + title: "E-mail", + dataIndex: "email", + sorter: true, + width: "25%", + render: (text, record) => { + const out = [] + out.push( +
+ { + // Open the individual subscriber page on ctrl+click + // and the modal otherwise. + if (!e.ctrlKey) { + this.handleShowEditForm(record) + e.preventDefault() + } + }} + > + {text} + +
+ ) - // Table layout. - this.columns = [{ - title: "E-mail", - dataIndex: "email", - sorter: true, - width: "25%", - render: (text, record) => { - const out = []; - out.push( -
- { - // Open the individual subscriber page on ctrl+click - // and the modal otherwise. - if(!e.ctrlKey) { - this.handleShowEditForm(record) - e.preventDefault() - } - }}>{ text } -
- ) - - if(record.lists.length > 0) { - for (let i = 0; i < record.lists.length; i++) { - out.push( - { record.lists[i].name } - { record.lists[i].subscription_status } - ) + if (record.lists.length > 0) { + for (let i = 0; i < record.lists.length; i++) { + out.push( + + + {record.lists[i].name} + + + {" "} + {record.lists[i].subscription_status} + + + ) + } + } + + return out + } + }, + { + title: "Name", + dataIndex: "name", + sorter: true, + width: "15%", + render: (text, record) => { + return ( + { + // 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 ( - { - // Open the individual subscriber page on ctrl+click - // and the modal otherwise. - if(!e.ctrlKey) { - this.handleShowEditForm(record) - e.preventDefault() - } - }}>{ text } - ) - } - }, - { - title: "Status", - dataIndex: "status", - width: "5%", - render: (status, _) => { - return { status } - } - }, - { - title: "Lists", - dataIndex: "lists", - width: "10%", - align: "center", - render: (lists, _) => { - return { lists.reduce((def, item) => def + (item.subscription_status !== cs.SubscriptionStatusUnsubscribed ? 1 : 0), 0) } - } - }, - { - 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 ( -
- {/* */} - this.handleShowEditForm(record)}> - this.handleDeleteRecord(record)}> - - -
- ) - } + }} + > + {text} + + ) } - ] + }, + { + title: "Status", + dataIndex: "status", + width: "5%", + render: (status, _) => { + return ( + + {status} + + ) + } + }, + { + title: "Lists", + dataIndex: "lists", + width: "10%", + align: "center", + render: (lists, _) => { + return ( + + {lists.reduce( + (def, item) => + def + + (item.subscription_status !== + cs.SubscriptionStatusUnsubscribed + ? 1 + : 0), + 0 + )} + + ) + } + }, + { + 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 ( +
+ {/* */} + + this.handleShowEditForm(record)} + > + + + + this.handleDeleteRecord(record)} + > + + + + + + +
+ ) + } + } + ] + } + + 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 } - 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 - } - }) - } + // The records are for a specific list. + if (this.state.queryParams.listID) { + qParams.list_id = this.state.queryParams.listID + } + + if (params) { + qParams = { ...qParams, ...params } + } + + 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 }) + }) + } - 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 - } - - // The records are for a specific list. - if(this.state.queryParams.listID) { - qParams.list_id = this.state.queryParams.listID - } - - if(params) { - qParams = { ...qParams, ...params } - } - - 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, - }}) + 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" }) - } - 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 }) + }) + } - // 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" }) - - // 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" }) - - // 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" }) - - // 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 - } - - 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 }) + 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" }) + + // 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" + }) + + // 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" + }) + + // 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 } - handleHideForm = () => { - this.setState({ formType: null }) + 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 } - handleShowCreateForm = () => { - this.setState({ formType: cs.FormCreate, attribs: [], record: {} }) + 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 } - handleShowEditForm = (record) => { - this.setState({ formType: cs.FormEdit, record: record }) + if (this.state.queryParams.list) { + this.props.pageTitle(this.state.queryParams.list.name + " / Subscribers") + } else { + this.props.pageTitle("Subscribers") } - handleToggleListsForm = () => { - this.setState({ listsFormVisible: !this.state.listsFormVisible }) - } + return ( +
+
+ +
+

+ Subscribers + {this.props.data[cs.ModelSubscribers].total > 0 && ( + ({this.props.data[cs.ModelSubscribers].total}) + )} + {this.state.queryParams.list && ( + » {this.state.queryParams.list.name} + )} +

+ + + + + + - handleSearch = (q) => { - q = q.trim().toLowerCase() - if(q === "") { - this.fetchRecords({ query: null }) - return - } - - 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 - } - - if(this.state.queryParams.list) { - this.props.pageTitle(this.state.queryParams.list.name + " / Subscribers") - } else { - this.props.pageTitle("Subscribers") - } - - return ( -
-
- -
-

- Subscribers - { this.props.data[cs.ModelSubscribers].total > 0 && - ({ this.props.data[cs.ModelSubscribers].total }) } - { this.state.queryParams.list && - » { this.state.queryParams.list.name } } -

- - - - - - - -
- -
- - - - - {" "} - - -
- - Advanced - - - { this.state.queryFormVisible && -
-

- - { - this.setState({ queryParams: { ...this.state.queryParams, query: e.target.value } }) - }} - value={ this.state.queryParams.query } - autosize={{ minRows: 2, maxRows: 10 }} /> - - Write a partial SQL expression to query the subscribers based on their primary information or attributes. Learn more. - -

-

- - {" "} - -

-
- } - - - { this.state.selectedRows.length > 0 && - - } - - +
+ +
+ + + + {" "} + + + +
+ + Advanced + + + + {this.state.queryFormVisible && ( +
+

+ + { + this.setState({ + queryParams: { + ...this.state.queryParams, + query: e.target.value + } + }) + }} + value={this.state.queryParams.query} + autosize={{ minRows: 2, maxRows: 10 }} + /> + + Write a partial SQL expression to query the subscribers + based on their primary information or attributes. Learn + more. + +

+

+ {" "} + +

+ )} + + + {this.state.selectedRows.length > 0 && ( + + )} + + + -
`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}`) - }} - /> +
`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 && - { - if(!this.state.modalForm) { - return; - } + {this.state.formType !== null && ( + { + 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 }}> - { - if(!r) { - return - } - - this.setState({ modalForm: r }) - }}/> - + // 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 + }} + > + { + if (!r) { + return } - { this.state.listsFormVisible && - } - - ) - } + this.setState({ modalForm: r }) + }} + /> + + )} + + {this.state.listsFormVisible && ( + + )} + + ) + } } export default Subscribers diff --git a/frontend/my/src/Templates.js b/frontend/my/src/Templates.js index ddef599..866b231 100644 --- a/frontend/my/src/Templates.js +++ b/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: "" + 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 } } } - // Handle create / edit form submission. - handleSubmit = (e) => { - e.preventDefault() - this.props.form.validateFields((err, values) => { - if (err) { - return + if (formType === null) { + return null + } + + return ( +
+ + +
+ + {getFieldDecorator("name", { + initialValue: record.name, + rules: [{ required: true }] + })()} + + + {getFieldDecorator("body", { + initialValue: record.body ? record.body : "", + rules: [{ required: true }] + })()} + + {this.props.form.getFieldValue("body") !== "" && ( + + + + )} + +
+ +
+ + The placeholder{" "} + + {"{"} + {"{"} template "content" . {"}"} + {"}"} + {" "} + should appear in the template.{" "} + + Read more on templating + + . + + + - 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 }) - }) + {this.state.previewBody && ( + { - 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 } } - } - - if (formType === null) { - return null - } - - return ( -
- - - -
- - {getFieldDecorator("name", { - initialValue: record.name, - rules: [{ required: true }] - })()} - - - {getFieldDecorator("body", { initialValue: record.body ? record.body : "", rules: [{ required: true }] })( - - )} - - { this.props.form.getFieldValue("body") !== "" && - - - - } - -
- -
- - The placeholder {'{'}{'{'} template "content" . {'}'}{'}'} should appear in the template. Read more on templating. - - - - - { this.state.previewBody && - { - this.setState({ previewBody: null, previewName: null }) - }} - /> - } - - ) - } + previewURL={cs.Routes.PreviewNewTemplate} + body={this.state.previewBody} + onCancel={() => { + this.setState({ previewBody: null, previewName: null }) + }} + /> + )} + + ) + } } const CreateForm = Form.create()(CreateFormDef) class Templates extends React.PureComponent { - state = { - formType: null, - record: {}, - previewRecord: null - } + state = { + formType: null, + record: {}, + previewRecord: null + } - constructor(props) { - super(props) + constructor(props) { + super(props) - this.columns = [{ - title: "Name", - dataIndex: "name", - sorter: true, - width: "50%", - render: (text, record) => { - return ( -
- this.handleShowEditForm(record)}>{ text } - { record.is_default && -
Default
} -
- ) - } - }, - { - 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 ( -
- this.handlePreview(record)}> + this.columns = [ + { + title: "Name", + dataIndex: "name", + sorter: true, + width: "50%", + render: (text, record) => { + return ( +
+ this.handleShowEditForm(record)}> + {text} + + {record.is_default && ( +
+ Default +
+ )} +
+ ) + } + }, + { + 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 ( +
+ this.handlePreview(record)} + > + + + + - { !record.is_default && - this.handleSetDefault(record)}> - - - } + {!record.is_default && ( + this.handleSetDefault(record)} + > + + + + + + + )} - this.handleShowEditForm(record)}> + + this.handleShowEditForm(record)} + > + + + - { record.id !== 1 && - this.handleDeleteRecord(record)}> - - - } -
- ) - } - }] - } + {record.id !== 1 && ( + this.handleDeleteRecord(record)} + > + + + + + + + )} +
+ ) + } + } + ] + } - componentDidMount() { - this.props.pageTitle("Templates") + 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` + }) + + // Reload the table. this.fetchRecords() - } + }) + .catch(e => { + notification["error"]({ message: "Error", description: e.message }) + }) + } - fetchRecords = () => { - this.props.modelRequest(cs.ModelTemplates, cs.Routes.GetTemplates, cs.MethodGet) - } + 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` + }) - 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"]({ + placement: cs.MsgPosition, + message: "Error", + description: e.message + }) + }) + } - // Reload the table. - this.fetchRecords() - }).catch(e => { - notification["error"]({ message: "Error", description: e.message }) - }) - } + handlePreview = record => { + this.setState({ previewRecord: record }) + } - 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 }) - }) - } + hideForm = () => { + this.setState({ formType: null }) + } - handlePreview = (record) => { - this.setState({ previewRecord: record }) - } + handleShowCreateForm = () => { + this.setState({ formType: cs.FormCreate, record: {} }) + } - hideForm = () => { - this.setState({ formType: null }) - } + handleShowEditForm = record => { + this.setState({ formType: cs.FormEdit, record: record }) + } - handleShowCreateForm = () => { - this.setState({ formType: cs.FormCreate, record: {} }) - } + render() { + return ( +
+ +
+

Templates ({this.props.data[cs.ModelTemplates].length})

+ + + + + +
- handleShowEditForm = (record) => { - this.setState({ formType: cs.FormEdit, record: record }) - } +
record.id} + dataSource={this.props.data[cs.ModelTemplates]} + loading={this.props.reqStates[cs.ModelTemplates] !== cs.StateDone} + pagination={false} + /> - render() { - return ( -
- -

Templates ({this.props.data[cs.ModelTemplates].length})

- - - - -
+ -
record.id } - dataSource={ this.props.data[cs.ModelTemplates] } - loading={ this.props.reqStates[cs.ModelTemplates] !== cs.StateDone } - pagination={ false } - /> - - - - { this.state.previewRecord && - { - this.setState({ previewRecord: null }) - }} - /> - } - - ) - } + {this.state.previewRecord && ( + { + this.setState({ previewRecord: null }) + }} + /> + )} + + ) + } } export default Templates diff --git a/frontend/my/src/Test.js b/frontend/my/src/Test.js deleted file mode 100644 index 9234407..0000000 --- a/frontend/my/src/Test.js +++ /dev/null @@ -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 ( -
- -
- ) - } -} - -export default QuillEditor; diff --git a/frontend/my/src/constants.js b/frontend/my/src/constants.js index 009f388..06b95c1 100644 --- a/frontend/my/src/constants.js +++ b/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" } diff --git a/frontend/my/src/index.js b/frontend/my/src/index.js index 90fb7e3..7fd0cad 100644 --- a/frontend/my/src/index.js +++ b/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((), document.getElementById('root')) +ReactDOM.render(, document.getElementById("root")) diff --git a/frontend/my/src/utils.js b/frontend/my/src/utils.js index fa8a863..959c2ef 100644 --- a/frontend/my/src/utils.js +++ b/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" ] + 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 "" - } - - let d = new Date(stamp) - - let out = Utils.days[d.getDay()] + ", " + d.getDate() + " " + Utils.months[d.getMonth()] + " " + d.getFullYear() - if(showTime) { - out += " " + d.getHours() + ":" + d.getMinutes() - } - - return out + // Converts the ISO date format to a simpler form. + static DateString = (stamp, showTime) => { + if (!stamp) { + return "" } - // 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": {} - } - } + let d = new Date(stamp) + let out = + Utils.days[d.getDay()] + + ", " + + d.getDate() + + " " + + Utils.months[d.getMonth()] + + " " + + d.getFullYear() - return { - "message": err.response.data.message, - "data": err.response.data.data - } + if (showTime) { + out += " " + d.getHours() + ":" + d.getMinutes() } - // Shows a flash message. - static Alert = (msg, msgType) => { - document.getElementById('alert-container').classList.add('visible') - ReactDOM.render(, - document.getElementById('alert-container')) + return out + } + + // HttpError takes an axios error and returns an error dict after some sanity checks. + static HttpError = err => { + if (!err.response) { + return err } - static ModalAlert = (msg, msgType) => { - document.getElementById('modal-alert-container').classList.add('visible') - ReactDOM.render(, - document.getElementById('modal-alert-container')) + + 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 + } + } + + // Shows a flash message. + static Alert = (msg, msgType) => { + document.getElementById("alert-container").classList.add("visible") + ReactDOM.render( + , + document.getElementById("alert-container") + ) + } + static ModalAlert = (msg, msgType) => { + document.getElementById("modal-alert-container").classList.add("visible") + ReactDOM.render( + , + document.getElementById("modal-alert-container") + ) + } } export default Utils diff --git a/queries.go b/queries.go index b343084..53e9052 100644 --- a/queries.go +++ b/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.