diff --git a/core/modules/views/config/schema/views.area.schema.yml b/core/modules/views/config/schema/views.area.schema.yml index d1396c7fd35f..51042e958709 100644 --- a/core/modules/views/config/schema/views.area.schema.yml +++ b/core/modules/views/config/schema/views.area.schema.yml @@ -77,3 +77,14 @@ views.area.http_status_code: status_code: type: integer label: 'HTTP status code' + +views.area.display_link: + type: views_area + label: 'Display link' + mapping: + display_id: + type: string + label: 'The display ID of the view display to link to.' + label: + type: label + label: 'The label of the link.' diff --git a/core/modules/views/css/views.module.css b/core/modules/views/css/views.module.css index da7a3425a442..05aaa8614b6a 100644 --- a/core/modules/views/css/views.module.css +++ b/core/modules/views/css/views.module.css @@ -17,3 +17,7 @@ float: left; width: 100%; } +/* Provide some space between display links. */ +.views-display-link + .views-display-link { + margin-left: 0.5em; +} diff --git a/core/modules/views/src/Plugin/views/area/DisplayLink.php b/core/modules/views/src/Plugin/views/area/DisplayLink.php new file mode 100644 index 000000000000..84112d1e09ba --- /dev/null +++ b/core/modules/views/src/Plugin/views/area/DisplayLink.php @@ -0,0 +1,246 @@ + NULL]; + $options['label'] = ['default' => NULL]; + return $options; + } + + /** + * {@inheritdoc} + */ + public function buildOptionsForm(&$form, FormStateInterface $form_state) { + parent::buildOptionsForm($form, $form_state); + + $allowed_displays = []; + $displays = $this->view->storage->get('display'); + foreach ($displays as $display_id => $display) { + if (!$this->isPathBasedDisplay($display_id)) { + unset($displays[$display_id]); + continue; + } + $allowed_displays[$display_id] = $display['display_title']; + } + + $form['description'] = [ + [ + '#markup' => $this->t('To make sure the results are the same when switching to the other display, it is recommended to make sure the display:'), + ], + [ + '#theme' => 'item_list', + '#items' => [ + $this->t('Has a path.'), + $this->t('Has the same filter criteria.'), + $this->t('Has the same sort criteria.'), + $this->t('Has the same pager settings.'), + $this->t('Has the same contextual filters.'), + ], + ], + ]; + + if (!$allowed_displays) { + $form['empty_message'] = [ + '#markup' => '
' . $this->t('There are no path-based displays available.') . '
', + ]; + } + else { + $form['display_id'] = [ + '#title' => $this->t('Display'), + '#type' => 'select', + '#options' => $allowed_displays, + '#default_value' => $this->options['display_id'], + '#required' => TRUE, + ]; + $form['label'] = [ + '#title' => $this->t('Label'), + '#description' => $this->t('The text of the link.'), + '#type' => 'textfield', + '#default_value' => $this->options['label'], + '#required' => TRUE, + ]; + } + } + + /** + * {@inheritdoc} + */ + public function validate() { + $errors = parent::validate(); + + // Do not add errors for the default display if it is not displayed in the + // UI. + if ($this->displayHandler->isDefaultDisplay() && !\Drupal::config('views.settings')->get('ui.show.master_display')) { + return $errors; + } + + // Ajax errors can cause the plugin to be added without any settings. + $linked_display_id = !empty($this->options['display_id']) ? $this->options['display_id'] : NULL; + if (!$linked_display_id) { + $errors[] = $this->t('%current_display: The link in the %area area has no configured display.', [ + '%current_display' => $this->displayHandler->display['display_title'], + '%area' => $this->areaType, + ]); + return $errors; + } + + // Check if the linked display hasn't been removed. + if (!$this->view->displayHandlers->get($linked_display_id)) { + $errors[] = $this->t('%current_display: The link in the %area area points to the %linked_display display which no longer exists.', [ + '%current_display' => $this->displayHandler->display['display_title'], + '%area' => $this->areaType, + '%linked_display' => $this->options['display_id'], + ]); + return $errors; + } + + // Check if the linked display is a path-based display. + if (!$this->isPathBasedDisplay($linked_display_id)) { + $errors[] = $this->t('%current_display: The link in the %area area points to the %linked_display display which does not have a path.', [ + '%current_display' => $this->displayHandler->display['display_title'], + '%area' => $this->areaType, + '%linked_display' => $this->view->displayHandlers->get($linked_display_id)->display['display_title'], + ]); + return $errors; + } + + // Check if options of the linked display are equal to the options of the + // current display. We "only" show a warning here, because even though we + // recommend keeping the display options equal, we do not want to enforce + // this. + $unequal_options = [ + 'filters' => t('Filter criteria'), + 'sorts' => t('Sort criteria'), + 'pager' => t('Pager'), + 'arguments' => t('Contextual filters'), + ]; + foreach (array_keys($unequal_options) as $option) { + if ($this->hasEqualOptions($linked_display_id, $option)) { + unset($unequal_options[$option]); + } + } + + if ($unequal_options) { + $warning = $this->t('%current_display: The link in the %area area points to the %linked_display display which uses different settings than the %current_display display for: %unequal_options. To make sure users see the exact same result when clicking the link, please check that the settings are the same.', [ + '%current_display' => $this->displayHandler->display['display_title'], + '%area' => $this->areaType, + '%linked_display' => $this->view->displayHandlers->get($linked_display_id)->display['display_title'], + '%unequal_options' => implode(', ', $unequal_options), + ]); + $this->messenger()->addWarning($warning); + } + return $errors; + } + + /** + * {@inheritdoc} + */ + public function render($empty = FALSE) { + if (($empty && empty($this->options['empty'])) || empty($this->options['display_id'])) { + return []; + } + + if (!$this->isPathBasedDisplay($this->options['display_id'])) { + return []; + } + + // Get query parameters from the exposed input and pager. + $query = $this->view->getExposedInput(); + if ($current_page = $this->view->getCurrentPage()) { + $query['page'] = $current_page; + } + + // @todo Remove this parsing once these are removed from the request in + // https://www.drupal.org/node/2504709. + foreach ([ + 'view_name', + 'view_display_id', + 'view_args', + 'view_path', + 'view_dom_id', + 'pager_element', + 'view_base_path', + AjaxResponseSubscriber::AJAX_REQUEST_PARAMETER, + FormBuilderInterface::AJAX_FORM_REQUEST, + MainContentViewSubscriber::WRAPPER_FORMAT, + ] as $key) { + unset($query[$key]); + } + + // Set default classes. + $classes = [ + 'views-display-link', + 'views-display-link-' . $this->options['display_id'], + ]; + if ($this->options['display_id'] === $this->view->current_display) { + $classes[] = 'is-active'; + } + + // By default, this element sets #theme so that the 'link' theme hook is + // used for rendering, with the 'views_display_link' suffix so that themes + // can override this specifically without overriding all link theming. + return [ + '#type' => 'link', + '#theme' => 'link__views_display_link', + '#title' => $this->options['label'], + '#url' => $this->view->getUrl($this->view->args, $this->options['display_id'])->setOptions(['query' => $query]), + '#options' => [ + 'view' => $this->view, + 'target_display_id' => $this->options['display_id'], + 'attributes' => ['class' => $classes], + ], + ]; + } + + /** + * Check if a views display is a path-based display. + * + * @param string $display_id + * The display ID to check. + * + * @return bool + * Whether the display ID is an allowed display or not. + */ + protected function isPathBasedDisplay($display_id) { + $loaded_display = $this->view->displayHandlers->get($display_id); + return $loaded_display instanceof PathPluginBase; + } + + /** + * Check if the options of a views display are equal to the current display. + * + * @param string $display_id + * The display ID to check. + * @param string $option + * The option to check. + * + * @return bool + * Whether the option of the view display are equal to the current display + * or not. + */ + protected function hasEqualOptions($display_id, $option) { + $loaded_display = $this->view->displayHandlers->get($display_id); + return $loaded_display->getOption($option) === $this->displayHandler->getOption($option); + } + +} diff --git a/core/modules/views/tests/src/Kernel/Handler/AreaDisplayLinkTest.php b/core/modules/views/tests/src/Kernel/Handler/AreaDisplayLinkTest.php new file mode 100644 index 000000000000..ae83ad6bf4b2 --- /dev/null +++ b/core/modules/views/tests/src/Kernel/Handler/AreaDisplayLinkTest.php @@ -0,0 +1,418 @@ +installConfig(['system', 'filter']); + $this->installEntitySchema('user'); + + $view = Views::getView('test_view'); + + // Add two page displays and a block display. + $page_1 = $view->newDisplay('page', 'Page 1', 'page_1'); + $page_1->setOption('path', 'page_1'); + $page_2 = $view->newDisplay('page', 'Page 2', 'page_2'); + $page_2->setOption('path', 'page_2'); + $view->newDisplay('block', 'Block 1', 'block_1'); + + // Add default filter criteria, sort criteria, pager settings and contextual + // filters. + $default = $view->displayHandlers->get('default'); + $default->setOption('filters', [ + 'status' => [ + 'id' => 'status', + 'table' => 'views_test_data', + 'field' => 'status', + 'relationship' => 'none', + 'operator' => '=', + 'value' => 1, + ], + ]); + $default->setOption('sorts', [ + 'name' => [ + 'id' => 'name', + 'table' => 'views_test_data', + 'field' => 'name', + 'relationship' => 'none', + 'order' => 'ASC', + ], + ]); + $default->setOption('pager', [ + 'type' => 'mini', + 'options' => ['items_per_page' => 10], + ]); + $default->setOption('arguments', [ + 'uid' => [ + 'id' => 'uid', + 'table' => 'views_test_data', + 'field' => 'uid', + 'relationship' => 'none', + ], + ]); + + // Add display links to both page displays. + $display_links = [ + 'display_link_1' => [ + 'id' => 'display_link_1', + 'table' => 'views', + 'field' => 'display_link', + 'display_id' => 'page_1', + 'label' => 'Page 1', + 'plugin_id' => 'display_link', + ], + 'display_link_2' => [ + 'id' => 'display_link_2', + 'table' => 'views', + 'field' => 'display_link', + 'display_id' => 'page_2', + 'label' => 'Page 2', + 'plugin_id' => 'display_link', + ], + ]; + $default->setOption('header', $display_links); + $view->save(); + } + + /** + * Tests the views area display_link handler. + */ + public function testAreaDisplayLink() { + $view = Views::getView('test_view'); + + // Assert only path-based displays are available in the display link + // settings form. + $view->setDisplay('page_1'); + $this->assertFormOptions($view, 'display_link_1'); + $this->assertFormOptions($view, 'display_link_2'); + $view->setDisplay('page_2'); + $this->assertFormOptions($view, 'display_link_1'); + $this->assertFormOptions($view, 'display_link_2'); + $view->setDisplay('block_1'); + $this->assertFormOptions($view, 'display_link_1'); + $this->assertFormOptions($view, 'display_link_2'); + + // Assert the links are rendered correctly for all displays. + $this->assertRenderedDisplayLinks($view, 'page_1'); + $this->assertRenderedDisplayLinks($view, 'page_2'); + $this->assertRenderedDisplayLinks($view, 'block_1'); + + // Assert some special request parameters are filtered from the display + // links. + $request_stack = new RequestStack(); + $request_stack->push(Request::create('page_1', 'GET', [ + 'name' => 'John', + 'sort_by' => 'created', + 'sort_order' => 'ASC', + 'page' => 1, + 'keep' => 'keep', + 'keep_another' => 1, + 'view_name' => 1, + 'view_display_id' => 1, + 'view_args' => 1, + 'view_path' => 1, + 'view_dom_id' => 1, + 'pager_element' => 1, + 'view_base_path' => 1, + AjaxResponseSubscriber::AJAX_REQUEST_PARAMETER => 1, + FormBuilderInterface::AJAX_FORM_REQUEST => 1, + MainContentViewSubscriber::WRAPPER_FORMAT => 1, + ])); + $this->container->set('request_stack', $request_stack); + $view->destroy(); + $view->setDisplay('page_1'); + $view->setCurrentPage(2); + $this->executeView($view, [1]); + $this->assertSame('Page 1', $this->renderDisplayLink($view, 'display_link_1')); + $this->assertSame('Page 2', $this->renderDisplayLink($view, 'display_link_2')); + + // Assert the validation adds warning messages when a display link is added + // to a display with different filter criteria, sort criteria, pager + // settings or contextual filters. Since all options are added to the + // default display there currently should be no warning messages. + $this->assertNoWarningMessages($view); + + // Assert the message are shown when changing the filter criteria of page_1. + $filters = [ + 'name' => [ + 'id' => 'name', + 'table' => 'views_test_data', + 'field' => 'name', + 'relationship' => 'none', + 'operator' => '=', + 'value' => '', + 'exposed' => TRUE, + 'expose' => [ + 'identifier' => 'name', + 'label' => 'Name', + ], + ], + ]; + $view->displayHandlers->get('page_1')->overrideOption('filters', $filters); + $this->assertWarningMessages($view, ['filters']); + + // Assert no messages are added after the default display is changed with + // the same options. + $view->displayHandlers->get('default')->overrideOption('filters', $filters); + $this->assertNoWarningMessages($view); + + // Assert the message are shown when changing the sort criteria of page_1. + $sorts = [ + 'created' => [ + 'id' => 'created', + 'table' => 'views_test_data', + 'field' => 'created', + 'relationship' => 'none', + 'order' => 'DESC', + 'exposed' => TRUE, + ], + ]; + $view->displayHandlers->get('page_1')->overrideOption('sorts', $sorts); + $this->assertWarningMessages($view, ['sorts']); + + // Assert no messages are added after the default display is changed with + // the same options. + $view->displayHandlers->get('default')->overrideOption('sorts', $sorts); + $this->assertNoWarningMessages($view); + + // Assert the message are shown when changing the sort criteria of page_1. + $pager = [ + 'type' => 'full', + 'options' => ['items_per_page' => 10], + ]; + $view->displayHandlers->get('page_1')->overrideOption('pager', $pager); + $this->assertWarningMessages($view, ['pager']); + + // Assert no messages are added after the default display is changed with + // the same options. + $view->displayHandlers->get('default')->overrideOption('pager', $pager); + $this->assertNoWarningMessages($view); + + // Assert the message are shown when changing the contextual filters of + // page_1. + $arguments = [ + 'id' => [ + 'id' => 'id', + 'table' => 'views_test_data', + 'field' => 'id', + 'relationship' => 'none', + ], + ]; + $view->displayHandlers->get('page_1')->overrideOption('arguments', $arguments); + $this->assertWarningMessages($view, ['arguments']); + + // Assert no messages are added after the default display is changed with + // the same options. + $view->displayHandlers->get('default')->overrideOption('arguments', $arguments); + $this->assertNoWarningMessages($view); + + // Assert an error is shown when the display ID is not set. + $display_link = [ + 'display_link_3' => [ + 'id' => 'display_link_3', + 'table' => 'views', + 'field' => 'display_link', + 'display_id' => '', + 'label' => 'Empty', + 'plugin_id' => 'display_link', + ], + ]; + $view->displayHandlers->get('page_1')->overrideOption('header', $display_link); + $view->destroy(); + $view->setDisplay('page_1'); + $errors = $view->validate(); + $this->assertCount(1, $errors); + $this->assertCount(1, $errors['page_1']); + $this->assertSame('Page 1: The link in the header area has no configured display.', $errors['page_1'][0]->__toString()); + + // Assert an error is shown when linking to a display ID that doesn't exist. + $display_link['display_link_3']['display_id'] = 'non-existent'; + $view->displayHandlers->get('page_1')->overrideOption('header', $display_link); + $view->destroy(); + $view->setDisplay('page_1'); + $errors = $view->validate(); + $this->assertCount(1, $errors); + $this->assertCount(1, $errors['page_1']); + $this->assertSame('Page 1: The link in the header area points to the non-existent display which no longer exists.', $errors['page_1'][0]->__toString()); + + // Assert an error is shown when linking to a display without a path. + $display_link['display_link_3']['display_id'] = 'block_1'; + $view->displayHandlers->get('page_1')->overrideOption('header', $display_link); + $view->destroy(); + $view->setDisplay('page_1'); + $errors = $view->validate(); + $this->assertCount(1, $errors); + $this->assertCount(1, $errors['page_1']); + $this->assertSame('Page 1: The link in the header area points to the Block 1 display which does not have a path.', $errors['page_1'][0]->__toString()); + } + + /** + * Assert the display options contains only path based displays. + * + * @param \Drupal\views\ViewExecutable $view + * The view to check. + * @param string $display_link_id + * The display link ID to check the options for. + */ + protected function assertFormOptions(ViewExecutable $view, $display_link_id) { + $form = []; + $form_state = new FormState(); + $view->display_handler->getHandler('header', $display_link_id)->buildOptionsForm($form, $form_state); + $this->assertTrue(isset($form['display_id']['#options']['page_1'])); + $this->assertTrue(isset($form['display_id']['#options']['page_2'])); + $this->assertFalse(isset($form['display_id']['#options']['block_1'])); + } + + /** + * Assert the display links are correctly rendered for a display. + * + * @param \Drupal\views\ViewExecutable $view + * The view to check. + * @param string $display_id + * The display ID to check the links for. + */ + protected function assertRenderedDisplayLinks(ViewExecutable $view, $display_id) { + $page_1_active = $display_id === 'page_1' ? ' is-active' : ''; + $page_2_active = $display_id === 'page_2' ? ' is-active' : ''; + + $view->destroy(); + $view->setDisplay($display_id); + $this->executeView($view); + $this->assertSame('Page 1', $this->renderDisplayLink($view, 'display_link_1')); + $this->assertSame('Page 2', $this->renderDisplayLink($view, 'display_link_2')); + + // Assert the exposed filters, pager and contextual links are passed + // correctly in the links. + $view->destroy(); + $view->setDisplay($display_id); + $view->setExposedInput([ + 'name' => 'John', + 'sort_by' => 'created', + 'sort_order' => 'ASC', + ]); + $view->setCurrentPage(2); + $this->executeView($view, [1]); + $this->assertSame('Page 1', $this->renderDisplayLink($view, 'display_link_1')); + $this->assertSame('Page 2', $this->renderDisplayLink($view, 'display_link_2')); + } + + /** + * Render a display link. + * + * @param \Drupal\views\ViewExecutable $view + * The view to render the link for. + * @param string $display_link_id + * The display link ID to render. + * + * @return string + * The rendered display link. + */ + protected function renderDisplayLink(ViewExecutable $view, $display_link_id) { + /** @var \Drupal\Core\Render\RendererInterface $renderer */ + $renderer = $this->container->get('renderer'); + $display_link = $view->display_handler->getHandler('header', $display_link_id)->render(); + return $renderer->renderRoot($display_link)->__toString(); + } + + /** + * Assert no warning messages are shown when all display are equal. + * + * @param \Drupal\views\ViewExecutable $view + * The view to check. + */ + protected function assertNoWarningMessages(ViewExecutable $view) { + $messenger = $this->container->get('messenger'); + + $view->validate(); + $this->assertCount(0, $messenger->messagesByType(MessengerInterface::TYPE_WARNING)); + } + + /** + * Assert the warning messages are shown after changing the page_1 display. + * + * @param \Drupal\views\ViewExecutable $view + * The view to check. + * @param array $unequal_options + * An array of options that should be unequal. + * + * @throws \Exception + */ + protected function assertWarningMessages(ViewExecutable $view, array $unequal_options) { + $messenger = $this->container->get('messenger'); + + // Create a list of options to check. + // @see \Drupal\views\Plugin\views\area\DisplayLink::validate() + $options = [ + 'filters' => 'Filter criteria', + 'sorts' => 'Sort criteria', + 'pager' => 'Pager', + 'arguments' => 'Contextual filters', + ]; + + // Create a list of options to check. + // @see \Drupal\views\Plugin\views\area\DisplayLink::validate() + $unequal_options_text = implode(', ', array_intersect_key($options, array_flip($unequal_options))); + + $errors = $view->validate(); + $messages = $messenger->messagesByType(MessengerInterface::TYPE_WARNING); + + $this->assertCount(0, $errors); + $this->assertCount(3, $messages); + $this->assertSame('Block 1: The link in the header area points to the Page 1 display which uses different settings than the Block 1 display for: ' . $unequal_options_text . '. To make sure users see the exact same result when clicking the link, please check that the settings are the same.', $messages[0]->__toString()); + $this->assertSame('Page 1: The link in the header area points to the Page 2 display which uses different settings than the Page 1 display for: ' . $unequal_options_text . '. To make sure users see the exact same result when clicking the link, please check that the settings are the same.', $messages[1]->__toString()); + $this->assertSame('Page 2: The link in the header area points to the Page 1 display which uses different settings than the Page 2 display for: ' . $unequal_options_text . '. To make sure users see the exact same result when clicking the link, please check that the settings are the same.', $messages[2]->__toString()); + + $messenger->deleteAll(); + + // If the master display is shown in the UI, warnings should be shown for + // this display as well. + $this->config('views.settings')->set('ui.show.master_display', TRUE)->save(); + + $errors = $view->validate(); + $messages = $messenger->messagesByType(MessengerInterface::TYPE_WARNING); + + $this->assertCount(0, $errors); + $this->assertCount(4, $messages); + $this->assertSame('Master: The link in the header area points to the Page 1 display which uses different settings than the Master display for: ' . $unequal_options_text . '. To make sure users see the exact same result when clicking the link, please check that the settings are the same.', $messages[0]->__toString()); + $this->assertSame('Block 1: The link in the header area points to the Page 1 display which uses different settings than the Block 1 display for: ' . $unequal_options_text . '. To make sure users see the exact same result when clicking the link, please check that the settings are the same.', $messages[1]->__toString()); + $this->assertSame('Page 1: The link in the header area points to the Page 2 display which uses different settings than the Page 1 display for: ' . $unequal_options_text . '. To make sure users see the exact same result when clicking the link, please check that the settings are the same.', $messages[2]->__toString()); + $this->assertSame('Page 2: The link in the header area points to the Page 1 display which uses different settings than the Page 2 display for: ' . $unequal_options_text . '. To make sure users see the exact same result when clicking the link, please check that the settings are the same.', $messages[3]->__toString()); + + $messenger->deleteAll(); + $this->config('views.settings')->set('ui.show.master_display', FALSE)->save(); + } + +} diff --git a/core/modules/views/views.views.inc b/core/modules/views/views.views.inc index c1bc624b5617..ab67dfdb9026 100644 --- a/core/modules/views/views.views.inc +++ b/core/modules/views/views.views.inc @@ -129,6 +129,14 @@ function views_views_data() { ], ]; + $data['views']['display_link'] = [ + 'title' => t('Link to display'), + 'help' => t('Displays a link to a path-based display of this view while keeping the filter criteria, sort criteria, pager settings and contextual filters.'), + 'area' => [ + 'id' => 'display_link', + ], + ]; + // Registers an entity area handler per entity type. foreach (\Drupal::entityManager()->getDefinitions() as $entity_type_id => $entity_type) { // Excludes entity types, which cannot be rendered. diff --git a/core/themes/stable/css/views/views.module.css b/core/themes/stable/css/views/views.module.css index da7a3425a442..05aaa8614b6a 100644 --- a/core/themes/stable/css/views/views.module.css +++ b/core/themes/stable/css/views/views.module.css @@ -17,3 +17,7 @@ float: left; width: 100%; } +/* Provide some space between display links. */ +.views-display-link + .views-display-link { + margin-left: 0.5em; +}