Skip to content
This repository
Browse code

UNFINISHED Processing multiple PJAX responses on CMS JavaScript, intr…

…oducing data-pjax-fragment attribute to identify reloadable template parts
  • Loading branch information...
commit 5178954311419b203a1ca4857b74c2665380eb75 1 parent 473eda4
Ingo Schommer authored April 18, 2012
6  admin/code/LeftAndMain.php
@@ -352,7 +352,6 @@ function redirect($url, $code=302) {
352 352
 		if($this->request->isAjax()) {
353 353
 			$this->response->addHeader('X-ControllerURL', $url);
354 354
 			if($header = $this->request->getHeader('X-Pjax')) $this->response->addHeader('X-Pjax', $header);
355  
-			if($header = $this->request->getHeader('X-Pjax-Selector')) $this->response->addHeader('X-Pjax-Selector', $header);
356 355
 			return ''; // Actual response will be re-requested by client
357 356
 		} else {
358 357
 			parent::redirect($url, $code);
@@ -437,6 +436,9 @@ public function getResponseNegotiator() {
437 436
 				'Content' => function() use(&$controller) {
438 437
 					return $controller->renderWith($controller->getTemplatesWithSuffix('_Content'));
439 438
 				},
  439
+				'Breadcrumbs' => function() use (&$controller) {
  440
+					return $controller->renderWith('CMSBreadcrumbs');
  441
+				},
440 442
 				'default' => function() use(&$controller) {
441 443
 					return $controller->renderWith($controller->getViewer('show'));
442 444
 				}
@@ -951,6 +953,7 @@ public function getEditForm($id = null, $fields = null) {
951 953
 			$form->addExtraClass('cms-edit-form');
952 954
 			$form->loadDataFrom($record);
953 955
 			$form->setTemplate($this->getTemplatesWithSuffix('_EditForm'));
  956
+			$form->setAttribute('data-pjax-fragment', 'CurrentForm');
954 957
 			
955 958
 			// Set this if you want to split up tabs into a separate header row
956 959
 			// if($form->Fields()->hasTabset()) $form->Fields()->findOrMakeTab('Root')->setTemplate('CMSTabSet');
@@ -1015,6 +1018,7 @@ function EmptyForm() {
1015 1018
 		$form->addExtraClass('cms-edit-form');
1016 1019
 		$form->addExtraClass('root-form');
1017 1020
 		$form->setTemplate($this->getTemplatesWithSuffix('_EditForm'));
  1021
+		$form->setAttribute('data-pjax-fragment', 'CurrentForm');
1018 1022
 		
1019 1023
 		return $form;
1020 1024
 	}
1  admin/code/ModelAdmin.php
@@ -151,6 +151,7 @@ function getEditForm($id = null, $fields = null) {
151 151
 		$form->addExtraClass('cms-edit-form cms-panel-padded center');
152 152
 		$form->setTemplate($this->getTemplatesWithSuffix('_EditForm'));
153 153
 		$form->setFormAction(Controller::join_links($this->Link($this->modelClass), 'EditForm'));
  154
+		$form->setAttribute('data-pjax-fragment', 'CurrentForm');
154 155
 
155 156
 		$this->extend('updateEditForm', $form);
156 157
 		
1  admin/code/SecurityAdmin.php
@@ -160,6 +160,7 @@ public function getEditForm($id = null, $fields = null) {
160 160
 		$form->setTemplate($this->getTemplatesWithSuffix('_EditForm'));
161 161
 		if($form->Fields()->hasTabset()) $form->Fields()->findOrMakeTab('Root')->setTemplate('CMSTabSet');
162 162
 		$form->addExtraClass('center ss-tabset cms-tabset ' . $this->BaseCSSClasses());
  163
+		$form->setAttribute('data-pjax-fragment', 'CurrentForm');
163 164
 
164 165
 		$this->extend('updateEditForm', $form);
165 166
 
3  admin/javascript/LeftAndMain.Content.js
@@ -86,8 +86,7 @@
86 86
 				// sending back different `X-Pjax` headers and content
87 87
 				jQuery.ajax(jQuery.extend({
88 88
 					headers: {
89  
-						"X-Pjax" : "CurrentForm",
90  
-						'X-Pjax-Selector': '.cms-edit-form'
  89
+						"X-Pjax" : "CurrentForm,Breadcrumbs"
91 90
 					},
92 91
 					url: form.attr('action'), 
93 92
 					data: formData,
116  admin/javascript/LeftAndMain.js
@@ -40,10 +40,7 @@ jQuery.noConflict();
40 40
 				// Normalize trailing slashes in URL to work around routing weirdnesses in SS_HTTPRequest.
41 41
 				var isSame = (url && History.getPageUrl().replace(/\/+$/, '') == url.replace(/\/+$/, ''));
42 42
 				if(url && !isSame) {
43  
-					opts = {
44  
-						pjax: xhr.getResponseHeader('X-Pjax') ? xhr.getResponseHeader('X-Pjax') : settings.headers['X-Pjax'], 
45  
-						selector: xhr.getResponseHeader('X-Pjax-Selector') ? xhr.getResponseHeader('X-Pjax-Selector') : settings.headers['X-Pjax-Selector']
46  
-					};
  43
+					opts = {pjax: xhr.getResponseHeader('X-Pjax') ? xhr.getResponseHeader('X-Pjax') : settings.headers['X-Pjax']};
47 44
 					window.History.pushState(opts, '', url);
48 45
 				}
49 46
 			}
@@ -205,30 +202,24 @@ jQuery.noConflict();
205 202
 			 * if the URL is loaded without ajax.
206 203
 			 */
207 204
 			handleStateChange: function() {
208  
-				var self = this, h = window.History, state = h.getState(); 
209  
-				
210 205
 				// Don't allow parallel loading to avoid edge cases
211 206
 				if(this.getCurrentXHR()) this.getCurrentXHR().abort();
212  
-				
213  
-				var selector = state.data.selector || '.cms-content', contentEl = $(selector);
  207
+
  208
+				var self = this, h = window.History, state = h.getState(),
  209
+					fragments = state.data.pjax || 'Content', headers = {},
  210
+					reduceFn = function(fragment) {return '[data-pjax-fragment="' + fragment + '"]';},
  211
+					contentEls = $($.map(fragments.split(','), reduceFn).join(','));
214 212
 				
215 213
 				this.trigger('beforestatechange', {
216  
-					state: state, element: contentEl
  214
+					state: state, element: contentEls
217 215
 				});
218 216
 
219 217
 				// Set Pjax headers, which can declare a preference for the returned view.
220 218
 				// The actually returned view isn't always decided upon when the request
221 219
 				// is fired, so the server might decide to change it based on its own logic.
222  
-				var headers = {};
223  
-				if(state.data.pjax) {
224  
-					headers['X-Pjax'] = state.data.pjax;
225  
-				} else {
226  
-					// Standard Pjax behaviour is to replace right content area
227  
-					headers["X-Pjax"] = 'Content';
228  
-				}
229  
-				headers['X-Pjax-Selector'] = selector;
  220
+				headers['X-Pjax'] = fragments;
230 221
 
231  
-				contentEl.addClass('loading');
  222
+				contentEls.addClass('loading');
232 223
 				var xhr = $.ajax({
233 224
 					headers: headers,
234 225
 					url: state.url,
@@ -240,46 +231,60 @@ jQuery.noConflict();
240 231
 						// Update title
241 232
 						var title = xhr.getResponseHeader('X-Title');
242 233
 						if(title) document.title = title;
243  
-						
244  
-						// Update panels
245  
-						var newContentEl = $(data);
246  
-						if(newContentEl.find('.cms-container').length) {
247  
-							throw 'Content loaded via ajax is not allowed to contain tags matching the ".cms-container" selector to avoid infinite loops';
248  
-						}
249  
-						
250  
-						// Set loading state and store element state
251  
-						newContentEl.addClass('loading');
252  
-						var origStyle = contentEl.attr('style');
253  
-						var layoutClasses = ['east', 'west', 'center', 'north', 'south'];
254  
-						var elemClasses = contentEl.attr('class');
255  
-						
256  
-						var origLayoutClasses = [];
257  
-						if(elemClasses) {
258  
-							origLayoutClasses = $.grep(
259  
-								elemClasses.split(' '),
260  
-								function(val) { return ($.inArray(val, layoutClasses) >= 0);}
261  
-							);
262  
-						}
263  
-						
264  
-						newContentEl
265  
-							.removeClass(layoutClasses.join(' '))
266  
-							.addClass(origLayoutClasses.join(' '));
267  
-						if(origStyle) newContentEl.attr('style', origStyle);
268  
-						newContentEl.css('visibility', 'hidden');
269  
-
270  
-						// Allow injection of inline styles, as they're not allowed in the document body.
271  
-						// Not handling this through jQuery.ondemand to avoid parsing the DOM twice.
272  
-						var styles = newContentEl.find('style').detach();
273  
-						if(styles.length) $(document).find('head').append(styles);
274 234
 
275  
-						// Replace panel completely (we need to override the "layout" attribute, so can't replace the child instead)
276  
-						contentEl.replaceWith(newContentEl);
  235
+						// Remove loading indication from old content els (regardless of which are replaced)
  236
+						contentEls.removeClass('loading');
277 237
 
278  
-						// Unset loading and restore element state (to avoid breaking existing panel visibility, e.g. with preview expanded)
279  
-						self.redraw();
280  
-						newContentEl.css('visibility', 'visible');
281  
-						newContentEl.removeClass('loading');
  238
+						var newFragments = {};
  239
+						if(xhr.getResponseHeader('Content-Type') == 'text/json') {
  240
+							newFragments = data;
  241
+						} else {
  242
+							// Fall back to replacing the first fragment only if HTML is returned
  243
+							newFragments[fragments.split(',').pop()] = data;
  244
+						}
282 245
 
  246
+						$.each(newFragments, function(newFragment, html) {
  247
+							var contentEl = $('[data-pjax-fragment=' + newFragment + ']'), newContentEl = $(html);
  248
+							
  249
+							// Update panels
  250
+							if(newContentEl.find('.cms-container').length) {
  251
+								throw 'Content loaded via ajax is not allowed to contain tags matching the ".cms-container" selector to avoid infinite loops';
  252
+							}
  253
+							
  254
+							// Set loading state and store element state
  255
+							newContentEl.addClass('loading');
  256
+							var origStyle = contentEl.attr('style');
  257
+							var layoutClasses = ['east', 'west', 'center', 'north', 'south'];
  258
+							var elemClasses = contentEl.attr('class');
  259
+							
  260
+							var origLayoutClasses = [];
  261
+							if(elemClasses) {
  262
+								origLayoutClasses = $.grep(
  263
+									elemClasses.split(' '),
  264
+									function(val) { return ($.inArray(val, layoutClasses) >= 0);}
  265
+								);
  266
+							}
  267
+							
  268
+							newContentEl
  269
+								.removeClass(layoutClasses.join(' '))
  270
+								.addClass(origLayoutClasses.join(' '));
  271
+							if(origStyle) newContentEl.attr('style', origStyle);
  272
+							newContentEl.css('visibility', 'hidden');
  273
+
  274
+							// Allow injection of inline styles, as they're not allowed in the document body.
  275
+							// Not handling this through jQuery.ondemand to avoid parsing the DOM twice.
  276
+							var styles = newContentEl.find('style').detach();
  277
+							if(styles.length) $(document).find('head').append(styles);
  278
+
  279
+							// Replace panel completely (we need to override the "layout" attribute, so can't replace the child instead)
  280
+							contentEl.replaceWith(newContentEl);
  281
+
  282
+							// Unset loading and restore element state (to avoid breaking existing panel visibility, e.g. with preview expanded)
  283
+							self.redraw();
  284
+							newContentEl.css('visibility', 'visible');
  285
+							newContentEl.removeClass('loading');
  286
+						});
  287
+						
283 288
 						self.trigger('afterstatechange', {data: data, status: status, xhr: xhr, element: newContentEl});
284 289
 					},
285 290
 					error: function(xhr, status, e) {
@@ -290,6 +295,7 @@ jQuery.noConflict();
290 295
 				
291 296
 				this.setCurrentXHR(xhr);
292 297
 			},
  298
+
293 299
 			/**
294 300
 			 * Function: refresh
295 301
 			 * 
2  admin/templates/CMSBreadcrumbs.ss
... ...
@@ -1,4 +1,4 @@
1  
-<div class="breadcrumbs-wrapper">
  1
+<div class="breadcrumbs-wrapper" data-pjax-fragment="Breadcrumbs">
2 2
 	<% loop Breadcrumbs %>
3 3
 		<% if Last %>
4 4
 			<span class="cms-panel-link crumb">$Title.XML</span>
2  admin/templates/Includes/LeftAndMain_Content.ss
... ...
@@ -1,4 +1,4 @@
1  
-<div class="cms-content center $BaseCSSClasses" data-layout-type="border">
  1
+<div class="cms-content center $BaseCSSClasses" data-layout-type="border" data-pjax-fragment="Content">
2 2
 
3 3
 	$Tools
4 4
 
2  admin/templates/Includes/ModelAdmin_Content.ss
... ...
@@ -1,4 +1,4 @@
1  
-<div class="cms-content center $BaseCSSClasses" data-layout-type="border">
  1
+<div class="cms-content center $BaseCSSClasses" data-layout-type="border" data-pjax-fragment="Content">
2 2
 
3 3
 	<div class="cms-content-header north">
4 4
 		<div>
75  docs/en/reference/cms-architecture.md
Source Rendered
@@ -145,7 +145,7 @@ Selectors used in these files should mirrow the "scope" set by its filename,
145 145
 so don't place a rule applying to all form buttons inside `ModelAdmin.js`.
146 146
 
147 147
 The CMS relies heavily on Ajax-loading of interfaces, so each interface and the JavaScript
148  
-driving it have to assume its underlying DOM structure is appended via Ajax callback
  148
+driving it have to assume its underlying DOM structure is appended via an Ajax callback
149 149
 rather than being available when the browser window first loads. 
150 150
 jQuery.entwine is effectively an advanced version of [jQuery.live](http://api.jquery.com/live/)
151 151
 and [jQuery.delegate](http://api.jquery.com/delegate/), so takes care of dynamic event binding.
@@ -167,9 +167,6 @@ a CMS developer needs to fire a navigation event rather than invoking the Ajax c
167 167
 The main point of contact here is `$('.cms-container').loadPanel(<url>, <title>, <data>)`
168 168
 in `LeftAndMain.js`. The `data` object can contain additional state which is required
169 169
 in case the same navigation event is fired again (e.g. when the user pressed the back button).
170  
-Most commonly, the (optional) `data.selector` property declares which DOM element to replace
171  
-with the newly loaded HTML (it defaults to `.cms-content`). This is handy to only replace
172  
-e.g. an edit form, but leave the search panel in the same "content area" unchanged.
173 170
 
174 171
 No callbacks are allowed in this style of Ajax loading, as all state needs
175 172
 to be "repeatable". Any logic required to be exected after the Ajax call
@@ -177,16 +174,79 @@ should be placed in jQuery.entinwe `onmatch()` rules which apply to the newly cr
177 174
 See `$('.cms-container').handleStateChange()` in `LeftAndMain.js` for details.
178 175
 
179 176
 Alternatively, form-related Ajax calls can be invoked through their own wrappers,
180  
-which don't cause history events and hence allow callbacks: `$('.cms-content').submitForm()`.
  177
+which don't cause history events and hence allow callbacks: `$('.cms-container').submitForm()`.
  178
+
  179
+## PJAX: Partial template replacement through Ajax
  180
+
  181
+Many user interactions can change more than one area in the CMS.
  182
+For example, editing a page title in the CMS form changes it in the page tree
  183
+as well as the breadcrumbs. In order to avoid unnecessary processing,
  184
+we often want to update these sections independently from their neighbouring content.
  185
+
  186
+In order for this to work, the CMS templates declare certain sections as "PJAX fragments"
  187
+through a `data-pjax-fragment` attribute. These names correlate to specific
  188
+rendering logic in the PHP controllers, through the `[api:PjaxResponseNegotiator]` class.
181 189
 
182  
-Within the PHP logic, the `[api:PjaxResponseNegotiator]` class determines which view is rendered.
183 190
 Through a custom `X-Pjax` HTTP header, the client can declare which view he's expecting,
184 191
 through identifiers like `CurrentForm` or `Content` (see `[api:LeftAndMain->getResponseNegotiator()]`).
185 192
 These identifiers are passed to `loadPanel()` via the `pjax` data option.
  193
+The HTTP response is a JSON object literal, with template replacements keyed by their Pjax fragment.
  194
+Through PHP callbacks, we ensure that only the required template parts are actually executed and rendered.
  195
+When the same URL is loaded without Ajax (and hence without `X-Pjax` headers),
  196
+it should behave like a normal full page template, but using the same controller logic.
  197
+
  198
+Example: Create a bare-bones CMS subclass which shows breadcrumbs (a built-in method),
  199
+as well as info on the current record. A single link updates both sections independently
  200
+in a single Ajax request.
  201
+
  202
+	:::php
  203
+	// mysite/code/MyAdmin.php
  204
+	class MyAdmin extends LeftAndMain {
  205
+		static $url_segment = 'myadmin';
  206
+		public function getResponseNegotiator() {
  207
+			$negotiator = parent::getResponseNegotiator();
  208
+			$controller = $this;
  209
+			// Register a new callback
  210
+			$negotiator->setCallback('MyRecordInfo', function() use(&$controller) {
  211
+				return $controller->MyRecordInfo();
  212
+			});
  213
+			return $negotiator;
  214
+		}
  215
+		public function MyRecordInfo() {
  216
+			return $this->renderWith('MyRecordInfo');
  217
+		}
  218
+	}
  219
+
  220
+	:::js
  221
+	// MyAdmin.ss
  222
+	<% include CMSBreadcrumbs %>
  223
+	<div>Static content (not affected by update)</div>
  224
+	<% include MyRecordInfo %>
  225
+	<a href="admin/myadmin" class="cms-panel-link" data-pjax-target="MyRecordInfo,Breadcrumbs">
  226
+		Update record info
  227
+	</a>
  228
+
  229
+	:::ss
  230
+	// MyRecordInfo.ss
  231
+	<div data-pjax-fragment="MyRecordInfo">
  232
+		Current Record: $currentPage.Title
  233
+	</div>
  234
+
  235
+A click on the link will cause the following (abbreviated) ajax HTTP request:
  236
+
  237
+	GET /admin/myadmin HTTP/1.1
  238
+	X-Pjax:Content
  239
+	X-Requested-With:XMLHttpRequest
  240
+
  241
+... and result in the following response:
  242
+
  243
+	{"MyRecordInfo": "<div...", "CMSBreadcrumbs": "<div..."}
  244
+
186 245
 Keep in mind that the returned view isn't always decided upon when the Ajax request
187 246
 is fired, so the server might decide to change it based on its own logic,
188 247
 sending back different `X-Pjax` headers and content.
189 248
 
  249
+
190 250
 ## Ajax Redirects
191 251
 
192 252
 Sometimes, a server response represents a new URL state, e.g. when submitting an "add record" form,
@@ -226,8 +286,7 @@ Built-in headers are:
226 286
 Some links should do more than load a new page in the browser window.
227 287
 To avoid repetition, we've written some helpers for various use cases:
228 288
 
229  
- * Load into a panel: `<a href="..." class="cms-panel-link" data-target-panel=".cms-content">`
230  
- * Load via ajax, and show response status message: `<a href="..." class="cms-link-ajax">`
  289
+ * Load into a PJAX panel: `<a href="..." class="cms-panel-link" data-pjax-target="Content">`
231 290
  * Load URL as an iframe into a popup/dialog: `<a href="..." class="ss-ui-dialog-link">`
232 291
 
233 292
 ## Buttons
1  forms/gridfield/GridFieldDetailForm.php
@@ -326,6 +326,7 @@ function ItemEditForm() {
326 326
 			// TODO Allow customization, e.g. to display an edit form alongside a search form from the CMS controller
327 327
 			$form->setTemplate('LeftAndMain_EditForm');
328 328
 			$form->addExtraClass('cms-content cms-edit-form center ss-tabset');
  329
+			$form->setAttribute('data-pjax-fragment', 'CurrentForm Content');
329 330
 			if($form->Fields()->hasTabset()) $form->Fields()->findOrMakeTab('Root')->setTemplate('CMSTabSet');
330 331
 			// TODO Link back to controller action (and edited root record) rather than index,
331 332
 			// which requires more URL knowledge than the current link to this field gives us.
2  forms/gridfield/GridFieldLevelup.php
@@ -33,7 +33,7 @@ public function getHTMLFragments($gridField) {
33 33
 			//$controller = $gridField->getForm()->Controller();
34 34
 			$forTemplate = new ArrayData(array(
35 35
 				'UpLink' => sprintf(
36  
-					'<a class="cms-panel-link list-parent-link" href="?ParentID=%d&view=list" data-target-panel="#Form_ListViewForm" data-pjax="ListViewForm">%s</a>',
  36
+					'<a class="cms-panel-link list-parent-link" href="?ParentID=%d&view=list" data-pjax-target="ListViewForm,Breadcrumbs">%s</a>',
37 37
 					$parentID,
38 38
 					_t('GridField.LEVELUP', 'Level up' )
39 39
 				),

0 notes on commit 5178954

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