Manage subscriptions

- make use of the SubscriberSegment::status column to keep track of unsubscriptions
- unsubscribed segments now appear grayed out in the Subscribers listing
- added unsubscribed_at after segment names when editing a subscriber
- added date() method for Twig (uses WP's date format / date_offset)
- fixed typo in Form iframe export
- fixed unit test for Newsletters
- updated selection component (JSX) to allow more customization
This commit is contained in:
Jonathan Labreuille
2016-02-22 11:35:34 +01:00
parent acf300160d
commit 07d533a810
19 changed files with 313 additions and 138 deletions

View File

@@ -17,3 +17,5 @@
@require 'settings' @require 'settings'
@require 'progress_bar' @require 'progress_bar'
@require 'subscribers'

View File

@@ -0,0 +1,3 @@
#subscribers_container
.mailpoet_segments_unsubscribed
color: lighten(#555, 33)

View File

@@ -26,7 +26,7 @@ function(
&& (this.props.item.id !== prevProps.item.id) && (this.props.item.id !== prevProps.item.id)
) { ) {
jQuery('#'+this.refs.select.id) jQuery('#'+this.refs.select.id)
.val(this.props.item[this.props.field.name]) .val(this.getSelectedValues())
.trigger('change'); .trigger('change');
} }
}, },
@@ -44,10 +44,14 @@ function(
templateResult: function(item) { templateResult: function(item) {
if(item.element && item.element.selected) { if(item.element && item.element.selected) {
return null; return null;
} else {
if(item.title) {
return item.title;
} else { } else {
return item.text; return item.text;
} }
} }
}
}); });
var hasRemoved = false; var hasRemoved = false;
@@ -65,15 +69,25 @@ function(
select2.select2( select2.select2(
'val', 'val',
this.props.item[this.props.field.name] this.getSelectedValues()
); );
this.setState({ initialized: true }); this.setState({ initialized: true });
}, },
getSelectedValues: function() {
if(this.props.field['selected'] !== undefined) {
return this.props.field['selected'](this.props.item);
} else if(this.props.item !== undefined && this.props.field.name !== undefined) {
return this.props.item[this.props.field.name];
} else {
return null;
}
},
loadCachedItems: function() { loadCachedItems: function() {
if(typeof(window['mailpoet_'+this.props.field.endpoint]) !== 'undefined') { if(typeof(window['mailpoet_'+this.props.field.endpoint]) !== 'undefined') {
var items = window['mailpoet_'+this.props.field.endpoint]; var items = window['mailpoet_'+this.props.field.endpoint];
if(this.props.field['filter'] !== undefined) { if(this.props.field['filter'] !== undefined) {
items = items.filter(this.props.field.filter); items = items.filter(this.props.field.filter);
} }
@@ -98,31 +112,48 @@ function(
}); });
} }
}, },
getLabel: function(item) {
if(this.props.field['getLabel'] !== undefined) {
return this.props.field.getLabel(item, this.props.item);
}
return item.name;
},
getSearchLabel: function(item) {
if(this.props.field['getSearchLabel'] !== undefined) {
return this.props.field.getSearchLabel(item, this.props.item);
}
return null;
},
getValue: function(item) {
if(this.props.field['getValue'] !== undefined) {
return this.props.field.getValue(item, this.props.item);
}
return item.id;
},
render: function() { render: function() {
var options = this.state.items.map(function(item, index) { const options = this.state.items.map((item, index) => {
let label = this.getLabel(item);
let searchLabel = this.getSearchLabel(item);
let value = this.getValue(item);
return ( return (
<option <option
key={ item.id } key={ 'option-'+index }
value={ item.id } value={ value }
title={ searchLabel }
> >
{ item.name } { label }
</option> </option>
); );
}); });
var default_value = (
(this.props.item !== undefined && this.props.field.name !== undefined)
? this.props.item[this.props.field.name]
: null
);
return ( return (
<select <select
id={ this.props.field.id || this.props.field.name } id={ this.props.field.id || this.props.field.name }
ref="select" ref="select"
data-placeholder={ this.props.field.placeholder } data-placeholder={ this.props.field.placeholder }
multiple={ this.props.field.multiple } multiple={ this.props.field.multiple }
defaultValue={ default_value } defaultValue={ this.getSelectedValues() }
{...this.props.field.validation} {...this.props.field.validation}
>{ options }</select> >{ options }</select>
); );

View File

@@ -181,13 +181,13 @@ const SegmentList = React.createClass({
<abbr>{ segment.description }</abbr> <abbr>{ segment.description }</abbr>
</td> </td>
<td className="column-date" data-colname="Subscribed"> <td className="column-date" data-colname="Subscribed">
<abbr>{ segment.subscribed || 0 }</abbr> <abbr>{ segment.subscribers_count.subscribed || 0 }</abbr>
</td> </td>
<td className="column-date" data-colname="Unconfirmed"> <td className="column-date" data-colname="Unconfirmed">
<abbr>{ segment.unconfirmed || 0 }</abbr> <abbr>{ segment.subscribers_count.unconfirmed || 0 }</abbr>
</td> </td>
<td className="column-date" data-colname="Unsubscribed"> <td className="column-date" data-colname="Unsubscribed">
<abbr>{ segment.unsubscribed || 0 }</abbr> <abbr>{ segment.subscribers_count.unsubscribed || 0 }</abbr>
</td> </td>
<td className="column-date" data-colname="Created on"> <td className="column-date" data-colname="Created on">
<abbr>{ segment.created_at }</abbr> <abbr>{ segment.created_at }</abbr>

View File

@@ -3,15 +3,16 @@ define(
'react', 'react',
'react-router', 'react-router',
'mailpoet', 'mailpoet',
'form/form.jsx' 'form/form.jsx',
'moment'
], ],
function( function(
React, React,
Router, Router,
MailPoet, MailPoet,
Form Form,
Moment
) { ) {
var fields = [ var fields = [
{ {
name: 'email', name: 'email',
@@ -45,8 +46,38 @@ define(
placeholder: "Select a list", placeholder: "Select a list",
endpoint: "segments", endpoint: "segments",
multiple: true, multiple: true,
selected: function(subscriber) {
if (Array.isArray(subscriber.subscriptions) === false) {
return null;
}
return subscriber.subscriptions.map(function(subscription) {
if (subscription.status === 'subscribed') {
return subscription.segment_id;
}
});
},
filter: function(segment) { filter: function(segment) {
return !!(!segment.deleted_at); return !!(!segment.deleted_at);
},
getSearchLabel: function(segment, subscriber) {
let label = '';
if (subscriber.subscriptions !== undefined) {
subscriber.subscriptions.map(function(subscription) {
if (segment.id === subscription.segment_id) {
label = segment.name;
if (subscription.status === 'unsubscribed') {
const unsubscribed_at = Moment(subscription.updated_at)
.utcOffset(parseInt(mailpoet_date_offset))
.format('ddd, D MMM YYYY HH:mm:ss');
label += ' (Unsubscribed on '+unsubscribed_at+')';
}
}
});
}
return label;
} }
} }
]; ];

View File

@@ -231,6 +231,15 @@ const item_actions = [
]; ];
const SubscriberList = React.createClass({ const SubscriberList = React.createClass({
getSegmentFromId: function(segment_id) {
let result = false;
mailpoet_segments.map(function(segment) {
if (segment.id === segment_id) {
result = segment;
}
});
return result;
},
renderItem: function(subscriber, actions) { renderItem: function(subscriber, actions) {
let row_classes = classNames( let row_classes = classNames(
'manage-column', 'manage-column',
@@ -255,11 +264,41 @@ const SubscriberList = React.createClass({
break; break;
} }
let segments = mailpoet_segments.filter(function(segment) { let segments = false;
return (jQuery.inArray(segment.id, subscriber.segments) !== -1);
}).map(function(segment) { if (subscriber.subscriptions.length > 0) {
return segment.name; let subscribed_segments = [];
}).join(', '); let unsubscribed_segments = [];
subscriber.subscriptions.map((subscription) => {
const segment = this.getSegmentFromId(subscription.segment_id);
if (subscription.status === 'subscribed') {
subscribed_segments.push(segment.name);
} else {
unsubscribed_segments.push(segment.name);
}
});
segments = (
<span>
<span className="mailpoet_segments_subscribed">
{ subscribed_segments.join(', ') }
{
(
subscribed_segments.length > 0
&& unsubscribed_segments.length > 0
) ? ' / ' : ''
}
</span>
<span
className="mailpoet_segments_unsubscribed"
title="Lists to which the subscriber was subscribed."
>
{ unsubscribed_segments.join(', ') }
</span>
</span>
);
}
let avatar = false; let avatar = false;
if(subscriber.avatar_url) { if(subscriber.avatar_url) {

View File

@@ -22,19 +22,21 @@ class Export {
), site_url()); ), site_url());
// generate iframe // generate iframe
return '<iframe '. return join(' ', array(
'width="100%" '. '<iframe',
'scrolling="no" '. 'width="100%"',
'frameborder="0" '. 'scrolling="no"',
'src="'.$iframe_url.'" '. 'frameborder="0"',
'class="mailpoet_form_iframe" '. 'src="'.$iframe_url.'"',
'vspace="0" '. 'class="mailpoet_form_iframe"',
'tabindex="0" '. 'vspace="0"',
'onload="javascript:(this.style.height = this.contentWindow.document.body.scrollHeight + \'px\');"'. 'tabindex="0"',
'marginwidth="0" '. 'onload="javascript:(this.style.height = this.contentWindow.document.body.scrollHeight + \'px\');"',
'marginheight="0" '. 'marginwidth="0"',
'hspace="0" '. 'marginheight="0"',
'allowtransparency="true"></iframe>'; 'hspace="0"',
'allowtransparency="true"></iframe>'
));
break; break;
case 'php': case 'php':

View File

@@ -89,7 +89,7 @@ class Handler {
$items = $this->model $items = $this->model
->offset($this->data['offset']) ->offset($this->data['offset'])
->limit($this->data['limit']) ->limit($this->data['limit'])
->findArray(); ->findMany();
return array( return array(
'count' => $count, 'count' => $count,

View File

@@ -52,6 +52,11 @@ class Newsletter extends Model {
); );
} }
function withSegments() {
$this->segments = $this->segments()->findArray();
return $this;
}
function options() { function options() {
return $this->has_many_through( return $this->has_many_through(
__NAMESPACE__.'\NewsletterOptionField', __NAMESPACE__.'\NewsletterOptionField',
@@ -67,6 +72,12 @@ class Newsletter extends Model {
->findOne(); ->findOne();
} }
function withSendingQueue() {
$this->queue = $this->getQueue();
return $this;
}
static function search($orm, $search = '') { static function search($orm, $search = '') {
return $orm->where_like('subject', '%' . $search . '%'); return $orm->where_like('subject', '%' . $search . '%');
} }

View File

@@ -38,15 +38,6 @@ class Segment extends Model {
); );
} }
function segmentFilters() {
return $this->has_many_through(
__NAMESPACE__.'\Filter',
__NAMESPACE__.'\SegmentFilter',
'segment_id',
'filter_id'
);
}
function duplicate($data = array()) { function duplicate($data = array()) {
$duplicate = parent::duplicate($data); $duplicate = parent::duplicate($data);
@@ -76,6 +67,32 @@ class Segment extends Model {
->delete(); ->delete();
} }
function withSubscribersCount() {
$this->subscribers_count = SubscriberSegment::table_alias('relation')
->where('relation.segment_id', $this->id)
->join(
MP_SUBSCRIBERS_TABLE,
'subscribers.id = relation.subscriber_id',
'subscribers'
)
->select_expr(
'SUM(CASE subscribers.status WHEN "subscribed" THEN 1 ELSE 0 END)',
'subscribed'
)
->select_expr(
'SUM(CASE subscribers.status WHEN "unsubscribed" THEN 1 ELSE 0 END)',
'unsubscribed'
)
->select_expr(
'SUM(CASE subscribers.status WHEN "unconfirmed" THEN 1 ELSE 0 END)',
'unconfirmed'
)
->findOne()
->asArray();
return $this;
}
static function getWPUsers() { static function getWPUsers() {
return self::where('type', 'wp_users')->findOne(); return self::where('type', 'wp_users')->findOne();
} }

View File

@@ -291,7 +291,7 @@ class Subscriber extends Model {
} }
} }
if($segment_ids !== false) { if($segment_ids !== false) {
$subscriber->addToSegments($segment_ids); SubscriberSegment::setSubscriptions($subscriber, $segment_ids);
} }
} }
return $subscriber; return $subscriber;
@@ -314,6 +314,17 @@ class Subscriber extends Model {
return $this; return $this;
} }
function withSegments() {
$this->segments = $this->segments()->findArray();
return $this;
}
function withSubscriptions() {
$this->subscriptions = SubscriberSegment::where('subscriber_id', $this->id())
->findArray();
return $this;
}
function getCustomField($custom_field_id, $default = null) { function getCustomField($custom_field_id, $default = null) {
$custom_field = SubscriberCustomField::select('value') $custom_field = SubscriberCustomField::select('value')
->where('custom_field_id', $custom_field_id) ->where('custom_field_id', $custom_field_id)

View File

@@ -12,6 +12,28 @@ class SubscriberSegment extends Model {
parent::__construct(); parent::__construct();
} }
static function setSubscriptions($subscriber, $segment_ids = array()) {
if($subscriber->id > 0) {
// unsubscribe from current subscriptions
SubscriberSegment::where('subscriber_id', $subscriber->id)
->whereNotIn('segment_id', $segment_ids)
->findResultSet()
->set('status', 'unsubscribed')
->save();
// subscribe to segments
foreach($segment_ids as $segment_id) {
self::createOrUpdate(array(
'subscriber_id' => $subscriber->id,
'segment_id' => $segment_id,
'status' => 'subscribed'
));
}
}
return $subscriber;
}
static function filterWithCustomFields($orm) { static function filterWithCustomFields($orm) {
$orm = $orm->select(MP_SUBSCRIBERS_TABLE.'.*'); $orm = $orm->select(MP_SUBSCRIBERS_TABLE.'.*');
$customFields = CustomField::findArray(); $customFields = CustomField::findArray();
@@ -37,6 +59,30 @@ class SubscriberSegment extends Model {
return $orm->where('status', 'subscribed'); return $orm->where('status', 'subscribed');
} }
static function createOrUpdate($data = array()) {
$subscription = false;
if(isset($data['id']) && (int)$data['id'] > 0) {
$subscription = self::findOne((int)$data['id']);
}
if(isset($data['subscriber_id']) && isset($data['segment_id'])) {
$subscription = self::where('subscriber_id', (int)$data['subscriber_id'])
->where('segment_id', (int)$data['segment_id'])
->findOne();
}
if($subscription === false) {
$subscription = self::create();
$subscription->hydrate($data);
} else {
unset($data['id']);
$subscription->set($data);
}
return $subscription->save();
}
static function createMultiple($segmnets, $subscribers) { static function createMultiple($segmnets, $subscribers) {
$values = Helpers::flattenArray( $values = Helpers::flattenArray(
array_map(function ($segment) use ($subscribers) { array_map(function ($segment) use ($subscribers) {

View File

@@ -13,11 +13,10 @@ class Forms {
function get($id = false) { function get($id = false) {
$form = Form::findOne($id); $form = Form::findOne($id);
if($form === false) { if($form !== false) {
return false; $form = $form->asArray();
} else {
return $form->asArray();
} }
return $form;
} }
function listing($data = array()) { function listing($data = array()) {
@@ -29,19 +28,14 @@ class Forms {
$listing_data = $listing->get(); $listing_data = $listing->get();
// fetch segments relations for each returned item // fetch segments relations for each returned item
foreach($listing_data['items'] as &$item) { foreach($listing_data['items'] as $key => $form) {
// form's segments $form = $form->asArray();
$form_settings = ( $form['segments'] = (
(is_serialized($item['settings'])) !empty($form['settings']['segments'])
? unserialize($item['settings']) ? $form['settings']['segments']
: array()
);
$item['segments'] = (
!empty($form_settings['segments'])
? $form_settings['segments']
: array() : array()
); );
$listing_data['items'][$key] = $form;
} }
return $listing_data; return $listing_data;

View File

@@ -206,20 +206,11 @@ class Newsletters {
$listing_data = $listing->get(); $listing_data = $listing->get();
foreach($listing_data['items'] as &$item) { foreach($listing_data['items'] as $key => $newsletter) {
// get segments $listing_data['items'][$key] = $newsletter
$segments = NewsletterSegment::select('segment_id') ->withSegments()
->where('newsletter_id', $item['id']) ->withSendingQueue()
->findMany(); ->asArray();
$item['segments'] = array_map(function($relation) {
return $relation->segment_id;
}, $segments);
// get queue
$queue = SendingQueue::where('newsletter_id', $item['id'])
->orderByDesc('updated_at')
->findOne();
$item['queue'] = ($queue !== false) ? $queue->asArray() : null;
} }
return $listing_data; return $listing_data;

View File

@@ -30,36 +30,14 @@ class Segments {
$listing_data = $listing->get(); $listing_data = $listing->get();
// fetch segments relations for each returned item // fetch segments relations for each returned item
foreach($listing_data['items'] as &$item) { foreach($listing_data['items'] as $key => $segment) {
$stats = SubscriberSegment::table_alias('relation') $segment->subscribers_url = admin_url(
->where( 'admin.php?page=mailpoet-subscribers#/filter[segment='.$segment->id.']'
'relation.segment_id',
$item['id']
)
->join(
MP_SUBSCRIBERS_TABLE,
'subscribers.id = relation.subscriber_id',
'subscribers'
)
->select_expr(
'SUM(CASE subscribers.status WHEN "subscribed" THEN 1 ELSE 0 END)',
'subscribed'
)
->select_expr(
'SUM(CASE subscribers.status WHEN "unsubscribed" THEN 1 ELSE 0 END)',
'unsubscribed'
)
->select_expr(
'SUM(CASE subscribers.status WHEN "unconfirmed" THEN 1 ELSE 0 END)',
'unconfirmed'
)
->findOne()->asArray();
$item = array_merge($item, $stats);
$item['subscribers_url'] = admin_url(
'admin.php?page=mailpoet-subscribers#/filter[segment='.$item['id'].']'
); );
$listing_data['items'][$key] = $segment
->withSubscribersCount()
->asArray();
} }
return $listing_data; return $listing_data;

View File

@@ -17,15 +17,12 @@ class Subscribers {
function get($id = false) { function get($id = false) {
$subscriber = Subscriber::findOne($id); $subscriber = Subscriber::findOne($id);
if($subscriber !== false && $subscriber->id() > 0) { if($subscriber !== false) {
$segments = $subscriber->segments()->findArray(); $subscriber = $subscriber
->withCustomFields()
$subscriber = $subscriber->withCustomFields()->asArray(); ->withSubscriptions()
$subscriber['segments'] = array_map(function($segment) { ->asArray();
return $segment['id'];
}, $segments);
} }
return $subscriber; return $subscriber;
} }
@@ -38,19 +35,10 @@ class Subscribers {
$listing_data = $listing->get(); $listing_data = $listing->get();
// fetch segments relations for each returned item // fetch segments relations for each returned item
foreach($listing_data['items'] as &$item) { foreach($listing_data['items'] as $key => $subscriber) {
// avatar $listing_data['items'][$key] = $subscriber
$item['avatar_url'] = get_avatar_url($item['email'], array( ->withSubscriptions()
'size' => 32 ->asArray();
));
// subscriber's segments
$relations = SubscriberSegment::select('segment_id')
->where('subscriber_id', $item['id'])
->findMany();
$item['segments'] = array_map(function($relation) {
return $relation->segment_id;
}, $relations);
} }
return $listing_data; return $listing_data;

View File

@@ -18,7 +18,13 @@ class i18n extends \Twig_Extension {
// twig custom functions // twig custom functions
$twig_functions = array(); $twig_functions = array();
// list of WP functions to map // list of WP functions to map
$functions = array('localize', '__', '_n'); $functions = array(
'localize',
'__',
'_n',
'date',
'date_format'
);
foreach($functions as $function) { foreach($functions as $function) {
$twig_functions[] = new \Twig_SimpleFunction( $twig_functions[] = new \Twig_SimpleFunction(
@@ -57,6 +63,23 @@ class i18n extends \Twig_Extension {
return call_user_func_array('_n', $this->setTextDomain($args)); return call_user_func_array('_n', $this->setTextDomain($args));
} }
function date() {
$args = func_get_args();
$date = (isset($args[0])) ? $args[0] : null;
$date_format = (isset($args[1])) ? $args[1] : get_option('date_format');
if(empty($date)) return;
// check if it's an int passed as a string
if((string)(int)$date === $date) {
$date = (int)$date;
} else if(!is_int($date)) {
$date = strtotime($date);
}
return get_date_from_gmt(date('Y-m-d H:i:s', $date), $date_format);
}
private function setTextDomain($args = array()) { private function setTextDomain($args = array()) {
// make sure that the last argument is our text domain // make sure that the last argument is our text domain
if($args[count($args) - 1] !== $this->_text_domain) { if($args[count($args) - 1] !== $this->_text_domain) {

View File

@@ -160,13 +160,18 @@ class NewslettersCest {
expect($response['items'][0]['subject'])->equals('My Standard Newsletter'); expect($response['items'][0]['subject'])->equals('My Standard Newsletter');
expect($response['items'][1]['subject'])->equals('My Post Notification'); expect($response['items'][1]['subject'])->equals('My Post Notification');
expect($response['items'][0]['segments'])->equals(array(
$segment_1->id(), // 1st subscriber has 2 segments
$segment_2->id() expect($response['items'][0]['segments'])->count(2);
)); expect($response['items'][0]['segments'][0]['id'])
expect($response['items'][1]['segments'])->equals(array( ->equals($segment_1->id);
$segment_2->id() expect($response['items'][0]['segments'][1]['id'])
)); ->equals($segment_2->id);
// 2nd subscriber has 1 segment
expect($response['items'][1]['segments'])->count(1);
expect($response['items'][1]['segments'][0]['id'])
->equals($segment_2->id);
} }
function itCanBulkDeleteNewsletters() { function itCanBulkDeleteNewsletters() {
@@ -193,6 +198,7 @@ class NewslettersCest {
function _after() { function _after() {
Newsletter::deleteMany(); Newsletter::deleteMany();
NewsletterSegment::deleteMany();
Segment::deleteMany(); Segment::deleteMany();
} }
} }

View File

@@ -20,5 +20,7 @@
var mailpoet_segments = <%= json_encode(segments) %>; var mailpoet_segments = <%= json_encode(segments) %>;
var mailpoet_custom_fields = <%= json_encode(custom_fields) %>; var mailpoet_custom_fields = <%= json_encode(custom_fields) %>;
var mailpoet_month_names = <%= json_encode(month_names) %>; var mailpoet_month_names = <%= json_encode(month_names) %>;
var mailpoet_date_format = "<%= get_option('date_format') %>";
var mailpoet_date_offset = "<%= get_option('gmt_offset') %>";
</script> </script>
<% endblock %> <% endblock %>