mirror of
https://github.com/silverstripe/silverstripe-framework
synced 2024-10-22 14:05:37 +02:00
UNFINISHED Processing multiple PJAX responses on CMS JavaScript, introducing data-pjax-fragment attribute to identify reloadable template parts
This commit is contained in:
parent
473eda43cb
commit
5178954311
@ -352,7 +352,6 @@ class LeftAndMain extends Controller implements PermissionProvider {
|
||||
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 @@ class LeftAndMain extends Controller implements PermissionProvider {
|
||||
'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 @@ class LeftAndMain extends Controller implements PermissionProvider {
|
||||
$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 @@ class LeftAndMain extends Controller implements PermissionProvider {
|
||||
$form->addExtraClass('cms-edit-form');
|
||||
$form->addExtraClass('root-form');
|
||||
$form->setTemplate($this->getTemplatesWithSuffix('_EditForm'));
|
||||
$form->setAttribute('data-pjax-fragment', 'CurrentForm');
|
||||
|
||||
return $form;
|
||||
}
|
||||
|
@ -151,6 +151,7 @@ abstract class ModelAdmin extends LeftAndMain {
|
||||
$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);
|
||||
|
||||
|
@ -160,6 +160,7 @@ class SecurityAdmin extends LeftAndMain implements PermissionProvider {
|
||||
$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);
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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';
|
||||
|
||||
// Remove loading indication from old content els (regardless of which are replaced)
|
||||
contentEls.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');
|
||||
});
|
||||
|
||||
// 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
|
||||
*
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -326,6 +326,7 @@ class GridFieldDetailForm_ItemRequest extends RequestHandler {
|
||||
// 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.
|
||||
|
@ -33,7 +33,7 @@ class GridFieldLevelup implements GridField_HTMLProvider{
|
||||
//$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' )
|
||||
),
|
||||
|
Loading…
Reference in New Issue
Block a user