Refactored subscriber add/edit from from modal to modal + standalone view

This commit is contained in:
Kailash Nadh 2019-01-04 18:27:34 +05:30
parent ab1a6bbed8
commit ac2234a838
5 changed files with 353 additions and 190 deletions

View File

@ -9,6 +9,7 @@ import logo from "./static/listmonk.svg"
import Dashboard from "./Dashboard"
import Lists from "./Lists"
import Subscribers from "./Subscribers"
import Subscriber from "./Subscriber"
import Templates from "./Templates"
import Import from "./Import"
import Test from "./Test"
@ -87,6 +88,7 @@ class Base extends React.Component {
<Route exact key="/subscribers" path="/subscribers" render={(props) => <Subscribers { ...{ ...this.props, route: props } } />} />
<Route exact key="/subscribers/lists/:listID" path="/subscribers/lists/:listID" render={(props) => <Subscribers { ...{ ...this.props, route: props } } />} />
<Route exact key="/subscribers/import" path="/subscribers/import" render={(props) => <Import { ...{ ...this.props, route: props } } />} />
<Route exact key="/subscribers/:subID" path="/subscribers/:subID" render={(props) => <Subscriber { ...{ ...this.props, route: props } } />} />
<Route exact key="/campaigns" path="/campaigns" render={(props) => <Campaigns { ...{ ...this.props, route: props } } />} />
<Route exact key="/campaigns/new" path="/campaigns/new" render={(props) => <Campaign { ...{ ...this.props, route: props } } />} />
<Route exact key="/campaigns/media" path="/campaigns/media" render={(props) => <Media { ...{ ...this.props, route: props } } />} />

View File

@ -62,7 +62,7 @@ class CreateFormDef extends React.PureComponent {
{" "}
{ record.name }
<br />
<span className="text-tiny text-grey">ID { record.id } &mdash; UUID { record.uuid }</span>
<span className="text-tiny text-grey">ID { record.id } / UUID { record.uuid }</span>
</div>
)
}

View File

@ -0,0 +1,292 @@
import React from "react"
import { Row, Col, Form, Input, Select, Button, Tag, Spin, Popconfirm, notification } from "antd"
import * as cs from "./constants"
const tagColors = {
"enabled": "green",
"blacklisted": "red"
}
const formItemLayoutModal = {
labelCol: { xs: { span: 24 }, sm: { span: 4 } },
wrapperCol: { xs: { span: 24 }, sm: { span: 18 } }
}
const formItemLayout = {
labelCol: { xs: { span: 16 }, sm: { span: 4 } },
wrapperCol: { xs: { span: 16 }, sm: { span: 10 } }
}
const formItemTailLayout = {
wrapperCol: { xs: { span: 24, offset: 0 }, sm: { span: 10, offset: 4 } }
}
class CreateFormDef extends React.PureComponent {
state = {
confirmDirty: false,
loading: false
}
// Handle create / edit form submission.
handleSubmit = (e, cb) => {
e.preventDefault()
if(!cb) {
// Set a fake callback.
cb = () => {}
}
var err = null, values = {}
this.props.form.validateFields((e, v) => {
err = e
values = v
})
if(err) {
return
}
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.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)
class Subscriber extends React.PureComponent {
state = {
loading: true,
formRef: null,
record: {},
subID: this.props.route.match.params ? parseInt(this.props.route.match.params.subID, 10) : 0,
}
componentDidMount() {
// When this component is invoked within a modal from the subscribers list page,
// the necessary context is supplied and there's no need to fetch anything.
if(!this.props.isModal) {
// Fetch lists.
this.props.modelRequest(cs.ModelLists, cs.Routes.GetLists, cs.MethodGet)
// Fetch subscriber.
this.fetchRecord(this.state.subID)
} else {
this.setState({ record: this.props.record, loading: false })
}
}
fetchRecord = (id) => {
this.props.request(cs.Routes.GetSubscriber, cs.MethodGet, { id: id }).then((r) => {
this.setState({ record: r.data.data, loading: false })
}).catch(e => {
notification["error"]({ placement: cs.MsgPosition, message: "Error", description: e.message })
})
}
setFormRef = (r) => {
this.setState({ formRef: r })
}
submitForm = (e, cb) => {
if(this.state.formRef) {
this.state.formRef.handleSubmit(e, cb)
}
}
render() {
return (
<section className="content">
<header className="header">
<Row>
<Col span={ 20 }>
{ !this.state.record.id &&
<h1>Add subscriber</h1>
}
{ this.state.record.id &&
<div>
<h1>
<Tag color={ tagColors.hasOwnProperty(this.state.record.status) ? tagColors[this.state.record.status] : "" }>{ this.state.record.status }</Tag>
{" "}
{ this.state.record.name } ({ this.state.record.email })
</h1>
<span className="text-small text-grey">ID { this.state.record.id } / UUID { this.state.record.uuid }</span>
</div>
}
</Col>
<Col span={ 2 }>
</Col>
</Row>
</header>
<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

View File

@ -3,6 +3,7 @@ import { Link } from "react-router-dom"
import { Row, Col, Modal, Form, Input, Select, Button, Table, Icon, Tooltip, Tag, Popconfirm, Spin, notification, Radio } from "antd"
import Utils from "./utils"
import Subscriber from "./Subscriber"
import * as cs from "./constants"
@ -11,183 +12,6 @@ const tagColors = {
"blacklisted": "red"
}
class CreateFormDef extends React.PureComponent {
state = {
confirmDirty: false,
attribs: {},
modalWaiting: false
}
componentDidMount() {
this.setState({ attribs: this.props.record.attribs })
}
// 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
}
values["attribs"] = {}
let a = this.props.form.getFieldValue("attribs-json")
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({ modalWaiting: 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` })
this.props.fetchRecords()
this.props.onClose()
}).catch(e => {
notification["error"]({ message: "Error", description: e.message })
this.setState({ modalWaiting: 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(() => {
notification["success"]({ message: "Subscriber modified", description: `${values["email"]} modified` })
// Reload the table.
this.props.fetchRecords()
this.props.onClose()
}).catch(e => {
notification["error"]({ message: "Error", description: e.message })
this.setState({ modalWaiting: false })
})
}
}
modalTitle(formType, record) {
if(formType === cs.FormCreate) {
return "Add subscriber"
}
return (
<div>
<Tag color={ tagColors.hasOwnProperty(record.status) ? tagColors[record.status] : "" }>{ record.status }</Tag>
{" "}
{ record.name } ({ record.email })
<br />
<span className="text-tiny text-grey">ID { record.id } &mdash; UUID { record.uuid }</span>
</div>
)
}
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
}
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 ]
}
return (
<Modal visible={ true } width="750px"
className="subscriber-modal"
title={ this.modalTitle(formType, record) }
okText={ this.state.form === cs.FormCreate ? "Add" : "Save" }
confirmLoading={ this.state.modalWaiting }
onCancel={ onClose }
onOk={ this.handleSubmit }
okButtonProps={{ disabled: this.props.reqStates[cs.ModelSubscribers] === cs.StatePending }}>
<div id="modal-alert-container"></div>
<Spin spinning={ this.props.reqStates[cs.ModelSubscribers] === cs.StatePending }>
<Form onSubmit={ this.handleSubmit }>
<Form.Item {...formItemLayout} label="E-mail">
{getFieldDecorator("email", {
initialValue: record.email,
rules: [{ required: true }]
})(<Input autoFocus pattern="(.+?)@(.+?)" maxLength="200" />)}
</Form.Item>
<Form.Item {...formItemLayout} label="Name">
{getFieldDecorator("name", {
initialValue: record.name,
rules: [{ required: true }]
})(<Input maxLength="200" />)}
</Form.Item>
<Form.Item {...formItemLayout} 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 {...formItemLayout} 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>
<section>
<h3>Attributes</h3>
<p className="ant-form-extra">Attributes can be defined as a JSON map, for example:
{'{"age": 30, "color": "red", "is_user": true}'}. <a href="">More info</a>.</p>
<div className="json-editor">
{getFieldDecorator("attribs-json", {
initialValue: JSON.stringify(this.state.attribs, null, 4)
})(
<Input.TextArea placeholder="{}"
rows={10}
readOnly={false}
autosize={{ minRows: 5, maxRows: 10 }} />)}
</div>
</section>
</Form>
</Spin>
</Modal>
)
}
}
class ListsFormDef extends React.PureComponent {
state = {
modalWaiting: false
@ -273,7 +97,6 @@ class ListsFormDef extends React.PureComponent {
}
}
const CreateForm = Form.create()(CreateFormDef)
const ListsForm = Form.create()(ListsFormDef)
class Subscribers extends React.PureComponent {
@ -282,6 +105,7 @@ class Subscribers extends React.PureComponent {
state = {
formType: null,
listsFormVisible: false,
modalForm: null,
record: {},
queryParams: {
page: 1,
@ -327,7 +151,14 @@ class Subscribers extends React.PureComponent {
const out = [];
out.push(
<div key={`sub-email-${ record.id }`} className="sub-name">
<a role="button" onClick={() => { this.handleShowEditForm(record)}}>{text}</a>
<Link to={ `/subscribers/${record.id}` } onClick={(e) => {
// Open the individual subscriber page on ctrl+click
// and the modal otherwise.
if(!e.ctrlKey) {
this.handleShowEditForm(record)
e.preventDefault()
}
}}>{ text }</Link>
</div>
)
@ -350,7 +181,14 @@ class Subscribers extends React.PureComponent {
width: "15%",
render: (text, record) => {
return (
<a role="button" onClick={() => this.handleShowEditForm(record)}>{text}</a>
<Link to={ `/subscribers/${record.id}` } onClick={(e) => {
// Open the individual subscriber page on ctrl+click
// and the modal otherwise.
if(!e.ctrlKey) {
this.handleShowEditForm(record)
e.preventDefault()
}
}}>{ text }</Link>
)
}
},
@ -718,14 +556,38 @@ class Subscribers extends React.PureComponent {
}}
/>
{ this.state.formType !== null && <CreateForm {...this.props}
formType={ this.state.formType }
record={ this.state.record }
lists={ this.props.data[cs.ModelLists] }
list={ this.state.queryParams.list }
fetchRecords={ this.fetchRecords }
queryParams= { this.state.queryParams }
onClose={ this.handleHideForm } />
{ this.state.formType !== null &&
<Modal visible={ true } width="750px"
className="subscriber-modal"
okText={ this.state.form === cs.FormCreate ? "Add" : "Save" }
confirmLoading={ this.state.modalWaiting }
onOk={(e) => {
if(!this.state.modalForm) {
return;
}
// This submits the form embedded in the Subscriber component.
this.state.modalForm.submitForm(e, (ok) => {
if(ok) {
this.handleHideForm()
this.fetchRecords()
}
})
}}
onCancel={ this.handleHideForm }
okButtonProps={{ disabled: this.props.reqStates[cs.ModelSubscribers] === cs.StatePending }}>
<Subscriber {...this.props}
isModal={ true }
formType={ this.state.formType }
record={ this.state.record }
ref={ (r) => {
if(!r) {
return
}
this.setState({ modalForm: r })
}}/>
</Modal>
}
{ this.state.listsFormVisible && <ListsForm {...this.props}

View File

@ -55,12 +55,16 @@ export const Routes = {
GetDashboarcStats: "/api/dashboard/stats",
GetUsers: "/api/users",
// Lists.
GetLists: "/api/lists",
CreateList: "/api/lists",
UpdateList: "/api/lists/:id",
DeleteList: "/api/lists/:id",
// Subscribers.
ViewSubscribers: "/subscribers",
GetSubscribers: "/api/subscribers",
GetSubscriber: "/api/subscribers/:id",
GetSubscribersByList: "/api/subscribers/lists/:listID",
PreviewCampaign: "/api/campaigns/:id/preview",
CreateSubscriber: "/api/subscribers",
@ -71,11 +75,11 @@ export const Routes = {
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",
@ -88,10 +92,12 @@ export const Routes = {
UpdateCampaignStatus: "/api/campaigns/:id/status",
DeleteCampaign: "/api/campaigns/:id",
// Media.
GetMedia: "/api/media",
AddMedia: "/api/media",
DeleteMedia: "/api/media/:id",
// Templates.
GetTemplates: "/api/templates",
PreviewTemplate: "/api/templates/:id/preview",
PreviewNewTemplate: "/api/templates/preview",
@ -100,6 +106,7 @@ export const Routes = {
SetDefaultTemplate: "/api/templates/:id/default",
DeleteTemplate: "/api/templates/:id",
// Import.
UploadRouteImport: "/api/import/subscribers",
GetRouteImportStats: "/api/import/subscribers",
GetRouteImportLogs: "/api/import/subscribers/logs"