mirror of
https://github.com/silverstripe/silverstripe-framework
synced 2024-10-22 12:05:37 +00:00
API Implement campaign list view
This commit is contained in:
parent
05973cee55
commit
5900893753
@ -27,21 +27,41 @@ class CampaignAdmin extends LeftAndMain implements PermissionProvider {
|
||||
private static $url_handlers = [
|
||||
'GET sets' => 'readCampaigns',
|
||||
'POST set/$ID' => 'createCampaign',
|
||||
'GET set/$ID' => 'readCampaign',
|
||||
'GET set/$ID/$Name' => 'readCampaign',
|
||||
'PUT set/$ID' => 'updateCampaign',
|
||||
'DELETE set/$ID' => 'deleteCampaign',
|
||||
];
|
||||
|
||||
private static $url_segment = 'campaigns';
|
||||
|
||||
/**
|
||||
* Size of thumbnail width
|
||||
*
|
||||
* @config
|
||||
* @var int
|
||||
*/
|
||||
private static $thumbnail_width = 64;
|
||||
|
||||
/**
|
||||
* Size of thumbnail height
|
||||
*
|
||||
* @config
|
||||
* @var int
|
||||
*/
|
||||
private static $thumbnail_height = 64;
|
||||
|
||||
public function getClientConfig() {
|
||||
$urlSegment = Config::inst()->get($this->class, 'url_segment');
|
||||
|
||||
return array_merge(parent::getClientConfig(), [
|
||||
'forms' => [
|
||||
// TODO Use schemaUrl instead
|
||||
'editForm' => [
|
||||
'schemaUrl' => $this->Link('schema/EditForm')
|
||||
]
|
||||
]
|
||||
],
|
||||
'campaignViewRoute' => $urlSegment . '/:type?/:id?/:view?',
|
||||
'itemListViewEndpoint' => $this->Link('set/:id/show'),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -191,7 +211,7 @@ JSON;
|
||||
$hal = $this->getListResource();
|
||||
$response->setBody(Convert::array2json($hal));
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list contained as a hal wrapper
|
||||
@ -259,7 +279,10 @@ JSON;
|
||||
* @return array
|
||||
*/
|
||||
protected function getChangeSetItemResource(ChangeSetItem $changeSetItem) {
|
||||
$objectSingleton = DataObject::singleton($changeSetItem->ObjectClass);
|
||||
$baseClass = ClassInfo::baseDataClass($changeSetItem->ObjectClass);
|
||||
$baseSingleton = DataObject::singleton($baseClass);
|
||||
$thumbnailWidth = (int)$this->config()->thumbnail_width;
|
||||
$thumbnailHeight = (int)$this->config()->thumbnail_height;
|
||||
$hal = [
|
||||
'_links' => [
|
||||
'self' => [
|
||||
@ -274,8 +297,10 @@ JSON;
|
||||
'Added' => $changeSetItem->Added,
|
||||
'ObjectClass' => $changeSetItem->ObjectClass,
|
||||
'ObjectID' => $changeSetItem->ObjectID,
|
||||
'ObjectSingular' => $objectSingleton->i18n_singular_name(),
|
||||
'ObjectPlural' => $objectSingleton->i18n_plural_name(),
|
||||
'BaseClass' => $baseClass,
|
||||
'Singular' => $baseSingleton->i18n_singular_name(),
|
||||
'Plural' => $baseSingleton->i18n_plural_name(),
|
||||
'Thumbnail' => $changeSetItem->ThumbnailURL($thumbnailWidth, $thumbnailHeight),
|
||||
];
|
||||
// Depending on whether the object was added implicitly or explicitly, set
|
||||
// other related objects.
|
||||
@ -320,12 +345,27 @@ JSON;
|
||||
*/
|
||||
public function readCampaign(SS_HTTPRequest $request) {
|
||||
$response = new SS_HTTPResponse();
|
||||
$response->addHeader('Content-Type', 'application/json');
|
||||
$response->setBody('');
|
||||
|
||||
// TODO Implement data retrieval and serialisation
|
||||
if ($request->getHeader('Accept') == 'text/json') {
|
||||
$response->addHeader('Content-Type', 'application/json');
|
||||
$changeSet = ChangeSet::get()->byId($request->param('ID'));
|
||||
|
||||
switch ($request->param('Name')) {
|
||||
case "edit":
|
||||
$response->setBody('{"message":"show the edit view"}');
|
||||
break;
|
||||
case "show":
|
||||
$response->setBody(Convert::raw2json($this->getChangeSetResource($changeSet)));
|
||||
break;
|
||||
default:
|
||||
$response->setBody('{"message":"404"}');
|
||||
}
|
||||
|
||||
return $response;
|
||||
|
||||
} else {
|
||||
return $this->index($request);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -8,6 +8,7 @@ import * as configActions from 'state/config/actions';
|
||||
import ConfigReducer from 'state/config/reducer';
|
||||
import SchemaReducer from 'state/schema/reducer';
|
||||
import RecordsReducer from 'state/records/reducer';
|
||||
import CampaignReducer from 'state/campaign/reducer';
|
||||
|
||||
// Sections
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
@ -17,6 +18,7 @@ function appBoot() {
|
||||
reducerRegister.add('config', ConfigReducer);
|
||||
reducerRegister.add('schemas', SchemaReducer);
|
||||
reducerRegister.add('records', RecordsReducer);
|
||||
reducerRegister.add('campaign', CampaignReducer);
|
||||
|
||||
const initialState = {};
|
||||
const rootReducer = combineReducers(reducerRegister.getAll());
|
||||
|
@ -19,4 +19,3 @@ require('../../../../javascript/src/HtmlEditorField.js');
|
||||
require('../../../../javascript/src/TabSet.js');
|
||||
require('../../src/ssui.core.js');
|
||||
require('../../../../javascript/src/GridField.js');
|
||||
require('json-js');
|
||||
|
31
admin/javascript/src/components/accordion/group.js
Normal file
31
admin/javascript/src/components/accordion/group.js
Normal file
@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import SilverStripeComponent from 'silverstripe-component';
|
||||
import 'bootstrap-collapse';
|
||||
|
||||
class AccordionGroup extends SilverStripeComponent {
|
||||
render() {
|
||||
const headerID = `${this.props.groupid}_Header`;
|
||||
const listID = `${this.props.groupid}_Items`;
|
||||
const href = `#${listID}`;
|
||||
const groupProps = {
|
||||
id: listID,
|
||||
'aria-expanded': true,
|
||||
className: 'list-group list-group-flush collapse in',
|
||||
role: 'tabpanel',
|
||||
'aria-labelledby': headerID,
|
||||
};
|
||||
return (
|
||||
<div className="accordion-group">
|
||||
<h6 className="accordion-group__title" role="tab" id={headerID}>
|
||||
<a data-toggle="collapse" href={href} aria-expanded="true" aria-controls={listID}>
|
||||
{this.props.title}
|
||||
</a>
|
||||
</h6>
|
||||
<div {...groupProps}>
|
||||
{this.props.children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
export default AccordionGroup;
|
11
admin/javascript/src/components/accordion/index.js
Normal file
11
admin/javascript/src/components/accordion/index.js
Normal file
@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
import SilverStripeComponent from 'silverstripe-component';
|
||||
|
||||
class Accordion extends SilverStripeComponent {
|
||||
render() {
|
||||
return (
|
||||
<div role="tablist" aria-multiselectable="true">{this.props.children}</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
export default Accordion;
|
14
admin/javascript/src/components/accordion/item.js
Normal file
14
admin/javascript/src/components/accordion/item.js
Normal file
@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import SilverStripeComponent from 'silverstripe-component';
|
||||
|
||||
class AccordionItem extends SilverStripeComponent {
|
||||
render() {
|
||||
let className = `list-group-item ${this.props.className}`;
|
||||
return (
|
||||
<a className={className}>
|
||||
{this.props.children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
}
|
||||
export default AccordionItem;
|
@ -14,6 +14,10 @@ Actions the component can dispatch. This should include but is not limited to:
|
||||
|
||||
An action to call when the response from fetching schema data is returned. This would normally be a simple action to set the store's `schema` key to the returned data.
|
||||
|
||||
### createFn (func)
|
||||
|
||||
Gives container components a chance to access a form component before it's constructed. Use this as an opportunity to pass a custom click handler to to a field for example.
|
||||
|
||||
### schemaUrl
|
||||
|
||||
The schema URL where the form will be scaffolded from e.g. '/admin/pages/schema/1'.
|
||||
|
@ -66,6 +66,7 @@ export class FormBuilderComponent extends SilverStripeComponent {
|
||||
|
||||
this.formSchemaPromise = null;
|
||||
this.state = { isFetching: false };
|
||||
this.mapFieldsToComponents = this.mapFieldsToComponents.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
@ -102,11 +103,17 @@ export class FormBuilderComponent extends SilverStripeComponent {
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(json => {
|
||||
// TODO See "Enable once <CampaignAdmin> ..." below
|
||||
this.setState({ isFetching: false });
|
||||
this.props.actions.setSchema(json);
|
||||
});
|
||||
|
||||
this.setState({ isFetching: true });
|
||||
// TODO Enable once <CampaignAdmin> is initialised via page.js route callbacks
|
||||
// At the moment, it's running an Entwine onadd() rule which ends up
|
||||
// rendering the index view, and only then calling route.start() to
|
||||
// match the detail view (admin/campaigns/set/:id/show).
|
||||
// This causes the form builder to be unmounted during a fetch() call.
|
||||
// this.setState({ isFetching: true });
|
||||
|
||||
return this.formSchemaPromise;
|
||||
}
|
||||
@ -121,6 +128,8 @@ export class FormBuilderComponent extends SilverStripeComponent {
|
||||
* @return array
|
||||
*/
|
||||
mapFieldsToComponents(fields) {
|
||||
const createFn = this.props.createFn;
|
||||
|
||||
return fields.map((field, i) => {
|
||||
const Component = field.component !== null
|
||||
? fakeInjector.getComponentByName(field.component)
|
||||
@ -135,6 +144,12 @@ export class FormBuilderComponent extends SilverStripeComponent {
|
||||
// which props are required.
|
||||
const props = deepFreeze(field);
|
||||
|
||||
// Provides container components a place to hook in
|
||||
// and apply customisations to scaffolded components.
|
||||
if (typeof createFn === 'function') {
|
||||
return createFn(Component, props);
|
||||
}
|
||||
|
||||
return <Component key={i} {...props} />;
|
||||
});
|
||||
}
|
||||
@ -171,6 +186,7 @@ export class FormBuilderComponent extends SilverStripeComponent {
|
||||
|
||||
FormBuilderComponent.propTypes = {
|
||||
actions: React.PropTypes.object.isRequired,
|
||||
createFn: React.PropTypes.func,
|
||||
schemaUrl: React.PropTypes.string.isRequired,
|
||||
schemas: React.PropTypes.object.isRequired,
|
||||
};
|
||||
|
@ -3,16 +3,36 @@ import SilverStripeComponent from 'silverstripe-component';
|
||||
|
||||
class GridFieldCellComponent extends SilverStripeComponent {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleDrillDown = this.handleDrillDown.bind(this);
|
||||
}
|
||||
|
||||
render() {
|
||||
const props = {
|
||||
className: `grid-field-cell-component ${this.props.className}`,
|
||||
onClick: this.handleDrillDown,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid-field-cell-component">{this.props.children}</div>
|
||||
<div {...props}>{this.props.children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
handleDrillDown(event) {
|
||||
if (typeof this.props.handleDrillDown === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.handleDrillDown(event);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
GridFieldCellComponent.PropTypes = {
|
||||
width: React.PropTypes.number,
|
||||
handleDrillDown: React.PropTypes.func,
|
||||
};
|
||||
|
||||
export default GridFieldCellComponent;
|
||||
|
@ -41,6 +41,8 @@ class GridField extends SilverStripeComponent {
|
||||
|
||||
render() {
|
||||
const records = this.props.records;
|
||||
const handleDrillDown = this.props.data.handleDrillDown;
|
||||
|
||||
if (!records) {
|
||||
return <div></div>;
|
||||
}
|
||||
@ -55,10 +57,21 @@ class GridField extends SilverStripeComponent {
|
||||
const header = <GridFieldHeader>{headerCells.concat(actionPlaceholder)}</GridFieldHeader>;
|
||||
|
||||
const rows = records.map((record, i) => {
|
||||
// Build cells
|
||||
const cells = columns.map((column, j) => {
|
||||
// Get value by dot notation
|
||||
const val = column.field.split('.').reduce((a, b) => a[b], record);
|
||||
return <GridFieldCell key={j} width={column.width}>{val}</GridFieldCell>;
|
||||
const cellProps = {
|
||||
handleDrillDown: handleDrillDown ? (event) => handleDrillDown(event, record) : null,
|
||||
className: handleDrillDown ? 'grid-field-cell-component--drillable' : '',
|
||||
key: j,
|
||||
width: column.width,
|
||||
};
|
||||
return (
|
||||
<GridFieldCell {...cellProps}>
|
||||
{val}
|
||||
</GridFieldCell>
|
||||
);
|
||||
});
|
||||
|
||||
const rowActions = (
|
||||
@ -78,7 +91,16 @@ class GridField extends SilverStripeComponent {
|
||||
</GridFieldCell>
|
||||
);
|
||||
|
||||
return <GridFieldRow key={i}>{cells.concat(rowActions)}</GridFieldRow>;
|
||||
const rowProps = {
|
||||
key: i,
|
||||
className: handleDrillDown ? 'grid-field-row-component--drillable' : '',
|
||||
};
|
||||
|
||||
return (
|
||||
<GridFieldRow {...rowProps}>
|
||||
{cells.concat(rowActions)}
|
||||
</GridFieldRow>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
@ -112,6 +134,7 @@ GridField.propTypes = {
|
||||
recordType: React.PropTypes.string.isRequired,
|
||||
headerColumns: React.PropTypes.array,
|
||||
collectionReadEndpoint: React.PropTypes.object,
|
||||
handleDrillDown: React.PropTypes.func,
|
||||
}),
|
||||
};
|
||||
|
||||
|
@ -4,11 +4,9 @@ import SilverStripeComponent from 'silverstripe-component';
|
||||
class GridFieldRowComponent extends SilverStripeComponent {
|
||||
|
||||
render() {
|
||||
return (
|
||||
<li className="grid-field-row-component [ list-group-item ]">{this.props.children}</li>
|
||||
);
|
||||
const className = `grid-field-row-component [ list-group-item ] ${this.props.className}`;
|
||||
return <li className={className}>{this.props.children}</li>;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default GridFieldRowComponent;
|
||||
|
@ -65,6 +65,17 @@
|
||||
&:first-child {
|
||||
background: none;
|
||||
}
|
||||
|
||||
// Drillable rows highlight on hover
|
||||
&--drillable:hover {
|
||||
color: #555;
|
||||
text-decoration: none;
|
||||
background-color: #f5f5f5;
|
||||
|
||||
.grid-field-cell-component--drillable {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,10 +1,13 @@
|
||||
import React from 'react';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { connect } from 'react-redux';
|
||||
import * as actions from 'state/campaign/actions';
|
||||
import SilverStripeComponent from 'silverstripe-component';
|
||||
import FormAction from 'components/form-action/index';
|
||||
import i18n from 'i18n';
|
||||
import NorthHeader from 'components/north-header/index';
|
||||
import FormBuilder from 'components/form-builder/index';
|
||||
import CampaignListContainer from './list';
|
||||
|
||||
class CampaignAdminContainer extends SilverStripeComponent {
|
||||
|
||||
@ -12,24 +15,121 @@ class CampaignAdminContainer extends SilverStripeComponent {
|
||||
super(props);
|
||||
|
||||
this.addCampaign = this.addCampaign.bind(this);
|
||||
this.createFn = this.createFn.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
window.ss.router(`/${this.props.config.campaignViewRoute}`, (ctx) => {
|
||||
this.props.actions.showCampaignView(ctx.params.id, ctx.params.view);
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
let view = null;
|
||||
|
||||
switch (this.props.view) {
|
||||
case 'show':
|
||||
view = this.renderItemListView();
|
||||
break;
|
||||
case 'edit':
|
||||
view = this.renderDetailEditView();
|
||||
break;
|
||||
default:
|
||||
view = this.renderIndexView();
|
||||
}
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the default view which displays a list of Campaigns.
|
||||
*
|
||||
* @return object
|
||||
*/
|
||||
renderIndexView() {
|
||||
const schemaUrl = this.props.config.forms.editForm.schemaUrl;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<NorthHeader />
|
||||
<FormAction
|
||||
label={i18n._t('Campaigns.ADDCAMPAIGN')}
|
||||
icon={'plus-circled'}
|
||||
handleClick={this.addCampaign}
|
||||
/>
|
||||
<FormBuilder schemaUrl={schemaUrl} />
|
||||
<div className="cms-middle no-preview">
|
||||
<div className="cms-campaigns collapse in" aria-expanded="true">
|
||||
<NorthHeader />
|
||||
<FormAction
|
||||
label={i18n._t('Campaigns.ADDCAMPAIGN')}
|
||||
icon={'plus-circled'}
|
||||
handleClick={this.addCampaign}
|
||||
/>
|
||||
<FormBuilder schemaUrl={schemaUrl} createFn={this.createFn} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a list of items in a Campaign.
|
||||
*
|
||||
* @return object
|
||||
*/
|
||||
renderItemListView() {
|
||||
const props = {
|
||||
campaignId: this.props.campaignId,
|
||||
itemListViewEndpoint: this.props.config.itemListViewEndpoint,
|
||||
};
|
||||
|
||||
return (
|
||||
<CampaignListContainer {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
renderDetailEditView() {
|
||||
return <p>Edit</p>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to allow customisation of components being constructed by FormBuilder.
|
||||
*
|
||||
* @param object Component - Component constructor.
|
||||
* @param object props - Props passed from FormBuilder.
|
||||
*
|
||||
* @return object - Instanciated React component
|
||||
*/
|
||||
createFn(Component, props) {
|
||||
const campaignViewRoute = this.props.config.campaignViewRoute;
|
||||
|
||||
if (props.component === 'GridField') {
|
||||
const extendedProps = Object.assign({}, props, {
|
||||
data: Object.assign({}, props.data, {
|
||||
handleDrillDown: (event, record) => {
|
||||
// Set url and set list
|
||||
const path = campaignViewRoute
|
||||
.replace(/:type\?/, 'set')
|
||||
.replace(/:id\?/, record.ID)
|
||||
.replace(/:view\?/, 'show');
|
||||
|
||||
window.ss.router.show(path);
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return <Component key={extendedProps.name} {...extendedProps} />;
|
||||
}
|
||||
|
||||
return <Component key={props.name} {...props} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets preview URL for itemid
|
||||
* @param int id
|
||||
* @returns string
|
||||
*/
|
||||
previewURLForItem(id) {
|
||||
if (!id) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// hard code in baseurl for any itemid preview url
|
||||
return document.getElementsByTagName('base')[0].href;
|
||||
}
|
||||
|
||||
addCampaign() {
|
||||
// Add campaign
|
||||
}
|
||||
@ -50,7 +150,15 @@ CampaignAdminContainer.propTypes = {
|
||||
function mapStateToProps(state, ownProps) {
|
||||
return {
|
||||
config: state.config.sections[ownProps.sectionConfigKey],
|
||||
campaignId: state.campaign.campaignId,
|
||||
view: state.campaign.view,
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(CampaignAdminContainer);
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return {
|
||||
actions: bindActionCreators(actions, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(CampaignAdminContainer);
|
||||
|
49
admin/javascript/src/sections/campaign-admin/item.js
Normal file
49
admin/javascript/src/sections/campaign-admin/item.js
Normal file
@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import SilverStripeComponent from 'silverstripe-component';
|
||||
|
||||
/**
|
||||
* Describes an individual campaign item
|
||||
*/
|
||||
class CampaignItem extends SilverStripeComponent {
|
||||
render() {
|
||||
let thumbnail = '';
|
||||
let badge = '';
|
||||
const item = this.props.item;
|
||||
|
||||
// change badge
|
||||
switch (item.ChangeType) {
|
||||
case 'created':
|
||||
badge = <span className="label label-warning">Draft</span>;
|
||||
break;
|
||||
case 'modified':
|
||||
badge = <span className="label label-warning">Modified</span>;
|
||||
break;
|
||||
case 'deleted':
|
||||
badge = <span className="label label-error">Removed</span>;
|
||||
break;
|
||||
case 'none':
|
||||
default:
|
||||
badge = <span className="label label-success item_visible-hovered">Already published</span>;
|
||||
break;
|
||||
}
|
||||
|
||||
// Linked items
|
||||
let links = <span className="list-group-item__linked item_visible-hovered">[lk] 3 links</span>;
|
||||
|
||||
// Thumbnail
|
||||
if (item.Thumbnail) {
|
||||
thumbnail = <span className="item__thumbnail"><img src={item.Thumbnail} /></span>;
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
{thumbnail}
|
||||
<h4 className="list-group-item-heading">{item.Title}</h4>
|
||||
{links}
|
||||
{badge}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
export default CampaignItem;
|
156
admin/javascript/src/sections/campaign-admin/list.js
Normal file
156
admin/javascript/src/sections/campaign-admin/list.js
Normal file
@ -0,0 +1,156 @@
|
||||
import React from 'react';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { connect } from 'react-redux';
|
||||
import * as actions from 'state/records/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 CampaignItem from './item';
|
||||
import CampaignPreview from './preview';
|
||||
|
||||
/**
|
||||
* Represents a campaign list view
|
||||
*/
|
||||
class CampaignListContainer extends SilverStripeComponent {
|
||||
|
||||
componentDidMount() {
|
||||
const fetchURL = this.props.itemListViewEndpoint.replace(/:id/, this.props.campaignId);
|
||||
super.componentDidMount();
|
||||
this.props.actions.fetchRecord('ChangeSet', 'get', fetchURL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a list of items in a Campaign.
|
||||
*
|
||||
* @return object
|
||||
*/
|
||||
render() {
|
||||
const itemID = 1; // todo - hook up to "click" handler for changesetitems
|
||||
const campaignId = this.props.campaignId;
|
||||
|
||||
// Trigger different layout when preview is enabled
|
||||
const previewUrl = this.previewURLForItem(itemID);
|
||||
const itemGroups = this.groupItemsForSet();
|
||||
const classNames = previewUrl ? 'cms-middle with-preview' : 'cms-middle no-preview';
|
||||
|
||||
// Get items in this set
|
||||
let accordionGroups = [];
|
||||
|
||||
Object.keys(itemGroups).forEach(className => {
|
||||
const group = itemGroups[className];
|
||||
const groupCount = group.items.length;
|
||||
|
||||
let accordionItems = [];
|
||||
let title = `${groupCount} ${groupCount === 1 ? group.singular : group.plural}`;
|
||||
let groupid = `Set_${campaignId}_Group_${className}`;
|
||||
|
||||
// Create items for this group
|
||||
group.items.forEach(item => {
|
||||
// Add extra css class for published items
|
||||
let itemClassName = '';
|
||||
|
||||
if (item.ChangeType === 'none') {
|
||||
itemClassName = 'list-group-item--published';
|
||||
}
|
||||
|
||||
accordionItems.push(
|
||||
<AccordionItem key={item.ID} className={itemClassName}>
|
||||
<CampaignItem item={item} />
|
||||
</AccordionItem>
|
||||
);
|
||||
});
|
||||
|
||||
// Merge into group
|
||||
accordionGroups.push(
|
||||
<AccordionGroup key={groupid} groupid={groupid} title={title}>
|
||||
{accordionItems}
|
||||
</AccordionGroup>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={classNames}>
|
||||
<div className="cms-campaigns collapse in" aria-expanded="true">
|
||||
<NorthHeader />
|
||||
<div className="col-md-12 campaign-items">
|
||||
<Accordion>
|
||||
{accordionGroups}
|
||||
</Accordion>
|
||||
</div>
|
||||
</div>
|
||||
{ previewUrl && <CampaignPreview previewUrl={previewUrl} /> }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Gets preview URL for itemid
|
||||
* @param int id
|
||||
* @returns string
|
||||
*/
|
||||
previewURLForItem(id) {
|
||||
if (!id) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// hard code in baseurl for any itemid preview url
|
||||
return document.getElementsByTagName('base')[0].href;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group items for changeset display
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
groupItemsForSet() {
|
||||
const groups = {};
|
||||
if (!this.props.record || !this.props.record._embedded) {
|
||||
return groups;
|
||||
}
|
||||
const items = this.props.record._embedded.ChangeSetItems;
|
||||
|
||||
// group by whatever
|
||||
items.forEach(item => {
|
||||
// Create new group if needed
|
||||
const classname = item.BaseClass;
|
||||
|
||||
if (!groups[classname]) {
|
||||
groups[classname] = {
|
||||
singular: item.Singular,
|
||||
plural: item.Plural,
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Push items
|
||||
groups[classname].items.push(item);
|
||||
});
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function mapStateToProps(state, ownProps) {
|
||||
// Find record specific to this item
|
||||
let record = null;
|
||||
if (state.records && state.records.ChangeSet && ownProps.campaignId) {
|
||||
record = state.records.ChangeSet.find(
|
||||
(nextRecord) => (nextRecord.ID === parseInt(ownProps.campaignId, 10))
|
||||
);
|
||||
}
|
||||
return {
|
||||
record: record || [],
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return {
|
||||
actions: bindActionCreators(actions, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(CampaignListContainer);
|
15
admin/javascript/src/sections/campaign-admin/preview.js
Normal file
15
admin/javascript/src/sections/campaign-admin/preview.js
Normal file
@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import SilverStripeComponent from 'silverstripe-component';
|
||||
|
||||
class CampaignPreview extends SilverStripeComponent {
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="pages-preview">
|
||||
<iframe src={this.props.previewUrl}></iframe>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default CampaignPreview;
|
@ -1,3 +1,210 @@
|
||||
.CampaignAdmin {
|
||||
overflow-y: auto;
|
||||
}
|
||||
overflow-y: auto;
|
||||
display: block;
|
||||
|
||||
// Contains campaign form and preview layout
|
||||
.cms-middle {
|
||||
padding-left: 0;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
transition: padding .2s;
|
||||
|
||||
&.with-preview {
|
||||
@media (min-width: 992px) { /* lg */
|
||||
padding-left: 316px;
|
||||
.cms-campaigns {
|
||||
width: 316px;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1200px) { /* xl */
|
||||
padding-left: 448px;
|
||||
.cms-campaigns {
|
||||
width: 448px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* CAMPAIGNS */
|
||||
.cms-campaigns {
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background-color: #f6f7f8;
|
||||
z-index: 2;
|
||||
transition: width .2s;
|
||||
padding-bottom: 53px;
|
||||
|
||||
.campaign-items {
|
||||
height: calc(100% - 53px);
|
||||
margin-bottom: 40px;
|
||||
overflow: auto;
|
||||
|
||||
h4 {
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
margin: 2px 0 5px;
|
||||
}
|
||||
|
||||
.list-group {
|
||||
margin-left: -0.9375rem;
|
||||
margin-right: -0.9375rem;
|
||||
|
||||
border-bottom: 1px solid #ddd;
|
||||
margin-bottom: .75rem;
|
||||
|
||||
.list-group-item {
|
||||
padding-left: 0.9375rem; /* would normally be set in variables as 20px */
|
||||
padding-right: 0.9375rem;
|
||||
min-height: 64px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
|
||||
&:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.item__thumbnail {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
display: block;
|
||||
background: #ccc;
|
||||
float: left;
|
||||
margin: -12px 12px 0 -12px;
|
||||
}
|
||||
|
||||
.label {
|
||||
text-transform: uppercase;
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
letter-spacing: .4px;
|
||||
}
|
||||
|
||||
// On hover show all linked items
|
||||
.list-group-item__linked {
|
||||
color: $body-color;
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hover items
|
||||
.list-group-item--published {
|
||||
opacity: .6;
|
||||
}
|
||||
.list-group-item--published:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.item_visible-hovered {
|
||||
opacity: 0;
|
||||
transition: opacity .2s ease-in-out;
|
||||
}
|
||||
a:hover .item_visible-hovered,
|
||||
a.active.list-group-item--published,
|
||||
a.active.list-group-item--published .item_visible-hovered {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
// Accordion styles
|
||||
.accordion-group {
|
||||
margin-top: 16px;
|
||||
|
||||
&__title {
|
||||
margin-bottom: 0;
|
||||
|
||||
a {
|
||||
font-size: 12px; /* extend table header */
|
||||
font-weight: 400;
|
||||
text-transform: uppercase;
|
||||
padding: 1rem 0.9375rem;
|
||||
display: block;
|
||||
margin-left: -0.9375rem;
|
||||
margin-right: -0.9375rem;
|
||||
color: #555;
|
||||
text-decoration: none;
|
||||
position: relative;
|
||||
border-bottom: 1px solid #ddd;
|
||||
|
||||
|
||||
&::before {
|
||||
content: "-";
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 10px 0.9375rem;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
font-size: 16px;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&.collapsed::before {
|
||||
content: "+";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TO MOVE
|
||||
|
||||
/* Preview panel */
|
||||
.pages-preview {
|
||||
display: block;
|
||||
position: relative;
|
||||
background-color: #BBB;
|
||||
border-left: 1px solid #dbdde0;
|
||||
height: 100%;
|
||||
|
||||
iframe {
|
||||
width: 100%;
|
||||
height: calc(100% - 53px);
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
///* btn toolbar */
|
||||
//.cms-south-actions {
|
||||
// height: 53px;
|
||||
// position: absolute;
|
||||
// bottom: 0;
|
||||
// width: 100%;
|
||||
// border-top: 1px solid #ddd;
|
||||
// background-color: #f6f7f8;
|
||||
// padding: 8px 15px;
|
||||
//}
|
||||
//.btn-toolbar {
|
||||
//
|
||||
//}
|
||||
//.btn-toolbar .btn {
|
||||
// font-size: 13px;
|
||||
// line-height: 20px;
|
||||
//}
|
||||
//
|
||||
|
||||
//
|
||||
//.popover-content a {
|
||||
// display: inline-block;
|
||||
// width: 100%;
|
||||
//}
|
||||
//.icon-back {
|
||||
// display: inline-block;
|
||||
// margin-right: 12px;
|
||||
//}
|
||||
//.icon-back + h1 {
|
||||
// display: inline-block;
|
||||
//}
|
||||
//
|
||||
|
||||
//.btn .label_empty {
|
||||
// border-radius: 50%;
|
||||
// height: 10px;
|
||||
// width: 10px;
|
||||
// top: 1px;
|
||||
//}
|
||||
}
|
||||
|
@ -307,11 +307,20 @@ class SilverStripeBackend {
|
||||
/**
|
||||
* Makes a network request using the GET HTTP verb.
|
||||
*
|
||||
* @experimental
|
||||
*
|
||||
* @param string url - Endpoint URL.
|
||||
* @param object data - Data to send with the request.
|
||||
* @param Array headers
|
||||
* @return object - Promise
|
||||
*/
|
||||
get(url) {
|
||||
return this.fetch(url, { method: 'get', credentials: 'same-origin' })
|
||||
get(url, data = {}, headers = {}) {
|
||||
return this.fetch(url, {
|
||||
method: 'get',
|
||||
credentials: 'same-origin',
|
||||
headers,
|
||||
body: data,
|
||||
})
|
||||
.then(checkStatus);
|
||||
}
|
||||
|
||||
@ -320,14 +329,14 @@ class SilverStripeBackend {
|
||||
*
|
||||
* @param string url - Endpoint URL.
|
||||
* @param object data - Data to send with the request.
|
||||
* @param Array headers
|
||||
* @return object - Promise
|
||||
*/
|
||||
post(url, data) {
|
||||
post(url, data = {}, headers = {}) {
|
||||
const defaultHeaders = { 'Content-Type': 'application/x-www-form-urlencoded' };
|
||||
return this.fetch(url, {
|
||||
method: 'post',
|
||||
headers: new Headers({
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
}),
|
||||
headers: Object.assign({}, defaultHeaders, headers),
|
||||
credentials: 'same-origin',
|
||||
body: data,
|
||||
})
|
||||
@ -339,10 +348,11 @@ class SilverStripeBackend {
|
||||
*
|
||||
* @param string url - Endpoint URL.
|
||||
* @param object data - Data to send with the request.
|
||||
* @param Array headers
|
||||
* @return object - Promise
|
||||
*/
|
||||
put(url, data) {
|
||||
return this.fetch(url, { method: 'put', credentials: 'same-origin', body: data })
|
||||
put(url, data = {}, headers = {}) {
|
||||
return this.fetch(url, { method: 'put', credentials: 'same-origin', body: data, headers })
|
||||
.then(checkStatus);
|
||||
}
|
||||
|
||||
@ -351,10 +361,11 @@ class SilverStripeBackend {
|
||||
*
|
||||
* @param string url - Endpoint URL.
|
||||
* @param object data - Data to send with the request.
|
||||
* @param Array headers
|
||||
* @return object - Promise
|
||||
*/
|
||||
delete(url, data) {
|
||||
return this.fetch(url, { method: 'delete', credentials: 'same-origin', body: data })
|
||||
delete(url, data = {}, headers = {}) {
|
||||
return this.fetch(url, { method: 'delete', credentials: 'same-origin', body: data, headers })
|
||||
.then(checkStatus);
|
||||
}
|
||||
|
||||
|
3
admin/javascript/src/state/campaign/action-types.js
Normal file
3
admin/javascript/src/state/campaign/action-types.js
Normal file
@ -0,0 +1,3 @@
|
||||
export default {
|
||||
SET_CAMPAIGN_ACTIVE_CHANGESET: 'SET_CAMPAIGN_ACTIVE_CHANGESET',
|
||||
};
|
15
admin/javascript/src/state/campaign/actions.js
Normal file
15
admin/javascript/src/state/campaign/actions.js
Normal file
@ -0,0 +1,15 @@
|
||||
import ACTION_TYPES from './action-types';
|
||||
|
||||
/**
|
||||
* Show specified campaign set
|
||||
*
|
||||
* @param number campaignId - ID of the Campaign to show.
|
||||
* @param string view - The view mode to display the Campaign in.
|
||||
*/
|
||||
export function showCampaignView(campaignId, view) {
|
||||
return {
|
||||
type: ACTION_TYPES.SET_CAMPAIGN_ACTIVE_CHANGESET,
|
||||
payload: { campaignId, view },
|
||||
};
|
||||
}
|
||||
|
24
admin/javascript/src/state/campaign/reducer.js
Normal file
24
admin/javascript/src/state/campaign/reducer.js
Normal file
@ -0,0 +1,24 @@
|
||||
import deepFreeze from 'deep-freeze';
|
||||
import ACTION_TYPES from './action-types';
|
||||
|
||||
const initialState = {
|
||||
campaignId: null,
|
||||
view: null,
|
||||
};
|
||||
|
||||
function campaignReducer(state = initialState, action) {
|
||||
switch (action.type) {
|
||||
|
||||
case ACTION_TYPES.SET_CAMPAIGN_ACTIVE_CHANGESET:
|
||||
return deepFreeze(Object.assign({}, state, {
|
||||
campaignId: action.payload.campaignId,
|
||||
view: action.payload.view,
|
||||
}));
|
||||
|
||||
default:
|
||||
return state;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
export default campaignReducer;
|
@ -5,6 +5,9 @@ export default {
|
||||
FETCH_RECORDS_REQUEST: 'FETCH_RECORDS_REQUEST',
|
||||
FETCH_RECORDS_FAILURE: 'FETCH_RECORDS_FAILURE',
|
||||
FETCH_RECORDS_SUCCESS: 'FETCH_RECORDS_SUCCESS',
|
||||
FETCH_RECORD_REQUEST: 'FETCH_RECORD_REQUEST',
|
||||
FETCH_RECORD_FAILURE: 'FETCH_RECORD_FAILURE',
|
||||
FETCH_RECORD_SUCCESS: 'FETCH_RECORD_SUCCESS',
|
||||
DELETE_RECORD_REQUEST: 'DELETE_RECORD_REQUEST',
|
||||
DELETE_RECORD_FAILURE: 'DELETE_RECORD_FAILURE',
|
||||
DELETE_RECORD_SUCCESS: 'DELETE_RECORD_SUCCESS',
|
||||
|
@ -26,12 +26,13 @@ function populate(str, params) {
|
||||
*/
|
||||
export function fetchRecords(recordType, method, url) {
|
||||
const payload = { recordType };
|
||||
const headers = { Accept: 'text/json' };
|
||||
return (dispatch) => {
|
||||
dispatch({
|
||||
type: ACTION_TYPES.FETCH_RECORDS_REQUEST,
|
||||
payload,
|
||||
});
|
||||
return backend[method.toLowerCase()](populate(url, payload))
|
||||
return backend[method.toLowerCase()](populate(url, payload), {}, headers)
|
||||
.then(response => response.json())
|
||||
.then(json => {
|
||||
dispatch({
|
||||
@ -48,6 +49,39 @@ export function fetchRecords(recordType, method, url) {
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Fetches a single record
|
||||
*
|
||||
* @param string recordType Type of record (the "class name")
|
||||
* @param string method HTTP method
|
||||
* @param string url API endpoint
|
||||
*/
|
||||
export function fetchRecord(recordType, method, url) {
|
||||
const payload = { recordType };
|
||||
const headers = { Accept: 'text/json' };
|
||||
return (dispatch) => {
|
||||
dispatch({
|
||||
type: ACTION_TYPES.FETCH_RECORD_REQUEST,
|
||||
payload,
|
||||
});
|
||||
return backend[method.toLowerCase()](populate(url, payload), {}, headers)
|
||||
.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 },
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a record
|
||||
*
|
||||
|
@ -1,29 +1,24 @@
|
||||
import deepFreeze from 'deep-freeze';
|
||||
import ACTION_TYPES from './action-types';
|
||||
|
||||
const initialState = {
|
||||
};
|
||||
const initialState = {};
|
||||
|
||||
function recordsReducer(state = initialState, action) {
|
||||
let records;
|
||||
let recordType;
|
||||
let record;
|
||||
let recordIndex;
|
||||
|
||||
switch (action.type) {
|
||||
|
||||
case ACTION_TYPES.CREATE_RECORD:
|
||||
return deepFreeze(Object.assign({}, state, {
|
||||
|
||||
}));
|
||||
return deepFreeze(Object.assign({}, state, {}));
|
||||
|
||||
case ACTION_TYPES.UPDATE_RECORD:
|
||||
return deepFreeze(Object.assign({}, state, {
|
||||
|
||||
}));
|
||||
return deepFreeze(Object.assign({}, state, {}));
|
||||
|
||||
case ACTION_TYPES.DELETE_RECORD:
|
||||
return deepFreeze(Object.assign({}, state, {
|
||||
|
||||
}));
|
||||
return deepFreeze(Object.assign({}, state, {}));
|
||||
|
||||
case ACTION_TYPES.FETCH_RECORDS_REQUEST:
|
||||
return state;
|
||||
@ -39,6 +34,29 @@ function recordsReducer(state = initialState, action) {
|
||||
[recordType]: records,
|
||||
}));
|
||||
|
||||
case ACTION_TYPES.FETCH_RECORD_REQUEST:
|
||||
return state;
|
||||
|
||||
case ACTION_TYPES.FETCH_RECORD_FAILURE:
|
||||
return state;
|
||||
|
||||
case ACTION_TYPES.FETCH_RECORD_SUCCESS:
|
||||
recordType = action.payload.recordType;
|
||||
record = action.payload.data;
|
||||
records = state[recordType] ? state[recordType] : [];
|
||||
|
||||
// Update or insert
|
||||
recordIndex = records.findIndex((nextRecord) => (nextRecord.ID === record.ID));
|
||||
if (recordIndex > -1) {
|
||||
records[recordIndex] = record;
|
||||
} else {
|
||||
records.push(record);
|
||||
}
|
||||
|
||||
return deepFreeze(Object.assign({}, state, {
|
||||
[recordType]: records,
|
||||
}));
|
||||
|
||||
case ACTION_TYPES.DELETE_RECORD_REQUEST:
|
||||
return state;
|
||||
|
||||
@ -48,7 +66,7 @@ function recordsReducer(state = initialState, action) {
|
||||
case ACTION_TYPES.DELETE_RECORD_SUCCESS:
|
||||
recordType = action.payload.recordType;
|
||||
records = state[recordType]
|
||||
.filter(record => record.ID !== action.payload.id);
|
||||
.filter(nextRecord => nextRecord.ID !== action.payload.id);
|
||||
|
||||
return deepFreeze(Object.assign({}, state, {
|
||||
[recordType]: records,
|
||||
|
@ -32,10 +32,11 @@ describe('SilverStripeBackend', () => {
|
||||
|
||||
it('should send a GET request to an endpoint', () => {
|
||||
backend.get('http://example.com');
|
||||
expect(backend.fetch).toBeCalledWith(
|
||||
'http://example.com',
|
||||
{ method: 'get', credentials: 'same-origin' }
|
||||
);
|
||||
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'
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@ -51,12 +52,10 @@ 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,
|
||||
credentials: 'same-origin',
|
||||
body: postData
|
||||
}));
|
||||
});
|
||||
});
|
||||
@ -72,10 +71,12 @@ describe('SilverStripeBackend', () => {
|
||||
|
||||
backend.put('http://example.com', putData);
|
||||
|
||||
expect(backend.fetch).toBeCalledWith(
|
||||
'http://example.com',
|
||||
{ method: 'put', body: putData, credentials: 'same-origin' }
|
||||
);
|
||||
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
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@ -90,10 +91,12 @@ describe('SilverStripeBackend', () => {
|
||||
|
||||
backend.delete('http://example.com', deleteData);
|
||||
|
||||
expect(backend.fetch).toBeCalledWith(
|
||||
'http://example.com',
|
||||
{ method: 'delete', body: deleteData, credentials: 'same-origin' }
|
||||
);
|
||||
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
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@ -143,7 +146,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');
|
||||
|
@ -120,6 +120,18 @@ $sprites-64x64-2x-tab-list-hover: -0px -160px 80px 80px;
|
||||
$sprites-64x64-2x-tab-list: -0px -240px 80px 80px;
|
||||
$sprites-64x64-2x-tab-tree-hover: -0px -320px 80px 80px;
|
||||
$sprites-64x64-2x-tab-tree: -0px -400px 80px 80px;
|
||||
$menu-icons-24x24-home: -0px -0px 24px 24px;
|
||||
$menu-icons-24x24-blog: -0px -24px 24px 24px;
|
||||
$menu-icons-24x24-community: -0px -48px 24px 24px;
|
||||
$menu-icons-24x24-db: -0px -72px 24px 24px;
|
||||
$menu-icons-24x24-document: -0px -96px 24px 24px;
|
||||
$menu-icons-24x24-gears: -0px -120px 24px 24px;
|
||||
$menu-icons-24x24-collection: -0px -144px 24px 24px;
|
||||
$menu-icons-24x24-information: -0px -168px 24px 24px;
|
||||
$menu-icons-24x24-network: -0px -192px 24px 24px;
|
||||
$menu-icons-24x24-pencil: -0px -216px 24px 24px;
|
||||
$menu-icons-24x24-picture: -0px -240px 24px 24px;
|
||||
$menu-icons-24x24-pie-chart: -0px -264px 24px 24px;
|
||||
$menu-icons-16x16-home: -0px -0px 16px 16px;
|
||||
$menu-icons-16x16-blog: -0px -16px 16px 16px;
|
||||
$menu-icons-16x16-community: -0px -32px 16px 16px;
|
||||
@ -144,18 +156,6 @@ $menu-icons-16x16-2x-network: -0px -256px 32px 32px;
|
||||
$menu-icons-16x16-2x-pencil: -0px -288px 32px 32px;
|
||||
$menu-icons-16x16-2x-picture: -0px -320px 32px 32px;
|
||||
$menu-icons-16x16-2x-pie-chart: -0px -352px 32px 32px;
|
||||
$menu-icons-24x24-home: -0px -0px 24px 24px;
|
||||
$menu-icons-24x24-blog: -0px -24px 24px 24px;
|
||||
$menu-icons-24x24-community: -0px -48px 24px 24px;
|
||||
$menu-icons-24x24-db: -0px -72px 24px 24px;
|
||||
$menu-icons-24x24-document: -0px -96px 24px 24px;
|
||||
$menu-icons-24x24-gears: -0px -120px 24px 24px;
|
||||
$menu-icons-24x24-collection: -0px -144px 24px 24px;
|
||||
$menu-icons-24x24-information: -0px -168px 24px 24px;
|
||||
$menu-icons-24x24-network: -0px -192px 24px 24px;
|
||||
$menu-icons-24x24-pencil: -0px -216px 24px 24px;
|
||||
$menu-icons-24x24-picture: -0px -240px 24px 24px;
|
||||
$menu-icons-24x24-pie-chart: -0px -264px 24px 24px;
|
||||
$menu-icons-24x24-2x-home: -0px -0px 48px 48px;
|
||||
$menu-icons-24x24-2x-blog: -0px -48px 48px 48px;
|
||||
$menu-icons-24x24-2x-db: -0px -96px 48px 48px;
|
||||
@ -174,10 +174,10 @@ $menu-icons-24x24-2x-pie-chart: -0px -480px 48px 48px;
|
||||
height: nth($sprite, 4);
|
||||
}
|
||||
@function sprite-width($sprite) {
|
||||
@return nth($sprite, 3);
|
||||
@return nth($sprite, 3);
|
||||
}
|
||||
@function sprite-height($sprite) {
|
||||
@return nth($sprite, 4);
|
||||
@return nth($sprite, 4);
|
||||
}
|
||||
@mixin sprite-position($sprite) {
|
||||
$sprite-offset-x: nth($sprite, 1);
|
||||
@ -185,7 +185,7 @@ $menu-icons-24x24-2x-pie-chart: -0px -480px 48px 48px;
|
||||
background-position: $sprite-offset-x $sprite-offset-y;
|
||||
}
|
||||
@mixin sprite($sprite, $display: block) {
|
||||
@include sprite-position($sprite);
|
||||
@include sprite-position($sprite);
|
||||
background-repeat: no-repeat;
|
||||
overflow: hidden;
|
||||
display: $display;
|
||||
@ -213,6 +213,10 @@ $menu-icons-24x24-2x-pie-chart: -0px -480px 48px 48px;
|
||||
background-image: url('../images/sprites/dist/sprite-sprites-64x64-2x.png');
|
||||
}
|
||||
|
||||
.icon-menu-icons-24x24 {
|
||||
background-image: url('../images/sprites/dist/sprite-menu-icons-24x24.png');
|
||||
}
|
||||
|
||||
.icon-menu-icons-16x16 {
|
||||
background-image: url('../images/sprites/dist/sprite-menu-icons-16x16.png');
|
||||
}
|
||||
@ -221,10 +225,7 @@ $menu-icons-24x24-2x-pie-chart: -0px -480px 48px 48px;
|
||||
background-image: url('../images/sprites/dist/sprite-menu-icons-16x16-2x.png');
|
||||
}
|
||||
|
||||
.icon-menu-icons-24x24 {
|
||||
background-image: url('../images/sprites/dist/sprite-menu-icons-24x24.png');
|
||||
}
|
||||
|
||||
.icon-menu-icons-24x24-2x {
|
||||
background-image: url('../images/sprites/dist/sprite-menu-icons-24x24-2x.png');
|
||||
}
|
||||
}
|
@ -32,7 +32,7 @@ $gray-light: #d3d9dd;
|
||||
$gray-lighter: #e8e9ea;
|
||||
// $gray-lightest: #f7f7f9;
|
||||
//
|
||||
// $brand-primary: #0275d8;
|
||||
$brand-primary: #29abe2; //#0275d8;
|
||||
$brand-success: #3fa142;
|
||||
// $brand-info: #5bc0de;
|
||||
// $brand-warning: #f0ad4e;
|
||||
@ -57,28 +57,28 @@ $brand-danger: #D40404;
|
||||
// Control the default styling of most Bootstrap elements by modifying these
|
||||
// variables. Mostly focused on spacing.
|
||||
|
||||
// $spacer: 1rem;
|
||||
// $spacer-x: $spacer;
|
||||
// $spacer-y: $spacer;
|
||||
// $spacers: (
|
||||
// 0: (
|
||||
// x: 0,
|
||||
// y: 0
|
||||
// ),
|
||||
// 1: (
|
||||
// x: $spacer-x,
|
||||
// y: $spacer-y
|
||||
// ),
|
||||
// 2: (
|
||||
// x: ($spacer-x * 1.5),
|
||||
// y: ($spacer-y * 1.5)
|
||||
// ),
|
||||
// 3: (
|
||||
// x: ($spacer-x * 3),
|
||||
// y: ($spacer-y * 3)
|
||||
// )
|
||||
// );
|
||||
// $border-width: 1px;
|
||||
$spacer: 1rem;
|
||||
$spacer-x: $spacer;
|
||||
$spacer-y: $spacer;
|
||||
$spacers: (
|
||||
0: (
|
||||
x: 0,
|
||||
y: 0
|
||||
),
|
||||
1: (
|
||||
x: $spacer-x,
|
||||
y: $spacer-y
|
||||
),
|
||||
2: (
|
||||
x: ($spacer-x * 1.5),
|
||||
y: ($spacer-y * 1.5)
|
||||
),
|
||||
3: (
|
||||
x: ($spacer-x * 3),
|
||||
y: ($spacer-y * 3)
|
||||
)
|
||||
);
|
||||
$border-width: 1px;
|
||||
|
||||
|
||||
// Body
|
||||
@ -86,7 +86,7 @@ $brand-danger: #D40404;
|
||||
// Settings for the `<body>` element.
|
||||
|
||||
// $body-bg: #fff;
|
||||
// $body-color: $gray-dark;
|
||||
$body-color: $gray-dark;
|
||||
|
||||
|
||||
// Links
|
||||
@ -160,7 +160,7 @@ $font-size-h2: 18px; /* 2rem; */
|
||||
$font-size-h3: 16px; /* 1.75rem; */
|
||||
$font-size-h4: 14px; /* 1.5rem; */
|
||||
$font-size-h5: 13px; /* 1.25rem; */
|
||||
$font-size-h6: 1rem;
|
||||
$font-size-h6: 12px; /* 1rem; */
|
||||
|
||||
// $display1-size: 6rem;
|
||||
// $display2-size: 5.5rem;
|
||||
@ -174,7 +174,7 @@ $font-size-h6: 1rem;
|
||||
|
||||
$line-height: 1.538;
|
||||
|
||||
// $headings-margin-bottom: ($spacer / 2);
|
||||
$headings-margin-bottom: $spacer;
|
||||
// $headings-font-family: inherit;
|
||||
// $headings-font-weight: 500;
|
||||
// $headings-line-height: 1.1;
|
||||
@ -183,7 +183,7 @@ $line-height: 1.538;
|
||||
// $lead-font-size: 1.25rem;
|
||||
// $lead-font-weight: 300;
|
||||
//
|
||||
// $text-muted: $gray-light;
|
||||
$text-muted: #7f8b97;
|
||||
//
|
||||
// $abbr-border-color: $gray-light;
|
||||
//
|
||||
|
@ -47,19 +47,13 @@ trait Injectable {
|
||||
* way to access instance methods which don't rely on instance
|
||||
* data (e.g. the custom SilverStripe static handling).
|
||||
*
|
||||
* @param string $className Optional classname (if called on Object directly)
|
||||
* @param string $class Optional classname to create, if the called class should not be used
|
||||
* @return static The singleton instance
|
||||
*/
|
||||
public static function singleton() {
|
||||
$args = func_get_args();
|
||||
|
||||
// Singleton to create should be the calling class if not Object,
|
||||
// otherwise the first parameter
|
||||
$class = get_called_class();
|
||||
if($class === 'Object') {
|
||||
$class = array_shift($args);
|
||||
public static function singleton($class = null) {
|
||||
if(!$class) {
|
||||
$class = get_called_class();
|
||||
}
|
||||
|
||||
return Injector::inst()->get($class);
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,8 @@
|
||||
<?php
|
||||
|
||||
use SilverStripe\Filesystem\Thumbnail;
|
||||
use SilverStripe\Filesystem\ImageManipulation;
|
||||
use SilverStripe\Filesystem\Storage\AssetContainer;
|
||||
use SilverStripe\Filesystem\Storage\AssetStore;
|
||||
|
||||
/**
|
||||
* This class handles the representation of a file on the filesystem within the framework.
|
||||
@ -65,7 +65,7 @@ use SilverStripe\Filesystem\Storage\AssetStore;
|
||||
* @mixin Hierarchy
|
||||
* @mixin Versioned
|
||||
*/
|
||||
class File extends DataObject implements ShortcodeHandler, AssetContainer {
|
||||
class File extends DataObject implements ShortcodeHandler, AssetContainer, Thumbnail {
|
||||
|
||||
use ImageManipulation;
|
||||
|
||||
|
19
filesystem/Thumbnail.php
Normal file
19
filesystem/Thumbnail.php
Normal file
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace SilverStripe\Filesystem;
|
||||
|
||||
/**
|
||||
* An object which may have a thumbnail url
|
||||
*/
|
||||
interface Thumbnail {
|
||||
|
||||
/**
|
||||
* Get a thumbnail for this object
|
||||
*
|
||||
* @param int $width Preferred width of the thumbnail
|
||||
* @param int $height Preferred height of the thumbnail
|
||||
* @return string URL to the thumbnail, if available
|
||||
*/
|
||||
public function ThumbnailURL($width, $height);
|
||||
|
||||
}
|
@ -2,9 +2,8 @@
|
||||
|
||||
namespace SilverStripe\Filesystem\Storage;
|
||||
|
||||
use SilverStripe\Filesystem\Thumbnail;
|
||||
use SilverStripe\Filesystem\ImageManipulation;
|
||||
use SilverStripe\Filesystem\Storage\AssetContainer;
|
||||
use SilverStripe\Filesystem\Storage\AssetStore;
|
||||
use SilverStripe\Model\FieldType\DBComposite;
|
||||
|
||||
use Injector;
|
||||
@ -12,7 +11,6 @@ use AssetField;
|
||||
use File;
|
||||
use Director;
|
||||
use Permission;
|
||||
use ShortcodeHandler;
|
||||
use ValidationResult;
|
||||
use ValidationException;
|
||||
|
||||
@ -26,7 +24,7 @@ use ValidationException;
|
||||
* @package framework
|
||||
* @subpackage filesystem
|
||||
*/
|
||||
class DBFile extends DBComposite implements AssetContainer {
|
||||
class DBFile extends DBComposite implements AssetContainer, Thumbnail {
|
||||
|
||||
use ImageManipulation;
|
||||
|
||||
|
@ -234,6 +234,9 @@ gulp.task('bundle-lib', function bundleLib() {
|
||||
.require('redux-thunk',
|
||||
{ expose: 'redux-thunk' }
|
||||
)
|
||||
.require(`${PATHS.MODULES}/bootstrap/dist/js/umd/collapse.js`,
|
||||
{ expose: 'bootstrap-collapse' }
|
||||
)
|
||||
.require(`${PATHS.ADMIN_JAVASCRIPT_SRC}/components/form/index`,
|
||||
{ expose: 'components/form/index' }
|
||||
)
|
||||
@ -368,6 +371,7 @@ gulp.task('bundle-framework', function bundleBoot() {
|
||||
.external('redux-thunk')
|
||||
.external('redux')
|
||||
.external('silverstripe-component')
|
||||
.external('bootstrap-collapse')
|
||||
.bundle()
|
||||
.on('update', bundleBoot)
|
||||
.on('error', notify.onError({ message: `${bundleFileName}: <%= error.message %>` }))
|
||||
|
@ -24,6 +24,19 @@ function show(pageShow) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the passed route applies to the current location.
|
||||
*
|
||||
* @param string route - The route to check.
|
||||
*
|
||||
* @return boolean
|
||||
*/
|
||||
function routeAppliesToCurrentLocation(route) {
|
||||
const r = new page.Route(route);
|
||||
return r.match(page.current, {});
|
||||
}
|
||||
|
||||
page.show = show(page.show);
|
||||
page.routeAppliesToCurrentLocation = routeAppliesToCurrentLocation;
|
||||
|
||||
export default page;
|
||||
|
@ -31,7 +31,7 @@ class ChangeSet extends DataObject {
|
||||
|
||||
private static $db = array(
|
||||
'Name' => 'Varchar',
|
||||
'State' => "Enum('open,published,reverted')",
|
||||
'State' => "Enum('open,published,reverted','open')",
|
||||
);
|
||||
|
||||
private static $has_many = array(
|
||||
@ -46,6 +46,21 @@ class ChangeSet extends DataObject {
|
||||
'Owner' => 'Member',
|
||||
);
|
||||
|
||||
private static $casting = array(
|
||||
'Description' => 'Text',
|
||||
);
|
||||
|
||||
/**
|
||||
* List of classes to set apart in description
|
||||
*
|
||||
* @config
|
||||
* @var array
|
||||
*/
|
||||
private static $important_classes = array(
|
||||
'SiteTree',
|
||||
'File',
|
||||
);
|
||||
|
||||
/**
|
||||
* Default permission to require for publishers.
|
||||
* Publishers must either be able to use the campaign admin, or have all admin access.
|
||||
|
@ -2,6 +2,9 @@
|
||||
|
||||
// namespace SilverStripe\Framework\Model\Versioning
|
||||
|
||||
use SilverStripe\Filesystem\Thumbnail;
|
||||
|
||||
|
||||
/**
|
||||
* A single line in a changeset
|
||||
*
|
||||
@ -12,7 +15,7 @@
|
||||
* @method ManyManyList References() List of implicit items required by this change
|
||||
* @method ChangeSet ChangeSet()
|
||||
*/
|
||||
class ChangeSetItem extends DataObject {
|
||||
class ChangeSetItem extends DataObject implements Thumbnail {
|
||||
|
||||
const EXPLICITLY = 'explicitly';
|
||||
|
||||
@ -62,6 +65,33 @@ class ChangeSetItem extends DataObject {
|
||||
)
|
||||
);
|
||||
|
||||
public function getTitle() {
|
||||
// Get title of modified object
|
||||
$object = $this->getObjectLatestVersion();
|
||||
if($object) {
|
||||
return $object->getTitle();
|
||||
}
|
||||
return $this->i18n_singular_name() . ' #' . $this->ID;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Get a thumbnail for this object
|
||||
*
|
||||
* @param int $width Preferred width of the thumbnail
|
||||
* @param int $height Preferred height of the thumbnail
|
||||
* @return string URL to the thumbnail, if available
|
||||
*/
|
||||
public function ThumbnailURL($width, $height) {
|
||||
$object = $this->getObjectLatestVersion();
|
||||
if($object instanceof Thumbnail) {
|
||||
return $object->ThumbnailURL($width, $height);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the type of change: none, created, deleted, modified, manymany
|
||||
*
|
||||
@ -99,10 +129,19 @@ class ChangeSetItem extends DataObject {
|
||||
* @param string $stage
|
||||
* @return Versioned|DataObject
|
||||
*/
|
||||
private function getObjectInStage($stage) {
|
||||
protected function getObjectInStage($stage) {
|
||||
return Versioned::get_by_stage($this->ObjectClass, $stage)->byID($this->ObjectID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find latest version of this object
|
||||
*
|
||||
* @return Versioned|DataObject
|
||||
*/
|
||||
protected function getObjectLatestVersion() {
|
||||
return Versioned::get_latest_version($this->ObjectClass, $this->ObjectID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all implicit objects for this change
|
||||
*
|
||||
@ -195,7 +234,7 @@ class ChangeSetItem extends DataObject {
|
||||
public function canRevert($member) {
|
||||
// Just get the best version as this object may not even exist on either stage anymore.
|
||||
/** @var Versioned|DataObject $object */
|
||||
$object = Versioned::get_latest_version($this->ObjectClass, $this->ObjectID);
|
||||
$object = $this->getObjectLatestVersion();
|
||||
if(!$object) {
|
||||
return false;
|
||||
}
|
||||
@ -276,5 +315,4 @@ class ChangeSetItem extends DataObject {
|
||||
// Default permissions
|
||||
return (bool)Permission::checkMember($member, ChangeSet::config()->required_permission);
|
||||
}
|
||||
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user