diff --git a/Control/Controller.php b/Control/Controller.php index e4f641d58..375718efc 100644 --- a/Control/Controller.php +++ b/Control/Controller.php @@ -695,12 +695,15 @@ class Controller extends RequestHandler implements TemplateGlobalProvider { * * 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 */ - public static function join_links() { - $args = func_get_args(); + public static function join_links($arg = null) { + if (func_num_args() === 1 && is_array($arg)) { + $args = $arg; + } else { + $args = func_get_args(); + } $result = ""; $queryargs = array(); $fragmentIdentifier = null; diff --git a/Forms/FormField.php b/Forms/FormField.php index 0f4d54aba..ad000116a 100644 --- a/Forms/FormField.php +++ b/Forms/FormField.php @@ -1424,7 +1424,7 @@ class FormField extends RequestHandler { * @return array */ public function getSchemaData() { - return array_merge($this->getSchemaDataDefaults(), $this->schemaData); + return array_replace_recursive($this->getSchemaDataDefaults(), $this->schemaData); } /** diff --git a/Forms/Schema/FormSchema.php b/Forms/Schema/FormSchema.php index 17bd3b8c7..f8335d86b 100644 --- a/Forms/Schema/FormSchema.php +++ b/Forms/Schema/FormSchema.php @@ -17,18 +17,16 @@ class FormSchema { * Gets the schema for this form as a nested array. * * @param Form $form + * @param string $schemaLink Link to get this schema * @return array */ - public function getSchema(Form $form) { - $request = $form->getController()->getRequest(); - + public function getSchema(Form $form, $schemaLink) { $schema = [ 'name' => $form->getName(), 'id' => $form->FormName(), 'action' => $form->FormAction(), 'method' => $form->FormMethod(), - // @todo Not really reliable. Refactor into action on $this->Link('schema') - 'schema_url' => $request->getURL(), + 'schema_url' => $schemaLink, 'attributes' => $form->getAttributes(), 'data' => [], 'fields' => [], @@ -62,7 +60,10 @@ class FormSchema { ]; // 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()) { $state['messages'][] = [ @@ -76,6 +77,7 @@ class FormSchema { protected function getFieldStates($fields) { $states = []; + /** @var FormField $field */ foreach ($fields as $field) { $states[] = $field->getSchemaState(); diff --git a/ORM/Versioning/ChangeSetItem.php b/ORM/Versioning/ChangeSetItem.php index e4f005837..e965f619b 100644 --- a/ORM/Versioning/ChangeSetItem.php +++ b/ORM/Versioning/ChangeSetItem.php @@ -5,6 +5,7 @@ namespace SilverStripe\ORM\Versioning; use SilverStripe\Admin\CMSPreviewable; use SilverStripe\Assets\Thumbnail; use SilverStripe\Control\Controller; +use SilverStripe\ORM\ArrayList; use SilverStripe\ORM\DataList; use SilverStripe\ORM\DataObject; use SilverStripe\ORM\ManyManyList; @@ -109,8 +110,8 @@ class ChangeSetItem extends DataObject implements Thumbnail { /** * Get the type of change: none, created, deleted, modified, manymany - * * @return string + * @throws UnexpectedDataException */ public function getChangeType() { if(!class_exists($this->ObjectClass)) { @@ -146,7 +147,8 @@ class ChangeSetItem extends DataObject implements Thumbnail { * Find version of this object in the given stage * * @param string $stage - * @return Versioned|DataObject + * @return DataObject|Versioned + * @throws UnexpectedDataException */ protected function getObjectInStage($stage) { if(!class_exists($this->ObjectClass)) { @@ -158,8 +160,8 @@ class ChangeSetItem extends DataObject implements Thumbnail { /** * Find latest version of this object - * - * @return Versioned|DataObject + * @return DataObject|Versioned + * @throws UnexpectedDataException */ protected function getObjectLatestVersion() { if(!class_exists($this->ObjectClass)) { @@ -177,14 +179,22 @@ class ChangeSetItem extends DataObject implements Thumbnail { public function findReferenced() { if($this->getChangeType() === ChangeSetItem::CHANGE_DELETED) { // 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 { // If changed on stage, look at owned objects there - return $this->getObjectInStage(Versioned::DRAFT)->findOwned()->filterByCallback(function ($owned) { - /** @var Versioned|DataObject $owned */ - return $owned->stagesDiffer(Versioned::DRAFT, Versioned::LIVE); - }); + $record = $this->getObjectInStage(Versioned::DRAFT); + if ($record) { + return $record->findOwned()->filterByCallback(function ($owned) { + /** @var Versioned|DataObject $owned */ + return $owned->stagesDiffer(Versioned::DRAFT, Versioned::LIVE); + }); + } } + // Empty set + return new ArrayList(); } /** diff --git a/admin/client/dist/js/bundle.js b/admin/client/dist/js/bundle.js index 6dd331ec5..ce75f76f5 100644 --- a/admin/client/dist/js/bundle.js +++ b/admin/client/dist/js/bundle.js @@ -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=[] 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) -if("undefined"!=typeof n.id){var r={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) +"undefined"!=typeof n.id&&!function(){var t={SecurityID:e.props.config.SecurityID} +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) - -}},{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"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={} -return 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 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},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 n=e.find(function(e){return e.id===t}) 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 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 @@ -356,27 +357,27 @@ if(null!==e.component&&void 0===n)throw Error("Component not found in injector: var i=c({},e,t) null===i.value&&delete i.value 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.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.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, -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 +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 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.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 var t=this.getFormSchema(),n=this.props.form[e] if(!t||!t.schema)return null var i=c({},t.schema.attributes,{className:t.schema.attributes["class"],encType:t.schema.attributes.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, -mapFieldsToComponents:this.mapFieldsToComponents} -return h["default"].createElement(E["default"],o)}}]),t}(C["default"]) +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, +mapActionsToComponents:this.mapActionsToComponents,mapFieldsToComponents:this.mapFieldsToComponents} +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, 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" 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){ 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}) @@ -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, 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), -schemaUrl:e+"/"+this.props.params.id} +return p["default"].createElement(A["default"],e)}},{key:"renderDetailEditView",value:function m(){var e=this.props.sectionConfig.form.DetailEditForm.schemaUrl,t=e +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"],{ 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), -schemaUrl:e+"/"+this.props.params.id} +},p["default"].createElement(I["default"],n))))}},{key:"renderCreateView",value:function g(){var e=this.props.sectionConfig.form.DetailEditForm.schemaUrl,t=e +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"],{ 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)}}) 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)}}) diff --git a/admin/client/src/components/FormBuilder/FormBuilder.js b/admin/client/src/components/FormBuilder/FormBuilder.js index f8aa357be..32cd90f75 100644 --- a/admin/client/src/components/FormBuilder/FormBuilder.js +++ b/admin/client/src/components/FormBuilder/FormBuilder.js @@ -105,15 +105,23 @@ export class FormBuilderComponent extends SilverStripeComponent { SecurityID: this.props.config.SecurityID, }; - if (formSchema.schema.actions.length > 0) { - defaultData[formSchema.schema.actions[0].name] = 1; - } + this.submitApi = (...args) => { + const endPoint = backend.createEndpointFetcher({ + url: formSchema.schema.attributes.action, + method: formSchema.schema.attributes.method, + defaultData, + }); - this.submitApi = backend.createEndpointFetcher({ - url: formSchema.schema.attributes.action, - method: formSchema.schema.attributes.method, - defaultData, - }); + // Ensure that schema changes are handled prior to updating state + return endPoint(...args) + .then((response) => { + 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); } @@ -248,6 +256,14 @@ export class FormBuilderComponent extends SilverStripeComponent { ? schema.state.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 .reduce((prev, curr) => { const match = this.findField(fields, curr.id); @@ -258,7 +274,11 @@ export class FormBuilderComponent extends SilverStripeComponent { return Object.assign({}, prev, { [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); if (field.children) { - data.children = this.getFieldData(field.children, formState); + return Object.assign({}, data, { + children: this.getFieldData(field.children, formState), + }); } return data; @@ -451,9 +473,10 @@ export class FormBuilderComponent extends SilverStripeComponent { delete attributes.enctype; const fieldData = this.getFieldData(formSchema.schema.fields, formState); + const actionData = this.getFieldData(formSchema.schema.actions, formState); const formProps = { - actions: formSchema.schema.actions, + actions: actionData, attributes, componentWillUnmount: this.removeForm, data: formSchema.schema.data, diff --git a/admin/client/src/components/FormBuilder/tests/FormBuilder-test.js b/admin/client/src/components/FormBuilder/tests/FormBuilder-test.js index 78dfa3545..10177fd93 100644 --- a/admin/client/src/components/FormBuilder/tests/FormBuilder-test.js +++ b/admin/client/src/components/FormBuilder/tests/FormBuilder-test.js @@ -63,6 +63,7 @@ describe('FormBuilderComponent', () => { props = { form: { MyForm: { + submitAction: 'action_save', fields: [ { id: 'fieldOne', value: 'valOne' }, { id: 'fieldTwo', value: null }, @@ -89,6 +90,7 @@ describe('FormBuilderComponent', () => { fieldValues = formBuilder.getFieldValues(); expect(fieldValues).toEqual({ + action_save: 1, fieldOne: 'valOne', fieldTwo: null, }); diff --git a/admin/client/src/containers/CampaignAdmin/CampaignAdmin.js b/admin/client/src/containers/CampaignAdmin/CampaignAdmin.js index 507afd11c..14c8a87ad 100644 --- a/admin/client/src/containers/CampaignAdmin/CampaignAdmin.js +++ b/admin/client/src/containers/CampaignAdmin/CampaignAdmin.js @@ -170,9 +170,13 @@ class CampaignAdmin extends SilverStripeComponent { */ renderDetailEditView() { 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 = { createFn: this.campaignEditCreateFn.bind(this), - schemaUrl: `${baseSchemaUrl}/${this.props.params.id}`, + schemaUrl, }; return ( @@ -195,9 +199,13 @@ class CampaignAdmin extends SilverStripeComponent { */ renderCreateView() { 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 = { createFn: this.campaignAddCreateFn.bind(this), - schemaUrl: `${baseSchemaUrl}/${this.props.params.id}`, + schemaUrl, }; return ( diff --git a/admin/client/src/state/form/FormActions.js b/admin/client/src/state/form/FormActions.js index e66482317..f903963be 100644 --- a/admin/client/src/state/form/FormActions.js +++ b/admin/client/src/state/form/FormActions.js @@ -58,7 +58,7 @@ export function addForm(formState) { export function submitForm(submitApi, formId, fieldValues) { return (dispatch) => { const headers = { - 'X-Formschema-Request': 'state', + 'X-Formschema-Request': 'schema,state', 'X-Requested-With': 'XMLHttpRequest', }; dispatch({ diff --git a/admin/code/LeftAndMain.php b/admin/code/LeftAndMain.php index 9b80a4a52..cfdf6d561 100644 --- a/admin/code/LeftAndMain.php +++ b/admin/code/LeftAndMain.php @@ -66,1990 +66,2007 @@ use SilverStripe\SiteConfig\SiteConfig; */ class LeftAndMain extends Controller implements PermissionProvider { - /** - * Enable front-end debugging (increases verbosity) in dev mode. - * Will be ignored in live environments. - * - * @var bool - */ - private static $client_debugging = true; - - /** - * The current url segment attached to the LeftAndMain instance - * - * @config - * @var string - */ - private static $url_segment; - - /** - * @config - * @var string - */ - private static $url_rule = '/$Action/$ID/$OtherID'; - - /** - * @config - * @var string - */ - private static $menu_title; - - /** - * @config - * @var string - */ - private static $menu_icon; - - /** - * @config - * @var int - */ - private static $menu_priority = 0; - - /** - * @config - * @var int - */ - private static $url_priority = 50; - - /** - * A subclass of {@link DataObject}. - * - * Determines what is managed in this interface, through - * {@link getEditForm()} and other logic. - * - * @config - * @var string - */ - private static $tree_class = null; - - /** - * The url used for the link in the Help tab in the backend - * - * @config - * @var string - */ - private static $help_link = '//userhelp.silverstripe.org/framework/en/3.3'; - - /** - * @var array - */ - private static $allowed_actions = [ - 'index', - 'save', - 'savetreenode', - 'getsubtree', - 'updatetreenodes', - 'printable', - 'show', - 'EditorToolbar', - 'EditForm', - 'AddForm', - 'batchactions', - 'BatchActionsForm', - 'schema', - ]; - - private static $url_handlers = [ - 'GET schema/$FormName/$ItemID' => 'schema' - ]; - - private static $dependencies = [ - 'schema' => '%$FormSchema' - ]; - - /** - * Assign themes to use for cms - * - * @config - * @var array - */ - private static $admin_themes = [ - 'silverstripe/framework:/admin/themes/cms-forms', - SSViewer::DEFAULT_THEME, - ]; - - /** - * Codes which are required from the current user to view this controller. - * If multiple codes are provided, all of them are required. - * All CMS controllers require "CMS_ACCESS_LeftAndMain" as a baseline check, - * and fall back to "CMS_ACCESS_" if no permissions are defined here. - * See {@link canView()} for more details on permission checks. - * - * @config - * @var array - */ - private static $required_permission_codes; - - /** - * @config - * @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 - * controllers are logically bundled together, and mainly separated - * to achieve more flexible templating. - */ - private static $session_namespace; - - /** - * Register additional requirements through the {@link Requirements} class. - * Used mainly to work around the missing "lazy loading" functionality - * for getting css/javascript required after an ajax-call (e.g. loading the editform). - * - * YAML configuration example: - * - * LeftAndMain: - * extra_requirements_javascript: - * - mysite/javascript/myscript.js - * - * - * @config - * @var array - */ - private static $extra_requirements_javascript = array(); - - /** - * YAML configuration example: - * - * LeftAndMain: - * extra_requirements_css: - * - mysite/css/mystyle.css: - * media: screen - * - * - * @config - * @var array See {@link extra_requirements_javascript} - */ - private static $extra_requirements_css = array(); - - /** - * @config - * @var array See {@link extra_requirements_javascript} - */ - private static $extra_requirements_themedCss = array(); - - /** - * If true, call a keepalive ping every 5 minutes from the CMS interface, - * to ensure that the session never dies. - * - * @config - * @var boolean - */ - private static $session_keepalive_ping = true; - - /** - * Value of X-Frame-Options header - * - * @config - * @var string - */ - private static $frame_options = 'SAMEORIGIN'; - - /** - * @var PjaxResponseNegotiator - */ - protected $responseNegotiator; - - /** - * Gets the combined configuration of all LeafAndMain subclasses required by the client app. - * - * @return array - * - * WARNING: Experimental API - */ - public function getCombinedClientConfig() { - $combinedClientConfig = ['sections' => []]; - $cmsClassNames = CMSMenu::get_cms_classes('SilverStripe\\Admin\\LeftAndMain', true, CMSMenu::URL_PRIORITY); - - foreach ($cmsClassNames as $className) { - $combinedClientConfig['sections'][$className] = Injector::inst()->get($className)->getClientConfig(); - } - - // Pass in base url (absolute and relative) - $combinedClientConfig['baseUrl'] = Director::baseURL(); - $combinedClientConfig['absoluteBaseUrl'] = Director::absoluteBaseURL(); - $combinedClientConfig['adminUrl'] = AdminRootController::admin_url(); - - // Get "global" CSRF token for use in JavaScript - $token = SecurityToken::inst(); - $combinedClientConfig[$token->getName()] = $token->getValue(); - - // Set env - $combinedClientConfig['environment'] = Director::get_environment_type(); - $combinedClientConfig['debugging'] = $this->config()->client_debugging; - - return Convert::raw2json($combinedClientConfig); - } - - /** - * Returns configuration required by the client app. - * - * @return array - * - * WARNING: Experimental API - */ - public function getClientConfig() { - return [ - // Trim leading/trailing slash to make it easier to concatenate URL - // and use in routing definitions. - 'url' => trim($this->Link(), '/'), - ]; - } - - /** - * Gets a JSON schema representing the current edit form. - * - * WARNING: Experimental API. - * - * @param HTTPRequest $request - * @return HTTPResponse - */ - public function schema($request) { - $response = $this->getResponse(); - $formName = $request->param('FormName'); - $itemID = $request->param('ItemID'); - - if (!$formName) { - return (new HTTPResponse('Missing request params', 400)); - } - - if(!$this->hasMethod("get{$formName}")) { - return (new HTTPResponse('Form not found', 404)); - } - - if(!$this->hasAction($formName)) { - return (new HTTPResponse('Form not accessible', 401)); - } - - $form = $this->{"get{$formName}"}($itemID); - - $response->addHeader('Content-Type', 'application/json'); - $response->setBody(Convert::raw2json($this->getSchemaForForm($form))); - - return $response; - } - - /** - * Given a form, generate a response containing the requested form - * schema if X-Formschema-Request header is set. - * - * @param Form $form - * @return HTTPResponse - */ - protected function getSchemaResponse($form) { - $request = $this->getRequest(); - if($request->getHeader('X-Formschema-Request')) { - $data = $this->getSchemaForForm($form); - $response = new HTTPResponse(Convert::raw2json($data)); - $response->addHeader('Content-Type', 'application/json'); - return $response; - } - return null; - } - - /** - * Returns a representation of the provided {@link Form} as structured data, - * based on the request data. - * - * @param Form $form - * @return array - */ - protected function getSchemaForForm(Form $form) { - $request = $this->getRequest(); - $return = null; - - // 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 - // and used to construct the response body. - if ($schemaHeader = $request->getHeader('X-Formschema-Request')) { - $schemaParts = array_filter(explode(',', $schemaHeader), function($value) { - $validHeaderValues = ['schema', 'state']; - return in_array(trim($value), $validHeaderValues); - }); - } else { - $schemaParts = ['schema']; - } - - $return = ['id' => $form->FormName()]; - - if (in_array('schema', $schemaParts)) { - $return['schema'] = $this->schema->getSchema($form); - } - - if (in_array('state', $schemaParts)) { - $return['state'] = $this->schema->getState($form); - } - - return $return; - } - - /** - * @param Member $member - * @return boolean - */ - public function canView($member = null) { - if(!$member && $member !== FALSE) $member = Member::currentUser(); - - // cms menus only for logged-in members - if(!$member) return false; - - // alternative extended checks - if($this->hasMethod('alternateAccessCheck')) { - $alternateAllowed = $this->alternateAccessCheck(); - if($alternateAllowed === false) { - return false; - } - } - - // Check for "CMS admin" permission - if(Permission::checkMember($member, "CMS_ACCESS_LeftAndMain")) { - return true; - } - - // Check for LeftAndMain sub-class permissions - $codes = $this->getRequiredPermissions(); - if($codes === false) { // allow explicit FALSE to disable subclass check - return true; - } - foreach((array)$codes as $code) { - if(!Permission::checkMember($member, $code)) { - return false; - } - } - - return true; - } - - /** - * Get list of required permissions - * - * @return array|string|bool Code, array of codes, or false if no permission required - */ - public static function getRequiredPermissions() { - $class = get_called_class(); - $code = Config::inst()->get($class, 'required_permission_codes', Config::FIRST_SET); - if ($code === false) { - return false; - } - if ($code) { - return $code; - } - return "CMS_ACCESS_" . $class; - } - - /** - * @uses LeftAndMainExtension->init() - * @uses LeftAndMainExtension->accessedCMS() - * @uses CMSMenu - */ - protected function init() { - parent::init(); - - SSViewer::config()->update('rewrite_hash_links', false); - ContentNegotiator::config()->update('enabled', false); - - // set language - $member = Member::currentUser(); - if(!empty($member->Locale)) { - i18n::set_locale($member->Locale); - } - if(!empty($member->DateFormat)) { - i18n::config()->date_format = $member->DateFormat; - } - if(!empty($member->TimeFormat)) { - i18n::config()->time_format = $member->TimeFormat; - } - - // can't be done in cms/_config.php as locale is not set yet - CMSMenu::add_link( - 'Help', - _t('LeftAndMain.HELP', 'Help', 'Menu title'), - $this->config()->help_link, - -2, - array( - 'target' => '_blank' - ) - ); - - // Allow customisation of the access check by a extension - // 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 - $menu = $this->MainMenu(); - foreach($menu as $candidate) { - if( - $candidate->Link && - $candidate->Link != $this->Link() - && $candidate->MenuItem->controller - && singleton($candidate->MenuItem->controller)->canView() - ) { - $this->redirect($candidate->Link); - return; - } - } - - if(Member::currentUser()) { - Session::set("BackURL", null); - } - - // if no alternate menu items have matched, return a permission error - $messageSet = array( - 'default' => _t( - 'LeftAndMain.PERMDEFAULT', - "You must be logged in to access the administration area; please enter your credentials below." - ), - 'alreadyLoggedIn' => _t( - '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." - ), - 'logInAgain' => _t( - 'LeftAndMain.PERMAGAIN', - "You have been logged out of the CMS. If you would like to log in again, enter a username and" - . " password below." - ), - ); - - Security::permissionFailure($this, $messageSet); - return; - } - - // Don't continue if there's already been a redirection request. - if($this->redirectedTo()) { - return; - } - - // Audit logging hook - if(empty($_REQUEST['executeForm']) && !$this->getRequest()->isAjax()) $this->extend('accessedCMS'); - - // Set the members html editor config - if(Member::currentUser()) { - HTMLEditorConfig::set_active_identifier(Member::currentUser()->getHtmlEditorConfigForCMS()); - } - - // Set default values in the config if missing. These things can't be defined in the config - // file because insufficient information exists when that is being processed - $htmlEditorConfig = HTMLEditorConfig::get_active(); - $htmlEditorConfig->setOption('language', i18n::get_tinymce_lang()); - - Requirements::customScript(" - window.ss = window.ss || {}; - window.ss.config = " . $this->getCombinedClientConfig() . "; - "); - - Requirements::javascript(FRAMEWORK_ADMIN_DIR . '/client/dist/js/vendor.js'); - Requirements::javascript(FRAMEWORK_ADMIN_DIR . '/client/dist/js/bundle.js'); - Requirements::css(ltrim(FRAMEWORK_ADMIN_DIR . '/client/dist/styles/bundle.css', '/')); - - Requirements::add_i18n_javascript(ltrim(FRAMEWORK_DIR . '/client/lang', '/'), false, true); - Requirements::add_i18n_javascript(FRAMEWORK_ADMIN_DIR . '/client/lang', false, true); - - if ($this->config()->session_keepalive_ping) { - Requirements::javascript(FRAMEWORK_ADMIN_DIR . '/client/dist/js/LeftAndMain.Ping.js'); - } - - if (Director::isDev()) { - // TODO Confuses jQuery.ondemand through document.write() - Requirements::javascript(ADMIN_THIRDPARTY_DIR . '/jquery-entwine/src/jquery.entwine.inspector.js'); - Requirements::javascript(FRAMEWORK_ADMIN_DIR . '/client/dist/js/leaktools.js'); - } - - // Custom requirements - $extraJs = $this->stat('extra_requirements_javascript'); - - if($extraJs) { - foreach($extraJs as $file => $config) { - if(is_numeric($file)) { - $file = $config; - } - - Requirements::javascript($file); - } - } - - $extraCss = $this->stat('extra_requirements_css'); - - if($extraCss) { - foreach($extraCss as $file => $config) { - if(is_numeric($file)) { - $file = $config; - $config = array(); - } - - Requirements::css($file, isset($config['media']) ? $config['media'] : null); - } - } - - $extraThemedCss = $this->stat('extra_requirements_themedCss'); - - if($extraThemedCss) { - foreach ($extraThemedCss as $file => $config) { - if(is_numeric($file)) { - $file = $config; - $config = array(); - } - - Requirements::themedCSS($file, isset($config['media']) ? $config['media'] : null); - } - } - - $dummy = null; - $this->extend('init', $dummy); - - // Assign default cms theme and replace user-specified themes - SSViewer::set_themes($this->config()->admin_themes); - - //set the reading mode for the admin to stage - Versioned::set_stage(Versioned::DRAFT); - } - - public function handleRequest(HTTPRequest $request, DataModel $model = null) { - try { - $response = parent::handleRequest($request, $model); - } catch(ValidationException $e) { - // Nicer presentation of model-level validation errors - $msgs = _t('LeftAndMain.ValidationError', 'Validation error') . ': ' - . $e->getMessage(); - $e = new HTTPResponse_Exception($msgs, 403); - $errorResponse = $e->getResponse(); - $errorResponse->addHeader('Content-Type', 'text/plain'); - $errorResponse->addHeader('X-Status', rawurlencode($msgs)); - $e->setResponse($errorResponse); - throw $e; - } - - $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)); - - // Prevent clickjacking, see https://developer.mozilla.org/en-US/docs/HTTP/X-Frame-Options - $originalResponse = $this->getResponse(); - $originalResponse->addHeader('X-Frame-Options', $this->config()->frame_options); - $originalResponse->addHeader('Vary', 'X-Requested-With'); - - return $response; - } - - /** - * Overloaded redirection logic to trigger a fake redirect on ajax requests. - * While this violates HTTP principles, its the only way to work around the - * 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. - * See LeftAndMain.js for the required jQuery ajaxComplete handlers. - * - * @param string $url - * @param int $code - * @return HTTPResponse|string - */ - public function redirect($url, $code=302) { - if($this->getRequest()->isAjax()) { - $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')); - } - $newResponse = new LeftAndMain_HTTPResponse( - $response->getBody(), - $response->getStatusCode(), - $response->getStatusDescription() - ); - foreach($response->getHeaders() as $k => $v) { - $newResponse->addHeader($k, $v); - } - $newResponse->setIsFinished(true); - $this->setResponse($newResponse); - return ''; // Actual response will be re-requested by client - } else { - parent::redirect($url, $code); - } - } - - /** - * @param HTTPRequest $request - * @return HTTPResponse - */ - public function index($request) { - return $this->getResponseNegotiator()->respond($request); - } - - /** - * If this is set to true, the "switchView" context in the - * template is shown, with links to the staging and publish site. - * - * @return boolean - */ - public function ShowSwitchView() { - return false; - } - - - //------------------------------------------------------------------------------------------// - // Main controllers - - /** - * You should implement a Link() function in your subclass of LeftAndMain, - * to point to the URL of that particular controller. - * - * @param string $action - * @return string - */ - public function Link($action = null) { - // Handle missing url_segments - if($this->config()->url_segment) { - $segment = $this->config()->get('url_segment', Config::FIRST_SET); - } else { - $segment = $this->class; - }; - - $link = Controller::join_links( - AdminRootController::admin_url(), - $segment, - '/', // trailing slash needed if $action is null! - "$action" - ); - $this->extend('updateLink', $link); - return $link; - } - - /** - * @deprecated 5.0 - */ - public static function menu_title_for_class($class) { - Deprecation::notice('5.0', 'Use menu_title() instead'); - return static::menu_title($class, false); - } - - /** - * Get menu title for this section (translated) - * - * @param string $class Optional class name if called on LeftAndMain directly - * @param bool $localise Determine if menu title should be localised via i18n. - * @return string Menu title for the given class - */ - 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); - } - if(!$class) { - $class = get_called_class(); - } - - // Get default class title - $title = Config::inst()->get($class, 'menu_title', Config::FIRST_SET); - if(!$title) { - $title = preg_replace('/Admin$/', '', $class); - } - - // Check localisation - if(!$localise) { - return $title; - } - return i18n::_t("{$class}.MENUTITLE", $title); - } - - /** - * Return styling for the menu icon, if a custom icon is set for this class - * - * Example: static $menu-icon = '/path/to/image/'; - * @param string $class - * @return string - */ - 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))); - return ".icon.icon-16.icon-{$class} { background-image: url('{$icon}'); } "; - } - return ''; - } - - /** - * @param HTTPRequest $request - * @return HTTPResponse - * @throws HTTPResponse_Exception - */ - public function show($request) { - // TODO Necessary for TableListField URLs to work properly - if($request->param('ID')) $this->setCurrentPageID($request->param('ID')); - return $this->getResponseNegotiator()->respond($request); - } - - /** - * Caution: Volatile API. - * - * @return PjaxResponseNegotiator - */ - public function getResponseNegotiator() { - if(!$this->responseNegotiator) { - $controller = $this; - $this->responseNegotiator = new PjaxResponseNegotiator( - array( - 'CurrentForm' => function() use(&$controller) { - return $controller->getEditForm()->forTemplate(); - }, - 'Content' => function() use(&$controller) { - return $controller->renderWith($controller->getTemplatesWithSuffix('_Content')); - }, - 'Breadcrumbs' => function() use (&$controller) { - return $controller->renderWith([ - 'type' => 'Includes', - 'SilverStripe\\Admin\\CMSBreadcrumbs' - ]); - }, - 'default' => function() use(&$controller) { - return $controller->renderWith($controller->getViewer('show')); - } - ), - $this->getResponse() - ); - } - return $this->responseNegotiator; - } - - //------------------------------------------------------------------------------------------// - // Main UI components - - /** - * Returns the main menu of the CMS. This is also used by init() - * to work out which sections the user has access to. - * - * @param bool $cached - * @return SS_List - */ - public function MainMenu($cached = true) { - if(!isset($this->_cache_MainMenu) || !$cached) { - // Don't accidentally return a menu if you're not logged in - it's used to determine access. - if(!Member::currentUser()) return new ArrayList(); - - // Encode into DO set - $menu = new ArrayList(); - $menuItems = CMSMenu::get_viewable_menu_items(); - - // extra styling for custom menu-icons - $menuIconStyling = ''; - - if($menuItems) { - /** @var CMSMenuItem $menuItem */ - foreach($menuItems as $code => $menuItem) { - // alternate permission checks (in addition to LeftAndMain->canView()) - if( - isset($menuItem->controller) - && $this->hasMethod('alternateMenuDisplayCheck') - && !$this->alternateMenuDisplayCheck($menuItem->controller) - ) { - continue; - } - - $linkingmode = "link"; - - if($menuItem->controller && get_class($this) == $menuItem->controller) { - $linkingmode = "current"; - } else if(strpos($this->Link(), $menuItem->url) !== false) { - if($this->Link() == $menuItem->url) { - $linkingmode = "current"; - - // default menu is the one with a blank {@link url_segment} - } else if(singleton($menuItem->controller)->stat('url_segment') == '') { - if($this->Link() == AdminRootController::admin_url()) { - $linkingmode = "current"; - } - - } else { - $linkingmode = "current"; - } - } - - // 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 - // if we're dealing with a controller - if($menuItem->controller) { - $title = LeftAndMain::menu_title($menuItem->controller); - } else { - $title = $menuItem->title; - } - - // 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) { - $menuIcon = LeftAndMain::menu_icon_for_class($menuItem->controller); - if (!empty($menuIcon)) { - $menuIconStyling .= $menuIcon; - } - } - - $menu->push(new ArrayData(array( - "MenuItem" => $menuItem, - "AttributesHTML" => $menuItem->getAttributesHTML(), - "Title" => Convert::raw2xml($title), - "Code" => $code, - "Icon" => strtolower($code), - "Link" => $menuItem->url, - "LinkingMode" => $linkingmode - ))); - } - } - if ($menuIconStyling) Requirements::customCSS($menuIconStyling); - - $this->_cache_MainMenu = $menu; - } - - return $this->_cache_MainMenu; - } - - public function Menu() { - return $this->renderWith($this->getTemplatesWithSuffix('_Menu')); - } - - /** - * @todo Wrap in CMSMenu instance accessor - * @return ArrayData A single menu entry (see {@link MainMenu}) - */ - public function MenuCurrentItem() { - $items = $this->MainMenu(); - return $items->find('LinkingMode', 'current'); - } - - /** - * Return a list of appropriate templates for this class, with the given suffix using - * {@link SSViewer::get_templates_by_class()} - * - * @param string $suffix - * @return array - */ - public function getTemplatesWithSuffix($suffix) { - $templates = SSViewer::get_templates_by_class(get_class($this), $suffix, __CLASS__); - return SSViewer::chooseTemplate($templates); - } - - public function Content() { - return $this->renderWith($this->getTemplatesWithSuffix('_Content')); - } - - /** - * Render $PreviewPanel content - * - * @return DBHTMLText - */ - public function PreviewPanel() { - $template = $this->getTemplatesWithSuffix('_PreviewPanel'); - // Only render sections with preview panel - if ($template) { - return $this->renderWith($template); - } - } - - public function getRecord($id) { - $className = $this->stat('tree_class'); - if($className && $id instanceof $className) { - return $id; - } else if($className && $id == 'root') { - return singleton($className); - } else if($className && is_numeric($id)) { - return DataObject::get_by_id($className, $id); - } else { - return false; - } - } - - /** - * @param bool $unlinked - * @return ArrayList - */ - public function Breadcrumbs($unlinked = false) { - $items = new ArrayList(array( - new ArrayData(array( - 'Title' => $this->menu_title(), - 'Link' => ($unlinked) ? false : $this->Link() - )) - )); - $record = $this->currentPage(); - if($record && $record->exists()) { - if($record->hasExtension('SilverStripe\\ORM\\Hierarchy\\Hierarchy')) { - $ancestors = $record->getAncestors(); - $ancestors = new ArrayList(array_reverse($ancestors->toArray())); - $ancestors->push($record); - foreach($ancestors as $ancestor) { - $items->push(new ArrayData(array( - 'Title' => ($ancestor->MenuTitle) ? $ancestor->MenuTitle : $ancestor->Title, - 'Link' => ($unlinked) ? false : Controller::join_links($this->Link('show'), $ancestor->ID) - ))); - } - } else { - $items->push(new ArrayData(array( - 'Title' => ($record->MenuTitle) ? $record->MenuTitle : $record->Title, - 'Link' => ($unlinked) ? false : Controller::join_links($this->Link('show'), $record->ID) - ))); - } - } - - return $items; - } - - /** - * @return String HTML - */ - public function SiteTreeAsUL() { - $html = $this->getSiteTreeFor($this->stat('tree_class')); - $this->extend('updateSiteTreeAsUL', $html); - return $html; - } - - /** - * Gets the current search filter for this request, if available - * - * @throws InvalidArgumentException - * @return LeftAndMain_SearchFilter - */ - protected function getSearchFilter() { - // Check for given FilterClass - $params = $this->getRequest()->getVar('q'); - if(empty($params['FilterClass'])) { - return null; - } - - // Validate classname - $filterClass = $params['FilterClass']; - $filterInfo = new ReflectionClass($filterClass); - if(!$filterInfo->implementsInterface('SilverStripe\\Admin\\LeftAndMain_SearchFilter')) { - throw new InvalidArgumentException(sprintf('Invalid filter class passed: %s', $filterClass)); - } - - return Injector::inst()->createWithArgs($filterClass, array($params)); - } - - /** - * Get a site tree HTML listing which displays the nodes under the given criteria. - * - * @param string $className The class of the root object - * @param string $rootID The ID of the root object. If this is null then a complete tree will be - * shown - * @param string $childrenMethod The method to call to get the children of the tree. For example, - * Children, AllChildrenIncludingDeleted, or AllHistoricalChildren - * @param string $numChildrenMethod - * @param callable $filterFunction - * @param int $nodeCountThreshold - * @return string Nested unordered list with links to each page - */ - public function getSiteTreeFor($className, $rootID = null, $childrenMethod = null, $numChildrenMethod = null, - $filterFunction = null, $nodeCountThreshold = 30) { - - // Filter criteria - $filter = $this->getSearchFilter(); - - // Default childrenMethod and numChildrenMethod - if(!$childrenMethod) $childrenMethod = ($filter && $filter->getChildrenMethod()) - ? $filter->getChildrenMethod() - : 'AllChildrenIncludingDeleted'; - - if(!$numChildrenMethod) { - $numChildrenMethod = 'numChildren'; - if($filter && $filter->getNumChildrenMethod()) { - $numChildrenMethod = $filter->getNumChildrenMethod(); - } - } - if(!$filterFunction && $filter) { - $filterFunction = function($node) use($filter) { - return $filter->isPageIncluded($node); - }; - } - - // Get the tree root - $record = ($rootID) ? $this->getRecord($rootID) : null; - $obj = $record ? $record : singleton($className); - - // Get the current page - // NOTE: This *must* be fetched before markPartialTree() is called, as this - // 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(); - - // Mark the nodes of the tree to return - if ($filterFunction) $obj->setMarkingFilterFunction($filterFunction); - - $obj->markPartialTree($nodeCountThreshold, $this, $childrenMethod, $numChildrenMethod); - - // Ensure current page is exposed - if($currentPage) $obj->markToExpose($currentPage); - - // NOTE: SiteTree/CMSMain coupling :-( - if(class_exists('SilverStripe\\CMS\\Model\\SiteTree')) { - SiteTree::prepopulate_permission_cache( - 'CanEditType', - $obj->markedNodeIDs(), - 'SilverStripe\\CMS\\Model\\SiteTree::can_edit_multiple' - ); - } - - // getChildrenAsUL is a flexible and complex way of traversing the tree - $controller = $this; - $recordController = ($this->stat('tree_class') == 'SilverStripe\\CMS\\Model\\SiteTree') - ? CMSPageEditController::singleton() - : $this; - $titleFn = function(&$child, $numChildrenMethod) use(&$controller, &$recordController, $filter) { - $link = Controller::join_links($recordController->Link("show"), $child->ID); - $node = LeftAndMain_TreeNode::create($child, $link, $controller->isCurrentPage($child), $numChildrenMethod, $filter); - return $node->forTemplate(); - }; - - // Limit the amount of nodes shown for performance reasons. - // Skip the check if we're filtering the tree, since its not clear how many children will - // 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'); - if($nodeThresholdLeaf && !$filterFunction) { - $nodeCountCallback = function($parent, $numChildren) use(&$controller, $className, $nodeThresholdLeaf) { - if ($className !== 'SilverStripe\\CMS\\Model\\SiteTree' - || !$parent->ID - || $numChildren >= $nodeThresholdLeaf - ) { - return null; - } - return sprintf( - '', - _t('LeftAndMain.TooManyPages', 'Too many pages'), - Controller::join_links( - $controller->LinkWithSearch($controller->Link()), ' - ?view=list&ParentID=' . $parent->ID - ), - _t( - 'LeftAndMain.ShowAsList', - 'show as list', - 'Show large amount of pages in list instead of tree view' - ) - ); - }; - } else { - $nodeCountCallback = null; - } - - // If the amount of pages exceeds the node thresholds set, use the callback - $html = null; - if($obj->ParentID && $nodeCountCallback) { - $html = $nodeCountCallback($obj, $obj->$numChildrenMethod()); - } - - // Otherwise return the actual tree (which might still filter leaf thresholds on children) - if(!$html) { - $html = $obj->getChildrenAsUL( - "", - $titleFn, - CMSPagesController::singleton(), - true, - $childrenMethod, - $numChildrenMethod, - $nodeCountThreshold, - $nodeCountCallback - ); - } - - // Wrap the root if needs be. - if(!$rootID) { - $rootLink = $this->Link('show') . '/root'; - - // This lets us override the tree title with an extension - if($this->hasMethod('getCMSTreeTitle') && $customTreeTitle = $this->getCMSTreeTitle()) { - $treeTitle = $customTreeTitle; - } elseif(class_exists('SilverStripe\\SiteConfig\\SiteConfig')) { - $siteConfig = SiteConfig::current_site_config(); - $treeTitle = Convert::raw2xml($siteConfig->Title); - } else { - $treeTitle = '...'; - } - - $html = ""; - } - - return $html; - } - - /** - * Get a subtree underneath the request param 'ID'. - * If ID = 0, then get the whole tree. - * - * @param HTTPRequest $request - * @return string - */ - public function getsubtree($request) { - $html = $this->getSiteTreeFor( - $this->stat('tree_class'), - $request->getVar('ID'), - null, - null, - null, - $request->getVar('minNodeCount') - ); - - // Trim off the outer tag - $html = preg_replace('/^[\s\t\r\n]*]*>/','', $html); - $html = preg_replace('/<\/ul[^>]*>[\s\t\r\n]*$/','', $html); - - return $html; - } - - /** - * Allows requesting a view update on specific tree nodes. - * Similar to {@link getsubtree()}, but doesn't enforce loading - * all children with the node. Useful to refresh views after - * state modifications, e.g. saving a form. - * - * @param HTTPRequest $request - * @return string JSON - */ - public function updatetreenodes($request) { - $data = array(); - $ids = explode(',', $request->getVar('ids')); - foreach($ids as $id) { - if($id === "") continue; // $id may be a blank string, which is invalid and should be skipped over - - $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() - : $this; - - // Find the next & previous nodes, for proper positioning (Sort isn't good enough - it's not a raw offset) - // TODO: These methods should really be in hierarchy - for a start it assumes Sort exists - $next = $prev = null; - - $className = $this->stat('tree_class'); - $next = DataObject::get($className) - ->filter('ParentID', $record->ParentID) - ->filter('Sort:GreaterThan', $record->Sort) - ->first(); - - if (!$next) { - $prev = DataObject::get($className) - ->filter('ParentID', $record->ParentID) - ->filter('Sort:LessThan', $record->Sort) - ->reverse() - ->first(); - } - - $link = Controller::join_links($recordController->Link("show"), $record->ID); - $html = LeftAndMain_TreeNode::create($record, $link, $this->isCurrentPage($record)) - ->forTemplate() . ''; - - $data[$id] = array( - 'html' => $html, - 'ParentID' => $record->ParentID, - 'NextID' => $next ? $next->ID : null, - 'PrevID' => $prev ? $prev->ID : null - ); - } - $this->getResponse()->addHeader('Content-Type', 'text/json'); - return Convert::raw2json($data); - } - - /** - * Save handler - * - * @param array $data - * @param Form $form - * @return HTTPResponse - */ - public function save($data, $form) { - $request = $this->getRequest(); - $className = $this->stat('tree_class'); - - // Existing or new record? - $id = $data['ID']; - if(is_numeric($id) && $id > 0) { - $record = DataObject::get_by_id($className, $id); - if($record && !$record->canEdit()) { - return Security::permissionFailure($this); - } - if(!$record || !$record->ID) { - $this->httpError(404, "Bad record ID #" . (int)$id); - } - } else { - if(!singleton($this->stat('tree_class'))->canCreate()) { - return Security::permissionFailure($this); - } - $record = $this->getNewItem($id, false); - } - - // save form data into record - $form->saveInto($record, true); - $record->write(); - $this->extend('onAfterSave', $record); - $this->setCurrentPageID($record->ID); - - $message = _t('LeftAndMain.SAVEDUP', 'Saved.'); - if($request->getHeader('X-Formschema-Request')) { - // Ensure that newly created records have all their data loaded back into the form. - $form->loadDataFrom($record); - $form->setMessage($message, 'good'); - $data = $this->getSchemaForForm($form); - $response = new HTTPResponse(Convert::raw2json($data)); - $response->addHeader('Content-Type', 'application/json'); - } else { - $response = $this->getResponseNegotiator()->respond($request); - } - - $response->addHeader('X-Status', rawurlencode($message)); - return $response; - } - - /** - * Create new item. - * - * @param string|int $id - * @param bool $setID - * @return DataObject - */ - public function getNewItem($id, $setID = true) { - $class = $this->stat('tree_class'); - $object = Injector::inst()->create($class); - if($setID) { - $object->ID = $id; - } - return $object; - } - - public function delete($data, $form) { - $className = $this->stat('tree_class'); - - $id = $data['ID']; - $record = DataObject::get_by_id($className, $id); - if($record && !$record->canDelete()) return Security::permissionFailure(); - if(!$record || !$record->ID) $this->httpError(404, "Bad record ID #" . (int)$id); - - $record->delete(); - - $this->getResponse()->addHeader('X-Status', rawurlencode(_t('LeftAndMain.DELETED', 'Deleted.'))); - return $this->getResponseNegotiator()->respond( - $this->getRequest(), - array('currentform' => array($this, 'EmptyForm')) - ); - } - - /** - * Update the position and parent of a tree node. - * Only saves the node if changes were made. - * - * Required data: - * - 'ID': The moved node - * - 'ParentID': New parent relation of the moved node (0 for root) - * - '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. - * - * @param HTTPRequest $request - * @return HTTPResponse JSON string with a - * @throws HTTPResponse_Exception - */ - public function savetreenode($request) { - if (!SecurityToken::inst()->checkRequest($request)) { - return $this->httpError(400); - } - if (!Permission::check('SITETREE_REORGANISE') && !Permission::check('ADMIN')) { - $this->getResponse()->setStatusCode( - 403, - _t('LeftAndMain.CANT_REORGANISE', - "You do not have permission to rearange the site tree. Your change was not saved.") - ); - return; - } - - $className = $this->stat('tree_class'); - $statusUpdates = array('modified'=>array()); - $id = $request->requestVar('ID'); - $parentID = $request->requestVar('ParentID'); - - if($className == 'SilverStripe\\CMS\\Model\\SiteTree' && $page = DataObject::get_by_id('Page', $id)){ - $root = $page->getParentType(); - if(($parentID == '0' || $root == 'root') && !SiteConfig::current_site_config()->canCreateTopLevel()){ - $this->getResponse()->setStatusCode( - 403, - _t('LeftAndMain.CANT_REORGANISE', - "You do not have permission to alter Top level pages. Your change was not saved.") - ); - return; - } - } - - $siblingIDs = $request->requestVar('SiblingIDs'); - $statusUpdates = array('modified'=>array()); - if(!is_numeric($id) || !is_numeric($parentID)) throw new InvalidArgumentException(); - - $node = DataObject::get_by_id($className, $id); - if($node && !$node->canEdit()) return Security::permissionFailure($this); - - if(!$node) { - $this->getResponse()->setStatusCode( - 500, - _t('LeftAndMain.PLEASESAVE', - "Please Save Page: This page could not be updated because it hasn't been saved yet." - ) - ); - return; - } - - // Update hierarchy (only if ParentID changed) - if($node->ParentID != $parentID) { - $node->ParentID = (int)$parentID; - $node->write(); - - $statusUpdates['modified'][$node->ID] = array( - 'TreeTitle'=>$node->TreeTitle - ); - - // Update all dependent pages - if(class_exists('SilverStripe\\CMS\\Model\\VirtualPage')) { - $virtualPages = VirtualPage::get()->filter("CopyContentFromID", $node->ID); - foreach($virtualPages as $virtualPage) { - $statusUpdates['modified'][$virtualPage->ID] = array( - 'TreeTitle' => $virtualPage->TreeTitle() - ); - } - } - - $this->getResponse()->addHeader('X-Status', - rawurlencode(_t('LeftAndMain.REORGANISATIONSUCCESSFUL', 'Reorganised the site tree successfully.'))); - } - - // Update sorting - if(is_array($siblingIDs)) { - $counter = 0; - foreach($siblingIDs as $id) { - if($id == $node->ID) { - $node->Sort = ++$counter; - $node->write(); - $statusUpdates['modified'][$node->ID] = array( - 'TreeTitle' => $node->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; - DB::prepared_query( - "UPDATE \"$className\" SET \"Sort\" = ? WHERE \"ID\" = ?", - array($counter, $id) - ); - } - } - - $this->getResponse()->addHeader('X-Status', - rawurlencode(_t('LeftAndMain.REORGANISATIONSUCCESSFUL', 'Reorganised the site tree successfully.'))); - } - - return Convert::raw2json($statusUpdates); - } - - public function CanOrganiseSitetree() { - return !Permission::check('SITETREE_REORGANISE') && !Permission::check('ADMIN') ? false : true; - } - - /** - * Retrieves an edit form, either for display, or to process submitted data. - * 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()}. - * The form usually construct itself from {@link DataObject->getCMSFields()} - * for the specific managed subclass defined in {@link LeftAndMain::$tree_class}. - * - * @param HTTPRequest $request Optionally contains an identifier for the - * 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 - * the record. - */ - /** - * @return Form - */ - public function EditForm($request = null) { - return $this->getEditForm(); - } - - /** - * Calls {@link SiteTree->getCMSFields()} - * - * @param Int $id - * @param FieldList $fields - * @return Form - */ - public function getEditForm($id = null, $fields = null) { - if(!$id) $id = $this->currentPageID(); - - if(is_object($id)) { - $record = $id; - } else { - $record = $this->getRecord($id); - if($record && !$record->canView()) return Security::permissionFailure($this); - } - - if($record) { - $fields = ($fields) ? $fields : $record->getCMSFields(); - if ($fields == null) { - user_error( - "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 - ); - } - - // Add hidden fields which are required for saving the record - // and loading the UI state - if(!$fields->dataFieldByName('ClassName')) { - $fields->push(new HiddenField('ClassName')); - } - - $tree_class = $this->stat('tree_class'); - if( - $tree_class::has_extension('SilverStripe\\ORM\\Hierarchy\\Hierarchy') - && !$fields->dataFieldByName('ParentID') - ) { - $fields->push(new HiddenField('ParentID')); - } - - // 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()); - $navField->setAllowHTML(true); - $fields->push($navField); - } - - if($record->hasMethod('getAllCMSActions')) { - $actions = $record->getAllCMSActions(); - } else { - $actions = $record->getCMSActions(); - // add default actions if none are defined - if(!$actions || !$actions->count()) { - if($record->hasMethod('canEdit') && $record->canEdit()) { - $actions->push( - FormAction::create('save',_t('CMSMain.SAVE','Save')) - ->addExtraClass('ss-ui-action-constructive')->setAttribute('data-icon', 'accept') - ); - } - if($record->hasMethod('canDelete') && $record->canDelete()) { - $actions->push( - FormAction::create('delete',_t('ModelAdmin.DELETE','Delete')) - ->addExtraClass('ss-ui-action-destructive') - ); - } - } - } - - // Use