Merge pull request #6119 from open-sausages/pulls/4.0/assets-custom-actions

API Update form schema to be more responsive to dynamic form changes
This commit is contained in:
Damian Mooyman 2016-10-07 17:08:58 +13:00 committed by GitHub
commit 89c88f0f60
13 changed files with 2139 additions and 2050 deletions

View File

@ -695,12 +695,15 @@ class Controller extends RequestHandler implements TemplateGlobalProvider {
* *
* Caution: All parameters are expected to be URI-encoded already. * Caution: All parameters are expected to be URI-encoded already.
* *
* @param string * @param string|array $arg,.. One or more link segments, or list of link segments as an array
*
* @return string * @return string
*/ */
public static function join_links() { public static function join_links($arg = null) {
$args = func_get_args(); if (func_num_args() === 1 && is_array($arg)) {
$args = $arg;
} else {
$args = func_get_args();
}
$result = ""; $result = "";
$queryargs = array(); $queryargs = array();
$fragmentIdentifier = null; $fragmentIdentifier = null;

View File

@ -1424,7 +1424,7 @@ class FormField extends RequestHandler {
* @return array * @return array
*/ */
public function getSchemaData() { public function getSchemaData() {
return array_merge($this->getSchemaDataDefaults(), $this->schemaData); return array_replace_recursive($this->getSchemaDataDefaults(), $this->schemaData);
} }
/** /**

View File

@ -17,18 +17,16 @@ class FormSchema {
* Gets the schema for this form as a nested array. * Gets the schema for this form as a nested array.
* *
* @param Form $form * @param Form $form
* @param string $schemaLink Link to get this schema
* @return array * @return array
*/ */
public function getSchema(Form $form) { public function getSchema(Form $form, $schemaLink) {
$request = $form->getController()->getRequest();
$schema = [ $schema = [
'name' => $form->getName(), 'name' => $form->getName(),
'id' => $form->FormName(), 'id' => $form->FormName(),
'action' => $form->FormAction(), 'action' => $form->FormAction(),
'method' => $form->FormMethod(), 'method' => $form->FormMethod(),
// @todo Not really reliable. Refactor into action on $this->Link('schema') 'schema_url' => $schemaLink,
'schema_url' => $request->getURL(),
'attributes' => $form->getAttributes(), 'attributes' => $form->getAttributes(),
'data' => [], 'data' => [],
'fields' => [], 'fields' => [],
@ -62,7 +60,10 @@ class FormSchema {
]; ];
// flattened nested fields are returned, rather than only top level fields. // flattened nested fields are returned, rather than only top level fields.
$state['fields'] = $this->getFieldStates($form->Fields()); $state['fields'] = array_merge(
$this->getFieldStates($form->Fields()),
$this->getFieldStates($form->Actions())
);
if($form->Message()) { if($form->Message()) {
$state['messages'][] = [ $state['messages'][] = [
@ -76,6 +77,7 @@ class FormSchema {
protected function getFieldStates($fields) { protected function getFieldStates($fields) {
$states = []; $states = [];
/** @var FormField $field */
foreach ($fields as $field) { foreach ($fields as $field) {
$states[] = $field->getSchemaState(); $states[] = $field->getSchemaState();

View File

@ -5,6 +5,7 @@ namespace SilverStripe\ORM\Versioning;
use SilverStripe\Admin\CMSPreviewable; use SilverStripe\Admin\CMSPreviewable;
use SilverStripe\Assets\Thumbnail; use SilverStripe\Assets\Thumbnail;
use SilverStripe\Control\Controller; use SilverStripe\Control\Controller;
use SilverStripe\ORM\ArrayList;
use SilverStripe\ORM\DataList; use SilverStripe\ORM\DataList;
use SilverStripe\ORM\DataObject; use SilverStripe\ORM\DataObject;
use SilverStripe\ORM\ManyManyList; use SilverStripe\ORM\ManyManyList;
@ -109,8 +110,8 @@ class ChangeSetItem extends DataObject implements Thumbnail {
/** /**
* Get the type of change: none, created, deleted, modified, manymany * Get the type of change: none, created, deleted, modified, manymany
*
* @return string * @return string
* @throws UnexpectedDataException
*/ */
public function getChangeType() { public function getChangeType() {
if(!class_exists($this->ObjectClass)) { if(!class_exists($this->ObjectClass)) {
@ -146,7 +147,8 @@ class ChangeSetItem extends DataObject implements Thumbnail {
* Find version of this object in the given stage * Find version of this object in the given stage
* *
* @param string $stage * @param string $stage
* @return Versioned|DataObject * @return DataObject|Versioned
* @throws UnexpectedDataException
*/ */
protected function getObjectInStage($stage) { protected function getObjectInStage($stage) {
if(!class_exists($this->ObjectClass)) { if(!class_exists($this->ObjectClass)) {
@ -158,8 +160,8 @@ class ChangeSetItem extends DataObject implements Thumbnail {
/** /**
* Find latest version of this object * Find latest version of this object
* * @return DataObject|Versioned
* @return Versioned|DataObject * @throws UnexpectedDataException
*/ */
protected function getObjectLatestVersion() { protected function getObjectLatestVersion() {
if(!class_exists($this->ObjectClass)) { if(!class_exists($this->ObjectClass)) {
@ -177,14 +179,22 @@ class ChangeSetItem extends DataObject implements Thumbnail {
public function findReferenced() { public function findReferenced() {
if($this->getChangeType() === ChangeSetItem::CHANGE_DELETED) { if($this->getChangeType() === ChangeSetItem::CHANGE_DELETED) {
// If deleted from stage, need to look at live record // If deleted from stage, need to look at live record
return $this->getObjectInStage(Versioned::LIVE)->findOwners(false); $record = $this->getObjectInStage(Versioned::LIVE);
if ($record) {
return $record->findOwners(false);
}
} else { } else {
// If changed on stage, look at owned objects there // If changed on stage, look at owned objects there
return $this->getObjectInStage(Versioned::DRAFT)->findOwned()->filterByCallback(function ($owned) { $record = $this->getObjectInStage(Versioned::DRAFT);
/** @var Versioned|DataObject $owned */ if ($record) {
return $owned->stagesDiffer(Versioned::DRAFT, Versioned::LIVE); return $record->findOwned()->filterByCallback(function ($owned) {
}); /** @var Versioned|DataObject $owned */
return $owned->stagesDiffer(Versioned::DRAFT, Versioned::LIVE);
});
}
} }
// Empty set
return new ArrayList();
} }
/** /**

View File

@ -331,24 +331,25 @@ return e?e.id:null}},{key:"componentDidMount",value:function r(){this.fetch()}},
var e=this,t=arguments.length<=0||void 0===arguments[0]||arguments[0],n=arguments.length<=1||void 0===arguments[1]||arguments[1],i=[] var e=this,t=arguments.length<=0||void 0===arguments[0]||arguments[0],n=arguments.length<=1||void 0===arguments[1]||arguments[1],i=[]
return this.state.isFetching===!0?this.formSchemaPromise:(t===!0&&i.push("schema"),n===!0&&i.push("state"),this.formSchemaPromise=(0,O["default"])(this.props.schemaUrl,{headers:{"X-FormSchema-Request":i.join() return this.state.isFetching===!0?this.formSchemaPromise:(t===!0&&i.push("schema"),n===!0&&i.push("state"),this.formSchemaPromise=(0,O["default"])(this.props.schemaUrl,{headers:{"X-FormSchema-Request":i.join()
},credentials:"same-origin"}).then(function(e){return e.json()}).then(function(t){var n=c({},{id:t.id,schema:t.schema}),i=c({},t.state) },credentials:"same-origin"}).then(function(e){return e.json()}).then(function(t){var n=c({},{id:t.id,schema:t.schema}),i=c({},t.state)
if("undefined"!=typeof n.id){var r={SecurityID:e.props.config.SecurityID} "undefined"!=typeof n.id&&!function(){var t={SecurityID:e.props.config.SecurityID}
n.schema.actions.length>0&&(r[n.schema.actions[0].name]=1),e.submitApi=k["default"].createEndpointFetcher({url:n.schema.attributes.action,method:n.schema.attributes.method,defaultData:r}),e.props.schemaActions.setSchema(n) e.submitApi=function(){var i=k["default"].createEndpointFetcher({url:n.schema.attributes.action,method:n.schema.attributes.method,defaultData:t})
return i.apply(void 0,arguments).then(function(t){if(t.schema){var n=c({},{id:t.id,schema:t.schema})
e.props.schemaActions.setSchema(n)}return t})},e.props.schemaActions.setSchema(n)}(),"undefined"!=typeof i.id&&e.props.formActions.addForm(i)}),this.formSchemaPromise)}},{key:"handleFieldUpdate",value:function p(e,t,n){
"function"==typeof n?n(this.getFormId(),this.props.formActions.updateField):this.props.formActions.updateField(this.getFormId(),t)}},{key:"handleAction",value:function m(e,t){this.props.formActions.setSubmitAction(this.getFormId(),t),
"function"==typeof this.props.handleAction&&this.props.handleAction(e,t,this.getFieldValues())}},{key:"handleSubmit",value:function g(e){var t=this,n=this.getFieldValues(),i=function r(){return t.props.formActions.submitForm(t.submitApi,t.getFormId(),n)
}"undefined"!=typeof i.id&&e.props.formActions.addForm(i)}),this.formSchemaPromise)}},{key:"handleFieldUpdate",value:function p(e,t,n){"function"==typeof n?n(this.getFormId(),this.props.formActions.updateField):this.props.formActions.updateField(this.getFormId(),t) }
return"undefined"!=typeof this.props.handleSubmit?this.props.handleSubmit(e,n,i):(e.preventDefault(),i())}},{key:"getFieldValues",value:function v(){var e=this,t=this.props.schemas[this.props.schemaUrl],n=t.state?t.state.fields:t.schema.fields,i=this.getSubmitAction(),r={}
}},{key:"handleAction",value:function m(e,t){this.props.formActions.setSubmitAction(this.getFormId(),t),"function"==typeof this.props.handleAction&&this.props.handleAction(e,t,this.getFieldValues())}},{
key:"handleSubmit",value:function g(e){var t=this,n=this.getFieldValues(),i=function r(){return t.props.formActions.submitForm(t.submitApi,t.getFormId(),n)}
return"undefined"!=typeof this.props.handleSubmit?this.props.handleSubmit(e,n,i):(e.preventDefault(),i())}},{key:"getFieldValues",value:function v(){var e=this,t=this.props.schemas[this.props.schemaUrl],n=t.state?t.state.fields:t.schema.fields
return this.props.form[this.getFormId()].fields.reduce(function(t,i){var r=e.findField(n,i.id) return i&&(r[i]=1),this.props.form[this.getFormId()].fields.reduce(function(t,i){var r=e.findField(n,i.id)
return r?c({},t,o({},r.name,i.value)):t},{})}},{key:"findField",value:function y(e,t){var n=null return r?c({},t,o({},r.name,i.value)):t},r)}},{key:"getSubmitAction",value:function y(){return this.props.form[this.getFormId()].submitAction}},{key:"findField",value:function b(e,t){var n=null
if(!e)return n if(!e)return n
n=e.find(function(e){return e.id===t}) n=e.find(function(e){return e.id===t})
var i=!0,r=!1,o=void 0 var i=!0,r=!1,o=void 0
try{for(var a=e[Symbol.iterator](),s;!(i=(s=a.next()).done);i=!0){var l=s.value try{for(var a=e[Symbol.iterator](),s;!(i=(s=a.next()).done);i=!0){var l=s.value
if(n)break if(n)break
n=this.findField(l.children,t)}}catch(u){r=!0,o=u}finally{try{!i&&a["return"]&&a["return"]()}finally{if(r)throw o}}return n}},{key:"buildComponent",value:function b(e){var t=arguments.length<=1||void 0===arguments[1]?{}:arguments[1],n=null!==e.component?x["default"].getComponentByName(e.component):x["default"].getComponentByDataType(e.type) n=this.findField(l.children,t)}}catch(u){r=!0,o=u}finally{try{!i&&a["return"]&&a["return"]()}finally{if(r)throw o}}return n}},{key:"buildComponent",value:function w(e){var t=arguments.length<=1||void 0===arguments[1]?{}:arguments[1],n=null!==e.component?x["default"].getComponentByName(e.component):x["default"].getComponentByDataType(e.type)
if(null===n)return null if(null===n)return null
@ -356,27 +357,27 @@ if(null!==e.component&&void 0===n)throw Error("Component not found in injector:
var i=c({},e,t) var i=c({},e,t)
null===i.value&&delete i.value null===i.value&&delete i.value
var r=this.props.createFn var r=this.props.createFn
return"function"==typeof r?r(n,i):h["default"].createElement(n,c({key:i.id},i))}},{key:"mapFieldsToComponents",value:function w(e){var t=this return"function"==typeof r?r(n,i):h["default"].createElement(n,c({key:i.id},i))}},{key:"mapFieldsToComponents",value:function _(e){var t=this
return e.map(function(e){var n={onChange:t.handleFieldUpdate} return e.map(function(e){var n={onChange:t.handleFieldUpdate}
return e.children&&(n.children=t.mapFieldsToComponents(e.children)),t.buildComponent(e,n)})}},{key:"mapActionsToComponents",value:function _(e){var t=this,n=this.props.form[this.getFormId()] return e.children&&(n.children=t.mapFieldsToComponents(e.children)),t.buildComponent(e,n)})}},{key:"mapActionsToComponents",value:function C(e){var t=this,n=this.props.form[this.getFormId()]
return e.map(function(e){var i=n&&n.submitting&&n.submitAction===e.name,r={handleClick:t.handleAction,loading:i,disabled:i||e.disabled} return e.map(function(e){var i=n&&n.submitting&&n.submitAction===e.name,r={handleClick:t.handleAction,loading:i,disabled:i||e.disabled}
return e.children&&(r.children=t.mapActionsToComponents(e.children)),t.buildComponent(e,r)})}},{key:"mergeFieldData",value:function C(e,t){return"undefined"==typeof t?e:I["default"].recursive(!0,e,{data:t.data, return e.children&&(r.children=t.mapActionsToComponents(e.children)),t.buildComponent(e,r)})}},{key:"mergeFieldData",value:function T(e,t){return"undefined"==typeof t?e:I["default"].recursive(!0,e,{data:t.data,
source:t.source,messages:t.messages,valid:t.valid,value:t.value})}},{key:"removeForm",value:function T(e){this.props.formActions.removeForm(e)}},{key:"getFieldData",value:function P(e,t){var n=this source:t.source,messages:t.messages,valid:t.valid,value:t.value})}},{key:"removeForm",value:function P(e){this.props.formActions.removeForm(e)}},{key:"getFieldData",value:function S(e,t){var n=this
return e&&t&&t.fields?e.map(function(e){var i=t.fields.find(function(t){return t.id===e.id}),r=n.mergeFieldData(e,i) return e&&t&&t.fields?e.map(function(e){var i=t.fields.find(function(t){return t.id===e.id}),r=n.mergeFieldData(e,i)
return e.children&&(r.children=n.getFieldData(e.children,t)),r}):e}},{key:"render",value:function S(){var e=this.getFormId() return e.children?c({},r,{children:n.getFieldData(e.children,t)}):r}):e}},{key:"render",value:function j(){var e=this.getFormId()
if(!e)return null if(!e)return null
var t=this.getFormSchema(),n=this.props.form[e] var t=this.getFormSchema(),n=this.props.form[e]
if(!t||!t.schema)return null if(!t||!t.schema)return null
var i=c({},t.schema.attributes,{className:t.schema.attributes["class"],encType:t.schema.attributes.enctype}) var i=c({},t.schema.attributes,{className:t.schema.attributes["class"],encType:t.schema.attributes.enctype})
delete i["class"],delete i.enctype delete i["class"],delete i.enctype
var r=this.getFieldData(t.schema.fields,n),o={actions:t.schema.actions,attributes:i,componentWillUnmount:this.removeForm,data:t.schema.data,fields:r,formId:e,handleSubmit:this.handleSubmit,mapActionsToComponents:this.mapActionsToComponents, var r=this.getFieldData(t.schema.fields,n),o=this.getFieldData(t.schema.actions,n),a={actions:o,attributes:i,componentWillUnmount:this.removeForm,data:t.schema.data,fields:r,formId:e,handleSubmit:this.handleSubmit,
mapFieldsToComponents:this.mapFieldsToComponents} mapActionsToComponents:this.mapActionsToComponents,mapFieldsToComponents:this.mapFieldsToComponents}
return h["default"].createElement(E["default"],o)}}]),t}(C["default"]) return h["default"].createElement(E["default"],a)}}]),t}(C["default"])
D.propTypes={config:h["default"].PropTypes.object,createFn:h["default"].PropTypes.func,form:h["default"].PropTypes.object.isRequired,formActions:h["default"].PropTypes.object.isRequired,handleSubmit:h["default"].PropTypes.func, D.propTypes={config:h["default"].PropTypes.object,createFn:h["default"].PropTypes.func,form:h["default"].PropTypes.object.isRequired,formActions:h["default"].PropTypes.object.isRequired,handleSubmit:h["default"].PropTypes.func,
handleAction:h["default"].PropTypes.func,schemas:h["default"].PropTypes.object.isRequired,schemaActions:h["default"].PropTypes.object.isRequired,schemaUrl:h["default"].PropTypes.string.isRequired},t["default"]=(0, handleAction:h["default"].PropTypes.func,schemas:h["default"].PropTypes.object.isRequired,schemaActions:h["default"].PropTypes.object.isRequired,schemaUrl:h["default"].PropTypes.string.isRequired},t["default"]=(0,
m.connect)(u,d)(D)},function(e,t){e.exports=ReactRedux},,function(e,t,n){"use strict" m.connect)(u,d)(D)},function(e,t){e.exports=ReactRedux},,function(e,t,n){"use strict"
function i(e){return function(t){t({type:u.ACTION_TYPES.REMOVE_FORM,payload:{formId:e}})}}function r(e,t){return function(n){n({type:u.ACTION_TYPES.UPDATE_FIELD,payload:{formId:e,updates:t}})}}function o(e){ function i(e){return function(t){t({type:u.ACTION_TYPES.REMOVE_FORM,payload:{formId:e}})}}function r(e,t){return function(n){n({type:u.ACTION_TYPES.UPDATE_FIELD,payload:{formId:e,updates:t}})}}function o(e){
return function(t){t({type:u.ACTION_TYPES.ADD_FORM,payload:{formState:e}})}}function a(e,t,n){return function(i){var r={"X-Formschema-Request":"state","X-Requested-With":"XMLHttpRequest"} return function(t){t({type:u.ACTION_TYPES.ADD_FORM,payload:{formState:e}})}}function a(e,t,n){return function(i){var r={"X-Formschema-Request":"schema,state","X-Requested-With":"XMLHttpRequest"}
return i({type:u.ACTION_TYPES.SUBMIT_FORM_REQUEST,payload:{formId:t}}),e(l({ID:t},n),r).then(function(e){return i({type:u.ACTION_TYPES.SUBMIT_FORM_SUCCESS,payload:{response:e}}),e})["catch"](function(e){ return i({type:u.ACTION_TYPES.SUBMIT_FORM_REQUEST,payload:{formId:t}}),e(l({ID:t},n),r).then(function(e){return i({type:u.ACTION_TYPES.SUBMIT_FORM_SUCCESS,payload:{response:e}}),e})["catch"](function(e){
throw e.response.text().then(function(e){return i({type:u.ACTION_TYPES.SUBMIT_FORM_FAILURE,payload:{formId:t,error:e}}),e})})}}function s(e,t){return function(n){n({type:u.ACTION_TYPES.SET_SUBMIT_ACTION, throw e.response.text().then(function(e){return i({type:u.ACTION_TYPES.SUBMIT_FORM_FAILURE,payload:{formId:t,error:e}}),e})})}}function s(e,t){return function(n){n({type:u.ACTION_TYPES.SET_SUBMIT_ACTION,
payload:{formId:e,submitAction:t}})}}Object.defineProperty(t,"__esModule",{value:!0}) payload:{formId:e,submitAction:t}})}}Object.defineProperty(t,"__esModule",{value:!0})
@ -2408,15 +2409,17 @@ multiline:!0,crumbs:this.props.breadcrumbs})),p["default"].createElement("div",{
}},{key:"renderItemListView",value:function h(){var e={sectionConfig:this.props.sectionConfig,campaignId:this.props.params.id,itemListViewEndpoint:this.props.sectionConfig.itemListViewEndpoint,publishApi:this.publishApi, }},{key:"renderItemListView",value:function h(){var e={sectionConfig:this.props.sectionConfig,campaignId:this.props.params.id,itemListViewEndpoint:this.props.sectionConfig.itemListViewEndpoint,publishApi:this.publishApi,
handleBackButtonClick:this.handleBackButtonClick.bind(this)} handleBackButtonClick:this.handleBackButtonClick.bind(this)}
return p["default"].createElement(A["default"],e)}},{key:"renderDetailEditView",value:function m(){var e=this.props.sectionConfig.form.DetailEditForm.schemaUrl,t={createFn:this.campaignEditCreateFn.bind(this), return p["default"].createElement(A["default"],e)}},{key:"renderDetailEditView",value:function m(){var e=this.props.sectionConfig.form.DetailEditForm.schemaUrl,t=e
schemaUrl:e+"/"+this.props.params.id} this.props.params.id>0&&(t=e+"/"+this.props.params.id)
var n={createFn:this.campaignEditCreateFn.bind(this),schemaUrl:t}
return p["default"].createElement("div",{className:"cms-content__inner"},p["default"].createElement(x["default"],{showBackButton:!0,handleBackButtonClick:this.handleBackButtonClick},p["default"].createElement(C["default"],{ return p["default"].createElement("div",{className:"cms-content__inner"},p["default"].createElement(x["default"],{showBackButton:!0,handleBackButtonClick:this.handleBackButtonClick},p["default"].createElement(C["default"],{
multiline:!0,crumbs:this.props.breadcrumbs})),p["default"].createElement("div",{className:"panel panel--padded panel--scrollable panel--single-toolbar"},p["default"].createElement("div",{className:"form--inline" multiline:!0,crumbs:this.props.breadcrumbs})),p["default"].createElement("div",{className:"panel panel--padded panel--scrollable panel--single-toolbar"},p["default"].createElement("div",{className:"form--inline"
},p["default"].createElement(I["default"],t))))}},{key:"renderCreateView",value:function g(){var e=this.props.sectionConfig.form.DetailEditForm.schemaUrl,t={createFn:this.campaignAddCreateFn.bind(this), },p["default"].createElement(I["default"],n))))}},{key:"renderCreateView",value:function g(){var e=this.props.sectionConfig.form.DetailEditForm.schemaUrl,t=e
schemaUrl:e+"/"+this.props.params.id} this.props.params.id>0&&(t=e+"/"+this.props.params.id)
var n={createFn:this.campaignAddCreateFn.bind(this),schemaUrl:t}
return p["default"].createElement("div",{className:"cms-content__inner"},p["default"].createElement(x["default"],{showBackButton:!0,handleBackButtonClick:this.handleBackButtonClick},p["default"].createElement(C["default"],{ return p["default"].createElement("div",{className:"cms-content__inner"},p["default"].createElement(x["default"],{showBackButton:!0,handleBackButtonClick:this.handleBackButtonClick},p["default"].createElement(C["default"],{
multiline:!0,crumbs:this.props.breadcrumbs})),p["default"].createElement("div",{className:"panel panel--padded panel--scrollable panel--single-toolbar"},p["default"].createElement("div",{className:"form--inline" multiline:!0,crumbs:this.props.breadcrumbs})),p["default"].createElement("div",{className:"panel panel--padded panel--scrollable panel--single-toolbar"},p["default"].createElement("div",{className:"form--inline"
},p["default"].createElement(I["default"],t))))}},{key:"campaignEditCreateFn",value:function v(e,t){var n=this,i=this.props.sectionConfig.url },p["default"].createElement(I["default"],n))))}},{key:"campaignEditCreateFn",value:function v(e,t){var n=this,i=this.props.sectionConfig.url
if("action_cancel"===t.name){var r=d({},t,{handleClick:function o(e){e.preventDefault(),n.props.router.push(i)}}) if("action_cancel"===t.name){var r=d({},t,{handleClick:function o(e){e.preventDefault(),n.props.router.push(i)}})
return p["default"].createElement(e,d({key:t.id},r))}return p["default"].createElement(e,d({key:t.id},t))}},{key:"campaignAddCreateFn",value:function b(e,t){var n=this,i=this.props.sectionConfig.url return p["default"].createElement(e,d({key:t.id},r))}return p["default"].createElement(e,d({key:t.id},t))}},{key:"campaignAddCreateFn",value:function b(e,t){var n=this,i=this.props.sectionConfig.url
if("action_cancel"===t.name){var r=d({},t,{handleClick:function o(e){e.preventDefault(),n.props.router.push(i)}}) if("action_cancel"===t.name){var r=d({},t,{handleClick:function o(e){e.preventDefault(),n.props.router.push(i)}})

View File

@ -105,15 +105,23 @@ export class FormBuilderComponent extends SilverStripeComponent {
SecurityID: this.props.config.SecurityID, SecurityID: this.props.config.SecurityID,
}; };
if (formSchema.schema.actions.length > 0) { this.submitApi = (...args) => {
defaultData[formSchema.schema.actions[0].name] = 1; const endPoint = backend.createEndpointFetcher({
} url: formSchema.schema.attributes.action,
method: formSchema.schema.attributes.method,
defaultData,
});
this.submitApi = backend.createEndpointFetcher({ // Ensure that schema changes are handled prior to updating state
url: formSchema.schema.attributes.action, return endPoint(...args)
method: formSchema.schema.attributes.method, .then((response) => {
defaultData, if (response.schema) {
}); const newSchema = Object.assign({}, { id: response.id, schema: response.schema });
this.props.schemaActions.setSchema(newSchema);
}
return response;
});
};
this.props.schemaActions.setSchema(formSchema); this.props.schemaActions.setSchema(formSchema);
} }
@ -248,6 +256,14 @@ export class FormBuilderComponent extends SilverStripeComponent {
? schema.state.fields ? schema.state.fields
: schema.schema.fields; : schema.schema.fields;
// Set action
const action = this.getSubmitAction();
const values = {};
if (action) {
values[action] = 1;
}
// Reduce all other fields
return this.props.form[this.getFormId()].fields return this.props.form[this.getFormId()].fields
.reduce((prev, curr) => { .reduce((prev, curr) => {
const match = this.findField(fields, curr.id); const match = this.findField(fields, curr.id);
@ -258,7 +274,11 @@ export class FormBuilderComponent extends SilverStripeComponent {
return Object.assign({}, prev, { return Object.assign({}, prev, {
[match.name]: curr.value, [match.name]: curr.value,
}); });
}, {}); }, values);
}
getSubmitAction() {
return this.props.form[this.getFormId()].submitAction;
} }
/** /**
@ -419,7 +439,9 @@ export class FormBuilderComponent extends SilverStripeComponent {
const data = this.mergeFieldData(field, state); const data = this.mergeFieldData(field, state);
if (field.children) { if (field.children) {
data.children = this.getFieldData(field.children, formState); return Object.assign({}, data, {
children: this.getFieldData(field.children, formState),
});
} }
return data; return data;
@ -451,9 +473,10 @@ export class FormBuilderComponent extends SilverStripeComponent {
delete attributes.enctype; delete attributes.enctype;
const fieldData = this.getFieldData(formSchema.schema.fields, formState); const fieldData = this.getFieldData(formSchema.schema.fields, formState);
const actionData = this.getFieldData(formSchema.schema.actions, formState);
const formProps = { const formProps = {
actions: formSchema.schema.actions, actions: actionData,
attributes, attributes,
componentWillUnmount: this.removeForm, componentWillUnmount: this.removeForm,
data: formSchema.schema.data, data: formSchema.schema.data,

View File

@ -63,6 +63,7 @@ describe('FormBuilderComponent', () => {
props = { props = {
form: { form: {
MyForm: { MyForm: {
submitAction: 'action_save',
fields: [ fields: [
{ id: 'fieldOne', value: 'valOne' }, { id: 'fieldOne', value: 'valOne' },
{ id: 'fieldTwo', value: null }, { id: 'fieldTwo', value: null },
@ -89,6 +90,7 @@ describe('FormBuilderComponent', () => {
fieldValues = formBuilder.getFieldValues(); fieldValues = formBuilder.getFieldValues();
expect(fieldValues).toEqual({ expect(fieldValues).toEqual({
action_save: 1,
fieldOne: 'valOne', fieldOne: 'valOne',
fieldTwo: null, fieldTwo: null,
}); });

View File

@ -170,9 +170,13 @@ class CampaignAdmin extends SilverStripeComponent {
*/ */
renderDetailEditView() { renderDetailEditView() {
const baseSchemaUrl = this.props.sectionConfig.form.DetailEditForm.schemaUrl; const baseSchemaUrl = this.props.sectionConfig.form.DetailEditForm.schemaUrl;
let schemaUrl = baseSchemaUrl;
if (this.props.params.id > 0) {
schemaUrl = `${baseSchemaUrl}/${this.props.params.id}`;
}
const formBuilderProps = { const formBuilderProps = {
createFn: this.campaignEditCreateFn.bind(this), createFn: this.campaignEditCreateFn.bind(this),
schemaUrl: `${baseSchemaUrl}/${this.props.params.id}`, schemaUrl,
}; };
return ( return (
@ -195,9 +199,13 @@ class CampaignAdmin extends SilverStripeComponent {
*/ */
renderCreateView() { renderCreateView() {
const baseSchemaUrl = this.props.sectionConfig.form.DetailEditForm.schemaUrl; const baseSchemaUrl = this.props.sectionConfig.form.DetailEditForm.schemaUrl;
let schemaUrl = baseSchemaUrl;
if (this.props.params.id > 0) {
schemaUrl = `${baseSchemaUrl}/${this.props.params.id}`;
}
const formBuilderProps = { const formBuilderProps = {
createFn: this.campaignAddCreateFn.bind(this), createFn: this.campaignAddCreateFn.bind(this),
schemaUrl: `${baseSchemaUrl}/${this.props.params.id}`, schemaUrl,
}; };
return ( return (

View File

@ -58,7 +58,7 @@ export function addForm(formState) {
export function submitForm(submitApi, formId, fieldValues) { export function submitForm(submitApi, formId, fieldValues) {
return (dispatch) => { return (dispatch) => {
const headers = { const headers = {
'X-Formschema-Request': 'state', 'X-Formschema-Request': 'schema,state',
'X-Requested-With': 'XMLHttpRequest', 'X-Requested-With': 'XMLHttpRequest',
}; };
dispatch({ dispatch({

View File

@ -66,1990 +66,2007 @@ use SilverStripe\SiteConfig\SiteConfig;
*/ */
class LeftAndMain extends Controller implements PermissionProvider { class LeftAndMain extends Controller implements PermissionProvider {
/** /**
* Enable front-end debugging (increases verbosity) in dev mode. * Enable front-end debugging (increases verbosity) in dev mode.
* Will be ignored in live environments. * Will be ignored in live environments.
* *
* @var bool * @var bool
*/ */
private static $client_debugging = true; private static $client_debugging = true;
/** /**
* The current url segment attached to the LeftAndMain instance * The current url segment attached to the LeftAndMain instance
* *
* @config * @config
* @var string * @var string
*/ */
private static $url_segment; private static $url_segment;
/** /**
* @config * @config
* @var string * @var string
*/ */
private static $url_rule = '/$Action/$ID/$OtherID'; private static $url_rule = '/$Action/$ID/$OtherID';
/** /**
* @config * @config
* @var string * @var string
*/ */
private static $menu_title; private static $menu_title;
/** /**
* @config * @config
* @var string * @var string
*/ */
private static $menu_icon; private static $menu_icon;
/** /**
* @config * @config
* @var int * @var int
*/ */
private static $menu_priority = 0; private static $menu_priority = 0;
/** /**
* @config * @config
* @var int * @var int
*/ */
private static $url_priority = 50; private static $url_priority = 50;
/** /**
* A subclass of {@link DataObject}. * A subclass of {@link DataObject}.
* *
* Determines what is managed in this interface, through * Determines what is managed in this interface, through
* {@link getEditForm()} and other logic. * {@link getEditForm()} and other logic.
* *
* @config * @config
* @var string * @var string
*/ */
private static $tree_class = null; private static $tree_class = null;
/** /**
* The url used for the link in the Help tab in the backend * The url used for the link in the Help tab in the backend
* *
* @config * @config
* @var string * @var string
*/ */
private static $help_link = '//userhelp.silverstripe.org/framework/en/3.3'; private static $help_link = '//userhelp.silverstripe.org/framework/en/3.3';
/** /**
* @var array * @var array
*/ */
private static $allowed_actions = [ private static $allowed_actions = [
'index', 'index',
'save', 'save',
'savetreenode', 'savetreenode',
'getsubtree', 'getsubtree',
'updatetreenodes', 'updatetreenodes',
'printable', 'printable',
'show', 'show',
'EditorToolbar', 'EditorToolbar',
'EditForm', 'EditForm',
'AddForm', 'AddForm',
'batchactions', 'batchactions',
'BatchActionsForm', 'BatchActionsForm',
'schema', 'schema',
]; ];
private static $url_handlers = [ private static $url_handlers = [
'GET schema/$FormName/$ItemID' => 'schema' 'GET schema/$FormName/$ItemID' => 'schema'
]; ];
private static $dependencies = [ private static $dependencies = [
'schema' => '%$FormSchema' 'schema' => '%$FormSchema'
]; ];
/** /**
* Assign themes to use for cms * Assign themes to use for cms
* *
* @config * @config
* @var array * @var array
*/ */
private static $admin_themes = [ private static $admin_themes = [
'silverstripe/framework:/admin/themes/cms-forms', 'silverstripe/framework:/admin/themes/cms-forms',
SSViewer::DEFAULT_THEME, SSViewer::DEFAULT_THEME,
]; ];
/** /**
* Codes which are required from the current user to view this controller. * Codes which are required from the current user to view this controller.
* If multiple codes are provided, all of them are required. * If multiple codes are provided, all of them are required.
* All CMS controllers require "CMS_ACCESS_LeftAndMain" as a baseline check, * All CMS controllers require "CMS_ACCESS_LeftAndMain" as a baseline check,
* and fall back to "CMS_ACCESS_<class>" if no permissions are defined here. * and fall back to "CMS_ACCESS_<class>" if no permissions are defined here.
* See {@link canView()} for more details on permission checks. * See {@link canView()} for more details on permission checks.
* *
* @config * @config
* @var array * @var array
*/ */
private static $required_permission_codes; private static $required_permission_codes;
/** /**
* @config * @config
* @var String Namespace for session info, e.g. current record. * @var String Namespace for session info, e.g. current record.
* Defaults to the current class name, but can be amended to share a namespace in case * Defaults to the current class name, but can be amended to share a namespace in case
* controllers are logically bundled together, and mainly separated * controllers are logically bundled together, and mainly separated
* to achieve more flexible templating. * to achieve more flexible templating.
*/ */
private static $session_namespace; private static $session_namespace;
/** /**
* Register additional requirements through the {@link Requirements} class. * Register additional requirements through the {@link Requirements} class.
* Used mainly to work around the missing "lazy loading" functionality * Used mainly to work around the missing "lazy loading" functionality
* for getting css/javascript required after an ajax-call (e.g. loading the editform). * for getting css/javascript required after an ajax-call (e.g. loading the editform).
* *
* YAML configuration example: * YAML configuration example:
* <code> * <code>
* LeftAndMain: * LeftAndMain:
* extra_requirements_javascript: * extra_requirements_javascript:
* - mysite/javascript/myscript.js * - mysite/javascript/myscript.js
* </code> * </code>
* *
* @config * @config
* @var array * @var array
*/ */
private static $extra_requirements_javascript = array(); private static $extra_requirements_javascript = array();
/** /**
* YAML configuration example: * YAML configuration example:
* <code> * <code>
* LeftAndMain: * LeftAndMain:
* extra_requirements_css: * extra_requirements_css:
* - mysite/css/mystyle.css: * - mysite/css/mystyle.css:
* media: screen * media: screen
* </code> * </code>
* *
* @config * @config
* @var array See {@link extra_requirements_javascript} * @var array See {@link extra_requirements_javascript}
*/ */
private static $extra_requirements_css = array(); private static $extra_requirements_css = array();
/** /**
* @config * @config
* @var array See {@link extra_requirements_javascript} * @var array See {@link extra_requirements_javascript}
*/ */
private static $extra_requirements_themedCss = array(); private static $extra_requirements_themedCss = array();
/** /**
* If true, call a keepalive ping every 5 minutes from the CMS interface, * If true, call a keepalive ping every 5 minutes from the CMS interface,
* to ensure that the session never dies. * to ensure that the session never dies.
* *
* @config * @config
* @var boolean * @var boolean
*/ */
private static $session_keepalive_ping = true; private static $session_keepalive_ping = true;
/** /**
* Value of X-Frame-Options header * Value of X-Frame-Options header
* *
* @config * @config
* @var string * @var string
*/ */
private static $frame_options = 'SAMEORIGIN'; private static $frame_options = 'SAMEORIGIN';
/** /**
* @var PjaxResponseNegotiator * @var PjaxResponseNegotiator
*/ */
protected $responseNegotiator; protected $responseNegotiator;
/** /**
* Gets the combined configuration of all LeafAndMain subclasses required by the client app. * Gets the combined configuration of all LeafAndMain subclasses required by the client app.
* *
* @return array * @return array
* *
* WARNING: Experimental API * WARNING: Experimental API
*/ */
public function getCombinedClientConfig() { public function getCombinedClientConfig() {
$combinedClientConfig = ['sections' => []]; $combinedClientConfig = ['sections' => []];
$cmsClassNames = CMSMenu::get_cms_classes('SilverStripe\\Admin\\LeftAndMain', true, CMSMenu::URL_PRIORITY); $cmsClassNames = CMSMenu::get_cms_classes('SilverStripe\\Admin\\LeftAndMain', true, CMSMenu::URL_PRIORITY);
foreach ($cmsClassNames as $className) { foreach ($cmsClassNames as $className) {
$combinedClientConfig['sections'][$className] = Injector::inst()->get($className)->getClientConfig(); $combinedClientConfig['sections'][$className] = Injector::inst()->get($className)->getClientConfig();
} }
// Pass in base url (absolute and relative) // Pass in base url (absolute and relative)
$combinedClientConfig['baseUrl'] = Director::baseURL(); $combinedClientConfig['baseUrl'] = Director::baseURL();
$combinedClientConfig['absoluteBaseUrl'] = Director::absoluteBaseURL(); $combinedClientConfig['absoluteBaseUrl'] = Director::absoluteBaseURL();
$combinedClientConfig['adminUrl'] = AdminRootController::admin_url(); $combinedClientConfig['adminUrl'] = AdminRootController::admin_url();
// Get "global" CSRF token for use in JavaScript // Get "global" CSRF token for use in JavaScript
$token = SecurityToken::inst(); $token = SecurityToken::inst();
$combinedClientConfig[$token->getName()] = $token->getValue(); $combinedClientConfig[$token->getName()] = $token->getValue();
// Set env // Set env
$combinedClientConfig['environment'] = Director::get_environment_type(); $combinedClientConfig['environment'] = Director::get_environment_type();
$combinedClientConfig['debugging'] = $this->config()->client_debugging; $combinedClientConfig['debugging'] = $this->config()->client_debugging;
return Convert::raw2json($combinedClientConfig); return Convert::raw2json($combinedClientConfig);
} }
/** /**
* Returns configuration required by the client app. * Returns configuration required by the client app.
* *
* @return array * @return array
* *
* WARNING: Experimental API * WARNING: Experimental API
*/ */
public function getClientConfig() { public function getClientConfig() {
return [ return [
// Trim leading/trailing slash to make it easier to concatenate URL // Trim leading/trailing slash to make it easier to concatenate URL
// and use in routing definitions. // and use in routing definitions.
'url' => trim($this->Link(), '/'), 'url' => trim($this->Link(), '/'),
]; ];
} }
/** /**
* Gets a JSON schema representing the current edit form. * Gets a JSON schema representing the current edit form.
* *
* WARNING: Experimental API. * WARNING: Experimental API.
* *
* @param HTTPRequest $request * @param HTTPRequest $request
* @return HTTPResponse * @return HTTPResponse
*/ */
public function schema($request) { public function schema($request) {
$response = $this->getResponse(); $response = $this->getResponse();
$formName = $request->param('FormName'); $formName = $request->param('FormName');
$itemID = $request->param('ItemID'); $itemID = $request->param('ItemID');
if (!$formName) { if (!$formName) {
return (new HTTPResponse('Missing request params', 400)); return (new HTTPResponse('Missing request params', 400));
} }
if(!$this->hasMethod("get{$formName}")) { if(!$this->hasMethod("get{$formName}")) {
return (new HTTPResponse('Form not found', 404)); return (new HTTPResponse('Form not found', 404));
} }
if(!$this->hasAction($formName)) { if(!$this->hasAction($formName)) {
return (new HTTPResponse('Form not accessible', 401)); return (new HTTPResponse('Form not accessible', 401));
} }
$form = $this->{"get{$formName}"}($itemID); $form = $this->{"get{$formName}"}($itemID);
$response->addHeader('Content-Type', 'application/json'); $response->addHeader('Content-Type', 'application/json');
$response->setBody(Convert::raw2json($this->getSchemaForForm($form))); $response->setBody(Convert::raw2json($this->getSchemaForForm($form)));
return $response; return $response;
} }
/** /**
* Given a form, generate a response containing the requested form * Given a form, generate a response containing the requested form
* schema if X-Formschema-Request header is set. * schema if X-Formschema-Request header is set.
* *
* @param Form $form * @param Form $form
* @return HTTPResponse * @return HTTPResponse
*/ */
protected function getSchemaResponse($form) { protected function getSchemaResponse($form) {
$request = $this->getRequest(); $request = $this->getRequest();
if($request->getHeader('X-Formschema-Request')) { if($request->getHeader('X-Formschema-Request')) {
$data = $this->getSchemaForForm($form); $data = $this->getSchemaForForm($form);
$response = new HTTPResponse(Convert::raw2json($data)); $response = new HTTPResponse(Convert::raw2json($data));
$response->addHeader('Content-Type', 'application/json'); $response->addHeader('Content-Type', 'application/json');
return $response; return $response;
} }
return null; return null;
} }
/** /**
* Returns a representation of the provided {@link Form} as structured data, * Returns a representation of the provided {@link Form} as structured data,
* based on the request data. * based on the request data.
* *
* @param Form $form * @param Form $form
* @return array * @return array
*/ */
protected function getSchemaForForm(Form $form) { protected function getSchemaForForm(Form $form) {
$request = $this->getRequest(); $request = $this->getRequest();
$return = null; $return = null;
// Valid values for the "X-Formschema-Request" header are "schema" and "state". // Valid values for the "X-Formschema-Request" header are "schema" and "state".
// If either of these values are set they will be stored in the $schemaParst array // If either of these values are set they will be stored in the $schemaParst array
// and used to construct the response body. // and used to construct the response body.
if ($schemaHeader = $request->getHeader('X-Formschema-Request')) { if ($schemaHeader = $request->getHeader('X-Formschema-Request')) {
$schemaParts = array_filter(explode(',', $schemaHeader), function($value) { $schemaParts = array_filter(explode(',', $schemaHeader), function($value) {
$validHeaderValues = ['schema', 'state']; $validHeaderValues = ['schema', 'state'];
return in_array(trim($value), $validHeaderValues); return in_array(trim($value), $validHeaderValues);
}); });
} else { } else {
$schemaParts = ['schema']; $schemaParts = ['schema'];
} }
$return = ['id' => $form->FormName()]; $return = ['id' => $form->FormName()];
if (in_array('schema', $schemaParts)) { if (in_array('schema', $schemaParts)) {
$return['schema'] = $this->schema->getSchema($form); $schemaLink = $this->getSchemaLinkForForm($form);
} $return['schema'] = $this->schema->getSchema($form, $schemaLink);
}
if (in_array('state', $schemaParts)) {
$return['state'] = $this->schema->getState($form); if (in_array('state', $schemaParts)) {
} $return['state'] = $this->schema->getState($form);
}
return $return;
} return $return;
}
/**
* @param Member $member /**
* @return boolean * Get link to schema url for a given form
*/ *
public function canView($member = null) { * @param Form $form
if(!$member && $member !== FALSE) $member = Member::currentUser(); * @return string
*/
// cms menus only for logged-in members protected function getSchemaLinkForForm(Form $form) {
if(!$member) return false; $parts = [$this->Link('schema'), $form->getName()];
if (($record = $form->getRecord()) && $record->isInDB()) {
// alternative extended checks $parts[] = $record->ID;
if($this->hasMethod('alternateAccessCheck')) { } elseif (($data = $form->getData()) && !empty($data['ID'])) {
$alternateAllowed = $this->alternateAccessCheck(); $parts[] = $data['ID'];
if($alternateAllowed === false) { }
return false; return Controller::join_links($parts);
} }
}
/**
// Check for "CMS admin" permission * @param Member $member
if(Permission::checkMember($member, "CMS_ACCESS_LeftAndMain")) { * @return boolean
return true; */
} public function canView($member = null) {
if(!$member && $member !== FALSE) $member = Member::currentUser();
// Check for LeftAndMain sub-class permissions
$codes = $this->getRequiredPermissions(); // cms menus only for logged-in members
if($codes === false) { // allow explicit FALSE to disable subclass check if(!$member) return false;
return true;
} // alternative extended checks
foreach((array)$codes as $code) { if($this->hasMethod('alternateAccessCheck')) {
if(!Permission::checkMember($member, $code)) { $alternateAllowed = $this->alternateAccessCheck();
return false; if($alternateAllowed === false) {
} return false;
} }
}
return true;
} // Check for "CMS admin" permission
if(Permission::checkMember($member, "CMS_ACCESS_LeftAndMain")) {
/** return true;
* Get list of required permissions }
*
* @return array|string|bool Code, array of codes, or false if no permission required // Check for LeftAndMain sub-class permissions
*/ $codes = $this->getRequiredPermissions();
public static function getRequiredPermissions() { if($codes === false) { // allow explicit FALSE to disable subclass check
$class = get_called_class(); return true;
$code = Config::inst()->get($class, 'required_permission_codes', Config::FIRST_SET); }
if ($code === false) { foreach((array)$codes as $code) {
return false; if(!Permission::checkMember($member, $code)) {
} return false;
if ($code) { }
return $code; }
}
return "CMS_ACCESS_" . $class; return true;
} }
/** /**
* @uses LeftAndMainExtension->init() * Get list of required permissions
* @uses LeftAndMainExtension->accessedCMS() *
* @uses CMSMenu * @return array|string|bool Code, array of codes, or false if no permission required
*/ */
protected function init() { public static function getRequiredPermissions() {
parent::init(); $class = get_called_class();
$code = Config::inst()->get($class, 'required_permission_codes', Config::FIRST_SET);
SSViewer::config()->update('rewrite_hash_links', false); if ($code === false) {
ContentNegotiator::config()->update('enabled', false); return false;
}
// set language if ($code) {
$member = Member::currentUser(); return $code;
if(!empty($member->Locale)) { }
i18n::set_locale($member->Locale); return "CMS_ACCESS_" . $class;
} }
if(!empty($member->DateFormat)) {
i18n::config()->date_format = $member->DateFormat; /**
} * @uses LeftAndMainExtension->init()
if(!empty($member->TimeFormat)) { * @uses LeftAndMainExtension->accessedCMS()
i18n::config()->time_format = $member->TimeFormat; * @uses CMSMenu
} */
protected function init() {
// can't be done in cms/_config.php as locale is not set yet parent::init();
CMSMenu::add_link(
'Help', SSViewer::config()->update('rewrite_hash_links', false);
_t('LeftAndMain.HELP', 'Help', 'Menu title'), ContentNegotiator::config()->update('enabled', false);
$this->config()->help_link,
-2, // set language
array( $member = Member::currentUser();
'target' => '_blank' if(!empty($member->Locale)) {
) i18n::set_locale($member->Locale);
); }
if(!empty($member->DateFormat)) {
// Allow customisation of the access check by a extension i18n::config()->date_format = $member->DateFormat;
// Also all the canView() check to execute Controller::redirect() }
if(!$this->canView() && !$this->getResponse()->isFinished()) { if(!empty($member->TimeFormat)) {
// When access /admin/, we should try a redirect to another part of the admin rather than be locked out i18n::config()->time_format = $member->TimeFormat;
$menu = $this->MainMenu(); }
foreach($menu as $candidate) {
if( // can't be done in cms/_config.php as locale is not set yet
$candidate->Link && CMSMenu::add_link(
$candidate->Link != $this->Link() 'Help',
&& $candidate->MenuItem->controller _t('LeftAndMain.HELP', 'Help', 'Menu title'),
&& singleton($candidate->MenuItem->controller)->canView() $this->config()->help_link,
) { -2,
$this->redirect($candidate->Link); array(
return; 'target' => '_blank'
} )
} );
if(Member::currentUser()) { // Allow customisation of the access check by a extension
Session::set("BackURL", null); // Also all the canView() check to execute Controller::redirect()
} if(!$this->canView() && !$this->getResponse()->isFinished()) {
// When access /admin/, we should try a redirect to another part of the admin rather than be locked out
// if no alternate menu items have matched, return a permission error $menu = $this->MainMenu();
$messageSet = array( foreach($menu as $candidate) {
'default' => _t( if(
'LeftAndMain.PERMDEFAULT', $candidate->Link &&
"You must be logged in to access the administration area; please enter your credentials below." $candidate->Link != $this->Link()
), && $candidate->MenuItem->controller
'alreadyLoggedIn' => _t( && singleton($candidate->MenuItem->controller)->canView()
'LeftAndMain.PERMALREADY', ) {
"I'm sorry, but you can't access that part of the CMS. If you want to log in as someone else, do" $this->redirect($candidate->Link);
. " so below." return;
), }
'logInAgain' => _t( }
'LeftAndMain.PERMAGAIN',
"You have been logged out of the CMS. If you would like to log in again, enter a username and" if(Member::currentUser()) {
. " password below." Session::set("BackURL", null);
), }
);
// if no alternate menu items have matched, return a permission error
Security::permissionFailure($this, $messageSet); $messageSet = array(
return; 'default' => _t(
} 'LeftAndMain.PERMDEFAULT',
"You must be logged in to access the administration area; please enter your credentials below."
// Don't continue if there's already been a redirection request. ),
if($this->redirectedTo()) { 'alreadyLoggedIn' => _t(
return; 'LeftAndMain.PERMALREADY',
} "I'm sorry, but you can't access that part of the CMS. If you want to log in as someone else, do"
. " so below."
// Audit logging hook ),
if(empty($_REQUEST['executeForm']) && !$this->getRequest()->isAjax()) $this->extend('accessedCMS'); 'logInAgain' => _t(
'LeftAndMain.PERMAGAIN',
// Set the members html editor config "You have been logged out of the CMS. If you would like to log in again, enter a username and"
if(Member::currentUser()) { . " password below."
HTMLEditorConfig::set_active_identifier(Member::currentUser()->getHtmlEditorConfigForCMS()); ),
} );
// Set default values in the config if missing. These things can't be defined in the config Security::permissionFailure($this, $messageSet);
// file because insufficient information exists when that is being processed return;
$htmlEditorConfig = HTMLEditorConfig::get_active(); }
$htmlEditorConfig->setOption('language', i18n::get_tinymce_lang());
// Don't continue if there's already been a redirection request.
Requirements::customScript(" if($this->redirectedTo()) {
window.ss = window.ss || {}; return;
window.ss.config = " . $this->getCombinedClientConfig() . "; }
");
// Audit logging hook
Requirements::javascript(FRAMEWORK_ADMIN_DIR . '/client/dist/js/vendor.js'); if(empty($_REQUEST['executeForm']) && !$this->getRequest()->isAjax()) $this->extend('accessedCMS');
Requirements::javascript(FRAMEWORK_ADMIN_DIR . '/client/dist/js/bundle.js');
Requirements::css(ltrim(FRAMEWORK_ADMIN_DIR . '/client/dist/styles/bundle.css', '/')); // Set the members html editor config
if(Member::currentUser()) {
Requirements::add_i18n_javascript(ltrim(FRAMEWORK_DIR . '/client/lang', '/'), false, true); HTMLEditorConfig::set_active_identifier(Member::currentUser()->getHtmlEditorConfigForCMS());
Requirements::add_i18n_javascript(FRAMEWORK_ADMIN_DIR . '/client/lang', false, true); }
if ($this->config()->session_keepalive_ping) { // Set default values in the config if missing. These things can't be defined in the config
Requirements::javascript(FRAMEWORK_ADMIN_DIR . '/client/dist/js/LeftAndMain.Ping.js'); // file because insufficient information exists when that is being processed
} $htmlEditorConfig = HTMLEditorConfig::get_active();
$htmlEditorConfig->setOption('language', i18n::get_tinymce_lang());
if (Director::isDev()) {
// TODO Confuses jQuery.ondemand through document.write() Requirements::customScript("
Requirements::javascript(ADMIN_THIRDPARTY_DIR . '/jquery-entwine/src/jquery.entwine.inspector.js'); window.ss = window.ss || {};
Requirements::javascript(FRAMEWORK_ADMIN_DIR . '/client/dist/js/leaktools.js'); window.ss.config = " . $this->getCombinedClientConfig() . ";
} ");
// Custom requirements Requirements::javascript(FRAMEWORK_ADMIN_DIR . '/client/dist/js/vendor.js');
$extraJs = $this->stat('extra_requirements_javascript'); Requirements::javascript(FRAMEWORK_ADMIN_DIR . '/client/dist/js/bundle.js');
Requirements::css(ltrim(FRAMEWORK_ADMIN_DIR . '/client/dist/styles/bundle.css', '/'));
if($extraJs) {
foreach($extraJs as $file => $config) { Requirements::add_i18n_javascript(ltrim(FRAMEWORK_DIR . '/client/lang', '/'), false, true);
if(is_numeric($file)) { Requirements::add_i18n_javascript(FRAMEWORK_ADMIN_DIR . '/client/lang', false, true);
$file = $config;
} if ($this->config()->session_keepalive_ping) {
Requirements::javascript(FRAMEWORK_ADMIN_DIR . '/client/dist/js/LeftAndMain.Ping.js');
Requirements::javascript($file); }
}
} if (Director::isDev()) {
// TODO Confuses jQuery.ondemand through document.write()
$extraCss = $this->stat('extra_requirements_css'); Requirements::javascript(ADMIN_THIRDPARTY_DIR . '/jquery-entwine/src/jquery.entwine.inspector.js');
Requirements::javascript(FRAMEWORK_ADMIN_DIR . '/client/dist/js/leaktools.js');
if($extraCss) { }
foreach($extraCss as $file => $config) {
if(is_numeric($file)) { // Custom requirements
$file = $config; $extraJs = $this->stat('extra_requirements_javascript');
$config = array();
} if($extraJs) {
foreach($extraJs as $file => $config) {
Requirements::css($file, isset($config['media']) ? $config['media'] : null); if(is_numeric($file)) {
} $file = $config;
} }
$extraThemedCss = $this->stat('extra_requirements_themedCss'); Requirements::javascript($file);
}
if($extraThemedCss) { }
foreach ($extraThemedCss as $file => $config) {
if(is_numeric($file)) { $extraCss = $this->stat('extra_requirements_css');
$file = $config;
$config = array(); if($extraCss) {
} foreach($extraCss as $file => $config) {
if(is_numeric($file)) {
Requirements::themedCSS($file, isset($config['media']) ? $config['media'] : null); $file = $config;
} $config = array();
} }
$dummy = null; Requirements::css($file, isset($config['media']) ? $config['media'] : null);
$this->extend('init', $dummy); }
}
// Assign default cms theme and replace user-specified themes
SSViewer::set_themes($this->config()->admin_themes); $extraThemedCss = $this->stat('extra_requirements_themedCss');
//set the reading mode for the admin to stage if($extraThemedCss) {
Versioned::set_stage(Versioned::DRAFT); foreach ($extraThemedCss as $file => $config) {
} if(is_numeric($file)) {
$file = $config;
public function handleRequest(HTTPRequest $request, DataModel $model = null) { $config = array();
try { }
$response = parent::handleRequest($request, $model);
} catch(ValidationException $e) { Requirements::themedCSS($file, isset($config['media']) ? $config['media'] : null);
// Nicer presentation of model-level validation errors }
$msgs = _t('LeftAndMain.ValidationError', 'Validation error') . ': ' }
. $e->getMessage();
$e = new HTTPResponse_Exception($msgs, 403); $dummy = null;
$errorResponse = $e->getResponse(); $this->extend('init', $dummy);
$errorResponse->addHeader('Content-Type', 'text/plain');
$errorResponse->addHeader('X-Status', rawurlencode($msgs)); // Assign default cms theme and replace user-specified themes
$e->setResponse($errorResponse); SSViewer::set_themes($this->config()->admin_themes);
throw $e;
} //set the reading mode for the admin to stage
Versioned::set_stage(Versioned::DRAFT);
$title = $this->Title(); }
if(!$response->getHeader('X-Controller')) $response->addHeader('X-Controller', $this->class);
if(!$response->getHeader('X-Title')) $response->addHeader('X-Title', urlencode($title)); public function handleRequest(HTTPRequest $request, DataModel $model = null) {
try {
// Prevent clickjacking, see https://developer.mozilla.org/en-US/docs/HTTP/X-Frame-Options $response = parent::handleRequest($request, $model);
$originalResponse = $this->getResponse(); } catch(ValidationException $e) {
$originalResponse->addHeader('X-Frame-Options', $this->config()->frame_options); // Nicer presentation of model-level validation errors
$originalResponse->addHeader('Vary', 'X-Requested-With'); $msgs = _t('LeftAndMain.ValidationError', 'Validation error') . ': '
. $e->getMessage();
return $response; $e = new HTTPResponse_Exception($msgs, 403);
} $errorResponse = $e->getResponse();
$errorResponse->addHeader('Content-Type', 'text/plain');
/** $errorResponse->addHeader('X-Status', rawurlencode($msgs));
* Overloaded redirection logic to trigger a fake redirect on ajax requests. $e->setResponse($errorResponse);
* While this violates HTTP principles, its the only way to work around the throw $e;
* fact that browsers handle HTTP redirects opaquely, no intervention via JS is possible. }
* In isolation, that's not a problem - but combined with history.pushState()
* it means we would request the same redirection URL twice if we want to update the URL as well. $title = $this->Title();
* See LeftAndMain.js for the required jQuery ajaxComplete handlers. if(!$response->getHeader('X-Controller')) $response->addHeader('X-Controller', $this->class);
* if(!$response->getHeader('X-Title')) $response->addHeader('X-Title', urlencode($title));
* @param string $url
* @param int $code // Prevent clickjacking, see https://developer.mozilla.org/en-US/docs/HTTP/X-Frame-Options
* @return HTTPResponse|string $originalResponse = $this->getResponse();
*/ $originalResponse->addHeader('X-Frame-Options', $this->config()->frame_options);
public function redirect($url, $code=302) { $originalResponse->addHeader('Vary', 'X-Requested-With');
if($this->getRequest()->isAjax()) {
$response = $this->getResponse(); return $response;
$response->addHeader('X-ControllerURL', $url); }
if($this->getRequest()->getHeader('X-Pjax') && !$response->getHeader('X-Pjax')) {
$response->addHeader('X-Pjax', $this->getRequest()->getHeader('X-Pjax')); /**
} * Overloaded redirection logic to trigger a fake redirect on ajax requests.
$newResponse = new LeftAndMain_HTTPResponse( * While this violates HTTP principles, its the only way to work around the
$response->getBody(), * fact that browsers handle HTTP redirects opaquely, no intervention via JS is possible.
$response->getStatusCode(), * In isolation, that's not a problem - but combined with history.pushState()
$response->getStatusDescription() * it means we would request the same redirection URL twice if we want to update the URL as well.
); * See LeftAndMain.js for the required jQuery ajaxComplete handlers.
foreach($response->getHeaders() as $k => $v) { *
$newResponse->addHeader($k, $v); * @param string $url
} * @param int $code
$newResponse->setIsFinished(true); * @return HTTPResponse|string
$this->setResponse($newResponse); */
return ''; // Actual response will be re-requested by client public function redirect($url, $code=302) {
} else { if($this->getRequest()->isAjax()) {
parent::redirect($url, $code); $response = $this->getResponse();
} $response->addHeader('X-ControllerURL', $url);
} if($this->getRequest()->getHeader('X-Pjax') && !$response->getHeader('X-Pjax')) {
$response->addHeader('X-Pjax', $this->getRequest()->getHeader('X-Pjax'));
/** }
* @param HTTPRequest $request $newResponse = new LeftAndMain_HTTPResponse(
* @return HTTPResponse $response->getBody(),
*/ $response->getStatusCode(),
public function index($request) { $response->getStatusDescription()
return $this->getResponseNegotiator()->respond($request); );
} foreach($response->getHeaders() as $k => $v) {
$newResponse->addHeader($k, $v);
/** }
* If this is set to true, the "switchView" context in the $newResponse->setIsFinished(true);
* template is shown, with links to the staging and publish site. $this->setResponse($newResponse);
* return ''; // Actual response will be re-requested by client
* @return boolean } else {
*/ parent::redirect($url, $code);
public function ShowSwitchView() { }
return false; }
}
/**
* @param HTTPRequest $request
//------------------------------------------------------------------------------------------// * @return HTTPResponse
// Main controllers */
public function index($request) {
/** return $this->getResponseNegotiator()->respond($request);
* You should implement a Link() function in your subclass of LeftAndMain, }
* to point to the URL of that particular controller.
* /**
* @param string $action * If this is set to true, the "switchView" context in the
* @return string * template is shown, with links to the staging and publish site.
*/ *
public function Link($action = null) { * @return boolean
// Handle missing url_segments */
if($this->config()->url_segment) { public function ShowSwitchView() {
$segment = $this->config()->get('url_segment', Config::FIRST_SET); return false;
} else { }
$segment = $this->class;
};
//------------------------------------------------------------------------------------------//
$link = Controller::join_links( // Main controllers
AdminRootController::admin_url(),
$segment, /**
'/', // trailing slash needed if $action is null! * You should implement a Link() function in your subclass of LeftAndMain,
"$action" * to point to the URL of that particular controller.
); *
$this->extend('updateLink', $link); * @param string $action
return $link; * @return string
} */
public function Link($action = null) {
/** // Handle missing url_segments
* @deprecated 5.0 if($this->config()->url_segment) {
*/ $segment = $this->config()->get('url_segment', Config::FIRST_SET);
public static function menu_title_for_class($class) { } else {
Deprecation::notice('5.0', 'Use menu_title() instead'); $segment = $this->class;
return static::menu_title($class, false); };
}
$link = Controller::join_links(
/** AdminRootController::admin_url(),
* Get menu title for this section (translated) $segment,
* '/', // trailing slash needed if $action is null!
* @param string $class Optional class name if called on LeftAndMain directly "$action"
* @param bool $localise Determine if menu title should be localised via i18n. );
* @return string Menu title for the given class $this->extend('updateLink', $link);
*/ return $link;
public static function menu_title($class = null, $localise = true) { }
if($class && is_subclass_of($class, __CLASS__)) {
// Respect oveloading of menu_title() in subclasses /**
return $class::menu_title(null, $localise); * @deprecated 5.0
} */
if(!$class) { public static function menu_title_for_class($class) {
$class = get_called_class(); Deprecation::notice('5.0', 'Use menu_title() instead');
} return static::menu_title($class, false);
}
// Get default class title
$title = Config::inst()->get($class, 'menu_title', Config::FIRST_SET); /**
if(!$title) { * Get menu title for this section (translated)
$title = preg_replace('/Admin$/', '', $class); *
} * @param string $class Optional class name if called on LeftAndMain directly
* @param bool $localise Determine if menu title should be localised via i18n.
// Check localisation * @return string Menu title for the given class
if(!$localise) { */
return $title; public static function menu_title($class = null, $localise = true) {
} if($class && is_subclass_of($class, __CLASS__)) {
return i18n::_t("{$class}.MENUTITLE", $title); // Respect oveloading of menu_title() in subclasses
} return $class::menu_title(null, $localise);
}
/** if(!$class) {
* Return styling for the menu icon, if a custom icon is set for this class $class = get_called_class();
* }
* Example: static $menu-icon = '/path/to/image/';
* @param string $class // Get default class title
* @return string $title = Config::inst()->get($class, 'menu_title', Config::FIRST_SET);
*/ if(!$title) {
public static function menu_icon_for_class($class) { $title = preg_replace('/Admin$/', '', $class);
$icon = Config::inst()->get($class, 'menu_icon', Config::FIRST_SET); }
if (!empty($icon)) {
$class = strtolower(Convert::raw2htmlname(str_replace('\\', '-', $class))); // Check localisation
return ".icon.icon-16.icon-{$class} { background-image: url('{$icon}'); } "; if(!$localise) {
} return $title;
return ''; }
} return i18n::_t("{$class}.MENUTITLE", $title);
}
/**
* @param HTTPRequest $request /**
* @return HTTPResponse * Return styling for the menu icon, if a custom icon is set for this class
* @throws HTTPResponse_Exception *
*/ * Example: static $menu-icon = '/path/to/image/';
public function show($request) { * @param string $class
// TODO Necessary for TableListField URLs to work properly * @return string
if($request->param('ID')) $this->setCurrentPageID($request->param('ID')); */
return $this->getResponseNegotiator()->respond($request); public static function menu_icon_for_class($class) {
} $icon = Config::inst()->get($class, 'menu_icon', Config::FIRST_SET);
if (!empty($icon)) {
/** $class = strtolower(Convert::raw2htmlname(str_replace('\\', '-', $class)));
* Caution: Volatile API. return ".icon.icon-16.icon-{$class} { background-image: url('{$icon}'); } ";
* }
* @return PjaxResponseNegotiator return '';
*/ }
public function getResponseNegotiator() {
if(!$this->responseNegotiator) { /**
$controller = $this; * @param HTTPRequest $request
$this->responseNegotiator = new PjaxResponseNegotiator( * @return HTTPResponse
array( * @throws HTTPResponse_Exception
'CurrentForm' => function() use(&$controller) { */
return $controller->getEditForm()->forTemplate(); public function show($request) {
}, // TODO Necessary for TableListField URLs to work properly
'Content' => function() use(&$controller) { if($request->param('ID')) $this->setCurrentPageID($request->param('ID'));
return $controller->renderWith($controller->getTemplatesWithSuffix('_Content')); return $this->getResponseNegotiator()->respond($request);
}, }
'Breadcrumbs' => function() use (&$controller) {
return $controller->renderWith([ /**
'type' => 'Includes', * Caution: Volatile API.
'SilverStripe\\Admin\\CMSBreadcrumbs' *
]); * @return PjaxResponseNegotiator
}, */
'default' => function() use(&$controller) { public function getResponseNegotiator() {
return $controller->renderWith($controller->getViewer('show')); if(!$this->responseNegotiator) {
} $controller = $this;
), $this->responseNegotiator = new PjaxResponseNegotiator(
$this->getResponse() array(
); 'CurrentForm' => function() use(&$controller) {
} return $controller->getEditForm()->forTemplate();
return $this->responseNegotiator; },
} 'Content' => function() use(&$controller) {
return $controller->renderWith($controller->getTemplatesWithSuffix('_Content'));
//------------------------------------------------------------------------------------------// },
// Main UI components 'Breadcrumbs' => function() use (&$controller) {
return $controller->renderWith([
/** 'type' => 'Includes',
* Returns the main menu of the CMS. This is also used by init() 'SilverStripe\\Admin\\CMSBreadcrumbs'
* to work out which sections the user has access to. ]);
* },
* @param bool $cached 'default' => function() use(&$controller) {
* @return SS_List return $controller->renderWith($controller->getViewer('show'));
*/ }
public function MainMenu($cached = true) { ),
if(!isset($this->_cache_MainMenu) || !$cached) { $this->getResponse()
// Don't accidentally return a menu if you're not logged in - it's used to determine access. );
if(!Member::currentUser()) return new ArrayList(); }
return $this->responseNegotiator;
// Encode into DO set }
$menu = new ArrayList();
$menuItems = CMSMenu::get_viewable_menu_items(); //------------------------------------------------------------------------------------------//
// Main UI components
// extra styling for custom menu-icons
$menuIconStyling = ''; /**
* Returns the main menu of the CMS. This is also used by init()
if($menuItems) { * to work out which sections the user has access to.
/** @var CMSMenuItem $menuItem */ *
foreach($menuItems as $code => $menuItem) { * @param bool $cached
// alternate permission checks (in addition to LeftAndMain->canView()) * @return SS_List
if( */
isset($menuItem->controller) public function MainMenu($cached = true) {
&& $this->hasMethod('alternateMenuDisplayCheck') if(!isset($this->_cache_MainMenu) || !$cached) {
&& !$this->alternateMenuDisplayCheck($menuItem->controller) // Don't accidentally return a menu if you're not logged in - it's used to determine access.
) { if(!Member::currentUser()) return new ArrayList();
continue;
} // Encode into DO set
$menu = new ArrayList();
$linkingmode = "link"; $menuItems = CMSMenu::get_viewable_menu_items();
if($menuItem->controller && get_class($this) == $menuItem->controller) { // extra styling for custom menu-icons
$linkingmode = "current"; $menuIconStyling = '';
} else if(strpos($this->Link(), $menuItem->url) !== false) {
if($this->Link() == $menuItem->url) { if($menuItems) {
$linkingmode = "current"; /** @var CMSMenuItem $menuItem */
foreach($menuItems as $code => $menuItem) {
// default menu is the one with a blank {@link url_segment} // alternate permission checks (in addition to LeftAndMain->canView())
} else if(singleton($menuItem->controller)->stat('url_segment') == '') { if(
if($this->Link() == AdminRootController::admin_url()) { isset($menuItem->controller)
$linkingmode = "current"; && $this->hasMethod('alternateMenuDisplayCheck')
} && !$this->alternateMenuDisplayCheck($menuItem->controller)
) {
} else { continue;
$linkingmode = "current"; }
}
} $linkingmode = "link";
// already set in CMSMenu::populate_menu(), but from a static pre-controller if($menuItem->controller && get_class($this) == $menuItem->controller) {
// context, so doesn't respect the current user locale in _t() calls - as a workaround, $linkingmode = "current";
// we simply call LeftAndMain::menu_title() again } else if(strpos($this->Link(), $menuItem->url) !== false) {
// if we're dealing with a controller if($this->Link() == $menuItem->url) {
if($menuItem->controller) { $linkingmode = "current";
$title = LeftAndMain::menu_title($menuItem->controller);
} else { // default menu is the one with a blank {@link url_segment}
$title = $menuItem->title; } else if(singleton($menuItem->controller)->stat('url_segment') == '') {
} if($this->Link() == AdminRootController::admin_url()) {
$linkingmode = "current";
// Provide styling for custom $menu-icon. Done here instead of in }
// CMSMenu::populate_menu(), because the icon is part of
// the CMS right pane for the specified class as well... } else {
if($menuItem->controller) { $linkingmode = "current";
$menuIcon = LeftAndMain::menu_icon_for_class($menuItem->controller); }
if (!empty($menuIcon)) { }
$menuIconStyling .= $menuIcon;
} // already set in CMSMenu::populate_menu(), but from a static pre-controller
} // context, so doesn't respect the current user locale in _t() calls - as a workaround,
// we simply call LeftAndMain::menu_title() again
$menu->push(new ArrayData(array( // if we're dealing with a controller
"MenuItem" => $menuItem, if($menuItem->controller) {
"AttributesHTML" => $menuItem->getAttributesHTML(), $title = LeftAndMain::menu_title($menuItem->controller);
"Title" => Convert::raw2xml($title), } else {
"Code" => $code, $title = $menuItem->title;
"Icon" => strtolower($code), }
"Link" => $menuItem->url,
"LinkingMode" => $linkingmode // Provide styling for custom $menu-icon. Done here instead of in
))); // CMSMenu::populate_menu(), because the icon is part of
} // the CMS right pane for the specified class as well...
} if($menuItem->controller) {
if ($menuIconStyling) Requirements::customCSS($menuIconStyling); $menuIcon = LeftAndMain::menu_icon_for_class($menuItem->controller);
if (!empty($menuIcon)) {
$this->_cache_MainMenu = $menu; $menuIconStyling .= $menuIcon;
} }
}
return $this->_cache_MainMenu;
} $menu->push(new ArrayData(array(
"MenuItem" => $menuItem,
public function Menu() { "AttributesHTML" => $menuItem->getAttributesHTML(),
return $this->renderWith($this->getTemplatesWithSuffix('_Menu')); "Title" => Convert::raw2xml($title),
} "Code" => $code,
"Icon" => strtolower($code),
/** "Link" => $menuItem->url,
* @todo Wrap in CMSMenu instance accessor "LinkingMode" => $linkingmode
* @return ArrayData A single menu entry (see {@link MainMenu}) )));
*/ }
public function MenuCurrentItem() { }
$items = $this->MainMenu(); if ($menuIconStyling) Requirements::customCSS($menuIconStyling);
return $items->find('LinkingMode', 'current');
} $this->_cache_MainMenu = $menu;
}
/**
* Return a list of appropriate templates for this class, with the given suffix using return $this->_cache_MainMenu;
* {@link SSViewer::get_templates_by_class()} }
*
* @param string $suffix public function Menu() {
* @return array return $this->renderWith($this->getTemplatesWithSuffix('_Menu'));
*/ }
public function getTemplatesWithSuffix($suffix) {
$templates = SSViewer::get_templates_by_class(get_class($this), $suffix, __CLASS__); /**
return SSViewer::chooseTemplate($templates); * @todo Wrap in CMSMenu instance accessor
} * @return ArrayData A single menu entry (see {@link MainMenu})
*/
public function Content() { public function MenuCurrentItem() {
return $this->renderWith($this->getTemplatesWithSuffix('_Content')); $items = $this->MainMenu();
} return $items->find('LinkingMode', 'current');
}
/**
* Render $PreviewPanel content /**
* * Return a list of appropriate templates for this class, with the given suffix using
* @return DBHTMLText * {@link SSViewer::get_templates_by_class()}
*/ *
public function PreviewPanel() { * @param string $suffix
$template = $this->getTemplatesWithSuffix('_PreviewPanel'); * @return array
// Only render sections with preview panel */
if ($template) { public function getTemplatesWithSuffix($suffix) {
return $this->renderWith($template); $templates = SSViewer::get_templates_by_class(get_class($this), $suffix, __CLASS__);
} return SSViewer::chooseTemplate($templates);
} }
public function getRecord($id) { public function Content() {
$className = $this->stat('tree_class'); return $this->renderWith($this->getTemplatesWithSuffix('_Content'));
if($className && $id instanceof $className) { }
return $id;
} else if($className && $id == 'root') { /**
return singleton($className); * Render $PreviewPanel content
} else if($className && is_numeric($id)) { *
return DataObject::get_by_id($className, $id); * @return DBHTMLText
} else { */
return false; public function PreviewPanel() {
} $template = $this->getTemplatesWithSuffix('_PreviewPanel');
} // Only render sections with preview panel
if ($template) {
/** return $this->renderWith($template);
* @param bool $unlinked }
* @return ArrayList }
*/
public function Breadcrumbs($unlinked = false) { public function getRecord($id) {
$items = new ArrayList(array( $className = $this->stat('tree_class');
new ArrayData(array( if($className && $id instanceof $className) {
'Title' => $this->menu_title(), return $id;
'Link' => ($unlinked) ? false : $this->Link() } else if($className && $id == 'root') {
)) return singleton($className);
)); } else if($className && is_numeric($id)) {
$record = $this->currentPage(); return DataObject::get_by_id($className, $id);
if($record && $record->exists()) { } else {
if($record->hasExtension('SilverStripe\\ORM\\Hierarchy\\Hierarchy')) { return false;
$ancestors = $record->getAncestors(); }
$ancestors = new ArrayList(array_reverse($ancestors->toArray())); }
$ancestors->push($record);
foreach($ancestors as $ancestor) { /**
$items->push(new ArrayData(array( * @param bool $unlinked
'Title' => ($ancestor->MenuTitle) ? $ancestor->MenuTitle : $ancestor->Title, * @return ArrayList
'Link' => ($unlinked) ? false : Controller::join_links($this->Link('show'), $ancestor->ID) */
))); public function Breadcrumbs($unlinked = false) {
} $items = new ArrayList(array(
} else { new ArrayData(array(
$items->push(new ArrayData(array( 'Title' => $this->menu_title(),
'Title' => ($record->MenuTitle) ? $record->MenuTitle : $record->Title, 'Link' => ($unlinked) ? false : $this->Link()
'Link' => ($unlinked) ? false : Controller::join_links($this->Link('show'), $record->ID) ))
))); ));
} $record = $this->currentPage();
} if($record && $record->exists()) {
if($record->hasExtension('SilverStripe\\ORM\\Hierarchy\\Hierarchy')) {
return $items; $ancestors = $record->getAncestors();
} $ancestors = new ArrayList(array_reverse($ancestors->toArray()));
$ancestors->push($record);
/** foreach($ancestors as $ancestor) {
* @return String HTML $items->push(new ArrayData(array(
*/ 'Title' => ($ancestor->MenuTitle) ? $ancestor->MenuTitle : $ancestor->Title,
public function SiteTreeAsUL() { 'Link' => ($unlinked) ? false : Controller::join_links($this->Link('show'), $ancestor->ID)
$html = $this->getSiteTreeFor($this->stat('tree_class')); )));
$this->extend('updateSiteTreeAsUL', $html); }
return $html; } else {
} $items->push(new ArrayData(array(
'Title' => ($record->MenuTitle) ? $record->MenuTitle : $record->Title,
/** 'Link' => ($unlinked) ? false : Controller::join_links($this->Link('show'), $record->ID)
* Gets the current search filter for this request, if available )));
* }
* @throws InvalidArgumentException }
* @return LeftAndMain_SearchFilter
*/ return $items;
protected function getSearchFilter() { }
// Check for given FilterClass
$params = $this->getRequest()->getVar('q'); /**
if(empty($params['FilterClass'])) { * @return String HTML
return null; */
} public function SiteTreeAsUL() {
$html = $this->getSiteTreeFor($this->stat('tree_class'));
// Validate classname $this->extend('updateSiteTreeAsUL', $html);
$filterClass = $params['FilterClass']; return $html;
$filterInfo = new ReflectionClass($filterClass); }
if(!$filterInfo->implementsInterface('SilverStripe\\Admin\\LeftAndMain_SearchFilter')) {
throw new InvalidArgumentException(sprintf('Invalid filter class passed: %s', $filterClass)); /**
} * Gets the current search filter for this request, if available
*
return Injector::inst()->createWithArgs($filterClass, array($params)); * @throws InvalidArgumentException
} * @return LeftAndMain_SearchFilter
*/
/** protected function getSearchFilter() {
* Get a site tree HTML listing which displays the nodes under the given criteria. // Check for given FilterClass
* $params = $this->getRequest()->getVar('q');
* @param string $className The class of the root object if(empty($params['FilterClass'])) {
* @param string $rootID The ID of the root object. If this is null then a complete tree will be return null;
* shown }
* @param string $childrenMethod The method to call to get the children of the tree. For example,
* Children, AllChildrenIncludingDeleted, or AllHistoricalChildren // Validate classname
* @param string $numChildrenMethod $filterClass = $params['FilterClass'];
* @param callable $filterFunction $filterInfo = new ReflectionClass($filterClass);
* @param int $nodeCountThreshold if(!$filterInfo->implementsInterface('SilverStripe\\Admin\\LeftAndMain_SearchFilter')) {
* @return string Nested unordered list with links to each page throw new InvalidArgumentException(sprintf('Invalid filter class passed: %s', $filterClass));
*/ }
public function getSiteTreeFor($className, $rootID = null, $childrenMethod = null, $numChildrenMethod = null,
$filterFunction = null, $nodeCountThreshold = 30) { return Injector::inst()->createWithArgs($filterClass, array($params));
}
// Filter criteria
$filter = $this->getSearchFilter(); /**
* Get a site tree HTML listing which displays the nodes under the given criteria.
// Default childrenMethod and numChildrenMethod *
if(!$childrenMethod) $childrenMethod = ($filter && $filter->getChildrenMethod()) * @param string $className The class of the root object
? $filter->getChildrenMethod() * @param string $rootID The ID of the root object. If this is null then a complete tree will be
: 'AllChildrenIncludingDeleted'; * shown
* @param string $childrenMethod The method to call to get the children of the tree. For example,
if(!$numChildrenMethod) { * Children, AllChildrenIncludingDeleted, or AllHistoricalChildren
$numChildrenMethod = 'numChildren'; * @param string $numChildrenMethod
if($filter && $filter->getNumChildrenMethod()) { * @param callable $filterFunction
$numChildrenMethod = $filter->getNumChildrenMethod(); * @param int $nodeCountThreshold
} * @return string Nested unordered list with links to each page
} */
if(!$filterFunction && $filter) { public function getSiteTreeFor($className, $rootID = null, $childrenMethod = null, $numChildrenMethod = null,
$filterFunction = function($node) use($filter) { $filterFunction = null, $nodeCountThreshold = 30) {
return $filter->isPageIncluded($node);
}; // Filter criteria
} $filter = $this->getSearchFilter();
// Get the tree root // Default childrenMethod and numChildrenMethod
$record = ($rootID) ? $this->getRecord($rootID) : null; if(!$childrenMethod) $childrenMethod = ($filter && $filter->getChildrenMethod())
$obj = $record ? $record : singleton($className); ? $filter->getChildrenMethod()
: 'AllChildrenIncludingDeleted';
// Get the current page
// NOTE: This *must* be fetched before markPartialTree() is called, as this if(!$numChildrenMethod) {
// causes the Hierarchy::$marked cache to be flushed (@see CMSMain::getRecord) $numChildrenMethod = 'numChildren';
// which means that deleted pages stored in the marked tree would be removed if($filter && $filter->getNumChildrenMethod()) {
$currentPage = $this->currentPage(); $numChildrenMethod = $filter->getNumChildrenMethod();
}
// Mark the nodes of the tree to return }
if ($filterFunction) $obj->setMarkingFilterFunction($filterFunction); if(!$filterFunction && $filter) {
$filterFunction = function($node) use($filter) {
$obj->markPartialTree($nodeCountThreshold, $this, $childrenMethod, $numChildrenMethod); return $filter->isPageIncluded($node);
};
// Ensure current page is exposed }
if($currentPage) $obj->markToExpose($currentPage);
// Get the tree root
// NOTE: SiteTree/CMSMain coupling :-( $record = ($rootID) ? $this->getRecord($rootID) : null;
if(class_exists('SilverStripe\\CMS\\Model\\SiteTree')) { $obj = $record ? $record : singleton($className);
SiteTree::prepopulate_permission_cache(
'CanEditType', // Get the current page
$obj->markedNodeIDs(), // NOTE: This *must* be fetched before markPartialTree() is called, as this
'SilverStripe\\CMS\\Model\\SiteTree::can_edit_multiple' // causes the Hierarchy::$marked cache to be flushed (@see CMSMain::getRecord)
); // which means that deleted pages stored in the marked tree would be removed
} $currentPage = $this->currentPage();
// getChildrenAsUL is a flexible and complex way of traversing the tree // Mark the nodes of the tree to return
$controller = $this; if ($filterFunction) $obj->setMarkingFilterFunction($filterFunction);
$recordController = ($this->stat('tree_class') == 'SilverStripe\\CMS\\Model\\SiteTree')
? CMSPageEditController::singleton() $obj->markPartialTree($nodeCountThreshold, $this, $childrenMethod, $numChildrenMethod);
: $this;
$titleFn = function(&$child, $numChildrenMethod) use(&$controller, &$recordController, $filter) { // Ensure current page is exposed
$link = Controller::join_links($recordController->Link("show"), $child->ID); if($currentPage) $obj->markToExpose($currentPage);
$node = LeftAndMain_TreeNode::create($child, $link, $controller->isCurrentPage($child), $numChildrenMethod, $filter);
return $node->forTemplate(); // NOTE: SiteTree/CMSMain coupling :-(
}; if(class_exists('SilverStripe\\CMS\\Model\\SiteTree')) {
SiteTree::prepopulate_permission_cache(
// Limit the amount of nodes shown for performance reasons. 'CanEditType',
// Skip the check if we're filtering the tree, since its not clear how many children will $obj->markedNodeIDs(),
// match the filter criteria until they're queried (and matched up with previously marked nodes). 'SilverStripe\\CMS\\Model\\SiteTree::can_edit_multiple'
$nodeThresholdLeaf = Config::inst()->get('SilverStripe\\ORM\\Hierarchy\\Hierarchy', 'node_threshold_leaf'); );
if($nodeThresholdLeaf && !$filterFunction) { }
$nodeCountCallback = function($parent, $numChildren) use(&$controller, $className, $nodeThresholdLeaf) {
if ($className !== 'SilverStripe\\CMS\\Model\\SiteTree' // getChildrenAsUL is a flexible and complex way of traversing the tree
|| !$parent->ID $controller = $this;
|| $numChildren >= $nodeThresholdLeaf $recordController = ($this->stat('tree_class') == 'SilverStripe\\CMS\\Model\\SiteTree')
) { ? CMSPageEditController::singleton()
return null; : $this;
} $titleFn = function(&$child, $numChildrenMethod) use(&$controller, &$recordController, $filter) {
return sprintf( $link = Controller::join_links($recordController->Link("show"), $child->ID);
'<ul><li class="readonly"><span class="item">' $node = LeftAndMain_TreeNode::create($child, $link, $controller->isCurrentPage($child), $numChildrenMethod, $filter);
. '%s (<a href="%s" class="cms-panel-link" data-pjax-target="Content">%s</a>)' return $node->forTemplate();
. '</span></li></ul>', };
_t('LeftAndMain.TooManyPages', 'Too many pages'),
Controller::join_links( // Limit the amount of nodes shown for performance reasons.
$controller->LinkWithSearch($controller->Link()), ' // Skip the check if we're filtering the tree, since its not clear how many children will
?view=list&ParentID=' . $parent->ID // match the filter criteria until they're queried (and matched up with previously marked nodes).
), $nodeThresholdLeaf = Config::inst()->get('SilverStripe\\ORM\\Hierarchy\\Hierarchy', 'node_threshold_leaf');
_t( if($nodeThresholdLeaf && !$filterFunction) {
'LeftAndMain.ShowAsList', $nodeCountCallback = function($parent, $numChildren) use(&$controller, $className, $nodeThresholdLeaf) {
'show as list', if ($className !== 'SilverStripe\\CMS\\Model\\SiteTree'
'Show large amount of pages in list instead of tree view' || !$parent->ID
) || $numChildren >= $nodeThresholdLeaf
); ) {
}; return null;
} else { }
$nodeCountCallback = null; return sprintf(
} '<ul><li class="readonly"><span class="item">'
. '%s (<a href="%s" class="cms-panel-link" data-pjax-target="Content">%s</a>)'
// If the amount of pages exceeds the node thresholds set, use the callback . '</span></li></ul>',
$html = null; _t('LeftAndMain.TooManyPages', 'Too many pages'),
if($obj->ParentID && $nodeCountCallback) { Controller::join_links(
$html = $nodeCountCallback($obj, $obj->$numChildrenMethod()); $controller->LinkWithSearch($controller->Link()), '
} ?view=list&ParentID=' . $parent->ID
),
// Otherwise return the actual tree (which might still filter leaf thresholds on children) _t(
if(!$html) { 'LeftAndMain.ShowAsList',
$html = $obj->getChildrenAsUL( 'show as list',
"", 'Show large amount of pages in list instead of tree view'
$titleFn, )
CMSPagesController::singleton(), );
true, };
$childrenMethod, } else {
$numChildrenMethod, $nodeCountCallback = null;
$nodeCountThreshold, }
$nodeCountCallback
); // If the amount of pages exceeds the node thresholds set, use the callback
} $html = null;
if($obj->ParentID && $nodeCountCallback) {
// Wrap the root if needs be. $html = $nodeCountCallback($obj, $obj->$numChildrenMethod());
if(!$rootID) { }
$rootLink = $this->Link('show') . '/root';
// Otherwise return the actual tree (which might still filter leaf thresholds on children)
// This lets us override the tree title with an extension if(!$html) {
if($this->hasMethod('getCMSTreeTitle') && $customTreeTitle = $this->getCMSTreeTitle()) { $html = $obj->getChildrenAsUL(
$treeTitle = $customTreeTitle; "",
} elseif(class_exists('SilverStripe\\SiteConfig\\SiteConfig')) { $titleFn,
$siteConfig = SiteConfig::current_site_config(); CMSPagesController::singleton(),
$treeTitle = Convert::raw2xml($siteConfig->Title); true,
} else { $childrenMethod,
$treeTitle = '...'; $numChildrenMethod,
} $nodeCountThreshold,
$nodeCountCallback
$html = "<ul><li id=\"record-0\" data-id=\"0\" class=\"Root nodelete\"><strong>$treeTitle</strong>" );
. $html . "</li></ul>"; }
}
// Wrap the root if needs be.
return $html; if(!$rootID) {
} $rootLink = $this->Link('show') . '/root';
/** // This lets us override the tree title with an extension
* Get a subtree underneath the request param 'ID'. if($this->hasMethod('getCMSTreeTitle') && $customTreeTitle = $this->getCMSTreeTitle()) {
* If ID = 0, then get the whole tree. $treeTitle = $customTreeTitle;
* } elseif(class_exists('SilverStripe\\SiteConfig\\SiteConfig')) {
* @param HTTPRequest $request $siteConfig = SiteConfig::current_site_config();
* @return string $treeTitle = Convert::raw2xml($siteConfig->Title);
*/ } else {
public function getsubtree($request) { $treeTitle = '...';
$html = $this->getSiteTreeFor( }
$this->stat('tree_class'),
$request->getVar('ID'), $html = "<ul><li id=\"record-0\" data-id=\"0\" class=\"Root nodelete\"><strong>$treeTitle</strong>"
null, . $html . "</li></ul>";
null, }
null,
$request->getVar('minNodeCount') return $html;
); }
// Trim off the outer tag /**
$html = preg_replace('/^[\s\t\r\n]*<ul[^>]*>/','', $html); * Get a subtree underneath the request param 'ID'.
$html = preg_replace('/<\/ul[^>]*>[\s\t\r\n]*$/','', $html); * If ID = 0, then get the whole tree.
*
return $html; * @param HTTPRequest $request
} * @return string
*/
/** public function getsubtree($request) {
* Allows requesting a view update on specific tree nodes. $html = $this->getSiteTreeFor(
* Similar to {@link getsubtree()}, but doesn't enforce loading $this->stat('tree_class'),
* all children with the node. Useful to refresh views after $request->getVar('ID'),
* state modifications, e.g. saving a form. null,
* null,
* @param HTTPRequest $request null,
* @return string JSON $request->getVar('minNodeCount')
*/ );
public function updatetreenodes($request) {
$data = array(); // Trim off the outer tag
$ids = explode(',', $request->getVar('ids')); $html = preg_replace('/^[\s\t\r\n]*<ul[^>]*>/','', $html);
foreach($ids as $id) { $html = preg_replace('/<\/ul[^>]*>[\s\t\r\n]*$/','', $html);
if($id === "") continue; // $id may be a blank string, which is invalid and should be skipped over
return $html;
$record = $this->getRecord($id); }
if(!$record) continue; // In case a page is no longer available
$recordController = ($this->stat('tree_class') == 'SilverStripe\\CMS\\Model\\SiteTree') /**
? CMSPageEditController::singleton() * Allows requesting a view update on specific tree nodes.
: $this; * Similar to {@link getsubtree()}, but doesn't enforce loading
* all children with the node. Useful to refresh views after
// Find the next & previous nodes, for proper positioning (Sort isn't good enough - it's not a raw offset) * state modifications, e.g. saving a form.
// TODO: These methods should really be in hierarchy - for a start it assumes Sort exists *
$next = $prev = null; * @param HTTPRequest $request
* @return string JSON
$className = $this->stat('tree_class'); */
$next = DataObject::get($className) public function updatetreenodes($request) {
->filter('ParentID', $record->ParentID) $data = array();
->filter('Sort:GreaterThan', $record->Sort) $ids = explode(',', $request->getVar('ids'));
->first(); foreach($ids as $id) {
if($id === "") continue; // $id may be a blank string, which is invalid and should be skipped over
if (!$next) {
$prev = DataObject::get($className) $record = $this->getRecord($id);
->filter('ParentID', $record->ParentID) if(!$record) continue; // In case a page is no longer available
->filter('Sort:LessThan', $record->Sort) $recordController = ($this->stat('tree_class') == 'SilverStripe\\CMS\\Model\\SiteTree')
->reverse() ? CMSPageEditController::singleton()
->first(); : $this;
}
// Find the next & previous nodes, for proper positioning (Sort isn't good enough - it's not a raw offset)
$link = Controller::join_links($recordController->Link("show"), $record->ID); // TODO: These methods should really be in hierarchy - for a start it assumes Sort exists
$html = LeftAndMain_TreeNode::create($record, $link, $this->isCurrentPage($record)) $next = $prev = null;
->forTemplate() . '</li>';
$className = $this->stat('tree_class');
$data[$id] = array( $next = DataObject::get($className)
'html' => $html, ->filter('ParentID', $record->ParentID)
'ParentID' => $record->ParentID, ->filter('Sort:GreaterThan', $record->Sort)
'NextID' => $next ? $next->ID : null, ->first();
'PrevID' => $prev ? $prev->ID : null
); if (!$next) {
} $prev = DataObject::get($className)
$this->getResponse()->addHeader('Content-Type', 'text/json'); ->filter('ParentID', $record->ParentID)
return Convert::raw2json($data); ->filter('Sort:LessThan', $record->Sort)
} ->reverse()
->first();
/** }
* Save handler
* $link = Controller::join_links($recordController->Link("show"), $record->ID);
* @param array $data $html = LeftAndMain_TreeNode::create($record, $link, $this->isCurrentPage($record))
* @param Form $form ->forTemplate() . '</li>';
* @return HTTPResponse
*/ $data[$id] = array(
public function save($data, $form) { 'html' => $html,
$request = $this->getRequest(); 'ParentID' => $record->ParentID,
$className = $this->stat('tree_class'); 'NextID' => $next ? $next->ID : null,
'PrevID' => $prev ? $prev->ID : null
// Existing or new record? );
$id = $data['ID']; }
if(is_numeric($id) && $id > 0) { $this->getResponse()->addHeader('Content-Type', 'text/json');
$record = DataObject::get_by_id($className, $id); return Convert::raw2json($data);
if($record && !$record->canEdit()) { }
return Security::permissionFailure($this);
} /**
if(!$record || !$record->ID) { * Save handler
$this->httpError(404, "Bad record ID #" . (int)$id); *
} * @param array $data
} else { * @param Form $form
if(!singleton($this->stat('tree_class'))->canCreate()) { * @return HTTPResponse
return Security::permissionFailure($this); */
} public function save($data, $form) {
$record = $this->getNewItem($id, false); $request = $this->getRequest();
} $className = $this->stat('tree_class');
// save form data into record // Existing or new record?
$form->saveInto($record, true); $id = $data['ID'];
$record->write(); if(is_numeric($id) && $id > 0) {
$this->extend('onAfterSave', $record); $record = DataObject::get_by_id($className, $id);
$this->setCurrentPageID($record->ID); if($record && !$record->canEdit()) {
return Security::permissionFailure($this);
$message = _t('LeftAndMain.SAVEDUP', 'Saved.'); }
if($request->getHeader('X-Formschema-Request')) { if(!$record || !$record->ID) {
// Ensure that newly created records have all their data loaded back into the form. $this->httpError(404, "Bad record ID #" . (int)$id);
$form->loadDataFrom($record); }
$form->setMessage($message, 'good'); } else {
$data = $this->getSchemaForForm($form); if(!singleton($this->stat('tree_class'))->canCreate()) {
$response = new HTTPResponse(Convert::raw2json($data)); return Security::permissionFailure($this);
$response->addHeader('Content-Type', 'application/json'); }
} else { $record = $this->getNewItem($id, false);
$response = $this->getResponseNegotiator()->respond($request); }
}
// save form data into record
$response->addHeader('X-Status', rawurlencode($message)); $form->saveInto($record, true);
return $response; $record->write();
} $this->extend('onAfterSave', $record);
$this->setCurrentPageID($record->ID);
/**
* Create new item. $message = _t('LeftAndMain.SAVEDUP', 'Saved.');
* if($request->getHeader('X-Formschema-Request')) {
* @param string|int $id // Ensure that newly created records have all their data loaded back into the form.
* @param bool $setID $form->loadDataFrom($record);
* @return DataObject $form->setMessage($message, 'good');
*/ $data = $this->getSchemaForForm($form);
public function getNewItem($id, $setID = true) { $response = new HTTPResponse(Convert::raw2json($data));
$class = $this->stat('tree_class'); $response->addHeader('Content-Type', 'application/json');
$object = Injector::inst()->create($class); } else {
if($setID) { $response = $this->getResponseNegotiator()->respond($request);
$object->ID = $id; }
}
return $object; $response->addHeader('X-Status', rawurlencode($message));
} return $response;
}
public function delete($data, $form) {
$className = $this->stat('tree_class'); /**
* Create new item.
$id = $data['ID']; *
$record = DataObject::get_by_id($className, $id); * @param string|int $id
if($record && !$record->canDelete()) return Security::permissionFailure(); * @param bool $setID
if(!$record || !$record->ID) $this->httpError(404, "Bad record ID #" . (int)$id); * @return DataObject
*/
$record->delete(); public function getNewItem($id, $setID = true) {
$class = $this->stat('tree_class');
$this->getResponse()->addHeader('X-Status', rawurlencode(_t('LeftAndMain.DELETED', 'Deleted.'))); $object = Injector::inst()->create($class);
return $this->getResponseNegotiator()->respond( if($setID) {
$this->getRequest(), $object->ID = $id;
array('currentform' => array($this, 'EmptyForm')) }
); return $object;
} }
/** public function delete($data, $form) {
* Update the position and parent of a tree node. $className = $this->stat('tree_class');
* Only saves the node if changes were made.
* $id = $data['ID'];
* Required data: $record = DataObject::get_by_id($className, $id);
* - 'ID': The moved node if($record && !$record->canDelete()) return Security::permissionFailure();
* - 'ParentID': New parent relation of the moved node (0 for root) if(!$record || !$record->ID) $this->httpError(404, "Bad record ID #" . (int)$id);
* - 'SiblingIDs': Array of all sibling nodes to the moved node (incl. the node itself).
* In case of a 'ParentID' change, relates to the new siblings under the new parent. $record->delete();
*
* @param HTTPRequest $request $this->getResponse()->addHeader('X-Status', rawurlencode(_t('LeftAndMain.DELETED', 'Deleted.')));
* @return HTTPResponse JSON string with a return $this->getResponseNegotiator()->respond(
* @throws HTTPResponse_Exception $this->getRequest(),
*/ array('currentform' => array($this, 'EmptyForm'))
public function savetreenode($request) { );
if (!SecurityToken::inst()->checkRequest($request)) { }
return $this->httpError(400);
} /**
if (!Permission::check('SITETREE_REORGANISE') && !Permission::check('ADMIN')) { * Update the position and parent of a tree node.
$this->getResponse()->setStatusCode( * Only saves the node if changes were made.
403, *
_t('LeftAndMain.CANT_REORGANISE', * Required data:
"You do not have permission to rearange the site tree. Your change was not saved.") * - 'ID': The moved node
); * - 'ParentID': New parent relation of the moved node (0 for root)
return; * - 'SiblingIDs': Array of all sibling nodes to the moved node (incl. the node itself).
} * In case of a 'ParentID' change, relates to the new siblings under the new parent.
*
$className = $this->stat('tree_class'); * @param HTTPRequest $request
$statusUpdates = array('modified'=>array()); * @return HTTPResponse JSON string with a
$id = $request->requestVar('ID'); * @throws HTTPResponse_Exception
$parentID = $request->requestVar('ParentID'); */
public function savetreenode($request) {
if($className == 'SilverStripe\\CMS\\Model\\SiteTree' && $page = DataObject::get_by_id('Page', $id)){ if (!SecurityToken::inst()->checkRequest($request)) {
$root = $page->getParentType(); return $this->httpError(400);
if(($parentID == '0' || $root == 'root') && !SiteConfig::current_site_config()->canCreateTopLevel()){ }
$this->getResponse()->setStatusCode( if (!Permission::check('SITETREE_REORGANISE') && !Permission::check('ADMIN')) {
403, $this->getResponse()->setStatusCode(
_t('LeftAndMain.CANT_REORGANISE', 403,
"You do not have permission to alter Top level pages. Your change was not saved.") _t('LeftAndMain.CANT_REORGANISE',
); "You do not have permission to rearange the site tree. Your change was not saved.")
return; );
} return;
} }
$siblingIDs = $request->requestVar('SiblingIDs'); $className = $this->stat('tree_class');
$statusUpdates = array('modified'=>array()); $statusUpdates = array('modified'=>array());
if(!is_numeric($id) || !is_numeric($parentID)) throw new InvalidArgumentException(); $id = $request->requestVar('ID');
$parentID = $request->requestVar('ParentID');
$node = DataObject::get_by_id($className, $id);
if($node && !$node->canEdit()) return Security::permissionFailure($this); if($className == 'SilverStripe\\CMS\\Model\\SiteTree' && $page = DataObject::get_by_id('Page', $id)){
$root = $page->getParentType();
if(!$node) { if(($parentID == '0' || $root == 'root') && !SiteConfig::current_site_config()->canCreateTopLevel()){
$this->getResponse()->setStatusCode( $this->getResponse()->setStatusCode(
500, 403,
_t('LeftAndMain.PLEASESAVE', _t('LeftAndMain.CANT_REORGANISE',
"Please Save Page: This page could not be updated because it hasn't been saved yet." "You do not have permission to alter Top level pages. Your change was not saved.")
) );
); return;
return; }
} }
// Update hierarchy (only if ParentID changed) $siblingIDs = $request->requestVar('SiblingIDs');
if($node->ParentID != $parentID) { $statusUpdates = array('modified'=>array());
$node->ParentID = (int)$parentID; if(!is_numeric($id) || !is_numeric($parentID)) throw new InvalidArgumentException();
$node->write();
$node = DataObject::get_by_id($className, $id);
$statusUpdates['modified'][$node->ID] = array( if($node && !$node->canEdit()) return Security::permissionFailure($this);
'TreeTitle'=>$node->TreeTitle
); if(!$node) {
$this->getResponse()->setStatusCode(
// Update all dependent pages 500,
if(class_exists('SilverStripe\\CMS\\Model\\VirtualPage')) { _t('LeftAndMain.PLEASESAVE',
$virtualPages = VirtualPage::get()->filter("CopyContentFromID", $node->ID); "Please Save Page: This page could not be updated because it hasn't been saved yet."
foreach($virtualPages as $virtualPage) { )
$statusUpdates['modified'][$virtualPage->ID] = array( );
'TreeTitle' => $virtualPage->TreeTitle() return;
); }
}
} // Update hierarchy (only if ParentID changed)
if($node->ParentID != $parentID) {
$this->getResponse()->addHeader('X-Status', $node->ParentID = (int)$parentID;
rawurlencode(_t('LeftAndMain.REORGANISATIONSUCCESSFUL', 'Reorganised the site tree successfully.'))); $node->write();
}
$statusUpdates['modified'][$node->ID] = array(
// Update sorting 'TreeTitle'=>$node->TreeTitle
if(is_array($siblingIDs)) { );
$counter = 0;
foreach($siblingIDs as $id) { // Update all dependent pages
if($id == $node->ID) { if(class_exists('SilverStripe\\CMS\\Model\\VirtualPage')) {
$node->Sort = ++$counter; $virtualPages = VirtualPage::get()->filter("CopyContentFromID", $node->ID);
$node->write(); foreach($virtualPages as $virtualPage) {
$statusUpdates['modified'][$node->ID] = array( $statusUpdates['modified'][$virtualPage->ID] = array(
'TreeTitle' => $node->TreeTitle 'TreeTitle' => $virtualPage->TreeTitle()
); );
} else if(is_numeric($id)) { }
// Nodes that weren't "actually moved" shouldn't be registered as }
// having been edited; do a direct SQL update instead
++$counter; $this->getResponse()->addHeader('X-Status',
DB::prepared_query( rawurlencode(_t('LeftAndMain.REORGANISATIONSUCCESSFUL', 'Reorganised the site tree successfully.')));
"UPDATE \"$className\" SET \"Sort\" = ? WHERE \"ID\" = ?", }
array($counter, $id)
); // Update sorting
} if(is_array($siblingIDs)) {
} $counter = 0;
foreach($siblingIDs as $id) {
$this->getResponse()->addHeader('X-Status', if($id == $node->ID) {
rawurlencode(_t('LeftAndMain.REORGANISATIONSUCCESSFUL', 'Reorganised the site tree successfully.'))); $node->Sort = ++$counter;
} $node->write();
$statusUpdates['modified'][$node->ID] = array(
return Convert::raw2json($statusUpdates); 'TreeTitle' => $node->TreeTitle
} );
} else if(is_numeric($id)) {
public function CanOrganiseSitetree() { // Nodes that weren't "actually moved" shouldn't be registered as
return !Permission::check('SITETREE_REORGANISE') && !Permission::check('ADMIN') ? false : true; // having been edited; do a direct SQL update instead
} ++$counter;
DB::prepared_query(
/** "UPDATE \"$className\" SET \"Sort\" = ? WHERE \"ID\" = ?",
* Retrieves an edit form, either for display, or to process submitted data. array($counter, $id)
* Also used in the template rendered through {@link Right()} in the $EditForm placeholder. );
* }
* This is a "pseudo-abstract" methoed, usually connected to a {@link getEditForm()} }
* method in an entwine subclass. This method can accept a record identifier,
* selected either in custom logic, or through {@link currentPageID()}. $this->getResponse()->addHeader('X-Status',
* The form usually construct itself from {@link DataObject->getCMSFields()} rawurlencode(_t('LeftAndMain.REORGANISATIONSUCCESSFUL', 'Reorganised the site tree successfully.')));
* for the specific managed subclass defined in {@link LeftAndMain::$tree_class}. }
*
* @param HTTPRequest $request Optionally contains an identifier for the return Convert::raw2json($statusUpdates);
* record to load into the form. }
* @return Form Should return a form regardless wether a record has been found.
* Form might be readonly if the current user doesn't have the permission to edit public function CanOrganiseSitetree() {
* the record. return !Permission::check('SITETREE_REORGANISE') && !Permission::check('ADMIN') ? false : true;
*/ }
/**
* @return Form /**
*/ * Retrieves an edit form, either for display, or to process submitted data.
public function EditForm($request = null) { * Also used in the template rendered through {@link Right()} in the $EditForm placeholder.
return $this->getEditForm(); *
} * This is a "pseudo-abstract" methoed, usually connected to a {@link getEditForm()}
* method in an entwine subclass. This method can accept a record identifier,
/** * selected either in custom logic, or through {@link currentPageID()}.
* Calls {@link SiteTree->getCMSFields()} * The form usually construct itself from {@link DataObject->getCMSFields()}
* * for the specific managed subclass defined in {@link LeftAndMain::$tree_class}.
* @param Int $id *
* @param FieldList $fields * @param HTTPRequest $request Optionally contains an identifier for the
* @return Form * record to load into the form.
*/ * @return Form Should return a form regardless wether a record has been found.
public function getEditForm($id = null, $fields = null) { * Form might be readonly if the current user doesn't have the permission to edit
if(!$id) $id = $this->currentPageID(); * the record.
*/
if(is_object($id)) { /**
$record = $id; * @return Form
} else { */
$record = $this->getRecord($id); public function EditForm($request = null) {
if($record && !$record->canView()) return Security::permissionFailure($this); return $this->getEditForm();
} }
if($record) { /**
$fields = ($fields) ? $fields : $record->getCMSFields(); * Calls {@link SiteTree->getCMSFields()}
if ($fields == null) { *
user_error( * @param Int $id
"getCMSFields() returned null - it should return a FieldList object. * @param FieldList $fields
Perhaps you forgot to put a return statement at the end of your method?", * @return Form
E_USER_ERROR */
); public function getEditForm($id = null, $fields = null) {
} if(!$id) $id = $this->currentPageID();
// Add hidden fields which are required for saving the record if(is_object($id)) {
// and loading the UI state $record = $id;
if(!$fields->dataFieldByName('ClassName')) { } else {
$fields->push(new HiddenField('ClassName')); $record = $this->getRecord($id);
} if($record && !$record->canView()) return Security::permissionFailure($this);
}
$tree_class = $this->stat('tree_class');
if( if($record) {
$tree_class::has_extension('SilverStripe\\ORM\\Hierarchy\\Hierarchy') $fields = ($fields) ? $fields : $record->getCMSFields();
&& !$fields->dataFieldByName('ParentID') if ($fields == null) {
) { user_error(
$fields->push(new HiddenField('ParentID')); "getCMSFields() returned null - it should return a FieldList object.
} Perhaps you forgot to put a return statement at the end of your method?",
E_USER_ERROR
// Added in-line to the form, but plucked into different view by frontend scripts. );
if ($record instanceof CMSPreviewable) { }
/** @skipUpgrade */
$navField = new LiteralField('SilverStripeNavigator', $this->getSilverStripeNavigator()); // Add hidden fields which are required for saving the record
$navField->setAllowHTML(true); // and loading the UI state
$fields->push($navField); if(!$fields->dataFieldByName('ClassName')) {
} $fields->push(new HiddenField('ClassName'));
}
if($record->hasMethod('getAllCMSActions')) {
$actions = $record->getAllCMSActions(); $tree_class = $this->stat('tree_class');
} else { if(
$actions = $record->getCMSActions(); $tree_class::has_extension('SilverStripe\\ORM\\Hierarchy\\Hierarchy')
// add default actions if none are defined && !$fields->dataFieldByName('ParentID')
if(!$actions || !$actions->count()) { ) {
if($record->hasMethod('canEdit') && $record->canEdit()) { $fields->push(new HiddenField('ParentID'));
$actions->push( }
FormAction::create('save',_t('CMSMain.SAVE','Save'))
->addExtraClass('ss-ui-action-constructive')->setAttribute('data-icon', 'accept') // Added in-line to the form, but plucked into different view by frontend scripts.
); if ($record instanceof CMSPreviewable) {
} /** @skipUpgrade */
if($record->hasMethod('canDelete') && $record->canDelete()) { $navField = new LiteralField('SilverStripeNavigator', $this->getSilverStripeNavigator());
$actions->push( $navField->setAllowHTML(true);
FormAction::create('delete',_t('ModelAdmin.DELETE','Delete')) $fields->push($navField);
->addExtraClass('ss-ui-action-destructive') }
);
} if($record->hasMethod('getAllCMSActions')) {
} $actions = $record->getAllCMSActions();
} } else {
$actions = $record->getCMSActions();
// Use <button> to allow full jQuery UI styling // add default actions if none are defined
$actionsFlattened = $actions->dataFields(); if(!$actions || !$actions->count()) {
if($actionsFlattened) { if($record->hasMethod('canEdit') && $record->canEdit()) {
/** @var FormAction $action */ $actions->push(
foreach($actionsFlattened as $action) { FormAction::create('save',_t('CMSMain.SAVE','Save'))
$action->setUseButtonTag(true); ->addExtraClass('ss-ui-action-constructive')->setAttribute('data-icon', 'accept')
} );
} }
if($record->hasMethod('canDelete') && $record->canDelete()) {
$negotiator = $this->getResponseNegotiator(); $actions->push(
$form = Form::create( FormAction::create('delete',_t('ModelAdmin.DELETE','Delete'))
$this, "EditForm", $fields, $actions ->addExtraClass('ss-ui-action-destructive')
)->setHTMLID('Form_EditForm'); );
$form->addExtraClass('cms-edit-form'); }
$form->loadDataFrom($record); }
$form->setTemplate($this->getTemplatesWithSuffix('_EditForm')); }
$form->setAttribute('data-pjax-fragment', 'CurrentForm');
$form->setValidationResponseCallback(function() use ($negotiator, $form) { // Use <button> to allow full jQuery UI styling
$request = $this->getRequest(); $actionsFlattened = $actions->dataFields();
if($request->isAjax() && $negotiator) { if($actionsFlattened) {
$form->setupFormErrors(); /** @var FormAction $action */
$result = $form->forTemplate(); foreach($actionsFlattened as $action) {
$action->setUseButtonTag(true);
return $negotiator->respond($request, array( }
'CurrentForm' => function() use($result) { }
return $result;
} $negotiator = $this->getResponseNegotiator();
)); $form = Form::create(
} $this, "EditForm", $fields, $actions
}); )->setHTMLID('Form_EditForm');
$form->addExtraClass('cms-edit-form');
// Announce the capability so the frontend can decide whether to allow preview or not. $form->loadDataFrom($record);
if ($record instanceof CMSPreviewable) { $form->setTemplate($this->getTemplatesWithSuffix('_EditForm'));
$form->addExtraClass('cms-previewable'); $form->setAttribute('data-pjax-fragment', 'CurrentForm');
} $form->setValidationResponseCallback(function() use ($negotiator, $form) {
$request = $this->getRequest();
// Set this if you want to split up tabs into a separate header row if($request->isAjax() && $negotiator) {
// if($form->Fields()->hasTabset()) { $form->setupFormErrors();
// $form->Fields()->findOrMakeTab('Root')->setTemplate('SilverStripe\\Forms\\CMSTabSet'); $result = $form->forTemplate();
// }
return $negotiator->respond($request, array(
// Add a default or custom validator. 'CurrentForm' => function() use($result) {
// @todo Currently the default Validator.js implementation return $result;
// adds javascript to the document body, meaning it won't }
// be included properly if the associated fields are loaded ));
// through ajax. This means only serverside validation }
// will kick in for pages+validation loaded through ajax. });
// This will be solved by using less obtrusive javascript validation
// in the future, see http://open.silverstripe.com/ticket/2915 and // Announce the capability so the frontend can decide whether to allow preview or not.
// http://open.silverstripe.com/ticket/3386 if ($record instanceof CMSPreviewable) {
if($record->hasMethod('getCMSValidator')) { $form->addExtraClass('cms-previewable');
$validator = $record->getCMSValidator(); }
// The clientside (mainly LeftAndMain*.js) rely on ajax responses
// which can be evaluated as javascript, hence we need // Set this if you want to split up tabs into a separate header row
// to override any global changes to the validation handler. // if($form->Fields()->hasTabset()) {
if($validator != NULL){ // $form->Fields()->findOrMakeTab('Root')->setTemplate('SilverStripe\\Forms\\CMSTabSet');
$form->setValidator($validator); // }
}
} else { // Add a default or custom validator.
$form->unsetValidator(); // @todo Currently the default Validator.js implementation
} // adds javascript to the document body, meaning it won't
// be included properly if the associated fields are loaded
if($record->hasMethod('canEdit') && !$record->canEdit()) { // through ajax. This means only serverside validation
$readonlyFields = $form->Fields()->makeReadonly(); // will kick in for pages+validation loaded through ajax.
$form->setFields($readonlyFields); // This will be solved by using less obtrusive javascript validation
} // in the future, see http://open.silverstripe.com/ticket/2915 and
} else { // http://open.silverstripe.com/ticket/3386
$form = $this->EmptyForm(); if($record->hasMethod('getCMSValidator')) {
} $validator = $record->getCMSValidator();
// The clientside (mainly LeftAndMain*.js) rely on ajax responses
return $form; // which can be evaluated as javascript, hence we need
} // to override any global changes to the validation handler.
if($validator != NULL){
/** $form->setValidator($validator);
* Returns a placeholder form, used by {@link getEditForm()} if no record is selected. }
* Our javascript logic always requires a form to be present in the CMS interface. } else {
* $form->unsetValidator();
* @return Form }
*/
public function EmptyForm() { if($record->hasMethod('canEdit') && !$record->canEdit()) {
$form = Form::create( $readonlyFields = $form->Fields()->makeReadonly();
$this, $form->setFields($readonlyFields);
"EditForm", }
new FieldList( } else {
// new HeaderField( $form = $this->EmptyForm();
// 'WelcomeHeader', }
// $this->getApplicationName()
// ), return $form;
// new LiteralField( }
// 'WelcomeText',
// sprintf('<p id="WelcomeMessage">%s %s. %s</p>', /**
// _t('LeftAndMain_right_ss.WELCOMETO','Welcome to'), * Returns a placeholder form, used by {@link getEditForm()} if no record is selected.
// $this->getApplicationName(), * Our javascript logic always requires a form to be present in the CMS interface.
// _t('CHOOSEPAGE','Please choose an item from the left.') *
// ) * @return Form
// ) */
), public function EmptyForm() {
new FieldList() $form = Form::create(
)->setHTMLID('Form_EditForm'); $this,
$form->unsetValidator(); "EditForm",
$form->addExtraClass('cms-edit-form'); new FieldList(
$form->addExtraClass('root-form'); // new HeaderField(
$form->setTemplate($this->getTemplatesWithSuffix('_EditForm')); // 'WelcomeHeader',
$form->setAttribute('data-pjax-fragment', 'CurrentForm'); // $this->getApplicationName()
// ),
return $form; // new LiteralField(
} // 'WelcomeText',
// sprintf('<p id="WelcomeMessage">%s %s. %s</p>',
/** // _t('LeftAndMain_right_ss.WELCOMETO','Welcome to'),
* Return the CMS's HTML-editor toolbar // $this->getApplicationName(),
*/ // _t('CHOOSEPAGE','Please choose an item from the left.')
public function EditorToolbar() { // )
return HTMLEditorField_Toolbar::create($this, "EditorToolbar"); // )
} ),
new FieldList()
/** )->setHTMLID('Form_EditForm');
* Renders a panel containing tools which apply to all displayed $form->unsetValidator();
* "content" (mostly through {@link EditForm()}), for example a tree navigation or a filter panel. $form->addExtraClass('cms-edit-form');
* Auto-detects applicable templates by naming convention: "<controller classname>_Tools.ss", $form->addExtraClass('root-form');
* and takes the most specific template (see {@link getTemplatesWithSuffix()}). $form->setTemplate($this->getTemplatesWithSuffix('_EditForm'));
* To explicitly disable the panel in the subclass, simply create a more specific, empty template. $form->setAttribute('data-pjax-fragment', 'CurrentForm');
*
* @return String HTML return $form;
*/ }
public function Tools() {
$templates = $this->getTemplatesWithSuffix('_Tools'); /**
if($templates) { * Return the CMS's HTML-editor toolbar
$viewer = new SSViewer($templates); */
return $viewer->process($this); public function EditorToolbar() {
} else { return HTMLEditorField_Toolbar::create($this, "EditorToolbar");
return false; }
}
} /**
* Renders a panel containing tools which apply to all displayed
/** * "content" (mostly through {@link EditForm()}), for example a tree navigation or a filter panel.
* Renders a panel containing tools which apply to the currently displayed edit form. * Auto-detects applicable templates by naming convention: "<controller classname>_Tools.ss",
* The main difference to {@link Tools()} is that the panel is displayed within * and takes the most specific template (see {@link getTemplatesWithSuffix()}).
* the element structure of the form panel (rendered through {@link EditForm}). * To explicitly disable the panel in the subclass, simply create a more specific, empty template.
* This means the panel will be loaded alongside new forms, and refreshed upon save, *
* which can mean a performance hit, depending on how complex your panel logic gets. * @return String HTML
* Any form fields contained in the returned markup will also be submitted with the main form, */
* which might be desired depending on the implementation details. public function Tools() {
* $templates = $this->getTemplatesWithSuffix('_Tools');
* @return String HTML if($templates) {
*/ $viewer = new SSViewer($templates);
public function EditFormTools() { return $viewer->process($this);
$templates = $this->getTemplatesWithSuffix('_EditFormTools'); } else {
if($templates) { return false;
$viewer = new SSViewer($templates); }
return $viewer->process($this); }
} else {
return false; /**
} * Renders a panel containing tools which apply to the currently displayed edit form.
} * The main difference to {@link Tools()} is that the panel is displayed within
* the element structure of the form panel (rendered through {@link EditForm}).
/** * This means the panel will be loaded alongside new forms, and refreshed upon save,
* Batch Actions Handler * which can mean a performance hit, depending on how complex your panel logic gets.
*/ * Any form fields contained in the returned markup will also be submitted with the main form,
public function batchactions() { * which might be desired depending on the implementation details.
return new CMSBatchActionHandler($this, 'batchactions', $this->stat('tree_class')); *
} * @return String HTML
*/
/** public function EditFormTools() {
* @return Form $templates = $this->getTemplatesWithSuffix('_EditFormTools');
*/ if($templates) {
public function BatchActionsForm() { $viewer = new SSViewer($templates);
$actions = $this->batchactions()->batchActionList(); return $viewer->process($this);
$actionsMap = array('-1' => _t('LeftAndMain.DropdownBatchActionsDefault', 'Choose an action...')); // Placeholder action } else {
foreach($actions as $action) { return false;
$actionsMap[$action->Link] = $action->Title; }
} }
$form = new Form( /**
$this, * Batch Actions Handler
'BatchActionsForm', */
new FieldList( public function batchactions() {
new HiddenField('csvIDs'), return new CMSBatchActionHandler($this, 'batchactions', $this->stat('tree_class'));
DropdownField::create( }
'Action',
false, /**
$actionsMap * @return Form
) */
->setAttribute('autocomplete', 'off') public function BatchActionsForm() {
->setAttribute('data-placeholder', _t('LeftAndMain.DropdownBatchActionsDefault', 'Choose an action...')) $actions = $this->batchactions()->batchActionList();
), $actionsMap = array('-1' => _t('LeftAndMain.DropdownBatchActionsDefault', 'Choose an action...')); // Placeholder action
new FieldList( foreach($actions as $action) {
// TODO i18n $actionsMap[$action->Link] = $action->Title;
new FormAction('submit', _t('Form.SubmitBtnLabel', "Go")) }
)
); $form = new Form(
$form->addExtraClass('cms-batch-actions form--no-dividers'); $this,
$form->unsetValidator(); 'BatchActionsForm',
new FieldList(
$this->extend('updateBatchActionsForm', $form); new HiddenField('csvIDs'),
return $form; DropdownField::create(
} 'Action',
false,
public function printable() { $actionsMap
$form = $this->getEditForm($this->currentPageID()); )
if(!$form) return false; ->setAttribute('autocomplete', 'off')
->setAttribute('data-placeholder', _t('LeftAndMain.DropdownBatchActionsDefault', 'Choose an action...'))
$form->transform(new PrintableTransformation()); ),
$form->setActions(null); new FieldList(
// TODO i18n
Requirements::clear(); new FormAction('submit', _t('Form.SubmitBtnLabel', "Go"))
Requirements::css(FRAMEWORK_ADMIN_DIR . '/dist/css/LeftAndMain_printable.css'); )
return array( );
"PrintForm" => $form $form->addExtraClass('cms-batch-actions form--no-dividers');
); $form->unsetValidator();
}
$this->extend('updateBatchActionsForm', $form);
/** return $form;
* Used for preview controls, mainly links which switch between different states of the page. }
*
* @return DBHTMLText public function printable() {
*/ $form = $this->getEditForm($this->currentPageID());
public function getSilverStripeNavigator() { if(!$form) return false;
$page = $this->currentPage();
if ($page instanceof CMSPreviewable) { $form->transform(new PrintableTransformation());
$navigator = new SilverStripeNavigator($page); $form->setActions(null);
return $navigator->renderWith($this->getTemplatesWithSuffix('_SilverStripeNavigator'));
} Requirements::clear();
return null; Requirements::css(FRAMEWORK_ADMIN_DIR . '/dist/css/LeftAndMain_printable.css');
} return array(
"PrintForm" => $form
/** );
* Identifier for the currently shown record, }
* in most cases a database ID. Inspects the following
* sources (in this order): /**
* - GET/POST parameter named 'ID' * Used for preview controls, mainly links which switch between different states of the page.
* - URL parameter named 'ID' *
* - Session value namespaced by classname, e.g. "CMSMain.currentPage" * @return DBHTMLText
* */
* @return int public function getSilverStripeNavigator() {
*/ $page = $this->currentPage();
public function currentPageID() { if ($page instanceof CMSPreviewable) {
if($this->getRequest()->requestVar('ID') && is_numeric($this->getRequest()->requestVar('ID'))) { $navigator = new SilverStripeNavigator($page);
return $this->getRequest()->requestVar('ID'); return $navigator->renderWith($this->getTemplatesWithSuffix('_SilverStripeNavigator'));
} elseif ($this->getRequest()->requestVar('CMSMainCurrentPageID') && is_numeric($this->getRequest()->requestVar('CMSMainCurrentPageID'))) { }
// see GridFieldDetailForm::ItemEditForm return null;
return $this->getRequest()->requestVar('CMSMainCurrentPageID'); }
} elseif (isset($this->urlParams['ID']) && is_numeric($this->urlParams['ID'])) {
return $this->urlParams['ID']; /**
} elseif(Session::get($this->sessionNamespace() . ".currentPage")) { * Identifier for the currently shown record,
return Session::get($this->sessionNamespace() . ".currentPage"); * in most cases a database ID. Inspects the following
} else { * sources (in this order):
return null; * - GET/POST parameter named 'ID'
} * - URL parameter named 'ID'
} * - Session value namespaced by classname, e.g. "CMSMain.currentPage"
*
/** * @return int
* Forces the current page to be set in session, */
* which can be retrieved later through {@link currentPageID()}. public function currentPageID() {
* Keep in mind that setting an ID through GET/POST or if($this->getRequest()->requestVar('ID') && is_numeric($this->getRequest()->requestVar('ID'))) {
* as a URL parameter will overrule this value. return $this->getRequest()->requestVar('ID');
* } elseif ($this->getRequest()->requestVar('CMSMainCurrentPageID') && is_numeric($this->getRequest()->requestVar('CMSMainCurrentPageID'))) {
* @param int $id // see GridFieldDetailForm::ItemEditForm
*/ return $this->getRequest()->requestVar('CMSMainCurrentPageID');
public function setCurrentPageID($id) { } elseif (isset($this->urlParams['ID']) && is_numeric($this->urlParams['ID'])) {
$id = (int)$id; return $this->urlParams['ID'];
Session::set($this->sessionNamespace() . ".currentPage", $id); } elseif(Session::get($this->sessionNamespace() . ".currentPage")) {
} return Session::get($this->sessionNamespace() . ".currentPage");
} else {
/** return null;
* Uses {@link getRecord()} and {@link currentPageID()} }
* to get the currently selected record. }
*
* @return DataObject /**
*/ * Forces the current page to be set in session,
public function currentPage() { * which can be retrieved later through {@link currentPageID()}.
return $this->getRecord($this->currentPageID()); * Keep in mind that setting an ID through GET/POST or
} * as a URL parameter will overrule this value.
*
/** * @param int $id
* Compares a given record to the currently selected one (if any). */
* Used for marking the current tree node. public function setCurrentPageID($id) {
* $id = (int)$id;
* @param DataObject $record Session::set($this->sessionNamespace() . ".currentPage", $id);
* @return bool }
*/
public function isCurrentPage(DataObject $record) { /**
return ($record->ID == $this->currentPageID()); * Uses {@link getRecord()} and {@link currentPageID()}
} * to get the currently selected record.
*
/** * @return DataObject
* @return String */
*/ public function currentPage() {
protected function sessionNamespace() { return $this->getRecord($this->currentPageID());
$override = $this->stat('session_namespace'); }
return $override ? $override : $this->class;
} /**
* Compares a given record to the currently selected one (if any).
/** * Used for marking the current tree node.
* URL to a previewable record which is shown through this controller. *
* The controller might not have any previewable content, in which case * @param DataObject $record
* this method returns FALSE. * @return bool
* */
* @return String|boolean public function isCurrentPage(DataObject $record) {
*/ return ($record->ID == $this->currentPageID());
public function LinkPreview() { }
return false;
} /**
* @return String
/** */
* Return the version number of this application. protected function sessionNamespace() {
* Uses the number in <mymodule>/silverstripe_version $override = $this->stat('session_namespace');
* (automatically replaced by build scripts). return $override ? $override : $this->class;
* If silverstripe_version is empty, }
* then attempts to get it from composer.lock
* /**
* @return string * URL to a previewable record which is shown through this controller.
*/ * The controller might not have any previewable content, in which case
public function CMSVersion() { * this method returns FALSE.
$versions = array(); *
$modules = array( * @return String|boolean
'silverstripe/framework' => array( */
'title' => 'Framework', public function LinkPreview() {
'versionFile' => FRAMEWORK_PATH . '/silverstripe_version', return false;
) }
);
if(defined('CMS_PATH')) { /**
$modules['silverstripe/cms'] = array( * Return the version number of this application.
'title' => 'CMS', * Uses the number in <mymodule>/silverstripe_version
'versionFile' => CMS_PATH . '/silverstripe_version', * (automatically replaced by build scripts).
); * If silverstripe_version is empty,
} * then attempts to get it from composer.lock
*
// Tries to obtain version number from composer.lock if it exists * @return string
$composerLockPath = BASE_PATH . '/composer.lock'; */
if (file_exists($composerLockPath)) { public function CMSVersion() {
$cache = Cache::factory('LeftAndMain_CMSVersion'); $versions = array();
$cacheKey = filemtime($composerLockPath); $modules = array(
$versions = $cache->load($cacheKey); 'silverstripe/framework' => array(
if($versions) { 'title' => 'Framework',
$versions = json_decode($versions, true); 'versionFile' => FRAMEWORK_PATH . '/silverstripe_version',
} else { )
$versions = array(); );
} if(defined('CMS_PATH')) {
if(!$versions && $jsonData = file_get_contents($composerLockPath)) { $modules['silverstripe/cms'] = array(
$lockData = json_decode($jsonData); 'title' => 'CMS',
if($lockData && isset($lockData->packages)) { 'versionFile' => CMS_PATH . '/silverstripe_version',
foreach ($lockData->packages as $package) { );
if( }
array_key_exists($package->name, $modules)
&& isset($package->version) // Tries to obtain version number from composer.lock if it exists
) { $composerLockPath = BASE_PATH . '/composer.lock';
$versions[$package->name] = $package->version; if (file_exists($composerLockPath)) {
} $cache = Cache::factory('LeftAndMain_CMSVersion');
} $cacheKey = filemtime($composerLockPath);
$cache->save(json_encode($versions), $cacheKey); $versions = $cache->load($cacheKey);
} if($versions) {
} $versions = json_decode($versions, true);
} } else {
$versions = array();
// Fall back to static version file }
foreach($modules as $moduleName => $moduleSpec) { if(!$versions && $jsonData = file_get_contents($composerLockPath)) {
if(!isset($versions[$moduleName])) { $lockData = json_decode($jsonData);
if($staticVersion = file_get_contents($moduleSpec['versionFile'])) { if($lockData && isset($lockData->packages)) {
$versions[$moduleName] = $staticVersion; foreach ($lockData->packages as $package) {
} else { if(
$versions[$moduleName] = _t('LeftAndMain.VersionUnknown', 'Unknown'); array_key_exists($package->name, $modules)
} && isset($package->version)
} ) {
} $versions[$package->name] = $package->version;
}
$out = array(); }
foreach($modules as $moduleName => $moduleSpec) { $cache->save(json_encode($versions), $cacheKey);
$out[] = $modules[$moduleName]['title'] . ': ' . $versions[$moduleName]; }
} }
return implode(', ', $out); }
}
// Fall back to static version file
/** foreach($modules as $moduleName => $moduleSpec) {
* @return array if(!isset($versions[$moduleName])) {
*/ if($staticVersion = file_get_contents($moduleSpec['versionFile'])) {
public function SwitchView() { $versions[$moduleName] = $staticVersion;
if($page = $this->currentPage()) { } else {
$nav = SilverStripeNavigator::get_for_record($page); $versions[$moduleName] = _t('LeftAndMain.VersionUnknown', 'Unknown');
return $nav['items']; }
} }
} }
/** $out = array();
* @return SiteConfig foreach($modules as $moduleName => $moduleSpec) {
*/ $out[] = $modules[$moduleName]['title'] . ': ' . $versions[$moduleName];
public function SiteConfig() { }
return (class_exists('SilverStripe\\SiteConfig\\SiteConfig')) ? SiteConfig::current_site_config() : null; return implode(', ', $out);
} }
/** /**
* The href for the anchor on the Silverstripe logo. * @return array
* Set by calling LeftAndMain::set_application_link() */
* public function SwitchView() {
* @config if($page = $this->currentPage()) {
* @var String $nav = SilverStripeNavigator::get_for_record($page);
*/ return $nav['items'];
private static $application_link = '//www.silverstripe.org/'; }
}
/**
* @return String /**
*/ * @return SiteConfig
public function ApplicationLink() { */
return $this->stat('application_link'); public function SiteConfig() {
} return (class_exists('SilverStripe\\SiteConfig\\SiteConfig')) ? SiteConfig::current_site_config() : null;
}
/**
* The application name. Customisable by calling /**
* LeftAndMain::setApplicationName() - the first parameter. * The href for the anchor on the Silverstripe logo.
* * Set by calling LeftAndMain::set_application_link()
* @config *
* @var String * @config
*/ * @var String
private static $application_name = 'SilverStripe'; */
private static $application_link = '//www.silverstripe.org/';
/**
* Get the application name. /**
* * @return String
* @return string */
*/ public function ApplicationLink() {
public function getApplicationName() { return $this->stat('application_link');
return $this->stat('application_name'); }
}
/**
/** * The application name. Customisable by calling
* @return string * LeftAndMain::setApplicationName() - the first parameter.
*/ *
public function Title() { * @config
$app = $this->getApplicationName(); * @var String
*/
return ($section = $this->SectionTitle()) ? sprintf('%s - %s', $app, $section) : $app; private static $application_name = 'SilverStripe';
}
/**
/** * Get the application name.
* Return the title of the current section. Either this is pulled from *
* the current panel's menu_title or from the first active menu * @return string
* */
* @return string public function getApplicationName() {
*/ return $this->stat('application_name');
public function SectionTitle() { }
$title = $this->menu_title();
if($title) { /**
return $title; * @return string
} */
public function Title() {
foreach($this->MainMenu() as $menuItem) { $app = $this->getApplicationName();
if($menuItem->LinkingMode != 'link') {
return $menuItem->Title; return ($section = $this->SectionTitle()) ? sprintf('%s - %s', $app, $section) : $app;
} }
}
} /**
* Return the title of the current section. Either this is pulled from
/** * the current panel's menu_title or from the first active menu
* Same as {@link ViewableData->CSSClasses()}, but with a changed name *
* to avoid problems when using {@link ViewableData->customise()} * @return string
* (which always returns "ArrayData" from the $original object). */
* public function SectionTitle() {
* @return String $title = $this->menu_title();
*/ if($title) {
public function BaseCSSClasses() { return $title;
return $this->CSSClasses('SilverStripe\\Control\\Controller'); }
}
foreach($this->MainMenu() as $menuItem) {
/** if($menuItem->LinkingMode != 'link') {
* @return String return $menuItem->Title;
*/ }
public function Locale() { }
return DBField::create_field('Locale', i18n::get_locale()); }
}
/**
public function providePermissions() { * Same as {@link ViewableData->CSSClasses()}, but with a changed name
$perms = array( * to avoid problems when using {@link ViewableData->customise()}
"CMS_ACCESS_LeftAndMain" => array( * (which always returns "ArrayData" from the $original object).
'name' => _t('CMSMain.ACCESSALLINTERFACES', 'Access to all CMS sections'), *
'category' => _t('Permission.CMS_ACCESS_CATEGORY', 'CMS Access'), * @return String
'help' => _t('CMSMain.ACCESSALLINTERFACESHELP', 'Overrules more specific access settings.'), */
'sort' => -100 public function BaseCSSClasses() {
) return $this->CSSClasses('SilverStripe\\Control\\Controller');
); }
// Add any custom ModelAdmin subclasses. Can't put this on ModelAdmin itself /**
// since its marked abstract, and needs to be singleton instanciated. * @return String
foreach(ClassInfo::subclassesFor('SilverStripe\\Admin\\ModelAdmin') as $i => $class) { */
if ($class == 'SilverStripe\\Admin\\ModelAdmin') { public function Locale() {
continue; return DBField::create_field('Locale', i18n::get_locale());
} }
if (ClassInfo::classImplements($class, 'SilverStripe\\Dev\\TestOnly')) {
continue; public function providePermissions() {
} $perms = array(
"CMS_ACCESS_LeftAndMain" => array(
// Check if modeladmin has explicit required_permission_codes option. 'name' => _t('CMSMain.ACCESSALLINTERFACES', 'Access to all CMS sections'),
// If a modeladmin is namespaced you can apply this config to override 'category' => _t('Permission.CMS_ACCESS_CATEGORY', 'CMS Access'),
// the default permission generation based on fully qualified class name. 'help' => _t('CMSMain.ACCESSALLINTERFACESHELP', 'Overrules more specific access settings.'),
$code = $this->getRequiredPermissions(); 'sort' => -100
if (!$code) { )
continue; );
}
// Get first permission if multiple specified // Add any custom ModelAdmin subclasses. Can't put this on ModelAdmin itself
if (is_array($code)) { // since its marked abstract, and needs to be singleton instanciated.
$code = reset($code); foreach(ClassInfo::subclassesFor('SilverStripe\\Admin\\ModelAdmin') as $i => $class) {
} if ($class == 'SilverStripe\\Admin\\ModelAdmin') {
$title = LeftAndMain::menu_title($class); continue;
$perms[$code] = array( }
'name' => _t( if (ClassInfo::classImplements($class, 'SilverStripe\\Dev\\TestOnly')) {
'CMSMain.ACCESS', continue;
"Access to '{title}' section", }
"Item in permission selection identifying the admin section. Example: Access to 'Files & Images'",
array('title' => $title) // Check if modeladmin has explicit required_permission_codes option.
), // If a modeladmin is namespaced you can apply this config to override
'category' => _t('Permission.CMS_ACCESS_CATEGORY', 'CMS Access') // the default permission generation based on fully qualified class name.
); $code = $this->getRequiredPermissions();
} if (!$code) {
continue;
return $perms; }
} // Get first permission if multiple specified
if (is_array($code)) {
$code = reset($code);
}
$title = LeftAndMain::menu_title($class);
$perms[$code] = array(
'name' => _t(
'CMSMain.ACCESS',
"Access to '{title}' section",
"Item in permission selection identifying the admin section. Example: Access to 'Files & Images'",
array('title' => $title)
),
'category' => _t('Permission.CMS_ACCESS_CATEGORY', 'CMS Access')
);
}
return $perms;
}
} }

View File

@ -792,6 +792,7 @@ specific functions.
Run `composer require --dev 'phpunit/phpunit:~4.8'` on existing projects to pull in the new dependency. Run `composer require --dev 'phpunit/phpunit:~4.8'` on existing projects to pull in the new dependency.
* Admin URL can now be configured via custom Director routing rule * Admin URL can now be configured via custom Director routing rule
* `Controller::init` visibility changed to protected. Use `Controller::doInit()` instead. * `Controller::init` visibility changed to protected. Use `Controller::doInit()` instead.
* `Controller::join_links` supports an array of link sections.
* `Object::useCustomClass` has been removed. You should use the config API with Injector instead. * `Object::useCustomClass` has been removed. You should use the config API with Injector instead.
* `Object::invokeWithExtensions` now has the same method signature as `Object::extend` and behaves the same way. * `Object::invokeWithExtensions` now has the same method signature as `Object::extend` and behaves the same way.
* `ServiceConfigurationLocator` is now an interface not a class. * `ServiceConfigurationLocator` is now an interface not a class.

View File

@ -283,6 +283,11 @@ class ControllerTest extends FunctionalTest {
/* Does type-safe checks for zero value */ /* Does type-safe checks for zero value */
$this->assertEquals("my-page/0", Controller::join_links("my-page", 0)); $this->assertEquals("my-page/0", Controller::join_links("my-page", 0));
// Test array args
$this->assertEquals("admin/crm/MyForm?a=1&b=2&c=3",
Controller::join_links(["?a=1", "admin/crm", "?b=2", "MyForm?c=3"])
);
} }
public function testLink() { public function testLink() {

View File

@ -10,9 +10,6 @@ use SilverStripe\Forms\RequiredFields;
use SilverStripe\Forms\FormAction; use SilverStripe\Forms\FormAction;
use SilverStripe\Forms\PopoverField; use SilverStripe\Forms\PopoverField;
class FormSchemaTest extends SapphireTest { class FormSchemaTest extends SapphireTest {
public function testGetSchema() { public function testGetSchema() {
@ -23,7 +20,7 @@ class FormSchemaTest extends SapphireTest {
'id' => 'Form_TestForm', 'id' => 'Form_TestForm',
'action' => 'Controller/TestForm', 'action' => 'Controller/TestForm',
'method' => 'POST', 'method' => 'POST',
'schema_url' => '', 'schema_url' => 'admin/mysection/schema',
'attributes' => [ 'attributes' => [
'id' => 'Form_TestForm', 'id' => 'Form_TestForm',
'action' => 'Controller/TestForm', 'action' => 'Controller/TestForm',
@ -56,7 +53,7 @@ class FormSchemaTest extends SapphireTest {
'actions' => [] 'actions' => []
]; ];
$schema = $formSchema->getSchema($form); $schema = $formSchema->getSchema($form, 'admin/mysection/schema');
$this->assertInternalType('array', $schema); $this->assertInternalType('array', $schema);
$this->assertJsonStringEqualsJsonString(json_encode($expected), json_encode($schema)); $this->assertJsonStringEqualsJsonString(json_encode($expected), json_encode($schema));
} }
@ -174,7 +171,7 @@ class FormSchemaTest extends SapphireTest {
'id' => 'Form_TestForm', 'id' => 'Form_TestForm',
'action' => 'Controller/TestForm', 'action' => 'Controller/TestForm',
'method' => 'POST', 'method' => 'POST',
'schema_url' => '', 'schema_url' => 'admin/mysection/schema',
'attributes' => [ 'attributes' => [
'id' => 'Form_TestForm', 'id' => 'Form_TestForm',
'action' => 'Controller/TestForm', 'action' => 'Controller/TestForm',
@ -339,9 +336,27 @@ class FormSchemaTest extends SapphireTest {
] ]
]; ];
$schema = $formSchema->getSchema($form); $schema = $formSchema->getSchema($form, 'admin/mysection/schema');
$this->assertInternalType('array', $schema); $this->assertInternalType('array', $schema);
$this->assertJsonStringEqualsJsonString(json_encode($expected), json_encode($schema)); $this->assertJsonStringEqualsJsonString(json_encode($expected), json_encode($schema));
} }
/**
* Test that schema is merged correctly
*/
public function testMergeSchema() {
$publishAction = FormAction::create('publish', 'Publish');
$publishAction->setIcon('save');
$publishAction->setSchemaData(['data' => ['buttonStyle' => 'primary']]);
$schema = $publishAction->getSchemaData();
$this->assertEquals(
[
'icon' => 'save',
'buttonStyle' => 'primary',
],
$schema['data']
);
}
} }