Campaign publish feature

This commit is contained in:
Ingo Schommer 2016-04-12 09:15:04 +12:00 committed by Damian Mooyman
parent 3820314a11
commit 107e38b7a7
16 changed files with 308 additions and 81 deletions

View File

@ -18,6 +18,7 @@ class CampaignAdmin extends LeftAndMain implements PermissionProvider {
'readCampaign',
'updateCampaign',
'deleteCampaign',
'publishCampaign',
];
private static $menu_priority = 11;
@ -28,6 +29,7 @@ class CampaignAdmin extends LeftAndMain implements PermissionProvider {
'GET sets' => 'readCampaigns',
'POST set/$ID' => 'createCampaign',
'GET set/$ID/$Name' => 'readCampaign',
'PUT set/$ID/publish' => 'publishCampaign',
'PUT set/$ID' => 'updateCampaign',
'DELETE set/$ID' => 'deleteCampaign',
];
@ -62,6 +64,10 @@ class CampaignAdmin extends LeftAndMain implements PermissionProvider {
],
'campaignViewRoute' => $urlSegment . '/:type?/:id?/:view?',
'itemListViewEndpoint' => $this->Link('set/:id/show'),
'publishEndpoint' => [
'url' => $this->Link('set/:id/publish'),
'method' => 'put'
]
]);
}
@ -258,6 +264,8 @@ JSON;
'Created' => $changeSet->Created,
'LastEdited' => $changeSet->LastEdited,
'State' => $changeSet->State,
'canEdit' => $changeSet->canEdit(),
'canPublish' => $changeSet->canPublish(),
'_embedded' => ['ChangeSetItems' => []]
];
foreach($changeSet->Changes() as $changeSetItem) {
@ -347,7 +355,7 @@ JSON;
$response = new SS_HTTPResponse();
if ($request->getHeader('Accept') == 'text/json') {
$response->addHeader('Content-Type', 'application/json');
$response->addHeader('Content-Type', 'application/json');
$changeSet = ChangeSet::get()->byId($request->param('ID'));
switch ($request->param('Name')) {
@ -361,7 +369,7 @@ JSON;
$response->setBody('{"message":"404"}');
}
return $response;
return $response;
} else {
return $this->index($request);
@ -395,19 +403,19 @@ JSON;
public function deleteCampaign(SS_HTTPRequest $request) {
$id = $request->param('ID');
if (!$id || !is_numeric($id)) {
return (new SS_HTTPResponse(json_encode(['status' => 'error']), 400))
->addHeader('Content-Type', 'application/json');
}
return (new SS_HTTPResponse(json_encode(['status' => 'error']), 400))
->addHeader('Content-Type', 'application/json');
}
$record = ChangeSet::get()->byID($id);
if(!$record) {
return (new SS_HTTPResponse(json_encode(['status' => 'error']), 404))
->addHeader('Content-Type', 'application/json');
->addHeader('Content-Type', 'application/json');
}
if(!$record->canDelete()) {
return (new SS_HTTPResponse(json_encode(['status' => 'error']), 401))
->addHeader('Content-Type', 'application/json');
->addHeader('Content-Type', 'application/json');
}
$record->delete();
@ -415,6 +423,44 @@ JSON;
return (new SS_HTTPResponse('', 204));
}
/**
* REST endpoint to publish a {@link ChangeSet} and all of its items.
*
* @param SS_HTTPRequest $request
*
* @return SS_HTTPResponse
*/
public function publishCampaign(SS_HTTPRequest $request) {
$id = $request->param('ID');
if(!$id || !is_numeric($id)) {
return (new SS_HTTPResponse(json_encode(['status' => 'error']), 400))
->addHeader('Content-Type', 'application/json');
}
$record = ChangeSet::get()->byID($id);
if(!$record) {
return (new SS_HTTPResponse(json_encode(['status' => 'error']), 404))
->addHeader('Content-Type', 'application/json');
}
if(!$record->canPublish()) {
return (new SS_HTTPResponse(json_encode(['status' => 'error']), 401))
->addHeader('Content-Type', 'application/json');
}
try {
$record->publish();
} catch(LogicException $e) {
return (new SS_HTTPResponse(json_encode(['status' => 'error', 'message' => $e->getMessage()]), 401))
->addHeader('Content-Type', 'application/json');
}
return (new SS_HTTPResponse(
Convert::raw2json($this->getChangeSetResource($record)),
200
))->addHeader('Content-Type', 'application/json');
}
/**
* @todo Use GridFieldDetailForm once it can handle structured data and form schemas
*

View File

@ -19,6 +19,9 @@ if(typeof(ss) == 'undefined' || typeof(ss.i18n) == 'undefined') {
"ModelAdmin.DELETED": "Deleted",
"ModelAdmin.VALIDATIONERROR": "Validation Error",
"LeftAndMain.PAGEWASDELETED": "This page was deleted. To edit a page, select it from the left.",
"Campaigns.ADDCAMPAIGN": "Add campaign"
"Campaigns.ADDCAMPAIGN": "Add campaign",
"Campaigns.PUBLISHCAMPAIGN": "Publish campaign",
"Campaigns.ITEM_SUMMARY_SINGULAR": "%s item",
"Campaigns.ITEM_SUMMARY_PLURAL": "%s items",
});
}
}

View File

@ -14,5 +14,6 @@
"ModelAdmin.DELETED": "Deleted",
"ModelAdmin.VALIDATIONERROR": "Validation Error",
"LeftAndMain.PAGEWASDELETED": "This page was deleted. To edit a page, select it from the left.",
"Campaigns.ADDCAMPAIGN": "Add campaign"
}
"Campaigns.ADDCAMPAIGN": "Add campaign",
"Campaigns.PUBLISHCAMPAIGN": "Publish campaign",
}

View File

@ -0,0 +1,15 @@
# Label
Small and adaptive tag for adding context to just about any content.
Extends the [Label component](http://v4-alpha.getbootstrap.com/components/label/)
in Bootstrap.
## Variations
Empty label - a round indicator demonstrating a status.
Use together with a "contextual variation" like `label-warning`
to get colour value.
```html
<span class="label label-warning label--empty" />
```

View File

@ -0,0 +1,6 @@
.label--empty {
border-radius: 50%;
height: 10px;
width: 10px;
top: 1px;
}

View File

@ -1,6 +1,7 @@
import React from 'react';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import backend from 'silverstripe-backend';
import * as actions from 'state/campaign/actions';
import SilverStripeComponent from 'silverstripe-component';
import FormAction from 'components/form-action/index';
@ -16,6 +17,13 @@ class CampaignAdminContainer extends SilverStripeComponent {
this.addCampaign = this.addCampaign.bind(this);
this.createFn = this.createFn.bind(this);
this.publishApi = backend.createEndpointFetcher({
url: this.props.config.publishEndpoint.url,
method: this.props.config.publishEndpoint.method,
payloadSchema: {
id: { urlReplacement: ':id', remove: true },
},
});
}
componentDidMount() {
@ -73,6 +81,7 @@ class CampaignAdminContainer extends SilverStripeComponent {
const props = {
campaignId: this.props.campaignId,
itemListViewEndpoint: this.props.config.itemListViewEndpoint,
publishApi: this.publishApi,
};
return (

View File

@ -7,7 +7,7 @@ import i18n from 'i18n';
*/
class CampaignItem extends SilverStripeComponent {
render() {
let thumbnail = '';
let thumbnail = null;
const badge = {};
const item = this.props.item;

View File

@ -1,24 +1,33 @@
import React from 'react';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import * as actions from 'state/records/actions';
import * as recordActions from 'state/records/actions';
import * as campaignActions from 'state/campaign/actions';
import SilverStripeComponent from 'silverstripe-component';
import Accordion from 'components/accordion/index';
import AccordionGroup from 'components/accordion/group';
import AccordionItem from 'components/accordion/item';
import NorthHeader from 'components/north-header/index';
import FormAction from 'components/form-action/index';
import CampaignItem from './item';
import CampaignPreview from './preview';
import i18n from 'i18n';
/**
* Represents a campaign list view
*/
class CampaignListContainer extends SilverStripeComponent {
constructor(props) {
super(props);
this.handlePublish = this.handlePublish.bind(this);
}
componentDidMount() {
const fetchURL = this.props.itemListViewEndpoint.replace(/:id/, this.props.campaignId);
super.componentDidMount();
this.props.actions.fetchRecord('ChangeSet', 'get', fetchURL);
this.props.recordActions.fetchRecord('ChangeSet', 'get', fetchURL);
}
/**
@ -79,12 +88,60 @@ class CampaignListContainer extends SilverStripeComponent {
{accordionGroups}
</Accordion>
</div>
<div className="cms-south-actions">
{this.renderButtonToolbar()}
</div>
</div>
{ previewUrl && <CampaignPreview previewUrl={previewUrl} /> }
</div>
);
}
renderButtonToolbar() {
const items = this.getItems(this.props.campaignId);
let itemSummaryLabel;
if (items) {
itemSummaryLabel = i18n.sprintf(
(items.length === 1) ?
i18n._t('Campaigns.ITEM_SUMMARY_SINGULAR')
: i18n._t('Campaigns.ITEM_SUMMARY_PLURAL'),
items.length
);
let button;
if (this.props.record.State === 'open') {
button = (
<FormAction
label={i18n._t('Campaigns.PUBLISHCAMPAIGN')}
style={'success'}
handleClick={this.handlePublish}
/>
);
} else if (this.props.record.State === 'published') {
// TODO Implement "revert" feature
button = (
<FormAction
label={i18n._t('Campaigns.PUBLISHCAMPAIGN')}
style={'success'}
disabled
/>
);
}
return (
<div className="btn-toolbar">
{button}
<span className="text-muted">
<span className="label label-warning label--empty">&nbsp;</span>
&nbsp;{itemSummaryLabel}
</span>
</div>
);
}
return <div className="btn-toolbar"></div>;
}
/**
* Gets preview URL for itemid
@ -100,6 +157,17 @@ class CampaignListContainer extends SilverStripeComponent {
return document.getElementsByTagName('base')[0].href;
}
/**
* @return {Array}
*/
getItems() {
if (this.props.record && this.props.record._embedded) {
return this.props.record._embedded.ChangeSetItems;
}
return null;
}
/**
* Group items for changeset display
*
@ -107,10 +175,10 @@ class CampaignListContainer extends SilverStripeComponent {
*/
groupItemsForSet() {
const groups = {};
if (!this.props.record || !this.props.record._embedded) {
const items = this.getItems();
if (!items) {
return groups;
}
const items = this.props.record._embedded.ChangeSetItems;
// group by whatever
items.forEach(item => {
@ -132,8 +200,20 @@ class CampaignListContainer extends SilverStripeComponent {
return groups;
}
handlePublish(e) {
e.preventDefault();
this.props.campaignActions.publishCampaign(
this.props.publishApi,
this.props.campaignId
);
}
}
CampaignListContainer.propTypes = {
publishApi: React.PropTypes.func.isRequired,
};
function mapStateToProps(state, ownProps) {
// Find record specific to this item
let record = null;
@ -149,7 +229,8 @@ function mapStateToProps(state, ownProps) {
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators(actions, dispatch),
recordActions: bindActionCreators(recordActions, dispatch),
campaignActions: bindActionCreators(campaignActions, dispatch),
};
}

View File

@ -1,3 +1,6 @@
export default {
SET_CAMPAIGN_ACTIVE_CHANGESET: 'SET_CAMPAIGN_ACTIVE_CHANGESET',
PUBLISH_CAMPAIGN_REQUEST: 'PUBLISH_CAMPAIGN_REQUEST',
PUBLISH_CAMPAIGN_SUCCESS: 'PUBLISH_CAMPAIGN_SUCCESS',
PUBLISH_CAMPAIGN_FAILURE: 'PUBLISH_CAMPAIGN_FAILURE',
};

View File

@ -1,4 +1,5 @@
import ACTION_TYPES from './action-types';
import RECORD_ACTION_TYPES from 'state/records/action-types';
/**
* Show specified campaign set
@ -13,3 +14,36 @@ export function showCampaignView(campaignId, view) {
};
}
/**
* Publish a campaign and all its items
*
* @param {Function} publishApi See silverstripe-backend.js
* @param {number} campaignId
* @return {Object}
*/
export function publishCampaign(publishApi, campaignId) {
return (dispatch) => {
dispatch({
type: ACTION_TYPES.PUBLISH_CAMPAIGN_REQUEST,
payload: { campaignId },
});
publishApi({ id: campaignId })
.then((data) => {
dispatch({
type: ACTION_TYPES.PUBLISH_CAMPAIGN_SUCCESS,
payload: { campaignId },
});
dispatch({
type: RECORD_ACTION_TYPES.FETCH_RECORD_SUCCESS,
payload: { recordType: 'ChangeSet', data },
});
})
.catch((error) => {
dispatch({
type: ACTION_TYPES.PUBLISH_CAMPAIGN_FAILURE,
payload: { error },
});
});
};
}

View File

@ -27,29 +27,32 @@ function populate(str, params) {
export function fetchRecords(recordType, method, url) {
const payload = { recordType };
const headers = { Accept: 'text/json' };
const methodToLowerCase = method.toLowerCase();
return (dispatch) => {
dispatch({
type: ACTION_TYPES.FETCH_RECORDS_REQUEST,
payload,
});
const args = method.toLowerCase() === 'get'
const args = methodToLowerCase === 'get'
? [populate(url, payload), headers]
: [populate(url, payload), {}, headers];
return backend[method.toLowerCase()](...args)
.then(response => response.json())
.then(json => {
dispatch({
type: ACTION_TYPES.FETCH_RECORDS_SUCCESS,
payload: { recordType, data: json },
return backend[methodToLowerCase](...args)
.then(response => response.json())
.then(json => {
dispatch({
type: ACTION_TYPES.FETCH_RECORDS_SUCCESS,
payload: { recordType, data: json },
});
})
.catch((err) => {
dispatch({
type: ACTION_TYPES.FETCH_RECORDS_FAILURE,
payload: { error: err, recordType },
});
});
})
.catch((err) => {
dispatch({
type: ACTION_TYPES.FETCH_RECORDS_FAILURE,
payload: { error: err, recordType },
});
});
};
}
@ -64,29 +67,32 @@ export function fetchRecords(recordType, method, url) {
export function fetchRecord(recordType, method, url) {
const payload = { recordType };
const headers = { Accept: 'text/json' };
const methodToLowerCase = method.toLowerCase();
return (dispatch) => {
dispatch({
type: ACTION_TYPES.FETCH_RECORD_REQUEST,
payload,
});
const args = method.toLowerCase() === 'get'
const args = methodToLowerCase === 'get'
? [populate(url, payload), headers]
: [populate(url, payload), {}, headers];
return backend[method.toLowerCase()](...args)
.then(response => response.json())
.then(json => {
dispatch({
type: ACTION_TYPES.FETCH_RECORD_SUCCESS,
payload: { recordType, data: json },
return backend[methodToLowerCase](...args)
.then(response => response.json())
.then(json => {
dispatch({
type: ACTION_TYPES.FETCH_RECORD_SUCCESS,
payload: { recordType, data: json },
});
})
.catch((err) => {
dispatch({
type: ACTION_TYPES.FETCH_RECORD_FAILURE,
payload: { error: err, recordType },
});
});
})
.catch((err) => {
dispatch({
type: ACTION_TYPES.FETCH_RECORD_FAILURE,
payload: { error: err, recordType },
});
});
};
}

View File

@ -0,0 +1,9 @@
.cms-south-actions {
height: 53px;
position: absolute;
bottom: 0;
width: 100%;
border-top: 1px solid #ddd;
background-color: #f6f7f8;
padding: 8px 15px;
}

View File

@ -1,13 +1,13 @@
/** -----------------------------
* Sections
* ------------------------------ */
@import "../sections/campaign-admin/styles";
/** -----------------------------
* components
* ------------------------------ */
// Components
@import "../components/grid-field/styles";
@import "../components/north-header/styles";
@import "../components/north-header-breadcrumbs/styles";
@import "../components/form-action/styles";
@import "../components/hidden-field/styles";
@import "../components/label/styles";
// Structural
@import "layout";
// Sections
@import "../sections/campaign-admin/styles";

View File

@ -32,11 +32,15 @@ describe('SilverStripeBackend', () => {
it('should send a GET request to an endpoint', () => {
backend.get('http://example.com');
expect(backend.fetch).toBeCalled();
expect(backend.fetch.mock.calls[0][0]).toEqual('http://example.com');
expect(backend.fetch.mock.calls[0][1]).toEqual(jasmine.objectContaining({
method: 'get'
}));
expect(backend.fetch).toBeCalledWith(
'http://example.com',
{
method: 'get',
credentials: 'same-origin',
headers: {},
}
);
});
});
@ -51,12 +55,17 @@ describe('SilverStripeBackend', () => {
backend.post('http://example.com', postData);
expect(backend.fetch).toBeCalled();
expect(backend.fetch.mock.calls[0][0]).toEqual('http://example.com');
expect(backend.fetch.mock.calls[0][1]).toEqual(jasmine.objectContaining({
method: 'post',
body: postData
}));
expect(backend.fetch).toBeCalledWith(
'http://example.com',
{
method: 'post',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: postData,
}
);
});
});
@ -71,12 +80,15 @@ describe('SilverStripeBackend', () => {
backend.put('http://example.com', putData);
expect(backend.fetch).toBeCalled();
expect(backend.fetch.mock.calls[0][0]).toEqual('http://example.com');
expect(backend.fetch.mock.calls[0][1]).toEqual(jasmine.objectContaining({
method: 'put',
body: putData
}));
expect(backend.fetch).toBeCalledWith(
'http://example.com',
{
method: 'put',
credentials: 'same-origin',
headers: {},
body: putData,
}
);
});
});
@ -91,12 +103,15 @@ describe('SilverStripeBackend', () => {
backend.delete('http://example.com', deleteData);
expect(backend.fetch).toBeCalled();
expect(backend.fetch.mock.calls[0][0]).toEqual('http://example.com');
expect(backend.fetch.mock.calls[0][1]).toEqual(jasmine.objectContaining({
method: 'delete',
body: deleteData
}));
expect(backend.fetch).toBeCalledWith(
'http://example.com',
{
method: 'delete',
credentials: 'same-origin',
headers: {},
body: deleteData,
}
);
});
});
@ -134,7 +149,7 @@ describe('SilverStripeBackend', () => {
});
});
pit('should pass a JSON payload', () => {
it('should pass a JSON payload', () => {
const mock = getBackendMock({
text: () => Promise.resolve('{"status":"ok","message":"happy"}'),
headers: new Headers({
@ -146,7 +161,7 @@ describe('SilverStripeBackend', () => {
method: 'post',
payloadFormat: 'json',
responseFormat: 'json',
});
});
const promise = endpoint({ id: 1, values: { a: 'aye', b: 'bee' } });
expect(mock.post.mock.calls[0][0]).toEqual('http://example.org');

View File

@ -459,13 +459,12 @@ gulp.task('css', ['compile:css'], () => {
* Watches for changes if --development flag is given
*/
gulp.task('compile:css', () => {
const outputStyle = isDev ? 'expanded' : 'compressed';
const tasks = rootCompileFolders.map((folder) => { // eslint-disable-line
return gulp.src(`${folder}/scss/**/*.scss`)
.pipe(sourcemaps.init())
.pipe(
sass({
outputStyle,
outputStyle: 'compressed',
importer: (url, prev, done) => {
if (url.match(/^compass\//)) {
done({ file: 'scss/_compasscompat.scss' });

View File

@ -90,7 +90,7 @@ class ChangeSet extends DataObject {
);
}
if(!$this->canPublish()) {
throw new Exception("The current member does not have permission to publish this ChangeSet.");
throw new LogicException("The current member does not have permission to publish this ChangeSet.");
}
DB::get_conn()->withTransaction(function(){