mirror of
https://github.com/silverstripe/silverstripe-framework
synced 2024-10-22 14:05:37 +02:00
Campaign publish feature
This commit is contained in:
parent
3820314a11
commit
107e38b7a7
@ -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) {
|
||||
@ -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
|
||||
*
|
||||
|
@ -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",
|
||||
});
|
||||
}
|
@ -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",
|
||||
}
|
15
admin/javascript/src/components/label/README.md
Normal file
15
admin/javascript/src/components/label/README.md
Normal 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" />
|
||||
```
|
6
admin/javascript/src/components/label/styles.scss
Normal file
6
admin/javascript/src/components/label/styles.scss
Normal file
@ -0,0 +1,6 @@
|
||||
.label--empty {
|
||||
border-radius: 50%;
|
||||
height: 10px;
|
||||
width: 10px;
|
||||
top: 1px;
|
||||
}
|
@ -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 (
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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"> </span>
|
||||
{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),
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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',
|
||||
};
|
||||
|
@ -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 },
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
|
@ -27,16 +27,19 @@ 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)
|
||||
return backend[methodToLowerCase](...args)
|
||||
.then(response => response.json())
|
||||
.then(json => {
|
||||
dispatch({
|
||||
@ -64,16 +67,19 @@ 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)
|
||||
return backend[methodToLowerCase](...args)
|
||||
.then(response => response.json())
|
||||
.then(json => {
|
||||
dispatch({
|
||||
|
9
admin/javascript/src/styles/_layout.scss
Normal file
9
admin/javascript/src/styles/_layout.scss
Normal 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;
|
||||
}
|
@ -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";
|
||||
|
@ -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({
|
||||
expect(backend.fetch).toBeCalledWith(
|
||||
'http://example.com',
|
||||
{
|
||||
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);
|
||||
|
||||
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({
|
||||
expect(backend.fetch).toBeCalledWith(
|
||||
'http://example.com',
|
||||
{
|
||||
method: 'put',
|
||||
body: putData
|
||||
}));
|
||||
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({
|
||||
expect(backend.fetch).toBeCalledWith(
|
||||
'http://example.com',
|
||||
{
|
||||
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({
|
||||
text: () => Promise.resolve('{"status":"ok","message":"happy"}'),
|
||||
headers: new Headers({
|
||||
|
@ -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' });
|
||||
|
@ -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(){
|
||||
|
Loading…
Reference in New Issue
Block a user