diff --git a/core/lib/Drupal/Core/Render/Element/Checkboxes.php b/core/lib/Drupal/Core/Render/Element/Checkboxes.php index 8487a09..fe4a43b 100644 --- a/core/lib/Drupal/Core/Render/Element/Checkboxes.php +++ b/core/lib/Drupal/Core/Render/Element/Checkboxes.php @@ -122,4 +122,40 @@ public static function valueCallback(&$element, $input, FormStateInterface $form } } + /** + * Determines which checkboxes were checked when a form is submitted. + * + * @param array $input + * An array returned by the FormAPI for a set of checkboxes. + * + * @return array + * An array of keys that were checked. + */ + public static function getCheckedCheckboxes(array $input) { + // Browsers do not include unchecked options in a form submission. The + // FormAPI tries to normalize this to keep checkboxes consistent with other + // form elements. Checkboxes show up as an array in the form of option_id => + // option_id|0, where integer 0 is an unchecked option. + // + // @see \Drupal\Core\Render\Element\Checkboxes::valueCallback() + // @see https://www.w3.org/TR/html401/interact/forms.html#checkbox + $checked = array_filter($input, function($value) { + return $value !== 0; + }); + return array_keys($checked); + } + + /** + * Determines if all checkboxes in a set are unchecked. + * + * @param array $input + * An array returned by the FormAPI for a set of checkboxes. + * + * @return bool + * TRUE if all options are unchecked. FALSE otherwise. + */ + public static function detectEmptyCheckboxes(array $input) { + return empty(static::getCheckedCheckboxes($input)); + } + } diff --git a/core/modules/views/src/Form/ViewsExposedForm.php b/core/modules/views/src/Form/ViewsExposedForm.php index bea6152..bacd554 100644 --- a/core/modules/views/src/Form/ViewsExposedForm.php +++ b/core/modules/views/src/Form/ViewsExposedForm.php @@ -5,6 +5,7 @@ use Drupal\Component\Utility\Html; use Drupal\Core\Form\FormBase; use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Render\Element\Checkboxes; use Drupal\Core\Url; use Drupal\views\ExposedFormCache; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -146,26 +147,46 @@ public function validateForm(array &$form, FormStateInterface $form_state) { * {@inheritdoc} */ public function submitForm(array &$form, FormStateInterface $form_state) { + // Form input keys that will not be included in $view->exposed_raw_data. + $exclude = array('submit', 'form_build_id', 'form_id', 'form_token', 'exposed_form_plugin', 'reset'); + $values = $form_state->getValues(); foreach (array('field', 'filter') as $type) { /** @var \Drupal\views\Plugin\views\ViewsHandlerInterface[] $handlers */ $handlers = &$form_state->get('view')->$type; foreach ($handlers as $key => $info) { - $handlers[$key]->submitExposed($form, $form_state); + if ($handlers[$key]->acceptExposedInput($values)) { + $handlers[$key]->submitExposed($form, $form_state); + } + else { + // The input from the form did not validate, exclude it from the + // stored raw data. + $exclude[] = $key; + } } } $view = $form_state->get('view'); - $view->exposed_data = $form_state->getValues(); + $view->exposed_data = $values; $view->exposed_raw_input = []; $exclude = array('submit', 'form_build_id', 'form_id', 'form_token', 'exposed_form_plugin', 'reset'); - /** @var \Drupal\views\Plugin\views\exposed_form\ExposedFormPluginInterface $exposed_form_plugin */ + /** @var \Drupal\views\Plugin\views\exposed_form\ExposedFormPluginBase $exposed_form_plugin */ $exposed_form_plugin = $view->display_handler->getPlugin('exposed_form'); $exposed_form_plugin->exposedFormSubmit($form, $form_state, $exclude); - - foreach ($form_state->getValues() as $key => $value) { - if (!in_array($key, $exclude)) { - $view->exposed_raw_input[$key] = $value; + foreach ($values as $key => $value) { + if (!empty($key) && !in_array($key, $exclude)) { + if (is_array($value)) { + // Handle checkboxes, we only want to include the checked options. + // @todo: revisit the need for this when + // https://www.drupal.org/node/342316 is resolved. + $checked = Checkboxes::getCheckedCheckboxes($value); + foreach ($checked as $option_id) { + $view->exposed_raw_input[$option_id] = $value[$option_id]; + } + } + else { + $view->exposed_raw_input[$key] = $value; + } } } } diff --git a/core/modules/views/src/Plugin/views/filter/FilterPluginBase.php b/core/modules/views/src/Plugin/views/filter/FilterPluginBase.php index c458e63..34b8cb4 100644 --- a/core/modules/views/src/Plugin/views/filter/FilterPluginBase.php +++ b/core/modules/views/src/Plugin/views/filter/FilterPluginBase.php @@ -7,6 +7,7 @@ use Drupal\Core\Form\FormHelper; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Render\Element; +use Drupal\Core\Render\Element\Checkboxes; use Drupal\user\RoleInterface; use Drupal\views\Plugin\views\HandlerBase; use Drupal\Component\Utility\Html; @@ -1332,15 +1333,20 @@ public function storeGroupInput($input, $status) { } /** - * Check to see if input from the exposed filters should change - * the behavior of this filter. + * Determines if the input from a filter should change the generated query. + * + * @param array $input + * The exposed data for this view. + * + * @return bool + * TRUE if the input for this filter should be included in the view query. + * FALSE otherwise. */ public function acceptExposedInput($input) { if (empty($this->options['exposed'])) { return TRUE; } - if (!empty($this->options['expose']['use_operator']) && !empty($this->options['expose']['operator_id']) && isset($input[$this->options['expose']['operator_id']])) { $this->operator = $input[$this->options['expose']['operator_id']]; } @@ -1358,6 +1364,12 @@ public function acceptExposedInput($input) { if ($value == 'All' || $value === array()) { return FALSE; } + + // If checkboxes are used to render this filter, do not include the + // filter if no options are checked. + if (is_array($value) && Checkboxes::detectEmptyCheckboxes($value)) { + return FALSE; + } } if (!empty($this->alwaysMultiple) && $value === '') { diff --git a/core/modules/views/src/Plugin/views/filter/InOperator.php b/core/modules/views/src/Plugin/views/filter/InOperator.php index f3f1ca4..593e97b 100644 --- a/core/modules/views/src/Plugin/views/filter/InOperator.php +++ b/core/modules/views/src/Plugin/views/filter/InOperator.php @@ -282,15 +282,17 @@ public function reduceValueOptions($input = NULL) { return $options; } + /** + * {@inheritdoc} + */ public function acceptExposedInput($input) { - // A very special override because the All state for this type of - // filter could have a default: if (empty($this->options['exposed'])) { return TRUE; } - // If this is non-multiple and non-required, then this filter will - // participate, but using the default settings, *if* 'limit is true. + // The "All" state for this type of filter could have a default value. If + // this is a non-multiple and non-required option, then this filter will + // participate by using the default settings *if* 'limit' is true. if (empty($this->options['expose']['multiple']) && empty($this->options['expose']['required']) && !empty($this->options['expose']['limit'])) { $identifier = $this->options['expose']['identifier']; if ($input[$identifier] == 'All') { diff --git a/core/modules/views/src/Tests/Plugin/ExposedFormTest.php b/core/modules/views/src/Tests/Plugin/ExposedFormTest.php index c5cdd08..0204468 100644 --- a/core/modules/views/src/Tests/Plugin/ExposedFormTest.php +++ b/core/modules/views/src/Tests/Plugin/ExposedFormTest.php @@ -207,6 +207,48 @@ public function testExposedFormRender() { } /** + * Tests overriding the default render option with checkboxes. + */ + public function testExposedFormRenderCheckboxes() { + // Make sure we have at least two options for node type. + $this->drupalCreateContentType(['type' => 'page']); + $this->drupalCreateNode(['type' => 'page']); + + // Use a test theme to convert multi-select elements into checkboxes. + \Drupal::service('theme_handler')->install(array('views_test_checkboxes_theme')); + $this->config('system.theme') + ->set('default', 'views_test_checkboxes_theme') + ->save(); + + // Set the "type" filter to multi-select. + $view = Views::getView('test_exposed_form_buttons'); + $filter = $view->getHandler('page_1', 'filter', 'type'); + $filter['expose']['multiple'] = TRUE; + $view->setHandler('page_1', 'filter', 'type', $filter); + + // Only display 5 items per page so we can test that paging works. + $display = &$view->storage->getDisplay('default'); + $display['display_options']['pager']['options']['items_per_page'] = 5; + + $view->save(); + $this->drupalGet('test_exposed_form_buttons'); + + $actual = $this->xpath('//form//input[@type="checkbox" and @name="type[article]"]'); + $this->assertEqual(count($actual), 1, 'Article option renders as a checkbox.'); + $actual = $this->xpath('//form//input[@type="checkbox" and @name="type[page]"]'); + $this->assertEqual(count($actual), 1, 'Page option renders as a checkbox'); + + // Ensure that all results are displayed. + $rows = $this->xpath("//div[contains(@class, 'views-row')]"); + $this->assertEqual(count($rows), 5, '5 rows are displayed by default on the first page when no options are checked.'); + + $this->clickLink('Page 2'); + $rows = $this->xpath("//div[contains(@class, 'views-row')]"); + $this->assertEqual(count($rows), 1, '1 row is displayed by default on the second page when no options are checked.'); + $this->assertNoText('An illegal choice has been detected. Please contact the site administrator.'); + } + + /** * Tests the exposed block functionality. */ public function testExposedBlock() { diff --git a/core/modules/views/tests/themes/views_test_checkboxes_theme/views_test_checkboxes_theme.theme b/core/modules/views/tests/themes/views_test_checkboxes_theme/views_test_checkboxes_theme.theme index 4a038ae..cab52ac 100644 --- a/core/modules/views/tests/themes/views_test_checkboxes_theme/views_test_checkboxes_theme.theme +++ b/core/modules/views/tests/themes/views_test_checkboxes_theme/views_test_checkboxes_theme.theme @@ -1,7 +1,15 @@