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', 'readCampaign',
'updateCampaign', 'updateCampaign',
'deleteCampaign', 'deleteCampaign',
'publishCampaign',
]; ];
private static $menu_priority = 11; private static $menu_priority = 11;
@ -28,6 +29,7 @@ class CampaignAdmin extends LeftAndMain implements PermissionProvider {
'GET sets' => 'readCampaigns', 'GET sets' => 'readCampaigns',
'POST set/$ID' => 'createCampaign', 'POST set/$ID' => 'createCampaign',
'GET set/$ID/$Name' => 'readCampaign', 'GET set/$ID/$Name' => 'readCampaign',
'PUT set/$ID/publish' => 'publishCampaign',
'PUT set/$ID' => 'updateCampaign', 'PUT set/$ID' => 'updateCampaign',
'DELETE set/$ID' => 'deleteCampaign', 'DELETE set/$ID' => 'deleteCampaign',
]; ];
@ -62,6 +64,10 @@ class CampaignAdmin extends LeftAndMain implements PermissionProvider {
], ],
'campaignViewRoute' => $urlSegment . '/:type?/:id?/:view?', 'campaignViewRoute' => $urlSegment . '/:type?/:id?/:view?',
'itemListViewEndpoint' => $this->Link('set/:id/show'), 'itemListViewEndpoint' => $this->Link('set/:id/show'),
'publishEndpoint' => [
'url' => $this->Link('set/:id/publish'),
'method' => 'put'
]
]); ]);
} }
@ -258,6 +264,8 @@ JSON;
'Created' => $changeSet->Created, 'Created' => $changeSet->Created,
'LastEdited' => $changeSet->LastEdited, 'LastEdited' => $changeSet->LastEdited,
'State' => $changeSet->State, 'State' => $changeSet->State,
'canEdit' => $changeSet->canEdit(),
'canPublish' => $changeSet->canPublish(),
'_embedded' => ['ChangeSetItems' => []] '_embedded' => ['ChangeSetItems' => []]
]; ];
foreach($changeSet->Changes() as $changeSetItem) { foreach($changeSet->Changes() as $changeSetItem) {
@ -415,6 +423,44 @@ JSON;
return (new SS_HTTPResponse('', 204)); 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 * @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.DELETED": "Deleted",
"ModelAdmin.VALIDATIONERROR": "Validation Error", "ModelAdmin.VALIDATIONERROR": "Validation Error",
"LeftAndMain.PAGEWASDELETED": "This page was deleted. To edit a page, select it from the left.", "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.DELETED": "Deleted",
"ModelAdmin.VALIDATIONERROR": "Validation Error", "ModelAdmin.VALIDATIONERROR": "Validation Error",
"LeftAndMain.PAGEWASDELETED": "This page was deleted. To edit a page, select it from the left.", "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 React from 'react';
import { bindActionCreators } from 'redux'; import { bindActionCreators } from 'redux';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import backend from 'silverstripe-backend';
import * as actions from 'state/campaign/actions'; import * as actions from 'state/campaign/actions';
import SilverStripeComponent from 'silverstripe-component'; import SilverStripeComponent from 'silverstripe-component';
import FormAction from 'components/form-action/index'; import FormAction from 'components/form-action/index';
@ -16,6 +17,13 @@ class CampaignAdminContainer extends SilverStripeComponent {
this.addCampaign = this.addCampaign.bind(this); this.addCampaign = this.addCampaign.bind(this);
this.createFn = this.createFn.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() { componentDidMount() {
@ -73,6 +81,7 @@ class CampaignAdminContainer extends SilverStripeComponent {
const props = { const props = {
campaignId: this.props.campaignId, campaignId: this.props.campaignId,
itemListViewEndpoint: this.props.config.itemListViewEndpoint, itemListViewEndpoint: this.props.config.itemListViewEndpoint,
publishApi: this.publishApi,
}; };
return ( return (

View File

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

View File

@ -1,24 +1,33 @@
import React from 'react'; import React from 'react';
import { bindActionCreators } from 'redux'; import { bindActionCreators } from 'redux';
import { connect } from 'react-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 SilverStripeComponent from 'silverstripe-component';
import Accordion from 'components/accordion/index'; import Accordion from 'components/accordion/index';
import AccordionGroup from 'components/accordion/group'; import AccordionGroup from 'components/accordion/group';
import AccordionItem from 'components/accordion/item'; import AccordionItem from 'components/accordion/item';
import NorthHeader from 'components/north-header/index'; import NorthHeader from 'components/north-header/index';
import FormAction from 'components/form-action/index';
import CampaignItem from './item'; import CampaignItem from './item';
import CampaignPreview from './preview'; import CampaignPreview from './preview';
import i18n from 'i18n';
/** /**
* Represents a campaign list view * Represents a campaign list view
*/ */
class CampaignListContainer extends SilverStripeComponent { class CampaignListContainer extends SilverStripeComponent {
constructor(props) {
super(props);
this.handlePublish = this.handlePublish.bind(this);
}
componentDidMount() { componentDidMount() {
const fetchURL = this.props.itemListViewEndpoint.replace(/:id/, this.props.campaignId); const fetchURL = this.props.itemListViewEndpoint.replace(/:id/, this.props.campaignId);
super.componentDidMount(); 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} {accordionGroups}
</Accordion> </Accordion>
</div> </div>
<div className="cms-south-actions">
{this.renderButtonToolbar()}
</div>
</div> </div>
{ previewUrl && <CampaignPreview previewUrl={previewUrl} /> } { previewUrl && <CampaignPreview previewUrl={previewUrl} /> }
</div> </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 * Gets preview URL for itemid
@ -100,6 +157,17 @@ class CampaignListContainer extends SilverStripeComponent {
return document.getElementsByTagName('base')[0].href; 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 * Group items for changeset display
* *
@ -107,10 +175,10 @@ class CampaignListContainer extends SilverStripeComponent {
*/ */
groupItemsForSet() { groupItemsForSet() {
const groups = {}; const groups = {};
if (!this.props.record || !this.props.record._embedded) { const items = this.getItems();
if (!items) {
return groups; return groups;
} }
const items = this.props.record._embedded.ChangeSetItems;
// group by whatever // group by whatever
items.forEach(item => { items.forEach(item => {
@ -132,8 +200,20 @@ class CampaignListContainer extends SilverStripeComponent {
return groups; 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) { function mapStateToProps(state, ownProps) {
// Find record specific to this item // Find record specific to this item
let record = null; let record = null;
@ -149,7 +229,8 @@ function mapStateToProps(state, ownProps) {
function mapDispatchToProps(dispatch) { function mapDispatchToProps(dispatch) {
return { return {
actions: bindActionCreators(actions, dispatch), recordActions: bindActionCreators(recordActions, dispatch),
campaignActions: bindActionCreators(campaignActions, dispatch),
}; };
} }

View File

@ -1,3 +1,6 @@
export default { export default {
SET_CAMPAIGN_ACTIVE_CHANGESET: 'SET_CAMPAIGN_ACTIVE_CHANGESET', 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 ACTION_TYPES from './action-types';
import RECORD_ACTION_TYPES from 'state/records/action-types';
/** /**
* Show specified campaign set * 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,16 +27,19 @@ function populate(str, params) {
export function fetchRecords(recordType, method, url) { export function fetchRecords(recordType, method, url) {
const payload = { recordType }; const payload = { recordType };
const headers = { Accept: 'text/json' }; const headers = { Accept: 'text/json' };
const methodToLowerCase = method.toLowerCase();
return (dispatch) => { return (dispatch) => {
dispatch({ dispatch({
type: ACTION_TYPES.FETCH_RECORDS_REQUEST, type: ACTION_TYPES.FETCH_RECORDS_REQUEST,
payload, payload,
}); });
const args = method.toLowerCase() === 'get'
const args = methodToLowerCase === 'get'
? [populate(url, payload), headers] ? [populate(url, payload), headers]
: [populate(url, payload), {}, headers]; : [populate(url, payload), {}, headers];
return backend[method.toLowerCase()](...args) return backend[methodToLowerCase](...args)
.then(response => response.json()) .then(response => response.json())
.then(json => { .then(json => {
dispatch({ dispatch({
@ -64,16 +67,19 @@ export function fetchRecords(recordType, method, url) {
export function fetchRecord(recordType, method, url) { export function fetchRecord(recordType, method, url) {
const payload = { recordType }; const payload = { recordType };
const headers = { Accept: 'text/json' }; const headers = { Accept: 'text/json' };
const methodToLowerCase = method.toLowerCase();
return (dispatch) => { return (dispatch) => {
dispatch({ dispatch({
type: ACTION_TYPES.FETCH_RECORD_REQUEST, type: ACTION_TYPES.FETCH_RECORD_REQUEST,
payload, payload,
}); });
const args = method.toLowerCase() === 'get'
const args = methodToLowerCase === 'get'
? [populate(url, payload), headers] ? [populate(url, payload), headers]
: [populate(url, payload), {}, headers]; : [populate(url, payload), {}, headers];
return backend[method.toLowerCase()](...args) return backend[methodToLowerCase](...args)
.then(response => response.json()) .then(response => response.json())
.then(json => { .then(json => {
dispatch({ dispatch({

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 @@
/** ----------------------------- // Components
* Sections
* ------------------------------ */
@import "../sections/campaign-admin/styles";
/** -----------------------------
* components
* ------------------------------ */
@import "../components/grid-field/styles"; @import "../components/grid-field/styles";
@import "../components/north-header/styles"; @import "../components/north-header/styles";
@import "../components/north-header-breadcrumbs/styles"; @import "../components/north-header-breadcrumbs/styles";
@import "../components/form-action/styles"; @import "../components/form-action/styles";
@import "../components/hidden-field/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', () => { it('should send a GET request to an endpoint', () => {
backend.get('http://example.com'); backend.get('http://example.com');
expect(backend.fetch).toBeCalled();
expect(backend.fetch.mock.calls[0][0]).toEqual('http://example.com'); expect(backend.fetch).toBeCalledWith(
expect(backend.fetch.mock.calls[0][1]).toEqual(jasmine.objectContaining({ 'http://example.com',
method: 'get' {
})); method: 'get',
credentials: 'same-origin',
headers: {},
}
);
}); });
}); });
@ -51,12 +55,17 @@ describe('SilverStripeBackend', () => {
backend.post('http://example.com', postData); backend.post('http://example.com', postData);
expect(backend.fetch).toBeCalled(); expect(backend.fetch).toBeCalledWith(
expect(backend.fetch.mock.calls[0][0]).toEqual('http://example.com'); 'http://example.com',
expect(backend.fetch.mock.calls[0][1]).toEqual(jasmine.objectContaining({ {
method: 'post', method: 'post',
body: postData 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); backend.put('http://example.com', putData);
expect(backend.fetch).toBeCalled(); expect(backend.fetch).toBeCalledWith(
expect(backend.fetch.mock.calls[0][0]).toEqual('http://example.com'); 'http://example.com',
expect(backend.fetch.mock.calls[0][1]).toEqual(jasmine.objectContaining({ {
method: 'put', method: 'put',
body: putData credentials: 'same-origin',
})); headers: {},
body: putData,
}
);
}); });
}); });
@ -91,12 +103,15 @@ describe('SilverStripeBackend', () => {
backend.delete('http://example.com', deleteData); backend.delete('http://example.com', deleteData);
expect(backend.fetch).toBeCalled(); expect(backend.fetch).toBeCalledWith(
expect(backend.fetch.mock.calls[0][0]).toEqual('http://example.com'); 'http://example.com',
expect(backend.fetch.mock.calls[0][1]).toEqual(jasmine.objectContaining({ {
method: 'delete', method: 'delete',
body: deleteData 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({ const mock = getBackendMock({
text: () => Promise.resolve('{"status":"ok","message":"happy"}'), text: () => Promise.resolve('{"status":"ok","message":"happy"}'),
headers: new Headers({ headers: new Headers({

View File

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

View File

@ -90,7 +90,7 @@ class ChangeSet extends DataObject {
); );
} }
if(!$this->canPublish()) { 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(){ DB::get_conn()->withTransaction(function(){