Last step

- improved watch command (much simpler robofile + truly recursive)
- split all form fields into separate files (JSX)
- improved form to allow multiple fields per row
- added selection react class for multi select using select2
- added missing files for select2 (webpack doesn't include them)
This commit is contained in:
Jonathan Labreuille
2015-10-01 12:25:25 +02:00
parent f143531a1e
commit 9d0ca85490
22 changed files with 586 additions and 375 deletions

View File

@ -2,18 +2,6 @@
class RoboFile extends \Robo\Tasks {
private $css_files = array(
'assets/css/src/*.styl',
'assets/css/src/**/*.styl'
);
private $js_files = array(
'assets/js/src/*.js',
'assets/js/src/*.jsx',
'assets/js/src/**/*.js',
'assets/js/src/**/*.jsx'
);
function install() {
$this->_exec('./composer.phar install');
$this->_exec('npm install');
@ -25,16 +13,29 @@ class RoboFile extends \Robo\Tasks {
$this->_exec('npm update');
}
function watch() {
$js_files = array();
array_map(function($path) use(&$js_files) {
$js_files = array_merge($js_files, glob($path));
}, $this->js_files);
protected function rsearch($folder, $extensions = array()) {
$dir = new RecursiveDirectoryIterator($folder);
$iterator = new RecursiveIteratorIterator($dir);
$css_files = array();
array_map(function($path) use(&$css_files) {
$css_files = array_merge($css_files, glob($path));
}, $this->css_files);
$pattern = '/^.+\.('.join($extensions, '|').')$/i';
$files = new RegexIterator(
$iterator,
$pattern,
RecursiveRegexIterator::GET_MATCH
);
$list = array();
foreach($files as $file) {
$list[] = $file[0];
}
return $list;
}
function watch() {
$css_files = $this->rsearch('assets/css/src/', array('styl'));
$js_files = $this->rsearch('assets/js/src/', array('js', 'jsx'));
$this->taskWatch()
->monitor($js_files, function() {

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
assets/css/select2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 613 B

BIN
assets/css/select2x2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 845 B

View File

@ -11,3 +11,4 @@
@require 'listing'
@require 'box'
@require 'breadcrumb'
@require 'form'

2
assets/css/src/form.styl Normal file
View File

@ -0,0 +1,2 @@
.mailpoet_form td
vertical-align: top

View File

@ -1,41 +0,0 @@
if (!Function.prototype.bind) {
var Empty = function() {};
Function.prototype.bind = function bind(that) { // .length is 1
var target = this;
if (typeof target != "function") {
throw new TypeError("Function.prototype.bind called on incompatible " + target);
}
var args = Array.prototype.slice.call(arguments, 1); // for normal call
var binder = function() {
if (this instanceof bound) {
var result = target.apply(
this,
args.concat(Array.prototype.slice.call(arguments))
);
if (Object(result) === result) {
return result;
}
return this;
} else {
return target.apply(
that,
args.concat(Array.prototype.slice.call(arguments))
);
}
};
var boundLength = Math.max(0, target.length - args.length);
var boundArgs = [];
for (var i = 0; i < boundLength; i++) {
boundArgs.push("$" + i);
}
var bound = Function("binder", "return function(" + boundArgs.join(",") + "){return binder.apply(this,arguments)}")(binder);
if (target.prototype) {
Empty.prototype = target.prototype;
bound.prototype = new Empty();
// Clean up dangling references.
Empty.prototype = null;
}
return bound;
}
};

View File

@ -0,0 +1,64 @@
define([
'react',
'react-checkbox-group'
],
function(
React,
CheckboxGroup
) {
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(';')
}
});
}
});
return FormFieldCheckbox;
});

View File

@ -0,0 +1,110 @@
define([
'react',
'form/fields/text.jsx',
'form/fields/textarea.jsx',
'form/fields/select.jsx',
'form/fields/radio.jsx',
'form/fields/checkbox.jsx'
],
function(
React,
FormFieldText,
FormFieldTextarea,
FormFieldSelect,
FormFieldRadio,
FormFieldCheckbox
) {
var FormField = React.createClass({
renderField: function(data, inline = true) {
var description = false;
if(data.field.description) {
description = (
<p className="description">{ data.field.description }</p>
);
}
var field = false;
if(data.field['field'] !== undefined) {
field = data.field.field;
} else{
switch(data.field.type) {
case 'text':
field = (<FormFieldText {...data} />);
break;
case 'textarea':
field = (<FormFieldTextarea {...data} />);
break;
case 'select':
field = (<FormFieldSelect {...data} />);
break;
case 'radio':
field = (<FormFieldRadio {...data} />);
break;
case 'checkbox':
field = (<FormFieldCheckbox {...data} />);
break;
}
}
if(inline === true) {
return (
<span>
{ field }
{ description }
</span>
);
} else {
return (
<div>
{ field }
{ description }
</div>
);
}
},
render: function() {
var field = false;
if(this.props.field['fields'] !== undefined) {
field = this.props.field.fields.map(function(subfield) {
return this.renderField({
field: subfield,
item: this.props.item
});
}.bind(this));
} else {
field = this.renderField(this.props);
}
var tip = false;
if(this.props.field.tip) {
tip = (
<p className="description">{ this.props.field.tip }</p>
);
}
return (
<tr>
<th scope="row">
<label
htmlFor={ 'field_'+this.props.field.name }
>
{ this.props.field.label }
{ tip }
</label>
</th>
<td>
{ field }
</td>
</tr>
);
}
});
return FormField;
});

View File

@ -0,0 +1,39 @@
define([
'react'
],
function(
React
) {
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>
);
}
});
return FormFieldRadio;
});

View File

@ -0,0 +1,34 @@
define([
'react'
],
function(
React
) {
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>
);
}
});
return FormFieldSelect;
});

View File

@ -0,0 +1,119 @@
define([
'react',
'jquery'
],
function(
React,
jQuery
) {
var Selection = React.createClass({
getInitialState: function() {
return {
loading: false,
items: [],
selected: []
}
},
componentWillMount: function() {
this.loadCachedItems();
},
componentDidMount: function() {
jQuery('#'+this.props.id).select2({
width: '25em'
});
},
loadCachedItems: function() {
if(typeof(window['mailpoet_'+this.props.endpoint]) !== 'undefined') {
var items = window['mailpoet_'+this.props.endpoint];
this.setState({
items: items
});
}
},
loadItems: function() {
this.setState({ loading: true });
MailPoet.Ajax.post({
endpoint: this.props.endpoint,
action: 'listing',
data: {
'offset': 0,
'limit': 100,
'search': '',
'sort_by': 'name',
'sort_order': 'asc'
}
})
.done(function(response) {
if(this.isMounted()) {
if(response === false) {
this.setState({
loading: false,
items: []
});
} else {
this.setState({
loading: false,
items: response.items
});
}
}
}.bind(this));
},
handleChange: function() {
var new_value = this.refs.selection.getDOMNode().value;
if(this.props.multiple === false) {
if(new_value.trim().length === 0) {
new_value = false;
}
this.setState({
selected: new_value
});
} else {
var selected_values = this.state.selected || [];
if(selected_values.indexOf(new_value) !== -1) {
// value already present so remove it
selected_values.splice(selected_values.indexOf(new_value), 1);
} else {
selected_values.push(new_value);
}
this.setState({
selected: selected_values
});
}
},
getSelected: function() {
return this.state.selected;
},
render: function() {
var options = this.state.items.map(function(item, index) {
return (
<option
key={ 'action-' + index }
value={ item.id }>
{ item.name }
</option>
);
});
return (
<select
ref="selection"
id={ this.props.id }
value={ this.state.selected }
onChange={ this.handleChange }
placeholder={ this.props.placeholder }
multiple
>
{ options }
</select>
);
}
});
return Selection;
});

View File

@ -0,0 +1,24 @@
define([
'react'
],
function(
React
) {
var FormFieldText = React.createClass({
render: function() {
return (
<input
type="text"
className={ (this.props.field.size) ? '' : 'regular-text' }
size={ (this.props.field.size !== 'auto') ? this.props.field.size : false }
name={ this.props.field.name }
id={ 'field_'+this.props.field.name }
value={ this.props.item[this.props.field.name] }
placeholder={ this.props.field.placeholder }
onChange={ this.props.onValueChange } />
);
}
});
return FormFieldText;
});

View File

@ -0,0 +1,22 @@
define([
'react'
],
function(
React
) {
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 } />
);
}
});
return FormFieldTextarea;
});

View File

@ -4,206 +4,15 @@ define(
'mailpoet',
'classnames',
'react-router',
'react-checkbox-group'
'form/fields/field.jsx'
],
function(
React,
MailPoet,
classNames,
Router,
CheckboxGroup
FormField
) {
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">
<label
htmlFor={ 'field_'+this.props.field.name }
>{ this.props.field.label }</label>
</th>
<td>
{ field }
{ description }
</td>
</tr>
);
}
});
var Form = React.createClass({
mixins: [
Router.Navigation
@ -305,25 +114,42 @@ define(
});
var formClasses = classNames(
'mailpoet_form',
{ 'mailpoet_form_loading': this.state.loading }
);
var fields = this.props.fields.map(function(field, index) {
var fields = this.props.fields.map(function(field, i) {
// if(field['fields'] !== undefined) {
// return field.fields.map(function(subfield, j) {
// return (
// <FormField
// field={ subfield }
// item={ this.state.item }
// onValueChange={ this.handleValueChange }
// key={ 'subfield-'+j } />
// );
// }.bind(this));
// } else {
return (
<FormField
field={ field }
item={ this.state.item }
onValueChange={ this.handleValueChange }
key={ 'field-'+index } />
key={ 'field-'+i } />
);
// }
}.bind(this));
return (
<form
ref="form"
className={ formClasses }
onSubmit={ this.handleSubmit }>
onSubmit={
(this.props.onSubmit !== undefined)
? this.props.onSubmit
: this.handleSubmit
}
>
{ errors }
<table className="form-table">

View File

@ -4,14 +4,16 @@ define(
'react-router',
'newsletters/list.jsx',
'newsletters/types.jsx',
'newsletters/templates.jsx'
'newsletters/templates.jsx',
'newsletters/send.jsx'
],
function(
React,
Router,
NewsletterList,
NewsletterTypes,
NewsletterTemplates
NewsletterTemplates,
NewsletterSend
) {
var DefaultRoute = Router.DefaultRoute;
var Link = Router.Link;
@ -31,6 +33,7 @@ define(
<Route name="app" path="/" handler={App}>
<Route name="new" path="/new" handler={ NewsletterTypes } />
<Route name="template" path="/new/:type" handler={ NewsletterTemplates } />
<Route name="send" path="/send/:id" handler={ NewsletterSend } />
<NotFoundRoute handler={ NewsletterList } />
<DefaultRoute handler={ NewsletterList } />
</Route>

View File

@ -0,0 +1,103 @@
define(
[
'react',
'mailpoet',
'form/form.jsx',
'form/fields/selection.jsx',
'newsletters/breadcrumb.jsx'
],
function(
React,
MailPoet,
Form,
Selection,
Breadcrumb
) {
var fields = [
{
name: 'subject',
label: 'Subject line',
tip: "Be creative! It's the first thing your subscribers see."+
"Tempt them to open your email.",
type: 'text'
},
{
name: 'list',
label: 'Lists',
tip: "The subscriber list that will be used for this campaign.",
field: (
<Selection
placeholder="Select a list"
id="mailpoet_segments"
endpoint="segments" />
)
},
{
name: 'sender',
label: 'Sender',
tip: "Name & email of yourself or your company.",
fields: [
{
name: 'from_name',
type: 'text',
placeholder: 'John Doe',
size: 'auto'
},
{
name: 'from_email',
type: 'text',
placeholder: 'john.doe@email.com',
size: 'auto'
},
]
},
{
name: 'reply-to',
label: 'Reply-to',
tip: 'When the subscribers hit "reply" this is who will receive their '+
'email.',
inline: true,
fields: [
{
name: 'reply_to_name',
type: 'text',
placeholder: 'John Doe',
size: 'auto'
},
{
name: 'reply_to_email',
type: 'text',
placeholder: 'john.doe@email.com',
size: 'auto'
},
]
}
];
var messages = {
updated: function() {
MailPoet.Notice.success('Newsletter succesfully updated!');
}
};
var NewsletterSend = React.createClass({
render: function() {
return (
<div>
<h1>Final step: last details</h1>
<Breadcrumb step="send" />
<Form
endpoint="newsletters"
fields={ fields }
params={ this.props.params }
messages={ messages } />
</div>
);
}
});
return NewsletterSend;
}
);

View File

@ -60,7 +60,7 @@ define(
}.bind(this));
},
handlePreviewTemplate: function(template) {
console.log('preview '+template.id);
console.log('preview template #'+template.id);
},
handleDeleteTemplate: function(template) {
this.setState({ loading: true });
@ -101,6 +101,7 @@ define(
</a>
&nbsp;
<a
style={ { display: 'none' }}
className="button button-secondary"
onClick={ this.handlePreviewTemplate.bind(null, template) }
>

View File

@ -3,6 +3,7 @@ define(
'react',
'react-router',
'listing/listing.jsx',
'form/fields/selection.jsx',
'classnames',
'mailpoet',
'jquery',
@ -12,6 +13,7 @@ define(
React,
Router,
Listing,
Selection,
classNames,
MailPoet,
jQuery
@ -57,111 +59,6 @@ define(
},
];
var ItemSelection = React.createClass({
getInitialState: function() {
return {
loading: false,
items: [],
selected: false,
multiple: false
}
},
componentDidMount: function() {
// this.loadItems();
this.loadCachedItems();
},
loadCachedItems: function() {
if(typeof(window['mailpoet_'+this.props.endpoint]) !== 'undefined') {
var items = window['mailpoet_'+this.props.endpoint];
this.setState({
items: items
});
}
},
loadItems: function() {
this.setState({ loading: true });
MailPoet.Ajax.post({
endpoint: this.props.endpoint,
action: 'listing',
data: {
'offset': 0,
'limit': 100,
'search': '',
'sort_by': 'name',
'sort_order': 'asc'
}
})
.done(function(response) {
if(this.isMounted()) {
if(response === false) {
this.setState({
loading: false,
items: []
});
} else {
this.setState({
loading: false,
items: response.items
});
}
}
}.bind(this));
},
handleChange: function() {
var new_value = this.refs.selection.getDOMNode().value;
if(this.state.multiple === false) {
if(new_value.trim().length === 0) {
new_value = false;
}
this.setState({
selected: new_value
});
} else {
var selected_values = this.state.selected || [];
if(selected_values.indexOf(new_value) !== -1) {
// value already present so remove it
selected_values.splice(selected_values.indexOf(new_value), 1);
} else {
selected_values.push(new_value);
}
this.setState({
selected: selected_values
});
}
},
getSelected: function() {
return this.state.selected;
},
render: function() {
var options = this.state.items.map(function(item, index) {
return (
<option
key={ 'action-' + index }
value={ item.id }>
{ item.name }
</option>
);
});
return (
<select
ref="selection"
id={ this.props.id }
value={ this.state.selected }
onChange={ this.handleChange }
multiple={ this.state.multiple }>
<option value="">Select a list</option>
{ options }
</select>
);
}
});
var bulk_actions = [
{
name: 'moveToList',

View File

@ -110,6 +110,9 @@ class Menu {
function newsletters() {
$data = array();
$data['segments'] = Segment::findArray();
echo $this->renderer->render('newsletters.html', $data);
}

View File

@ -23,7 +23,6 @@
<!-- javascripts -->
<%= javascript(
'src/_phantomjs_shim.js',
'vendor.js',
'mailpoet.js',
'admin.js'

View File

@ -9,4 +9,8 @@
'loadingItems': __('Loading newsletters...'),
'noItemsFound': __('No newsletters found.')
}) %>
<script type="text/javascript">
var mailpoet_segments = <%= json_encode(segments) %>;
</script>
<% endblock %>