Compare commits

...

23 Commits
0.0.1 ... 0.0.2

Author SHA1 Message Date
d85f51e9fc Update composer lockfile. 2015-10-30 22:26:40 +01:00
40a62687cf Update Composer lockfile. 2015-10-30 22:13:00 +01:00
136e09e9fb Merge pull request #202 from mailpoet/newsletter_width
Increase newsletter width to 660px from 600px
2015-10-30 12:37:16 +01:00
f509dc0d7e Increase newsletter width to 660px from 600px 2015-10-30 13:25:45 +02:00
c100130f39 Merge pull request #201 from mailpoet/forms
Listing/Model/Router refactoring + Forms
2015-10-30 11:45:34 +01:00
9922ecd93c Merge pull request #200 from mailpoet/notification_type
Add notification email type
2015-10-30 11:40:50 +01:00
a4cf2f9c76 Major refactoring of listing/router/model relation
- updated Subscribers listing
- udpated Segments listing
- added Forms router
2015-10-29 15:30:24 +01:00
576fbf2085 Add notification email type 2015-10-29 15:59:09 +02:00
5c63971314 Merge pull request #198 from mailpoet/editor_select2
Update select2 version to 4.0 for newsletter editor
2015-10-28 15:30:08 +01:00
7418923bbc Remove glow and margins from select2 input 2015-10-28 16:01:23 +02:00
a8f8134f67 Adapt select2 integration code to select2 4.0 2015-10-28 16:01:23 +02:00
103da61d45 basic listing files 2015-10-28 13:19:48 +01:00
01e6a5e6b2 forms table 2015-10-28 13:19:48 +01:00
f5ccf3b38a Merge pull request #195 from mailpoet/subscribers_round_1
Subscriber & Segment listings
2015-10-28 12:24:19 +01:00
c8929351ba Merge pull request #196 from mailpoet/openssl
use of Crypt\RSA library in order to generate dkim keys
2015-10-28 12:23:33 +01:00
6ca536e9ca use of Crypt\RSA library in order to generate dkim keys 2015-10-27 16:41:28 +01:00
89b04e8691 Subscriber & Segment listings
- fixed filters
- added load/save state from url
- added goBack on forms in order to get back listing states
wx# Please enter the commit message for your changes. Lines starting
2015-10-27 16:24:00 +01:00
588b441fb1 Merge pull request #194 from mailpoet/lists_round_1
Lists round 1
2015-10-27 10:30:45 +01:00
13dc3577f1 lotta fixes for filtering + listing 2015-10-26 18:23:32 +01:00
505b979ac5 Segment actions
- added duplicate
- added view subscribers
2015-10-23 17:34:35 +02:00
3b4c5c83e1 added segment stats in listing 2015-10-22 20:40:46 +02:00
056e79eeac bugfix 2015-10-22 19:24:58 +02:00
4bde705f04 listing modifications + added description to segments 2015-10-22 19:19:40 +02:00
52 changed files with 2438 additions and 1036 deletions

View File

@ -19,8 +19,10 @@ a:focus
// select 2 // select 2
.select2-container .select2-container
// textareas
textarea.regular-text
width: 25em !important width: 25em !important
@media screen and (max-width: 782px) @media screen and (max-width: 782px)
.select2-container .select2-container
width: 100% !important width: 100% !important

View File

@ -1,4 +1,4 @@
.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
@ -8,6 +8,20 @@
.mailpoet_select_all td .mailpoet_select_all td
text-align: center text-align: center
table.widefat thead .check-column, .mailpoet_listing_table
table.widefat tfoot .check-column th span
padding: 10px 0 0 3px white-space: nowrap
thead .check-column
tfoot .check-column
padding: 10px 0 0 3px
thead th.column-primary
tfoot th.column-primary
width: 25em
// responsive
@media screen and (max-width: 782px)
thead th.column-primary
tfoot th.column-primary
width: 100%

View File

@ -1,3 +1,8 @@
$column-margin = 20px
$one-column-width = $newsletter-width - (2 * $column-margin)
$two-column-width = ($newsletter-width / 2) - (2 * $column-margin)
$three-column-width = ($newsletter-width / 3) - (2 * $column-margin)
.mailpoet_container .mailpoet_container
width: 100% width: 100%
min-height: 15px min-height: 15px
@ -44,7 +49,7 @@
.mailpoet_container_horizontal > .mailpoet_container_block .mailpoet_container_horizontal > .mailpoet_container_block
margin-bottom: 0 margin-bottom: 0
width: 20px + 560px + 20px width: $column-margin + $one-column-width + $column-margin
// More than one column // More than one column
& > .mailpoet_container_block > .mailpoet_container > .mailpoet_container_block > .mailpoet_container_horizontal & > .mailpoet_container_block > .mailpoet_container > .mailpoet_container_block > .mailpoet_container_horizontal
@ -57,14 +62,14 @@
& > .mailpoet_block:first-child:nth-last-child(2) ~ .mailpoet_block & > .mailpoet_block:first-child:nth-last-child(2) ~ .mailpoet_block
//padding-left: 20px //padding-left: 20px
//padding-right: 20px //padding-right: 20px
width: 260px + 20px + 20px width: $column-margin + $two-column-width + $column-margin
// Three columns // Three columns
& > .mailpoet_block:first-child:nth-last-child(3) & > .mailpoet_block:first-child:nth-last-child(3)
& > .mailpoet_block:first-child:nth-last-child(3) ~ .mailpoet_block & > .mailpoet_block:first-child:nth-last-child(3) ~ .mailpoet_block
//padding-left: 20px //padding-left: 20px
//padding-right: 20px //padding-right: 20px
width: 160px + 20px + 20px width: $column-margin + $three-column-width + $column-margin
.mailpoet_container_empty .mailpoet_container_empty
text-align: center text-align: center

View File

@ -1,5 +1,5 @@
/* Fix select2 z-index to work with MailPoet.Modal */ /* Fix select2 z-index to work with MailPoet.Modal */
.select2-drop .select2-dropdown
z-index: 101000 z-index: 101000
/* Remove input field styles from select2 type input */ /* Remove input field styles from select2 type input */
@ -7,6 +7,19 @@
border: none border: none
padding: 0 padding: 0
/* Fix select2 input glow and margins that wordpress may insert */
.select2 input,
.select2 input:focus
border-color: none
box-shadow: none
margin: 0
padding: 0
/* Fix width overrides for select2 */
.mailpoet_editor_settings .select2-container
width: 100% !important
/* Fix inline TinyMCE toolbar to have minimal width instead of being close to 100% of the screen */ /* Fix inline TinyMCE toolbar to have minimal width instead of being close to 100% of the screen */
div.mce-toolbar-grp.mce-container div.mce-toolbar-grp.mce-container
position: absolute position: absolute

View File

@ -23,4 +23,4 @@ $warning-alternate-text-color = #f4c6c8
$error-text-color = #d54e21 $error-text-color = #d54e21
// Dimensions // Dimensions
$newsletter-width = 600px $newsletter-width = 660px

View File

@ -70,15 +70,31 @@ define(
this.setState({ loading: true }); this.setState({ loading: true });
// only get values from displayed fields
item = {};
this.props.fields.map(function(field) {
item[field.name] = this.state.item[field.name];
}.bind(this));
// set id if specified
if(this.props.params.id !== undefined) {
item.id = this.props.params.id;
}
MailPoet.Ajax.post({ MailPoet.Ajax.post({
endpoint: this.props.endpoint, endpoint: this.props.endpoint,
action: 'save', action: 'save',
data: this.state.item data: item
}).done(function(response) { }).done(function(response) {
this.setState({ loading: false }); this.setState({ loading: false });
if(response === true) { if(response === true) {
this.history.pushState(null, '/'); if(this.props.onSuccess !== undefined) {
this.props.onSuccess()
} else {
this.history.pushState(null, '/')
}
if(this.props.params.id !== undefined) { if(this.props.params.id !== undefined) {
this.props.messages['updated'](); this.props.messages['updated']();
} else { } else {

View File

@ -0,0 +1,56 @@
import React from 'react'
import ReactDOM from 'react-dom'
import { Router, History } from 'react-router'
import MailPoet from 'mailpoet'
import Form from 'form/form.jsx'
const fields = [
{
name: 'name',
label: 'Name',
type: 'text'
},
{
name: 'segments',
label: 'Lists',
type: 'selection',
endpoint: 'segments'
}
]
const messages = {
updated: function() {
MailPoet.Notice.success('Form successfully updated!');
},
created: function() {
MailPoet.Notice.success('Form successfully added!');
}
}
const FormForm = React.createClass({
mixins: [
History
],
render() {
return (
<div>
<h2 className="title">
Form <a
href="javascript:;"
className="add-new-h2"
onClick={ this.history.goBack }
>Back to list</a>
</h2>
<Form
endpoint="forms"
fields={ fields }
params={ this.props.params }
messages={ messages }
onSuccess={ this.history.goBack } />
</div>
);
}
});
module.exports = FormForm

View File

@ -0,0 +1,29 @@
import React from 'react'
import ReactDOM from 'react-dom'
import { Router, Route, IndexRoute } from 'react-router'
import FormList from 'forms/list.jsx'
import FormForm from 'forms/form.jsx'
import createHashHistory from 'history/lib/createHashHistory'
let history = createHashHistory({ queryKey: false })
const App = React.createClass({
render() {
return this.props.children
}
});
let container = document.getElementById('forms_container');
if(container) {
ReactDOM.render((
<Router history={ history }>
<Route path="/" component={ App }>
<IndexRoute component={ FormList } />
<Route path="new" component={ FormForm } />
<Route path="edit/:id" component={ FormForm } />
<Route path="*" component={ FormList } />
</Route>
</Router>
), container);
}

View File

@ -0,0 +1,178 @@
import React from 'react'
import ReactDOM from 'react-dom'
import { Router, Link, History } from 'react-router'
import Listing from 'listing/listing.jsx'
import classNames from 'classnames'
import MailPoet from 'mailpoet'
const columns = [
{
name: 'name',
label: 'Name',
sortable: true
},
{
name: 'created_at',
label: 'Created on',
sortable: true
}
];
const messages = {
onTrash: function(response) {
let count = ~~response.forms;
let message = null;
if(count === 1 || response === true) {
message = (
'1 form was moved to the trash.'
);
} else if(count > 1) {
message = (
'%$1d forms were moved to the trash.'
).replace('%$1d', count);
}
if(message !== null) {
MailPoet.Notice.success(message);
}
},
onDelete: function(response) {
let count = ~~response.forms;
let message = null;
if(count === 1 || response === true) {
message = (
'1 form was permanently deleted.'
);
} else if(count > 1) {
message = (
'%$1d forms were permanently deleted.'
).replace('%$1d', count);
}
if(message !== null) {
MailPoet.Notice.success(message);
}
},
onRestore: function(response) {
let count = ~~response.forms;
let message = null;
if(count === 1 || response === true) {
message = (
'1 form has been restored from the trash.'
);
} else if(count > 1) {
message = (
'%$1d forms have been restored from the trash.'
).replace('%$1d', count);
}
if(message !== null) {
MailPoet.Notice.success(message);
}
}
};
const item_actions = [
{
name: 'edit',
link: function(item) {
return (
<Link to={ `/edit/${item.id}` }>Edit</Link>
);
}
},
{
name: 'duplicate_form',
refresh: true,
link: function(item) {
return (
<a
href="javascript:;"
onClick={ this.onDuplicate.bind(null, item) }
>Duplicate</a>
);
},
onDuplicate: function(item) {
MailPoet.Ajax.post({
endpoint: 'forms',
action: 'duplicate',
data: item.id
}).done(function() {
MailPoet.Notice.success(
('List "%$1s" has been duplicated.').replace('%$1s', item.name)
);
});
}
}
];
const bulk_actions = [
{
name: 'trash',
label: 'Trash',
getData: function() {
return {
confirm: false
}
},
onSuccess: messages.onDelete
}
];
const FormList = React.createClass({
renderItem: function(form, actions) {
let row_classes = classNames(
'manage-column',
'column-primary',
'has-row-actions'
);
let segments = mailpoet_segments.filter(function(segment) {
return (jQuery.inArray(segment.id, form.segments) !== -1);
}).map(function(segment) {
return segment.name;
}).join(', ');
return (
<div>
<td className={ row_classes }>
<strong>
<a>{ form.name }</a>
</strong>
{ actions }
</td>
<td className="column-format" data-colname="Lists">
{ segments }
</td>
<td className="column-date" data-colname="Created on">
<abbr>{ form.created_at }</abbr>
</td>
</div>
);
},
render() {
return (
<div>
<h2 className="title">
Forms <Link className="add-new-h2" to="/new">New</Link>
</h2>
<Listing
messages={ messages }
search={ false }
limit={ 1000 }
endpoint="forms"
onRenderItem={ this.renderItem }
columns={ columns }
bulk_actions={ bulk_actions }
item_actions={ item_actions }
/>
</div>
);
}
});
module.exports = FormList;

View File

@ -45,12 +45,13 @@ function(
data.action = this.state.action; data.action = this.state.action;
var callback = function() {};
if(action['onSuccess'] !== undefined) { if(action['onSuccess'] !== undefined) {
data.onSuccess = action.onSuccess; callback = action.onSuccess;
} }
if(data.action) { if(data.action) {
this.props.onBulkAction(selected_ids, data); this.props.onBulkAction(selected_ids, data, callback);
} }
this.setState({ this.setState({

View File

@ -1,57 +1,67 @@
define([ define([
'react' 'react',
'jquery'
], ],
function( function(
React React,
jQuery
) { ) {
var ListingFilters = React.createClass({ var ListingFilters = React.createClass({
handleFilterAction: function() { handleFilterAction: function() {
var filters = this.props.filters.map(function(filter, index) { let filters = {}
var value = this.refs['filter-'+index].value; this.getAvailableFilters().map((filter, i) => {
if(value) { filters[this.refs['filter-'+i].name] = this.refs['filter-'+i].value
return { })
'name': filter.name,
'value': value
};
}
}.bind(this));
return this.props.onSelectFilter(filters); return this.props.onSelectFilter(filters);
}, },
handleChangeAction: function() { getAvailableFilters: function() {
return true; let filters = this.props.filters;
return Object.keys(filters).filter(function(filter) {
return !(
filters[filter].length === 0
|| (
filters[filter].length === 1
&& !filters[filter][0].value
)
);
})
}, },
render: function() { render: function() {
var filters = this.props.filters const filters = this.props.filters;
.filter(function(filter) { const selected_filters = this.props.filter;
return !(
filter.options.length === 0 const available_filters = this.getAvailableFilters()
|| (
filter.options.length === 1
&& !filter.options[0].value
)
);
})
.map(function(filter, i) { .map(function(filter, i) {
let default_value = false;
if(selected_filters[filter] !== undefined && selected_filters[filter]) {
default_value = selected_filters[filter]
} else {
jQuery(`select[name="${filter}"]`).val('');
}
return ( return (
<select <select
ref={ 'filter-'+i } ref={ `filter-${i}` }
key={ 'filter-'+i } key={ `filter-${i}` }
onChange={ this.handleChangeAction }> name={ filter }
{ filter.options.map(function(option, j) { defaultValue={ default_value }
return ( >
<option { filters[filter].map(function(option, j) {
value={ option.value } return (
key={ 'filter-option-' + j } <option
>{ option.label }</option> value={ option.value }
); key={ 'filter-option-' + j }
}.bind(this)) } >{ option.label }</option>
);
}.bind(this)) }
</select> </select>
); );
}.bind(this)); }.bind(this));
var button = false; let button = false;
if(filters.length > 0) { if(available_filters.length > 0) {
button = ( button = (
<input <input
onClick={ this.handleFilterAction } onClick={ this.handleFilterAction }
@ -63,7 +73,7 @@ function(
return ( return (
<div className="alignleft actions actions"> <div className="alignleft actions actions">
{ filters } { available_filters }
{ button } { button }
</div> </div>
); );

View File

@ -9,6 +9,9 @@ define(['react', 'classnames'], function(React, classNames) {
render: function() { render: function() {
var columns = this.props.columns.map(function(column, index) { var columns = this.props.columns.map(function(column, index) {
column.is_primary = (index === 0); column.is_primary = (index === 0);
column.sorted = (this.props.sort_by === column.name)
? this.props.sort_order
: 'asc';
return ( return (
<ListingColumn <ListingColumn
onSort={this.props.onSort} onSort={this.props.onSort}

View File

@ -46,8 +46,11 @@ define(
handleRestoreItem: function(id) { handleRestoreItem: function(id) {
this.props.onRestoreItem(id); this.props.onRestoreItem(id);
}, },
handleDeleteItem: function(id, confirm = false) { handleTrashItem: function(id) {
this.props.onDeleteItem(id, confirm); this.props.onTrashItem(id);
},
handleDeleteItem: function(id) {
this.props.onDeleteItem(id);
}, },
handleToggleItem: function(id) { handleToggleItem: function(id) {
this.setState({ toggled: !this.state.toggled }); this.setState({ toggled: !this.state.toggled });
@ -77,12 +80,39 @@ define(
if(custom_actions.length > 0) { if(custom_actions.length > 0) {
item_actions = custom_actions.map(function(action, index) { item_actions = custom_actions.map(function(action, index) {
return ( if(action.refresh) {
<span key={ 'action-'+index } className={ action.name }> return (
{ action.link(this.props.item.id) } <span
{(index < (custom_actions.length - 1)) ? ' | ' : ''} onClick={ this.props.onRefreshItems }
</span> key={ 'action-'+index } className={ action.name }>
); { action.link(this.props.item) }
{(index < (custom_actions.length - 1)) ? ' | ' : ''}
</span>
);
} else if(action.link) {
return (
<span
key={ 'action-'+index } className={ action.name }>
{ action.link(this.props.item) }
{(index < (custom_actions.length - 1)) ? ' | ' : ''}
</span>
);
} else {
return (
<span
key={ 'action-'+index } className={ action.name }>
<a href="javascript:;" onClick={
(action.onClick !== undefined)
? action.onClick.bind(null,
this.props.item,
this.props.onRefreshItems
)
: false
}>{ action.label }</a>
{(index < (custom_actions.length - 1)) ? ' | ' : ''}
</span>
);
}
}.bind(this)); }.bind(this));
} else { } else {
item_actions = ( item_actions = (
@ -112,8 +142,7 @@ define(
href="javascript:;" href="javascript:;"
onClick={ this.handleDeleteItem.bind( onClick={ this.handleDeleteItem.bind(
null, null,
this.props.item.id, this.props.item.id
true
)} )}
>Delete permanently</a> >Delete permanently</a>
</span> </span>
@ -134,10 +163,9 @@ define(
<span className="trash"> <span className="trash">
<a <a
href="javascript:;" href="javascript:;"
onClick={ this.handleDeleteItem.bind( onClick={ this.handleTrashItem.bind(
null, null,
this.props.item.id, this.props.item.id
false
) }> ) }>
Trash Trash
</a> </a>
@ -222,7 +250,7 @@ define(
</td> </td>
</tr> </tr>
{this.props.items.map(function(item) { {this.props.items.map(function(item, index) {
item.id = parseInt(item.id, 10); item.id = parseInt(item.id, 10);
item.selected = (this.props.selected_ids.indexOf(item.id) !== -1); item.selected = (this.props.selected_ids.indexOf(item.id) !== -1);
@ -233,11 +261,13 @@ define(
onRenderItem={ this.props.onRenderItem } onRenderItem={ this.props.onRenderItem }
onDeleteItem={ this.props.onDeleteItem } onDeleteItem={ this.props.onDeleteItem }
onRestoreItem={ this.props.onRestoreItem } onRestoreItem={ this.props.onRestoreItem }
onTrashItem={ this.props.onTrashItem }
onRefreshItems={ this.props.onRefreshItems }
selection={ this.props.selection } selection={ this.props.selection }
is_selectable={ this.props.is_selectable } is_selectable={ this.props.is_selectable }
item_actions={ this.props.item_actions } item_actions={ this.props.item_actions }
group={ this.props.group } group={ this.props.group }
key={ 'item-' + item.id } key={ 'item-' + index }
item={ item } /> item={ item } />
); );
}.bind(this))} }.bind(this))}
@ -248,6 +278,9 @@ define(
}); });
var Listing = React.createClass({ var Listing = React.createClass({
mixins: [
Router.History
],
getInitialState: function() { getInitialState: function() {
return { return {
loading: false, loading: false,
@ -260,43 +293,136 @@ define(
items: [], items: [],
groups: [], groups: [],
group: 'all', group: 'all',
filters: [], filters: {},
filter: [], filter: {},
selected_ids: [], selected_ids: [],
selection: false selection: false
}; };
}, },
componentDidUpdate: function(prevProps, prevState) {
// reset group to "all" if trash gets emptied
if(
// we were viewing the trash
(prevState.group === 'trash' && prevState.count > 0)
&&
// we are still viewing the trash but there are no items left
(this.state.group === 'trash' && this.state.count === 0)
&&
// only do this when no filter is set
(Object.keys(this.state.filter).length === 0)
) {
this.handleGroup('all');
}
},
getParam: function(param) {
var regex = /(.*)\[(.*)\]/
var matches = regex.exec(param)
return [matches[1], matches[2]]
},
initWithParams: function(params) {
let state = this.state || {}
let original_state = state
// check for url params
if(params.splat !== undefined) {
params.splat.split('/').map(param => {
let [key, value] = this.getParam(param);
switch(key) {
case 'filter':
let filters = {}
value.split('&').map(function(pair) {
let [k, v] = pair.split('=')
filters[k] = v
}
)
state.filter = filters
break;
default:
state[key] = value
}
})
}
if(this.props.limit !== undefined) {
state.limit = Math.abs(~~this.props.limit);
}
this.setState(state, function() {
this.getItems();
}.bind(this));
},
setParams: function() {
var params = Object.keys(this.state)
.filter(key => {
return (
[
'group',
'filter',
'search',
'page',
'sort_by',
'sort_order'
].indexOf(key) !== -1
)
})
.map(key => {
let value = this.state[key]
if(value === Object(value)) {
value = jQuery.param(value)
} else if(value === Boolean(value)) {
value = value.toString()
}
if(value !== '') {
return `${key}[${value}]`
}
})
.filter(key => { return (key !== undefined) })
.join('/');
params = '/'+params
if(this.props.location) {
if(this.props.location.pathname !== params) {
this.history.pushState(null, `${params}`)
}
}
},
componentDidMount: function() { componentDidMount: function() {
this.getItems(); if(this.isMounted()) {
const params = this.props.params || {}
this.initWithParams(params)
}
},
componentWillReceiveProps: function(nextProps) {
const params = nextProps.params || {}
//this.initWithParams(params)
}, },
getItems: function() { getItems: function() {
this.setState({ loading: true }); if(this.isMounted()) {
this.setState({ loading: true });
this.clearSelection(); this.clearSelection();
MailPoet.Ajax.post({ MailPoet.Ajax.post({
endpoint: this.props.endpoint, endpoint: this.props.endpoint,
action: 'listing', action: 'listing',
data: { data: {
offset: (this.state.page - 1) * this.state.limit, offset: (this.state.page - 1) * this.state.limit,
limit: this.state.limit, limit: this.state.limit,
group: this.state.group, group: this.state.group,
filter: this.state.filter, filter: this.state.filter,
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
} }
}).done(function(response) { }).done(function(response) {
if(this.isMounted()) {
this.setState({ this.setState({
items: response.items || [], items: response.items || [],
filters: response.filters || [], filters: response.filters || {},
groups: response.groups || [], groups: response.groups || [],
count: response.count || 0, count: response.count || 0,
loading: false loading: false
}); });
} }.bind(this));
}.bind(this)); }
}, },
handleRestoreItem: function(id) { handleRestoreItem: function(id) {
this.setState({ this.setState({
@ -318,7 +444,27 @@ define(
this.getItems(); this.getItems();
}.bind(this)); }.bind(this));
}, },
handleDeleteItem: function(id, confirm = false) { handleTrashItem: function(id) {
this.setState({
loading: true,
page: 1
});
MailPoet.Ajax.post({
endpoint: this.props.endpoint,
action: 'trash',
data: id
}).done(function(response) {
if(
this.props.messages !== undefined
&& this.props.messages['onTrash'] !== undefined
) {
this.props.messages.onTrash(response);
}
this.getItems();
}.bind(this));
},
handleDeleteItem: function(id) {
this.setState({ this.setState({
loading: true, loading: true,
page: 1 page: 1
@ -327,31 +473,18 @@ define(
MailPoet.Ajax.post({ MailPoet.Ajax.post({
endpoint: this.props.endpoint, endpoint: this.props.endpoint,
action: 'delete', action: 'delete',
data: { data: id
id: id,
confirm: confirm
}
}).done(function(response) { }).done(function(response) {
if(confirm === true) { if(
if( this.props.messages !== undefined
this.props.messages !== undefined && this.props.messages['onDelete'] !== undefined
&& this.props.messages['onConfirmDelete'] !== undefined ) {
) { this.props.messages.onDelete(response);
this.props.messages.onConfirmDelete(response);
}
} else {
if(
this.props.messages !== undefined
&& this.props.messages['onDelete'] !== undefined
) {
this.props.messages.onDelete(response);
}
} }
this.getItems(); this.getItems();
}.bind(this)); }.bind(this));
}, },
handleBulkAction: function(selected_ids, params) { handleBulkAction: function(selected_ids, params, callback) {
if( if(
this.state.selection === false this.state.selection === false
&& this.state.selected_ids.length === 0 && this.state.selected_ids.length === 0
@ -362,12 +495,6 @@ define(
this.setState({ loading: true }); this.setState({ loading: true });
var data = params || {}; var data = params || {};
var callback = ((data['onSuccess'] !== undefined)
? data['onSuccess']
: function() {}
);
delete data.onSuccess;
data.listing = { data.listing = {
offset: 0, offset: 0,
limit: 0, limit: 0,
@ -393,6 +520,7 @@ define(
selection: false, selection: false,
selected_ids: [] selected_ids: []
}, function() { }, function() {
this.setParams();
this.getItems(); this.getItems();
}.bind(this)); }.bind(this));
}, },
@ -401,6 +529,7 @@ define(
sort_by: sort_by, sort_by: sort_by,
sort_order: sort_order, sort_order: sort_order,
}, function() { }, function() {
this.setParams();
this.getItems(); this.getItems();
}.bind(this)); }.bind(this));
}, },
@ -460,6 +589,7 @@ define(
filter: filters, filter: filters,
page: 1 page: 1
}, function() { }, function() {
this.setParams();
this.getItems(); this.getItems();
}.bind(this)); }.bind(this));
}, },
@ -469,10 +599,11 @@ define(
this.setState({ this.setState({
group: group, group: group,
filter: [], filter: {},
search: '', search: '',
page: 1 page: 1
}, function() { }, function() {
this.setParams();
this.getItems(); this.getItems();
}.bind(this)); }.bind(this));
}, },
@ -482,6 +613,7 @@ define(
selection: false, selection: false,
selected_ids: [] selected_ids: []
}, function() { }, function() {
this.setParams();
this.getItems(); this.getItems();
}.bind(this)); }.bind(this));
}, },
@ -489,17 +621,14 @@ define(
var render = this.props.onRenderItem(item, actions); var render = this.props.onRenderItem(item, actions);
return render.props.children; return render.props.children;
}, },
handleRefreshItems: function() {
this.getItems();
},
render: function() { render: function() {
var items = this.state.items, var items = this.state.items,
sort_by = this.state.sort_by, sort_by = this.state.sort_by,
sort_order = this.state.sort_order; sort_order = this.state.sort_order;
// set sortable columns
columns = this.props.columns.map(function(column) {
column.sorted = (column.name === sort_by) ? sort_order : false;
return column;
});
// bulk actions // bulk actions
var bulk_actions = this.props.bulk_actions || []; var bulk_actions = this.props.bulk_actions || [];
@ -511,12 +640,9 @@ define(
onSuccess: this.props.messages.onRestore onSuccess: this.props.messages.onRestore
}, },
{ {
name: 'trash', name: 'delete',
label: 'Delete permanently', label: 'Delete permanently',
onSuccess: this.props.messages.onConfirmDelete, onSuccess: this.props.messages.onDelete
getData: function() {
return { confirm: true };
}
} }
]; ];
} }
@ -524,22 +650,42 @@ define(
// item actions // item actions
var item_actions = this.props.item_actions || []; var item_actions = this.props.item_actions || [];
var tableClasses = classNames( var table_classes = classNames(
'mailpoet_listing_table',
'wp-list-table', 'wp-list-table',
'widefat', 'widefat',
'fixed', 'fixed',
'striped', 'striped',
{ 'mailpoet_listing_loading': this.state.loading } { 'mailpoet_listing_loading': this.state.loading }
); );
// search
var search = (
<ListingSearch
onSearch={ this.handleSearch }
search={ this.state.search }
/>
);
if(this.props.search === false) {
search = false;
}
// groups
var groups = (
<ListingGroups
groups={ this.state.groups }
group={ this.state.group }
onSelectGroup={ this.handleGroup }
/>
);
if(this.props.groups === false) {
groups = false;
}
return ( return (
<div> <div>
<ListingGroups { groups }
groups={ this.state.groups } { search }
group={ this.state.group }
onSelectGroup={ this.handleGroup } />
<ListingSearch
onSearch={ this.handleSearch }
search={ this.state.search } />
<div className="tablenav top clearfix"> <div className="tablenav top clearfix">
<ListingBulkActions <ListingBulkActions
bulk_actions={ bulk_actions } bulk_actions={ bulk_actions }
@ -548,7 +694,7 @@ define(
onBulkAction={ this.handleBulkAction } /> onBulkAction={ this.handleBulkAction } />
<ListingFilters <ListingFilters
filters={ this.state.filters } filters={ this.state.filters }
filter={ this.state.filter } filter={ this.state.filter }
onSelectFilter={ this.handleFilter } /> onSelectFilter={ this.handleFilter } />
<ListingPages <ListingPages
count={ this.state.count } count={ this.state.count }
@ -556,7 +702,7 @@ define(
limit={ this.state.limit } limit={ this.state.limit }
onSetPage={ this.handleSetPage } /> onSetPage={ this.handleSetPage } />
</div> </div>
<table className={ tableClasses }> <table className={ table_classes }>
<thead> <thead>
<ListingHeader <ListingHeader
onSort={ this.handleSort } onSort={ this.handleSort }
@ -572,6 +718,8 @@ define(
onRenderItem={ this.handleRenderItem } onRenderItem={ this.handleRenderItem }
onDeleteItem={ this.handleDeleteItem } onDeleteItem={ this.handleDeleteItem }
onRestoreItem={ this.handleRestoreItem } onRestoreItem={ this.handleRestoreItem }
onTrashItem={ this.handleTrashItem }
onRefreshItems={ this.handleRefreshItems }
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

@ -40,23 +40,23 @@ define(['react', 'classnames'], function(React, classNames) {
}, },
render: function() { render: function() {
if(this.props.count === 0) { if(this.props.count === 0) {
return (<div></div>); return false;
} else { } else {
var pagination, var pagination = false;
firstPage = ( var firstPage = (
<span aria-hidden="true" className="tablenav-pages-navspan">«</span> <span aria-hidden="true" className="tablenav-pages-navspan">«</span>
), );
previousPage = ( var previousPage = (
<span aria-hidden="true" className="tablenav-pages-navspan"></span> <span aria-hidden="true" className="tablenav-pages-navspan"></span>
), );
nextPage = ( var nextPage = (
<span aria-hidden="true" className="tablenav-pages-navspan"></span> <span aria-hidden="true" className="tablenav-pages-navspan"></span>
), );
lastPage = ( var lastPage = (
<span aria-hidden="true" className="tablenav-pages-navspan">»</span> <span aria-hidden="true" className="tablenav-pages-navspan">»</span>
); );
if(this.props.count > this.props.limit) { if(this.props.limit > 0 && this.props.count > this.props.limit) {
if(this.props.page > 1) { if(this.props.page > 1) {
previousPage = ( previousPage = (
<a href="javascript:;" <a href="javascript:;"
@ -104,6 +104,7 @@ define(['react', 'classnames'], function(React, classNames) {
pagination = ( pagination = (
<span className="pagination-links"> <span className="pagination-links">
{firstPage} {firstPage}
&nbsp;
{previousPage} {previousPage}
&nbsp; &nbsp;
<span className="paging-input"> <span className="paging-input">
@ -128,6 +129,7 @@ define(['react', 'classnames'], function(React, classNames) {
</span> </span>
&nbsp; &nbsp;
{nextPage} {nextPage}
&nbsp;
{lastPage} {lastPage}
</span> </span>
); );
@ -140,7 +142,7 @@ define(['react', 'classnames'], function(React, classNames) {
return ( return (
<div className={ classes }> <div className={ classes }>
<span className="displaying-num">{ this.props.count } item(s)</span> <span className="displaying-num">{ this.props.count } items</span>
{ pagination } { pagination }
</div> </div>
); );

View File

@ -7,26 +7,33 @@ define(['react'], function(React) {
this.refs.search.value this.refs.search.value
); );
}, },
componentWillReceiveProps: function(nextProps) {
this.refs.search.value = nextProps.search
},
render: function() { render: function() {
return ( if(this.props.search === false) {
<form name="search" onSubmit={this.handleSearch}> return false;
<p className="search-box"> } else {
<label htmlFor="search_input" className="screen-reader-text"> return (
Search <form name="search" onSubmit={this.handleSearch}>
</label> <p className="search-box">
<input <label htmlFor="search_input" className="screen-reader-text">
type="search" Search
id="search_input" </label>
ref="search" <input
name="s" type="search"
defaultValue={this.props.search} /> id="search_input"
<input ref="search"
type="submit" name="s"
defaultValue={MailPoetI18n.searchLabel} defaultValue={this.props.search} />
className="button" /> <input
</p> type="submit"
</form> defaultValue={MailPoetI18n.searchLabel}
); className="button" />
</p>
</form>
);
}
} }
}); });

View File

@ -166,81 +166,62 @@ define([
this.$('.mailpoet_automated_latest_content_categories_and_tags').select2({ this.$('.mailpoet_automated_latest_content_categories_and_tags').select2({
multiple: true, multiple: true,
allowClear: true, allowClear: true,
query: function(options) { ajax: {
var taxonomies = []; data: function (params) {
// Delegate data loading to our own endpoints return {
WordpressComponent.getTaxonomies(that.model.get('contentType')).then(function(tax) { term: params.term
taxonomies = tax; };
// Fetch available terms based on the list of taxonomies already fetched },
var promise = WordpressComponent.getTerms({ transport: function(options, success, failure) {
search: options.term, var taxonomies,
taxonomies: _.keys(taxonomies) promise = WordpressComponent.getTaxonomies(that.model.get('contentType')).then(function(tax) {
}).then(function(terms) { taxonomies = tax;
return { // Fetch available terms based on the list of taxonomies already fetched
taxonomies: taxonomies, var promise = WordpressComponent.getTerms({
terms: terms, search: options.data.term,
}; taxonomies: _.keys(taxonomies)
}).then(function(terms) {
return {
taxonomies: taxonomies,
terms: terms,
};
});
return promise;
}); });
promise.then(success);
promise.fail(failure);
return promise; return promise;
}).done(function(args) { },
processResults: function(data) {
// Transform taxonomies and terms into select2 compatible format // Transform taxonomies and terms into select2 compatible format
options.callback({ return {
results: _.map( results: _.map(
args.terms, data.terms,
function(item) { function(item) {
return _.defaults({ return _.defaults({
text: args.taxonomies[item.taxonomy].labels.singular_name + ': ' + item.name, text: data.taxonomies[item.taxonomy].labels.singular_name + ': ' + item.name,
id: item.term_id id: item.term_id
}, item); }, item);
} }
) )
}); };
}); },
}, },
initSelection: function(element, callback) { }).on({
// On external data load tell select2 which terms to preselect 'select2:select': function(event) {
var terms = that.model.get('terms');
callback(_.map( terms.add(event.params.data);
that.model.get('terms').toJSON(), // Reset whole model in order for change events to propagate properly
function(item) { that.model.set('terms', terms.toJSON());
return {
id: item.id,
text: item.text,
};
}
));
}, },
}).trigger( 'change' ).on({ 'select2:unselect': function(event) {
'change': function(e){ var terms = that.model.get('terms');
var data = jQuery(this).data('selected'); terms.remove(event.params.data);
// Reset whole model in order for change events to propagate properly
if (typeof data === 'string') { that.model.set('terms', terms.toJSON());
if (data === '') { },
data = []; }).trigger( 'change' );
} else {
data = JSON.parse(data);
}
}
if ( e.added ){
data.push(e.added);
} else {
data = _.filter(data, function(item) {
return item.id !== e.removed.id;
});
}
// Update ALC model
that.model.set('terms', data);
jQuery(this).data('selected', JSON.stringify(data));
}
});
},
onBeforeDestroy: function() {
base.BlockSettingsView.prototype.onBeforeDestroy.apply(this, arguments);
// Force close select2 if it hasn't closed yet
this.$('.mailpoet_automated_latest_content_categories_and_tags').select2('close');
}, },
toggleDisplayOptions: function(event) { toggleDisplayOptions: function(event) {
var el = this.$('.mailpoet_automated_latest_content_display_options'), var el = this.$('.mailpoet_automated_latest_content_display_options'),

View File

@ -297,9 +297,9 @@ define([
// Following advice from Becs, the target width should // Following advice from Becs, the target width should
// be a double of one column width to render well on // be a double of one column width to render well on
// retina screen devices // retina screen devices
targetImageWidth = 1200, targetImageWidth = 1320,
// For main image use the size, that's closest to being 600px in width // For main image use the size, that's closest to being 660px in width
sizeKeys = _.keys(sizes), sizeKeys = _.keys(sizes),
// Pick the width that is closest to target width // Pick the width that is closest to target width

View File

@ -21,7 +21,8 @@ define([
'newsletter_editor/components/wordpress', 'newsletter_editor/components/wordpress',
'newsletter_editor/blocks/base', 'newsletter_editor/blocks/base',
'newsletter_editor/blocks/button', 'newsletter_editor/blocks/button',
'newsletter_editor/blocks/divider' 'newsletter_editor/blocks/divider',
'select2'
], function(Backbone, Marionette, Radio, _, jQuery, MailPoet, App, WordpressComponent, BaseBlock, ButtonBlock, DividerBlock) { ], function(Backbone, Marionette, Radio, _, jQuery, MailPoet, App, WordpressComponent, BaseBlock, ButtonBlock, DividerBlock) {
"use strict"; "use strict";
@ -249,59 +250,62 @@ define([
this.$('.mailpoet_posts_categories_and_tags').select2({ this.$('.mailpoet_posts_categories_and_tags').select2({
multiple: true, multiple: true,
allowClear: true, allowClear: true,
query: function(options) { ajax: {
var taxonomies = []; data: function (params) {
// Delegate data loading to our own endpoints return {
WordpressComponent.getTaxonomies(that.model.get('contentType')).then(function(tax) { term: params.term
taxonomies = tax; };
// Fetch available terms based on the list of taxonomies already fetched },
var promise = WordpressComponent.getTerms({ transport: function(options, success, failure) {
search: options.term, var taxonomies,
taxonomies: _.keys(taxonomies) promise = WordpressComponent.getTaxonomies(that.model.get('contentType')).then(function(tax) {
}).then(function(terms) { taxonomies = tax;
return { // Fetch available terms based on the list of taxonomies already fetched
taxonomies: taxonomies, var promise = WordpressComponent.getTerms({
terms: terms, search: options.data.term,
}; taxonomies: _.keys(taxonomies)
}).then(function(terms) {
return {
taxonomies: taxonomies,
terms: terms,
};
});
return promise;
}); });
promise.then(success);
promise.fail(failure);
return promise; return promise;
}).done(function(args) { },
processResults: function(data) {
// Transform taxonomies and terms into select2 compatible format // Transform taxonomies and terms into select2 compatible format
options.callback({ return {
results: _.map( results: _.map(
args.terms, data.terms,
function(item) { function(item) {
return _.defaults({ return _.defaults({
text: args.taxonomies[item.taxonomy].labels.singular_name + ': ' + item.name, text: data.taxonomies[item.taxonomy].labels.singular_name + ': ' + item.name,
id: item.term_id id: item.term_id
}, item); }, item);
} }
) )
}); };
}); },
}, },
}).trigger( 'change' ).on({ }).on({
'change': function(e){ 'select2:select': function(event) {
var data = []; var terms = that.model.get('terms');
terms.add(event.params.data);
if (typeof data === 'string') { // Reset whole model in order for change events to propagate properly
if (data === '') { that.model.set('terms', terms.toJSON());
data = []; },
} else { 'select2:unselect': function(event) {
data = JSON.parse(data); var terms = that.model.get('terms');
} terms.remove(event.params.data);
} // Reset whole model in order for change events to propagate properly
that.model.set('terms', terms.toJSON());
if ( e.added ){ },
data.push(e.added); }).trigger( 'change' );
}
// Update ALC model
that.model.set('terms', data);
jQuery(this).data('selected', JSON.stringify(data));
}
});
}, },
onBeforeDestroy: function() { onBeforeDestroy: function() {
base.BlockSettingsView.prototype.onBeforeDestroy.apply(this, arguments); base.BlockSettingsView.prototype.onBeforeDestroy.apply(this, arguments);

View File

@ -37,19 +37,77 @@ define(
} }
]; ];
var messages = {
onTrash: function(response) {
var count = ~~response.newsletters;
var message = null;
if(count === 1 || response === true) {
message = (
'1 newsletter was moved to the trash.'
);
} else if(count > 1) {
message = (
'%$1d newsletters were moved to the trash.'
).replace('%$1d', count);
}
if(message !== null) {
MailPoet.Notice.success(message);
}
},
onDelete: function(response) {
var count = ~~response.newsletters;
var message = null;
if(count === 1 || response === true) {
message = (
'1 newsletter was permanently deleted.'
);
} else if(count > 1) {
message = (
'%$1d newsletters were permanently deleted.'
).replace('%$1d', count);
}
if(message !== null) {
MailPoet.Notice.success(message);
}
},
onRestore: function(response) {
var count = ~~response.newsletters;
var message = null;
if(count === 1 || response === true) {
message = (
'1 newsletter has been restored from the trash.'
);
} else if(count > 1) {
message = (
'%$1d newsletters have been restored from the trash.'
).replace('%$1d', count);
}
if(message !== null) {
MailPoet.Notice.success(message);
}
}
};
var bulk_actions = [ var bulk_actions = [
{ {
name: 'trash', name: 'trash',
label: 'Trash' label: 'Trash',
onSuccess: messages.onTrash
} }
]; ];
var item_actions = [ var item_actions = [
{ {
name: 'edit', name: 'edit',
link: function(id) { link: function(item) {
return ( return (
<a href={ '?page=mailpoet-newsletter-editor&id=' + id }> <a href={ `?page=mailpoet-newsletter-editor&id=${ item.id }` }>
Edit Edit
</a> </a>
); );
@ -100,11 +158,13 @@ define(
</h2> </h2>
<Listing <Listing
params={ this.props.params }
endpoint="newsletters" endpoint="newsletters"
onRenderItem={this.renderItem} onRenderItem={this.renderItem}
columns={columns} columns={columns}
bulk_actions={ bulk_actions } bulk_actions={ bulk_actions }
item_actions={ item_actions } /> item_actions={ item_actions }
messages={ messages } />
</div> </div>
); );
} }

View File

@ -7,6 +7,7 @@ import NewsletterTemplates from 'newsletters/templates.jsx'
import NewsletterSend from 'newsletters/send.jsx' import NewsletterSend from 'newsletters/send.jsx'
import NewsletterStandard from 'newsletters/types/standard.jsx' import NewsletterStandard from 'newsletters/types/standard.jsx'
import NewsletterWelcome from 'newsletters/types/welcome.jsx' import NewsletterWelcome from 'newsletters/types/welcome.jsx'
import NewsletterNotification from 'newsletters/types/notification.jsx'
import createHashHistory from 'history/lib/createHashHistory' import createHashHistory from 'history/lib/createHashHistory'
let history = createHashHistory({ queryKey: false }) let history = createHashHistory({ queryKey: false })
@ -27,8 +28,10 @@ if(container) {
<Route path="new" component={ NewsletterTypes } /> <Route path="new" component={ NewsletterTypes } />
<Route name="standard" path="new/standard" component={ NewsletterStandard } /> <Route name="standard" path="new/standard" component={ NewsletterStandard } />
<Route name="welcome" path="new/welcome" component={ NewsletterWelcome } /> <Route name="welcome" path="new/welcome" component={ NewsletterWelcome } />
<Route name="notification" path="new/notification" component={ NewsletterNotification } />
<Route name="template" path="template/:id" component={ NewsletterTemplates } /> <Route name="template" path="template/:id" component={ NewsletterTemplates } />
<Route path="send/:id" component={ NewsletterSend } /> <Route path="send/:id" component={ NewsletterSend } />
<Route path="filter[:filter]" component={ NewsletterList } />
<Route path="*" component={ NewsletterList } /> <Route path="*" component={ NewsletterList } />
</Route> </Route>
</Router> </Router>

View File

@ -85,6 +85,26 @@ define(
</a> </a>
</div> </div>
</li> </li>
<li data-type="notification">
<div className="mailpoet_thumbnail"></div>
<div className="mailpoet_description">
<h3>Post notifications</h3>
<p>
Automatically send posts immediately, daily, weekly or monthly. Filter by categories, if you like.
</p>
</div>
<div className="mailpoet_actions">
<a
className="button button-primary"
onClick={ this.setupNewsletter.bind(null, 'notification') }
>
Set up
</a>
</div>
</li>
</ul> </ul>
</div> </div>
); );

View File

@ -0,0 +1,225 @@
define(
[
'underscore',
'react',
'react-router',
'mailpoet',
'form/form.jsx',
'form/fields/select.jsx',
'form/fields/selection.jsx',
'form/fields/text.jsx',
'newsletters/breadcrumb.jsx'
],
function(
_,
React,
Router,
MailPoet,
Form,
Select,
Selection,
Text,
Breadcrumb
) {
var intervalField = {
name: 'interval',
values: {
'daily': 'Once a day at...',
'weekly': 'Weekly on...',
'monthly': 'Monthly on the...',
'nthWeekDay': 'Monthly every...',
'immediately': 'Immediately.',
},
};
var SECONDS_IN_DAY = 86400;
var TIME_STEP_SECONDS = 3600; // Default: 3600
var numberOfTimeSteps = SECONDS_IN_DAY / TIME_STEP_SECONDS;
var timeOfDayValues = _.object(_.map(
_.times(numberOfTimeSteps, function(step) { return step * TIME_STEP_SECONDS; }),
function(seconds) {
var date = new Date(null);
date.setSeconds(seconds);
var timeLabel = date.toISOString().substr(11, 5);
return [seconds, timeLabel];
}
));
var timeOfDayField = {
name: 'timeOfDay',
values: timeOfDayValues,
};
var weekDayField = {
name: 'weekDay',
values: {
0: 'Monday',
1: 'Tuesday',
2: 'Wednesday',
3: 'Thursday',
4: 'Friday',
5: 'Saturday',
6: 'Sunday',
},
};
var NUMBER_OF_DAYS_IN_MONTH = 28; // 28 for compatibility with MP2
var monthDayField = {
name: 'monthDay',
values: _.object(_.map(
_.times(NUMBER_OF_DAYS_IN_MONTH, function(day) { return day; }),
function(day) {
var suffixes = {
0: 'st',
1: 'nd',
2: 'rd'
};
var suffix = suffixes[day] || 'th';
return [day, (day + 1).toString() + suffix];
},
)),
};
var nthWeekDayField = {
name: 'nthWeekDay',
values: {
'0': '1st',
'1': '2nd',
'2': '3rd',
'3': 'last',
},
};
var NewsletterWelcome = React.createClass({
mixins: [
Router.History
],
getInitialState: function() {
return {
intervalType: 'immediate', // 'immediate'|'daily'|'weekly'|'monthly'
timeOfDay: 0,
weekDay: 0,
monthDay: 0,
nthWeekDay: 0,
};
},
handleIntervalChange: function(event) {
this.setState({
intervalType: event.target.value,
});
},
handleTimeOfDayChange: function(event) {
this.setState({
timeOfDay: event.target.value,
});
},
handleWeekDayChange: function(event) {
this.setState({
weekDay: event.target.value,
});
},
handleMonthDayChange: function(event) {
this.setState({
monthDay: event.target.value,
});
},
handleNthWeekDayChange: function(event) {
this.setState({
nthWeekDay: event.target.value,
});
},
handleNext: function() {
MailPoet.Ajax.post({
endpoint: 'newsletters',
action: 'create',
data: {
type: 'notification',
options: this.state,
},
}).done(function(response) {
if(response.id !== undefined) {
this.showTemplateSelection(response.id);
} else {
response.map(function(error) {
MailPoet.Notice.error(error);
});
}
}.bind(this));
},
showTemplateSelection: function(newsletterId) {
this.history.pushState(null, `/template/${newsletterId}`);
},
render: function() {
var timeOfDaySelection,
weekDaySelection,
monthDaySelection,
nthWeekDaySelection;
if (this.state.intervalType !== 'immediately') {
timeOfDaySelection = (
<Select
field={timeOfDayField}
item={this.state}
onValueChange={this.handleTimeOfDayChange} />
);
}
if (this.state.intervalType === 'weekly'
|| this.state.intervalType === 'nthWeekDay') {
weekDaySelection = (
<Select
field={weekDayField}
item={this.state}
onValueChange={this.handleWeekDayChange} />
);
}
if (this.state.intervalType === 'monthly') {
monthDaySelection = (
<Select
field={monthDayField}
item={this.state}
onValueChange={this.handleMonthDayChange} />
);
}
if (this.state.intervalType === 'nthWeekDay') {
nthWeekDaySelection = (
<Select
field={nthWeekDayField}
item={this.state}
onValueChange={this.handleNthWeekDayChange} />
);
}
return (
<div>
<h1>Post notifications</h1>
<Breadcrumb step="type" />
<Select
field={intervalField}
item={this.state}
onValueChange={this.handleIntervalChange} />
{nthWeekDaySelection}
{monthDaySelection}
{weekDaySelection}
{timeOfDaySelection}
<p className="submit">
<input
className="button button-primary"
type="button"
onClick={ this.handleNext }
value="Next" />
</p>
</div>
);
},
});
return NewsletterWelcome;
}
);

View File

@ -17,6 +17,11 @@ define(
name: 'name', name: 'name',
label: 'Name', label: 'Name',
type: 'text' type: 'text'
},
{
name: 'description',
label: 'Description',
type: 'textarea'
} }
]; ];
@ -29,21 +34,27 @@ define(
} }
}; };
var Link = Router.Link;
var SegmentForm = React.createClass({ var SegmentForm = React.createClass({
mixins: [
Router.History
],
render: function() { render: function() {
return ( return (
<div> <div>
<h2 className="title"> <h2 className="title">
Segment <Link className="add-new-h2" to="/">Back to list</Link> Segment <a
href="javascript:;"
className="add-new-h2"
onClick={ this.history.goBack }
>Back to list</a>
</h2> </h2>
<Form <Form
endpoint="segments" endpoint="segments"
fields={ fields } fields={ fields }
params={ this.props.params } params={ this.props.params }
messages={ messages } /> messages={ messages }
onSuccess={ this.history.goBack } />
</div> </div>
); );
} }

View File

@ -1,85 +1,202 @@
define( import React from 'react'
[ import { Router, Route, Link } from 'react-router'
'react',
'react-router',
'listing/listing.jsx',
'classnames'
],
function(
React,
Router,
Listing,
classNames
) {
var columns = [
{
name: 'name',
label: 'Name',
sortable: true
},
{
name: 'created_at',
label: 'Created on',
sortable: true
},
{
name: 'updated_at',
label: 'Last modified on',
sortable: true
}
];
var bulk_actions = [ import jQuery from 'jquery'
{ import MailPoet from 'mailpoet'
name: 'trash', import classNames from 'classnames'
label: 'Trash'
}
];
var Link = Router.Link; import Listing from 'listing/listing.jsx'
var SegmentList = React.createClass({ var columns = [
renderItem: function(segment, actions) { {
var rowClasses = classNames( name: 'name',
'manage-column', label: 'Name',
'column-primary', sortable: true
'has-row-actions' },
); {
name: 'description',
return ( label: 'Description',
<div> sortable: false
<td className={ rowClasses }> },
<strong> {
<a>{ segment.name }</a> name: 'subscribed',
</strong> label: 'Subscribed',
{ actions } sortable: false
</td> },
<td className="column-date" data-colname="Subscribed on"> {
<abbr>{ segment.created_at }</abbr> name: 'unconfirmed',
</td> label: 'Unconfirmed',
<td className="column-date" data-colname="Last modified on"> sortable: false
<abbr>{ segment.updated_at }</abbr> },
</td> {
</div> name: 'unsubscribed',
); label: 'Unsubscribed',
}, sortable: false
render: function() { },
return ( {
<div> name: 'created_at',
<h2 className="title"> label: 'Created on',
Segments <Link className="add-new-h2" to="/new">New</Link> sortable: true
</h2>
<Listing
endpoint="segments"
onRenderItem={this.renderItem}
columns={columns}
bulk_actions={ bulk_actions } />
</div>
);
}
});
return SegmentList;
} }
); ];
var messages = {
onTrash: function(response) {
if(response) {
var message = null;
if(~~response === 1) {
message = (
'1 segment was moved to the trash.'
);
} else if(~~response > 1) {
message = (
'%$1d segments were moved to the trash.'
).replace('%$1d', ~~response);
}
if(message !== null) {
MailPoet.Notice.success(message);
}
}
},
onDelete: function(response) {
if(response) {
var message = null;
if(~~response === 1) {
message = (
'1 segment was permanently deleted.'
);
} else if(~~response > 1) {
message = (
'%$1d segments were permanently deleted.'
).replace('%$1d', ~~response);
}
if(message !== null) {
MailPoet.Notice.success(message);
}
}
},
onRestore: function(response) {
if(response) {
var message = null;
if(~~response === 1) {
message = (
'1 segment has been restored from the trash.'
);
} else if(~~response > 1) {
message = (
'%$1d segments have been restored from the trash.'
).replace('%$1d', ~~response);
}
if(message !== null) {
MailPoet.Notice.success(message);
}
}
}
};
var item_actions = [
{
name: 'edit',
label: 'Edit',
link: function(item) {
return (
<Link to={ `/edit/${item.id}` }>Edit</Link>
);
}
},
{
name: 'duplicate_segment',
label: 'Duplicate',
onClick: function(item, refresh) {
return MailPoet.Ajax.post({
endpoint: 'segments',
action: 'duplicate',
data: item.id
}).done(function(response) {
MailPoet.Notice.success(
('List "%$1s" has been duplicated.').replace('%$1s', response.name)
);
refresh();
});
}
},
{
name: 'view_subscribers',
link: function(item) {
return (
<a href={ item.subscribers_url }>View subscribers</a>
);
}
}
];
var bulk_actions = [
{
name: 'trash',
label: 'Trash',
onSuccess: messages.onTrash
}
];
var SegmentList = React.createClass({
renderItem: function(segment, actions) {
var rowClasses = classNames(
'manage-column',
'column-primary',
'has-row-actions'
);
return (
<div>
<td className={ rowClasses }>
<strong>
<a>{ segment.name }</a>
</strong>
{ actions }
</td>
<td className="column-date" data-colname="Description">
<abbr>{ segment.description }</abbr>
</td>
<td className="column-date" data-colname="Subscribed">
<abbr>{ segment.subscribed || 0 }</abbr>
</td>
<td className="column-date" data-colname="Unconfirmed">
<abbr>{ segment.unconfirmed || 0 }</abbr>
</td>
<td className="column-date" data-colname="Unsubscribed">
<abbr>{ segment.unsubscribed || 0 }</abbr>
</td>
<td className="column-date" data-colname="Created on">
<abbr>{ segment.created_at }</abbr>
</td>
</div>
);
},
render: function() {
return (
<div>
<h2 className="title">
Segments <Link className="add-new-h2" to="/new">New</Link>
</h2>
<Listing
location={ this.props.location }
params={ this.props.params }
messages={ messages }
search={ false }
limit={ 1000 }
endpoint="segments"
onRenderItem={ this.renderItem }
columns={ columns }
bulk_actions={ bulk_actions }
item_actions={ item_actions }
/>
</div>
);
}
});
module.exports = SegmentList;

View File

@ -52,18 +52,26 @@ define(
var Link = Router.Link; var Link = Router.Link;
var SubscriberForm = React.createClass({ var SubscriberForm = React.createClass({
mixins: [
Router.History
],
render: function() { render: function() {
return ( return (
<div> <div>
<h2 className="title"> <h2 className="title">
Subscriber <Link className="add-new-h2" to="/">Back to list</Link> Subscriber <a
href="javascript:;"
className="add-new-h2"
onClick={ this.history.goBack }
>Back to list</a>
</h2> </h2>
<Form <Form
endpoint="subscribers" endpoint="subscribers"
fields={ fields } fields={ fields }
params={ this.props.params } params={ this.props.params }
messages={ messages } /> messages={ messages }
onSuccess={ this.history.goBack } />
</div> </div>
); );
} }

View File

@ -1,313 +1,294 @@
define( import React from 'react'
[ import { Router, Route, Link } from 'react-router'
'react',
'react-router',
'listing/listing.jsx',
'form/fields/selection.jsx',
'classnames',
'mailpoet',
'jquery',
'select2'
],
function(
React,
Router,
Listing,
Selection,
classNames,
MailPoet,
jQuery
) {
var Link = Router.Link;
var columns = [ import jQuery from 'jquery'
{ import MailPoet from 'mailpoet'
name: 'email', import classNames from 'classnames'
label: 'Email',
sortable: true
},
{
name: 'first_name',
label: 'Firstname',
sortable: true
},
{
name: 'last_name',
label: 'Lastname',
sortable: true
},
{
name: 'status',
label: 'Status',
sortable: true
},
{
name: 'segments',
label: 'Lists',
sortable: false
},
{ import Listing from 'listing/listing.jsx'
name: 'created_at', import Selection from 'form/fields/selection.jsx'
label: 'Subscribed on',
sortable: true
},
{
name: 'updated_at',
label: 'Last modified on',
sortable: true
},
];
var messages = { const columns = [
onDelete: function(response) { {
var count = ~~response.subscribers; name: 'email',
var message = null; label: 'Subscriber',
sortable: true
},
{
name: 'status',
label: 'Status',
sortable: true
},
{
name: 'segments',
label: 'Lists',
sortable: false
},
if(count === 1) { {
message = ( name: 'created_at',
'1 subscriber was moved to the trash.' label: 'Subscribed on',
).replace('%$1d', count); sortable: true
} else if(count > 1) { },
message = ( {
'%$1d subscribers were moved to the trash.' name: 'updated_at',
).replace('%$1d', count); label: 'Last modified on',
} sortable: true
},
];
if(message !== null) { const messages = {
MailPoet.Notice.success(message); onTrash: function(response) {
} if(response) {
}, var message = null;
onConfirmDelete: function(response) { if(~~response === 1) {
var count = ~~response.subscribers; message = (
var message = null; '1 subscriber was moved to the trash.'
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',
label: 'Move to list...',
onSelect: function() {
var field = {
id: 'move_to_segment',
endpoint: 'segments'
};
return (
<Selection field={ field }/>
);
},
getData: function() {
return {
segment_id: ~~(jQuery('#move_to_segment').val())
}
},
onSuccess: function(response) {
MailPoet.Notice.success(
'%$1d subscribers were moved to list <strong>%$2s</strong>.'
.replace('%$1d', ~~response.subscribers)
.replace('%$2s', response.segment)
);
}
},
{
name: 'addToList',
label: 'Add to list...',
onSelect: function() {
var field = {
id: 'add_to_segment',
endpoint: 'segments'
};
return (
<Selection field={ field }/>
);
},
getData: function() {
return {
segment_id: ~~(jQuery('#add_to_segment').val())
}
},
onSuccess: function(response) {
MailPoet.Notice.success(
'%$1d subscribers were added to list <strong>%$2s</strong>.'
.replace('%$1d', ~~response.subscribers)
.replace('%$2s', response.segment)
);
}
},
{
name: 'removeFromList',
label: 'Remove from list...',
onSelect: function() {
var field = {
id: 'remove_from_segment',
endpoint: 'segments'
};
return (
<Selection field={ field }/>
);
},
getData: function() {
return {
segment_id: ~~(jQuery('#remove_from_segment').val())
}
},
onSuccess: function(response) {
MailPoet.Notice.success(
'%$1d subscribers were removed from list <strong>%$2s</strong>.'
.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',
getData: function() {
return {
confirm: false
}
},
onSuccess: messages.onDelete
}
];
var SubscriberList = React.createClass({
renderItem: function(subscriber, actions) {
var row_classes = classNames(
'manage-column',
'column-primary',
'has-row-actions'
);
var status = '';
switch(subscriber.status) {
case 'subscribed':
status = 'Subscribed';
break;
case 'unconfirmed':
status = 'Unconfirmed';
break;
case 'unsubscribed':
status = 'Unsubscribed';
break;
}
var segments = mailpoet_segments.filter(function(segment) {
return (jQuery.inArray(segment.id, subscriber.segments) !== -1);
}).map(function(segment) {
return segment.name;
}).join(', ');
var row_actions = false;
return (
<div>
<td className={ row_classes }>
<strong><Link to={ `/edit/${ subscriber.id }` }>
{ subscriber.email }
</Link></strong>
{ actions }
</td>
<td className="column" data-colname="First name">
{ subscriber.first_name }
</td>
<td className="column" data-colname="Last name">
{ subscriber.last_name }
</td>
<td className="column" data-colname="Status">
{ status }
</td>
<td className="column" data-colname="Lists">
{ segments }
</td>
<td className="column-date" data-colname="Subscribed on">
<abbr>{ subscriber.created_at }</abbr>
</td>
<td className="column-date" data-colname="Last modified on">
<abbr>{ subscriber.updated_at }</abbr>
</td>
</div>
);
},
render: function() {
return (
<div>
<h2 className="title">
Subscribers <Link className="add-new-h2" to="/new">New</Link>
</h2>
<Listing
endpoint="subscribers"
onRenderItem={ this.renderItem }
columns={ columns }
bulk_actions={ bulk_actions }
messages={ messages }
/>
</div>
); );
} else if(~~response > 1) {
message = (
'%$1d subscribers were moved to the trash.'
).replace('%$1d', ~~response);
} }
});
return SubscriberList; if(message !== null) {
MailPoet.Notice.success(message);
}
}
},
onDelete: function(response) {
if(response) {
var message = null;
if(~~response === 1) {
message = (
'1 subscriber was permanently deleted.'
);
} else if(~~response > 1) {
message = (
'%$1d subscribers were permanently deleted.'
).replace('%$1d', ~~response);
}
if(message !== null) {
MailPoet.Notice.success(message);
}
}
},
onRestore: function(response) {
if(response) {
var message = null;
if(~~response === 1) {
message = (
'1 subscriber has been restored from the trash.'
);
} else if(~~response > 1) {
message = (
'%$1d subscribers have been restored from the trash.'
).replace('%$1d', ~~response);
}
if(message !== null) {
MailPoet.Notice.success(message);
}
}
} }
); };
const bulk_actions = [
{
name: 'moveToList',
label: 'Move to list...',
onSelect: function() {
let field = {
id: 'move_to_segment',
endpoint: 'segments'
};
return (
<Selection field={ field }/>
);
},
getData: function() {
return {
segment_id: ~~(jQuery('#move_to_segment').val())
}
},
onSuccess: function(response) {
MailPoet.Notice.success(
'%$1d subscribers were moved to list <strong>%$2s</strong>.'
.replace('%$1d', ~~response.subscribers)
.replace('%$2s', response.segment)
);
}
},
{
name: 'addToList',
label: 'Add to list...',
onSelect: function() {
let field = {
id: 'add_to_segment',
endpoint: 'segments'
};
return (
<Selection field={ field }/>
);
},
getData: function() {
return {
segment_id: ~~(jQuery('#add_to_segment').val())
}
},
onSuccess: function(response) {
MailPoet.Notice.success(
'%$1d subscribers were added to list <strong>%$2s</strong>.'
.replace('%$1d', ~~response.subscribers)
.replace('%$2s', response.segment)
);
}
},
{
name: 'removeFromList',
label: 'Remove from list...',
onSelect: function() {
let field = {
id: 'remove_from_segment',
endpoint: 'segments'
};
return (
<Selection field={ field }/>
);
},
getData: function() {
return {
segment_id: ~~(jQuery('#remove_from_segment').val())
}
},
onSuccess: function(response) {
MailPoet.Notice.success(
'%$1d subscribers were removed from list <strong>%$2s</strong>.'
.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)
);
}
},
{
name: 'confirmUnconfirmed',
label: 'Confirm unconfirmed',
onSuccess: function(response) {
MailPoet.Notice.success(
'%$1d subscribers have been confirmed.'
.replace('%$1d', ~~response)
);
}
},
{
name: 'trash',
label: 'Trash',
onSuccess: messages.onTrash
}
];
const SubscriberList = React.createClass({
renderItem: function(subscriber, actions) {
let row_classes = classNames(
'manage-column',
'column-primary',
'has-row-actions',
'column-username'
);
let status = '';
switch(subscriber.status) {
case 'subscribed':
status = 'Subscribed';
break;
case 'unconfirmed':
status = 'Unconfirmed';
break;
case 'unsubscribed':
status = 'Unsubscribed';
break;
}
let segments = mailpoet_segments.filter(function(segment) {
return (jQuery.inArray(segment.id, subscriber.segments) !== -1);
}).map(function(segment) {
return segment.name;
}).join(', ');
let avatar = false;
if(subscriber.avatar_url) {
avatar = (
<img
className="avatar"
src={ subscriber.avatar_url }
title=""
width="32"
height="32"
/>
);
}
return (
<div>
<td className={ row_classes }>
<strong><Link to={ `/edit/${ subscriber.id }` }>
{ subscriber.email }
</Link></strong>
<p style={{margin: 0}}>
{ subscriber.first_name } { subscriber.last_name }
</p>
{ actions }
</td>
<td className="column" data-colname="Status">
{ status }
</td>
<td className="column" data-colname="Lists">
{ segments }
</td>
<td className="column-date" data-colname="Subscribed on">
<abbr>{ subscriber.created_at }</abbr>
</td>
<td className="column-date" data-colname="Last modified on">
<abbr>{ subscriber.updated_at }</abbr>
</td>
</div>
);
},
render: function() {
return (
<div>
<h2 className="title">
Subscribers <Link className="add-new-h2" to="/new">New</Link>
</h2>
<Listing
location={ this.props.location }
params={ this.props.params }
endpoint="subscribers"
onRenderItem={ this.renderItem }
columns={ columns }
bulk_actions={ bulk_actions }
messages={ messages }
/>
</div>
)
}
});
module.exports = SubscriberList;

View File

@ -5,15 +5,15 @@ import SubscriberList from 'subscribers/list.jsx'
import SubscriberForm from 'subscribers/form.jsx' import SubscriberForm from 'subscribers/form.jsx'
import createHashHistory from 'history/lib/createHashHistory' import createHashHistory from 'history/lib/createHashHistory'
let history = createHashHistory({ queryKey: false }) const history = createHashHistory({ queryKey: false })
const App = React.createClass({ const App = React.createClass({
render() { render() {
return this.props.children return this.props.children;
} }
}); });
let container = document.getElementById('subscribers_container'); const container = document.getElementById('subscribers_container')
if(container) { if(container) {
ReactDOM.render(( ReactDOM.render((

View File

@ -7,7 +7,8 @@
"sunra/php-simple-html-dom-parser": "*", "sunra/php-simple-html-dom-parser": "*",
"tburry/pquery": "*", "tburry/pquery": "*",
"j4mie/paris": "1.5.4", "j4mie/paris": "1.5.4",
"swiftmailer/swiftmailer": "^5.4" "swiftmailer/swiftmailer": "^5.4",
"phpseclib/phpseclib": "*"
}, },
"require-dev": { "require-dev": {
"codeception/codeception": "*", "codeception/codeception": "*",

359
composer.lock generated
View File

@ -4,8 +4,8 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"hash": "92704d2679fce692438b9e6f1dc6e02f", "hash": "7d7ef94b6e40ac2b2d594e5832d7e16d",
"content-hash": "3297411fcec47a02bc4f456fbf3751d1", "content-hash": "2e70c335edf7429df0794ebf49e2f210",
"packages": [ "packages": [
{ {
"name": "cerdic/css-tidy", "name": "cerdic/css-tidy",
@ -218,6 +218,94 @@
"description": "PHPMailer is a full-featured email creation and transfer class for PHP", "description": "PHPMailer is a full-featured email creation and transfer class for PHP",
"time": "2015-09-14 09:18:12" "time": "2015-09-14 09:18:12"
}, },
{
"name": "phpseclib/phpseclib",
"version": "2.0.0",
"source": {
"type": "git",
"url": "https://github.com/phpseclib/phpseclib.git",
"reference": "a74aa9efbe61430fcb60157c8e025a48ec8ff604"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/a74aa9efbe61430fcb60157c8e025a48ec8ff604",
"reference": "a74aa9efbe61430fcb60157c8e025a48ec8ff604",
"shasum": ""
},
"require": {
"php": ">=5.3.3"
},
"require-dev": {
"phing/phing": "~2.7",
"phpunit/phpunit": "~4.0",
"sami/sami": "~2.0",
"squizlabs/php_codesniffer": "~2.0"
},
"suggest": {
"ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.",
"ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.",
"ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.",
"ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations.",
"pear-pear/PHP_Compat": "Install PHP_Compat to get phpseclib working on PHP < 5.0.0."
},
"type": "library",
"autoload": {
"psr-4": {
"phpseclib\\": "phpseclib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"include-path": [
"phpseclib/"
],
"license": [
"MIT"
],
"authors": [
{
"name": "Jim Wigginton",
"email": "terrafrost@php.net",
"role": "Lead Developer"
},
{
"name": "Patrick Monnerat",
"email": "pm@datasphere.ch",
"role": "Developer"
},
{
"name": "Andreas Fischer",
"email": "bantu@phpbb.com",
"role": "Developer"
},
{
"name": "Hans-Jürgen Petrich",
"email": "petrich@tronic-media.com",
"role": "Developer"
}
],
"description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.",
"homepage": "http://phpseclib.sourceforge.net",
"keywords": [
"BigInteger",
"aes",
"asn.1",
"asn1",
"blowfish",
"crypto",
"cryptography",
"encryption",
"rsa",
"security",
"sftp",
"signature",
"signing",
"ssh",
"twofish",
"x.509",
"x509"
],
"time": "2015-08-04 04:48:03"
},
{ {
"name": "sunra/php-simple-html-dom-parser", "name": "sunra/php-simple-html-dom-parser",
"version": "v1.5.0", "version": "v1.5.0",
@ -368,16 +456,16 @@
}, },
{ {
"name": "twig/twig", "name": "twig/twig",
"version": "v1.22.3", "version": "v1.23.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/twigphp/Twig.git", "url": "https://github.com/twigphp/Twig.git",
"reference": "ebfc36b7e77b0c1175afe30459cf943010245540" "reference": "5868cd822fd6cf626d5f805439575f9c323cee2a"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/twigphp/Twig/zipball/ebfc36b7e77b0c1175afe30459cf943010245540", "url": "https://api.github.com/repos/twigphp/Twig/zipball/5868cd822fd6cf626d5f805439575f9c323cee2a",
"reference": "ebfc36b7e77b0c1175afe30459cf943010245540", "reference": "5868cd822fd6cf626d5f805439575f9c323cee2a",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -390,7 +478,7 @@
"type": "library", "type": "library",
"extra": { "extra": {
"branch-alias": { "branch-alias": {
"dev-master": "1.22-dev" "dev-master": "1.23-dev"
} }
}, },
"autoload": { "autoload": {
@ -425,7 +513,7 @@
"keywords": [ "keywords": [
"templating" "templating"
], ],
"time": "2015-10-13 07:07:02" "time": "2015-10-29 23:29:01"
} }
], ],
"packages-dev": [ "packages-dev": [
@ -545,16 +633,16 @@
}, },
{ {
"name": "codegyre/robo", "name": "codegyre/robo",
"version": "0.5.4", "version": "0.6.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/Codegyre/Robo.git", "url": "https://github.com/Codegyre/Robo.git",
"reference": "10aa223f6d1db182dc81d723bf1545dfc6ff380d" "reference": "d18185f0494c854d36aa5ee0ad931ee23bbef552"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/Codegyre/Robo/zipball/10aa223f6d1db182dc81d723bf1545dfc6ff380d", "url": "https://api.github.com/repos/Codegyre/Robo/zipball/d18185f0494c854d36aa5ee0ad931ee23bbef552",
"reference": "10aa223f6d1db182dc81d723bf1545dfc6ff380d", "reference": "d18185f0494c854d36aa5ee0ad931ee23bbef552",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -568,6 +656,7 @@
"require-dev": { "require-dev": {
"codeception/aspect-mock": "0.5.*", "codeception/aspect-mock": "0.5.*",
"codeception/base": "~2.1", "codeception/base": "~2.1",
"codeception/codeception": "2.1",
"codeception/verify": "0.2.*", "codeception/verify": "0.2.*",
"natxet/cssmin": "~3.0", "natxet/cssmin": "~3.0",
"patchwork/jsqueeze": "~1.0" "patchwork/jsqueeze": "~1.0"
@ -592,7 +681,7 @@
} }
], ],
"description": "Modern task runner", "description": "Modern task runner",
"time": "2015-08-31 17:35:30" "time": "2015-10-30 11:29:52"
}, },
{ {
"name": "doctrine/instantiator", "name": "doctrine/instantiator",
@ -867,12 +956,12 @@
"version": "1.0.0", "version": "1.0.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/henrikbjorn/Lurker.git", "url": "https://github.com/flint/Lurker.git",
"reference": "a020d45b3bc37810aeafe27343c51af8a74c9419" "reference": "a020d45b3bc37810aeafe27343c51af8a74c9419"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/henrikbjorn/Lurker/zipball/a020d45b3bc37810aeafe27343c51af8a74c9419", "url": "https://api.github.com/repos/flint/Lurker/zipball/a020d45b3bc37810aeafe27343c51af8a74c9419",
"reference": "a020d45b3bc37810aeafe27343c51af8a74c9419", "reference": "a020d45b3bc37810aeafe27343c51af8a74c9419",
"shasum": "" "shasum": ""
}, },
@ -901,18 +990,16 @@
], ],
"authors": [ "authors": [
{ {
"name": "Henrik Bjornskov", "name": "Yaroslav Kiliba",
"email": "henrik@bjrnskov.dk", "email": "om.dattaya@gmail.com"
"homepage": "http://henrik.bjrnskov.dk"
}, },
{ {
"name": "Konstantin Kudryashov", "name": "Konstantin Kudryashov",
"email": "ever.zet@gmail.com", "email": "ever.zet@gmail.com"
"homepage": "http://everzet.com"
}, },
{ {
"name": "Yaroslav Kiliba", "name": "Henrik Bjrnskov",
"email": "om.dattaya@gmail.com" "email": "henrik@bjrnskov.dk"
} }
], ],
"description": "Resource Watcher.", "description": "Resource Watcher.",
@ -1873,16 +1960,16 @@
}, },
{ {
"name": "symfony/browser-kit", "name": "symfony/browser-kit",
"version": "v2.7.5", "version": "v2.7.6",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/browser-kit.git", "url": "https://github.com/symfony/browser-kit.git",
"reference": "277a2457776d4cc25706fbdd9d1e4ab2dac884e4" "reference": "07d664a052572ccc28eb2ab7dbbe82155b1ad367"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/browser-kit/zipball/277a2457776d4cc25706fbdd9d1e4ab2dac884e4", "url": "https://api.github.com/repos/symfony/browser-kit/zipball/07d664a052572ccc28eb2ab7dbbe82155b1ad367",
"reference": "277a2457776d4cc25706fbdd9d1e4ab2dac884e4", "reference": "07d664a052572ccc28eb2ab7dbbe82155b1ad367",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -1891,8 +1978,7 @@
}, },
"require-dev": { "require-dev": {
"symfony/css-selector": "~2.0,>=2.0.5", "symfony/css-selector": "~2.0,>=2.0.5",
"symfony/phpunit-bridge": "~2.7", "symfony/process": "~2.3.34|~2.7,>=2.7.6"
"symfony/process": "~2.0,>=2.0.5"
}, },
"suggest": { "suggest": {
"symfony/process": "" "symfony/process": ""
@ -1924,29 +2010,26 @@
], ],
"description": "Symfony BrowserKit Component", "description": "Symfony BrowserKit Component",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"time": "2015-09-06 08:36:38" "time": "2015-10-23 14:47:27"
}, },
{ {
"name": "symfony/config", "name": "symfony/config",
"version": "v2.7.5", "version": "v2.7.6",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/config.git", "url": "https://github.com/symfony/config.git",
"reference": "9698fdf0a750d6887d5e7729d5cf099765b20e61" "reference": "831f88908b51b9ce945f5e6f402931d1ac544423"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/config/zipball/9698fdf0a750d6887d5e7729d5cf099765b20e61", "url": "https://api.github.com/repos/symfony/config/zipball/831f88908b51b9ce945f5e6f402931d1ac544423",
"reference": "9698fdf0a750d6887d5e7729d5cf099765b20e61", "reference": "831f88908b51b9ce945f5e6f402931d1ac544423",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": ">=5.3.9", "php": ">=5.3.9",
"symfony/filesystem": "~2.3" "symfony/filesystem": "~2.3"
}, },
"require-dev": {
"symfony/phpunit-bridge": "~2.7"
},
"type": "library", "type": "library",
"extra": { "extra": {
"branch-alias": { "branch-alias": {
@ -1974,20 +2057,20 @@
], ],
"description": "Symfony Config Component", "description": "Symfony Config Component",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"time": "2015-09-21 15:02:29" "time": "2015-10-11 09:39:48"
}, },
{ {
"name": "symfony/console", "name": "symfony/console",
"version": "v2.7.5", "version": "v2.7.6",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/console.git", "url": "https://github.com/symfony/console.git",
"reference": "06cb17c013a82f94a3d840682b49425cd00a2161" "reference": "5efd632294c8320ea52492db22292ff853a43766"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/console/zipball/06cb17c013a82f94a3d840682b49425cd00a2161", "url": "https://api.github.com/repos/symfony/console/zipball/5efd632294c8320ea52492db22292ff853a43766",
"reference": "06cb17c013a82f94a3d840682b49425cd00a2161", "reference": "5efd632294c8320ea52492db22292ff853a43766",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -1996,7 +2079,6 @@
"require-dev": { "require-dev": {
"psr/log": "~1.0", "psr/log": "~1.0",
"symfony/event-dispatcher": "~2.1", "symfony/event-dispatcher": "~2.1",
"symfony/phpunit-bridge": "~2.7",
"symfony/process": "~2.1" "symfony/process": "~2.1"
}, },
"suggest": { "suggest": {
@ -2031,28 +2113,25 @@
], ],
"description": "Symfony Console Component", "description": "Symfony Console Component",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"time": "2015-09-25 08:32:23" "time": "2015-10-20 14:38:46"
}, },
{ {
"name": "symfony/css-selector", "name": "symfony/css-selector",
"version": "v2.7.5", "version": "v2.7.6",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/css-selector.git", "url": "https://github.com/symfony/css-selector.git",
"reference": "abe19cc0429a06be0c133056d1f9859854860970" "reference": "e1b865b26be4a56d22a8dee398375044a80c865b"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/css-selector/zipball/abe19cc0429a06be0c133056d1f9859854860970", "url": "https://api.github.com/repos/symfony/css-selector/zipball/e1b865b26be4a56d22a8dee398375044a80c865b",
"reference": "abe19cc0429a06be0c133056d1f9859854860970", "reference": "e1b865b26be4a56d22a8dee398375044a80c865b",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": ">=5.3.9" "php": ">=5.3.9"
}, },
"require-dev": {
"symfony/phpunit-bridge": "~2.7"
},
"type": "library", "type": "library",
"extra": { "extra": {
"branch-alias": { "branch-alias": {
@ -2084,28 +2163,27 @@
], ],
"description": "Symfony CssSelector Component", "description": "Symfony CssSelector Component",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"time": "2015-09-22 13:49:29" "time": "2015-10-11 09:39:48"
}, },
{ {
"name": "symfony/dom-crawler", "name": "symfony/dom-crawler",
"version": "v2.7.5", "version": "v2.7.6",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/dom-crawler.git", "url": "https://github.com/symfony/dom-crawler.git",
"reference": "2e185ca136399f902b948694987e62c80099c052" "reference": "5fef7d8b80d8f9992df99d8ee283f420484c9612"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/dom-crawler/zipball/2e185ca136399f902b948694987e62c80099c052", "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/5fef7d8b80d8f9992df99d8ee283f420484c9612",
"reference": "2e185ca136399f902b948694987e62c80099c052", "reference": "5fef7d8b80d8f9992df99d8ee283f420484c9612",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": ">=5.3.9" "php": ">=5.3.9"
}, },
"require-dev": { "require-dev": {
"symfony/css-selector": "~2.3", "symfony/css-selector": "~2.3"
"symfony/phpunit-bridge": "~2.7"
}, },
"suggest": { "suggest": {
"symfony/css-selector": "" "symfony/css-selector": ""
@ -2137,20 +2215,20 @@
], ],
"description": "Symfony DomCrawler Component", "description": "Symfony DomCrawler Component",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"time": "2015-09-20 21:13:58" "time": "2015-10-11 09:39:48"
}, },
{ {
"name": "symfony/event-dispatcher", "name": "symfony/event-dispatcher",
"version": "v2.7.5", "version": "v2.7.6",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/event-dispatcher.git", "url": "https://github.com/symfony/event-dispatcher.git",
"reference": "ae4dcc2a8d3de98bd794167a3ccda1311597c5d9" "reference": "87a5db5ea887763fa3a31a5471b512ff1596d9b8"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/ae4dcc2a8d3de98bd794167a3ccda1311597c5d9", "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/87a5db5ea887763fa3a31a5471b512ff1596d9b8",
"reference": "ae4dcc2a8d3de98bd794167a3ccda1311597c5d9", "reference": "87a5db5ea887763fa3a31a5471b512ff1596d9b8",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -2161,7 +2239,6 @@
"symfony/config": "~2.0,>=2.0.5", "symfony/config": "~2.0,>=2.0.5",
"symfony/dependency-injection": "~2.6", "symfony/dependency-injection": "~2.6",
"symfony/expression-language": "~2.6", "symfony/expression-language": "~2.6",
"symfony/phpunit-bridge": "~2.7",
"symfony/stopwatch": "~2.3" "symfony/stopwatch": "~2.3"
}, },
"suggest": { "suggest": {
@ -2195,28 +2272,25 @@
], ],
"description": "Symfony EventDispatcher Component", "description": "Symfony EventDispatcher Component",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"time": "2015-09-22 13:49:29" "time": "2015-10-11 09:39:48"
}, },
{ {
"name": "symfony/filesystem", "name": "symfony/filesystem",
"version": "v2.7.5", "version": "v2.7.6",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/filesystem.git", "url": "https://github.com/symfony/filesystem.git",
"reference": "a17f8a17c20e8614c15b8e116e2f4bcde102cfab" "reference": "56fd6df73be859323ff97418d97edc1d756df6df"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/filesystem/zipball/a17f8a17c20e8614c15b8e116e2f4bcde102cfab", "url": "https://api.github.com/repos/symfony/filesystem/zipball/56fd6df73be859323ff97418d97edc1d756df6df",
"reference": "a17f8a17c20e8614c15b8e116e2f4bcde102cfab", "reference": "56fd6df73be859323ff97418d97edc1d756df6df",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": ">=5.3.9" "php": ">=5.3.9"
}, },
"require-dev": {
"symfony/phpunit-bridge": "~2.7"
},
"type": "library", "type": "library",
"extra": { "extra": {
"branch-alias": { "branch-alias": {
@ -2244,28 +2318,25 @@
], ],
"description": "Symfony Filesystem Component", "description": "Symfony Filesystem Component",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"time": "2015-09-09 17:42:36" "time": "2015-10-18 20:23:18"
}, },
{ {
"name": "symfony/finder", "name": "symfony/finder",
"version": "v2.7.5", "version": "v2.7.6",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/finder.git", "url": "https://github.com/symfony/finder.git",
"reference": "8262ab605973afbb3ef74b945daabf086f58366f" "reference": "2ffb4e9598db3c48eb6d0ae73b04bbf09280c59d"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/finder/zipball/8262ab605973afbb3ef74b945daabf086f58366f", "url": "https://api.github.com/repos/symfony/finder/zipball/2ffb4e9598db3c48eb6d0ae73b04bbf09280c59d",
"reference": "8262ab605973afbb3ef74b945daabf086f58366f", "reference": "2ffb4e9598db3c48eb6d0ae73b04bbf09280c59d",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": ">=5.3.9" "php": ">=5.3.9"
}, },
"require-dev": {
"symfony/phpunit-bridge": "~2.7"
},
"type": "library", "type": "library",
"extra": { "extra": {
"branch-alias": { "branch-alias": {
@ -2293,20 +2364,20 @@
], ],
"description": "Symfony Finder Component", "description": "Symfony Finder Component",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"time": "2015-09-19 19:59:23" "time": "2015-10-11 09:39:48"
}, },
{ {
"name": "symfony/form", "name": "symfony/form",
"version": "v2.7.5", "version": "v2.7.6",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/form.git", "url": "https://github.com/symfony/form.git",
"reference": "d4a990d2ebe4dd39cac52c5a40a5aac84b12b237" "reference": "b93fcb816bec2b8470ea9d54e4b6658b2461b83c"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/form/zipball/d4a990d2ebe4dd39cac52c5a40a5aac84b12b237", "url": "https://api.github.com/repos/symfony/form/zipball/b93fcb816bec2b8470ea9d54e4b6658b2461b83c",
"reference": "d4a990d2ebe4dd39cac52c5a40a5aac84b12b237", "reference": "b93fcb816bec2b8470ea9d54e4b6658b2461b83c",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -2325,7 +2396,6 @@
"doctrine/collections": "~1.0", "doctrine/collections": "~1.0",
"symfony/http-foundation": "~2.2", "symfony/http-foundation": "~2.2",
"symfony/http-kernel": "~2.4", "symfony/http-kernel": "~2.4",
"symfony/phpunit-bridge": "~2.7",
"symfony/security-csrf": "~2.4", "symfony/security-csrf": "~2.4",
"symfony/translation": "~2.0,>=2.0.5", "symfony/translation": "~2.0,>=2.0.5",
"symfony/validator": "~2.6,>=2.6.8" "symfony/validator": "~2.6,>=2.6.8"
@ -2363,28 +2433,27 @@
], ],
"description": "Symfony Form Component", "description": "Symfony Form Component",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"time": "2015-09-22 13:49:29" "time": "2015-10-27 15:38:06"
}, },
{ {
"name": "symfony/intl", "name": "symfony/intl",
"version": "v2.7.5", "version": "v2.7.6",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/intl.git", "url": "https://github.com/symfony/intl.git",
"reference": "35f902b232c10056e17d94a842160d44bb540838" "reference": "330f52a996749eb6a2fdc1506c7a4868e070d678"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/intl/zipball/35f902b232c10056e17d94a842160d44bb540838", "url": "https://api.github.com/repos/symfony/intl/zipball/330f52a996749eb6a2fdc1506c7a4868e070d678",
"reference": "35f902b232c10056e17d94a842160d44bb540838", "reference": "330f52a996749eb6a2fdc1506c7a4868e070d678",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": ">=5.3.9" "php": ">=5.3.9"
}, },
"require-dev": { "require-dev": {
"symfony/filesystem": "~2.1", "symfony/filesystem": "~2.1"
"symfony/phpunit-bridge": "~2.7"
}, },
"suggest": { "suggest": {
"ext-intl": "to use the component with locales other than \"en\"" "ext-intl": "to use the component with locales other than \"en\""
@ -2438,28 +2507,25 @@
"l10n", "l10n",
"localization" "localization"
], ],
"time": "2015-09-09 17:53:06" "time": "2015-10-11 09:39:48"
}, },
{ {
"name": "symfony/options-resolver", "name": "symfony/options-resolver",
"version": "v2.7.5", "version": "v2.7.6",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/options-resolver.git", "url": "https://github.com/symfony/options-resolver.git",
"reference": "75389f6f948edfdf0c0ebdbe00c4f84ab5d1a03e" "reference": "85fd10e551677d3c9a4632def78b8ec4670b247d"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/options-resolver/zipball/75389f6f948edfdf0c0ebdbe00c4f84ab5d1a03e", "url": "https://api.github.com/repos/symfony/options-resolver/zipball/85fd10e551677d3c9a4632def78b8ec4670b247d",
"reference": "75389f6f948edfdf0c0ebdbe00c4f84ab5d1a03e", "reference": "85fd10e551677d3c9a4632def78b8ec4670b247d",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": ">=5.3.9" "php": ">=5.3.9"
}, },
"require-dev": {
"symfony/phpunit-bridge": "~2.7"
},
"type": "library", "type": "library",
"extra": { "extra": {
"branch-alias": { "branch-alias": {
@ -2492,28 +2558,25 @@
"configuration", "configuration",
"options" "options"
], ],
"time": "2015-09-25 06:59:16" "time": "2015-10-11 09:39:48"
}, },
{ {
"name": "symfony/process", "name": "symfony/process",
"version": "v2.7.5", "version": "v2.7.6",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/process.git", "url": "https://github.com/symfony/process.git",
"reference": "b27c8e317922cd3cdd3600850273cf6b82b2e8e9" "reference": "4a959dd4e19c2c5d7512689413921e0a74386ec7"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/process/zipball/b27c8e317922cd3cdd3600850273cf6b82b2e8e9", "url": "https://api.github.com/repos/symfony/process/zipball/4a959dd4e19c2c5d7512689413921e0a74386ec7",
"reference": "b27c8e317922cd3cdd3600850273cf6b82b2e8e9", "reference": "4a959dd4e19c2c5d7512689413921e0a74386ec7",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": ">=5.3.9" "php": ">=5.3.9"
}, },
"require-dev": {
"symfony/phpunit-bridge": "~2.7"
},
"type": "library", "type": "library",
"extra": { "extra": {
"branch-alias": { "branch-alias": {
@ -2541,28 +2604,25 @@
], ],
"description": "Symfony Process Component", "description": "Symfony Process Component",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"time": "2015-09-19 19:59:23" "time": "2015-10-23 14:47:27"
}, },
{ {
"name": "symfony/property-access", "name": "symfony/property-access",
"version": "v2.7.5", "version": "v2.7.6",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/property-access.git", "url": "https://github.com/symfony/property-access.git",
"reference": "f8ea7aa472f0e3f8cdf43287caa72a70ff5c088c" "reference": "368b784738fa932e6d86866038312b03e073a824"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/property-access/zipball/f8ea7aa472f0e3f8cdf43287caa72a70ff5c088c", "url": "https://api.github.com/repos/symfony/property-access/zipball/368b784738fa932e6d86866038312b03e073a824",
"reference": "f8ea7aa472f0e3f8cdf43287caa72a70ff5c088c", "reference": "368b784738fa932e6d86866038312b03e073a824",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": ">=5.3.9" "php": ">=5.3.9"
}, },
"require-dev": {
"symfony/phpunit-bridge": "~2.7"
},
"type": "library", "type": "library",
"extra": { "extra": {
"branch-alias": { "branch-alias": {
@ -2601,20 +2661,20 @@
"property path", "property path",
"reflection" "reflection"
], ],
"time": "2015-08-24 07:13:45" "time": "2015-10-23 14:47:27"
}, },
{ {
"name": "symfony/routing", "name": "symfony/routing",
"version": "v2.7.5", "version": "v2.7.6",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/routing.git", "url": "https://github.com/symfony/routing.git",
"reference": "6c5fae83efa20baf166fcf4582f57094e9f60f16" "reference": "f353e1f588679c3ec987624e6c617646bd01ba38"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/routing/zipball/6c5fae83efa20baf166fcf4582f57094e9f60f16", "url": "https://api.github.com/repos/symfony/routing/zipball/f353e1f588679c3ec987624e6c617646bd01ba38",
"reference": "6c5fae83efa20baf166fcf4582f57094e9f60f16", "reference": "f353e1f588679c3ec987624e6c617646bd01ba38",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -2630,7 +2690,6 @@
"symfony/config": "~2.7", "symfony/config": "~2.7",
"symfony/expression-language": "~2.4", "symfony/expression-language": "~2.4",
"symfony/http-foundation": "~2.3", "symfony/http-foundation": "~2.3",
"symfony/phpunit-bridge": "~2.7",
"symfony/yaml": "~2.0,>=2.0.5" "symfony/yaml": "~2.0,>=2.0.5"
}, },
"suggest": { "suggest": {
@ -2672,20 +2731,20 @@
"uri", "uri",
"url" "url"
], ],
"time": "2015-09-14 14:14:09" "time": "2015-10-27 15:38:06"
}, },
{ {
"name": "symfony/translation", "name": "symfony/translation",
"version": "v2.7.5", "version": "v2.7.6",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/translation.git", "url": "https://github.com/symfony/translation.git",
"reference": "485877661835e188cd78345c6d4eef1290d17571" "reference": "6ccd9289ec1c71d01a49d83480de3b5293ce30c8"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/translation/zipball/485877661835e188cd78345c6d4eef1290d17571", "url": "https://api.github.com/repos/symfony/translation/zipball/6ccd9289ec1c71d01a49d83480de3b5293ce30c8",
"reference": "485877661835e188cd78345c6d4eef1290d17571", "reference": "6ccd9289ec1c71d01a49d83480de3b5293ce30c8",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -2698,7 +2757,6 @@
"psr/log": "~1.0", "psr/log": "~1.0",
"symfony/config": "~2.7", "symfony/config": "~2.7",
"symfony/intl": "~2.4", "symfony/intl": "~2.4",
"symfony/phpunit-bridge": "~2.7",
"symfony/yaml": "~2.2" "symfony/yaml": "~2.2"
}, },
"suggest": { "suggest": {
@ -2733,20 +2791,20 @@
], ],
"description": "Symfony Translation Component", "description": "Symfony Translation Component",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"time": "2015-09-06 08:36:38" "time": "2015-10-27 15:38:06"
}, },
{ {
"name": "symfony/twig-bridge", "name": "symfony/twig-bridge",
"version": "v2.7.5", "version": "v2.7.6",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/twig-bridge.git", "url": "https://github.com/symfony/twig-bridge.git",
"reference": "bce37975610a46bde48dbf2f67f724401251d199" "reference": "3dd44937b1e08af8c8f6b14850f4b9c4d1039c6f"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/twig-bridge/zipball/bce37975610a46bde48dbf2f67f724401251d199", "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/3dd44937b1e08af8c8f6b14850f4b9c4d1039c6f",
"reference": "bce37975610a46bde48dbf2f67f724401251d199", "reference": "3dd44937b1e08af8c8f6b14850f4b9c4d1039c6f",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -2758,10 +2816,9 @@
"symfony/console": "~2.7", "symfony/console": "~2.7",
"symfony/expression-language": "~2.4", "symfony/expression-language": "~2.4",
"symfony/finder": "~2.3", "symfony/finder": "~2.3",
"symfony/form": "~2.7,>=2.7.2", "symfony/form": "~2.7,>=2.7.6",
"symfony/http-kernel": "~2.3", "symfony/http-kernel": "~2.3",
"symfony/intl": "~2.3", "symfony/intl": "~2.3",
"symfony/phpunit-bridge": "~2.7",
"symfony/routing": "~2.2", "symfony/routing": "~2.2",
"symfony/security": "~2.6", "symfony/security": "~2.6",
"symfony/security-acl": "~2.6", "symfony/security-acl": "~2.6",
@ -2812,28 +2869,25 @@
], ],
"description": "Symfony Twig Bridge", "description": "Symfony Twig Bridge",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"time": "2015-09-23 09:17:11" "time": "2015-10-11 09:39:48"
}, },
{ {
"name": "symfony/yaml", "name": "symfony/yaml",
"version": "v2.7.5", "version": "v2.7.6",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/yaml.git", "url": "https://github.com/symfony/yaml.git",
"reference": "31cb2ad0155c95b88ee55fe12bc7ff92232c1770" "reference": "eca9019c88fbe250164affd107bc8057771f3f4d"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/yaml/zipball/31cb2ad0155c95b88ee55fe12bc7ff92232c1770", "url": "https://api.github.com/repos/symfony/yaml/zipball/eca9019c88fbe250164affd107bc8057771f3f4d",
"reference": "31cb2ad0155c95b88ee55fe12bc7ff92232c1770", "reference": "eca9019c88fbe250164affd107bc8057771f3f4d",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": ">=5.3.9" "php": ">=5.3.9"
}, },
"require-dev": {
"symfony/phpunit-bridge": "~2.7"
},
"type": "library", "type": "library",
"extra": { "extra": {
"branch-alias": { "branch-alias": {
@ -2861,7 +2915,7 @@
], ],
"description": "Symfony Yaml Component", "description": "Symfony Yaml Component",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"time": "2015-09-14 14:14:09" "time": "2015-10-11 09:39:48"
}, },
{ {
"name": "twig/extensions", "name": "twig/extensions",
@ -2966,25 +3020,30 @@
}, },
{ {
"name": "vlucas/phpdotenv", "name": "vlucas/phpdotenv",
"version": "v2.0.1", "version": "v2.1.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/vlucas/phpdotenv.git", "url": "https://github.com/vlucas/phpdotenv.git",
"reference": "91064290f5b53a09bdff1b939d7f69fb0e7531b5" "reference": "c10040e0df17d2ee88e9212b50cbe9319e878f59"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/91064290f5b53a09bdff1b939d7f69fb0e7531b5", "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/c10040e0df17d2ee88e9212b50cbe9319e878f59",
"reference": "91064290f5b53a09bdff1b939d7f69fb0e7531b5", "reference": "c10040e0df17d2ee88e9212b50cbe9319e878f59",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": ">=5.3.2" "php": ">=5.3.9"
}, },
"require-dev": { "require-dev": {
"phpunit/phpunit": "~4.0" "phpunit/phpunit": "~4.0"
}, },
"type": "library", "type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.1-dev"
}
},
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Dotenv\\": "src/" "Dotenv\\": "src/"
@ -3008,7 +3067,7 @@
"env", "env",
"environment" "environment"
], ],
"time": "2015-05-30 16:15:01" "time": "2015-10-28 18:53:35"
} }
], ],
"aliases": [], "aliases": [],

View File

@ -39,6 +39,7 @@ class Initializer {
$newsletters = Env::$db_prefix . 'newsletters'; $newsletters = Env::$db_prefix . 'newsletters';
$newsletter_templates = Env::$db_prefix . 'newsletter_templates'; $newsletter_templates = Env::$db_prefix . 'newsletter_templates';
$segments = Env::$db_prefix . 'segments'; $segments = Env::$db_prefix . 'segments';
$forms = Env::$db_prefix . 'forms';
$subscriber_segment = Env::$db_prefix . 'subscriber_segment'; $subscriber_segment = Env::$db_prefix . 'subscriber_segment';
$newsletter_segment = Env::$db_prefix . 'newsletter_segment'; $newsletter_segment = Env::$db_prefix . 'newsletter_segment';
$custom_fields = Env::$db_prefix . 'custom_fields'; $custom_fields = Env::$db_prefix . 'custom_fields';
@ -50,6 +51,7 @@ class Initializer {
define('MP_SETTINGS_TABLE', $settings); define('MP_SETTINGS_TABLE', $settings);
define('MP_NEWSLETTERS_TABLE', $newsletters); define('MP_NEWSLETTERS_TABLE', $newsletters);
define('MP_SEGMENTS_TABLE', $segments); define('MP_SEGMENTS_TABLE', $segments);
define('MP_FORMS_TABLE', $forms);
define('MP_SUBSCRIBER_SEGMENT_TABLE', $subscriber_segment); define('MP_SUBSCRIBER_SEGMENT_TABLE', $subscriber_segment);
define('MP_NEWSLETTER_TEMPLATES_TABLE', $newsletter_templates); define('MP_NEWSLETTER_TEMPLATES_TABLE', $newsletter_templates);
define('MP_NEWSLETTER_SEGMENT_TABLE', $newsletter_segment); define('MP_NEWSLETTER_SEGMENT_TABLE', $newsletter_segment);

View File

@ -41,6 +41,14 @@ class Menu {
'mailpoet-newsletters', 'mailpoet-newsletters',
array($this, 'newsletters') array($this, 'newsletters')
); );
add_submenu_page(
'mailpoet',
__('Forms'),
__('Forms'),
'manage_options',
'mailpoet-forms',
array($this, 'forms')
);
add_submenu_page( add_submenu_page(
'mailpoet', 'mailpoet',
__('Subscribers'), __('Subscribers'),
@ -163,6 +171,13 @@ class Menu {
echo $this->renderer->render('segments.html', $data); echo $this->renderer->render('segments.html', $data);
} }
function forms() {
$data = array();
$data['segments'] = Segment::findArray();
echo $this->renderer->render('forms.html', $data);
}
function newsletters() { function newsletters() {
global $wp_roles; global $wp_roles;

View File

@ -21,6 +21,7 @@ class Migrator {
'subscriber_custom_field', 'subscriber_custom_field',
'newsletter_option_fields', 'newsletter_option_fields',
'newsletter_option', 'newsletter_option',
'forms'
); );
} }
@ -107,6 +108,7 @@ class Migrator {
$attributes = array( $attributes = array(
'id mediumint(9) NOT NULL AUTO_INCREMENT,', 'id mediumint(9) NOT NULL AUTO_INCREMENT,',
'name varchar(90) NOT NULL,', 'name varchar(90) NOT NULL,',
'description varchar(250) NOT NULL,',
'created_at TIMESTAMP NOT NULL DEFAULT 0,', 'created_at TIMESTAMP NOT NULL DEFAULT 0,',
'deleted_at TIMESTAMP NULL DEFAULT NULL,', 'deleted_at TIMESTAMP NULL DEFAULT NULL,',
'updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,', 'updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,',
@ -192,6 +194,19 @@ class Migrator {
return $this->sqlify(__FUNCTION__, $attributes); return $this->sqlify(__FUNCTION__, $attributes);
} }
function forms() {
$attributes = array(
'id mediumint(9) NOT NULL AUTO_INCREMENT,',
'name varchar(90) 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)'
);
return $this->sqlify(__FUNCTION__, $attributes);
}
private function sqlify($model, $attributes) { private function sqlify($model, $attributes) {
$table = $this->prefix . $model; $table = $this->prefix . $model;

View File

@ -60,6 +60,27 @@ class Populator {
'name' => 'afterTimeType', 'name' => 'afterTimeType',
'newsletter_type' => 'welcome', 'newsletter_type' => 'welcome',
), ),
array(
'name' => 'intervalType',
'newsletter_type' => 'notification',
),
array(
'name' => 'timeOfDay',
'newsletter_type' => 'notification',
),
array(
'name' => 'weekDay',
'newsletter_type' => 'notification',
),
array(
'name' => 'monthDay',
'newsletter_type' => 'notification',
),
array(
'name' => 'nthWeekDay',
'newsletter_type' => 'notification',
),
); );
} }

View File

@ -5,15 +5,17 @@ if(!defined('ABSPATH')) exit;
class BulkAction { class BulkAction {
private $listing = null; private $listing = null;
private $action = null;
private $data = null; private $data = null;
private $model_class = null; private $model_class = null;
function __construct($model_class, $data) { function __construct($model_class, $data) {
$this->model_class = $model_class; $this->action = $data['action'];
unset($data['action']);
$this->data = $data; $this->data = $data;
$this->model_class = $model_class;
$this->listing = new Handler( $this->listing = new Handler(
$this->model_class, $model_class,
$this->data['listing'] $this->data['listing']
); );
return $this; return $this;
@ -21,8 +23,9 @@ class BulkAction {
function apply() { function apply() {
return call_user_func_array( return call_user_func_array(
array($this->model_class, $this->data['action']), array($this->model_class, 'bulk'.ucfirst($this->action)),
array($this->listing, $this->data) array($this->listing->getSelection(), $this->data)
); );
return $models->count();
} }
} }

View File

@ -4,16 +4,13 @@ namespace MailPoet\Listing;
if(!defined('ABSPATH')) exit; if(!defined('ABSPATH')) exit;
class Handler { class Handler {
private $data = array(); private $data = array();
private $model = null; private $model = null;
function __construct($model_class, $data = array()) { function __construct($model_class, $data = array()) {
$class = new \ReflectionClass($model_class); $class = new \ReflectionClass($model_class);
$this->table_name = $class->getStaticPropertyValue('_table'); $this->table_name = $class->getStaticPropertyValue('_table');
$this->model = $model_class::select('*');
$this->model = \Model::factory($model_class);
$this->data = array( $this->data = array(
// pagination // pagination
'offset' => (isset($data['offset']) ? (int)$data['offset'] : 0), 'offset' => (isset($data['offset']) ? (int)$data['offset'] : 0),
@ -31,7 +28,7 @@ class Handler {
'selection' => (isset($data['selection']) ? $data['selection'] : null) 'selection' => (isset($data['selection']) ? $data['selection'] : null)
); );
$this->model = $this->setFilter(); $this->setFilter();
$this->setSearch(); $this->setSearch();
$this->setGroup(); $this->setGroup();
$this->setOrder(); $this->setOrder();
@ -59,22 +56,18 @@ class Handler {
private function setFilter() { private function setFilter() {
if($this->data['filter'] === null) { if($this->data['filter'] === null) {
return $this->model; return;
} }
return $this->model->filter('filterBy', $this->data['filter']); $this->model = $this->model->filter('filterBy', $this->data['filter']);
} }
function getSelection() { function getSelection() {
if(!empty($this->data['selection'])) { if(!empty($this->data['selection'])) {
$this->model->whereIn('id', $this->data['selection']); $this->model->whereIn($this->table_name.'.id', $this->data['selection']);
} }
return $this->model; return $this->model;
} }
function count() {
return (int)$this->model->count();
}
function getSelectionIds() { function getSelectionIds() {
$models = $this->getSelection() $models = $this->getSelection()
->select('id') ->select('id')
@ -86,14 +79,18 @@ class Handler {
} }
function get() { function get() {
$count = $this->model->count();
$items = $this->model
->offset($this->data['offset'])
->limit($this->data['limit'])
->findArray();
return array( return array(
'count' => $this->model->count(), 'count' => $count,
'filters' => $this->model->filter('filters'), 'filters' => $this->model->filter('filters'),
'groups' => $this->model->filter('groups'), 'groups' => $this->model->filter('groups'),
'items' => $this->model 'items' => $items
->offset($this->data['offset'])
->limit($this->data['limit'])
->findArray()
); );
} }
} }

View File

@ -0,0 +1,36 @@
<?php
namespace MailPoet\Listing;
if(!defined('ABSPATH')) exit;
class ItemAction {
private $model = null;
private $action = null;
private $data = null;
function __construct($model_class, $data) {
$id = (int)$data['id'];
unset($data['id']);
$this->action = $data['action'];
unset($data['action']);
$this->model = $model_class::findOne($id);
if(!empty($data)) {
$this->data = $data;
}
return $this;
}
function apply() {
if($this->data === null) {
return call_user_func_array(
array($this->model, $this->action),
array()
);
} else {
return call_user_func_array(
array($this->model, $this->action),
array($this->data)
);
}
}
}

112
lib/Models/Form.php Normal file
View File

@ -0,0 +1,112 @@
<?php
namespace MailPoet\Models;
if(!defined('ABSPATH')) exit;
class Form extends Model {
static $_table = MP_FORMS_TABLE;
function __construct() {
parent::__construct();
$this->addValidations('name', array(
'required' => __('You need to specify a name.')
));
}
static function search($orm, $search = '') {
return $orm->where_like('name', '%'.$search.'%');
}
static function groups() {
return array(
array(
'name' => 'all',
'label' => __('All'),
'count' => Form::whereNull('deleted_at')->count()
),
array(
'name' => 'trash',
'label' => __('Trash'),
'count' => Form::whereNotNull('deleted_at')->count()
)
);
}
static function groupBy($orm, $group = null) {
if($group === 'trash') {
return $orm->whereNotNull('deleted_at');
} else {
$orm = $orm->whereNull('deleted_at');
}
}
static function createOrUpdate($data = array()) {
$form = false;
if(isset($data['id']) && (int)$data['id'] > 0) {
$form = self::findOne((int)$data['id']);
}
if($form === false) {
$form = self::create();
$form->hydrate($data);
} else {
unset($data['id']);
$form->set($data);
}
$saved = $form->save();
if($saved === true) {
return true;
} else {
$errors = $form->getValidationErrors();
if(!empty($errors)) {
return $errors;
}
}
return false;
}
static function trash($listing, $data = array()) {
$confirm_delete = filter_var($data['confirm'], FILTER_VALIDATE_BOOLEAN);
if($confirm_delete) {
// delete relations with all segments
$forms = $listing->getSelection()->findResultSet();
if(!empty($forms)) {
$forms_count = 0;
foreach($forms as $form) {
if($form->delete()) {
$forms_count++;
}
}
return array(
'segments' => $forms_count
);
}
return false;
} else {
// soft delete
$forms = $listing->getSelection()
->findResultSet()
->set_expr('deleted_at', 'NOW()')
->save();
return array(
'segments' => $forms->count()
);
}
}
static function restore($listing, $data = array()) {
$forms = $listing->getSelection()
->findResultSet()
->set_expr('deleted_at', 'NULL')
->save();
return array(
'segments' => $forms->count()
);
}
}

View File

@ -9,6 +9,10 @@ class Model extends \Sudzy\ValidModel {
parent::__construct($customValidators->init()); parent::__construct($customValidators->init());
} }
static function create() {
return parent::create();
}
function save() { function save() {
$this->setTimestamp(); $this->setTimestamp();
try { try {
@ -21,9 +25,57 @@ class Model extends \Sudzy\ValidModel {
} }
} }
function trash() {
return $this->set_expr('deleted_at', 'NOW()')->save();
}
static function bulkTrash($orm) {
$models = $orm->findResultSet();
$models->set_expr('deleted_at', 'NOW()')->save();
return $models->count();
}
static function bulkDelete($orm) {
$models = $orm->findMany();
$count = 0;
foreach($models as $model) {
$model->delete();
$count++;
}
return $count;
}
function restore() {
return $this->set_expr('deleted_at', 'NULl')->save();
}
static function bulkRestore($orm) {
$models = $orm->findResultSet();
$models->set_expr('deleted_at', 'NULL')->save();
return $models->count();
}
function duplicate($data = array()) {
$model = get_called_class();
$model_data = array_merge($this->asArray(), $data);
unset($model_data['id']);
$duplicate = $model::create();
$duplicate->hydrate($model_data);
$duplicate->set_expr('created_at', 'NOW()');
$duplicate->set_expr('updated_at', 'NOW()');
$duplicate->set_expr('deleted_at', 'NULL');
if($duplicate->save()) {
return $duplicate;
} else {
return false;
}
}
private function setTimestamp() { private function setTimestamp() {
if($this->created_at === null) { if($this->created_at === null) {
$this->created_at = date('Y-m-d H:i:s'); $this->set_expr('created_at', 'NOW()');
} }
} }

View File

@ -39,10 +39,10 @@ class Newsletter extends Model {
'label' => __('All lists'), 'label' => __('All lists'),
'value' => '' 'value' => ''
); );
foreach($segments as $segment) { foreach($segments as $segment) {
$newsletters_count = $segment->newsletters()->count(); $newsletters_count = $segment->newsletters()->count();
if($newsletters_count > 0) { if($newsletters_count > 0) {
$segment_list[] = array( $segment_list[] = array(
'label' => sprintf('%s (%d)', $segment->name, $newsletters_count), 'label' => sprintf('%s (%d)', $segment->name, $newsletters_count),
'value' => $segment->id() 'value' => $segment->id()
@ -51,34 +51,21 @@ class Newsletter extends Model {
} }
$filters = array( $filters = array(
array( 'segment' => $segment_list
'name' => 'segment',
'options' => $segment_list
)
); );
return $filters; return $filters;
} }
static function filterBy($orm, $filters = null) { static function filterBy($orm, $filters = null) {
if(empty($filters)) { if(empty($filters)) {
return $orm; return $orm;
} }
foreach($filters as $key => $value) {
foreach($filters as $filter) { if($key === 'segment') {
if($filter['name'] === 'segment') { $segment = Segment::findOne($value);
$segment = Segment::findOne($filter['value']);
if($segment !== false) { if($segment !== false) {
$orm = $orm $orm = $segment->newsletters();
->select(MP_NEWSLETTERS_TABLE.'.*')
->select('newsletter_segment.id', 'newsletter_segment_id')
->join(
MP_NEWSLETTER_SEGMENT_TABLE,
MP_NEWSLETTERS_TABLE.'.id = newsletter_segment.newsletter_id',
'newsletter_segment'
)
->where('newsletter_segment.segment_id', (int)$filter['value']);
} }
} }
} }
@ -112,12 +99,21 @@ class Newsletter extends Model {
array( array(
'name' => 'all', 'name' => 'all',
'label' => __('All'), 'label' => __('All'),
'count' => Newsletter::count() 'count' => Newsletter::whereNull('deleted_at')->count()
),
array(
'name' => 'trash',
'label' => __('Trash'),
'count' => Newsletter::whereNotNull('deleted_at')->count()
) )
); );
} }
static function group($orm, $group = null) { static function groupBy($orm, $group = null) {
if($group === 'trash') {
return $orm->whereNotNull('deleted_at');
}
return $orm->whereNull('deleted_at');
} }
static function createOrUpdate($data = array()) { static function createOrUpdate($data = array()) {
@ -147,9 +143,4 @@ class Newsletter extends Model {
} }
return false; return false;
} }
static function trash($listing) {
return $listing->getSelection()
->deleteMany();
}
} }

View File

@ -14,13 +14,10 @@ class Segment extends Model {
)); ));
} }
function subscribers() { function delete() {
return $this->has_many_through( // delete all relations to subscribers
__NAMESPACE__.'\Subscriber', SubscriberSegment::where('segment_id', $this->id)->deleteMany();
__NAMESPACE__.'\SubscriberSegment', parent::delete();
'segment_id',
'subscriber_id'
);
} }
function newsletters() { function newsletters() {
@ -41,12 +38,22 @@ class Segment extends Model {
array( array(
'name' => 'all', 'name' => 'all',
'label' => __('All'), 'label' => __('All'),
'count' => Segment::count() 'count' => Segment::whereNull('deleted_at')->count()
),
array(
'name' => 'trash',
'label' => __('Trash'),
'count' => Segment::whereNotNull('deleted_at')->count()
) )
); );
} }
static function group($orm, $group = null) { static function groupBy($orm, $group = null) {
if($group === 'trash') {
return $orm->whereNotNull('deleted_at');
} else {
$orm = $orm->whereNull('deleted_at');
}
} }
static function createOrUpdate($data = array()) { static function createOrUpdate($data = array()) {
@ -77,7 +84,28 @@ class Segment extends Model {
return false; return false;
} }
static function trash($listing) { function duplicate($data = array()) {
return $listing->getSelection()->deleteMany(); $duplicate = parent::duplicate($data);
if($duplicate !== false) {
foreach($this->subscribers()->findResultSet() as $relation) {
$new_relation = SubscriberSegment::create();
$new_relation->set('subscriber_id', $relation->id);
$new_relation->set('segment_id', $duplicate->id);
$new_relation->save();
}
return $duplicate;
}
return false;
}
function subscribers() {
return $this->has_many_through(
__NAMESPACE__.'\Subscriber',
__NAMESPACE__.'\SubscriberSegment',
'segment_id',
'subscriber_id'
);
} }
} }

View File

@ -19,7 +19,7 @@ class Subscriber extends Model {
// delete all relations to segments // delete all relations to segments
SubscriberSegment::where('subscriber_id', $this->id)->deleteMany(); SubscriberSegment::where('subscriber_id', $this->id)->deleteMany();
parent::delete(); return parent::delete();
} }
static function search($orm, $search = '') { static function search($orm, $search = '') {
@ -36,7 +36,6 @@ class Subscriber extends Model {
static function filters() { static function filters() {
$segments = Segment::orderByAsc('name')->findMany(); $segments = Segment::orderByAsc('name')->findMany();
$segment_list = array(); $segment_list = array();
$segment_list[] = array( $segment_list[] = array(
'label' => __('All lists'), 'label' => __('All lists'),
'value' => '' 'value' => ''
@ -53,10 +52,7 @@ class Subscriber extends Model {
} }
$filters = array( $filters = array(
array( 'segment' => $segment_list
'name' => 'segment',
'options' => $segment_list
)
); );
return $filters; return $filters;
@ -66,12 +62,11 @@ class Subscriber extends Model {
if(empty($filters)) { if(empty($filters)) {
return $orm; return $orm;
} }
foreach($filters as $key => $value) {
foreach($filters as $filter) { if($key === 'segment') {
if($filter['name'] === 'segment') { $segment = Segment::findOne($value);
$segment = Segment::findOne($filter['value']);
if($segment !== false) { if($segment !== false) {
$orm = $segment->subscribers(); return $segment->subscribers();
} }
} }
} }
@ -194,13 +189,11 @@ class Subscriber extends Model {
return false; return false;
} }
static function moveToList($listing, $data = array()) { static function bulkMoveToList($orm, $data = array()) {
$segment_id = (isset($data['segment_id']) ? (int)$data['segment_id'] : 0); $segment_id = (isset($data['segment_id']) ? (int)$data['segment_id'] : 0);
$segment = Segment::findOne($segment_id); $segment = Segment::findOne($segment_id);
if($segment !== false) { if($segment !== false) {
$subscribers_count = 0; $subscribers = $orm->findResultSet();
$subscribers = $listing->getSelection()->findMany();
foreach($subscribers as $subscriber) { foreach($subscribers as $subscriber) {
// remove subscriber from all segments // remove subscriber from all segments
SubscriberSegment::where('subscriber_id', $subscriber->id)->deleteMany(); SubscriberSegment::where('subscriber_id', $subscriber->id)->deleteMany();
@ -210,37 +203,37 @@ class Subscriber extends Model {
$association->subscriber_id = $subscriber->id; $association->subscriber_id = $subscriber->id;
$association->segment_id = $segment->id; $association->segment_id = $segment->id;
$association->save(); $association->save();
$subscribers_count++;
} }
return array( return array(
'subscribers' => $subscribers_count, 'subscribers' => $subscribers->count(),
'segment' => $segment->name 'segment' => $segment->name
); );
} }
return false; return false;
} }
static function removeFromList($listing, $data = array()) { static function bulkRemoveFromList($orm, $data = array()) {
$segment_id = (isset($data['segment_id']) ? (int)$data['segment_id'] : 0); $segment_id = (isset($data['segment_id']) ? (int)$data['segment_id'] : 0);
$segment = Segment::findOne($segment_id); $segment = Segment::findOne($segment_id);
if($segment !== false) { if($segment !== false) {
// delete relations with segment // delete relations with segment
$subscriber_ids = $listing->getSelectionIds(); $subscribers = $orm->findResultSet();
SubscriberSegment::whereIn('subscriber_id', $subscriber_ids) foreach($subscribers as $subscriber) {
->where('segment_id', $segment->id) SubscriberSegment::where('subscriber_id', $subscriber->id)
->deleteMany(); ->where('segment_id', $segment->id)
->deleteMany();
}
return array( return array(
'subscribers' => count($subscriber_ids), 'subscribers' => $subscribers->count(),
'segment' => $segment->name 'segment' => $segment->name
); );
} }
return false; return false;
} }
static function removeFromAllLists($listing) { static function bulkRemoveFromAllLists($orm) {
$segments = Segment::findMany(); $segments = Segment::findMany();
$segment_ids = array_map(function($segment) { $segment_ids = array_map(function($segment) {
return $segment->id(); return $segment->id();
@ -248,62 +241,48 @@ class Subscriber extends Model {
if(!empty($segment_ids)) { if(!empty($segment_ids)) {
// delete relations with segment // delete relations with segment
$subscriber_ids = $listing->getSelectionIds(); $subscribers = $orm->findResultSet();
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) { foreach($subscribers as $subscriber) {
$subscriber->set('status', 'subscribed'); SubscriberSegment::where('subscriber_id', $subscriber->id)
if($subscriber->save() === true) { ->whereIn('segment_id', $segment_ids)
$subscribers_count++; ->deleteMany();
}
} }
return array( return $subscribers->count();
'subscribers' => $subscribers_count
);
} }
return false; return false;
} }
static function resendConfirmationEmail($listing) { static function bulkConfirmUnconfirmed($orm) {
$subscriber_ids = $listing->getSelectionIds(); $subscribers = $orm->findResultSet();
$subscribers = Subscriber::whereIn('id', $subscriber_ids) $subscribers->set('status', 'subscribed')->save();
return $subscribers->count();
}
static function bulkResendConfirmationEmail($orm) {
$subscribers = $orm
->where('status', 'unconfirmed') ->where('status', 'unconfirmed')
->findMany(); ->findResultSet();
if(!empty($subscribers)) { if(!empty($subscribers)) {
foreach($subscribers as $subscriber) { foreach($subscribers as $subscriber) {
// TODO: resend confirmation email // TODO: send confirmation email
// $subscriber->sendConfirmationEmail()
} }
return true;
return $subscribers->count();
} }
return false; return false;
} }
static function addToList($listing, $data = array()) { static function bulkAddToList($orm, $data = array()) {
$segment_id = (isset($data['segment_id']) ? (int)$data['segment_id'] : 0); $segment_id = (isset($data['segment_id']) ? (int)$data['segment_id'] : 0);
$segment = Segment::findOne($segment_id); $segment = Segment::findOne($segment_id);
if($segment !== false) { if($segment !== false) {
$subscribers_count = 0; $subscribers_count = 0;
$subscribers = $listing->getSelection()->findMany(); $subscribers = $orm->findMany();
foreach($subscribers as $subscriber) { foreach($subscribers as $subscriber) {
// create relation with segment // create relation with segment
$association = \MailPoet\Models\SubscriberSegment::create(); $association = \MailPoet\Models\SubscriberSegment::create();
@ -320,46 +299,4 @@ class Subscriber extends Model {
} }
return false; return false;
} }
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();
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()
);
}
} }

95
lib/Router/Forms.php Normal file
View File

@ -0,0 +1,95 @@
<?php
namespace MailPoet\Router;
use \MailPoet\Models\Form;
use \MailPoet\Listing;
if(!defined('ABSPATH')) exit;
class Forms {
function __construct() {
}
function get($data = array()) {
$id = (isset($data['id']) ? (int)$data['id'] : 0);
$form = Form::findOne($id);
if($form === false) {
wp_send_json(false);
} else {
wp_send_json($form->asArray());
}
}
function listing($data = array()) {
$listing = new Listing\Handler(
'\MailPoet\Models\Form',
$data
);
$listing_data = $listing->get();
wp_send_json($listing_data);
}
function getAll() {
$collection = Form::findArray();
wp_send_json($collection);
}
function save($data = array()) {
$result = Form::createOrUpdate($data);
if($result !== true) {
wp_send_json($result);
} else {
wp_send_json(true);
}
}
function restore($id) {
$form = Form::findOne($id);
if($form !== false) {
$form->set_expr('deleted_at', 'NULL');
$result = $form->save();
} else {
$result = false;
}
wp_send_json($result);
}
function delete($data = array()) {
$form = Form::findOne($data['id']);
$confirm_delete = filter_var($data['confirm'], FILTER_VALIDATE_BOOLEAN);
if($form !== false) {
if($confirm_delete) {
$form->delete();
$result = true;
} else {
$form->set_expr('deleted_at', 'NOW()');
$result = $form->save();
}
} else {
$result = false;
}
wp_send_json($result);
}
function duplicate($id) {
$result = false;
$form = Form::duplicate($id);
if($form !== false) {
$result = $form;
}
wp_send_json($result);
}
function bulk_action($data = array()) {
$bulk_action = new Listing\BulkAction(
'\MailPoet\Models\Form',
$data
);
wp_send_json($bulk_action->apply());
}
}

View File

@ -88,10 +88,28 @@ class Newsletters {
wp_send_json(($newsletter_id !== false)); wp_send_json(($newsletter_id !== false));
} }
function delete($id) { function delete($data = array()) {
$newsletter = newsletter::findOne($data['id']);
$confirm_delete = filter_var($data['confirm'], FILTER_VALIDATE_BOOLEAN);
if($newsletter !== false) {
if($confirm_delete) {
$newsletter->delete();
$result = array('newsletters' => 1);
} else {
$newsletter->set_expr('deleted_at', 'NOW()');
$result = array('newsletters' => (int)$newsletter->save());
}
} else {
$result = false;
}
wp_send_json($result);
}
function restore($id) {
$newsletter = Newsletter::findOne($id); $newsletter = Newsletter::findOne($id);
if($newsletter !== false) { if($newsletter !== false) {
$result = $newsletter->delete(); $newsletter->set_expr('deleted_at', 'NULL');
$result = array('newsletters' => (int)$newsletter->save());
} else { } else {
$result = false; $result = false;
} }

View File

@ -1,6 +1,7 @@
<?php <?php
namespace MailPoet\Router; namespace MailPoet\Router;
use \MailPoet\Models\Segment; use \MailPoet\Models\Segment;
use \MailPoet\Models\SubscriberSegment;
use \MailPoet\Listing; use \MailPoet\Listing;
if(!defined('ABSPATH')) exit; if(!defined('ABSPATH')) exit;
@ -25,11 +26,47 @@ class Segments {
'\MailPoet\Models\Segment', '\MailPoet\Models\Segment',
$data $data
); );
wp_send_json($listing->get());
$listing_data = $listing->get();
// fetch segments relations for each returned item
foreach($listing_data['items'] as &$item) {
$stats = SubscriberSegment::table_alias('relation')
->where(
'relation.segment_id',
$item['id']
)
->join(
MP_SUBSCRIBERS_TABLE,
'subscribers.id = relation.subscriber_id',
'subscribers'
)
->select_expr(
'SUM(CASE status WHEN "subscribed" THEN 1 ELSE 0 END)',
'subscribed'
)
->select_expr(
'SUM(CASE status WHEN "unsubscribed" THEN 1 ELSE 0 END)',
'unsubscribed'
)
->select_expr(
'SUM(CASE 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'].']'
);
}
wp_send_json($listing_data);
} }
function getAll() { function getAll() {
$collection = Segment::find_array(); $collection = Segment::findArray();
wp_send_json($collection); wp_send_json($collection);
} }
@ -43,17 +80,63 @@ class Segments {
} }
} }
function delete($id) { function restore($id) {
$result = false;
$segment = Segment::findOne($id); $segment = Segment::findOne($id);
if($segment !== false) { if($segment !== false) {
$result = $segment->delete(); $result = $segment->restore();
} else {
$result = false;
} }
wp_send_json($result); wp_send_json($result);
} }
function trash($id) {
$result = false;
$segment = Segment::findOne($id);
if($segment !== false) {
$result = $segment->trash();
}
wp_send_json($result);
}
function delete($id) {
$result = false;
$segment = Segment::findOne($id);
if($segment !== false) {
$segment->delete();
$result = 1;
}
wp_send_json($result);
}
function duplicate($id) {
$result = false;
$segment = Segment::findOne($id);
if($segment !== false) {
$data = array(
'name' => sprintf(__('Copy of %s'), $segment->name)
);
$result = $segment->duplicate($data)->asArray();
}
wp_send_json($result);
}
function item_action($data = array()) {
$item_action = new Listing\ItemAction(
'\MailPoet\Models\Segment',
$data
);
wp_send_json($item_action->apply());
}
function bulk_action($data = array()) { function bulk_action($data = array()) {
$bulk_action = new Listing\BulkAction( $bulk_action = new Listing\BulkAction(
'\MailPoet\Models\Segment', '\MailPoet\Models\Segment',

View File

@ -32,12 +32,18 @@ class Subscribers {
// 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 &$item) {
$segments = SubscriberSegment::select('segment_id') // avatar
$item['avatar_url'] = get_avatar_url($item['email'], array(
'size' => 32
));
// subscriber's segments
$relations = SubscriberSegment::select('segment_id')
->where('subscriber_id', $item['id']) ->where('subscriber_id', $item['id'])
->findMany(); ->findMany();
$item['segments'] = array_map(function($relation) { $item['segments'] = array_map(function($relation) {
return $relation->segment_id; return $relation->segment_id;
}, $segments); }, $relations);
} }
wp_send_json($listing_data); wp_send_json($listing_data);
@ -54,33 +60,48 @@ class Subscribers {
} }
function restore($id) { function restore($id) {
$result = false;
$subscriber = Subscriber::findOne($id); $subscriber = Subscriber::findOne($id);
if($subscriber !== false) { if($subscriber !== false) {
$subscriber->set_expr('deleted_at', 'NULL'); $result = $subscriber->restore();
$result = array('subscribers' => (int)$subscriber->save());
} else {
$result = false;
} }
wp_send_json($result); wp_send_json($result);
} }
function delete($data = array()) { function trash($id) {
$subscriber = Subscriber::findOne($data['id']); $result = false;
$confirm_delete = filter_var($data['confirm'], FILTER_VALIDATE_BOOLEAN);
$subscriber = Subscriber::findOne($id);
if($subscriber !== false) { if($subscriber !== false) {
if($confirm_delete) { $result = $subscriber->trash();
$subscriber->delete();
$result = array('subscribers' => 1);
} else {
$subscriber->set_expr('deleted_at', 'NOW()');
$result = array('subscribers' => (int)$subscriber->save());
}
} else {
$result = false;
} }
wp_send_json($result); wp_send_json($result);
} }
function delete($id) {
$result = false;
$subscriber = Subscriber::findOne($id);
if($subscriber !== false) {
$subscriber->delete();
$result = 1;
}
wp_send_json($result);
}
function item_action($data = array()) {
$item_action = new Listing\ItemAction(
'\MailPoet\Models\Segment',
$data
);
wp_send_json($item_action->apply());
}
function bulk_action($data = array()) { function bulk_action($data = array()) {
$bulk_action = new Listing\BulkAction( $bulk_action = new Listing\BulkAction(
'\MailPoet\Models\Subscriber', '\MailPoet\Models\Subscriber',

View File

@ -1,24 +1,17 @@
<?php <?php
namespace MailPoet\Util; namespace MailPoet\Util;
use \phpseclib\Crypt\RSA;
class DKIM { class DKIM {
static function generateKeys() { static function generateKeys() {
try { try {
$certificate = openssl_pkey_new(array('private_bits' => 1024)); $rsa = new RSA();
$rsa_keys = $rsa->createKey();
$keys = array('public' => '', 'private' => ''); return array(
'public' => self::trimKey($rsa_keys['publickey']),
// get private key 'private' => self::trimKey($rsa_keys['privatekey'])
openssl_pkey_export($certificate, $keys['private']); );
// get public key
$public = openssl_pkey_get_details($certificate);
// trim keys by removing BEGIN/END lines
$keys['public'] = self::trimKey($public['key']);
$keys['private'] = self::trimKey($keys['private']);
return $keys;
} catch(Exception $e) { } catch(Exception $e) {
return false; return false;
} }

View File

@ -36,9 +36,9 @@
"underscore": "1.8.3" "underscore": "1.8.3"
}, },
"devDependencies": { "devDependencies": {
"export-loader": "webpack/exports-loader.git", "expose-loader": "latest",
"import-loader": "webpack/imports-loader.git", "exports-loader": "latest",
"expose-loader": "webpack/expose-loader.git", "imports-loader": "latest",
"babel-core": "^5.8.22", "babel-core": "^5.8.22",
"babel-loader": "^5.3.2", "babel-loader": "^5.3.2",
"amd-inject-loader": "latest", "amd-inject-loader": "latest",

View File

@ -18,7 +18,11 @@ $models = array(
'SubscriberSegment' 'SubscriberSegment'
); );
$destroy = function ($model) { $destroy = function ($model) {
Model::factory('\MailPoet\Models\\' . $model) $class = new \ReflectionClass('\MailPoet\Models\\' . $model);
->deleteMany(); $table = $class->getStaticPropertyValue('_table');
$db = ORM::getDb();
$db->beginTransaction();
$db->exec('TRUNCATE '.$table);
$db->commit();
}; };
array_map($destroy, $models); array_map($destroy, $models);

16
views/forms.html Normal file
View File

@ -0,0 +1,16 @@
<% extends 'layout.html' %>
<% block content %>
<div id="forms_container"></div>
<%= localize({
'pageTitle': __('Forms'),
'searchLabel': __('Search'),
'loadingItems': __('Loading forms...'),
'noItemsFound': __('No forms found.')
}) %>
<script type="text/javascript">
var mailpoet_segments = <%= json_encode(segments) %>;
</script>
<% endblock %>

View File

@ -14,8 +14,12 @@
<div class="mailpoet_form_field"> <div class="mailpoet_form_field">
<div class="mailpoet_form_field_title"><%= __('Categories & tags:') %></div> <div class="mailpoet_form_field_title"><%= __('Categories & tags:') %></div>
<div class="mailpoet_form_field_input_option"> <div class="mailpoet_form_field_select_option">
<input type="hidden" class="mailpoet_input mailpoet_automated_latest_content_categories_and_tags" value="{{json_encode terms}}" data-selected="{{json_encode terms}}" /> <select class="mailpoet_select mailpoet_automated_latest_content_categories_and_tags" multiple="multiple">
{{#each terms}}
<option value="{{ id }}" selected="selected">{{ text }}</option>
{{/each}}
</select>
</div> </div>
<div class="mailpoet_form_field_radio_option"> <div class="mailpoet_form_field_radio_option">
<label> <label>

View File

@ -14,7 +14,11 @@
<option value="private"><%= __('Private') %></option> <option value="private"><%= __('Private') %></option>
</select></div> </select></div>
<div class="mailpoet_post_selection_filter_row"> <div class="mailpoet_post_selection_filter_row">
<input type="hidden" class="mailpoet_input mailpoet_posts_categories_and_tags" value="{{json_encode terms}}" data-selected="{{json_encode terms}}" data-placeholder="<%= __('Filter by category or tag') %>" /> <select class="mailpoet_select mailpoet_posts_categories_and_tags" multiple="multiple">
{{#each terms}}
<option value="{{ id }}" selected="selected">{{ text }}</option>
{{/each}}
</select>
</div> </div>
</div> </div>
<div class="mailpoet_post_selection_container"> <div class="mailpoet_post_selection_container">

View File

@ -21,7 +21,7 @@ baseConfig = {
'backbone.supermodel$': 'backbone.supermodel/build/backbone.supermodel.js', 'backbone.supermodel$': 'backbone.supermodel/build/backbone.supermodel.js',
'sticky-kit': 'sticky-kit/jquery.sticky-kit', 'sticky-kit': 'sticky-kit/jquery.sticky-kit',
'interact$': 'interact.js/interact.js', 'interact$': 'interact.js/interact.js',
'spectrum$': 'spectrum-colorpicker/spectrum.js', 'spectrum$': 'spectrum-colorpicker/spectrum.js'
}, },
}, },
node: { node: {
@ -69,6 +69,7 @@ config.push(_.extend({}, baseConfig, {
'subscribers/subscribers.jsx', 'subscribers/subscribers.jsx',
'newsletters/newsletters.jsx', 'newsletters/newsletters.jsx',
'segments/segments.jsx', 'segments/segments.jsx',
'forms/forms.jsx',
'settings/tabs.js' 'settings/tabs.js'
], ],
newsletter_editor: [ newsletter_editor: [