Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
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));
+ }
+
+}

0 comments on commit e01b0aa

Please sign in to comment.
Something went wrong with that request. Please try again.