Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

ENHANCEMENT PjaxResponseNegotiator for more structured partial ajax r…

…efreshes, applied in CMS and GridField. Also fixes issues with history.pushState() and pseudo-redirects on form submissions (e.g. from page/add to page/edit/show/<new-record-id>)
  • Loading branch information...
commit e01b0aa3d03de29510c6211981e30af333acafe9 1 parent 72985b6
@sminnee sminnee authored chillu committed
View
89 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 @@ function handleRequest(SS_HTTPRequest $request, DataModel $model = null) {
$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 @@ static function menu_title_for_class($class) {
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 @@ public function save($data, $form) {
$form->saveInto($record, true);
$record->write();
$this->extend('onAfterSave', $record);
-
- $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);
+ $this->setCurrentPageID($record->ID);
- return $form->forTemplate();
+ $this->response->addHeader('X-Status', _t('LeftAndMain.SAVEDUP'));
+ return $this->getResponseNegotiator()->respond($request);
}
public function delete($data, $form) {
@@ -697,12 +718,12 @@ public function delete($data, $form) {
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'))
+ );
}
/**
View
9 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);
View
12 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);
View
56 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);
- }
- }
- },
- 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;
+ $(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);
}
- 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
View
69 control/PjaxResponseNegotiator.php
@@ -0,0 +1,69 @@
+<?php
+/**
+ * Handle the X-Pjax header that AJAX responses may provide, returning the
+ * fragment, or, in the case of non-AJAX form submissions, redirecting back to the submitter.
+ *
+ * X-Pjax 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.
+ *
+ * Caution: This API is volatile, and might eventually be replaced by a generic
+ * action helper system for controllers.
+ */
+class PjaxResponseNegotiator {
+
+ /**
+ * @var Array See {@link respond()}
+ */
+ protected $callbacks = array(
+ // TODO Using deprecated functionality, but don't want to duplicate Controller->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;
+ }
+}
View
45 control/RequestHandler.php
@@ -348,51 +348,6 @@ function 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
* {@link handleAction()} or {@link handleRequest()} have been called,
View
4 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,
View
22 tests/control/PjaxResponseNegotiatorTest.php
@@ -0,0 +1,22 @@
+<?php
+class PjaxResponseNegotiatorTest extends SapphireTest {
+
+ function testDefaultCallbacks() {
+ $negotiator = new PjaxResponseNegotiator(array(
+ 'default' => 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));
+ }
+
+}
Please sign in to comment.
Something went wrong with that request. Please try again.