diff --git a/core/modules/views/js/ajax_view.es6.js b/core/modules/views/js/ajax_view.es6.js index 55c85fda18..424ba14efd 100644 --- a/core/modules/views/js/ajax_view.es6.js +++ b/core/modules/views/js/ajax_view.es6.js @@ -75,6 +75,15 @@ // Check if there are any GET parameters to send to views. let queryString = window.location.search || ''; + // Prepend the query parameters that built the view to the query string. + if (settings.view_query && settings.view_query.length) { + if (queryString.length) { + queryString = `${settings.view_query}&${queryString}`; + } + else { + queryString = settings.view_query; + } + } if (queryString !== '') { // Remove the question mark and Drupal path component if any. queryString = queryString diff --git a/core/modules/views/js/ajax_view.js b/core/modules/views/js/ajax_view.js index 95a803d7fe..c35fcd1ff5 100644 --- a/core/modules/views/js/ajax_view.js +++ b/core/modules/views/js/ajax_view.js @@ -47,6 +47,14 @@ } var queryString = window.location.search || ''; + + if (settings.view_query && settings.view_query.length) { + if (queryString.length) { + queryString = settings.view_query + '&' + queryString; + } else { + queryString = settings.view_query; + } + } if (queryString !== '') { queryString = queryString.slice(1).replace(/q=[^&]+&?|&?render=[^&]+/, ''); if (queryString !== '') { diff --git a/core/modules/views/src/Plugin/views/display/Block.php b/core/modules/views/src/Plugin/views/display/Block.php index bcdab29ecd..8e1cab0d26 100644 --- a/core/modules/views/src/Plugin/views/display/Block.php +++ b/core/modules/views/src/Plugin/views/display/Block.php @@ -3,13 +3,21 @@ namespace Drupal\views\Plugin\views\display; use Drupal\Core\Url; +use Drupal\Component\Plugin\ContextAwarePluginInterface; use Drupal\Component\Plugin\Discovery\CachedDiscoveryInterface; +use Drupal\Component\Utility\Crypt; +use Drupal\Component\Utility\UrlHelper; use Drupal\Core\Block\BlockManagerInterface; use Drupal\Core\DependencyInjection\DeprecatedServicePropertyTrait; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\KeyValueStore\KeyValueFactoryInterface; +use Drupal\Core\Plugin\Context\ContextHandlerInterface; +use Drupal\Core\Plugin\Context\ContextRepositoryInterface; +use Drupal\Core\Site\Settings; use Drupal\views\Plugin\Block\ViewsBlock; use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; /** * The plugin that handles a block. @@ -59,6 +67,34 @@ class Block extends DisplayPluginBase { */ protected $blockManager; + /** + * The key/value manager service. + * + * @var \Drupal\Core\KeyValueStore\KeyValueFactoryInterface + */ + protected $keyValue; + + /** + * The context repository. + * + * @var \Drupal\Core\Plugin\Context\ContextRepositoryInterface + */ + protected $contextRepository; + + /** + * The plugin context handler. + * + * @var \Drupal\Core\Plugin\Context\ContextHandlerInterface + */ + protected $contextHandler; + + /** + * A hashed key of the key/value entry that holds block instance settings. + * + * @var string + */ + protected $blockConfigKey; + /** * Constructs a new Block instance. * @@ -72,12 +108,21 @@ class Block extends DisplayPluginBase { * The entity manager. * @param \Drupal\Core\Block\BlockManagerInterface $block_manager * The block manager. + * @param \Drupal\Core\KeyValueStore\KeyValueFactoryInterface $key_value + * The key/value manager service. + * @param \Drupal\Core\Plugin\Context\ContextRepositoryInterface $context_repository + * The context repository. + * @param \Drupal\Core\Plugin\Context\ContextHandlerInterface $context_handler + * The ContextHandler for applying contexts to conditions properly. */ - public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager, BlockManagerInterface $block_manager) { + public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager, BlockManagerInterface $block_manager, KeyValueFactoryInterface $key_value = NULL, ContextRepositoryInterface $context_repository = NULL, ContextHandlerInterface $context_handler = NULL) { parent::__construct($configuration, $plugin_id, $plugin_definition); $this->entityTypeManager = $entity_type_manager; $this->blockManager = $block_manager; + $this->keyValue = $key_value ?: \Drupal::service('keyvalue'); + $this->contextRepository = $context_repository ?: \Drupal::service('context.repository'); + $this->contextHandler = $context_handler ?: \Drupal::service('context.handler'); } /** @@ -89,7 +134,10 @@ public static function create(ContainerInterface $container, array $configuratio $plugin_id, $plugin_definition, $container->get('entity_type.manager'), - $container->get('plugin.manager.block') + $container->get('plugin.manager.block'), + $container->get('keyvalue'), + $container->get('context.repository'), + $container->get('context.handler') ); } @@ -358,11 +406,179 @@ public function blockSubmit(ViewsBlock $block, $form, FormStateInterface $form_s */ public function preBlockBuild(ViewsBlock $block) { $config = $block->getConfiguration(); + + // If this block is being rebuilt as part of an AJAX call, the AJAX handler + // does not have block instance settings and context information available. + // Because of that, the first time this block is rendered (normally during + // a non-AJAX request, but it could be AJAX as well), we store the block + // instance overrides in the key/value store, to be retrieved when + // subsequent AJAX calls happen. This will be possible as long as all + // following calls pass along the 'block_config_key' query param and it + // matches the key we are generating here for this view+display combination. + // See \Drupal\views\Plugin\views\display\Block::preview(). + // See \Drupal\views\Plugin\views\display\Block::getConfigurationFromHashedKey(). + $key = $this->view->getRequest()->request->get('block_config_key'); + if (empty($key)) { + // Calculate a brand new key. + $this->blockConfigKey = $this->calculateConfigurationHash($config); + $key_value_storage = $this->keyValue->get('views_block_overrides'); + if (!$key_value_storage->has($this->blockConfigKey)) { + $key_value_storage->set($this->blockConfigKey, $config); + } + } + elseif ($this->getConfigurationFromHashedKey($key)) { + // If we can retrieve valid configuration from the received key, persist + // it between requests. + $this->blockConfigKey = $key; + } + else { + // If the received key does not validate, mark the build as failed, which + // will abort the rendering process. + // See \Drupal\views\ViewExecutable::render(). + $this->view->build_info['fail'] = TRUE; + return; + } + if ($config['items_per_page'] !== 'none') { $this->view->setItemsPerPage($config['items_per_page']); } } + /** + * {@inheritdoc} + */ + public function preview() { + // In AJAX requests, we have to figure out the block config ourselves and + // prepare the view using that. + if ($block_instance = $this->getBlockFromAjaxRequest()) { + $this->preBlockBuild($block_instance); + } + return parent::preview(); + } + + /** + * Returns a configured views block plugin instance on an AJAX request. + * + * @return \Drupal\views\Plugin\Block\ViewsBlock|null + * The views block or NULL if this is not an AJAX request or the block + * can't be instantiated. + */ + protected function getBlockFromAjaxRequest() { + if (!$this->view->getRequest()->isXmlHttpRequest()) { + return NULL; + } + + // We expect to receive here the "block_config_key" parameter, which will + // allow us to retrieve the block config from the key/value store. + $query_args = $this->view->getRequest()->request->all(); + // In order to have exposed filters submissions preserve the query args as + // well, they are injected in the 'view_query' param. We merge them all + // together here. + if (!empty($query_args['view_query'])) { + $parsed_view_query = UrlHelper::parse('?' . $query_args['view_query']); + $query_args += $parsed_view_query['query']; + } + if (empty($query_args['block_config_key'])) { + return NULL; + } + + // Retrieve the block configuration values from the key/value store, ensure + // that is been generated for the same view. + $configuration = $this->keyValue->get('views_block_overrides') + ->get($query_args['block_config_key']); + if ($configuration && !empty($configuration['id'])) { + $calculated_hash = $this->calculateConfigurationHash($configuration); + if ($calculated_hash !== $query_args['block_config_key']) { + throw new AccessDeniedHttpException('Invalid block config key.'); + } + } + + // Create a block instance with those settings. + /** @var \Drupal\views\Plugin\Block\ViewsBlock $block_instance */ + try { + $block_instance = $this->blockManager->createInstance($configuration['id'], $configuration); + $plugin_definition = $block_instance->getPluginDefinition(); + if ($plugin_definition['id'] == 'broken') { + return NULL; + } + if ($block_instance instanceof ContextAwarePluginInterface) { + $context_mapping = $block_instance->getContextMapping(); + $context_mapping = array_filter($context_mapping, function ($x) { + return $x !== 'layout_builder.entity'; + }); + $contexts = $this->contextRepository->getRuntimeContexts($context_mapping); + $this->contextHandler->applyContextMapping($block_instance, $contexts); + return $block_instance; + } + } + catch (\Exception $e) { + return NULL; + } + } + + /** + * Retrieve the stored configuration from a given hashed key. + * + * @param string $key + * The hashed key. + * + * @return array|false + * The configuration array if the received key is valid and matches with + * the view/display being executed, FALSE otherwise. + */ + protected function getConfigurationFromHashedKey($key) { + $configuration = $this->keyValue->get('views_block_overrides') + ->get($key); + if ($configuration && !empty($configuration['id'])) { + $calculated_hash = $this->calculateConfigurationHash($configuration); + if ($calculated_hash === $key) { + return $configuration; + } + } + return FALSE; + } + + /** + * Generates a hash for the given configuration and current view/display. + * + * @param array $configuration + * The block configuration. + * + * @return string + * The generated hash. + */ + protected function calculateConfigurationHash(array $configuration) { + $data = serialize($configuration) . $this->view->id() . $this->view->current_display; + return Crypt::hmacBase64($data, Settings::getHashSalt()); + } + + /** + * {@inheritdoc} + */ + public function elementPreRender(array $element) { + $element = parent::elementPreRender($element); + /** @var \Drupal\views\ViewExecutable $view */ + $view = $element['#view']; + + // Add the overrides key as a query param, so subsequent AJAX calls for + // other pages have this info available. + if (!empty($element['#pager']) && !empty($this->blockConfigKey)) { + $element['#pager']['#parameters']['block_config_key'] = $this->blockConfigKey; + } + + // Do the same for exposed filters. However, once here the submission + // happens in a POST request, we inject our overrides key in the view JS + // settings, that will be appended to the real query string later in the + // AJAX behavior. See views_views_pre_render() and Drupal.views.ajaxView + // for more information. + if ($view->ajaxEnabled() && !empty($view->exposed_widgets) && empty($view->is_attachment) && empty($view->live_preview)) { + $view_query = "block_config_key={$this->blockConfigKey}"; + $view->element['#attached']['drupalSettings']['views']['ajaxViews']['views_dom_id:' . $view->dom_id]['view_query'] = $view_query; + } + + return $element; + } + /** * Block views use exposed widgets only if AJAX is set. */ diff --git a/core/modules/views/tests/modules/views_test_config/test_views/views.view.content_block_overrides_ajax_test.yml b/core/modules/views/tests/modules/views_test_config/test_views/views.view.content_block_overrides_ajax_test.yml new file mode 100644 index 0000000000..bbe4e675e1 --- /dev/null +++ b/core/modules/views/tests/modules/views_test_config/test_views/views.view.content_block_overrides_ajax_test.yml @@ -0,0 +1,222 @@ +langcode: en +status: true +dependencies: + module: + - node + - user +id: content_block_overrides_ajax_test +label: 'Content block overrides AJAX test' +module: views +description: '' +tag: '' +base_table: node_field_data +base_field: nid +display: + default: + display_plugin: default + id: default + display_title: Master + position: 0 + display_options: + access: + type: perm + options: + perm: 'access content' + cache: + type: tag + options: { } + query: + type: views_query + options: + disable_sql_rewrite: false + distinct: false + replica: false + query_comment: '' + query_tags: { } + exposed_form: + type: basic + options: + submit_button: Apply + reset_button: false + reset_button_label: Reset + exposed_sorts_label: 'Sort by' + expose_sort_order: true + sort_asc_label: Asc + sort_desc_label: Desc + pager: + type: full + options: + items_per_page: 10 + offset: 0 + id: 0 + total_pages: null + expose: + items_per_page: false + items_per_page_label: 'Items per page' + items_per_page_options: '5, 10, 25, 50' + items_per_page_options_all: false + items_per_page_options_all_label: '- All -' + offset: false + offset_label: Offset + tags: + previous: '‹ Previous' + next: 'Next ›' + first: '« First' + last: 'Last »' + quantity: 9 + style: + type: table + row: + type: fields + fields: + title: + id: title + table: node_field_data + field: title + entity_type: node + entity_field: title + alter: + alter_text: false + make_link: false + absolute: false + trim: false + word_boundary: false + ellipsis: false + strip_tags: false + html: false + hide_empty: false + empty_zero: false + settings: + link_to_entity: true + plugin_id: field + relationship: none + group_type: group + admin_label: '' + label: Title + exclude: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: true + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_alter_empty: true + click_sort_column: value + type: string + group_column: value + group_columns: { } + group_rows: true + delta_limit: 0 + delta_offset: 0 + delta_reversed: false + delta_first_last: false + multi_type: separator + separator: ', ' + field_api_classes: false + filters: + status: + value: '1' + table: node_field_data + field: status + plugin_id: boolean + entity_type: node + entity_field: status + id: status + expose: + operator: '' + group: 1 + title: + id: title + table: node_field_data + field: title + relationship: none + group_type: group + admin_label: '' + operator: contains + value: '' + group: 1 + exposed: true + expose: + operator_id: title_op + label: Title + description: '' + use_operator: false + operator: title_op + identifier: title + required: false + remember: false + multiple: false + remember_roles: + authenticated: authenticated + anonymous: '0' + administrator: '0' + placeholder: '' + is_grouped: false + group_info: + label: '' + description: '' + identifier: '' + optional: true + widget: select + multiple: false + remember: false + default_group: All + default_group_multiple: { } + group_items: { } + entity_type: node + entity_field: title + plugin_id: string + sorts: + created: + id: created + table: node_field_data + field: created + relationship: none + group_type: group + admin_label: '' + order: ASC + exposed: false + expose: + label: '' + granularity: second + entity_type: node + entity_field: created + plugin_id: date + title: 'Content block overrides AJAX test' + header: { } + footer: { } + empty: { } + relationships: { } + arguments: { } + display_extenders: { } + use_ajax: true + cache_metadata: + max-age: -1 + contexts: + - 'languages:language_content' + - 'languages:language_interface' + - url + - url.query_args + - 'user.node_grants:view' + - user.permissions + tags: { } + block_1: + display_plugin: block + id: block_1 + display_title: Block + position: 1 + display_options: + display_extenders: { } + cache_metadata: + max-age: -1 + contexts: + - 'languages:language_content' + - 'languages:language_interface' + - url + - url.query_args + - 'user.node_grants:view' + - user.permissions + tags: { } diff --git a/core/modules/views/tests/src/FunctionalJavascript/BlockOverridesAJAXTest.php b/core/modules/views/tests/src/FunctionalJavascript/BlockOverridesAJAXTest.php new file mode 100644 index 0000000000..94eedd67f2 --- /dev/null +++ b/core/modules/views/tests/src/FunctionalJavascript/BlockOverridesAJAXTest.php @@ -0,0 +1,116 @@ +createContentType(['type' => 'page']); + for ($i = 1; $i <= 21; $i++) { + $this->createNode(['title' => 'Node ' . $i . ' content', 'created' => $i * 1000]); + } + + // Place the block on the page. + $settings = [ + 'region' => 'content', + // The view has 10 items per page configured, and here we will test that + // this override of 5 items per page works in all scenarios. + 'items_per_page' => 5, + ]; + $this->drupalPlaceBlock('views_block:content_block_overrides_ajax_test-block_1', $settings); + + // Create a user privileged enough to view content. + $user = $this->drupalCreateUser([ + 'administer site configuration', + 'access content', + 'access content overview', + 'administer blocks', + 'bypass node access', + ]); + $this->drupalLogin($user); + } + + /** + * Tests if block overrides are persisted through AJAX requests. + */ + public function testBlockOverridesAjax() { + $page = $this->getSession()->getPage(); + $assert_session = $this->assertSession(); + + $this->drupalGet('/node/1'); + + /** @var \Behat\Mink\Element\NodeElement[] $rows */ + $rows = $page->findAll('css', 'tbody tr'); + $this->assertCount(5, $rows); + $this->assertEquals('Node 1 content', $rows[0]->getText()); + + // Navigating back and forth using the pager links preserves the number of + // items per page. + $this->clickLink('Go to page 2'); + $assert_session->assertWaitOnAjaxRequest(); + $rows = $page->findAll('css', 'tbody tr'); + $this->assertCount(5, $rows); + $this->assertEquals('Node 6 content', $rows[0]->getText()); + $this->clickLink('Go to page 1'); + $assert_session->assertWaitOnAjaxRequest(); + $rows = $page->findAll('css', 'tbody tr'); + $this->assertCount(5, $rows); + $this->assertEquals('Node 1 content', $rows[0]->getText()); + + // Using the exposed filter also preserves the number of items per page. + $page->fillField('Title', 'Node'); + $page->pressButton('Apply'); + $assert_session->assertWaitOnAjaxRequest(); + $rows = $page->findAll('css', 'tbody tr'); + $this->assertCount(5, $rows); + $this->assertEquals('Node 1 content', $rows[0]->getText()); + + // After using the exposed filter and using the pager again, it still works. + $this->clickLink('Go to page 2'); + $assert_session->assertWaitOnAjaxRequest(); + $rows = $page->findAll('css', 'tbody tr'); + $this->assertCount(5, $rows); + $this->assertEquals('Node 6 content', $rows[0]->getText()); + } + +} diff --git a/core/modules/views/tests/src/FunctionalJavascript/PaginationAJAXTest.php b/core/modules/views/tests/src/FunctionalJavascript/PaginationAJAXTest.php index 0ddc8ea651..45df7a89cb 100644 --- a/core/modules/views/tests/src/FunctionalJavascript/PaginationAJAXTest.php +++ b/core/modules/views/tests/src/FunctionalJavascript/PaginationAJAXTest.php @@ -99,7 +99,7 @@ public function testBasicPagination() { $this->assertContains('Node 6 content', $rows[0]->getHtml()); $link = $page->findLink('Go to page 3'); // Test that no unwanted parameters are added to the URL. - $this->assertEquals('?status=All&type=All&langcode=All&items_per_page=5&order=changed&sort=asc&title=&page=2', $link->getAttribute('href')); + $this->assertEquals('?view_query=_wrapper_format%3Ddrupal_ajax&status=All&type=All&langcode=All&items_per_page=5&order=changed&sort=asc&wrapper_format=drupal_ajax&title=&page=2', $link->getAttribute('href')); $this->assertNoDuplicateAssetsOnPage(); $this->clickLink('Go to page 3'); diff --git a/core/modules/views/tests/src/Unit/Plugin/views/display/BlockTest.php b/core/modules/views/tests/src/Unit/Plugin/views/display/BlockTest.php index 91688a0368..6800e70571 100644 --- a/core/modules/views/tests/src/Unit/Plugin/views/display/BlockTest.php +++ b/core/modules/views/tests/src/Unit/Plugin/views/display/BlockTest.php @@ -3,6 +3,7 @@ namespace Drupal\Tests\views\Unit\Plugin\views\display; use Drupal\Tests\UnitTestCase; +use Symfony\Component\HttpFoundation\Request; /** * @coversDefaultClass \Drupal\views\Plugin\views\display\Block @@ -37,19 +38,70 @@ class BlockTest extends UnitTestCase { protected function setUp() { parent::setUp(); + $methods = [ + 'id', + 'executeDisplay', + 'setDisplay', + 'setItemsPerPage', + 'getRequest', + ]; $this->executable = $this->getMockBuilder('Drupal\views\ViewExecutable') ->disableOriginalConstructor() - ->setMethods(['executeDisplay', 'setDisplay', 'setItemsPerPage']) + ->setMethods($methods) ->getMock(); $this->executable->expects($this->any()) ->method('setDisplay') ->with('block_1') ->will($this->returnValue(TRUE)); + $this->executable->expects($this->any()) + ->method('id') + ->will($this->returnValue('foo')); + $this->executable->expects($this->any()) + ->method('getRequest') + ->will($this->returnValue(new Request())); - $this->blockDisplay = $this->executable->display_handler = $this->getMockBuilder('Drupal\views\Plugin\views\display\Block') + $key_value = $this->getMockBuilder('Drupal\Core\KeyValueStore\DatabaseStorage') + ->disableOriginalConstructor() + ->setMethods(['has', 'set']) + ->getMock(); + $key_value->expects($this->any()) + ->method('has') + ->will($this->returnValue(TRUE)); + $key_value->expects($this->any()) + ->method('set') + ->will($this->returnValue(NULL)); + $key_value_factory = $this->getMockBuilder('Drupal\Core\KeyValueStore\KeyValueDatabaseFactory') ->disableOriginalConstructor() - ->setMethods(NULL) + ->setMethods(['get']) + ->getMock(); + $key_value_factory->expects($this->any()) + ->method('get') + ->will($this->returnValue($key_value)); + $args = [ + [], + 'views_block', + [], + $this->getMockBuilder('Drupal\Core\Entity\EntityManagerInterface') + ->disableOriginalConstructor() + ->getMock(), + $this->getMockBuilder('Drupal\Core\Block\BlockManagerInterface') + ->disableOriginalConstructor() + ->getMock(), + $key_value_factory, + $this->getMockBuilder('Drupal\Core\Plugin\Context\ContextRepositoryInterface') + ->disableOriginalConstructor() + ->getMock(), + $this->getMockBuilder('Drupal\Core\Plugin\Context\ContextHandlerInterface') + ->disableOriginalConstructor() + ->getMock(), + ]; + $this->blockDisplay = $this->executable->display_handler = $this->getMockBuilder('Drupal\views\Plugin\views\display\Block') + ->setConstructorArgs($args) + ->setMethods(['calculateConfigurationHash']) ->getMock(); + $this->blockDisplay->expects($this->any()) + ->method('calculateConfigurationHash') + ->will($this->returnValue('foobar')); $this->blockDisplay->view = $this->executable; diff --git a/core/modules/views/views.module b/core/modules/views/views.module index 398b670f68..960c14dd67 100644 --- a/core/modules/views/views.module +++ b/core/modules/views/views.module @@ -53,6 +53,7 @@ function views_help($route_name, RouteMatchInterface $route_match) { function views_views_pre_render($view) { // If using AJAX, send identifying data about this view. if ($view->ajaxEnabled() && empty($view->is_attachment) && empty($view->live_preview)) { + $request = \Drupal::request(); $view->element['#attached']['drupalSettings']['views'] = [ 'ajax_path' => Url::fromRoute('views.ajax')->toString(), 'ajaxViews' => [ @@ -61,6 +62,7 @@ function views_views_pre_render($view) { 'view_display_id' => $view->current_display, 'view_args' => Html::escape(implode('/', $view->args)), 'view_path' => Html::escape(\Drupal::service('path.current')->getPath()), + 'view_query' => $request->getQueryString(), 'view_base_path' => $view->getPath(), 'view_dom_id' => $view->dom_id, // To fit multiple views on a page, the programmer may have