diff --git a/admin/code/LeftAndMain.php b/admin/code/LeftAndMain.php index 35ea95489..9d6af1e39 100644 --- a/admin/code/LeftAndMain.php +++ b/admin/code/LeftAndMain.php @@ -104,6 +104,11 @@ class LeftAndMain extends Controller implements PermissionProvider { 'css' => array(), 'themedcss' => array(), ); + + /** + * @var PJAXResponseNegotiator + */ + protected $responseNegotiator; /** * @param Member $member @@ -328,20 +333,29 @@ class LeftAndMain extends Controller implements PermissionProvider { $response = parent::handleRequest($request, $model); if(!$response->getHeader('X-Controller')) $response->addHeader('X-Controller', $this->class); if(!$response->getHeader('X-Title')) $response->addHeader('X-Title', $title); - if(!$response->getHeader('X-ControllerURL')) { - $url = $request->getURL(); - if($getVars = $request->getVars()) { - if(isset($getVars['url'])) unset($getVars['url']); - $url = Controller::join_links($url, $getVars ? '?' . http_build_query($getVars) : ''); - } - $response->addHeader('X-ControllerURL', $url); - } 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. + */ + function redirect($url, $code=302) { + if($this->request->isAjax()) { + $this->response->addHeader('X-ControllerURL', $url); + return ''; // Actual response will be re-requested by client + } else { + parent::redirect($url, $code); + } + } + function index($request) { - return ($request->isAjax()) ? $this->show($request) : $this->getViewer('index')->process($this); + return $this->getResponseNegotiator()->respond($request); } @@ -391,20 +405,30 @@ class LeftAndMain extends Controller implements PermissionProvider { public function show($request) { // TODO Necessary for TableListField URLs to work properly if($request->param('ID')) $this->setCurrentPageID($request->param('ID')); - - if($this->isAjax()) { - if($request->getVar('cms-view-form')) { - $form = $this->getEditForm(); - $content = $form->forTemplate(); - } else { - // Rendering is handled by template, which will call EditForm() eventually - $content = $this->renderWith($this->getTemplatesWithSuffix('_Content')); - } - } else { - $content = $this->renderWith($this->getViewer('show')); + return $this->getResponseNegotiator()->respond($request); + } + + /** + * Caution: Volatile API. + * + * @return PJAXResponseNegotiator + */ + protected 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')); + }, + 'default' => function() use(&$controller) { + return $controller->renderWith($controller->getViewer('show')); + } + )); } - - return $content; + return $this->responseNegotiator; } //------------------------------------------------------------------------------------------// @@ -680,13 +704,10 @@ class LeftAndMain extends Controller implements PermissionProvider { $form->saveInto($record, true); $record->write(); $this->extend('onAfterSave', $record); - + $this->setCurrentPageID($record->ID); + $this->response->addHeader('X-Status', _t('LeftAndMain.SAVEDUP')); - - // write process might've changed the record, so we reload before returning - $form = $this->getEditForm($record->ID); - - return $form->forTemplate(); + return $this->getResponseNegotiator()->respond($request); } public function delete($data, $form) { @@ -697,12 +718,12 @@ class LeftAndMain extends Controller implements PermissionProvider { if(!$record || !$record->ID) throw new HTTPResponse_Exception("Bad record ID #" . (int)$data['ID'], 404); $record->delete(); - - if($this->isAjax()) { - return $this->EmptyForm()->forTemplate(); - } else { - $this->redirectBack(); - } + + $this->response->addHeader('X-Status', _t('LeftAndMain.SAVEDUP')); + return $this->getResponseNegotiator()->respond( + $request, + array('currentform' => array($this, 'EmptyForm')) + ); } /** diff --git a/admin/javascript/LeftAndMain.AddForm.js b/admin/javascript/LeftAndMain.AddForm.js index 5c781d6a5..148bf9966 100644 --- a/admin/javascript/LeftAndMain.AddForm.js +++ b/admin/javascript/LeftAndMain.AddForm.js @@ -87,7 +87,7 @@ var data = this.serializeArray(); data.push({name:'Suffix',value:newPages[parentID]++}); data.push({name:button.attr('name'),value:button.val()}); - + // TODO Should be set by hiddenfield already jQuery('.cms-content').entwine('ss').loadForm( this.attr('action'), @@ -96,7 +96,12 @@ // Tree updates are triggered by Form_EditForm load events button.removeClass('loading'); }, - {type: 'POST', data: data} + { + type: 'POST', + data: data, + // Refresh the whole area to avoid reloading just the form, without the tree around it + headers: {'X-Pjax': 'Content'} + } ); this.setNewPages(newPages); diff --git a/admin/javascript/LeftAndMain.Content.js b/admin/javascript/LeftAndMain.Content.js index fb6c8a391..16e80272d 100644 --- a/admin/javascript/LeftAndMain.Content.js +++ b/admin/javascript/LeftAndMain.Content.js @@ -55,16 +55,18 @@ this.trigger('loadform', {form: form, url: url}); - return jQuery.ajax(jQuery.extend({ - url: url, + var opts = jQuery.extend({}, { // Ensure that form view is loaded (rather than whole "Content" template) - data: {'cms-view-form': 1}, + headers: {"X-Pjax" : "CurrentForm"}, + url: url, complete: function(xmlhttp, status) { self.loadForm_responseHandler(form, xmlhttp.responseText, status, xmlhttp); if(callback) callback.apply(self, arguments); }, dataType: 'html' - }, ajaxOptions)); + }, ajaxOptions); + + return jQuery.ajax(opts); }, /** @@ -148,6 +150,7 @@ formData.push({name: 'BackURL', value:History.getPageUrl()}); jQuery.ajax(jQuery.extend({ + headers: {"X-Pjax" : "CurrentForm"}, url: form.attr('action'), data: formData, type: 'POST', @@ -289,7 +292,6 @@ if($.path.isExternal($(node).find('a:first'))) url = url = $.path.makeUrlAbsolute(url, $('base').attr('href')); // Reload only edit form if it exists (side-by-side view of tree and edit view), otherwise reload whole panel if(container.find('.cms-edit-form').length) { - url += '?cms-view-form=1'; container.entwine('ss').loadPanel(url, null, {selector: '.cms-edit-form'}); } else { container.entwine('ss').loadPanel(url); diff --git a/admin/javascript/LeftAndMain.js b/admin/javascript/LeftAndMain.js index 5b393cd04..6c989144e 100644 --- a/admin/javascript/LeftAndMain.js +++ b/admin/javascript/LeftAndMain.js @@ -33,28 +33,29 @@ jQuery.noConflict(); $(window).bind('resize', positionLoadingSpinner).trigger('resize'); // global ajax handlers - $.ajaxSetup({ - complete: function(xhr) { - // Simulates a redirect on an ajax response - just exchange the URL without re-requesting it. - // Causes non-pushState browser to re-request the URL, so ignore for those. - if(window.History.enabled && !History.emulated.pushState) { - var url = xhr.getResponseHeader('X-ControllerURL'); - // Normalize trailing slashes in URL to work around routing weirdnesses in SS_HTTPRequest. - var isSame = (url && History.getPageUrl().replace(/\/+$/, '') == url.replace(/\/+$/, '')); - if(isSame) { - window.History.replaceState({}, '', url); - } + $(document).ajaxComplete(function(e, xhr, settings) { + // Simulates a redirect on an ajax response. + if(window.History.enabled) { + var url = xhr.getResponseHeader('X-ControllerURL'); + // Normalize trailing slashes in URL to work around routing weirdnesses in SS_HTTPRequest. + var isSame = (url && History.getPageUrl().replace(/\/+$/, '') == url.replace(/\/+$/, '')); + if(url && !isSame) { + var opts = { + pjax: settings.headers ? settings.headers['X-Pjax'] : null, + selector: settings.headers ? settings.headers['X-Pjax-Selector'] : null + }; + window.History.pushState(opts, '', url); } - }, - error: function(xmlhttp, status, error) { - if(xmlhttp.status < 200 || xmlhttp.status > 399) { - var msg = (xmlhttp.getResponseHeader('X-Status')) ? xmlhttp.getResponseHeader('X-Status') : xmlhttp.statusText; - } else { - msg = error; - } - statusMessage(msg, 'bad'); } }); + $(document).ajaxError(function(e, xhr, settings, error) { + if(xhr.status < 200 || xhr.status > 399) { + var msg = (xhr.getResponseHeader('X-Status')) ? xhr.getResponseHeader('X-Status') : xhr.statusText; + } else { + msg = error; + } + statusMessage(msg, 'bad'); + }); /** * Main LeftAndMain interface with some control panel and an edit form. @@ -147,8 +148,8 @@ jQuery.noConflict(); loadPanel: function(url, title, data) { if(!data) data = {}; if(!title) title = ""; - - var selector = data.selector || '.cms-content', contentEl = $(selector); + if(!data.selector) data.selector = '.cms-content'; + var contentEl = $(data.selector); // Check change tracking (can't use events as we need a way to cancel the current state change) var trackedEls = contentEl.find(':data(changetracker)').add(contentEl.filter(':data(changetracker)')); @@ -209,8 +210,21 @@ jQuery.noConflict(); state: state, element: contentEl }); + var headers = {}; + if(state.data.pjax) { + headers['X-Pjax'] = state.data.pjax; + } else if(contentEl[0] != null && contentEl.is('form')) { + // Replace a form + headers["X-Pjax"] = 'CurrentForm'; + } else { + // Replace full RHS content area + headers["X-Pjax"] = 'Content'; + } + headers['X-Pjax-Selector'] = selector; + contentEl.addClass('loading'); var xhr = $.ajax({ + headers: headers, url: state.url, success: function(data, status, xhr) { // Update title diff --git a/control/PjaxResponseNegotiator.php b/control/PjaxResponseNegotiator.php new file mode 100644 index 000000000..c429bf6d0 --- /dev/null +++ b/control/PjaxResponseNegotiator.php @@ -0,0 +1,69 @@ +redirectBack() + 'default' => array('Director', 'redirectBack'), + ); + + /** + * @param RequestHandler $controller + * @param Array $callbacks + */ + function __construct($callbacks = array()) { + $this->callbacks = $callbacks; + } + + /** + * Out of the box, the handler "CurrentForm" value, which will return the rendered form. + * Non-Ajax calls will redirect back. + * + * @param SS_HTTPRequest $request + * @param array $extraCallbacks List of anonymous functions or callables returning either a string + * or SS_HTTPResponse, keyed by their fragment identifier. The 'default' key can + * be used as a fallback for non-ajax responses. + * @return SS_HTTPResponse + */ + public function respond(SS_HTTPRequest $request, $extraCallbacks = array()) { + // Prepare the default options and combine with the others + $callbacks = array_merge( + array_change_key_case($this->callbacks, CASE_LOWER), + array_change_key_case($extraCallbacks, CASE_LOWER) + ); + + if($fragment = $request->getHeader('X-Pjax')) { + $fragment = strtolower($fragment); + if(isset($callbacks[$fragment])) { + return call_user_func($callbacks[$fragment]); + } else { + throw new SS_HTTPResponse_Exception("X-Pjax = '$fragment' not supported for this URL.", 400); + } + } else { + if($request->isAjax()) throw new SS_HTTPResponse_Exception("Ajax requests to this URL require an X-Pjax header.", 400); + return call_user_func($callbacks['default']); + } + + } + + /** + * @param String $fragment + * @param Callable $callback + */ + public function setCallback($fragment, $callback) { + $this->callbacks[$fragment] = $callback; + } +} \ No newline at end of file diff --git a/control/RequestHandler.php b/control/RequestHandler.php index 6167e19bb..f73e53c85 100644 --- a/control/RequestHandler.php +++ b/control/RequestHandler.php @@ -347,51 +347,6 @@ class RequestHandler extends ViewableData { return $this->request->isAjax(); } - /** - * Handle the X-Get-Fragment header that AJAX responses may provide, returning the - * fragment, or, in the case of non-AJAX form submissions, redirecting back to the submitter. - * - * X-Get-Fragment ensures that users won't end up seeing the unstyled form HTML in their browser - * If a JS error prevents the Ajax overriding of form submissions from happening. It also provides - * better non-JS operation. - * - * Out of the box, the handler "CurrentForm" value, which will return the rendered form. Non-Ajax - * calls will redirect back. - * - * To extend its responses, pass a map to the $options argument. Each key is the value of X-Get-Fragment - * that will work, and the value is a PHP 'callable' value that will return the response for that - * value. - * - * If you specify $options['default'], this will be used as the non-ajax response. - * - * Note that if you use handleFragmentResponse, then any Ajax requests will have to include X-Get-Fragment - * or an error will be thrown. - */ - function handleFragmentResponse($form, $options = array()) { - // Prepare the default options and combine with the others - $lOptions = array( - 'currentform' => array($form, 'forTemplate'), - 'default' => array('Director', 'redirectBack'), - ); - if($options) foreach($options as $k => $v) { - $lOptions[strtolower($k)] = $v; - } - - if($fragment = $this->request->getHeader('X-Get-Fragment')) { - $fragment = strtolower($fragment); - if(isset($lOptions[$fragment])) { - return call_user_func($lOptions[$fragment]); - } else { - throw new SS_HTTPResponse_Exception("X-Get-Fragment = '$fragment' not supported for this URL.", 400); - } - - } else { - if($this->isAjax()) throw new SS_HTTPResponse_Exception("Ajax requests to this URL require an X-Get-Fragment header.", 400); - return call_user_func($lOptions['default']); - } - - } - /** * Returns the SS_HTTPRequest object that this controller is using. * Returns a placeholder {@link NullHTTPRequest} object unless diff --git a/javascript/GridField.js b/javascript/GridField.js index f5ce69eb0..b0a41dc5a 100644 --- a/javascript/GridField.js +++ b/javascript/GridField.js @@ -23,7 +23,7 @@ form.addClass('loading'); $.ajax($.extend({}, { - headers: {"X-Get-Fragment" : 'CurrentField'}, + headers: {"X-Pjax" : 'CurrentField'}, type: "POST", url: this.data('url'), dataType: 'html', @@ -217,7 +217,7 @@ var suggestionUrl = $(searchField).attr('data-search-url').substr(1,$(searchField).attr('data-search-url').length-2); $.ajax({ headers: { - "X-Get-Fragment" : 'Partial' + "X-Pjax" : 'Partial' }, type: "GET", url: suggestionUrl+'/'+request.term, diff --git a/tests/control/PjaxResponseNegotiatorTest.php b/tests/control/PjaxResponseNegotiatorTest.php new file mode 100644 index 000000000..76ac8df93 --- /dev/null +++ b/tests/control/PjaxResponseNegotiatorTest.php @@ -0,0 +1,22 @@ + function() {return 'default response';}, + )); + $request = new SS_HTTPRequest('GET', '/'); // not setting pjax header + $this->assertEquals('default response', $negotiator->respond($request)); + } + + function testSelectsFragmentByHeader() { + $negotiator = new PjaxResponseNegotiator(array( + 'default' => function() {return 'default response';}, + 'myfragment' => function() {return 'myfragment response';}, + )); + $request = new SS_HTTPRequest('GET', '/'); + $request->addHeader('X-Pjax', 'myfragment'); + $this->assertEquals('myfragment response', $negotiator->respond($request)); + } + +} \ No newline at end of file