From 107e38b7a7e3e35d6018b7aeb41de4e8c87cb6c9 Mon Sep 17 00:00:00 2001 From: Ingo Schommer Date: Tue, 12 Apr 2016 09:15:04 +1200 Subject: [PATCH] Campaign publish feature --- admin/code/CampaignAdmin.php | 60 ++++++++++-- admin/javascript/lang/en.js | 7 +- admin/javascript/lang/src/en.js | 5 +- .../javascript/src/components/label/README.md | 15 +++ .../src/components/label/styles.scss | 6 ++ .../src/sections/campaign-admin/controller.js | 9 ++ .../src/sections/campaign-admin/item.js | 2 +- .../src/sections/campaign-admin/list.js | 91 ++++++++++++++++++- .../src/state/campaign/action-types.js | 3 + .../javascript/src/state/campaign/actions.js | 34 +++++++ admin/javascript/src/state/records/actions.js | 62 +++++++------ admin/javascript/src/styles/_layout.scss | 9 ++ admin/javascript/src/styles/main.scss | 16 ++-- .../src/tests/silverstripe-backend-test.js | 65 ++++++++----- gulpfile.js | 3 +- model/versioning/ChangeSet.php | 2 +- 16 files changed, 308 insertions(+), 81 deletions(-) create mode 100644 admin/javascript/src/components/label/README.md create mode 100644 admin/javascript/src/components/label/styles.scss create mode 100644 admin/javascript/src/styles/_layout.scss diff --git a/admin/code/CampaignAdmin.php b/admin/code/CampaignAdmin.php index 2e6d50e3c..997e74395 100644 --- a/admin/code/CampaignAdmin.php +++ b/admin/code/CampaignAdmin.php @@ -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 * diff --git a/admin/javascript/lang/en.js b/admin/javascript/lang/en.js index e8405915b..1e7fd3ed9 100644 --- a/admin/javascript/lang/en.js +++ b/admin/javascript/lang/en.js @@ -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", }); -} \ No newline at end of file +} diff --git a/admin/javascript/lang/src/en.js b/admin/javascript/lang/src/en.js index 85289aa4c..f37d7306d 100644 --- a/admin/javascript/lang/src/en.js +++ b/admin/javascript/lang/src/en.js @@ -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" -} \ No newline at end of file + "Campaigns.ADDCAMPAIGN": "Add campaign", + "Campaigns.PUBLISHCAMPAIGN": "Publish campaign", +} diff --git a/admin/javascript/src/components/label/README.md b/admin/javascript/src/components/label/README.md new file mode 100644 index 000000000..62558c6fb --- /dev/null +++ b/admin/javascript/src/components/label/README.md @@ -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 + +``` diff --git a/admin/javascript/src/components/label/styles.scss b/admin/javascript/src/components/label/styles.scss new file mode 100644 index 000000000..5d91c513d --- /dev/null +++ b/admin/javascript/src/components/label/styles.scss @@ -0,0 +1,6 @@ +.label--empty { + border-radius: 50%; + height: 10px; + width: 10px; + top: 1px; +} diff --git a/admin/javascript/src/sections/campaign-admin/controller.js b/admin/javascript/src/sections/campaign-admin/controller.js index 7b087b1bc..37af80cf2 100644 --- a/admin/javascript/src/sections/campaign-admin/controller.js +++ b/admin/javascript/src/sections/campaign-admin/controller.js @@ -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 ( diff --git a/admin/javascript/src/sections/campaign-admin/item.js b/admin/javascript/src/sections/campaign-admin/item.js index a9167f992..640ab2a8f 100644 --- a/admin/javascript/src/sections/campaign-admin/item.js +++ b/admin/javascript/src/sections/campaign-admin/item.js @@ -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; diff --git a/admin/javascript/src/sections/campaign-admin/list.js b/admin/javascript/src/sections/campaign-admin/list.js index 5c86d20be..fb10362e9 100644 --- a/admin/javascript/src/sections/campaign-admin/list.js +++ b/admin/javascript/src/sections/campaign-admin/list.js @@ -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} +
+ {this.renderButtonToolbar()} +
{ previewUrl && } ); } + 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 = ( + + ); + } else if (this.props.record.State === 'published') { + // TODO Implement "revert" feature + button = ( + + ); + } + + return ( +
+ {button} + +   +  {itemSummaryLabel} + +
+ ); + } + + return
; + } /** * 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), }; } diff --git a/admin/javascript/src/state/campaign/action-types.js b/admin/javascript/src/state/campaign/action-types.js index 2aa52e7dc..bf1711356 100644 --- a/admin/javascript/src/state/campaign/action-types.js +++ b/admin/javascript/src/state/campaign/action-types.js @@ -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', }; diff --git a/admin/javascript/src/state/campaign/actions.js b/admin/javascript/src/state/campaign/actions.js index 5d2700895..8a06520fc 100644 --- a/admin/javascript/src/state/campaign/actions.js +++ b/admin/javascript/src/state/campaign/actions.js @@ -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 }, + }); + }); + }; +} diff --git a/admin/javascript/src/state/records/actions.js b/admin/javascript/src/state/records/actions.js index 933a96e83..fb4975b29 100644 --- a/admin/javascript/src/state/records/actions.js +++ b/admin/javascript/src/state/records/actions.js @@ -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 }, - }); - }); }; } diff --git a/admin/javascript/src/styles/_layout.scss b/admin/javascript/src/styles/_layout.scss new file mode 100644 index 000000000..d458f22ec --- /dev/null +++ b/admin/javascript/src/styles/_layout.scss @@ -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; +} diff --git a/admin/javascript/src/styles/main.scss b/admin/javascript/src/styles/main.scss index 5f9ec8e8c..83b64cfce 100644 --- a/admin/javascript/src/styles/main.scss +++ b/admin/javascript/src/styles/main.scss @@ -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"; diff --git a/admin/javascript/src/tests/silverstripe-backend-test.js b/admin/javascript/src/tests/silverstripe-backend-test.js index 918a7b1ee..a5322f919 100644 --- a/admin/javascript/src/tests/silverstripe-backend-test.js +++ b/admin/javascript/src/tests/silverstripe-backend-test.js @@ -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'); diff --git a/gulpfile.js b/gulpfile.js index b065cfd21..6d1a0a76e 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -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' }); diff --git a/model/versioning/ChangeSet.php b/model/versioning/ChangeSet.php index 953561e29..a62e01203 100644 --- a/model/versioning/ChangeSet.php +++ b/model/versioning/ChangeSet.php @@ -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(){