Listing & form

- improved Listing in order to make it more DRY
- form builder with all field types
- added support for data array in ValidModel->set()
- updated models to comply with Listing & Form methods
This commit is contained in:
Jonathan Labreuille
2015-09-14 19:07:41 +02:00
parent e471d45827
commit 79f1896cf3
19 changed files with 451 additions and 272 deletions

View File

@ -1,5 +1,5 @@
.mailpoet_listing_loading tbody tr,
.mailpoet_form_loading
.mailpoet_form_loading tbody tr
opacity: 0.2;
.widefat tfoot td.mailpoet_check_column,

View File

@ -3,17 +3,191 @@ define(
'react',
'mailpoet',
'classnames',
'react-router'
'react-router',
'react-checkbox-group'
],
function(
React,
MailPoet,
classNames,
Router
Router,
CheckboxGroup
) {
var FormFieldSelect = React.createClass({
render: function() {
var options =
Object.keys(this.props.field.values).map(function(value, index) {
return (
<option
key={ 'option-' + index }
value={ value }>
{ this.props.field.values[value] }
</option>
);
}.bind(this)
);
return (
<select
name={ this.props.field.name }
id={ 'field_'+this.props.field.name }
value={ this.props.item[this.props.field.name] }
onChange={ this.props.onValueChange } >
{options}
</select>
);
}
});
var FormFieldRadio = React.createClass({
render: function() {
var selected_value = this.props.item[this.props.field.name];
var count = Object.keys(this.props.field.values).length;
var options = Object.keys(this.props.field.values).map(
function(value, index) {
return (
<p key={ 'radio-' + index }>
<label>
<input
type="radio"
checked={ selected_value === value }
value={ value }
onChange={ this.props.onValueChange }
name={ this.props.field.name } />
&nbsp;{ this.props.field.values[value] }
</label>
</p>
);
}.bind(this)
);
return (
<div>
{ options }
</div>
);
}
});
var FormFieldCheckbox = React.createClass({
render: function() {
var selected_values = this.props.item[this.props.field.name] || '';
if(
selected_values !== undefined
&& selected_values.constructor !== Array
) {
selected_values = selected_values.split(';').map(function(value) {
return value.trim();
});
}
var count = Object.keys(this.props.field.values).length;
var options = Object.keys(this.props.field.values).map(
function(value, index) {
return (
<p key={ 'checkbox-' + index }>
<label>
<input type="checkbox" value={ value } />
&nbsp;{ this.props.field.values[value] }
</label>
</p>
);
}.bind(this)
);
return (
<CheckboxGroup
name={ this.props.field.name }
value={ selected_values }
ref={ this.props.field.name }
onChange={ this.handleValueChange }>
{ options }
</CheckboxGroup>
);
},
handleValueChange: function() {
var field = this.props.field.name;
var group = this.refs[field];
var selected_values = [];
if(group !== undefined) {
selected_values = group.getCheckedValues();
}
return this.props.onValueChange({
target: {
name: field,
value: selected_values.join(';')
}
});
}
});
var FormFieldText = React.createClass({
render: function() {
return (
<input
type="text"
className="regular-text"
name={ this.props.field.name }
id={ 'field_'+this.props.field.name }
value={ this.props.item[this.props.field.name] }
onChange={ this.props.onValueChange } />
);
}
});
var FormFieldTextarea = React.createClass({
render: function() {
return (
<textarea
type="text"
className="regular-text"
name={ this.props.field.name }
id={ 'field_'+this.props.field.name }
value={ this.props.item[this.props.field.name] }
onChange={ this.props.onValueChange } />
);
}
});
var FormField = React.createClass({
render: function() {
var description = false;
if(this.props.field.description) {
description = (
<p className="description">{ this.props.field.description }</p>
);
}
var field = false;
switch(this.props.field.type) {
case 'text':
field = (<FormFieldText {...this.props} />);
break;
case 'textarea':
field = (<FormFieldTextarea {...this.props} />);
break;
case 'select':
field = (<FormFieldSelect {...this.props} />);
break;
case 'radio':
field = (<FormFieldRadio {...this.props} />);
break;
case 'checkbox':
field = (<FormFieldCheckbox {...this.props} />);
break;
}
return (
<tr>
<th scope="row">
@ -22,12 +196,8 @@ define(
>{ this.props.field.label }</label>
</th>
<td>
<input
type="text"
name={ this.props.field.name }
id={ 'field_'+this.props.field.name }
value={ this.props.item[this.props.field.name] }
onChange={ this.props.onValueChange } />
{ field }
{ description }
</td>
</tr>
);
@ -66,7 +236,7 @@ define(
this.setState({ loading: true });
MailPoet.Ajax.post({
endpoint: 'subscribers',
endpoint: this.props.endpoint,
action: 'get',
data: { id: id }
}).done(function(response) {
@ -91,7 +261,7 @@ define(
this.setState({ loading: true });
MailPoet.Ajax.post({
endpoint: 'subscribers',
endpoint: this.props.endpoint,
action: 'save',
data: this.state.item
}).done(function(response) {
@ -100,19 +270,21 @@ define(
if(response === true) {
this.transitionTo('/');
if(this.props.params.id !== undefined) {
MailPoet.Notice.success('Subscriber succesfully updated!');
this.props.messages['updated']();
} else {
MailPoet.Notice.success('Subscriber succesfully added!');
this.props.messages['created']();
}
} else {
this.setState({ errors: response });
}
}.bind(this));
},
handleValueChange: function(e) {
var item = this.state.item;
item[e.target.name] = e.target.value;
var item = this.state.item,
field = e.target.name;
item[field] = e.target.value;
this.setState({
item: item
});
@ -121,7 +293,9 @@ define(
render: function() {
var errors = this.state.errors.map(function(error, index) {
return (
<p key={'error-'+index} className="mailpoet_error">{ error }</p>
<p key={ 'error-'+index } className="mailpoet_error">
{ error }
</p>
);
});

View File

@ -159,7 +159,30 @@ define(
},
getItems: function() {
this.setState({ loading: true });
this.props.items.bind(null, this)();
MailPoet.Ajax.post({
endpoint: this.props.endpoint,
action: 'listing',
data: {
offset: (this.state.page - 1) * this.state.limit,
limit: this.state.limit,
group: this.state.group,
search: this.state.search,
sort_by: this.state.sort_by,
sort_order: this.state.sort_order
},
onSuccess: function(response) {
if(this.isMounted()) {
this.setState({
items: response.items || [],
filters: response.filters || [],
groups: response.groups || [],
count: response.count || 0,
loading: false
});
}
}.bind(this)
});
},
handleSearch: function(search) {
this.setState({

View File

@ -1,75 +1,50 @@
define(
[
'react',
'react-router',
'jquery',
'mailpoet'
'mailpoet',
'form/form.jsx'
],
function(
React,
Router,
jQuery,
MailPoet
MailPoet,
Form
) {
var Form = React.createClass({
mixins: [
Router.Navigation
],
getInitialState: function() {
return {
loading: false,
errors: []
};
var fields = [
{
name: 'subject',
label: 'Subject',
type: 'text'
},
handleSubmit: function(e) {
e.preventDefault();
{
name: 'body',
label: 'Body',
type: 'textarea'
}
];
this.setState({ loading: true });
MailPoet.Ajax.post({
endpoint: 'newsletters',
action: 'save',
data: {
subject: React.findDOMNode(this.refs.subject).value,
body: React.findDOMNode(this.refs.body).value
}
}).done(function(response) {
this.setState({ loading: false });
if(response === true) {
this.transitionTo('/');
} else {
this.setState({ errors: response });
}
}.bind(this));
var messages = {
updated: function() {
MailPoet.Notice.success('Newsletter succesfully updated!');
},
created: function() {
MailPoet.Notice.success('Newsletter succesfully added!');
}
};
var NewsletterForm = React.createClass({
render: function() {
var errors = this.state.errors.map(function(error, index) {
return (
<p key={'error-'+index} className="mailpoet_error">{ error }</p>
);
});
return (
<form onSubmit={ this.handleSubmit }>
{ errors }
<p>
<input type="text" placeholder="Subject" ref="subject" />
</p>
<p>
<input type="text" placeholder="Body" ref="body" />
</p>
<input
className="button button-primary"
type="submit"
value="Save"
disabled={this.state.loading} />
</form>
<Form
endpoint="newsletters"
fields={ fields }
params={ this.props.params }
messages={ messages } />
);
}
});
return Form;
return NewsletterForm;
}
);
);

View File

@ -1,18 +1,17 @@
define(
[
'react',
'jquery',
'mailpoet',
'react-router',
'listing/listing.jsx',
'classnames'
'classnames',
],
function(
React,
jQuery,
MailPoet,
Router,
Listing,
classNames
) {
var Link = Router.Link;
var columns = [
{
@ -32,32 +31,7 @@ define(
}
];
var List = React.createClass({
getItems: function(listing) {
MailPoet.Ajax.post({
endpoint: 'newsletters',
action: 'get',
data: {
offset: (listing.state.page - 1) * listing.state.limit,
limit: listing.state.limit,
group: listing.state.group,
search: listing.state.search,
sort_by: listing.state.sort_by,
sort_order: listing.state.sort_order
},
onSuccess: function(response) {
if(listing.isMounted()) {
listing.setState({
items: response.items || [],
filters: response.filters || [],
groups: response.groups || [],
count: response.count || 0,
loading: false
});
}
}.bind(listing)
});
},
var NewsletterList = React.createClass({
renderItem: function(newsletter) {
var rowClasses = classNames(
'manage-column',
@ -71,6 +45,12 @@ define(
<strong>
<a>{ newsletter.subject }</a>
</strong>
<div className="row-actions">
<span className="edit">
<Link to="edit" params={{ id: newsletter.id }}>Edit</Link>
</span>
</div>
</td>
<td className="column-date" data-colname="Subscribed on">
<abbr>{ newsletter.created_at }</abbr>
@ -84,6 +64,7 @@ define(
render: function() {
return (
<Listing
endpoint="newsletters"
onRenderItem={this.renderItem}
items={this.getItems}
columns={columns} />
@ -91,6 +72,6 @@ define(
}
});
return List;
return NewsletterList;
}
);

View File

@ -2,20 +2,20 @@ define(
[
'react',
'react-router',
'newsletters/form.jsx',
'newsletters/list.jsx'
'newsletters/list.jsx',
'newsletters/form.jsx'
],
function(
React,
Router,
Form,
List
List,
Form
) {
var DefaultRoute = Router.DefaultRoute;
var Link = Router.Link;
var Route = Router.Route;
var RouteHandler = Router.RouteHandler;
var NotFoundRoute = Router.NotFoundRoute;
var App = React.createClass({
render: function() {
@ -24,7 +24,7 @@ define(
<h1>
{ MailPoetI18n.pageTitle }
&nbsp;
<Link className="add-new-h2" to="form">New</Link>
<Link className="add-new-h2" to="new">New</Link>
</h1>
<RouteHandler/>
@ -35,8 +35,9 @@ define(
var routes = (
<Route name="app" path="/" handler={App}>
<Route name="list" handler={List} />
<Route name="form" handler={Form} />
<Route name="new" path="/new" handler={Form} />
<Route name="edit" path="/edit/:id" handler={Form} />
<NotFoundRoute handler={List} />
<DefaultRoute handler={List} />
</Route>
);

View File

@ -1,71 +1,45 @@
define(
[
'react',
'react-router',
'jquery',
'mailpoet'
'mailpoet',
'form/form.jsx'
],
function(
React,
Router,
jQuery,
MailPoet
MailPoet,
Form
) {
var Form = React.createClass({
mixins: [
Router.Navigation
],
getInitialState: function() {
return {
loading: false,
errors: []
};
var fields = [
{
name: 'name',
label: 'Name',
type: 'text'
}
];
var messages = {
updated: function() {
MailPoet.Notice.success('Segment succesfully updated!');
},
handleSubmit: function(e) {
e.preventDefault();
created: function() {
MailPoet.Notice.success('Segment succesfully added!');
}
};
this.setState({ loading: true });
MailPoet.Ajax.post({
endpoint: 'segments',
action: 'save',
data: {
name: React.findDOMNode(this.refs.name).value
}
}).done(function(response) {
this.setState({ loading: false });
if(response === true) {
this.transitionTo('/');
} else {
this.setState({ errors: response });
}
}.bind(this));
},
var SegmentForm = React.createClass({
render: function() {
var errors = this.state.errors.map(function(error, index) {
return (
<p key={'error-'+index} className="mailpoet_error">{ error }</p>
);
});
return (
<form onSubmit={ this.handleSubmit }>
{ errors }
<p>
<input type="text" placeholder="Name" ref="name" />
</p>
<input
className="button button-primary"
type="submit"
value="Save"
disabled={this.state.loading} />
</form>
<Form
endpoint="segments"
fields={ fields }
params={ this.props.params }
messages={ messages } />
);
}
});
return Form;
return SegmentForm;
}
);

View File

@ -1,18 +1,17 @@
define(
[
'react',
'jquery',
'mailpoet',
'react-router',
'listing/listing.jsx',
'classnames'
'classnames',
],
function(
React,
jQuery,
MailPoet,
Router,
Listing,
classNames
) {
var Link = Router.Link;
var columns = [
{
@ -32,32 +31,7 @@ define(
}
];
var List = React.createClass({
getItems: function(listing) {
MailPoet.Ajax.post({
endpoint: 'segments',
action: 'get',
data: {
offset: (listing.state.page - 1) * listing.state.limit,
limit: listing.state.limit,
group: listing.state.group,
search: listing.state.search,
sort_by: listing.state.sort_by,
sort_order: listing.state.sort_order
},
onSuccess: function(response) {
if(listing.isMounted()) {
listing.setState({
items: response.items || [],
filters: response.filters || [],
groups: response.groups || [],
count: response.count || 0,
loading: false
});
}
}.bind(listing)
});
},
var SegmentList = React.createClass({
renderItem: function(segment) {
var rowClasses = classNames(
'manage-column',
@ -71,6 +45,12 @@ define(
<strong>
<a>{ segment.name }</a>
</strong>
<div className="row-actions">
<span className="edit">
<Link to="edit" params={{ id: segment.id }}>Edit</Link>
</span>
</div>
</td>
<td className="column-date" data-colname="Subscribed on">
<abbr>{ segment.created_at }</abbr>
@ -84,6 +64,7 @@ define(
render: function() {
return (
<Listing
endpoint="segments"
onRenderItem={this.renderItem}
items={this.getItems}
columns={columns} />
@ -91,6 +72,6 @@ define(
}
});
return List;
return SegmentList;
}
);

View File

@ -11,11 +11,11 @@ define(
List,
Form
) {
var DefaultRoute = Router.DefaultRoute;
var Link = Router.Link;
var Route = Router.Route;
var RouteHandler = Router.RouteHandler;
var NotFoundRoute = Router.NotFoundRoute;
var App = React.createClass({
render: function() {
@ -24,7 +24,7 @@ define(
<h1>
{ MailPoetI18n.pageTitle }
&nbsp;
<Link className="add-new-h2" to="form">New</Link>
<Link className="add-new-h2" to="new">New</Link>
</h1>
<RouteHandler/>
@ -35,8 +35,9 @@ define(
var routes = (
<Route name="app" path="/" handler={App}>
<Route name="list" handler={List} />
<Route name="form" handler={Form} />
<Route name="new" path="/new" handler={Form} />
<Route name="edit" path="/edit/:id" handler={Form} />
<NotFoundRoute handler={List} />
<DefaultRoute handler={List} />
</Route>
);

View File

@ -1,41 +1,61 @@
define(
[
'react',
'react-router',
'jquery',
'mailpoet',
'classnames',
'form/form.jsx'
],
function(
React,
Router,
jQuery,
MailPoet,
classNames,
Form
) {
var fields = [
{
name: 'email',
label: 'E-mail'
label: 'E-mail',
type: 'text'
},
{
name: 'first_name',
label: 'Firstname'
label: 'Firstname',
type: 'text'
},
{
name: 'last_name',
label: 'Lastname'
label: 'Lastname',
type: 'text'
},
{
name: 'status',
label: 'Status',
type: 'select',
values: {
'subscribed': 'Subscribed',
'unconfirmed': 'Unconfirmed',
'unsubscribed': 'Unsubscribed'
}
}
];
var messages = {
updated: function() {
MailPoet.Notice.success('Subscriber succesfully updated!');
},
created: function() {
MailPoet.Notice.success('Subscriber succesfully added!');
}
};
var SubscriberForm = React.createClass({
render: function() {
return (
<Form fields={ fields } params={ this.props.params } />
<Form
endpoint="subscribers"
fields={ fields }
params={ this.props.params }
messages={ messages } />
);
}
});

View File

@ -2,18 +2,15 @@ define(
[
'react',
'react-router',
'mailpoet',
'listing/listing.jsx',
'classnames',
],
function(
React,
Router,
MailPoet,
Listing,
classNames
) {
var Link = Router.Link;
var columns = [
@ -65,31 +62,6 @@ define(
];
var List = React.createClass({
getItems: function(listing) {
MailPoet.Ajax.post({
endpoint: 'subscribers',
action: 'listing',
data: {
offset: (listing.state.page - 1) * listing.state.limit,
limit: listing.state.limit,
group: listing.state.group,
search: listing.state.search,
sort_by: listing.state.sort_by,
sort_order: listing.state.sort_order
},
onSuccess: function(response) {
if(listing.isMounted()) {
listing.setState({
items: response.items || [],
filters: response.filters || [],
groups: response.groups || [],
count: response.count || 0,
loading: false
});
}
}.bind(listing)
});
},
renderItem: function(subscriber) {
var rowClasses = classNames(
'manage-column',
@ -151,6 +123,7 @@ define(
render: function() {
return (
<Listing
endpoint="subscribers"
onRenderItem={ this.renderItem }
items={ this.getItems }
columns={ columns }

View File

@ -11,7 +11,6 @@ define(
List,
Form
) {
var DefaultRoute = Router.DefaultRoute;
var Link = Router.Link;
var Route = Router.Route;

View File

@ -35,4 +35,28 @@ class Newsletter extends Model {
static function group($orm, $group = null) {
}
public static function createOrUpdate($data = array()) {
$newsletter = false;
if(isset($data['id']) && (int)$data['id'] > 0) {
$newsletter = self::findOne((int)$data['id']);
}
if($newsletter === false) {
$newsletter = self::create();
$newsletter->hydrate($data);
} else {
unset($data['id']);
$newsletter->set($data);
}
$saved = $newsletter->save();
if($saved === false) {
return $newsletter->getValidationErrors();
} else {
return true;
}
}
}

View File

@ -15,20 +15,6 @@ class Segment extends Model {
));
}
public static function createOrUpdate($model) {
$exists = self::where('name', $model['name'])
->find_one();
if($exists === false) {
$new_model = self::create();
$new_model->name = $model['name'];
return $new_model->save();
}
$exists->name = $model['name_updated'];
return $exists->save();
}
public function subscribers() {
return $this->has_many_through(
__NAMESPACE__.'\Subscriber',
@ -54,4 +40,28 @@ class Segment extends Model {
static function group($orm, $group = null) {
}
public static function createOrUpdate($data = array()) {
$segment = false;
if(isset($data['id']) && (int)$data['id'] > 0) {
$segment = self::findOne((int)$data['id']);
}
if($segment === false) {
$segment = self::create();
$segment->hydrate($data);
} else {
unset($data['id']);
$segment->set($data);
}
$saved = $segment->save();
if($saved === false) {
return $segment->getValidationErrors();
} else {
return true;
}
}
}

View File

@ -80,10 +80,18 @@ class Subscriber extends Model {
if($subscriber === false) {
$subscriber = self::create();
$subscriber->hydrate($data);
} else {
unset($data['id']);
$subscriber->set($data);
}
$subscriber->hydrate($data);
return $subscriber->save();
$saved = $subscriber->save();
if($saved === false) {
return $subscriber->getValidationErrors();
} else {
return true;
}
}
}

View File

@ -12,6 +12,17 @@ class Newsletters {
}
function get($data = array()) {
$id = (isset($data['id']) ? (int)$data['id'] : 0);
$newsletter = Newsletter::findOne($id);
if($newsletter === false) {
wp_send_json(false);
} else {
wp_send_json($newsletter->asArray());
}
}
function listing($data = array()) {
$listing = new Listing\Handler(
\Model::factory('\MailPoet\Models\Newsletter'),
$data
@ -24,11 +35,14 @@ class Newsletters {
wp_send_json($collection);
}
function save($args) {
$model = Newsletter::create();
$model->hydrate($args);
$result = $model->save();
wp_send_json($result);
function save($data = array()) {
$result = Newsletter::createOrUpdate($data);
if($result !== true) {
wp_send_json($result);
} else {
wp_send_json(true);
}
}
function update($args) {

View File

@ -10,6 +10,17 @@ class Segments {
}
function get($data = array()) {
$id = (isset($data['id']) ? (int)$data['id'] : 0);
$segment = Segment::findOne($id);
if($segment === false) {
wp_send_json(false);
} else {
wp_send_json($segment->asArray());
}
}
function listing($data = array()) {
$listing = new Listing\Handler(
\Model::factory('\MailPoet\Models\Segment'),
$data
@ -22,11 +33,14 @@ class Segments {
wp_send_json($collection);
}
function save($args) {
$model = Segment::create();
$model->hydrate($args);
$result = $model->save();
wp_send_json($result);
function save($data = array()) {
$result = Segment::createOrUpdate($data);
if($result !== true) {
wp_send_json($result);
} else {
wp_send_json(true);
}
}
function update($args) {

View File

@ -10,7 +10,7 @@ abstract class ValidModel extends \Model
'indexedErrors' => false, // If True getValidationErrors will return an array with the index
// being the field name and the value the error. If multiple errors
// are triggered for a field only the first will be kept.
'throw' => self::ON_SAVE // One of self::ON_SET|ON_SAVE|NEVER.
'throw' => self::ON_SAVE // One of self::ON_SET|ON_SAVE|NEVER.
// + ON_SET throws immediately when field is set()
// + ON_SAVE throws on save()
// + NEVER means an exception is never thrown; check for ->getValidaionErrors()
@ -130,11 +130,17 @@ abstract class ValidModel extends \Model
/**
* Overload set; to call validateAndSet
* // TODO: handle multiple sets if $name is a field=>val array
*/
public function set($name, $value = null)
public function set($key, $value = null)
{
$this->validateAndSet($name, $value);
if(is_array($key)) {
// multiple values
foreach($key as $field => $value) {
$this->validateAndSet($field, $value);
}
} else {
$this->validateAndSet($key, $value);
}
}

View File

@ -23,10 +23,11 @@
"napa": "^1.2.0",
"papaparse": "4.1.1",
"react": "^0.13.3",
"react-checkbox-group": "^0.2.0",
"react-infinity": "^1.2.2",
"react-prefixr": "^0.1.0",
"react-waypoint": "^1.0.2",
"react-router": "^0.13.3",
"react-waypoint": "^1.0.2",
"select2": "3.5.1",
"spectrum-colorpicker": "^1.6.2",
"tinymce": "4.1.10",