Reformat all JS to 'prettier' style

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

View File

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

View File

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

View File

@ -1,13 +1,13 @@
import React from 'react' import React from "react"
import Utils from './utils' import Utils from "./utils"
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from "react-router-dom"
import { Icon, notification } from "antd" import { Icon, notification } from "antd"
import axios from 'axios' import axios from "axios"
import qs from 'qs' import qs from "qs"
import logo from "./static/listmonk.svg" import logo from "./static/listmonk.svg"
import Layout from './Layout' import Layout from "./Layout"
import * as cs from './constants' import * as cs from "./constants"
/* /*
App acts as a an "automagic" wrapper for all sub components. It is also the central App acts as a an "automagic" wrapper for all sub components. It is also the central
@ -26,144 +26,166 @@ import * as cs from './constants'
*/ */
class App extends React.PureComponent { class App extends React.PureComponent {
models = [cs.ModelUsers, models = [
cs.ModelSubscribers, cs.ModelUsers,
cs.ModelLists, cs.ModelSubscribers,
cs.ModelCampaigns, cs.ModelLists,
cs.ModelTemplates] cs.ModelCampaigns,
cs.ModelTemplates
]
state = { state = {
// Initialize empty states. // Initialize empty states.
reqStates: this.models.reduce((map, obj) => (map[obj] = cs.StatePending, map), {}), reqStates: this.models.reduce(
data: this.models.reduce((map, obj) => (map[obj] = [], map), {}), // eslint-disable-next-line
modStates: {} (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 (
<div className="broken">
<p>
<img src={logo} alt="listmonk logo" />
</p>
<hr />
<h1>
<Icon type="warning" /> Something's not right
</h1>
<p>
The app configuration could not be loaded. Please ensure that the
app is running and then refresh this page.
</p>
</div>
)
} }
componentDidMount = () => { return (
axios.defaults.paramsSerializer = params => { <BrowserRouter>
return qs.stringify(params, {arrayFormat: "repeat"}); <Layout
} modelRequest={this.modelRequest}
} request={this.request}
reqStates={this.state.reqStates}
// modelRequest is an opinionated wrapper for model specific HTTP requests, pageTitle={this.pageTitle}
// including setting model states. config={window.CONFIG}
modelRequest = async (model, route, method, params) => { data={this.state.data}
let url = replaceParams(route, params) />
</BrowserRouter>
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(
<div className="broken">
<p>
<img src={logo} alt="listmonk logo" />
</p>
<hr />
<h1><Icon type="warning" /> Something's not right</h1>
<p>The app configuration could not be loaded.
Please ensure that the app is running and then refresh this page.</p>
</div>
)
}
return (
<BrowserRouter>
<Layout
modelRequest={ this.modelRequest }
request={ this.request }
reqStates={ this.state.reqStates }
pageTitle={ this.pageTitle }
config={ window.CONFIG }
data={ this.state.data } />
</BrowserRouter>
)
}
} }
function replaceParams (route, params) { function replaceParams(route, params) {
// Replace :params in the URL with params in the array. // Replace :params in the URL with params in the array.
let uriParams = route.match(/:([a-z0-9\-_]+)/ig) let uriParams = route.match(/:([a-z0-9\-_]+)/gi)
if(uriParams && uriParams.length > 0) { if (uriParams && uriParams.length > 0) {
uriParams.forEach((p) => { uriParams.forEach(p => {
let pName = p.slice(1) // Lose the ":" prefix let pName = p.slice(1) // Lose the ":" prefix
if(params && params.hasOwnProperty(pName)) { if (params && params.hasOwnProperty(pName)) {
route = route.replace(p, params[pName]) route = route.replace(p, params[pName])
} }
}) })
} }
return route return route
} }
export default App export default App

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@ -1,267 +1,376 @@
import React from "react" import React from "react"
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
import { Row, Col, Modal, Form, Input, Select, Button, Table, Icon, Tooltip, Tag, Popconfirm, Spin, notification } from "antd" import {
Row,
Col,
Modal,
Form,
Input,
Select,
Button,
Table,
Icon,
Tooltip,
Tag,
Popconfirm,
Spin,
notification
} from "antd"
import Utils from "./utils" import Utils from "./utils"
import * as cs from "./constants" import * as cs from "./constants"
const tagColors = { const tagColors = {
"private": "orange", private: "orange",
"public": "green" public: "green"
} }
class CreateFormDef extends React.PureComponent { class CreateFormDef extends React.PureComponent {
state = { state = {
confirmDirty: false, confirmDirty: false,
modalWaiting: 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. return (
handleSubmit = (e) => { <div>
e.preventDefault() <Tag
this.props.form.validateFields((err, values) => { color={
if (err) { tagColors.hasOwnProperty(record.type) ? tagColors[record.type] : ""
return }
} >
{record.type}
</Tag>{" "}
{record.name}
<br />
<span className="text-tiny text-grey">
ID {record.id} / UUID {record.uuid}
</span>
</div>
)
}
this.setState({ modalWaiting: true }) render() {
if (this.props.formType === cs.FormCreate) { const { formType, record, onClose } = this.props
// Create a new list. const { getFieldDecorator } = this.props.form
this.props.modelRequest(cs.ModelLists, cs.Routes.CreateList, cs.MethodPost, values).then(() => {
notification["success"]({ placement: cs.MsgPosition, message: "List created", description: `"${values["name"]}" created` }) const formItemLayout = {
this.props.fetchRecords() labelCol: { xs: { span: 16 }, sm: { span: 4 } },
this.props.onClose() wrapperCol: { xs: { span: 16 }, sm: { span: 18 } }
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 === null) {
if(formType === cs.FormCreate) { return null
return "Create a list"
}
return (
<div>
<Tag color={ tagColors.hasOwnProperty(record.type) ? tagColors[record.type] : "" }>{ record.type }</Tag>
{" "}
{ record.name }
<br />
<span className="text-tiny text-grey">ID { record.id } / UUID { record.uuid }</span>
</div>
)
} }
render() { return (
const { formType, record, onClose } = this.props <Modal
const { getFieldDecorator } = this.props.form visible={true}
title={this.modalTitle(this.state.form, record)}
okText={this.state.form === cs.FormCreate ? "Create" : "Save"}
confirmLoading={this.state.modalWaiting}
onCancel={onClose}
onOk={this.handleSubmit}
>
<div id="modal-alert-container" />
const formItemLayout = { <Spin
labelCol: { xs: { span: 16 }, sm: { span: 4 } }, spinning={this.props.reqStates[cs.ModelLists] === cs.StatePending}
wrapperCol: { xs: { span: 16 }, sm: { span: 18 } } >
} <Form onSubmit={this.handleSubmit}>
<Form.Item {...formItemLayout} label="Name">
if (formType === null) { {getFieldDecorator("name", {
return null initialValue: record.name,
} rules: [{ required: true }]
})(<Input autoFocus maxLength="200" />)}
return ( </Form.Item>
<Modal visible={ true } title={ this.modalTitle(this.state.form, record) } <Form.Item
okText={ this.state.form === cs.FormCreate ? "Create" : "Save" } {...formItemLayout}
confirmLoading={ this.state.modalWaiting } name="type"
onCancel={ onClose } label="Type"
onOk={ this.handleSubmit }> extra="Public lists are open to the world to subscribe"
>
<div id="modal-alert-container"></div> {getFieldDecorator("type", {
initialValue: record.type ? record.type : "private",
<Spin spinning={ this.props.reqStates[cs.ModelLists] === cs.StatePending }> rules: [{ required: true }]
<Form onSubmit={this.handleSubmit}> })(
<Form.Item {...formItemLayout} label="Name"> <Select style={{ maxWidth: 120 }}>
{getFieldDecorator("name", { <Select.Option value="private">Private</Select.Option>
initialValue: record.name, <Select.Option value="public">Public</Select.Option>
rules: [{ required: true }] </Select>
})(<Input autoFocus maxLength="200" />)} )}
</Form.Item> </Form.Item>
<Form.Item {...formItemLayout} name="type" label="Type" extra="Public lists are open to the world to subscribe"> <Form.Item
{getFieldDecorator("type", { initialValue: record.type ? record.type : "private", rules: [{ required: true }] })( {...formItemLayout}
<Select style={{ maxWidth: 120 }}> label="Tags"
<Select.Option value="private">Private</Select.Option> extra="Hit Enter after typing a word to add multiple tags"
<Select.Option value="public">Public</Select.Option> >
</Select> {getFieldDecorator("tags", { initialValue: record.tags })(
)} <Select mode="tags" />
</Form.Item> )}
<Form.Item {...formItemLayout} label="Tags" extra="Hit Enter after typing a word to add multiple tags"> </Form.Item>
{getFieldDecorator("tags", { initialValue: record.tags })( </Form>
<Select mode="tags"></Select> </Spin>
)} </Modal>
</Form.Item> )
</Form> }
</Spin>
</Modal>
)
}
} }
const CreateForm = Form.create()(CreateFormDef) const CreateForm = Form.create()(CreateFormDef)
class Lists extends React.PureComponent { class Lists extends React.PureComponent {
state = { state = {
formType: null, formType: null,
record: {} record: {}
} }
constructor(props) { constructor(props) {
super(props) super(props)
this.columns = [{ this.columns = [
title: "Name", {
dataIndex: "name", title: "Name",
sorter: true, dataIndex: "name",
width: "40%", sorter: true,
render: (text, record) => { width: "40%",
const out = []; render: (text, record) => {
out.push( const out = []
<div className="name" key={`name-${record.id}`}><Link to={ `/subscribers/lists/${record.id}` }>{ text }</Link></div> out.push(
) <div className="name" key={`name-${record.id}`}>
<Link to={`/subscribers/lists/${record.id}`}>{text}</Link>
</div>
)
if(record.tags.length > 0) { if (record.tags.length > 0) {
for (let i = 0; i < record.tags.length; i++) { for (let i = 0; i < record.tags.length; i++) {
out.push(<Tag key={`tag-${i}`}>{ record.tags[i] }</Tag>); out.push(<Tag key={`tag-${i}`}>{record.tags[i]}</Tag>)
} }
} }
return out return out
} }
}, },
{ {
title: "Type", title: "Type",
dataIndex: "type", dataIndex: "type",
width: "10%", width: "10%",
render: (type, _) => { render: (type, _) => {
let color = type === "private" ? "orange" : "green" let color = type === "private" ? "orange" : "green"
return <Tag color={color}>{type}</Tag> return <Tag color={color}>{type}</Tag>
} }
}, },
{ {
title: "Subscribers", title: "Subscribers",
dataIndex: "subscriber_count", dataIndex: "subscriber_count",
width: "15%", width: "15%",
align: "center", align: "center",
render: (text, record) => { render: (text, record) => {
return( return (
<div className="name" key={`name-${record.id}`}><Link to={ `/subscribers/lists/${record.id}` }>{ text }</Link></div> <div className="name" key={`name-${record.id}`}>
) <Link to={`/subscribers/lists/${record.id}`}>{text}</Link>
} </div>
}, )
{ }
title: "Created", },
dataIndex: "created_at", {
render: (date, _) => { title: "Created",
return Utils.DateString(date) dataIndex: "created_at",
} render: (date, _) => {
}, return Utils.DateString(date)
{ }
title: "Updated", },
dataIndex: "updated_at", {
render: (date, _) => { title: "Updated",
return Utils.DateString(date) dataIndex: "updated_at",
} render: (date, _) => {
}, return Utils.DateString(date)
{ }
title: "", },
dataIndex: "actions", {
width: "10%", title: "",
render: (text, record) => { dataIndex: "actions",
return ( width: "10%",
<div className="actions"> render: (text, record) => {
<Tooltip title="Send a campaign"><a role="button"><Icon type="rocket" /></a></Tooltip> return (
<Tooltip title="Edit list"><a role="button" onClick={() => this.handleShowEditForm(record)}><Icon type="edit" /></a></Tooltip> <div className="actions">
<Popconfirm title="Are you sure?" onConfirm={() => this.deleteRecord(record)}> <Tooltip title="Send a campaign">
<Tooltip title="Delete list" placement="bottom"><a role="button"><Icon type="delete" /></a></Tooltip> <a role="button">
</Popconfirm> <Icon type="rocket" />
</div> </a>
) </Tooltip>
} <Tooltip title="Edit list">
}] <a
} role="button"
onClick={() => this.handleShowEditForm(record)}
>
<Icon type="edit" />
</a>
</Tooltip>
<Popconfirm
title="Are you sure?"
onConfirm={() => this.deleteRecord(record)}
>
<Tooltip title="Delete list" placement="bottom">
<a role="button">
<Icon type="delete" />
</a>
</Tooltip>
</Popconfirm>
</div>
)
}
}
]
}
componentDidMount() { componentDidMount() {
this.props.pageTitle("Lists") this.props.pageTitle("Lists")
this.fetchRecords()
}
fetchRecords = () => {
this.props.modelRequest(cs.ModelLists, cs.Routes.GetLists, cs.MethodGet)
}
deleteRecord = record => {
this.props
.modelRequest(cs.ModelLists, cs.Routes.DeleteList, cs.MethodDelete, {
id: record.id
})
.then(() => {
notification["success"]({
placement: cs.MsgPosition,
message: "List deleted",
description: `"${record.name}" deleted`
})
// Reload the table.
this.fetchRecords() this.fetchRecords()
} })
.catch(e => {
notification["error"]({
placement: cs.MsgPosition,
message: "Error",
description: e.message
})
})
}
fetchRecords = () => { handleHideForm = () => {
this.props.modelRequest(cs.ModelLists, cs.Routes.GetLists, cs.MethodGet) this.setState({ formType: null })
} }
deleteRecord = (record) => { handleShowCreateForm = () => {
this.props.modelRequest(cs.ModelLists, cs.Routes.DeleteList, cs.MethodDelete, { id: record.id }) this.setState({ formType: cs.FormCreate, record: {} })
.then(() => { }
notification["success"]({ placement: cs.MsgPosition, message: "List deleted", description: `"${record.name}" deleted` })
// Reload the table.
this.fetchRecords()
}).catch(e => {
notification["error"]({ placement: cs.MsgPosition, message: "Error", description: e.message })
})
}
handleHideForm = () => { handleShowEditForm = record => {
this.setState({ formType: null }) this.setState({ formType: cs.FormEdit, record: record })
} }
handleShowCreateForm = () => { render() {
this.setState({ formType: cs.FormCreate, record: {} }) return (
} <section className="content">
<Row>
<Col span={22}>
<h1>Lists ({this.props.data[cs.ModelLists].length}) </h1>
</Col>
<Col span={2}>
<Button
type="primary"
icon="plus"
onClick={this.handleShowCreateForm}
>
Create list
</Button>
</Col>
</Row>
<br />
handleShowEditForm = (record) => { <Table
this.setState({ formType: cs.FormEdit, record: record }) className="lists"
} columns={this.columns}
rowKey={record => record.uuid}
dataSource={this.props.data[cs.ModelLists]}
loading={this.props.reqStates[cs.ModelLists] !== cs.StateDone}
pagination={false}
/>
render() { <CreateForm
return ( {...this.props}
<section className="content"> formType={this.state.formType}
<Row> record={this.state.record}
<Col span={22}><h1>Lists ({this.props.data[cs.ModelLists].length}) </h1></Col> onClose={this.handleHideForm}
<Col span={2}> fetchRecords={this.fetchRecords}
<Button type="primary" icon="plus" onClick={this.handleShowCreateForm}>Create list</Button> />
</Col> </section>
</Row> )
<br /> }
<Table
className="lists"
columns={ this.columns }
rowKey={ record => record.uuid }
dataSource={ this.props.data[cs.ModelLists] }
loading={ this.props.reqStates[cs.ModelLists] !== cs.StateDone }
pagination={ false }
/>
<CreateForm { ...this.props }
formType={ this.state.formType }
record={ this.state.record }
onClose={ this.handleHideForm }
fetchRecords = { this.fetchRecords }
/>
</section>
)
}
} }
export default Lists export default Lists

View File

@ -1,132 +1,176 @@
import React from "react" import React from "react"
import { Row, Col, Form, Upload, Icon, Spin, Popconfirm, Tooltip, notification } from "antd" import {
Row,
Col,
Form,
Upload,
Icon,
Spin,
Popconfirm,
Tooltip,
notification
} from "antd"
import * as cs from "./constants" import * as cs from "./constants"
class TheFormDef extends React.PureComponent { class TheFormDef extends React.PureComponent {
state = { state = {
confirmDirty: false confirmDirty: false
} }
componentDidMount() { componentDidMount() {
this.props.pageTitle("Media") 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() 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.insertMedia(record.uri)
this.props.modelRequest(cs.ModelMedia, cs.Routes.GetMedia, cs.MethodGet) 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) => { return false
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. render() {
this.fetchRecords() const { getFieldDecorator } = this.props.form
}).catch(e => { const formItemLayout = {
notification["error"]({ placement: cs.MsgPosition, message: "Error", description: e.message }) labelCol: { xs: { span: 16 }, sm: { span: 4 } },
}) wrapperCol: { xs: { span: 16 }, sm: { span: 10 } }
} }
handleInsertMedia = (record) => { return (
// The insertMedia callback may be passed down by the invoker (Campaign) <Spin spinning={false}>
if(!this.props.insertMedia) { <Form>
return false <Form.Item {...formItemLayout} label="Upload images">
} <div className="dropbox">
{getFieldDecorator("file", {
this.props.insertMedia(record.uri) valuePropName: "file",
return false getValueFromEvent: this.normFile,
} rules: [{ required: true }]
})(
<Upload.Dragger
name="file"
action="/api/media"
multiple={true}
listType="picture"
onChange={this.onFileChange}
accept=".gif, .jpg, .jpeg, .png"
>
<p className="ant-upload-drag-icon">
<Icon type="inbox" />
</p>
<p className="ant-upload-text">Click or drag file here</p>
</Upload.Dragger>
)}
</div>
</Form.Item>
</Form>
onFileChange = (f) => { <section className="gallery">
if(f.file.error && f.file.response && f.file.response.hasOwnProperty("message")) { {this.props.media &&
notification["error"]({ placement: cs.MsgPosition, this.props.media.map((record, i) => (
message: "Error uploading file", <div key={i} className="image">
description: f.file.response.message }) <a
} else if(f.file.status === "done") { onClick={() => {
this.fetchRecords() this.handleInsertMedia(record)
} if (this.props.onCancel) {
this.props.onCancel()
return false }
} }}
>
render() { <img alt={record.filename} src={record.thumb_uri} />
const { getFieldDecorator } = this.props.form </a>
const formItemLayout = { <div className="actions">
labelCol: { xs: { span: 16 }, sm: { span: 4 } }, <Tooltip title="View" placement="bottom">
wrapperCol: { xs: { span: 16 }, sm: { span: 10 } } <a role="button" href={record.uri} target="_blank">
} <Icon type="login" />
</a>
return ( </Tooltip>
<Spin spinning={false}> <Popconfirm
<Form> title="Are you sure?"
<Form.Item onConfirm={() => this.handleDeleteRecord(record)}
{...formItemLayout} >
label="Upload images"> <Tooltip title="Delete" placement="bottom">
<div className="dropbox"> <a role="button">
{getFieldDecorator("file", { <Icon type="delete" />
valuePropName: "file", </a>
getValueFromEvent: this.normFile, </Tooltip>
rules: [{ required: true }] </Popconfirm>
})( </div>
<Upload.Dragger <div className="name" title={record.filename}>
name="file" {record.filename}
action="/api/media" </div>
multiple={ true } </div>
listType="picture" ))}
onChange={ this.onFileChange } </section>
accept=".gif, .jpg, .jpeg, .png"> </Spin>
<p className="ant-upload-drag-icon"> )
<Icon type="inbox" /> }
</p>
<p className="ant-upload-text">Click or drag file here</p>
</Upload.Dragger>
)}
</div>
</Form.Item>
</Form>
<section className="gallery">
{this.props.media && this.props.media.map((record, i) =>
<div key={ i } className="image">
<a onClick={ () => {
this.handleInsertMedia(record);
if( this.props.onCancel ) {
this.props.onCancel();
}
} }><img alt={ record.filename } src={ record.thumb_uri } /></a>
<div className="actions">
<Tooltip title="View" placement="bottom"><a role="button" href={ record.uri } target="_blank"><Icon type="login" /></a></Tooltip>
<Popconfirm title="Are you sure?" onConfirm={() => this.handleDeleteRecord(record)}>
<Tooltip title="Delete" placement="bottom"><a role="button"><Icon type="delete" /></a></Tooltip>
</Popconfirm>
</div>
<div className="name" title={ record.filename }>{ record.filename }</div>
</div>
)}
</section>
</Spin>
)
}
} }
const TheForm = Form.create()(TheFormDef) const TheForm = Form.create()(TheFormDef)
class Media extends React.PureComponent { class Media extends React.PureComponent {
render() { render() {
return ( return (
<section className="content media"> <section className="content media">
<Row> <Row>
<Col span={22}><h1>Images</h1></Col> <Col span={22}>
<Col span={2}> <h1>Images</h1>
</Col> </Col>
</Row> <Col span={2} />
</Row>
<TheForm { ...this.props } <TheForm {...this.props} media={this.props.data[cs.ModelMedia]} />
media={ this.props.data[cs.ModelMedia] }> </section>
</TheForm> )
</section> }
)
}
} }
export default Media export default Media

View File

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

View File

@ -1,292 +1,397 @@
import React from "react" import React from "react"
import { Row, Col, Form, Input, Select, Button, Tag, Spin, Popconfirm, notification } from "antd" import {
Row,
Col,
Form,
Input,
Select,
Button,
Tag,
Spin,
Popconfirm,
notification
} from "antd"
import * as cs from "./constants" import * as cs from "./constants"
const tagColors = { const tagColors = {
"enabled": "green", enabled: "green",
"blacklisted": "red" blacklisted: "red"
} }
const formItemLayoutModal = { const formItemLayoutModal = {
labelCol: { xs: { span: 24 }, sm: { span: 4 } }, labelCol: { xs: { span: 24 }, sm: { span: 4 } },
wrapperCol: { xs: { span: 24 }, sm: { span: 18 } } wrapperCol: { xs: { span: 24 }, sm: { span: 18 } }
} }
const formItemLayout = { const formItemLayout = {
labelCol: { xs: { span: 16 }, sm: { span: 4 } }, labelCol: { xs: { span: 16 }, sm: { span: 4 } },
wrapperCol: { xs: { span: 16 }, sm: { span: 10 } } wrapperCol: { xs: { span: 16 }, sm: { span: 10 } }
} }
const formItemTailLayout = { 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 { class CreateFormDef extends React.PureComponent {
state = { state = {
confirmDirty: false, confirmDirty: false,
loading: 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. var err = null,
handleSubmit = (e, cb) => { values = {}
e.preventDefault() this.props.form.validateFields((e, v) => {
if(!cb) { err = e
// Set a fake callback. values = v
cb = () => {} })
} if (err) {
return
}
var err = null, values = {} let a = values["attribs"]
this.props.form.validateFields((e, v) => { values["attribs"] = {}
err = e if (a && a.length > 0) {
values = v 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
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 })
})
}
} }
handleDeleteRecord = (record) => { this.setState({ loading: true })
this.props.modelRequest(cs.ModelSubscribers, cs.Routes.DeleteSubscriber, cs.MethodDelete, { id: record.id }) if (this.props.formType === cs.FormCreate) {
.then(() => { // Add a subscriber.
notification["success"]({ message: "Subscriber deleted", description: `${record.email} deleted` }) this.props
.modelRequest(
this.props.route.history.push({ cs.ModelSubscribers,
pathname: cs.Routes.ViewSubscribers, cs.Routes.CreateSubscriber,
}) cs.MethodPost,
}).catch(e => { values
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 (
<Spin spinning={ this.state.loading }>
<Form onSubmit={ this.handleSubmit }>
<Form.Item { ...layout } label="E-mail">
{getFieldDecorator("email", {
initialValue: record.email,
rules: [{ required: true }]
})(<Input autoFocus pattern="(.+?)@(.+?)" maxLength="200" />)}
</Form.Item>
<Form.Item { ...layout } label="Name">
{getFieldDecorator("name", {
initialValue: record.name,
rules: [{ required: true }]
})(<Input maxLength="200" />)}
</Form.Item>
<Form.Item { ...layout } name="status" label="Status" extra="Blacklisted users will not receive any e-mails ever">
{getFieldDecorator("status", { initialValue: record.status ? record.status : "enabled", rules: [{ required: true, message: "Type is required" }] })(
<Select style={{ maxWidth: 120 }}>
<Select.Option value="enabled">Enabled</Select.Option>
<Select.Option value="blacklisted">Blacklisted</Select.Option>
</Select>
)}
</Form.Item>
<Form.Item { ...layout } label="Lists" extra="Lists to subscribe to. Lists from which subscribers have unsubscribed themselves cannot be removed.">
{getFieldDecorator("lists", { initialValue: subListIDs })(
<Select mode="multiple">
{[...this.props.lists].map((v, i) =>
<Select.Option value={ v.id } key={ v.id } disabled={ subStatuses[v.id] === cs.SubscriptionStatusUnsubscribed }>
<span>{ v.name }
{ subStatuses[v.id] &&
<sup className={ "subscription-status " + subStatuses[v.id] }> { subStatuses[v.id] }</sup>
}
</span>
</Select.Option>
)}
</Select>
)}
</Form.Item>
<Form.Item { ...layout } label="Attributes" colon={ false }>
<div>
{getFieldDecorator("attribs", {
initialValue: record.attribs ? JSON.stringify(record.attribs, null, 4) : ""
})(
<Input.TextArea
placeholder="{}"
rows={10}
readOnly={false}
autosize={{ minRows: 5, maxRows: 10 }} />
)}
</div>
<p className="ant-form-extra">Attributes are defined as a JSON map, for example:
{' {"age": 30, "color": "red", "is_user": true}'}. <a href="">More info</a>.</p>
</Form.Item>
{ !this.props.isModal &&
<Form.Item { ...formItemTailLayout }>
<Button type="primary" htmlType="submit" icon={ this.props.formType === cs.FormCreate ? "plus" : "save" }>
{ this.props.formType === cs.FormCreate ? "Add" : "Save" }
</Button>
{" "}
{ this.props.formType === cs.FormEdit &&
<Popconfirm title="Are you sure?" onConfirm={() => {
this.handleDeleteRecord(record)
}}>
<Button icon="delete">Delete</Button>
</Popconfirm>
}
</Form.Item>
}
</Form>
</Spin>
) )
.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 (
<Spin spinning={this.state.loading}>
<Form onSubmit={this.handleSubmit}>
<Form.Item {...layout} label="E-mail">
{getFieldDecorator("email", {
initialValue: record.email,
rules: [{ required: true }]
})(<Input autoFocus pattern="(.+?)@(.+?)" maxLength="200" />)}
</Form.Item>
<Form.Item {...layout} label="Name">
{getFieldDecorator("name", {
initialValue: record.name,
rules: [{ required: true }]
})(<Input maxLength="200" />)}
</Form.Item>
<Form.Item
{...layout}
name="status"
label="Status"
extra="Blacklisted users will not receive any e-mails ever"
>
{getFieldDecorator("status", {
initialValue: record.status ? record.status : "enabled",
rules: [{ required: true, message: "Type is required" }]
})(
<Select style={{ maxWidth: 120 }}>
<Select.Option value="enabled">Enabled</Select.Option>
<Select.Option value="blacklisted">Blacklisted</Select.Option>
</Select>
)}
</Form.Item>
<Form.Item
{...layout}
label="Lists"
extra="Lists to subscribe to. Lists from which subscribers have unsubscribed themselves cannot be removed."
>
{getFieldDecorator("lists", { initialValue: subListIDs })(
<Select mode="multiple">
{[...this.props.lists].map((v, i) => (
<Select.Option
value={v.id}
key={v.id}
disabled={
subStatuses[v.id] === cs.SubscriptionStatusUnsubscribed
}
>
<span>
{v.name}
{subStatuses[v.id] && (
<sup
className={"subscription-status " + subStatuses[v.id]}
>
{" "}
{subStatuses[v.id]}
</sup>
)}
</span>
</Select.Option>
))}
</Select>
)}
</Form.Item>
<Form.Item {...layout} label="Attributes" colon={false}>
<div>
{getFieldDecorator("attribs", {
initialValue: record.attribs
? JSON.stringify(record.attribs, null, 4)
: ""
})(
<Input.TextArea
placeholder="{}"
rows={10}
readOnly={false}
autosize={{ minRows: 5, maxRows: 10 }}
/>
)}
</div>
<p className="ant-form-extra">
Attributes are defined as a JSON map, for example:
{' {"age": 30, "color": "red", "is_user": true}'}.{" "}
<a href="">More info</a>.
</p>
</Form.Item>
{!this.props.isModal && (
<Form.Item {...formItemTailLayout}>
<Button
type="primary"
htmlType="submit"
icon={this.props.formType === cs.FormCreate ? "plus" : "save"}
>
{this.props.formType === cs.FormCreate ? "Add" : "Save"}
</Button>{" "}
{this.props.formType === cs.FormEdit && (
<Popconfirm
title="Are you sure?"
onConfirm={() => {
this.handleDeleteRecord(record)
}}
>
<Button icon="delete">Delete</Button>
</Popconfirm>
)}
</Form.Item>
)}
</Form>
</Spin>
)
}
} }
const CreateForm = Form.create()(CreateFormDef) const CreateForm = Form.create()(CreateFormDef)
class Subscriber extends React.PureComponent { class Subscriber extends React.PureComponent {
state = { state = {
loading: true, loading: true,
formRef: null, formRef: null,
record: {}, record: {},
subID: this.props.route.match.params ? parseInt(this.props.route.match.params.subID, 10) : 0, subID: this.props.route.match.params
? parseInt(this.props.route.match.params.subID, 10)
: 0
}
componentDidMount() {
// 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() { fetchRecord = id => {
// When this component is invoked within a modal from the subscribers list page, this.props
// the necessary context is supplied and there's no need to fetch anything. .request(cs.Routes.GetSubscriber, cs.MethodGet, { id: id })
if(!this.props.isModal) { .then(r => {
// Fetch lists. this.setState({ record: r.data.data, loading: false })
this.props.modelRequest(cs.ModelLists, cs.Routes.GetLists, cs.MethodGet) })
.catch(e => {
// Fetch subscriber. notification["error"]({
this.fetchRecord(this.state.subID) placement: cs.MsgPosition,
} else { message: "Error",
this.setState({ record: this.props.record, loading: false }) 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) => { setFormRef = r => {
this.setState({ formRef: r }) this.setState({ formRef: r })
} }
submitForm = (e, cb) => { submitForm = (e, cb) => {
if(this.state.formRef) { if (this.state.formRef) {
this.state.formRef.handleSubmit(e, cb) this.state.formRef.handleSubmit(e, cb)
}
} }
}
render() { render() {
return ( return (
<section className="content"> <section className="content">
<header className="header"> <header className="header">
<Row> <Row>
<Col span={ 20 }> <Col span={20}>
{ !this.state.record.id && {!this.state.record.id && <h1>Add subscriber</h1>}
<h1>Add subscriber</h1> {this.state.record.id && (
}
{ this.state.record.id &&
<div>
<h1>
<Tag color={ tagColors.hasOwnProperty(this.state.record.status) ? tagColors[this.state.record.status] : "" }>{ this.state.record.status }</Tag>
{" "}
{ this.state.record.name } ({ this.state.record.email })
</h1>
<span className="text-small text-grey">ID { this.state.record.id } / UUID { this.state.record.uuid }</span>
</div>
}
</Col>
<Col span={ 2 }>
</Col>
</Row>
</header>
<div> <div>
<Spin spinning={ this.state.loading }> <h1>
<CreateForm <Tag
{...this.props} color={
formType={ this.props.formType ? this.props.formType : cs.FormEdit } tagColors.hasOwnProperty(this.state.record.status)
record={ this.state.record } ? tagColors[this.state.record.status]
fetchRecord={ this.fetchRecord } : ""
lists={ this.props.data[cs.ModelLists] } }
wrappedComponentRef={ (r) => { >
if(!r) { {this.state.record.status}
return </Tag>{" "}
} {this.state.record.name} ({this.state.record.email})
</h1>
// Save the form's reference so that when this component <span className="text-small text-grey">
// is used as a modal, the invoker of the model can submit ID {this.state.record.id} / UUID {this.state.record.uuid}
// it via submitForm() </span>
this.setState({ formRef: r })
}}
/>
</Spin>
</div> </div>
</section> )}
) </Col>
} <Col span={2} />
</Row>
</header>
<div>
<Spin spinning={this.state.loading}>
<CreateForm
{...this.props}
formType={this.props.formType ? this.props.formType : cs.FormEdit}
record={this.state.record}
fetchRecord={this.fetchRecord}
lists={this.props.data[cs.ModelLists]}
wrappedComponentRef={r => {
if (!r) {
return
}
// Save the form's reference so that when this component
// is used as a modal, the invoker of the model can submit
// it via submitForm()
this.setState({ formRef: r })
}}
/>
</Spin>
</div>
</section>
)
}
} }
export default Subscriber export default Subscriber

File diff suppressed because it is too large Load Diff

View File

@ -1,287 +1,439 @@
import React from "react" import React from "react"
import { Row, Col, Modal, Form, Input, Button, Table, Icon, Tooltip, Tag, Popconfirm, Spin, notification } from "antd" import {
Row,
Col,
Modal,
Form,
Input,
Button,
Table,
Icon,
Tooltip,
Tag,
Popconfirm,
Spin,
notification
} from "antd"
import ModalPreview from "./ModalPreview" import ModalPreview from "./ModalPreview"
import Utils from "./utils" import Utils from "./utils"
import * as cs from "./constants" import * as cs from "./constants"
class CreateFormDef extends React.PureComponent { class CreateFormDef extends React.PureComponent {
state = { state = {
confirmDirty: false, confirmDirty: false,
modalWaiting: false, modalWaiting: false,
previewName: "", previewName: "",
previewBody: "" 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. if (formType === null) {
handleSubmit = (e) => { return null
e.preventDefault() }
this.props.form.validateFields((err, values) => {
if (err) { return (
return <div>
<Modal
visible={true}
title={formType === cs.FormCreate ? "Add template" : record.name}
okText={this.state.form === cs.FormCreate ? "Add" : "Save"}
width="90%"
height={900}
confirmLoading={this.state.modalWaiting}
onCancel={onClose}
onOk={this.handleSubmit}
>
<Spin
spinning={
this.props.reqStates[cs.ModelTemplates] === cs.StatePending
} }
>
<Form onSubmit={this.handleSubmit}>
<Form.Item {...formItemLayout} label="Name">
{getFieldDecorator("name", {
initialValue: record.name,
rules: [{ required: true }]
})(<Input autoFocus maxLength="200" />)}
</Form.Item>
<Form.Item {...formItemLayout} name="body" label="Raw HTML">
{getFieldDecorator("body", {
initialValue: record.body ? record.body : "",
rules: [{ required: true }]
})(<Input.TextArea autosize={{ minRows: 10, maxRows: 30 }} />)}
</Form.Item>
{this.props.form.getFieldValue("body") !== "" && (
<Form.Item {...formItemLayout} colon={false} label="&nbsp;">
<Button
icon="search"
onClick={() =>
this.handlePreview(
this.props.form.getFieldValue("name"),
this.props.form.getFieldValue("body")
)
}
>
Preview
</Button>
</Form.Item>
)}
</Form>
</Spin>
<Row>
<Col span="4" />
<Col span="18" className="text-grey text-small">
The placeholder{" "}
<code>
{"{"}
{"{"} template "content" . {"}"}
{"}"}
</code>{" "}
should appear in the template.{" "}
<a href="" target="_blank">
Read more on templating
</a>
.
</Col>
</Row>
</Modal>
this.setState({ modalWaiting: true }) {this.state.previewBody && (
if (this.props.formType === cs.FormCreate) { <ModalPreview
// Create a new list. title={
this.props.modelRequest(cs.ModelTemplates, cs.Routes.CreateTemplate, cs.MethodPost, values).then(() => { this.state.previewName
notification["success"]({ placement: cs.MsgPosition, message: "Template added", description: `"${values["name"]}" added` }) ? this.state.previewName
this.props.fetchRecords() : "Template preview"
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 })
})
} }
}) previewURL={cs.Routes.PreviewNewTemplate}
} body={this.state.previewBody}
onCancel={() => {
handleConfirmBlur = (e) => { this.setState({ previewBody: null, previewName: null })
const value = e.target.value }}
this.setState({ confirmDirty: this.state.confirmDirty || !!value }) />
} )}
</div>
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 (
<div>
<Modal visible={ true } title={ formType === cs.FormCreate ? "Add template" : record.name }
okText={ this.state.form === cs.FormCreate ? "Add" : "Save" }
width="90%"
height={ 900 }
confirmLoading={ this.state.modalWaiting }
onCancel={ onClose }
onOk={ this.handleSubmit }>
<Spin spinning={ this.props.reqStates[cs.ModelTemplates] === cs.StatePending }>
<Form onSubmit={this.handleSubmit}>
<Form.Item {...formItemLayout} label="Name">
{getFieldDecorator("name", {
initialValue: record.name,
rules: [{ required: true }]
})(<Input autoFocus maxLength="200" />)}
</Form.Item>
<Form.Item {...formItemLayout} name="body" label="Raw HTML">
{getFieldDecorator("body", { initialValue: record.body ? record.body : "", rules: [{ required: true }] })(
<Input.TextArea autosize={{ minRows: 10, maxRows: 30 }} />
)}
</Form.Item>
{ this.props.form.getFieldValue("body") !== "" &&
<Form.Item {...formItemLayout} colon={ false } label="&nbsp;">
<Button icon="search" onClick={ () =>
this.handlePreview(this.props.form.getFieldValue("name"), this.props.form.getFieldValue("body"))
}>Preview</Button>
</Form.Item>
}
</Form>
</Spin>
<Row>
<Col span="4"></Col>
<Col span="18" className="text-grey text-small">
The placeholder <code>{'{'}{'{'} template "content" . {'}'}{'}'}</code> should appear in the template. <a href="" target="_blank">Read more on templating</a>.
</Col>
</Row>
</Modal>
{ this.state.previewBody &&
<ModalPreview
title={ this.state.previewName ? this.state.previewName : "Template preview" }
previewURL={ cs.Routes.PreviewNewTemplate }
body={ this.state.previewBody }
onCancel={() => {
this.setState({ previewBody: null, previewName: null })
}}
/>
}
</div>
)
}
} }
const CreateForm = Form.create()(CreateFormDef) const CreateForm = Form.create()(CreateFormDef)
class Templates extends React.PureComponent { class Templates extends React.PureComponent {
state = { state = {
formType: null, formType: null,
record: {}, record: {},
previewRecord: null previewRecord: null
} }
constructor(props) { constructor(props) {
super(props) super(props)
this.columns = [{ this.columns = [
title: "Name", {
dataIndex: "name", title: "Name",
sorter: true, dataIndex: "name",
width: "50%", sorter: true,
render: (text, record) => { width: "50%",
return ( render: (text, record) => {
<div className="name"> return (
<a role="button" onClick={() => this.handleShowEditForm(record)}>{ text }</a> <div className="name">
{ record.is_default && <a role="button" onClick={() => this.handleShowEditForm(record)}>
<div><Tag>Default</Tag></div>} {text}
</div> </a>
) {record.is_default && (
} <div>
}, <Tag>Default</Tag>
{ </div>
title: "Created", )}
dataIndex: "created_at", </div>
render: (date, _) => { )
return Utils.DateString(date) }
} },
}, {
{ title: "Created",
title: "Updated", dataIndex: "created_at",
dataIndex: "updated_at", render: (date, _) => {
render: (date, _) => { return Utils.DateString(date)
return Utils.DateString(date) }
} },
}, {
{ title: "Updated",
title: "", dataIndex: "updated_at",
dataIndex: "actions", render: (date, _) => {
width: "20%", return Utils.DateString(date)
className: "actions", }
render: (text, record) => { },
return ( {
<div className="actions"> title: "",
<Tooltip title="Preview template" onClick={() => this.handlePreview(record)}><a role="button"><Icon type="search" /></a></Tooltip> dataIndex: "actions",
width: "20%",
className: "actions",
render: (text, record) => {
return (
<div className="actions">
<Tooltip
title="Preview template"
onClick={() => this.handlePreview(record)}
>
<a role="button">
<Icon type="search" />
</a>
</Tooltip>
{ !record.is_default && {!record.is_default && (
<Popconfirm title="Are you sure?" onConfirm={() => this.handleSetDefault(record)}> <Popconfirm
<Tooltip title="Set as default" placement="bottom"><a role="button"><Icon type="check" /></a></Tooltip> title="Are you sure?"
</Popconfirm> onConfirm={() => this.handleSetDefault(record)}
} >
<Tooltip title="Set as default" placement="bottom">
<a role="button">
<Icon type="check" />
</a>
</Tooltip>
</Popconfirm>
)}
<Tooltip title="Edit template"><a role="button" onClick={() => this.handleShowEditForm(record)}><Icon type="edit" /></a></Tooltip> <Tooltip title="Edit template">
<a
role="button"
onClick={() => this.handleShowEditForm(record)}
>
<Icon type="edit" />
</a>
</Tooltip>
{ record.id !== 1 && {record.id !== 1 && (
<Popconfirm title="Are you sure?" onConfirm={() => this.handleDeleteRecord(record)}> <Popconfirm
<Tooltip title="Delete template" placement="bottom"><a role="button"><Icon type="delete" /></a></Tooltip> title="Are you sure?"
</Popconfirm> onConfirm={() => this.handleDeleteRecord(record)}
} >
</div> <Tooltip title="Delete template" placement="bottom">
) <a role="button">
} <Icon type="delete" />
}] </a>
} </Tooltip>
</Popconfirm>
)}
</div>
)
}
}
]
}
componentDidMount() { componentDidMount() {
this.props.pageTitle("Templates") 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() this.fetchRecords()
} })
.catch(e => {
notification["error"]({ message: "Error", description: e.message })
})
}
fetchRecords = () => { handleSetDefault = record => {
this.props.modelRequest(cs.ModelTemplates, cs.Routes.GetTemplates, cs.MethodGet) 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) => { // Reload the table.
this.props.modelRequest(cs.ModelTemplates, cs.Routes.DeleteTemplate, cs.MethodDelete, { id: record.id }) this.fetchRecords()
.then(() => { })
notification["success"]({ placement: cs.MsgPosition, message: "Template deleted", description: `"${record.name}" deleted` }) .catch(e => {
notification["error"]({
placement: cs.MsgPosition,
message: "Error",
description: e.message
})
})
}
// Reload the table. handlePreview = record => {
this.fetchRecords() this.setState({ previewRecord: record })
}).catch(e => { }
notification["error"]({ message: "Error", description: e.message })
})
}
handleSetDefault = (record) => { hideForm = () => {
this.props.modelRequest(cs.ModelTemplates, cs.Routes.SetDefaultTemplate, cs.MethodPut, { id: record.id }) this.setState({ formType: null })
.then(() => { }
notification["success"]({ placement: cs.MsgPosition, message: "Template updated", description: `"${record.name}" set as default` })
// Reload the table.
this.fetchRecords()
}).catch(e => {
notification["error"]({ placement: cs.MsgPosition, message: "Error", description: e.message })
})
}
handlePreview = (record) => { handleShowCreateForm = () => {
this.setState({ previewRecord: record }) this.setState({ formType: cs.FormCreate, record: {} })
} }
hideForm = () => { handleShowEditForm = record => {
this.setState({ formType: null }) this.setState({ formType: cs.FormEdit, record: record })
} }
handleShowCreateForm = () => { render() {
this.setState({ formType: cs.FormCreate, record: {} }) return (
} <section className="content templates">
<Row>
<Col span={22}>
<h1>Templates ({this.props.data[cs.ModelTemplates].length}) </h1>
</Col>
<Col span={2}>
<Button
type="primary"
icon="plus"
onClick={this.handleShowCreateForm}
>
Add template
</Button>
</Col>
</Row>
<br />
handleShowEditForm = (record) => { <Table
this.setState({ formType: cs.FormEdit, record: record }) columns={this.columns}
} rowKey={record => record.id}
dataSource={this.props.data[cs.ModelTemplates]}
loading={this.props.reqStates[cs.ModelTemplates] !== cs.StateDone}
pagination={false}
/>
render() { <CreateForm
return ( {...this.props}
<section className="content templates"> formType={this.state.formType}
<Row> record={this.state.record}
<Col span={22}><h1>Templates ({this.props.data[cs.ModelTemplates].length}) </h1></Col> onClose={this.hideForm}
<Col span={2}> fetchRecords={this.fetchRecords}
<Button type="primary" icon="plus" onClick={ this.handleShowCreateForm }>Add template</Button> />
</Col>
</Row>
<br />
<Table {this.state.previewRecord && (
columns={ this.columns } <ModalPreview
rowKey={ record => record.id } title={this.state.previewRecord.name}
dataSource={ this.props.data[cs.ModelTemplates] } previewURL={cs.Routes.PreviewTemplate.replace(
loading={ this.props.reqStates[cs.ModelTemplates] !== cs.StateDone } ":id",
pagination={ false } this.state.previewRecord.id
/> )}
onCancel={() => {
<CreateForm { ...this.props } this.setState({ previewRecord: null })
formType={ this.state.formType } }}
record={ this.state.record } />
onClose={ this.hideForm } )}
fetchRecords = { this.fetchRecords } </section>
/> )
}
{ this.state.previewRecord &&
<ModalPreview
title={ this.state.previewRecord.name }
previewURL={ cs.Routes.PreviewTemplate.replace(":id", this.state.previewRecord.id) }
onCancel={() => {
this.setState({ previewRecord: null })
}}
/>
}
</section>
)
}
} }
export default Templates export default Templates

View File

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

View File

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

View File

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

View File

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

View File

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