Merge pull request #552 from mailpoet/newsletter_listing

Post notification history listing
This commit is contained in:
Tautvidas Sipavičius
2016-07-22 16:20:00 +03:00
committed by GitHub
20 changed files with 533 additions and 228 deletions

View File

@ -1,6 +1,7 @@
import MailPoet from 'mailpoet' import MailPoet from 'mailpoet'
import jQuery from 'jquery' import jQuery from 'jquery'
import React from 'react' import React from 'react'
import _ from 'underscore'
import { Router, Link } from 'react-router' import { Router, Link } from 'react-router'
import classNames from 'classnames' import classNames from 'classnames'
import ListingBulkActions from 'listing/bulk_actions.jsx' import ListingBulkActions from 'listing/bulk_actions.jsx'
@ -13,7 +14,7 @@ import ListingFilters from 'listing/filters.jsx'
const ListingItem = React.createClass({ const ListingItem = React.createClass({
getInitialState: function() { getInitialState: function() {
return { return {
toggled: true expanded: false
}; };
}, },
handleSelectItem: function(e) { handleSelectItem: function(e) {
@ -34,7 +35,7 @@ const ListingItem = React.createClass({
this.props.onDeleteItem(id); this.props.onDeleteItem(id);
}, },
handleToggleItem: function(id) { handleToggleItem: function(id) {
this.setState({ toggled: !this.state.toggled }); this.setState({ expanded: !this.state.expanded });
}, },
render: function() { render: function() {
var checkbox = false; var checkbox = false;
@ -182,7 +183,7 @@ const ListingItem = React.createClass({
); );
} }
const row_classes = classNames({ 'is-expanded': !this.state.toggled }); const row_classes = classNames({ 'is-expanded': this.state.expanded });
return ( return (
<tr className={ row_classes }> <tr className={ row_classes }>
@ -303,13 +304,12 @@ const Listing = React.createClass({
getParam: function(param) { getParam: function(param) {
const regex = /(.*)\[(.*)\]/; const regex = /(.*)\[(.*)\]/;
const matches = regex.exec(param); const matches = regex.exec(param);
return [matches[1], matches[2]] return [matches[1], matches[2]];
}, },
initWithParams: function(params) { initWithParams: function(params) {
let state = this.getInitialState(); let state = this.getInitialState();
// check for url params // check for url params
if (params.splat !== undefined) { if (params.splat) {
params.splat.split('/').map(param => { params.splat.split('/').map(param => {
let [key, value] = this.getParam(param); let [key, value] = this.getParam(param);
switch(key) { switch(key) {
@ -348,6 +348,17 @@ const Listing = React.createClass({
this.getItems(); this.getItems();
}.bind(this)); }.bind(this));
}, },
getParams: function() {
// get all route parameters (without the "splat")
let params = _.omit(this.props.params, 'splat');
// TODO:
// find a way to set the "type" in the routes definition
// so that it appears in `this.props.params`
if (this.props.type) {
params.type = this.props.type;
}
return params;
},
setParams: function() { setParams: function() {
if (this.props.location) { if (this.props.location) {
let params = Object.keys(this.state) let params = Object.keys(this.state)
@ -378,18 +389,38 @@ const Listing = React.createClass({
.filter(key => { return (key !== undefined) }) .filter(key => { return (key !== undefined) })
.join('/'); .join('/');
// prepend url with "tab" if specified // set url
if (this.props.tab !== undefined) { let url = this.getUrlWithParams(params);
params = `/${ this.props.tab }/${ params }`;
} else {
params = `/${ params }`;
}
if (this.props.location.pathname !== params) { if (this.props.location.pathname !== url) {
this.context.router.push(`${params}`); this.context.router.push(`${url}`);
} }
} }
}, },
getUrlWithParams: function(params) {
let base_url = (this.props.base_url !== undefined)
? this.props.base_url
: null;
if (base_url !== null) {
base_url = this.setBaseUrlParams(base_url);
return `/${ base_url }/${ params }`;
} else {
return `/${ params }`;
}
},
setBaseUrlParams: function(base_url) {
if (base_url.indexOf(':') !== -1) {
const params = this.getParams();
Object.keys(params).map((key) => {
if (base_url.indexOf(':'+key) !== -1) {
base_url = base_url.replace(':'+key, params[key]);
}
});
}
return base_url;
},
componentDidMount: function() { componentDidMount: function() {
if (this.isMounted()) { if (this.isMounted()) {
const params = this.props.params || {}; const params = this.props.params || {};
@ -416,7 +447,7 @@ const Listing = React.createClass({
endpoint: this.props.endpoint, endpoint: this.props.endpoint,
action: 'listing', action: 'listing',
data: { data: {
tab: (this.props.tab) ? this.props.tab : '', params: this.getParams(),
offset: (this.state.page - 1) * this.state.limit, offset: (this.state.page - 1) * this.state.limit,
limit: this.state.limit, limit: this.state.limit,
group: this.state.group, group: this.state.group,
@ -531,7 +562,7 @@ const Listing = React.createClass({
var data = params || {}; var data = params || {};
data.listing = { data.listing = {
tab: (this.props.tab) ? this.props.tab : '', params: this.getParams(),
offset: 0, offset: 0,
limit: 0, limit: 0,
filter: this.state.filter, filter: this.state.filter,

View File

@ -0,0 +1,152 @@
import React from 'react'
import MailPoet from 'mailpoet'
import classNames from 'classnames'
import jQuery from 'jquery'
const _QueueMixin = {
pauseSending: function(newsletter) {
MailPoet.Ajax.post({
endpoint: 'sendingQueue',
action: 'pause',
data: newsletter.id
}).done(function() {
jQuery('#resume_'+newsletter.id).show();
jQuery('#pause_'+newsletter.id).hide();
});
},
resumeSending: function(newsletter) {
MailPoet.Ajax.post({
endpoint: 'sendingQueue',
action: 'resume',
data: newsletter.id
}).done(function() {
jQuery('#pause_'+newsletter.id).show();
jQuery('#resume_'+newsletter.id).hide();
});
},
renderQueueStatus: function(newsletter) {
if (!newsletter.queue) {
return (
<span>{MailPoet.I18n.t('notSentYet')}</span>
);
} else {
if (newsletter.queue.status === 'scheduled') {
return (
<span>
{ MailPoet.I18n.t('scheduledFor') } { MailPoet.Date.format(newsletter.queue.scheduled_at) }
</span>
)
}
const progressClasses = classNames(
'mailpoet_progress',
{ 'mailpoet_progress_complete': newsletter.queue.status === 'completed'}
);
// calculate percentage done
const percentage = Math.round(
(newsletter.queue.count_processed * 100) / (newsletter.queue.count_total)
);
let label;
if (newsletter.queue.status === 'completed') {
label = (
<span>
{
MailPoet.I18n.t('newsletterQueueCompleted')
.replace(
"%$1d",
newsletter.queue.count_processed - newsletter.queue.count_failed
)
.replace(
"%$2d",
newsletter.queue.count_total
)
}
</span>
);
} else {
label = (
<span>
{ newsletter.queue.count_processed } / { newsletter.queue.count_total }
&nbsp;&nbsp;
<a
id={ 'resume_'+newsletter.id }
className="button"
style={{ display: (newsletter.queue.status === 'paused')
? 'inline-block': 'none' }}
href="javascript:;"
onClick={ this.resumeSending.bind(null, newsletter) }
>{MailPoet.I18n.t('resume')}</a>
<a
id={ 'pause_'+newsletter.id }
className="button mailpoet_pause"
style={{ display: (newsletter.queue.status === null)
? 'inline-block': 'none' }}
href="javascript:;"
onClick={ this.pauseSending.bind(null, newsletter) }
>{MailPoet.I18n.t('pause')}</a>
</span>
);
}
return (
<div>
<div className={ progressClasses }>
<span
className="mailpoet_progress_bar"
style={ { width: percentage + "%"} }
></span>
<span className="mailpoet_progress_label">
{ percentage + "%" }
</span>
</div>
<p style={{ textAlign:'center' }}>
{ label }
</p>
</div>
);
}
},
};
const _StatisticsMixin = {
renderStatistics: function(newsletter) {
if (
newsletter.statistics
&& newsletter.queue
&& newsletter.queue.status !== 'scheduled'
) {
const total_sent = ~~(newsletter.queue.count_processed);
let percentage_clicked = 0;
let percentage_opened = 0;
let percentage_unsubscribed = 0;
if (total_sent > 0) {
percentage_clicked = Math.round(
(~~(newsletter.statistics.clicked) * 100) / total_sent
);
percentage_opened = Math.round(
(~~(newsletter.statistics.opened) * 100) / total_sent
);
percentage_unsubscribed = Math.round(
(~~(newsletter.statistics.unsubscribed) * 100) / total_sent
);
}
return (
<span>
{ percentage_opened }%, { percentage_clicked }%, { percentage_unsubscribed }%
</span>
);
} else {
return (
<span>{MailPoet.I18n.t('notSentYet')}</span>
);
}
}
}
export { _QueueMixin as QueueMixin };
export { _StatisticsMixin as StatisticsMixin };

View File

@ -252,6 +252,20 @@ const NewsletterListNotification = React.createClass({
</span> </span>
); );
}, },
renderHistoryLink: function(newsletter) {
const childrenCount = ~~(newsletter.children_count);
if (childrenCount === 0) {
return (
MailPoet.I18n.t('notSentYet')
);
} else {
return (
<Link
to={ `/notification/history/${ newsletter.id }` }
>{ MailPoet.I18n.t('viewHistory') }</Link>
);
}
},
renderItem: function(newsletter, actions) { renderItem: function(newsletter, actions) {
const rowClasses = classNames( const rowClasses = classNames(
'manage-column', 'manage-column',
@ -277,7 +291,7 @@ const NewsletterListNotification = React.createClass({
{ this.renderSettings(newsletter) } { this.renderSettings(newsletter) }
</td> </td>
<td className="column" data-colname={ MailPoet.I18n.t('history') }> <td className="column" data-colname={ MailPoet.I18n.t('history') }>
<a href="#TODO">{ MailPoet.I18n.t('viewHistory') }</a> { this.renderHistoryLink(newsletter) }
</td> </td>
<td className="column-date" data-colname={ MailPoet.I18n.t('lastModifiedOn') }> <td className="column-date" data-colname={ MailPoet.I18n.t('lastModifiedOn') }>
<abbr>{ MailPoet.Date.format(newsletter.updated_at) }</abbr> <abbr>{ MailPoet.Date.format(newsletter.updated_at) }</abbr>
@ -299,7 +313,8 @@ const NewsletterListNotification = React.createClass({
location={ this.props.location } location={ this.props.location }
params={ this.props.params } params={ this.props.params }
endpoint="newsletters" endpoint="newsletters"
tab="notification" type="notification"
base_url="notification"
onRenderItem={ this.renderItem } onRenderItem={ this.renderItem }
columns={ columns } columns={ columns }
bulk_actions={ bulk_actions } bulk_actions={ bulk_actions }

View File

@ -0,0 +1,125 @@
import React from 'react'
import { Router, Link } from 'react-router'
import classNames from 'classnames'
import jQuery from 'jquery'
import MailPoet from 'mailpoet'
import Listing from 'listing/listing.jsx'
import ListingTabs from 'newsletters/listings/tabs.jsx'
import { QueueMixin, StatisticsMixin } from 'newsletters/listings/mixins.jsx'
const mailpoet_tracking_enabled = (!!(window['mailpoet_tracking_enabled']));
const columns = [
{
name: 'subject',
label: MailPoet.I18n.t('subject'),
},
{
name: 'status',
label: MailPoet.I18n.t('status')
},
{
name: 'segments',
label: MailPoet.I18n.t('lists')
},
{
name: 'statistics',
label: MailPoet.I18n.t('statistics'),
display: mailpoet_tracking_enabled
},
{
name: 'processed_at',
label: MailPoet.I18n.t('sentOn'),
}
];
const newsletter_actions = [
{
name: 'view',
link: function(newsletter) {
return (
<a href={ newsletter.preview_url } target="_blank">
{MailPoet.I18n.t('preview')}
</a>
);
}
}
];
const NewsletterListNotificationHistory = React.createClass({
mixins: [QueueMixin, StatisticsMixin],
renderItem: function(newsletter, actions) {
const rowClasses = classNames(
'manage-column',
'column-primary',
'has-row-actions'
);
const segments = newsletter.segments.map(function(segment) {
return segment.name
}).join(', ');
return (
<div>
<td className={ rowClasses }>
<strong>
<a
href={ newsletter.preview_url }
target="_blank"
>{ newsletter.subject }</a>
</strong>
{ actions }
</td>
<td className="column" data-colname={ MailPoet.I18n.t('status') }>
{ this.renderQueueStatus(newsletter) }
</td>
<td className="column" data-colname={ MailPoet.I18n.t('lists') }>
{ segments }
</td>
{ (mailpoet_tracking_enabled === true) ? (
<td className="column" data-colname={ MailPoet.I18n.t('statistics') }>
{ this.renderStatistics(newsletter) }
</td>
) : null }
<td className="column-date" data-colname={ MailPoet.I18n.t('lastModifiedOn') }>
<abbr>{ MailPoet.Date.format(newsletter.updated_at) }</abbr>
</td>
</div>
);
},
render: function() {
return (
<div>
<h1 className="title">
{MailPoet.I18n.t('pageTitle')} <Link className="page-title-action" to="/new">{MailPoet.I18n.t('new')}</Link>
</h1>
<ListingTabs tab="notification" />
<Link
className="page-title-action"
to="/notification"
>{MailPoet.I18n.t('backToPostNotifications')}</Link>
<Listing
limit={ mailpoet_listing_per_page }
location={ this.props.location }
params={ this.props.params }
endpoint="newsletters"
type="notification_history"
base_url="notification/history/:parent_id"
onRenderItem={ this.renderItem }
columns={columns}
item_actions={ newsletter_actions }
auto_refresh={ true }
sort_by="updated_at"
sort_order="desc"
/>
</div>
);
}
});
module.exports = NewsletterListNotificationHistory;

View File

@ -7,6 +7,8 @@ import MailPoet from 'mailpoet'
import Listing from 'listing/listing.jsx' import Listing from 'listing/listing.jsx'
import ListingTabs from 'newsletters/listings/tabs.jsx' import ListingTabs from 'newsletters/listings/tabs.jsx'
import { QueueMixin, StatisticsMixin } from 'newsletters/listings/mixins.jsx'
const mailpoet_tracking_enabled = (!!(window['mailpoet_tracking_enabled'])); const mailpoet_tracking_enabled = (!!(window['mailpoet_tracking_enabled']));
const messages = { const messages = {
@ -139,135 +141,7 @@ const newsletter_actions = [
]; ];
const NewsletterListStandard = React.createClass({ const NewsletterListStandard = React.createClass({
pauseSending: function(newsletter) { mixins: [QueueMixin, StatisticsMixin],
MailPoet.Ajax.post({
endpoint: 'sendingQueue',
action: 'pause',
data: newsletter.id
}).done(function() {
jQuery('#resume_'+newsletter.id).show();
jQuery('#pause_'+newsletter.id).hide();
});
},
resumeSending: function(newsletter) {
MailPoet.Ajax.post({
endpoint: 'sendingQueue',
action: 'resume',
data: newsletter.id
}).done(function() {
jQuery('#pause_'+newsletter.id).show();
jQuery('#resume_'+newsletter.id).hide();
});
},
renderStatus: function(newsletter) {
if (!newsletter.queue) {
return (
<span>{MailPoet.I18n.t('notSentYet')}</span>
);
} else {
if (newsletter.queue.status === 'scheduled') {
return (
<span>{MailPoet.I18n.t('scheduledFor')} { MailPoet.Date.format(newsletter.queue.scheduled_at) } </span>
)
}
const progressClasses = classNames(
'mailpoet_progress',
{ 'mailpoet_progress_complete': newsletter.queue.status === 'completed'}
);
// calculate percentage done
const percentage = Math.round(
(newsletter.queue.count_processed * 100) / (newsletter.queue.count_total)
);
let label;
if (newsletter.queue.status === 'completed') {
label = (
<span>
{
MailPoet.I18n.t('newsletterQueueCompleted')
.replace("%$1d", newsletter.queue.count_processed - newsletter.queue.count_failed)
.replace("%$2d", newsletter.queue.count_total)
}
</span>
);
} else {
label = (
<span>
{ newsletter.queue.count_processed } / { newsletter.queue.count_total }
&nbsp;&nbsp;
<a
id={ 'resume_'+newsletter.id }
className="button"
style={{ display: (newsletter.queue.status === 'paused') ? 'inline-block': 'none' }}
href="javascript:;"
onClick={ this.resumeSending.bind(null, newsletter) }
>{MailPoet.I18n.t('resume')}</a>
<a
id={ 'pause_'+newsletter.id }
className="button mailpoet_pause"
style={{ display: (newsletter.queue.status === null) ? 'inline-block': 'none' }}
href="javascript:;"
onClick={ this.pauseSending.bind(null, newsletter) }
>{MailPoet.I18n.t('pause')}</a>
</span>
);
}
return (
<div>
<div className={ progressClasses }>
<span
className="mailpoet_progress_bar"
style={ { width: percentage + "%"} }
></span>
<span className="mailpoet_progress_label">
{ percentage + "%" }
</span>
</div>
<p style={{ textAlign:'center' }}>
{ label }
</p>
</div>
);
}
},
renderStatistics: function(newsletter) {
if (mailpoet_tracking_enabled === false) {
return;
}
if (newsletter.statistics && newsletter.queue && newsletter.queue.status !== 'scheduled') {
const total_sent = ~~(newsletter.queue.count_processed);
let percentage_clicked = 0;
let percentage_opened = 0;
let percentage_unsubscribed = 0;
if (total_sent > 0) {
percentage_clicked = Math.round(
(~~(newsletter.statistics.clicked) * 100) / total_sent
);
percentage_opened = Math.round(
(~~(newsletter.statistics.opened) * 100) / total_sent
);
percentage_unsubscribed = Math.round(
(~~(newsletter.statistics.unsubscribed) * 100) / total_sent
);
}
return (
<span>
{ percentage_opened }%, { percentage_clicked }%, { percentage_unsubscribed }%
</span>
);
} else {
return (
<span>{MailPoet.I18n.t('notSentYet')}</span>
);
}
},
renderItem: function(newsletter, actions) { renderItem: function(newsletter, actions) {
const rowClasses = classNames( const rowClasses = classNames(
'manage-column', 'manage-column',
@ -291,7 +165,7 @@ const NewsletterListStandard = React.createClass({
{ actions } { actions }
</td> </td>
<td className="column" data-colname={ MailPoet.I18n.t('status') }> <td className="column" data-colname={ MailPoet.I18n.t('status') }>
{ this.renderStatus(newsletter) } { this.renderQueueStatus(newsletter) }
</td> </td>
<td className="column" data-colname={ MailPoet.I18n.t('lists') }> <td className="column" data-colname={ MailPoet.I18n.t('lists') }>
{ segments } { segments }
@ -321,7 +195,8 @@ const NewsletterListStandard = React.createClass({
location={ this.props.location } location={ this.props.location }
params={ this.props.params } params={ this.props.params }
endpoint="newsletters" endpoint="newsletters"
tab="standard" type="standard"
base_url="standard"
onRenderItem={this.renderItem} onRenderItem={this.renderItem}
columns={columns} columns={columns}
bulk_actions={ bulk_actions } bulk_actions={ bulk_actions }

View File

@ -343,7 +343,8 @@ const NewsletterListWelcome = React.createClass({
location={ this.props.location } location={ this.props.location }
params={ this.props.params } params={ this.props.params }
endpoint="newsletters" endpoint="newsletters"
tab="welcome" type="welcome"
base_url="welcome"
onRenderItem={ this.renderItem } onRenderItem={ this.renderItem }
columns={ columns } columns={ columns }
bulk_actions={ bulk_actions } bulk_actions={ bulk_actions }

View File

@ -14,12 +14,13 @@ import NewsletterTypeNotification from 'newsletters/types/notification/notificat
import NewsletterListStandard from 'newsletters/listings/standard.jsx' import NewsletterListStandard from 'newsletters/listings/standard.jsx'
import NewsletterListWelcome from 'newsletters/listings/welcome.jsx' import NewsletterListWelcome from 'newsletters/listings/welcome.jsx'
import NewsletterListNotification from 'newsletters/listings/notification.jsx' import NewsletterListNotification from 'newsletters/listings/notification.jsx'
import NewsletterListNotificationHistory from 'newsletters/listings/notification_history.jsx'
const history = useRouterHistory(createHashHistory)({ queryKey: false }); const history = useRouterHistory(createHashHistory)({ queryKey: false });
const App = React.createClass({ const App = React.createClass({
render() { render() {
return this.props.children return this.props.children;
} }
}); });
@ -31,18 +32,16 @@ if(container) {
<Route path="/" component={ App }> <Route path="/" component={ App }>
<IndexRedirect to="standard" /> <IndexRedirect to="standard" />
{/* Listings */} {/* Listings */}
<Route name="listing/standard" path="standard" component={ NewsletterListStandard } /> <Route path="standard(/)**" params={{ tab: 'standard' }} component={ NewsletterListStandard } />
<Route name="listing/welcome" path="welcome" component={ NewsletterListWelcome } /> <Route path="welcome(/)**" component={ NewsletterListWelcome } />
<Route name="listing/notification" path="notification" component={ NewsletterListNotification } /> <Route path="notification/history/:parent_id(/)**" component={ NewsletterListNotificationHistory } />
<Route path="standard/*" component={ NewsletterListStandard } /> <Route path="notification(/)**" component={ NewsletterListNotification } />
<Route path="welcome/*" component={ NewsletterListWelcome } />
<Route path="notification/*" component={ NewsletterListNotification } />
{/* Newsletter: type selection */} {/* Newsletter: type selection */}
<Route path="new" component={ NewsletterTypes } /> <Route path="new" component={ NewsletterTypes } />
{/* New newsletter: types */} {/* New newsletter: types */}
<Route name="new/standard" path="new/standard" component={ NewsletterTypeStandard } /> <Route path="new/standard" component={ NewsletterTypeStandard } />
<Route name="new/welcome" path="new/welcome" component={ NewsletterTypeWelcome } /> <Route path="new/welcome" component={ NewsletterTypeWelcome } />
<Route name="new/notification" path="new/notification" component={ NewsletterTypeNotification } /> <Route path="new/notification" component={ NewsletterTypeNotification } />
{/* Template selection */} {/* Template selection */}
<Route name="template" path="template/:id" component={ NewsletterTemplates } /> <Route name="template" path="template/:id" component={ NewsletterTemplates } />
{/* Sending options */} {/* Sending options */}

View File

@ -32,7 +32,7 @@ class API {
$endpoint = self::ENDPOINT_NAMESPACE . ucfirst($this->endpoint); $endpoint = self::ENDPOINT_NAMESPACE . ucfirst($this->endpoint);
if(!$this->api_request) return; if(!$this->api_request) return;
if(!$this->endpoint || !class_exists($endpoint)) { if(!$this->endpoint || !class_exists($endpoint)) {
$this->terminateRequest(self::RESPONSE_ERROR, __('Invalid API endpoint.')); self::terminateRequest(self::RESPONSE_ERROR, __('Invalid API endpoint.'));
} }
$this->callEndpoint( $this->callEndpoint(
$endpoint, $endpoint,
@ -43,7 +43,7 @@ class API {
function callEndpoint($endpoint, $action, $data) { function callEndpoint($endpoint, $action, $data) {
if(!method_exists($endpoint, $action)) { if(!method_exists($endpoint, $action)) {
$this->terminateRequest(self::RESPONSE_ERROR, __('Invalid API action.')); self::terminateRequest(self::RESPONSE_ERROR, __('Invalid API action.'));
} }
call_user_func( call_user_func(
array( array(
@ -83,7 +83,7 @@ class API {
return add_query_arg($params, home_url()); return add_query_arg($params, home_url());
} }
function terminateRequest($code, $message) { static function terminateRequest($code, $message) {
status_header($code, $message); status_header($code, $message);
exit; exit;
} }

View File

@ -56,6 +56,7 @@ class Initializer {
\ORM::configure(Env::$db_source_name); \ORM::configure(Env::$db_source_name);
\ORM::configure('username', Env::$db_username); \ORM::configure('username', Env::$db_username);
\ORM::configure('password', Env::$db_password); \ORM::configure('password', Env::$db_password);
\ORM::configure('logging', WP_DEBUG);
\ORM::configure('driver_options', array( \ORM::configure('driver_options', array(
\PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8', \PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8',
\PDO::MYSQL_ATTR_INIT_COMMAND => \PDO::MYSQL_ATTR_INIT_COMMAND =>

View File

@ -173,6 +173,7 @@ class Migrator {
function newsletters() { function newsletters() {
$attributes = array( $attributes = array(
'id mediumint(9) NOT NULL AUTO_INCREMENT,', 'id mediumint(9) NOT NULL AUTO_INCREMENT,',
'parent_id mediumint(9) NULL,',
'subject varchar(250) NOT NULL DEFAULT "",', 'subject varchar(250) NOT NULL DEFAULT "",',
'type varchar(20) NOT NULL DEFAULT "standard",', 'type varchar(20) NOT NULL DEFAULT "standard",',
'sender_address varchar(150) NOT NULL DEFAULT "",', 'sender_address varchar(150) NOT NULL DEFAULT "",',

View File

@ -67,29 +67,33 @@ class Scheduler {
} }
function processPostNotificationNewsletter($newsletter, $queue) { function processPostNotificationNewsletter($newsletter, $queue) {
// ensure that segments exist
$segments = $newsletter->segments()->findArray(); $segments = $newsletter->segments()->findArray();
if(empty($segments)) { if(empty($segments)) {
$this->deleteQueueOrUpdateNextRunDate($queue, $newsletter); $this->deleteQueueOrUpdateNextRunDate($queue, $newsletter);
return; return;
} }
$segment_ids = array_map(function($segment) { $segment_ids = array_map(function($segment) {
return $segment['id']; return (int)$segment['id'];
}, $segments); }, $segments);
// ensure that subscribers are in segments
$subscribers = Subscriber::getSubscribedInSegments($segment_ids) $subscribers = Subscriber::getSubscribedInSegments($segment_ids)
->findArray(); ->findArray();
$subscribers = Helpers::arrayColumn($subscribers, 'subscriber_id'); $subscribers = Helpers::arrayColumn($subscribers, 'subscriber_id');
$subscribers = array_unique($subscribers); $subscribers = array_unique($subscribers);
if(empty($subscribers)) { if(empty($subscribers)) {
$this->deleteQueueOrUpdateNextRunDate($queue, $newsletter); $this->deleteQueueOrUpdateNextRunDate($queue, $newsletter);
return; return;
} }
// schedule new queue if the post notification is not destined for immediate delivery
if($newsletter->intervalType !== NewsletterScheduler::INTERVAL_IMMEDIATELY) { // create a duplicate newsletter that acts as a history record
$new_queue = SendingQueue::create(); $notification_history = $this->createNotificationHistory($newsletter->id);
$new_queue->newsletter_id = $newsletter->id; if(!$notification_history) return;
$new_queue->status = SendingQueue::STATUS_SCHEDULED;
self::deleteQueueOrUpdateNextRunDate($new_queue, $newsletter); // queue newsletter for delivery
} $queue->newsletter_id = $notification_history->id;
$queue->subscribers = serialize( $queue->subscribers = serialize(
array( array(
'to_process' => $subscribers 'to_process' => $subscribers
@ -164,7 +168,7 @@ class Scheduler {
return true; return true;
} }
private function deleteQueueOrUpdateNextRunDate($queue, $newsletter) { function deleteQueueOrUpdateNextRunDate($queue, $newsletter) {
if($newsletter->intervalType === NewsletterScheduler::INTERVAL_IMMEDIATELY) { if($newsletter->intervalType === NewsletterScheduler::INTERVAL_IMMEDIATELY) {
$queue->delete(); $queue->delete();
} else { } else {
@ -173,4 +177,12 @@ class Scheduler {
$queue->save(); $queue->save();
} }
} }
}
function createNotificationHistory($newsletter_id) {
$newsletter = Newsletter::findOne($newsletter_id);
$notification_history = $newsletter->createNotificationHistory();
return ($notification_history->getErrors() === false) ?
$notification_history :
false;
}
}

View File

@ -178,13 +178,14 @@ class SendingQueue {
if(!$queue->count_to_process) { if(!$queue->count_to_process) {
$queue->processed_at = current_time('mysql'); $queue->processed_at = current_time('mysql');
$queue->status = SendingQueueModel::STATUS_COMPLETED; $queue->status = SendingQueueModel::STATUS_COMPLETED;
// set newsletter status to sent // if it's a standard or post notificaiton newsletter, update its status to sent
$newsletter = NewsletterModel::findOne($queue->newsletter_id); $newsletter = NewsletterModel::findOne($queue->newsletter_id);
// if it's a standard newsletter, update its status if($newsletter->type === NewsletterModel::TYPE_STANDARD ||
if($newsletter->type === NewsletterModel::TYPE_STANDARD) { $newsletter->type === NewsletterModel::TYPE_NOTIFICATION_HISTORY
) {
$newsletter->setStatus(NewsletterModel::STATUS_SENT); $newsletter->setStatus(NewsletterModel::STATUS_SENT);
} }
} }
return $queue->save(); return $queue->save();
} }
} }

View File

@ -1,6 +1,7 @@
<?php <?php
namespace MailPoet\Cron\Workers\SendingQueue\Tasks; namespace MailPoet\Cron\Workers\SendingQueue\Tasks;
use MailPoet\Models\Newsletter;
use MailPoet\Models\NewsletterPost; use MailPoet\Models\NewsletterPost;
if(!defined('ABSPATH')) exit; if(!defined('ABSPATH')) exit;
@ -18,9 +19,12 @@ class Posts {
if(!count($matched_posts_ids)) { if(!count($matched_posts_ids)) {
return $newsletter; return $newsletter;
} }
$newsletter_id = ($newsletter['type'] === Newsletter::TYPE_NOTIFICATION_HISTORY) ?
$newsletter['parent_id'] :
$newsletter['id'];
foreach($matched_posts_ids as $post_id) { foreach($matched_posts_ids as $post_id) {
$newletter_post = NewsletterPost::create(); $newletter_post = NewsletterPost::create();
$newletter_post->newsletter_id = $newsletter['id']; $newletter_post->newsletter_id = $newsletter_id;
$newletter_post->post_id = $post_id; $newletter_post->post_id = $post_id;
$newletter_post->save(); $newletter_post->save();
} }

View File

@ -16,8 +16,8 @@ class Handler {
$this->model = \Model::factory($this->model_class); $this->model = \Model::factory($this->model_class);
$this->data = array( $this->data = array(
// tabs // extra parameters
'tab' => (isset($data['tab']) ? $data['tab'] : false), 'params' => (isset($data['params']) ? $data['params'] : array()),
// pagination // pagination
'offset' => (isset($data['offset']) ? (int)$data['offset'] : 0), 'offset' => (isset($data['offset']) ? (int)$data['offset'] : 0),
'limit' => (isset($data['limit']) 'limit' => (isset($data['limit'])
@ -121,7 +121,6 @@ class Handler {
$this->table_name.'.'.$this->data['sort_by'] $this->table_name.'.'.$this->data['sort_by']
) )
->findMany(); ->findMany();
} else { } else {
$this->setFilter(); $this->setFilter();
$this->setGroup(); $this->setGroup();

View File

@ -10,6 +10,7 @@ class Newsletter extends Model {
const TYPE_STANDARD = 'standard'; const TYPE_STANDARD = 'standard';
const TYPE_WELCOME = 'welcome'; const TYPE_WELCOME = 'welcome';
const TYPE_NOTIFICATION = 'notification'; const TYPE_NOTIFICATION = 'notification';
const TYPE_NOTIFICATION_HISTORY = 'notification_history';
// standard newsletters // standard newsletters
const STATUS_DRAFT = 'draft'; const STATUS_DRAFT = 'draft';
@ -19,7 +20,6 @@ class Newsletter extends Model {
// automatic newsletters status // automatic newsletters status
const STATUS_ACTIVE = 'active'; const STATUS_ACTIVE = 'active';
function __construct() { function __construct() {
parent::__construct(); parent::__construct();
@ -56,7 +56,6 @@ class Newsletter extends Model {
} }
function duplicate($data = array()) { function duplicate($data = array()) {
// get current newsletter's data as an array
$newsletter_data = $this->asArray(); $newsletter_data = $this->asArray();
// remove id so that it creates a new record // remove id so that it creates a new record
@ -109,6 +108,43 @@ class Newsletter extends Model {
return $duplicate; return $duplicate;
} }
function createNotificationHistory() {
$newsletter_data = $this->asArray();
// remove id so that it creates a new record
unset($newsletter_data['id']);
$data = array_merge(
$newsletter_data,
array(
'parent_id' => $this->id,
'type' => self::TYPE_NOTIFICATION_HISTORY,
'status' => self::STATUS_SENDING
)
);
$notification_history = self::create();
$notification_history->hydrate($data);
$notification_history->save();
if($notification_history->getErrors() === false) {
// create relationships between notification history and segments
$segments = $this->segments()->findArray();
if(!empty($segments)) {
foreach($segments as $segment) {
$relation = NewsletterSegment::create();
$relation->segment_id = $segment['id'];
$relation->newsletter_id = $notification_history->id;
$relation->save();
}
}
}
return $notification_history;
}
function asArray() { function asArray() {
$model = parent::asArray(); $model = parent::asArray();
@ -125,6 +161,14 @@ class Newsletter extends Model {
return parent::delete(); return parent::delete();
} }
function children() {
return $this->has_many(
__NAMESPACE__.'\Newsletter',
'parent_id',
'id'
);
}
function segments() { function segments() {
return $this->has_many_through( return $this->has_many_through(
__NAMESPACE__.'\Segment', __NAMESPACE__.'\Segment',
@ -139,6 +183,11 @@ class Newsletter extends Model {
return $this; return $this;
} }
function withChildrenCount() {
$this->children_count = $this->children()->count();
return $this;
}
function options() { function options() {
return $this->has_many_through( return $this->has_many_through(
__NAMESPACE__.'\NewsletterOptionField', __NAMESPACE__.'\NewsletterOptionField',
@ -189,15 +238,11 @@ class Newsletter extends Model {
} else { } else {
$this->statistics = $statistics->asArray(); $this->statistics = $statistics->asArray();
} }
return $this; return $this;
} }
function getStatistics() { function getStatistics() {
if($this->queue === false) { $statistics_query = SendingQueue::tableAlias('queues')
return false;
}
return SendingQueue::tableAlias('queues')
->selectExpr( ->selectExpr(
'COUNT(DISTINCT(clicks.subscriber_id)) as clicked, ' . 'COUNT(DISTINCT(clicks.subscriber_id)) as clicked, ' .
'COUNT(DISTINCT(opens.subscriber_id)) as opened, ' . 'COUNT(DISTINCT(opens.subscriber_id)) as opened, ' .
@ -217,9 +262,22 @@ class Newsletter extends Model {
MP_STATISTICS_UNSUBSCRIBES_TABLE, MP_STATISTICS_UNSUBSCRIBES_TABLE,
'queues.id = unsubscribes.queue_id', 'queues.id = unsubscribes.queue_id',
'unsubscribes' 'unsubscribes'
) );
->where('queues.id', $this->queue['id'])
->findOne(); if($this->type === self::TYPE_WELCOME) {
return $statistics_query
->where('queues.newsletter_id', $this->id)
->where('queues.status', SendingQueue::STATUS_COMPLETED)
->findOne();
} else {
if($this->queue === false) {
return false;
} else {
return $statistics_query
->where('queues.id', $this->queue['id'])
->findOne();
}
}
} }
static function search($orm, $search = '') { static function search($orm, $search = '') {
@ -230,6 +288,15 @@ class Newsletter extends Model {
} }
static function filters($data = array()) { static function filters($data = array()) {
$type = isset($data['params']['type']) ? $data['params']['type'] : null;
// newsletter types without filters
if(in_array($type, array(
self::TYPE_NOTIFICATION_HISTORY
))) {
return false;
}
$segments = Segment::orderByAsc('name')->findMany(); $segments = Segment::orderByAsc('name')->findMany();
$segment_list = array(); $segment_list = array();
$segment_list[] = array( $segment_list[] = array(
@ -239,7 +306,7 @@ class Newsletter extends Model {
foreach($segments as $segment) { foreach($segments as $segment) {
$newsletters = $segment->newsletters() $newsletters = $segment->newsletters()
->filter('filterType', $data['tab']) ->filter('filterType', $type)
->filter('groupBy', $data); ->filter('groupBy', $data);
$newsletters_count = $newsletters->count(); $newsletters_count = $newsletters->count();
@ -260,18 +327,32 @@ class Newsletter extends Model {
} }
static function filterBy($orm, $data = array()) { static function filterBy($orm, $data = array()) {
$type = isset($data['tab']) ? $data['tab'] : null; // apply filters
if(!empty($data['filter'])) { if(!empty($data['filter'])) {
foreach($data['filter'] as $key => $value) { foreach($data['filter'] as $key => $value) {
if($key === 'segment') { if($key === 'segment') {
$segment = Segment::findOne($value); $segment = Segment::findOne($value);
if($segment !== false) { if($segment !== false) {
$orm = $segment->newsletters()->filter('filterType', $type); $orm = $segment->newsletters();
} }
} }
} }
} }
// filter by type
$type = isset($data['params']['type']) ? $data['params']['type'] : null;
if($type !== null) {
$orm->filter('filterType', $type);
}
// filter by parent id
$parent_id = isset($data['params']['parent_id'])
? (int)$data['params']['parent_id']
: null;
if($parent_id !== null) {
$orm->where('parent_id', $parent_id);
}
return $orm; return $orm;
} }
@ -306,7 +387,14 @@ class Newsletter extends Model {
} }
static function groups($data = array()) { static function groups($data = array()) {
$type = isset($data['tab']) ? $data['tab'] : null; $type = isset($data['params']['type']) ? $data['params']['type'] : null;
// newsletter types without groups
if(in_array($type, array(
self::TYPE_NOTIFICATION_HISTORY
))) {
return false;
}
$groups = array( $groups = array(
array( array(
@ -431,7 +519,8 @@ class Newsletter extends Model {
if(in_array($type, array( if(in_array($type, array(
self::TYPE_STANDARD, self::TYPE_STANDARD,
self::TYPE_WELCOME, self::TYPE_WELCOME,
self::TYPE_NOTIFICATION self::TYPE_NOTIFICATION,
self::TYPE_NOTIFICATION_HISTORY
))) { ))) {
$orm->where('type', $type); $orm->where('type', $type);
} }
@ -447,7 +536,6 @@ class Newsletter extends Model {
'updated_at', 'updated_at',
'deleted_at' 'deleted_at'
)) ))
->filter('filterType', $data['tab'])
->filter('filterBy', $data) ->filter('filterBy', $data)
->filter('groupBy', $data) ->filter('groupBy', $data)
->filter('search', $data['search']); ->filter('search', $data['search']);
@ -523,4 +611,4 @@ class Newsletter extends Model {
->whereIn('options.value', $segments) ->whereIn('options.value', $segments)
->findMany(); ->findMany();
} }
} }

View File

@ -1,6 +1,7 @@
<?php <?php
namespace MailPoet\Newsletter\Renderer\Blocks; namespace MailPoet\Newsletter\Renderer\Blocks;
use MailPoet\Models\Newsletter;
use MailPoet\Newsletter\Renderer\StylesHelper; use MailPoet\Newsletter\Renderer\StylesHelper;
class Renderer { class Renderer {
@ -10,7 +11,10 @@ class Renderer {
function __construct(array $newsletter, $posts = false) { function __construct(array $newsletter, $posts = false) {
$this->newsletter = $newsletter; $this->newsletter = $newsletter;
$this->posts = array(); $this->posts = array();
$this->ALC = new \MailPoet\Newsletter\AutomatedLatestContent($this->newsletter['id']); $newsletter_id = ($newsletter['type'] === Newsletter::TYPE_NOTIFICATION_HISTORY) ?
$newsletter['parent_id'] :
$newsletter['id'];
$this->ALC = new \MailPoet\Newsletter\AutomatedLatestContent($newsletter_id);
} }
function render($data, $column_count) { function render($data, $column_count) {
@ -45,12 +49,12 @@ class Renderer {
function processAutomatedLatestContent($args, $column_count) { function processAutomatedLatestContent($args, $column_count) {
$posts_to_exclude = $this->getPosts(); $posts_to_exclude = $this->getPosts();
$ALCPosts = $this->ALC->getPosts($args, $posts_to_exclude); $ALC_posts = $this->ALC->getPosts($args, $posts_to_exclude);
foreach($ALCPosts as $post) { foreach($ALC_posts as $post) {
$posts_to_exclude[] = $post->ID; $posts_to_exclude[] = $post->ID;
} }
$transformed_posts = array( $transformed_posts = array(
'blocks' => $this->ALC->transformPosts($args, $ALCPosts) 'blocks' => $this->ALC->transformPosts($args, $ALC_posts)
); );
$this->setPosts($posts_to_exclude); $this->setPosts($posts_to_exclude);
$transformed_posts = StylesHelper::applyTextAlignment($transformed_posts); $transformed_posts = StylesHelper::applyTextAlignment($transformed_posts);

View File

@ -245,6 +245,7 @@ class Newsletters {
} }
function listing($data = array()) { function listing($data = array()) {
$listing = new Listing\Handler( $listing = new Listing\Handler(
'\MailPoet\Models\Newsletter', '\MailPoet\Models\Newsletter',
$data $data
@ -267,6 +268,11 @@ class Newsletters {
$newsletter $newsletter
->withOptions() ->withOptions()
->withSegments() ->withSegments()
->withChildrenCount();
} else if($newsletter->type === Newsletter::TYPE_NOTIFICATION_HISTORY) {
$newsletter
->withSegments()
->withSendingQueue()
->withStatistics(); ->withStatistics();
} }

View File

@ -37,8 +37,10 @@ class SendingQueue {
); );
} }
if($newsletter->type === Newsletter::TYPE_WELCOME) { if($newsletter->type === Newsletter::TYPE_WELCOME ||
// set welcome email active $newsletter->type === Newsletter::TYPE_NOTIFICATION
) {
// set newsletter status to active
$result = $newsletter->setStatus(Newsletter::STATUS_ACTIVE); $result = $newsletter->setStatus(Newsletter::STATUS_ACTIVE);
$errors = $result->getErrors(); $errors = $result->getErrors();
@ -48,20 +50,16 @@ class SendingQueue {
'errors' => $errors 'errors' => $errors
); );
} else { } else {
$message = ($newsletter->type === Newsletter::TYPE_WELCOME) ?
__('Your welcome email has been activated') :
__('Your post notification has been activated');
return array( return array(
'result' => true, 'result' => true,
'data' => array( 'data' => array(
'message' => __('Your welcome email has been activated') 'message' => $message
) )
); );
} }
} else if($newsletter->type === Newsletter::TYPE_NOTIFICATION) {
// Post Notifications
$newsletter = Scheduler::processPostNotificationSchedule($newsletter->id);
Scheduler::createPostNotificationQueue($newsletter);
// set post notification active
$newsletter->setStatus(Newsletter::STATUS_ACTIVE);
} }
$queue = SendingQueueModel::whereNull('status') $queue = SendingQueueModel::whereNull('status')
@ -83,18 +81,6 @@ class SendingQueue {
$queue->newsletter_id = $newsletter->id; $queue->newsletter_id = $newsletter->id;
} }
if($newsletter->type === Newsletter::TYPE_NOTIFICATION) {
$queue->scheduled_at = Scheduler::getNextRunDate($newsletter->schedule);
$queue->status = SendingQueueModel::STATUS_SCHEDULED;
$queue->save();
return array(
'result' => true,
'data' => array(
'message' => __('Your post notification has been activated')
)
);
}
if((bool)$newsletter->isScheduled) { if((bool)$newsletter->isScheduled) {
// set newsletter status // set newsletter status
$newsletter->setStatus(Newsletter::STATUS_SCHEDULED); $newsletter->setStatus(Newsletter::STATUS_SCHEDULED);
@ -188,4 +174,4 @@ class SendingQueue {
'result' => $result 'result' => $result
); );
} }
} }

View File

@ -19,7 +19,9 @@ class NewsletterRendererTest extends MailPoetTest {
), ),
'id' => 1, 'id' => 1,
'subject' => 'Some subject', 'subject' => 'Some subject',
'preheader' => 'Some preheader' 'preheader' => 'Some preheader',
'type' => 'standard',
'status' => 'active'
); );
$this->renderer = new Renderer($this->newsletter); $this->renderer = new Renderer($this->newsletter);
$this->column_renderer = new ColumnRenderer(); $this->column_renderer = new ColumnRenderer();

View File

@ -229,6 +229,9 @@
'sendNthWeekDay': __('Send every %$1s %$2s of the month at %$3s'), 'sendNthWeekDay': __('Send every %$1s %$2s of the month at %$3s'),
'sendImmediately': __('Send immediately'), 'sendImmediately': __('Send immediately'),
'ifNewContentToSegments': __("if there's new content to %$1s."), 'ifNewContentToSegments': __("if there's new content to %$1s."),
'sendingToSegmentsNotSpecified': __('You need to select a segment to send to.') 'sendingToSegmentsNotSpecified': __('You need to select a segment to send to.'),
'backToPostNotifications': __('Back to Post notifications'),
'sentOn': __('Sent on')
}) %> }) %>
<% endblock %> <% endblock %>