Skip to content
This repository
Browse code

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
Sam Minnée authored March 24, 2012 chillu committed April 05, 2012
89  admin/code/LeftAndMain.php
@@ -104,6 +104,11 @@ class LeftAndMain extends Controller implements PermissionProvider {
104 104
 		'css' => array(),
105 105
 		'themedcss' => array(),
106 106
 	);
  107
+
  108
+	/**
  109
+	 * @var PJAXResponseNegotiator
  110
+	 */
  111
+	protected $responseNegotiator;
107 112
 	
108 113
 	/**
109 114
 	 * @param Member $member
@@ -328,20 +333,29 @@ function handleRequest(SS_HTTPRequest $request, DataModel $model = null) {
328 333
 		$response = parent::handleRequest($request, $model);
329 334
 		if(!$response->getHeader('X-Controller')) $response->addHeader('X-Controller', $this->class);
330 335
 		if(!$response->getHeader('X-Title')) $response->addHeader('X-Title', $title);
331  
-		if(!$response->getHeader('X-ControllerURL')) {
332  
-			$url = $request->getURL();
333  
-			if($getVars = $request->getVars()) {
334  
-				if(isset($getVars['url'])) unset($getVars['url']);
335  
-				$url = Controller::join_links($url, $getVars ? '?' . http_build_query($getVars) : '');
336  
-			}
337  
-			$response->addHeader('X-ControllerURL', $url);
338  
-		}
339 336
 		
340 337
 		return $response;
341 338
 	}
342 339
 
  340
+	/**
  341
+	 * Overloaded redirection logic to trigger a fake redirect on ajax requests.
  342
+	 * While this violates HTTP principles, its the only way to work around the
  343
+	 * fact that browsers handle HTTP redirects opaquely, no intervention via JS is possible.
  344
+	 * In isolation, that's not a problem - but combined with history.pushState()
  345
+	 * it means we would request the same redirection URL twice if we want to update the URL as well.
  346
+	 * See LeftAndMain.js for the required jQuery ajaxComplete handlers.
  347
+	 */
  348
+	function redirect($url, $code=302) {
  349
+		if($this->request->isAjax()) {
  350
+			$this->response->addHeader('X-ControllerURL', $url);
  351
+			return ''; // Actual response will be re-requested by client
  352
+		} else {
  353
+			parent::redirect($url, $code);
  354
+		}
  355
+	}
  356
+
343 357
 	function index($request) {
344  
-		return ($request->isAjax()) ? $this->show($request) : $this->getViewer('index')->process($this);
  358
+		return $this->getResponseNegotiator()->respond($request);
345 359
 	}
346 360
 
347 361
 	
@@ -391,20 +405,30 @@ static function menu_title_for_class($class) {
391 405
 	public function show($request) {
392 406
 		// TODO Necessary for TableListField URLs to work properly
393 407
 		if($request->param('ID')) $this->setCurrentPageID($request->param('ID'));
394  
-		
395  
-		if($this->isAjax()) {
396  
-			if($request->getVar('cms-view-form')) {
397  
-				$form = $this->getEditForm();
398  
-				$content = $form->forTemplate();
399  
-			} else {
400  
-				// Rendering is handled by template, which will call EditForm() eventually
401  
-				$content = $this->renderWith($this->getTemplatesWithSuffix('_Content'));
402  
-			}
403  
-		} else {
404  
-			$content = $this->renderWith($this->getViewer('show'));
  408
+		return $this->getResponseNegotiator()->respond($request);
  409
+	}
  410
+
  411
+	/**
  412
+	 * Caution: Volatile API.
  413
+	 *  
  414
+	 * @return PJAXResponseNegotiator
  415
+	 */
  416
+	protected function getResponseNegotiator() {
  417
+		if(!$this->responseNegotiator) {
  418
+			$controller = $this;
  419
+			$this->responseNegotiator = new PJAXResponseNegotiator(array(
  420
+				'CurrentForm' => function() use(&$controller) {
  421
+					return $controller->getEditForm()->forTemplate();
  422
+				},
  423
+				'Content' => function() use(&$controller) {
  424
+					return $controller->renderWith($controller->getTemplatesWithSuffix('_Content'));
  425
+				},
  426
+				'default' => function() use(&$controller) {
  427
+					return $controller->renderWith($controller->getViewer('show'));
  428
+				}
  429
+			));
405 430
 		}
406  
-				
407  
-		return $content;
  431
+		return $this->responseNegotiator;
408 432
 	}
409 433
 
410 434
 	//------------------------------------------------------------------------------------------//
@@ -680,13 +704,10 @@ public function save($data, $form) {
680 704
 		$form->saveInto($record, true);
681 705
 		$record->write();
682 706
 		$this->extend('onAfterSave', $record);
683  
-
684  
-		$this->response->addHeader('X-Status', _t('LeftAndMain.SAVEDUP'));
685  
-		
686  
-		// write process might've changed the record, so we reload before returning
687  
-		$form = $this->getEditForm($record->ID);
  707
+		$this->setCurrentPageID($record->ID);
688 708
 		
689  
-		return $form->forTemplate();
  709
+		$this->response->addHeader('X-Status', _t('LeftAndMain.SAVEDUP'));
  710
+		return $this->getResponseNegotiator()->respond($request);
690 711
 	}
691 712
 	
692 713
 	public function delete($data, $form) {
@@ -697,12 +718,12 @@ public function delete($data, $form) {
697 718
 		if(!$record || !$record->ID) throw new HTTPResponse_Exception("Bad record ID #" . (int)$data['ID'], 404);
698 719
 		
699 720
 		$record->delete();
700  
-		
701  
-		if($this->isAjax()) {
702  
-			return $this->EmptyForm()->forTemplate();
703  
-		} else {
704  
-			$this->redirectBack();
705  
-		}
  721
+
  722
+		$this->response->addHeader('X-Status', _t('LeftAndMain.SAVEDUP'));
  723
+		return $this->getResponseNegotiator()->respond(
  724
+			$request, 
  725
+			array('currentform' => array($this, 'EmptyForm'))
  726
+		);
706 727
 	}
707 728
 
708 729
 	/**
9  admin/javascript/LeftAndMain.AddForm.js
@@ -87,7 +87,7 @@
87 87
 				var data = this.serializeArray();
88 88
 				data.push({name:'Suffix',value:newPages[parentID]++});
89 89
 				data.push({name:button.attr('name'),value:button.val()});
90  
-				
  90
+
91 91
 				// TODO Should be set by hiddenfield already
92 92
 				jQuery('.cms-content').entwine('ss').loadForm(
93 93
 					this.attr('action'),
@@ -96,7 +96,12 @@
96 96
 						// Tree updates are triggered by Form_EditForm load events
97 97
 						button.removeClass('loading');
98 98
 					},
99  
-					{type: 'POST', data: data}
  99
+					{
  100
+						type: 'POST', 
  101
+						data: data, 
  102
+						// Refresh the whole area to avoid reloading just the form, without the tree around it
  103
+						headers: {'X-Pjax': 'Content'}
  104
+					}
100 105
 				);
101 106
 		
102 107
 				this.setNewPages(newPages);
12  admin/javascript/LeftAndMain.Content.js
@@ -55,16 +55,18 @@
55 55
 
56 56
 				this.trigger('loadform', {form: form, url: url});
57 57
 			
58  
-				return jQuery.ajax(jQuery.extend({
59  
-					url: url, 
  58
+				var opts = jQuery.extend({}, {
60 59
 					// Ensure that form view is loaded (rather than whole "Content" template)
61  
-					data: {'cms-view-form': 1},
  60
+					headers: {"X-Pjax" : "CurrentForm"},
  61
+					url: url, 
62 62
 					complete: function(xmlhttp, status) {
63 63
 						self.loadForm_responseHandler(form, xmlhttp.responseText, status, xmlhttp);
64 64
 						if(callback) callback.apply(self, arguments);
65 65
 					}, 
66 66
 					dataType: 'html'
67  
-				}, ajaxOptions));
  67
+				}, ajaxOptions);
  68
+
  69
+				return jQuery.ajax(opts);
68 70
 			},
69 71
 			
70 72
 			/**
@@ -148,6 +150,7 @@
148 150
 				formData.push({name: 'BackURL', value:History.getPageUrl()});
149 151
 
150 152
 				jQuery.ajax(jQuery.extend({
  153
+					headers: {"X-Pjax" : "CurrentForm"},
151 154
 					url: form.attr('action'), 
152 155
 					data: formData,
153 156
 					type: 'POST',
@@ -289,7 +292,6 @@
289 292
 					if($.path.isExternal($(node).find('a:first'))) url = url = $.path.makeUrlAbsolute(url, $('base').attr('href'));
290 293
 					// Reload only edit form if it exists (side-by-side view of tree and edit view), otherwise reload whole panel
291 294
 					if(container.find('.cms-edit-form').length) {
292  
-						url += '?cms-view-form=1';
293 295
 						container.entwine('ss').loadPanel(url, null, {selector: '.cms-edit-form'});
294 296
 					} else {
295 297
 						container.entwine('ss').loadPanel(url);	
56  admin/javascript/LeftAndMain.js
@@ -33,28 +33,29 @@ jQuery.noConflict();
33 33
 		$(window).bind('resize', positionLoadingSpinner).trigger('resize');
34 34
 
35 35
 		// global ajax handlers
36  
-		$.ajaxSetup({
37  
-			complete: function(xhr) {
38  
-				// Simulates a redirect on an ajax response - just exchange the URL without re-requesting it.
39  
-				// Causes non-pushState browser to re-request the URL, so ignore for those.
40  
-				if(window.History.enabled && !History.emulated.pushState) {
41  
-					var url = xhr.getResponseHeader('X-ControllerURL');
42  
-					// Normalize trailing slashes in URL to work around routing weirdnesses in SS_HTTPRequest.
43  
-					var isSame = (url && History.getPageUrl().replace(/\/+$/, '') == url.replace(/\/+$/, ''));
44  
-					if(isSame) {
45  
-						window.History.replaceState({}, '', url);
46  
-					}
47  
-				}
48  
-			},
49  
-			error: function(xmlhttp, status, error) {
50  
-				if(xmlhttp.status < 200 || xmlhttp.status > 399) {
51  
-					var msg = (xmlhttp.getResponseHeader('X-Status')) ? xmlhttp.getResponseHeader('X-Status') : xmlhttp.statusText;
52  
-				} else {
53  
-					msg = error;
  36
+		$(document).ajaxComplete(function(e, xhr, settings) {
  37
+			// Simulates a redirect on an ajax response.
  38
+			if(window.History.enabled) {
  39
+				var url = xhr.getResponseHeader('X-ControllerURL');
  40
+				// Normalize trailing slashes in URL to work around routing weirdnesses in SS_HTTPRequest.
  41
+				var isSame = (url && History.getPageUrl().replace(/\/+$/, '') == url.replace(/\/+$/, ''));
  42
+				if(url && !isSame) {
  43
+					var opts = {
  44
+						pjax: settings.headers ? settings.headers['X-Pjax'] : null, 
  45
+						selector: settings.headers ? settings.headers['X-Pjax-Selector'] : null
  46
+					};
  47
+					window.History.pushState(opts, '', url);
54 48
 				}
55  
-				statusMessage(msg, 'bad');
56 49
 			}
57 50
 		});
  51
+		$(document).ajaxError(function(e, xhr, settings, error) {
  52
+			if(xhr.status < 200 || xhr.status > 399) {
  53
+				var msg = (xhr.getResponseHeader('X-Status')) ? xhr.getResponseHeader('X-Status') : xhr.statusText;
  54
+			} else {
  55
+				msg = error;
  56
+			}
  57
+			statusMessage(msg, 'bad');
  58
+		});
58 59
 		
59 60
 		/**
60 61
 		 * Main LeftAndMain interface with some control panel and an edit form.
@@ -147,8 +148,8 @@ jQuery.noConflict();
147 148
 			loadPanel: function(url, title, data) {
148 149
 				if(!data) data = {};
149 150
 				if(!title) title = "";
150  
-				
151  
-				var selector = data.selector || '.cms-content', contentEl = $(selector);
  151
+				if(!data.selector) data.selector = '.cms-content';
  152
+				var contentEl = $(data.selector);
152 153
 				
153 154
 				// Check change tracking (can't use events as we need a way to cancel the current state change)
154 155
 				var trackedEls = contentEl.find(':data(changetracker)').add(contentEl.filter(':data(changetracker)'));
@@ -209,8 +210,21 @@ jQuery.noConflict();
209 210
 					state: state, element: contentEl
210 211
 				});
211 212
 
  213
+				var headers = {};
  214
+				if(state.data.pjax) {
  215
+					headers['X-Pjax'] = state.data.pjax;
  216
+				} else if(contentEl[0] != null && contentEl.is('form')) {
  217
+					// Replace a form
  218
+					headers["X-Pjax"] = 'CurrentForm';
  219
+				} else {
  220
+					// Replace full RHS content area
  221
+					headers["X-Pjax"] = 'Content';
  222
+				}
  223
+				headers['X-Pjax-Selector'] = selector;
  224
+
212 225
 				contentEl.addClass('loading');
213 226
 				var xhr = $.ajax({
  227
+					headers: headers,
214 228
 					url: state.url,
215 229
 					success: function(data, status, xhr) {
216 230
 						// Update title
69  control/PjaxResponseNegotiator.php
... ...
@@ -0,0 +1,69 @@
  1
+<?php
  2
+/**
  3
+ * Handle the X-Pjax header that AJAX responses may provide, returning the 
  4
+ * fragment, or, in the case of non-AJAX form submissions, redirecting back to the submitter.
  5
+ *
  6
+ * X-Pjax ensures that users won't end up seeing the unstyled form HTML in their browser
  7
+ * If a JS error prevents the Ajax overriding of form submissions from happening. 
  8
+ * It also provides better non-JS operation.
  9
+ * 
  10
+ * Caution: This API is volatile, and might eventually be replaced by a generic
  11
+ * action helper system for controllers.
  12
+ */
  13
+class PjaxResponseNegotiator {
  14
+
  15
+	/**
  16
+	 * @var Array See {@link respond()}
  17
+	 */
  18
+	protected $callbacks = array(
  19
+		// TODO Using deprecated functionality, but don't want to duplicate Controller->redirectBack()
  20
+		'default' => array('Director', 'redirectBack'),
  21
+	);
  22
+
  23
+	/**
  24
+	 * @param RequestHandler $controller
  25
+	 * @param Array $callbacks
  26
+	 */
  27
+	function __construct($callbacks = array()) {
  28
+		$this->callbacks = $callbacks; 
  29
+	}
  30
+
  31
+	/**
  32
+	 * Out of the box, the handler "CurrentForm" value, which will return the rendered form.  
  33
+	 * Non-Ajax calls will redirect back.
  34
+	 * 
  35
+	 * @param SS_HTTPRequest $request 
  36
+	 * @param array $extraCallbacks List of anonymous functions or callables returning either a string
  37
+	 * or SS_HTTPResponse, keyed by their fragment identifier. The 'default' key can
  38
+	 * be used as a fallback for non-ajax responses.
  39
+	 * @return SS_HTTPResponse
  40
+	 */
  41
+	public function respond(SS_HTTPRequest $request, $extraCallbacks = array()) {
  42
+		// Prepare the default options and combine with the others
  43
+		$callbacks = array_merge(
  44
+			array_change_key_case($this->callbacks, CASE_LOWER),
  45
+			array_change_key_case($extraCallbacks, CASE_LOWER)
  46
+		);
  47
+		
  48
+		if($fragment = $request->getHeader('X-Pjax')) {
  49
+			$fragment = strtolower($fragment);
  50
+			if(isset($callbacks[$fragment])) {
  51
+				return call_user_func($callbacks[$fragment]);
  52
+			} else {
  53
+				throw new SS_HTTPResponse_Exception("X-Pjax = '$fragment' not supported for this URL.", 400);
  54
+			}
  55
+		} else {
  56
+			if($request->isAjax()) throw new SS_HTTPResponse_Exception("Ajax requests to this URL require an X-Pjax header.", 400);
  57
+			return call_user_func($callbacks['default']);
  58
+		}
  59
+		
  60
+	}
  61
+
  62
+	/**
  63
+	 * @param String   $fragment
  64
+	 * @param Callable $callback
  65
+	 */
  66
+	public function setCallback($fragment, $callback) {
  67
+		$this->callbacks[$fragment] = $callback;
  68
+	}
  69
+}
45  control/RequestHandler.php
@@ -348,51 +348,6 @@ function isAjax() {
348 348
 	}
349 349
 
350 350
 	/**
351  
-	 * Handle the X-Get-Fragment header that AJAX responses may provide, returning the 
352  
-	 * fragment, or, in the case of non-AJAX form submissions, redirecting back to the submitter.
353  
-	 *
354  
-	 * X-Get-Fragment ensures that users won't end up seeing the unstyled form HTML in their browser
355  
-	 * If a JS error prevents the Ajax overriding of form submissions from happening. It also provides
356  
-	 * better non-JS operation.
357  
-	 * 
358  
-	 * Out of the box, the handler "CurrentForm" value, which will return the rendered form.  Non-Ajax
359  
-	 * calls will redirect back.
360  
-	 * 
361  
-	 * To extend its responses, pass a map to the $options argument.  Each key is the value of X-Get-Fragment
362  
-	 * that will work, and the value is a PHP 'callable' value that will return the response for that
363  
-	 * value.
364  
-	 * 
365  
-	 * If you specify $options['default'], this will be used as the non-ajax response.
366  
-	 * 
367  
-	 * Note that if you use handleFragmentResponse, then any Ajax requests will have to include X-Get-Fragment
368  
-	 * or an error will be thrown.
369  
-	 */
370  
-	function handleFragmentResponse($form, $options = array()) {
371  
-		// Prepare the default options and combine with the others
372  
-		$lOptions = array(
373  
-			'currentform' => array($form, 'forTemplate'),
374  
-			'default' => array('Director', 'redirectBack'),
375  
-		);
376  
-		if($options) foreach($options as $k => $v) {
377  
-			$lOptions[strtolower($k)] = $v;
378  
-		}
379  
-		
380  
-		if($fragment = $this->request->getHeader('X-Get-Fragment')) {
381  
-			$fragment = strtolower($fragment);
382  
-			if(isset($lOptions[$fragment])) {
383  
-				return call_user_func($lOptions[$fragment]);
384  
-			} else {
385  
-				throw new SS_HTTPResponse_Exception("X-Get-Fragment = '$fragment' not supported for this URL.", 400);
386  
-			}
387  
-			
388  
-		} else {
389  
-			if($this->isAjax()) throw new SS_HTTPResponse_Exception("Ajax requests to this URL require an X-Get-Fragment header.", 400);
390  
-			return call_user_func($lOptions['default']);
391  
-		}
392  
-		
393  
-	}
394  
-	
395  
-	/**
396 351
 	 * Returns the SS_HTTPRequest object that this controller is using.
397 352
 	 * Returns a placeholder {@link NullHTTPRequest} object unless 
398 353
 	 * {@link handleAction()} or {@link handleRequest()} have been called,
4  javascript/GridField.js
@@ -23,7 +23,7 @@
23 23
 			form.addClass('loading');
24 24
 
25 25
 			$.ajax($.extend({}, {
26  
-				headers: {"X-Get-Fragment" : 'CurrentField'},
  26
+				headers: {"X-Pjax" : 'CurrentField'},
27 27
 				type: "POST",
28 28
 				url: this.data('url'),
29 29
 				dataType: 'html',
@@ -217,7 +217,7 @@
217 217
 					var suggestionUrl = $(searchField).attr('data-search-url').substr(1,$(searchField).attr('data-search-url').length-2);
218 218
 					$.ajax({
219 219
 						headers: {
220  
-							"X-Get-Fragment" : 'Partial'
  220
+							"X-Pjax" : 'Partial'
221 221
 						},
222 222
 						type: "GET",
223 223
 						url: suggestionUrl+'/'+request.term,
22  tests/control/PjaxResponseNegotiatorTest.php
... ...
@@ -0,0 +1,22 @@
  1
+<?php
  2
+class PjaxResponseNegotiatorTest extends SapphireTest {
  3
+	
  4
+	function testDefaultCallbacks() {
  5
+		$negotiator = new PjaxResponseNegotiator(array(
  6
+			'default' => function() {return 'default response';},
  7
+		));
  8
+		$request = new SS_HTTPRequest('GET', '/'); // not setting pjax header
  9
+		$this->assertEquals('default response', $negotiator->respond($request));
  10
+	}
  11
+
  12
+	function testSelectsFragmentByHeader() {
  13
+		$negotiator = new PjaxResponseNegotiator(array(
  14
+			'default' => function() {return 'default response';},
  15
+			'myfragment' => function() {return 'myfragment response';},
  16
+		));
  17
+		$request = new SS_HTTPRequest('GET', '/');
  18
+		$request->addHeader('X-Pjax', 'myfragment');
  19
+		$this->assertEquals('myfragment response', $negotiator->respond($request));
  20
+	}
  21
+
  22
+}

0 notes on commit e01b0aa

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