diff --git a/assets/css/src/common.styl b/assets/css/src/common.styl index 2b929b74ae..7fe2aa4e4d 100644 --- a/assets/css/src/common.styl +++ b/assets/css/src/common.styl @@ -19,7 +19,7 @@ a:focus // select 2 .select2-container - width: 25em + width: 25em !important @media screen and (max-width: 782px) .select2-container diff --git a/assets/js/src/listing/bulk_actions.jsx b/assets/js/src/listing/bulk_actions.jsx index aee7c2f5bb..b7d44a2359 100644 --- a/assets/js/src/listing/bulk_actions.jsx +++ b/assets/js/src/listing/bulk_actions.jsx @@ -45,6 +45,10 @@ function( data.action = this.state.action; + if(action['onSuccess'] !== undefined) { + data.onSuccess = action.onSuccess; + } + if(data.action) { this.props.onBulkAction(selected_ids, data); } diff --git a/assets/js/src/listing/groups.jsx b/assets/js/src/listing/groups.jsx index ddfd3213ac..eda640f0cd 100644 --- a/assets/js/src/listing/groups.jsx +++ b/assets/js/src/listing/groups.jsx @@ -5,8 +5,10 @@ define(['react', 'classnames'], function(React, classNames) { return this.props.onSelectGroup(group); }, render: function() { - var count = this.props.groups.length; var groups = this.props.groups.map(function(group, index) { + if(group.name === 'trash' && group.count === 0) { + return false; + } var classes = classNames( { 'current' : (group.name === this.props.group) } @@ -14,12 +16,13 @@ define(['react', 'classnames'], function(React, classNames) { return (
  • + {(index > 0) ? ' |' : ''} {group.label} ({ group.count }) - {(index < (count - 1)) ? ' | ' : ''} +
  • ); }.bind(this)); diff --git a/assets/js/src/listing/listing.jsx b/assets/js/src/listing/listing.jsx index cff914483f..5673386611 100644 --- a/assets/js/src/listing/listing.jsx +++ b/assets/js/src/listing/listing.jsx @@ -43,8 +43,11 @@ define( return !e.target.checked; }, - handleDeleteItem: function(id) { - this.props.onDeleteItem(id); + handleRestoreItem: function(id) { + this.props.onRestoreItem(id); + }, + handleDeleteItem: function(id, confirm = false) { + this.props.onDeleteItem(id, confirm); }, handleToggleItem: function(id) { this.setState({ toggled: !this.state.toggled }); @@ -89,26 +92,65 @@ define( ); } - var actions = ( -
    -
    - { item_actions } - { ' | ' } - - - Trash - - + if(this.props.group === 'trash') { + var actions = ( +
    +
    + + Restore + + { ' | ' } + + Delete permanently + +
    +
    - -
    - ); + ); + } else { + var actions = ( +
    +
    + { item_actions } + { ' | ' } + + + Trash + + +
    + +
    + ); + } var row_classes = classNames({ 'is-expanded': !this.state.toggled }) @@ -190,9 +232,11 @@ define( onSelectItem={ this.props.onSelectItem } onRenderItem={ this.props.onRenderItem } onDeleteItem={ this.props.onDeleteItem } + onRestoreItem={ this.props.onRestoreItem } selection={ this.props.selection } is_selectable={ this.props.is_selectable } item_actions={ this.props.item_actions } + group={ this.props.group } key={ 'item-' + item.id } item={ item } /> ); @@ -254,7 +298,27 @@ define( } }.bind(this)); }, - handleDeleteItem: function(id) { + handleRestoreItem: function(id) { + this.setState({ + loading: true, + page: 1 + }); + + MailPoet.Ajax.post({ + endpoint: this.props.endpoint, + action: 'restore', + data: id + }).done(function(response) { + if( + this.props.messages !== undefined + && this.props.messages['onRestore'] !== undefined + ) { + this.props.messages.onRestore(response); + } + this.getItems(); + }.bind(this)); + }, + handleDeleteItem: function(id, confirm = false) { this.setState({ loading: true, page: 1 @@ -263,8 +327,27 @@ define( MailPoet.Ajax.post({ endpoint: this.props.endpoint, action: 'delete', - data: id - }).done(function() { + data: { + id: id, + confirm: confirm + } + }).done(function(response) { + if(confirm === true) { + if( + this.props.messages !== undefined + && this.props.messages['onConfirmDelete'] !== undefined + ) { + this.props.messages.onConfirmDelete(response); + } + } else { + if( + this.props.messages !== undefined + && this.props.messages['onDelete'] !== undefined + ) { + this.props.messages.onDelete(response); + } + } + this.getItems(); }.bind(this)); }, @@ -279,6 +362,11 @@ define( this.setState({ loading: true }); var data = params || {}; + var callback = ((data['onSuccess'] !== undefined) + ? data['onSuccess'] + : function() {} + ); + delete data.onSuccess; data.listing = { offset: 0, @@ -293,8 +381,9 @@ define( endpoint: this.props.endpoint, action: 'bulk_action', data: data - }).done(function() { + }).done(function(response) { this.getItems(); + callback(response); }.bind(this)); }, handleSearch: function(search) { @@ -414,6 +503,24 @@ define( // bulk actions var bulk_actions = this.props.bulk_actions || []; + if(this.state.group === 'trash') { + bulk_actions = [ + { + name: 'restore', + label: 'Restore', + onSuccess: this.props.messages.onRestore + }, + { + name: 'trash', + label: 'Delete permanently', + onSuccess: this.props.messages.onConfirmDelete, + getData: function() { + return { confirm: true }; + } + } + ]; + } + // item actions var item_actions = this.props.item_actions || []; @@ -464,6 +571,7 @@ define( 0 } onSelectItem={ this.handleSelectItem } @@ -471,6 +579,7 @@ define( selection={ this.state.selection } selected_ids={ this.state.selected_ids } loading={ this.state.loading } + group={ this.state.group } count={ this.state.count } limit={ this.state.limit } item_actions={ item_actions } diff --git a/assets/js/src/notice.js b/assets/js/src/notice.js index bc3cd5f4f3..1718f9b963 100644 --- a/assets/js/src/notice.js +++ b/assets/js/src/notice.js @@ -121,6 +121,22 @@ define('notice', ['mailpoet', 'jquery'], function(MailPoet, jQuery) { // set message this.setMessage(this.options.message); + // position notice + this.element.insertAfter(jQuery('h2.title')); + + // set class name + switch(this.options.type) { + case 'success': + this.element.addClass('updated'); + break; + case 'system': + this.element.addClass('update-nag'); + break; + case 'error': + this.element.addClass('error'); + break; + } + // make the notice appear this.element.fadeIn(200); diff --git a/assets/js/src/subscribers/list.jsx b/assets/js/src/subscribers/list.jsx index b6802abcb1..ee02e6e88d 100644 --- a/assets/js/src/subscribers/list.jsx +++ b/assets/js/src/subscribers/list.jsx @@ -59,6 +59,63 @@ define( }, ]; + var messages = { + onDelete: function(response) { + var count = ~~response.subscribers; + var message = null; + + if(count === 1) { + message = ( + '1 subscriber was moved to the trash.' + ).replace('%$1d', count); + } else if(count > 1) { + message = ( + '%$1d subscribers were moved to the trash.' + ).replace('%$1d', count); + } + + if(message !== null) { + MailPoet.Notice.success(message); + } + }, + onConfirmDelete: function(response) { + var count = ~~response.subscribers; + var message = null; + + if(count === 1) { + message = ( + '1 subscriber was permanently deleted.' + ).replace('%$1d', count); + } else if(count > 1) { + message = ( + '%$1d subscribers were permanently deleted.' + ).replace('%$1d', count); + } + + if(message !== null) { + MailPoet.Notice.success(message); + } + }, + onRestore: function(response) { + var count = ~~response.subscribers; + var message = null; + + if(count === 1) { + message = ( + '1 subscriber has been restored from the trash.' + ).replace('%$1d', count); + } else if(count > 1) { + message = ( + '%$1d subscribers have been restored from the trash.' + ).replace('%$1d', count); + } + + if(message !== null) { + MailPoet.Notice.success(message); + } + } + }; + var bulk_actions = [ { name: 'moveToList', @@ -77,6 +134,13 @@ define( return { segment_id: ~~(jQuery('#move_to_segment').val()) } + }, + onSuccess: function(response) { + MailPoet.Notice.success( + '%$1d subscribers were moved to list %$2s.' + .replace('%$1d', ~~response.subscribers) + .replace('%$2s', response.segment) + ); } }, { @@ -96,6 +160,13 @@ define( return { segment_id: ~~(jQuery('#add_to_segment').val()) } + }, + onSuccess: function(response) { + MailPoet.Notice.success( + '%$1d subscribers were added to list %$2s.' + .replace('%$1d', ~~response.subscribers) + .replace('%$2s', response.segment) + ); } }, { @@ -115,11 +186,45 @@ define( return { segment_id: ~~(jQuery('#remove_from_segment').val()) } + }, + onSuccess: function(response) { + MailPoet.Notice.success( + '%$1d subscribers were removed from list %$2s.' + .replace('%$1d', ~~response.subscribers) + .replace('%$2s', response.segment) + ); + } + }, + { + name: 'removeFromAllLists', + label: 'Remove from all lists', + onSuccess: function(response) { + MailPoet.Notice.success( + '%$1d subscribers were removed from all lists.' + .replace('%$1d', ~~response.subscribers) + .replace('%$2s', response.segment) + ); + } + }, + { + name: 'confirmUnconfirmed', + label: 'Confirm unconfirmed', + onSuccess: function(response) { + MailPoet.Notice.success( + '%$1d subscribers have been confirmed.' + .replace('%$1d', ~~response.subscribers) + ); } }, { name: 'trash', - label: 'Trash' + label: 'Trash', + getData: function() { + return { + confirm: false + } + }, + onSuccess: messages.onDelete } ]; @@ -153,14 +258,14 @@ define( return segment.name; }).join(', '); + var row_actions = false; + return (
    - - - { subscriber.email } - - + + { subscriber.email } + { actions } @@ -195,7 +300,9 @@ define( endpoint="subscribers" onRenderItem={ this.renderItem } columns={ columns } - bulk_actions={ bulk_actions } /> + bulk_actions={ bulk_actions } + messages={ messages } + />
    ); } diff --git a/composer.json b/composer.json index 7091ac4db1..17d7aa0c89 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,7 @@ "vlucas/phpdotenv": "*", "umpirsky/twig-gettext-extractor": "1.1.*", "raveren/kint": "^1.0" - }, + }, "autoload": { "psr-4": { "MailPoet\\": "lib/", diff --git a/lib/Config/Migrator.php b/lib/Config/Migrator.php index d115967702..5d1800a044 100644 --- a/lib/Config/Migrator.php +++ b/lib/Config/Migrator.php @@ -54,6 +54,7 @@ class Migrator { 'email varchar(150) NOT NULL,', 'status varchar(12) NOT NULL DEFAULT "unconfirmed",', 'created_at TIMESTAMP NOT NULL DEFAULT 0,', + 'deleted_at TIMESTAMP NULL DEFAULT NULL,', 'updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,', 'PRIMARY KEY (id),', 'UNIQUE KEY email (email)' @@ -82,6 +83,7 @@ class Migrator { 'preheader varchar(250) NOT NULL,', 'body longtext,', 'created_at TIMESTAMP NOT NULL DEFAULT 0,', + 'deleted_at TIMESTAMP NULL DEFAULT NULL,', 'updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,', 'PRIMARY KEY (id)' ); @@ -106,6 +108,7 @@ class Migrator { 'id mediumint(9) NOT NULL AUTO_INCREMENT,', 'name varchar(90) NOT NULL,', 'created_at TIMESTAMP NOT NULL DEFAULT 0,', + 'deleted_at TIMESTAMP NULL DEFAULT NULL,', 'updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,', 'PRIMARY KEY (id),', 'UNIQUE KEY name (name)' @@ -170,8 +173,7 @@ class Migrator { 'newsletter_type varchar(90) NOT NULL,', 'created_at TIMESTAMP NOT NULL DEFAULT 0,', 'updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,', - 'PRIMARY KEY (id),', - 'UNIQUE KEY name_newsletter_type (newsletter_type, name)' + 'PRIMARY KEY (id)' ); return $this->sqlify(__FUNCTION__, $attributes); } diff --git a/lib/Listing/Handler.php b/lib/Listing/Handler.php index 3ddc97b835..dd56636b38 100644 --- a/lib/Listing/Handler.php +++ b/lib/Listing/Handler.php @@ -71,10 +71,17 @@ class Handler { return $this->model; } + function count() { + return (int)$this->model->count(); + } + function getSelectionIds() { - $models = $this->getSelection()->select('id')->findMany(); + $models = $this->getSelection() + ->select('id') + ->findArray(); + return array_map(function($model) { - return (int)$model->id; + return (int)$model['id']; }, $models); } diff --git a/lib/Models/Subscriber.php b/lib/Models/Subscriber.php index fc986ddfe4..a498148438 100644 --- a/lib/Models/Subscriber.php +++ b/lib/Models/Subscriber.php @@ -69,18 +69,8 @@ class Subscriber extends Model { foreach($filters as $filter) { if($filter['name'] === 'segment') { - $segment = Segment::findOne($filter['value']); if($segment !== false) { - /*$orm = $orm - ->select(MP_SUBSCRIBERS_TABLE.'.*') - ->select('subscriber_segment.id', 'subscriber_segment_id') - ->join( - MP_SUBSCRIBER_SEGMENT_TABLE, - MP_SUBSCRIBERS_TABLE.'.id = subscriber_segment.subscriber_id', - 'subscriber_segment' - ) - ->where('subscriber_segment.segment_id', (int)$filter['value']);*/ $orm = $segment->subscribers(); } } @@ -93,35 +83,47 @@ class Subscriber extends Model { array( 'name' => 'all', 'label' => __('All'), - 'count' => Subscriber::count() + 'count' => Subscriber::whereNull('deleted_at')->count() ), array( 'name' => 'subscribed', 'label' => __('Subscribed'), - 'count' => Subscriber::where('status', 'subscribed')->count() + 'count' => Subscriber::whereNull('deleted_at') + ->where('status', 'subscribed') + ->count() ), array( 'name' => 'unconfirmed', 'label' => __('Unconfirmed'), - 'count' => Subscriber::where('status', 'unconfirmed')->count() + 'count' => Subscriber::whereNull('deleted_at') + ->where('status', 'unconfirmed') + ->count() ), array( 'name' => 'unsubscribed', 'label' => __('Unsubscribed'), - 'count' => Subscriber::where('status', 'unsubscribed')->count() + 'count' => Subscriber::whereNull('deleted_at') + ->where('status', 'unsubscribed') + ->count() + ), + array( + 'name' => 'trash', + 'label' => __('Trash'), + 'count' => Subscriber::whereNotNull('deleted_at')->count() ) ); } static function groupBy($orm, $group = null) { - if($group === null or !in_array( - $group, - array('subscribed', 'unconfirmed', 'unsubscribed') - )) { - return $orm; - } + if($group === 'trash') { + return $orm->whereNotNull('deleted_at'); + } else { + $orm = $orm->whereNull('deleted_at'); - return $orm->where('status', $group); + if(in_array($group, array('subscribed', 'unsubscribed', 'unconfirmed'))) { + return $orm->where('status', $group); + } + } } static function filterWithCustomFields($orm) { @@ -197,6 +199,7 @@ class Subscriber extends Model { $segment = Segment::findOne($segment_id); if($segment !== false) { + $subscribers_count = 0; $subscribers = $listing->getSelection()->findMany(); foreach($subscribers as $subscriber) { // remove subscriber from all segments @@ -207,8 +210,13 @@ class Subscriber extends Model { $association->subscriber_id = $subscriber->id; $association->segment_id = $segment->id; $association->save(); + + $subscribers_count++; } - return true; + return array( + 'subscribers' => $subscribers_count, + 'segment' => $segment->name + ); } return false; } @@ -223,6 +231,67 @@ class Subscriber extends Model { SubscriberSegment::whereIn('subscriber_id', $subscriber_ids) ->where('segment_id', $segment->id) ->deleteMany(); + + return array( + 'subscribers' => count($subscriber_ids), + 'segment' => $segment->name + ); + } + return false; + } + + static function removeFromAllLists($listing) { + $segments = Segment::findMany(); + $segment_ids = array_map(function($segment) { + return $segment->id(); + }, $segments); + + if(!empty($segment_ids)) { + // delete relations with segment + $subscriber_ids = $listing->getSelectionIds(); + SubscriberSegment::whereIn('subscriber_id', $subscriber_ids) + ->whereIn('segment_id', $segment_ids) + ->deleteMany(); + + return array( + 'subscribers' => count($subscriber_ids) + ); + } + return false; + } + + static function confirmUnconfirmed($listing) { + $subscriber_ids = $listing->getSelectionIds(); + $subscribers = Subscriber::whereIn('id', $subscriber_ids) + ->where('status', 'unconfirmed') + ->findMany(); + + if(!empty($subscribers)) { + $subscribers_count = 0; + foreach($subscribers as $subscriber) { + $subscriber->set('status', 'subscribed'); + if($subscriber->save() === true) { + $subscribers_count++; + } + } + + return array( + 'subscribers' => $subscribers_count + ); + } + return false; + } + + static function resendConfirmationEmail($listing) { + $subscriber_ids = $listing->getSelectionIds(); + $subscribers = Subscriber::whereIn('id', $subscriber_ids) + ->where('status', 'unconfirmed') + ->findMany(); + + if(!empty($subscribers)) { + foreach($subscribers as $subscriber) { + // TODO: resend confirmation email + } return true; } return false; @@ -233,24 +302,64 @@ class Subscriber extends Model { $segment = Segment::findOne($segment_id); if($segment !== false) { + $subscribers_count = 0; $subscribers = $listing->getSelection()->findMany(); foreach($subscribers as $subscriber) { // create relation with segment $association = \MailPoet\Models\SubscriberSegment::create(); $association->subscriber_id = $subscriber->id; $association->segment_id = $segment->id; - $association->save(); + if($association->save()) { + $subscribers_count++; + } } - return true; + return array( + 'subscribers' => $subscribers_count, + 'segment' => $segment->name + ); } return false; } - static function trash($listing) { - // delete relations with all segments - $subscriber_ids = $listing->getSelectionIds(); - \MailPoet\Models\SubscriberSegment::whereIn('subscriber_id', $subscriber_ids)->deleteMany(); + static function trash($listing, $data = array()) { + $confirm_delete = filter_var($data['confirm'], FILTER_VALIDATE_BOOLEAN); + if($confirm_delete) { + // delete relations with all segments + $subscribers = $listing->getSelection()->findResultSet(); - return $listing->getSelection()->deleteMany(); + if(!empty($subscribers)) { + $subscribers_count = 0; + foreach($subscribers as $subscriber) { + if($subscriber->delete()) { + $subscribers_count++; + } + } + return array( + 'subscribers' => $subscribers_count + ); + } + return false; + } else { + // soft delete + $subscribers = $listing->getSelection() + ->findResultSet() + ->set_expr('deleted_at', 'NOW()') + ->save(); + + return array( + 'subscribers' => $subscribers->count() + ); + } + } + + static function restore($listing, $data = array()) { + $subscribers = $listing->getSelection() + ->findResultSet() + ->set_expr('deleted_at', 'NULL') + ->save(); + + return array( + 'subscribers' => $subscribers->count() + ); } } \ No newline at end of file diff --git a/lib/Router/Subscribers.php b/lib/Router/Subscribers.php index bce5d3f453..1649e77a61 100644 --- a/lib/Router/Subscribers.php +++ b/lib/Router/Subscribers.php @@ -53,10 +53,28 @@ class Subscribers { wp_send_json($result); } - function delete($id) { + function restore($id) { $subscriber = Subscriber::findOne($id); if($subscriber !== false) { - $result = $subscriber->delete(); + $subscriber->set_expr('deleted_at', 'NULL'); + $result = array('subscribers' => (int)$subscriber->save()); + } else { + $result = false; + } + wp_send_json($result); + } + + function delete($data = array()) { + $subscriber = Subscriber::findOne($data['id']); + $confirm_delete = filter_var($data['confirm'], FILTER_VALIDATE_BOOLEAN); + if($subscriber !== false) { + if($confirm_delete) { + $subscriber->delete(); + $result = array('subscribers' => 1); + } else { + $subscriber->set_expr('deleted_at', 'NOW()'); + $result = array('subscribers' => (int)$subscriber->save()); + } } else { $result = false; } diff --git a/views/layout.html b/views/layout.html index a4b521a283..ee5d6dde41 100644 --- a/views/layout.html +++ b/views/layout.html @@ -1,10 +1,10 @@ - +
    - - + + <% block title %><% endblock %> diff --git a/views/settings.html b/views/settings.html index e937f47724..baa835afdf 100644 --- a/views/settings.html +++ b/views/settings.html @@ -47,7 +47,7 @@ <% include 'settings/bounce.html' %>
    -

    +