From 473eda43cb24a66964d932a4b29376326b5b5587 Mon Sep 17 00:00:00 2001 From: Ingo Schommer Date: Wed, 18 Apr 2012 21:15:31 +0200 Subject: [PATCH 1/8] ENHANCEMENT Allowing multiple fragments to be returned by PjaxResponseNegotiator through JSON --- control/PjaxResponseNegotiator.php | 31 +++++++++++++++----- tests/control/PjaxResponseNegotiatorTest.php | 16 ++++++++++ 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/control/PjaxResponseNegotiator.php b/control/PjaxResponseNegotiator.php index b4f0fb8a2..06f598e5b 100644 --- a/control/PjaxResponseNegotiator.php +++ b/control/PjaxResponseNegotiator.php @@ -44,19 +44,36 @@ class PjaxResponseNegotiator { array_change_key_case($this->callbacks, CASE_LOWER), array_change_key_case($extraCallbacks, CASE_LOWER) ); + + $response = new SS_HTTPResponse(); - if($fragment = $request->getHeader('X-Pjax')) { - $fragment = strtolower($fragment); - if(isset($callbacks[$fragment])) { - return call_user_func($callbacks[$fragment]); + $responseParts = array(); + if($fragmentStr = $request->getHeader('X-Pjax')) { + $fragments = explode(',', strtolower($fragmentStr)); + foreach($fragments as $fragment) { + if(isset($callbacks[$fragment])) { + $responseParts[$fragment] = call_user_func($callbacks[$fragment]); + } else { + throw new SS_HTTPResponse_Exception("X-Pjax = '$fragment' not supported for this URL.", 400); + } + } + if(count($responseParts) == 1) { + $response->setBody(array_pop($responseParts)); } else { - throw new SS_HTTPResponse_Exception("X-Pjax = '$fragment' not supported for this URL.", 400); + if($request->getHeader('Accept') != 'text/json') { + throw new SS_HTTPResponse_Exception( + 'Multiple comma-separated fragments can only be returne with an "Accept: text/json" header', + 400 + ); + } + $response->setBody(Convert::raw2json($responseParts)); + $response->addHeader('Content-Type', 'text/json'); } } 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']); + $response->setBody(call_user_func($callbacks['default'])); } - + return $response; } /** diff --git a/tests/control/PjaxResponseNegotiatorTest.php b/tests/control/PjaxResponseNegotiatorTest.php index 1b44e09b2..32633b223 100644 --- a/tests/control/PjaxResponseNegotiatorTest.php +++ b/tests/control/PjaxResponseNegotiatorTest.php @@ -19,4 +19,20 @@ class PjaxResponseNegotiatorTest extends SapphireTest { $this->assertEquals('myfragment response', $negotiator->respond($request)); } + function testMultipleFragments() { + $negotiator = new PjaxResponseNegotiator(array( + 'default' => function() {return 'default response';}, + 'myfragment' => function() {return 'myfragment response';}, + 'otherfragment' => function() {return 'otherfragment response';}, + )); + $request = new SS_HTTPRequest('GET', '/'); + $request->addHeader('X-Pjax', 'myfragment,otherfragment'); + $request->addHeader('Accept', 'text/json'); + $json = json_decode($negotiator->respond($request)); + $this->assertObjectHasAttribute('myfragment', $json); + $this->assertEquals('myfragment response', $json->myfragment); + $this->assertObjectHasAttribute('otherfragment', $json); + $this->assertEquals('otherfragment response', $json->otherfragment); + } + } From 5178954311419b203a1ca4857b74c2665380eb75 Mon Sep 17 00:00:00 2001 From: Ingo Schommer Date: Wed, 18 Apr 2012 22:11:40 +0200 Subject: [PATCH 2/8] UNFINISHED Processing multiple PJAX responses on CMS JavaScript, introducing data-pjax-fragment attribute to identify reloadable template parts --- admin/code/LeftAndMain.php | 6 +- admin/code/ModelAdmin.php | 1 + admin/code/SecurityAdmin.php | 1 + admin/javascript/LeftAndMain.Content.js | 3 +- admin/javascript/LeftAndMain.js | 118 +++++++++--------- admin/templates/CMSBreadcrumbs.ss | 2 +- .../templates/Includes/LeftAndMain_Content.ss | 2 +- .../templates/Includes/ModelAdmin_Content.ss | 2 +- docs/en/reference/cms-architecture.md | 75 +++++++++-- forms/gridfield/GridFieldDetailForm.php | 1 + forms/gridfield/GridFieldLevelup.php | 2 +- 11 files changed, 142 insertions(+), 71 deletions(-) diff --git a/admin/code/LeftAndMain.php b/admin/code/LeftAndMain.php index 8ace852d0..f08b1394e 100644 --- a/admin/code/LeftAndMain.php +++ b/admin/code/LeftAndMain.php @@ -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; } diff --git a/admin/code/ModelAdmin.php b/admin/code/ModelAdmin.php index 0c8e4c370..0696324f7 100644 --- a/admin/code/ModelAdmin.php +++ b/admin/code/ModelAdmin.php @@ -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); diff --git a/admin/code/SecurityAdmin.php b/admin/code/SecurityAdmin.php index 7ad486ffd..0766c2ada 100755 --- a/admin/code/SecurityAdmin.php +++ b/admin/code/SecurityAdmin.php @@ -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); diff --git a/admin/javascript/LeftAndMain.Content.js b/admin/javascript/LeftAndMain.Content.js index 96ef06ead..2f69663ad 100644 --- a/admin/javascript/LeftAndMain.Content.js +++ b/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, diff --git a/admin/javascript/LeftAndMain.js b/admin/javascript/LeftAndMain.js index 8e36d467b..8fec12c78 100644 --- a/admin/javascript/LeftAndMain.js +++ b/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'; + + // 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 * diff --git a/admin/templates/CMSBreadcrumbs.ss b/admin/templates/CMSBreadcrumbs.ss index 77a1cdd42..9be8125c0 100644 --- a/admin/templates/CMSBreadcrumbs.ss +++ b/admin/templates/CMSBreadcrumbs.ss @@ -1,4 +1,4 @@ -