diff --git a/admin/code/CampaignAdmin.php b/admin/code/CampaignAdmin.php index 257919d98..2e6d50e3c 100644 --- a/admin/code/CampaignAdmin.php +++ b/admin/code/CampaignAdmin.php @@ -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); + } } /** diff --git a/admin/javascript/src/boot/index.js b/admin/javascript/src/boot/index.js index ad902b7ab..4a22f3c8e 100644 --- a/admin/javascript/src/boot/index.js +++ b/admin/javascript/src/boot/index.js @@ -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()); diff --git a/admin/javascript/src/bundles/lib.js b/admin/javascript/src/bundles/lib.js index 7e39fd17f..73a61d761 100644 --- a/admin/javascript/src/bundles/lib.js +++ b/admin/javascript/src/bundles/lib.js @@ -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'); diff --git a/admin/javascript/src/components/accordion/group.js b/admin/javascript/src/components/accordion/group.js new file mode 100644 index 000000000..10cc4c550 --- /dev/null +++ b/admin/javascript/src/components/accordion/group.js @@ -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 ( +
+ +
+ {this.props.children} +
+
+ ); + } +} +export default AccordionGroup; diff --git a/admin/javascript/src/components/accordion/index.js b/admin/javascript/src/components/accordion/index.js new file mode 100644 index 000000000..d91119064 --- /dev/null +++ b/admin/javascript/src/components/accordion/index.js @@ -0,0 +1,11 @@ +import React from 'react'; +import SilverStripeComponent from 'silverstripe-component'; + +class Accordion extends SilverStripeComponent { + render() { + return ( +
{this.props.children}
+ ); + } +} +export default Accordion; diff --git a/admin/javascript/src/components/accordion/item.js b/admin/javascript/src/components/accordion/item.js new file mode 100644 index 000000000..17b8efb5f --- /dev/null +++ b/admin/javascript/src/components/accordion/item.js @@ -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 ( + + {this.props.children} + + ); + } +} +export default AccordionItem; diff --git a/admin/javascript/src/components/form-builder/README.md b/admin/javascript/src/components/form-builder/README.md index f91c2237e..624ed76fd 100644 --- a/admin/javascript/src/components/form-builder/README.md +++ b/admin/javascript/src/components/form-builder/README.md @@ -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'. diff --git a/admin/javascript/src/components/form-builder/index.js b/admin/javascript/src/components/form-builder/index.js index 68662a81b..0f841f376 100644 --- a/admin/javascript/src/components/form-builder/index.js +++ b/admin/javascript/src/components/form-builder/index.js @@ -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 ..." below this.setState({ isFetching: false }); this.props.actions.setSchema(json); }); - this.setState({ isFetching: true }); + // TODO Enable once 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 ; }); } @@ -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, }; diff --git a/admin/javascript/src/components/grid-field/cell.js b/admin/javascript/src/components/grid-field/cell.js index 528f073c9..b118dd985 100644 --- a/admin/javascript/src/components/grid-field/cell.js +++ b/admin/javascript/src/components/grid-field/cell.js @@ -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 ( -
{this.props.children}
+
{this.props.children}
); } + + 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; diff --git a/admin/javascript/src/components/grid-field/index.js b/admin/javascript/src/components/grid-field/index.js index 90b391946..0b6eb5bcf 100644 --- a/admin/javascript/src/components/grid-field/index.js +++ b/admin/javascript/src/components/grid-field/index.js @@ -41,6 +41,8 @@ class GridField extends SilverStripeComponent { render() { const records = this.props.records; + const handleDrillDown = this.props.data.handleDrillDown; + if (!records) { return
; } @@ -55,10 +57,21 @@ class GridField extends SilverStripeComponent { const header = {headerCells.concat(actionPlaceholder)}; 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 {val}; + const cellProps = { + handleDrillDown: handleDrillDown ? (event) => handleDrillDown(event, record) : null, + className: handleDrillDown ? 'grid-field-cell-component--drillable' : '', + key: j, + width: column.width, + }; + return ( + + {val} + + ); }); const rowActions = ( @@ -78,7 +91,16 @@ class GridField extends SilverStripeComponent { ); - return {cells.concat(rowActions)}; + const rowProps = { + key: i, + className: handleDrillDown ? 'grid-field-row-component--drillable' : '', + }; + + return ( + + {cells.concat(rowActions)} + + ); }); return ( @@ -112,6 +134,7 @@ GridField.propTypes = { recordType: React.PropTypes.string.isRequired, headerColumns: React.PropTypes.array, collectionReadEndpoint: React.PropTypes.object, + handleDrillDown: React.PropTypes.func, }), }; diff --git a/admin/javascript/src/components/grid-field/row.js b/admin/javascript/src/components/grid-field/row.js index 21bc8084e..83d65ca89 100644 --- a/admin/javascript/src/components/grid-field/row.js +++ b/admin/javascript/src/components/grid-field/row.js @@ -4,11 +4,9 @@ import SilverStripeComponent from 'silverstripe-component'; class GridFieldRowComponent extends SilverStripeComponent { render() { - return ( -
  • {this.props.children}
  • - ); + const className = `grid-field-row-component [ list-group-item ] ${this.props.className}`; + return
  • {this.props.children}
  • ; } - } export default GridFieldRowComponent; diff --git a/admin/javascript/src/components/grid-field/styles.scss b/admin/javascript/src/components/grid-field/styles.scss index 4c694537a..ce903a999 100644 --- a/admin/javascript/src/components/grid-field/styles.scss +++ b/admin/javascript/src/components/grid-field/styles.scss @@ -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; + } + } } } diff --git a/admin/javascript/src/sections/campaign-admin/controller.js b/admin/javascript/src/sections/campaign-admin/controller.js index 130cb121e..bc9732068 100644 --- a/admin/javascript/src/sections/campaign-admin/controller.js +++ b/admin/javascript/src/sections/campaign-admin/controller.js @@ -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 ( -
    - - - +
    +
    + + + +
    ); } + /** + * Renders a list of items in a Campaign. + * + * @return object + */ + renderItemListView() { + const props = { + campaignId: this.props.campaignId, + itemListViewEndpoint: this.props.config.itemListViewEndpoint, + }; + + return ( + + ); + } + + renderDetailEditView() { + return

    Edit

    ; + } + + /** + * 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 ; + } + + return ; + } + + /** + * 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); diff --git a/admin/javascript/src/sections/campaign-admin/item.js b/admin/javascript/src/sections/campaign-admin/item.js new file mode 100644 index 000000000..8ee05e458 --- /dev/null +++ b/admin/javascript/src/sections/campaign-admin/item.js @@ -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 = Draft; + break; + case 'modified': + badge = Modified; + break; + case 'deleted': + badge = Removed; + break; + case 'none': + default: + badge = Already published; + break; + } + + // Linked items + let links = [lk] 3 links; + + // Thumbnail + if (item.Thumbnail) { + thumbnail = ; + } + + + return ( +
    + {thumbnail} +

    {item.Title}

    + {links} + {badge} +
    + ); + } +} +export default CampaignItem; diff --git a/admin/javascript/src/sections/campaign-admin/list.js b/admin/javascript/src/sections/campaign-admin/list.js new file mode 100644 index 000000000..5c86d20be --- /dev/null +++ b/admin/javascript/src/sections/campaign-admin/list.js @@ -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( + + + + ); + }); + + // Merge into group + accordionGroups.push( + + {accordionItems} + + ); + }); + + return ( +
    +
    + +
    + + {accordionGroups} + +
    +
    + { previewUrl && } +
    + ); + } + + + /** + * 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); diff --git a/admin/javascript/src/sections/campaign-admin/preview.js b/admin/javascript/src/sections/campaign-admin/preview.js new file mode 100644 index 000000000..e5e22c991 --- /dev/null +++ b/admin/javascript/src/sections/campaign-admin/preview.js @@ -0,0 +1,15 @@ +import React from 'react'; +import SilverStripeComponent from 'silverstripe-component'; + +class CampaignPreview extends SilverStripeComponent { + + render() { + return ( +
    + +
    + ); + } +} + +export default CampaignPreview; diff --git a/admin/javascript/src/sections/campaign-admin/styles.scss b/admin/javascript/src/sections/campaign-admin/styles.scss index 956a32f21..f6f517c97 100644 --- a/admin/javascript/src/sections/campaign-admin/styles.scss +++ b/admin/javascript/src/sections/campaign-admin/styles.scss @@ -1,3 +1,210 @@ .CampaignAdmin { - overflow-y: auto; -} \ No newline at end of file + 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; + //} +} diff --git a/admin/javascript/src/silverstripe-backend.js b/admin/javascript/src/silverstripe-backend.js index 5c7c4e609..d789f4ba8 100644 --- a/admin/javascript/src/silverstripe-backend.js +++ b/admin/javascript/src/silverstripe-backend.js @@ -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); } diff --git a/admin/javascript/src/state/campaign/action-types.js b/admin/javascript/src/state/campaign/action-types.js new file mode 100644 index 000000000..2aa52e7dc --- /dev/null +++ b/admin/javascript/src/state/campaign/action-types.js @@ -0,0 +1,3 @@ +export default { + SET_CAMPAIGN_ACTIVE_CHANGESET: 'SET_CAMPAIGN_ACTIVE_CHANGESET', +}; diff --git a/admin/javascript/src/state/campaign/actions.js b/admin/javascript/src/state/campaign/actions.js new file mode 100644 index 000000000..5d2700895 --- /dev/null +++ b/admin/javascript/src/state/campaign/actions.js @@ -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 }, + }; +} + diff --git a/admin/javascript/src/state/campaign/reducer.js b/admin/javascript/src/state/campaign/reducer.js new file mode 100644 index 000000000..5bad3aeb6 --- /dev/null +++ b/admin/javascript/src/state/campaign/reducer.js @@ -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; diff --git a/admin/javascript/src/state/records/action-types.js b/admin/javascript/src/state/records/action-types.js index 186cae2f2..684d468be 100644 --- a/admin/javascript/src/state/records/action-types.js +++ b/admin/javascript/src/state/records/action-types.js @@ -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', diff --git a/admin/javascript/src/state/records/actions.js b/admin/javascript/src/state/records/actions.js index 44de8aa15..0fa7b8b20 100644 --- a/admin/javascript/src/state/records/actions.js +++ b/admin/javascript/src/state/records/actions.js @@ -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 * diff --git a/admin/javascript/src/state/records/reducer.js b/admin/javascript/src/state/records/reducer.js index 7babf062b..16e1190c9 100644 --- a/admin/javascript/src/state/records/reducer.js +++ b/admin/javascript/src/state/records/reducer.js @@ -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, diff --git a/admin/javascript/src/tests/silverstripe-backend-test.js b/admin/javascript/src/tests/silverstripe-backend-test.js index 997212006..918a7b1ee 100644 --- a/admin/javascript/src/tests/silverstripe-backend-test.js +++ b/admin/javascript/src/tests/silverstripe-backend-test.js @@ -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'); diff --git a/admin/scss/_spritey.scss b/admin/scss/_spritey.scss index 65fa7596f..61ff060b2 100644 --- a/admin/scss/_spritey.scss +++ b/admin/scss/_spritey.scss @@ -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'); -} +} \ No newline at end of file diff --git a/admin/scss/bootstrap/_variables.scss b/admin/scss/bootstrap/_variables.scss index 79bf69c24..ad0c9a66d 100644 --- a/admin/scss/bootstrap/_variables.scss +++ b/admin/scss/bootstrap/_variables.scss @@ -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 `` 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; // diff --git a/core/Injectable.php b/core/Injectable.php index 2a280bcf0..37c6fbf15 100644 --- a/core/Injectable.php +++ b/core/Injectable.php @@ -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); } } diff --git a/filesystem/File.php b/filesystem/File.php index 6e273cbe5..e605ccc5d 100644 --- a/filesystem/File.php +++ b/filesystem/File.php @@ -1,8 +1,8 @@ ` })) diff --git a/javascript/src/router.js b/javascript/src/router.js index 41559cd5c..967360eac 100644 --- a/javascript/src/router.js +++ b/javascript/src/router.js @@ -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; diff --git a/model/versioning/ChangeSet.php b/model/versioning/ChangeSet.php index 9d1e25b77..8a3716098 100644 --- a/model/versioning/ChangeSet.php +++ b/model/versioning/ChangeSet.php @@ -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. diff --git a/model/versioning/ChangeSetItem.php b/model/versioning/ChangeSetItem.php index 76a7173a6..071e2b907 100644 --- a/model/versioning/ChangeSetItem.php +++ b/model/versioning/ChangeSetItem.php @@ -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); } - }