Listing & Form & Refactoring

- finished implementing forms
- fixed form reset when clicking on new
- fixed responsive for select all checkbox and toggle item details
- refactored listing items' actions
- added Trash action
- cleaned up validations on models
- fixed syntax
- fixed tests
This commit is contained in:
Jonathan Labreuille
2015-09-15 16:00:07 +02:00
parent 79f1896cf3
commit 2a4945c956
21 changed files with 196 additions and 183 deletions

View File

@ -1,24 +1,9 @@
.mailpoet_listing_loading tbody tr, .mailpoet_listing_loading tbody tr,
.mailpoet_form_loading tbody tr .mailpoet_form_loading tbody tr
opacity: 0.2; opacity: 0.2
.widefat tfoot td.mailpoet_check_column,
.widefat thead td.mailpoet_check_column
padding-top: 10px;
.widefat tbody th.mailpoet_check_column,
.widefat tfoot td.mailpoet_check_column,
.widefat thead td.mailpoet_check_column
padding: 11px 0 0 3px;
.widefat .mailpoet_check_column
padding: 6px 0 25px;
vertical-align: top;
width: 2.2em;
.mailpoet_select_all .mailpoet_select_all
background-color: #f1f1f1 background-color: #f1f1f1
.mailpoet_select_all td .mailpoet_select_all td
text-align: center text-align: center

View File

@ -228,6 +228,7 @@ define(
loading: false, loading: false,
item: {} item: {}
}); });
this.refs.form.getDOMNode().reset();
} else { } else {
this.loadItem(props.params.id); this.loadItem(props.params.id);
} }
@ -315,6 +316,7 @@ define(
return ( return (
<form <form
ref="form"
className={ formClasses } className={ formClasses }
onSubmit={ this.handleSubmit }> onSubmit={ this.handleSubmit }>

View File

@ -22,7 +22,8 @@ define(['react', 'classnames'], function(React, classNames) {
if(this.props.is_selectable === true) { if(this.props.is_selectable === true) {
checkbox = ( checkbox = (
<td className="manage-column column-cb mailpoet_check_column"> <td
className="manage-column column-cb check-column">
<label className="screen-reader-text"> <label className="screen-reader-text">
{ 'Select All' } { 'Select All' }
</label> </label>

View File

@ -3,6 +3,7 @@ define(
'mailpoet', 'mailpoet',
'jquery', 'jquery',
'react', 'react',
'react-router',
'classnames', 'classnames',
'listing/bulk_actions.jsx', 'listing/bulk_actions.jsx',
'listing/header.jsx', 'listing/header.jsx',
@ -15,6 +16,7 @@ define(
MailPoet, MailPoet,
jQuery, jQuery,
React, React,
Router,
classNames, classNames,
ListingBulkActions, ListingBulkActions,
ListingHeader, ListingHeader,
@ -23,7 +25,14 @@ define(
ListingGroups, ListingGroups,
ListingFilters ListingFilters
) { ) {
var Link = Router.Link;
var ListingItem = React.createClass({ var ListingItem = React.createClass({
getInitialState: function() {
return {
toggled: true
};
},
handleSelectItem: function(e) { handleSelectItem: function(e) {
var is_checked = jQuery(e.target).is(':checked'); var is_checked = jQuery(e.target).is(':checked');
@ -34,13 +43,18 @@ define(
return !e.target.checked; return !e.target.checked;
}, },
handleDeleteItem: function(id) {
this.props.onDeleteItem(id);
},
handleToggleItem: function(id) {
this.setState({ toggled: !this.state.toggled });
},
render: function() { render: function() {
var checkbox = false; var checkbox = false;
if(this.props.is_selectable === true) { if(this.props.is_selectable === true) {
checkbox = ( checkbox = (
<th className="mailpoet_check_column" scope="row"> <th className="check-column" scope="row">
<label className="screen-reader-text"> <label className="screen-reader-text">
{ 'Select ' + this.props.item.email }</label> { 'Select ' + this.props.item.email }</label>
<input <input
@ -55,10 +69,35 @@ define(
); );
} }
var item_actions = (
<div>
<div className="row-actions">
<span className="edit">
<Link to="edit" params={{ id: this.props.item.id }}>Edit</Link>
</span>
&nbsp;|&nbsp;
<span className="trash">
<a
href="javascript:;"
onClick={ this.handleDeleteItem.bind(null, this.props.item.id) }>
Trash
</a>
</span>
</div>
<button
onClick={ this.handleToggleItem.bind(null, this.props.item.id) }
className="toggle-row" type="button">
<span className="screen-reader-text">Show more details</span>
</button>
</div>
);
var row_classes = classNames({ 'is-expanded': !this.state.toggled })
return ( return (
<tr> <tr className={ row_classes }>
{ checkbox } { checkbox }
{ this.props.onRenderItem(this.props.item) } { this.props.onRenderItem(this.props.item, item_actions) }
</tr> </tr>
); );
} }
@ -124,6 +163,7 @@ define(
columns={ this.props.columns } columns={ this.props.columns }
onSelectItem={ this.props.onSelectItem } onSelectItem={ this.props.onSelectItem }
onRenderItem={ this.props.onRenderItem } onRenderItem={ this.props.onRenderItem }
onDeleteItem={ this.props.onDeleteItem }
selection={ this.props.selection } selection={ this.props.selection }
is_selectable={ this.props.is_selectable } is_selectable={ this.props.is_selectable }
key={ 'item-' + item.id } key={ 'item-' + item.id }
@ -170,8 +210,8 @@ define(
search: this.state.search, search: this.state.search,
sort_by: this.state.sort_by, sort_by: this.state.sort_by,
sort_order: this.state.sort_order sort_order: this.state.sort_order
}, }
onSuccess: function(response) { }).done(function(response) {
if(this.isMounted()) { if(this.isMounted()) {
this.setState({ this.setState({
items: response.items || [], items: response.items || [],
@ -181,8 +221,18 @@ define(
loading: false loading: false
}); });
} }
}.bind(this) }.bind(this));
}); },
handleDeleteItem: function(id) {
this.setState({ loading: true });
MailPoet.Ajax.post({
endpoint: this.props.endpoint,
action: 'delete',
data: id
}).done(function() {
this.getItems();
}.bind(this));
}, },
handleSearch: function(search) { handleSearch: function(search) {
this.setState({ this.setState({
@ -269,8 +319,8 @@ define(
this.getItems(); this.getItems();
}.bind(this)); }.bind(this));
}, },
handleRenderItem: function(item) { handleRenderItem: function(item, actions) {
return this.props.onRenderItem(item); return this.props.onRenderItem(item, actions);
}, },
render: function() { render: function() {
var items = this.state.items, var items = this.state.items,
@ -326,6 +376,7 @@ define(
<ListingItems <ListingItems
onRenderItem={ this.handleRenderItem } onRenderItem={ this.handleRenderItem }
onDeleteItem={ this.handleDeleteItem }
columns={ this.props.columns } columns={ this.props.columns }
is_selectable={ bulk_actions.length > 0 } is_selectable={ bulk_actions.length > 0 }
onSelectItem={ this.handleSelectItem } onSelectItem={ this.handleSelectItem }

View File

@ -34,7 +34,6 @@ define(
var NewsletterForm = React.createClass({ var NewsletterForm = React.createClass({
render: function() { render: function() {
return ( return (
<Form <Form
endpoint="newsletters" endpoint="newsletters"

View File

@ -1,18 +1,14 @@
define( define(
[ [
'react', 'react',
'react-router',
'listing/listing.jsx', 'listing/listing.jsx',
'classnames', 'classnames'
], ],
function( function(
React, React,
Router,
Listing, Listing,
classNames classNames
) { ) {
var Link = Router.Link;
var columns = [ var columns = [
{ {
name: 'subject', name: 'subject',
@ -32,7 +28,7 @@ define(
]; ];
var NewsletterList = React.createClass({ var NewsletterList = React.createClass({
renderItem: function(newsletter) { renderItem: function(newsletter, actions) {
var rowClasses = classNames( var rowClasses = classNames(
'manage-column', 'manage-column',
'column-primary', 'column-primary',
@ -45,12 +41,7 @@ define(
<strong> <strong>
<a>{ newsletter.subject }</a> <a>{ newsletter.subject }</a>
</strong> </strong>
{ actions }
<div className="row-actions">
<span className="edit">
<Link to="edit" params={{ id: newsletter.id }}>Edit</Link>
</span>
</div>
</td> </td>
<td className="column-date" data-colname="Subscribed on"> <td className="column-date" data-colname="Subscribed on">
<abbr>{ newsletter.created_at }</abbr> <abbr>{ newsletter.created_at }</abbr>

View File

@ -1,18 +1,14 @@
define( define(
[ [
'react', 'react',
'react-router',
'listing/listing.jsx', 'listing/listing.jsx',
'classnames', 'classnames'
], ],
function( function(
React, React,
Router,
Listing, Listing,
classNames classNames
) { ) {
var Link = Router.Link;
var columns = [ var columns = [
{ {
name: 'name', name: 'name',
@ -32,7 +28,7 @@ define(
]; ];
var SegmentList = React.createClass({ var SegmentList = React.createClass({
renderItem: function(segment) { renderItem: function(segment, actions) {
var rowClasses = classNames( var rowClasses = classNames(
'manage-column', 'manage-column',
'column-primary', 'column-primary',
@ -45,12 +41,7 @@ define(
<strong> <strong>
<a>{ segment.name }</a> <a>{ segment.name }</a>
</strong> </strong>
{ actions }
<div className="row-actions">
<span className="edit">
<Link to="edit" params={{ id: segment.id }}>Edit</Link>
</span>
</div>
</td> </td>
<td className="column-date" data-colname="Subscribed on"> <td className="column-date" data-colname="Subscribed on">
<abbr>{ segment.created_at }</abbr> <abbr>{ segment.created_at }</abbr>

View File

@ -31,8 +31,8 @@ define(
label: 'Status', label: 'Status',
type: 'select', type: 'select',
values: { values: {
'subscribed': 'Subscribed',
'unconfirmed': 'Unconfirmed', 'unconfirmed': 'Unconfirmed',
'subscribed': 'Subscribed',
'unsubscribed': 'Unsubscribed' 'unsubscribed': 'Unsubscribed'
} }
} }

View File

@ -1,18 +1,14 @@
define( define(
[ [
'react', 'react',
'react-router',
'listing/listing.jsx', 'listing/listing.jsx',
'classnames', 'classnames'
], ],
function( function(
React, React,
Router,
Listing, Listing,
classNames classNames
) { ) {
var Link = Router.Link;
var columns = [ var columns = [
{ {
name: 'email', name: 'email',
@ -62,8 +58,8 @@ define(
]; ];
var List = React.createClass({ var List = React.createClass({
renderItem: function(subscriber) { renderItem: function(subscriber, actions) {
var rowClasses = classNames( var row_classes = classNames(
'manage-column', 'manage-column',
'column-primary', 'column-primary',
'has-row-actions' 'has-row-actions'
@ -87,20 +83,11 @@ define(
return ( return (
<div> <div>
<td className={ rowClasses }> <td className={ row_classes }>
<strong> <strong>
<a>{ subscriber.email }</a> <a>{ subscriber.email }</a>
</strong> </strong>
{ actions }
<div className="row-actions">
<span className="edit">
<Link to="edit" params={{ id: subscriber.id }}>Edit</Link>
</span>
</div>
<button className="toggle-row" type="button">
<span className="screen-reader-text">Show more details</span>
</button>
</td> </td>
<td className="column" data-colname="First name"> <td className="column" data-colname="First name">
{ subscriber.first_name } { subscriber.first_name }
@ -125,7 +112,6 @@ define(
<Listing <Listing
endpoint="subscribers" endpoint="subscribers"
onRenderItem={ this.renderItem } onRenderItem={ this.renderItem }
items={ this.getItems }
columns={ columns } columns={ columns }
bulk_actions={ bulk_actions } /> bulk_actions={ bulk_actions } />
); );

View File

@ -10,12 +10,7 @@ class Newsletter extends Model {
parent::__construct(); parent::__construct();
$this->addValidations('subject', array( $this->addValidations('subject', array(
'required' => 'subject_is_blank', 'required' => __('You need to specify a subject.')
'isString' => 'subject_is_not_string'
));
$this->addValidations('body', array(
'required' => 'body_is_blank',
'isString' => 'body_is_not_string'
)); ));
} }
@ -36,7 +31,7 @@ class Newsletter extends Model {
static function group($orm, $group = null) { static function group($orm, $group = null) {
} }
public static function createOrUpdate($data = array()) { static function createOrUpdate($data = array()) {
$newsletter = false; $newsletter = false;
if(isset($data['id']) && (int)$data['id'] > 0) { if(isset($data['id']) && (int)$data['id'] > 0) {

View File

@ -4,18 +4,17 @@ namespace MailPoet\Models;
if(!defined('ABSPATH')) exit; if(!defined('ABSPATH')) exit;
class Segment extends Model { class Segment extends Model {
public static $_table = MP_SEGMENTS_TABLE; static $_table = MP_SEGMENTS_TABLE;
function __construct() { function __construct() {
parent::__construct(); parent::__construct();
$this->addValidations('name', array( $this->addValidations('name', array(
'required' => 'name_is_blank', 'required' => __('You need to specify a name.')
'isString' => 'name_is_not_string'
)); ));
} }
public function subscribers() { function subscribers() {
return $this->has_many_through( return $this->has_many_through(
__NAMESPACE__.'\Subscriber', __NAMESPACE__.'\Subscriber',
__NAMESPACE__.'\SubscriberSegment', __NAMESPACE__.'\SubscriberSegment',
@ -41,7 +40,7 @@ class Segment extends Model {
static function group($orm, $group = null) { static function group($orm, $group = null) {
} }
public static function createOrUpdate($data = array()) { static function createOrUpdate($data = array()) {
$segment = false; $segment = false;
if(isset($data['id']) && (int)$data['id'] > 0) { if(isset($data['id']) && (int)$data['id'] > 0) {

View File

@ -62,7 +62,7 @@ class Subscriber extends Model {
return $orm->where('status', $group); return $orm->where('status', $group);
} }
public function segments() { function segments() {
return $this->has_many_through( return $this->has_many_through(
__NAMESPACE__.'\Segment', __NAMESPACE__.'\Segment',
__NAMESPACE__.'\SubscriberSegment', __NAMESPACE__.'\SubscriberSegment',
@ -71,7 +71,7 @@ class Subscriber extends Model {
); );
} }
public static function createOrUpdate($data = array()) { static function createOrUpdate($data = array()) {
$subscriber = false; $subscriber = false;
if(isset($data['id']) && (int)$data['id'] > 0) { if(isset($data['id']) && (int)$data['id'] > 0) {

View File

@ -45,12 +45,15 @@ class Newsletters {
} }
} }
function update($args) { function delete($id) {
$newsletter = Newsletter::findOne($id);
if($newsletter !== false) {
$result = $newsletter->delete();
} else {
$result = false;
} }
function delete($id) { wp_send_json($result);
} }
function send($id) { function send($id) {

View File

@ -43,11 +43,14 @@ class Segments {
} }
} }
function update($args) {
}
function delete($id) { function delete($id) {
$segment = Segment::findOne($id);
if($segment !== false) {
$result = $segment->delete();
} else {
$result = false;
}
wp_send_json($result);
} }
} }

View File

@ -39,11 +39,13 @@ class Subscribers {
wp_send_json($result); wp_send_json($result);
} }
function update($data) {
}
function delete($id) { function delete($id) {
$subscriber = Subscriber::findOne($id);
if($subscriber !== false) {
$result = $subscriber->delete();
} else {
$result = false;
}
wp_send_json($result);
} }
} }

View File

@ -15,6 +15,6 @@ $models = array(
); );
$destroy = function ($model) { $destroy = function ($model) {
Model::factory('\MailPoet\Models\\' . $model) Model::factory('\MailPoet\Models\\' . $model)
->delete_many(); ->deleteMany();
}; };
array_map($destroy, $models); array_map($destroy, $models);

View File

@ -7,8 +7,8 @@ class NewslettersPageCest {
function _before(AcceptanceTester $I) { function _before(AcceptanceTester $I) {
$I->login(); $I->login();
$I->resizeWindow(1024, 768); $I->resizeWindow(1024, 768);
$this->firstElementInList = '//*[@id="newsletters"]/div/div/table/tbody/tr[1]'; $this->first_row = 'id("newsletters")//table/tbody/tr[2]';
$this->waitTime = 2; $this->timeout = 3;
} }
function iCanSeeTheTitle(AcceptanceTester $I) { function iCanSeeTheTitle(AcceptanceTester $I) {
@ -16,50 +16,51 @@ class NewslettersPageCest {
$I->see('Newsletters'); $I->see('Newsletters');
} }
function iCanAddNewsletterFromListingPage(AcceptanceTester $I) { function iCanAddANewsletter(AcceptanceTester $I) {
$I->waitForElement('.no-items', $this->waitTime); $I->amOnPage('/wp-admin/admin.php?page=mailpoet-newsletters');
$I->see('No newsletters found');
$I->click('New', '#newsletters'); $I->click('New', '#newsletters');
$I->fillField('Subject', 'first newsletter'); $I->fillField('subject', 'first newsletter');
$I->fillField('Body', 'some body'); $I->fillField('Body', 'some body');
$I->click('Save'); $I->click('Save');
$I->waitForText('1 item', $this->waitTime); $I->waitForText('1 item', $this->timeout);
} }
function iCanAddNewsletterFromNewNewsletterPage(AcceptanceTester $I) { function iCanAddAnotherNewsletter(AcceptanceTester $I) {
$I->amOnPage('/wp-admin/admin.php?page=mailpoet-newsletters#/form'); $I->amOnPage('/wp-admin/admin.php?page=mailpoet-newsletters#/new');
$I->fillField('Subject', 'second newsletter'); $I->fillField('subject', 'second newsletter');
$I->fillField('Body', 'some body'); $I->fillField('Body', 'some body');
$I->click('Save'); $I->click('Save');
$I->waitForText('2 item', $this->waitTime); $I->waitForText('2 item', $this->timeout);
} }
function iCanSortNewsletterBySubject(AcceptanceTester $I) { function iCanSortNewsletterBySubject(AcceptanceTester $I) {
$I->click('Subject'); $I->click('Subject');
$I->waitForText('first', $this->waitTime, $this->firstElementInList); $I->waitForText('first', $this->timeout, $this->first_row);
$I->click('Subject'); $I->click('Subject');
$I->waitForText('second', $this->waitTime, $this->firstElementInList); $I->waitForText('second', $this->timeout, $this->first_row);
} }
function iCanSortNewsletterByCreatedDate(AcceptanceTester $I) { function iCanSortNewsletterByCreatedDate(AcceptanceTester $I) {
$I->click('Created on'); $I->click('Created on');
$I->waitForText('first', $this->waitTime, $this->firstElementInList); $I->waitForText('first', $this->timeout, $this->first_row);
$I->click('Created on'); $I->click('Created on');
$I->waitForText('second', $this->waitTime, $this->firstElementInList); $I->waitForText('second', $this->timeout, $this->first_row);
} }
function iCanSearchNewsletters(AcceptanceTester $I) { function iCanSearchNewsletters(AcceptanceTester $I) {
$searchTerm = 'second'; $search_term = 'second';
$I->fillField('Search', $searchTerm); $I->fillField('Search', $search_term);
$I->click('Search'); $I->click('Search');
$I->waitForText($searchTerm, $this->waitTime, $this->firstElementInList); $I->waitForText($search_term, $this->timeout, $this->first_row);
} }
function iCanSeeMobileView(AcceptanceTester $I) { function iCanSeeMobileView(AcceptanceTester $I) {
$listingHeadings = '//*[@id="newsletters"]/div/div/table/thead'; $listing_header = 'id("newsletters")//table/thead';
$I->resizeWindow(640, 480); $I->resizeWindow(640, 480);
$I->dontSee('Created on', $listingHeadings); $I->dontSee('Created on', $listing_header);
$I->dontSee('Last modified', $listingHeadings); $I->dontSee('Last modified', $listing_header);
$I->see('Subject', $listingHeadings); $I->see('Subject', $listing_header);
} }
function _after(AcceptanceTester $I) { function _after(AcceptanceTester $I) {

View File

@ -4,28 +4,33 @@ use MailPoet\Models\Newsletter;
class NewsletterCest { class NewsletterCest {
function _before() { function _before() {
$this->before_time = time();
$this->data = array(
'subject' => 'My First Newsletter',
'body' => 'a verrryyyyy long body :)'
);
$newsletter = Newsletter::create();
$newsletter->hydrate($this->data);
$this->saved = $newsletter->save();
} }
function itCanBeCreated() { function itCanCreateOrUpdate() {
expect($this->saved)->equals(true); $is_created = Newsletter::createOrUpdate(array(
'subject' => 'new newsletter'
));
expect($is_created)->equals(true);
$newsletter = Newsletter::where('subject', 'new newsletter')->findOne();
expect($newsletter->subject)->equals('new newsletter');
$is_updated = Newsletter::createOrUpdate(array(
'id' => $newsletter->id,
'subject' => 'updated newsletter'
));
$newsletter = Newsletter::where('subject', 'updated newsletter')->findOne();
expect($newsletter->subject)->equals('updated newsletter');
} }
function itHasASearchFilter() { function itHasASearchFilter() {
$newsletter = Newsletter::filter('search', 'first')->findOne(); Newsletter::createOrUpdate(array('subject' => 'search for "pineapple"'));
expect($newsletter->subject)->equals($this->data['subject']); $newsletter = Newsletter::filter('search', 'pineapple')->findOne();
expect($newsletter->subject)->contains('pineapple');
} }
function _after() { function _after() {
ORM::for_table(Newsletter::$_table) ORM::for_table(Newsletter::$_table)
->delete_many(); ->deleteMany();
} }
} }

View File

@ -25,7 +25,7 @@ class SegmentCest {
$empty_model = Segment::create(); $empty_model = Segment::create();
expect($empty_model->save())->notEquals(true); expect($empty_model->save())->notEquals(true);
$validations = $empty_model->getValidationErrors(); $validations = $empty_model->getValidationErrors();
expect(count($validations))->equals(2); expect(count($validations))->equals(1);
} }
function itHasACreatedAtOnCreation() { function itHasACreatedAtOnCreation() {
@ -62,20 +62,20 @@ class SegmentCest {
} }
function itCanCreateOrUpdate() { function itCanCreateOrUpdate() {
$data = array( $is_created = Segment::createOrUpdate(array(
'name' => 'some other new name' 'name' => 'new list'
); ));
$createNewRecord = Segment::createOrUpdate($data); expect($is_created)->equals(true);
$data = array( $segment = Segment::where('name', 'new list')->findOne();
'name' => $this->data['name'], expect($segment->name)->equals('new list');
'name_updated' => 'updated name',
);
$updateExistingRecord = Segment::createOrUpdate($data);
$allRecords = Segment::find_array(); $is_updated = Segment::createOrUpdate(array(
expect(count($allRecords))->equals(2); 'id' => $segment->id,
expect($allRecords[0]['name'])->equals($data['name_updated']); 'name' => 'updated list'
));
$segment = Segment::where('name', 'updated list')->findOne();
expect($segment->name)->equals('updated list');
} }
function itCanHaveMultipleSubscribers() { function itCanHaveMultipleSubscribers() {
@ -108,12 +108,12 @@ class SegmentCest {
} }
function _after() { function _after() {
ORM::for_table(Segment::$_table) ORM::forTable(Segment::$_table)
->delete_many(); ->deleteMany();
ORM::for_table(Subscriber::$_table) ORM::forTable(Subscriber::$_table)
->delete_many(); ->deleteMany();
ORM::for_table(SubscriberSegment::$_table) ORM::forTable(SubscriberSegment::$_table)
->delete_many(); ->deleteMany();
} }

View File

@ -80,7 +80,7 @@ class SettingCest {
} }
function _after() { function _after() {
ORM::for_table(Setting::$_table) ORM::forTable(Setting::$_table)
->delete_many(); ->deleteMany();
} }
} }

View File

@ -6,7 +6,6 @@ use MailPoet\Models\SubscriberSegment;
class SubscriberCest { class SubscriberCest {
function _before() { function _before() {
$this->before_time = time();
$this->data = array( $this->data = array(
'first_name' => 'John', 'first_name' => 'John',
'last_name' => 'Mailer', 'last_name' => 'Mailer',
@ -143,11 +142,11 @@ class SubscriberCest {
} }
function _after() { function _after() {
ORM::for_table(Subscriber::$_table) ORM::forTable(Subscriber::$_table)
->delete_many(); ->deleteMany();
ORM::for_table(Segment::$_table) ORM::forTable(Segment::$_table)
->delete_many(); ->deleteMany();
ORM::for_table(SubscriberSegment::$_table) ORM::forTable(SubscriberSegment::$_table)
->delete_many(); ->deleteMany();
} }
} }