API Implement campaign list view

This commit is contained in:
Damian Mooyman 2016-04-12 10:24:16 +12:00
parent 05973cee55
commit 5900893753
35 changed files with 1032 additions and 135 deletions

View File

@ -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);
}
}
/**

View File

@ -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());

View File

@ -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');

View 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;

View 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;

View 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;

View File

@ -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'.

View File

@ -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,
};

View File

@ -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;

View File

@ -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,
}),
};

View File

@ -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;

View File

@ -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;
}
}
}
}

View File

@ -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);

View 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;

View 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);

View 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;

View File

@ -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;
//}
}

View File

@ -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);
}

View File

@ -0,0 +1,3 @@
export default {
SET_CAMPAIGN_ACTIVE_CHANGESET: 'SET_CAMPAIGN_ACTIVE_CHANGESET',
};

View 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 },
};
}

View 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;

View File

@ -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',

View File

@ -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
*

View File

@ -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,

View File

@ -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');

View File

@ -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');
}
}

View File

@ -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;
//

View File

@ -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);
}
}

View File

@ -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
View 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);
}

View File

@ -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;

View File

@ -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 %>` }))

View File

@ -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;

View File

@ -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.

View File

@ -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);
}
}