Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

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
@chillu chillu authored
View
6 admin/code/LeftAndMain.php
@@ -352,7 +352,6 @@ function redirect($url, $code=302) {
if($this->request->isAjax()) {
$this->response->addHeader('X-ControllerURL', $url);
if($header = $this->request->getHeader('X-Pjax')) $this->response->addHeader('X-Pjax', $header);
- if($header = $this->request->getHeader('X-Pjax-Selector')) $this->response->addHeader('X-Pjax-Selector', $header);
return ''; // Actual response will be re-requested by client
} else {
parent::redirect($url, $code);
@@ -437,6 +436,9 @@ public function getResponseNegotiator() {
'Content' => function() use(&$controller) {
return $controller->renderWith($controller->getTemplatesWithSuffix('_Content'));
},
+ 'Breadcrumbs' => function() use (&$controller) {
+ return $controller->renderWith('CMSBreadcrumbs');
+ },
'default' => function() use(&$controller) {
return $controller->renderWith($controller->getViewer('show'));
}
@@ -951,6 +953,7 @@ public function getEditForm($id = null, $fields = null) {
$form->addExtraClass('cms-edit-form');
$form->loadDataFrom($record);
$form->setTemplate($this->getTemplatesWithSuffix('_EditForm'));
+ $form->setAttribute('data-pjax-fragment', 'CurrentForm');
// Set this if you want to split up tabs into a separate header row
// if($form->Fields()->hasTabset()) $form->Fields()->findOrMakeTab('Root')->setTemplate('CMSTabSet');
@@ -1015,6 +1018,7 @@ function EmptyForm() {
$form->addExtraClass('cms-edit-form');
$form->addExtraClass('root-form');
$form->setTemplate($this->getTemplatesWithSuffix('_EditForm'));
+ $form->setAttribute('data-pjax-fragment', 'CurrentForm');
return $form;
}
View
1  admin/code/ModelAdmin.php
@@ -151,6 +151,7 @@ function getEditForm($id = null, $fields = null) {
$form->addExtraClass('cms-edit-form cms-panel-padded center');
$form->setTemplate($this->getTemplatesWithSuffix('_EditForm'));
$form->setFormAction(Controller::join_links($this->Link($this->modelClass), 'EditForm'));
+ $form->setAttribute('data-pjax-fragment', 'CurrentForm');
$this->extend('updateEditForm', $form);
View
1  admin/code/SecurityAdmin.php
@@ -160,6 +160,7 @@ public function getEditForm($id = null, $fields = null) {
$form->setTemplate($this->getTemplatesWithSuffix('_EditForm'));
if($form->Fields()->hasTabset()) $form->Fields()->findOrMakeTab('Root')->setTemplate('CMSTabSet');
$form->addExtraClass('center ss-tabset cms-tabset ' . $this->BaseCSSClasses());
+ $form->setAttribute('data-pjax-fragment', 'CurrentForm');
$this->extend('updateEditForm', $form);
View
3  admin/javascript/LeftAndMain.Content.js
@@ -86,8 +86,7 @@
// sending back different `X-Pjax` headers and content
jQuery.ajax(jQuery.extend({
headers: {
- "X-Pjax" : "CurrentForm",
- 'X-Pjax-Selector': '.cms-edit-form'
+ "X-Pjax" : "CurrentForm,Breadcrumbs"
},
url: form.attr('action'),
data: formData,
View
116 admin/javascript/LeftAndMain.js
@@ -40,10 +40,7 @@ jQuery.noConflict();
// Normalize trailing slashes in URL to work around routing weirdnesses in SS_HTTPRequest.
var isSame = (url && History.getPageUrl().replace(/\/+$/, '') == url.replace(/\/+$/, ''));
if(url && !isSame) {
- opts = {
- pjax: xhr.getResponseHeader('X-Pjax') ? xhr.getResponseHeader('X-Pjax') : settings.headers['X-Pjax'],
- selector: xhr.getResponseHeader('X-Pjax-Selector') ? xhr.getResponseHeader('X-Pjax-Selector') : settings.headers['X-Pjax-Selector']
- };
+ opts = {pjax: xhr.getResponseHeader('X-Pjax') ? xhr.getResponseHeader('X-Pjax') : settings.headers['X-Pjax']};
window.History.pushState(opts, '', url);
}
}
@@ -205,30 +202,24 @@ jQuery.noConflict();
* if the URL is loaded without ajax.
*/
handleStateChange: function() {
- var self = this, h = window.History, state = h.getState();
-
// Don't allow parallel loading to avoid edge cases
if(this.getCurrentXHR()) this.getCurrentXHR().abort();
-
- var selector = state.data.selector || '.cms-content', contentEl = $(selector);
+
+ var self = this, h = window.History, state = h.getState(),
+ fragments = state.data.pjax || 'Content', headers = {},
+ reduceFn = function(fragment) {return '[data-pjax-fragment="' + fragment + '"]';},
+ contentEls = $($.map(fragments.split(','), reduceFn).join(','));
this.trigger('beforestatechange', {
- state: state, element: contentEl
+ state: state, element: contentEls
});
// Set Pjax headers, which can declare a preference for the returned view.
// The actually returned view isn't always decided upon when the request
// is fired, so the server might decide to change it based on its own logic.
- var headers = {};
- if(state.data.pjax) {
- headers['X-Pjax'] = state.data.pjax;
- } else {
- // Standard Pjax behaviour is to replace right content area
- headers["X-Pjax"] = 'Content';
- }
- headers['X-Pjax-Selector'] = selector;
+ headers['X-Pjax'] = fragments;
- contentEl.addClass('loading');
+ contentEls.addClass('loading');
var xhr = $.ajax({
headers: headers,
url: state.url,
@@ -240,46 +231,60 @@ jQuery.noConflict();
// Update title
var title = xhr.getResponseHeader('X-Title');
if(title) document.title = title;
-
- // Update panels
- var newContentEl = $(data);
- if(newContentEl.find('.cms-container').length) {
- throw 'Content loaded via ajax is not allowed to contain tags matching the ".cms-container" selector to avoid infinite loops';
- }
-
- // Set loading state and store element state
- newContentEl.addClass('loading');
- var origStyle = contentEl.attr('style');
- var layoutClasses = ['east', 'west', 'center', 'north', 'south'];
- var elemClasses = contentEl.attr('class');
-
- var origLayoutClasses = [];
- if(elemClasses) {
- origLayoutClasses = $.grep(
- elemClasses.split(' '),
- function(val) { return ($.inArray(val, layoutClasses) >= 0);}
- );
- }
-
- newContentEl
- .removeClass(layoutClasses.join(' '))
- .addClass(origLayoutClasses.join(' '));
- if(origStyle) newContentEl.attr('style', origStyle);
- newContentEl.css('visibility', 'hidden');
-
- // Allow injection of inline styles, as they're not allowed in the document body.
- // Not handling this through jQuery.ondemand to avoid parsing the DOM twice.
- var styles = newContentEl.find('style').detach();
- if(styles.length) $(document).find('head').append(styles);
- // Replace panel completely (we need to override the "layout" attribute, so can't replace the child instead)
- contentEl.replaceWith(newContentEl);
+ // Remove loading indication from old content els (regardless of which are replaced)
+ contentEls.removeClass('loading');
- // Unset loading and restore element state (to avoid breaking existing panel visibility, e.g. with preview expanded)
- self.redraw();
- newContentEl.css('visibility', 'visible');
- newContentEl.removeClass('loading');
+ var newFragments = {};
+ if(xhr.getResponseHeader('Content-Type') == 'text/json') {
+ newFragments = data;
+ } else {
+ // Fall back to replacing the first fragment only if HTML is returned
+ newFragments[fragments.split(',').pop()] = data;
+ }
+ $.each(newFragments, function(newFragment, html) {
+ var contentEl = $('[data-pjax-fragment=' + newFragment + ']'), newContentEl = $(html);
+
+ // Update panels
+ if(newContentEl.find('.cms-container').length) {
+ throw 'Content loaded via ajax is not allowed to contain tags matching the ".cms-container" selector to avoid infinite loops';
+ }
+
+ // Set loading state and store element state
+ newContentEl.addClass('loading');
+ var origStyle = contentEl.attr('style');
+ var layoutClasses = ['east', 'west', 'center', 'north', 'south'];
+ var elemClasses = contentEl.attr('class');
+
+ var origLayoutClasses = [];
+ if(elemClasses) {
+ origLayoutClasses = $.grep(
+ elemClasses.split(' '),
+ function(val) { return ($.inArray(val, layoutClasses) >= 0);}
+ );
+ }
+
+ newContentEl
+ .removeClass(layoutClasses.join(' '))
+ .addClass(origLayoutClasses.join(' '));
+ if(origStyle) newContentEl.attr('style', origStyle);
+ newContentEl.css('visibility', 'hidden');
+
+ // Allow injection of inline styles, as they're not allowed in the document body.
+ // Not handling this through jQuery.ondemand to avoid parsing the DOM twice.
+ var styles = newContentEl.find('style').detach();
+ if(styles.length) $(document).find('head').append(styles);
+
+ // Replace panel completely (we need to override the "layout" attribute, so can't replace the child instead)
+ contentEl.replaceWith(newContentEl);
+
+ // Unset loading and restore element state (to avoid breaking existing panel visibility, e.g. with preview expanded)
+ self.redraw();
+ newContentEl.css('visibility', 'visible');
+ newContentEl.removeClass('loading');
+ });
+
self.trigger('afterstatechange', {data: data, status: status, xhr: xhr, element: newContentEl});
},
error: function(xhr, status, e) {
@@ -290,6 +295,7 @@ jQuery.noConflict();
this.setCurrentXHR(xhr);
},
+
/**
* Function: refresh
*
View
2  admin/templates/CMSBreadcrumbs.ss
@@ -1,4 +1,4 @@
-<div class="breadcrumbs-wrapper">
+<div class="breadcrumbs-wrapper" data-pjax-fragment="Breadcrumbs">
<% loop Breadcrumbs %>
<% if Last %>
<span class="cms-panel-link crumb">$Title.XML</span>
View
2  admin/templates/Includes/LeftAndMain_Content.ss
@@ -1,4 +1,4 @@
-<div class="cms-content center $BaseCSSClasses" data-layout-type="border">
+<div class="cms-content center $BaseCSSClasses" data-layout-type="border" data-pjax-fragment="Content">
$Tools
View
2  admin/templates/Includes/ModelAdmin_Content.ss
@@ -1,4 +1,4 @@
-<div class="cms-content center $BaseCSSClasses" data-layout-type="border">
+<div class="cms-content center $BaseCSSClasses" data-layout-type="border" data-pjax-fragment="Content">
<div class="cms-content-header north">
<div>
View
75 docs/en/reference/cms-architecture.md
@@ -145,7 +145,7 @@ Selectors used in these files should mirrow the "scope" set by its filename,
so don't place a rule applying to all form buttons inside `ModelAdmin.js`.
The CMS relies heavily on Ajax-loading of interfaces, so each interface and the JavaScript
-driving it have to assume its underlying DOM structure is appended via Ajax callback
+driving it have to assume its underlying DOM structure is appended via an Ajax callback
rather than being available when the browser window first loads.
jQuery.entwine is effectively an advanced version of [jQuery.live](http://api.jquery.com/live/)
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
The main point of contact here is `$('.cms-container').loadPanel(<url>, <title>, <data>)`
in `LeftAndMain.js`. The `data` object can contain additional state which is required
in case the same navigation event is fired again (e.g. when the user pressed the back button).
-Most commonly, the (optional) `data.selector` property declares which DOM element to replace
-with the newly loaded HTML (it defaults to `.cms-content`). This is handy to only replace
-e.g. an edit form, but leave the search panel in the same "content area" unchanged.
No callbacks are allowed in this style of Ajax loading, as all state needs
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
See `$('.cms-container').handleStateChange()` in `LeftAndMain.js` for details.
Alternatively, form-related Ajax calls can be invoked through their own wrappers,
-which don't cause history events and hence allow callbacks: `$('.cms-content').submitForm()`.
+which don't cause history events and hence allow callbacks: `$('.cms-container').submitForm()`.
+
+## PJAX: Partial template replacement through Ajax
+
+Many user interactions can change more than one area in the CMS.
+For example, editing a page title in the CMS form changes it in the page tree
+as well as the breadcrumbs. In order to avoid unnecessary processing,
+we often want to update these sections independently from their neighbouring content.
+
+In order for this to work, the CMS templates declare certain sections as "PJAX fragments"
+through a `data-pjax-fragment` attribute. These names correlate to specific
+rendering logic in the PHP controllers, through the `[api:PjaxResponseNegotiator]` class.
-Within the PHP logic, the `[api:PjaxResponseNegotiator]` class determines which view is rendered.
Through a custom `X-Pjax` HTTP header, the client can declare which view he's expecting,
through identifiers like `CurrentForm` or `Content` (see `[api:LeftAndMain->getResponseNegotiator()]`).
These identifiers are passed to `loadPanel()` via the `pjax` data option.
+The HTTP response is a JSON object literal, with template replacements keyed by their Pjax fragment.
+Through PHP callbacks, we ensure that only the required template parts are actually executed and rendered.
+When the same URL is loaded without Ajax (and hence without `X-Pjax` headers),
+it should behave like a normal full page template, but using the same controller logic.
+
+Example: Create a bare-bones CMS subclass which shows breadcrumbs (a built-in method),
+as well as info on the current record. A single link updates both sections independently
+in a single Ajax request.
+
+ :::php
+ // mysite/code/MyAdmin.php
+ class MyAdmin extends LeftAndMain {
+ static $url_segment = 'myadmin';
+ public function getResponseNegotiator() {
+ $negotiator = parent::getResponseNegotiator();
+ $controller = $this;
+ // Register a new callback
+ $negotiator->setCallback('MyRecordInfo', function() use(&$controller) {
+ return $controller->MyRecordInfo();
+ });
+ return $negotiator;
+ }
+ public function MyRecordInfo() {
+ return $this->renderWith('MyRecordInfo');
+ }
+ }
+
+ :::js
+ // MyAdmin.ss
+ <% include CMSBreadcrumbs %>
+ <div>Static content (not affected by update)</div>
+ <% include MyRecordInfo %>
+ <a href="admin/myadmin" class="cms-panel-link" data-pjax-target="MyRecordInfo,Breadcrumbs">
+ Update record info
+ </a>
+
+ :::ss
+ // MyRecordInfo.ss
+ <div data-pjax-fragment="MyRecordInfo">
+ Current Record: $currentPage.Title
+ </div>
+
+A click on the link will cause the following (abbreviated) ajax HTTP request:
+
+ GET /admin/myadmin HTTP/1.1
+ X-Pjax:Content
+ X-Requested-With:XMLHttpRequest
+
+... and result in the following response:
+
+ {"MyRecordInfo": "<div...", "CMSBreadcrumbs": "<div..."}
+
Keep in mind that the returned view isn't always decided upon when the Ajax request
is fired, so the server might decide to change it based on its own logic,
sending back different `X-Pjax` headers and content.
+
## Ajax Redirects
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:
Some links should do more than load a new page in the browser window.
To avoid repetition, we've written some helpers for various use cases:
- * Load into a panel: `<a href="..." class="cms-panel-link" data-target-panel=".cms-content">`
- * Load via ajax, and show response status message: `<a href="..." class="cms-link-ajax">`
+ * Load into a PJAX panel: `<a href="..." class="cms-panel-link" data-pjax-target="Content">`
* Load URL as an iframe into a popup/dialog: `<a href="..." class="ss-ui-dialog-link">`
## Buttons
View
1  forms/gridfield/GridFieldDetailForm.php
@@ -326,6 +326,7 @@ function ItemEditForm() {
// TODO Allow customization, e.g. to display an edit form alongside a search form from the CMS controller
$form->setTemplate('LeftAndMain_EditForm');
$form->addExtraClass('cms-content cms-edit-form center ss-tabset');
+ $form->setAttribute('data-pjax-fragment', 'CurrentForm Content');
if($form->Fields()->hasTabset()) $form->Fields()->findOrMakeTab('Root')->setTemplate('CMSTabSet');
// TODO Link back to controller action (and edited root record) rather than index,
// which requires more URL knowledge than the current link to this field gives us.
View
2  forms/gridfield/GridFieldLevelup.php
@@ -33,7 +33,7 @@ public function getHTMLFragments($gridField) {
//$controller = $gridField->getForm()->Controller();
$forTemplate = new ArrayData(array(
'UpLink' => sprintf(
- '<a class="cms-panel-link list-parent-link" href="?ParentID=%d&view=list" data-target-panel="#Form_ListViewForm" data-pjax="ListViewForm">%s</a>',
+ '<a class="cms-panel-link list-parent-link" href="?ParentID=%d&view=list" data-pjax-target="ListViewForm,Breadcrumbs">%s</a>',
$parentID,
_t('GridField.LEVELUP', 'Level up' )
),
Please sign in to comment.
Something went wrong with that request. Please try again.