diff --git a/core/modules/taxonomy/tests/src/Functional/Views/TaxonomyRelationshipTest.php b/core/modules/taxonomy/tests/src/Functional/Views/TaxonomyRelationshipTest.php index d034e57cd2..39939adb33 100644 --- a/core/modules/taxonomy/tests/src/Functional/Views/TaxonomyRelationshipTest.php +++ b/core/modules/taxonomy/tests/src/Functional/Views/TaxonomyRelationshipTest.php @@ -82,7 +82,7 @@ public function testTaxonomyRelationships() { $this->assertEquals('Parent', $views_data['parent_target_id']['relationship']['label']); $this->assertEquals('standard', $views_data['parent_target_id']['relationship']['id']); // Check the parent filter and argument data. - $this->assertEquals('numeric', $views_data['parent_target_id']['filter']['id']); + $this->assertEquals('entity_reference', $views_data['parent_target_id']['filter']['id']); $this->assertEquals('taxonomy', $views_data['parent_target_id']['argument']['id']); // Check an actual test view. diff --git a/core/modules/views/config/schema/views.filter.schema.yml b/core/modules/views/config/schema/views.filter.schema.yml index 1eb09007ae..2623c56faa 100644 --- a/core/modules/views/config/schema/views.filter.schema.yml +++ b/core/modules/views/config/schema/views.filter.schema.yml @@ -128,6 +128,20 @@ views.filter.many_to_one: type: boolean label: 'Reduce duplicate' +views.filter.entity_reference: + type: views.filter.many_to_one + label: 'Entity reference' + mapping: + handler: + type: string + label: 'Selection handler' + widget: + type: string + label: 'Selection type' + handler_settings: + type: entity_reference_selection.[%parent.handler] + label: 'Selection handler settings' + views.filter.standard: type: views_filter label: 'Standard' diff --git a/core/modules/views/src/EntityViewsData.php b/core/modules/views/src/EntityViewsData.php index 87bfa116b3..2958c7a075 100644 --- a/core/modules/views/src/EntityViewsData.php +++ b/core/modules/views/src/EntityViewsData.php @@ -607,7 +607,7 @@ protected function processViewsDataForEntityReference($table, FieldDefinitionInt ]; $views_field['field']['id'] = 'field'; $views_field['argument']['id'] = 'numeric'; - $views_field['filter']['id'] = 'numeric'; + $views_field['filter']['id'] = 'entity_reference'; $views_field['sort']['id'] = 'standard'; } else { diff --git a/core/modules/views/src/Plugin/views/filter/EntityReference.php b/core/modules/views/src/Plugin/views/filter/EntityReference.php new file mode 100644 index 0000000000..0b5aa0393f --- /dev/null +++ b/core/modules/views/src/Plugin/views/filter/EntityReference.php @@ -0,0 +1,740 @@ +selectionPluginManager = $selection_plugin_manager; + $this->entityTypeManager = $entity_type_manager; + $this->messenger = $messenger; + + // @todo Unify 'entity field'/'field_name' instead of converting back and + // forth. https://www.drupal.org/node/2410779 + if (isset($this->definition['entity field'])) { + $this->definition['field_name'] = $this->definition['entity field']; + } + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): EntityReference { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('plugin.manager.entity_reference_selection'), + $container->get('entity_type.manager'), + $container->get('messenger') + ); + } + + /** + * {@inheritdoc} + */ + public function init(ViewExecutable $view, DisplayPluginBase $display, array &$options = NULL): void { + parent::init($view, $display, $options); + + if (empty($this->definition['field_name'])) { + $this->definition['field_name'] = $options['field']; + } + + $this->definition['options callback'] = [$this, 'getValueOptionsCallback']; + $this->definition['options arguments'] = [$this->getSelectionHandler()]; + } + + /** + * Gets the entity reference selection handler. + * + * @return \Drupal\Core\Entity\EntityReferenceSelection\SelectionInterface + * The selection handler plugin instance. + */ + protected function getSelectionHandler(): SelectionInterface { + $handler_settings = $this->options['handler_settings'] + [ + 'target_type' => $this->getReferencedEntityType()->id(), + 'handler' => $this->options['handler'], + ]; + return $this->selectionPluginManager->getInstance($handler_settings); + } + + /** + * {@inheritdoc} + */ + protected function defineOptions(): array { + $options = parent::defineOptions(); + + $options['handler'] = ['default' => 'default:' . $this->getReferencedEntityType()->id()]; + $options['handler_settings'] = ['default' => []]; + $options['widget'] = ['default' => self::WIDGET_AUTOCOMPLETE]; + + return $options; + } + + /** + * {@inheritdoc} + */ + public function hasExtraOptions(): bool { + return TRUE; + } + + /** + * {@inheritdoc} + */ + public function buildExtraOptionsForm(&$form, FormStateInterface $form_state) { + $entity_type = $this->getReferencedEntityType(); + + // Get all selection plugins for this entity type. + $selection_plugins = $this->selectionPluginManager->getSelectionGroups($entity_type->id()); + $handlers_options = []; + foreach (array_keys($selection_plugins) as $selection_group_id) { + // We only display base plugins (e.g. 'default', 'views', ...). + if (array_key_exists($selection_group_id, $selection_plugins[$selection_group_id])) { + $handlers_options[$selection_group_id] = $selection_plugins[$selection_group_id][$selection_group_id]['label']; + } + elseif (array_key_exists($selection_group_id . ':' . $entity_type->id(), $selection_plugins[$selection_group_id])) { + $selection_group_plugin = $selection_group_id . ':' . $entity_type->id(); + $handlers_options[$selection_group_plugin] = $selection_plugins[$selection_group_id][$selection_group_plugin]['base_plugin_label']; + } + } + + $form['#process'] = [[get_class($this), 'extraOptionsAjaxProcess']]; + + // @todo: We would actually prefer organizing the form elements according + // to the required structure of the value tree, and to rearrange the visual + // grouping using the #group key, in order to avoid messing with #parents. + // Currently, this however isn't possible. Revisit once Core issue + // https://www.drupal.org/project/drupal/issues/2190333 landed. + $form['reference'] = [ + '#type' => 'details', + '#title' => $this->t('Reference type'), + '#open' => TRUE, + '#parents' => ['options'], + ]; + + $form['reference']['handler'] = [ + '#type' => 'select', + '#title' => $this->t('Reference method'), + '#options' => $handlers_options, + '#default_value' => $this->options['handler'], + '#required' => TRUE, + '#ajax' => TRUE, + '#limit_validation_errors' => [['options', 'handler']], + ]; + + $form['reference']['handler_submit'] = [ + '#type' => 'submit', + '#value' => $this->t('Change handler'), + '#limit_validation_errors' => [], + '#attributes' => [ + 'class' => ['js-hide'], + ], + '#submit' => [[get_class($this), 'settingsAjaxSubmit']], + ]; + + $form['reference']['handler_settings'] = [ + '#type' => 'container', + '#attributes' => ['class' => ['entity_reference-settings']], + '#process' => [[get_class($this), 'fixSubmitParents']], + ]; + + $selection_handler = $this->getSelectionHandler(); + $subform_state = SubformState::createForSubform($form['reference'], $form, $form_state); + $form['reference']['handler_settings'] += $selection_handler->buildConfigurationForm([], $subform_state); + + // Remove AJAX to load handler target bundles immediately. + $form['reference']['handler_settings']['target_bundles']['#ajax'] = FALSE; + + // Remove unnecessary handler settings from the filter config form. + $form['reference']['handler_settings']['target_bundles_update']['#access'] = FALSE; + $form['reference']['handler_settings']['auto_create']['#access'] = FALSE; + $form['reference']['handler_settings']['auto_create_bundle']['#access'] = FALSE; + + $form['widget'] = [ + '#type' => 'radios', + '#title' => $this->t('Selection type'), + '#default_value' => $this->options['widget'], + '#options' => [ + self::WIDGET_SELECT => $this->t('Select list'), + self::WIDGET_AUTOCOMPLETE => $this->t('Autocomplete'), + ], + ]; + } + + /** + * {@inheritdoc} + */ + public function submitExtraOptionsForm($form, FormStateInterface $form_state) { + // Remove the value of the js-hide "Change handler" submit button so it is + // not written to the configuration. + if ($form_state->hasValue(['options', 'handler_submit'])) { + $form_state->unsetValue(['options', 'handler_submit']); + } + + parent::submitExtraOptionsForm($form, $form_state); + } + + /** + * Processes the field settings form. + * + * @param array $form + * Associative array containing the structure of the form. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + * + * @return array + * Associative array containing the structure of the form. + * + * @see static::buildExtraOptionsForm() + */ + public static function fixSubmitParents(array $form, FormStateInterface $form_state): array { + static::fixSubmitParentsElement($form, 'root'); + return $form; + } + + /** + * Changes the parent of submit buttons on the field settings form for easier + * processing by the validation and submission handlers. + * + * @param array $element + * Associative array containing the structure of the form, subform or form + * element, passed by reference. + * @param string $key + * The element key, or 'root'. + * + * @see static::fixSubmitParents() + */ + public static function fixSubmitParentsElement(array &$element, $key) { + if (isset($element['#type']) && in_array($element['#type'], ['button', 'submit']) && $key !== 'root') { + $element['#parents'] = [$key]; + } + + foreach (Element::children($element) as $key) { + static::fixSubmitParentsElement($element[$key], $key); + } + } + + /** + * Processes the extra options form. + * + * @see static::buildExtraOptionsForm() + * @see \Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem::fieldSettingsAjaxProcess() + */ + public static function extraOptionsAjaxProcess(array $form, FormStateInterface $form_state): array { + static::extraOptionsAjaxProcessElement($form, $form, $form_state); + return $form; + } + + /** + * Adds entity_reference specific properties to AJAX form elements from the + * extra options form. + * + * @param array $element + * Associative array containing the structure of the form, subform or form + * element to be processed, passed by reference. + * @param array $main_form + * Associative array containing the structure of the form. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + * + * @see static::extraOptionsAjaxProcess() + * @see \Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem::fieldSettingsAjaxProcessElement() + */ + public static function extraOptionsAjaxProcessElement(array &$element, array $main_form, FormStateInterface $form_state) { + if (!empty($element['#ajax'])) { + $element['#ajax'] = [ + 'callback' => [get_called_class(), 'settingsAjax'], + 'url' => views_ui_build_form_url($form_state), + 'wrapper' => $main_form['#id'], + 'element' => $main_form['#array_parents'], + ]; + } + + foreach (Element::children($element) as $key) { + static::extraOptionsAjaxProcessElement($element[$key], $main_form, $form_state); + } + } + + /** + * AJAX callback for the handler settings form. + * + * @param array $form + * Associative array containing the structure of the form. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + * + * @return array + * Settings form array for the triggering element. + * + * @see static::extraOptionsAjaxProcessElement() + * @see \Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem::settingsAjax() + */ + public static function settingsAjax(array $form, FormStateInterface $form_state): array { + $triggering_element = $form_state->getTriggeringElement(); + return NestedArray::getValue($form, $triggering_element['#ajax']['element']); + } + + /** + * Submit handler for the non-JS case. + * + * @param array $form + * Associative array containing the structure of the form. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + * + * @see \Drupal\Core\Field\Plugin\Field\FieldType\EntityReferenceItem::settingsAjaxSubmit() + */ + public static function settingsAjaxSubmit(array $form, FormStateInterface $form_state) { + $form_state->setRebuild(); + } + + /** + * {@inheritdoc} + */ + public function validateExtraOptionsForm($form, FormStateInterface $form_state) { + $subform_state = SubformState::createForSubform($form['reference'], $form, $form_state); + + // Copy handler_settings from options to settings to be compatible with + // selection plugins. + $subform_state->setValue(['settings', 'handler_settings'], $form_state->getValue(['options', 'handler_settings'])); + + $this->getSelectionHandler()->validateConfigurationForm($form, $subform_state); + + // Copy handler_settings back into options. + // Necessary because DefaultSelection::validateConfigurationForm() + // manipulates the form state values. + $form_state->setValue(['options', 'handler_settings'], $subform_state->getValue(['settings', 'handler_settings'])); + + parent::validateExtraOptionsForm($form, $form_state); + } + + /** + * Fixes the issue with switching between the widgets in the view editor. + * + * @param array $form + * Associative array containing the structure of the form, passed by + * reference. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + */ + protected function alternateWidgetsDefaultNormalize(array &$form, FormStateInterface $form_state) { + $field_id = '_' . $this->getFieldDefinition()->getName() . '-widget'; + $form[$field_id] = [ + '#type' => 'hidden', + '#value' => $this->options['widget'], + ]; + + $previous_widget = $form_state->getUserInput()[$field_id] ?? NULL; + if ($previous_widget && $previous_widget !== $this->options['widget']) { + $form['value']['#value_callback'] = function ($element) { + return $element['#default_value'] ?? ''; + }; + } + } + + /** + * {@inheritdoc} + */ + protected function valueForm(&$form, FormStateInterface $form_state) { + switch ($this->options['widget']) { + case self::WIDGET_SELECT: + $this->valueFormAddSelect($form, $form_state); + break; + + case self::WIDGET_AUTOCOMPLETE: + $this->valueFormAddAutocomplete($form, $form_state); + break; + } + + if (!empty($this->view->live_preview)) { + $this->alternateWidgetsDefaultNormalize($form, $form_state); + } + + // Show or hide the value field depending on the operator field. + $is_exposed = $form_state->get('exposed'); + + $visible = []; + if ($is_exposed) { + $operator_field = ($this->options['expose']['use_operator'] && $this->options['expose']['operator_id']) ? $this->options['expose']['operator_id'] : NULL; + } + else { + $operator_field = 'options[operator]'; + $visible[] = [ + ':input[name="options[expose_button][checkbox][checkbox]"]' => ['checked' => TRUE], + ':input[name="options[expose][use_operator]"]' => ['checked' => TRUE], + ':input[name="options[expose][operator_id]"]' => ['empty' => FALSE], + ]; + } + if ($operator_field) { + foreach ($this->operatorValues(1) as $operator) { + $visible[] = [ + ':input[name="' . $operator_field . '"]' => ['value' => $operator], + ]; + } + $form['value']['#states'] = ['visible' => $visible]; + } + + if (!$is_exposed) { + // Retain the helper option. + $this->helper->buildOptionsForm($form, $form_state); + + // Show help text if not exposed to end users. + $form['value']['#description'] = $this->t('Leave blank for all. Otherwise, the first selected item will be the default instead of "Any".'); + } + } + + /** + * Adds an autocomplete element to the form. + * + * @param array $form + * Associative array containing the structure of the form, passed by + * reference. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + */ + protected function valueFormAddAutocomplete(array &$form, FormStateInterface $form_state): void { + $referenced_type = $this->getReferencedEntityType(); + $form['value'] = [ + '#title' => $this->t('Select %entity_types', ['%entity_types' => $referenced_type->getPluralLabel()]), + '#type' => 'entity_autocomplete', + '#default_value' => EntityAutocomplete::getEntityLabels($this->getDefaultSelectedEntities()), + '#tags' => TRUE, + '#process_default_value' => FALSE, + '#target_type' => $referenced_type->id(), + '#selection_handler' => $this->options['handler'], + '#selection_settings' => $this->options['handler_settings'], + // Validation is done by validateExposed(). + '#validate_reference' => FALSE, + ]; + } + + /** + * Adds a select element to the form. + * + * @param array $form + * Associative array containing the structure of the form, passed by + * reference. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + */ + protected function valueFormAddSelect(array &$form, FormStateInterface $form_state): void { + $is_exposed = $form_state->get('exposed'); + + $options = $this->getValueOptions(); + $default_value = (array) $this->value; + + if ($is_exposed) { + $identifier = $this->options['expose']['identifier']; + + if (!empty($this->options['expose']['reduce'])) { + $options = $this->reduceValueOptions($options); + + if (!empty($this->options['expose']['multiple']) && empty($this->options['expose']['required'])) { + $default_value = []; + } + } + + if (empty($this->options['expose']['multiple'])) { + if (empty($this->options['expose']['required']) && (empty($default_value) || !empty($this->options['expose']['reduce']))) { + $default_value = self::ALL_VALUE; + } + elseif (empty($default_value)) { + $keys = array_keys($options); + $default_value = array_shift($keys); + } + // Due to https://www.drupal.org/node/1464174 there is a chance that + // [''] was saved in the admin ui. Let's choose a safe default value. + elseif ($default_value == ['']) { + $default_value = self::ALL_VALUE; + } + else { + // Set the default value to be the first element of the array. + $default_value = reset($default_value); + } + } + } + + $referenced_type = $this->getReferencedEntityType(); + $form['value'] = [ + '#type' => 'select', + '#title' => $this->t('Select @entity_types', ['@entity_types' => $referenced_type->getPluralLabel()]), + '#multiple' => TRUE, + '#options' => $options, + // Set a minimum size to facilitate easier selection of entities. + '#size' => min(8, count($options)), + '#default_value' => $default_value, + ]; + + $user_input = $form_state->getUserInput(); + if ($is_exposed && isset($identifier) && !isset($user_input[$identifier])) { + $user_input[$identifier] = $default_value; + $form_state->setUserInput($user_input); + } + } + + /** + * Gets all entities selected by default. + * + * @return \Drupal\Core\Entity\EntityInterface[] + * All entities selected by default, or an empty array, if none. + */ + protected function getDefaultSelectedEntities(): array { + $referenced_type_id = $this->getReferencedEntityType()->id(); + $entity_storage = $this->entityTypeManager->getStorage($referenced_type_id); + + return $this->value && !isset($this->value[self::ALL_VALUE]) ? $entity_storage->loadMultiple($this->value) : []; + } + + /** + * Returns the value options for a select widget. + * + * @param \Drupal\Core\Entity\EntityReferenceSelection\SelectionInterface $selection_handler + * The selection handler. + * + * @return string[] + * The options. + * + * @see \Drupal\views\Plugin\views\filter\InOperator::getValueOptions() + */ + protected function getValueOptionsCallback(SelectionInterface $selection_handler): array { + switch ($this->options['widget']) { + case self::WIDGET_SELECT: + $entities = $selection_handler->getReferenceableEntities(NULL, 'CONTAINS'); + break; + + case self::WIDGET_AUTOCOMPLETE: + $entities = []; + break; + } + + $options = []; + foreach ($entities as $bundle) { + foreach ($bundle as $id => $entity_label) { + $options[$id] = $entity_label; + } + } + + return $options; + } + + /** + * {@inheritdoc} + */ + protected function valueValidate($form, FormStateInterface $form_state) { + if ($this->options['widget'] == self::WIDGET_AUTOCOMPLETE) { + + // Set value from the autocomplete reference to match the select list + // widget to ensure the two widgets can be interchangeable. + $ids = []; + if ($values = $form_state->getValue(['options', 'value'])) { + foreach ($form_state->getValue(['options', 'value']) as $value) { + $ids[] = $value['target_id']; + } + } + $form_state->setValue(['options', 'value'], $ids); + } + } + + /** + * {@inheritdoc} + */ + public function acceptExposedInput($input): bool { + if (empty($this->options['exposed'])) { + return TRUE; + } + // We need to know the operator, which is normally set in + // \Drupal\views\Plugin\views\filter\FilterPluginBase::acceptExposedInput(), + // before we actually call the parent version of ourselves. + 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']]; + } + + // If view is an attachment and is inheriting exposed filters, then assume + // exposed input has already been validated. + if (!empty($this->view->is_attachment) && $this->view->display_handler->usesExposed()) { + $this->validatedExposedInput = (array) $this->view->exposed_raw_input[$this->options['expose']['identifier']]; + } + + // If we're checking for EMPTY or NOT, we don't need any input, and we can + // say that our input conditions are met by just having the right operator. + if ($this->operator == 'empty' || $this->operator == 'not empty') { + return TRUE; + } + + // If it's non-required and there's no value don't bother filtering. + if (!$this->options['expose']['required'] && empty($this->validatedExposedInput)) { + return FALSE; + } + + $accept_exposed_input = parent::acceptExposedInput($input); + if ($accept_exposed_input) { + // If we have previously validated input, override. + if (isset($this->validatedExposedInput)) { + $this->value = $this->validatedExposedInput; + } + } + + return $accept_exposed_input; + } + + /** + * {@inheritdoc} + */ + public function validateExposed(&$form, FormStateInterface $form_state) { + if (empty($this->options['exposed'])) { + return; + } + + $identifier = $this->options['expose']['identifier']; + + // Set the validated exposed input from the select list when not the all + // value option. + if ($this->options['widget'] == self::WIDGET_SELECT) { + if ($form_state->getValue($identifier) != self::ALL_VALUE) { + $this->validatedExposedInput = (array) $form_state->getValue($identifier); + } + return; + } + + if (empty($identifier)) { + return; + } + + if ($values = $form_state->getValue($identifier)) { + foreach ($values as $value) { + $this->validatedExposedInput[] = $value['target_id']; + } + } + } + + /** + * {@inheritdoc} + */ + protected function valueSubmit($form, FormStateInterface $form_state) { + // Prevent the parent class InOperator from altering the array. + // @see \Drupal\views\Plugin\views\filter\InOperator::valueSubmit(). + } + + /** + * Gets the target entity type referenced by this field. + * + * @return \Drupal\Core\Entity\EntityTypeInterface + * The entity type definition. + */ + protected function getReferencedEntityType(): EntityTypeInterface { + $field_def = $this->getFieldDefinition(); + $entity_type_id = $field_def->getItemDefinition()->getSetting('target_type'); + return $this->entityTypeManager->getDefinition($entity_type_id); + } + + /** + * {@inheritdoc} + */ + public function calculateDependencies(): array { + $dependencies = parent::calculateDependencies(); + + $selection_handler = $this->getSelectionHandler(); + if ($selection_handler instanceof DependentPluginInterface) { + $dependencies += $selection_handler->calculateDependencies(); + } + + foreach ($this->getDefaultSelectedEntities() as $entity) { + $dependencies[$entity->getConfigDependencyKey()][] = $entity->getConfigDependencyName(); + } + + return $dependencies; + } + +} diff --git a/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_filter_entity_reference.yml b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_filter_entity_reference.yml new file mode 100644 index 0000000000..a526d5d174 --- /dev/null +++ b/core/modules/views/tests/modules/views_test_config/test_views/views.view.test_filter_entity_reference.yml @@ -0,0 +1,220 @@ +langcode: en +status: true +dependencies: + config: + - node.type.page + module: + - node + - user +id: test_filter_entity_reference +label: test_filter_entity_reference +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: none + 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: mini + 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: ‹‹ + next: ›› + style: + type: default + options: + grouping: { } + row_class: '' + default_row_class: true + uses_fields: false + row: + type: fields + options: + inline: { } + separator: '' + hide_empty: false + default_field_elements: true + fields: + title: + id: title + table: node_field_data + field: title + entity_type: node + entity_field: title + label: '' + 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: '' + 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 + type: + id: type + table: node_field_data + field: type + value: + page: page + entity_type: node + entity_field: type + plugin_id: bundle + field_test_target_id: + id: field_test_target_id + table: node__field_test + field: field_test_target_id + relationship: none + group_type: group + admin_label: '' + operator: or + value: { } + group: 1 + exposed: true + expose: + operator_id: field_test_target_id_op + label: 'Test (field_test)' + description: '' + use_operator: false + operator: field_test_target_id_op + identifier: field_test_target_id + required: false + remember: false + multiple: true + remember_roles: + authenticated: authenticated + anonymous: '0' + administrator: '0' + reduce: false + is_grouped: false + group_info: + label: '' + description: '' + identifier: '' + optional: true + widget: select + multiple: false + remember: false + default_group: All + default_group_multiple: { } + group_items: { } + reduce_duplicates: false + handler: 'default:node' + handler_settings: + target_bundles: + article: article + sort: + field: title + direction: ASC + auto_create: false + auto_create_bundle: '' + widget: select + plugin_id: entity_reference + sorts: + created: + id: created + table: node_field_data + field: created + order: DESC + entity_type: node + entity_field: created + plugin_id: date + relationship: none + group_type: group + admin_label: '' + exposed: false + expose: + label: '' + granularity: second + header: { } + footer: { } + empty: { } + relationships: { } + arguments: { } + display_extenders: { } diff --git a/core/modules/views/tests/src/Kernel/Entity/EntityViewsDataTest.php b/core/modules/views/tests/src/Kernel/Entity/EntityViewsDataTest.php index 372a4c4add..d8702f363b 100644 --- a/core/modules/views/tests/src/Kernel/Entity/EntityViewsDataTest.php +++ b/core/modules/views/tests/src/Kernel/Entity/EntityViewsDataTest.php @@ -750,7 +750,7 @@ protected function assertLanguageField(array $data): void { */ protected function assertEntityReferenceField(array $data): void { $this->assertEquals('field', $data['field']['id']); - $this->assertEquals('numeric', $data['filter']['id']); + $this->assertEquals('entity_reference', $data['filter']['id']); $this->assertEquals('numeric', $data['argument']['id']); $this->assertEquals('standard', $data['sort']['id']); } diff --git a/core/modules/views/tests/src/Kernel/Handler/FieldEntityReferenceTest.php b/core/modules/views/tests/src/Kernel/Handler/FieldEntityReferenceTest.php new file mode 100644 index 0000000000..ebe893fd65 --- /dev/null +++ b/core/modules/views/tests/src/Kernel/Handler/FieldEntityReferenceTest.php @@ -0,0 +1,195 @@ +installEntitySchema('node'); + $this->installEntitySchema('user'); + $this->installConfig(['node', 'user', 'filter']); + + ViewTestData::createTestViews(static::class, ['views_test_config']); + // Create two node types. + $this->createContentType(['type' => 'page']); + $this->createContentType(['type' => 'article']); + + // Add an entity reference field to the page type referencing the article + // type. + $selection_handler_settings = [ + 'target_bundles' => [ + 'article' => 'article', + ], + ]; + $this->createEntityReferenceField('node', 'page', 'field_test', 'Test reference', 'node', $selection_handler = 'default', $selection_handler_settings, FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED); + + // Create user 1. + $admin = $this->createUser(); + + // Create target nodes to be referenced. + foreach (range(0, 5) as $count) { + $this->targetNodes[$count] = $this->createNode([ + 'type' => 'article', + 'title' => 'Article ' . $count, + 'status' => 1, + ]); + } + + // Create a page referencing Article 0 and Article 1. + $this->hostNodes[0] = $this->createNode([ + 'type' => 'page', + 'title' => 'Page 0', + 'status' => 1, + 'field_test' => [ + $this->targetNodes[0]->id(), + $this->targetNodes[1]->id(), + ], + ]); + + // Create a page referencing Article 1, Article 2, and Article 3. + $this->hostNodes[1] = $this->createNode([ + 'type' => 'page', + 'title' => 'Page 1', + 'status' => 1, + 'field_test' => [ + $this->targetNodes[1]->id(), + $this->targetNodes[2]->id(), + $this->targetNodes[3]->id(), + ], + ]); + + // Create a page referencing nothing. + $this->hostNodes[2] = $this->createNode([ + 'type' => 'page', + 'title' => 'Page 2', + 'status' => 1, + ]); + } + + /** + * Tests that results are successfully filtered by the select list widget. + */ + public function testViewEntityReferenceAsSelectList() { + $view = Views::getView('test_filter_entity_reference'); + $view->setDisplay(); + $view->preExecute([]); + $view->setExposedInput([ + 'field_test_target_id' => [$this->targetNodes[0]->id()], + ]); + $this->executeView($view); + + // Expect to have only Page 0, with Article 0 referenced. + $expected = [ + ['title' => 'Page 0'], + ]; + $this->assertIdenticalResultset($view, $expected, [ + 'title' => 'title', + ]); + + // Change to both Article 0 and Article 3. + $view = Views::getView('test_filter_entity_reference'); + $view->setDisplay(); + $view->setExposedInput([ + 'field_test_target_id' => [ + $this->targetNodes[0]->id(), + $this->targetNodes[3]->id(), + ], + ]); + $this->executeView($view); + + // Expect to have Page 0 and 1, with Article 0 and 3 referenced. + $expected = [ + ['title' => 'Page 0'], + ['title' => 'Page 1'], + ]; + $this->assertIdenticalResultset($view, $expected, [ + 'title' => 'title', + ]); + } + + /** + * Tests that results are successfully filtered by the autocomplete widget. + */ + public function testViewEntityReferenceAsAutocomplete() { + + // Change the widget to autocomplete. + $view = Views::getView('test_filter_entity_reference'); + $view->setDisplay(); + $filters = $view->displayHandlers->get('default')->getOption('filters'); + $filters['field_test_target_id']['widget'] = EntityReference::WIDGET_AUTOCOMPLETE; + $view->displayHandlers->get('default')->overrideOption('filters', $filters); + $view->setExposedInput([ + 'field_test_target_id' => [ + ['target_id' => $this->targetNodes[0]->id()], + ['target_id' => $this->targetNodes[3]->id()], + ], + ]); + $this->executeView($view); + + // Expect to have Page 0 and 1, with Article 0 and 3 referenced. + $expected = [ + ['title' => 'Page 0'], + ['title' => 'Page 1'], + ]; + $this->assertIdenticalResultset($view, $expected, [ + 'title' => 'title', + ]); + } + +} diff --git a/core/modules/views/views.views.inc b/core/modules/views/views.views.inc index de79773321..6e6b7789c7 100644 --- a/core/modules/views/views.views.inc +++ b/core/modules/views/views.views.inc @@ -767,9 +767,10 @@ function views_field_default_views_data(FieldStorageConfigInterface $field_stora /** * Implements hook_field_views_data(). * - * The function implements the hook in behalf of 'core' because it adds a - * relationship and a reverse relationship to entity_reference field type, which - * is provided by core. + * The function implements the hook in behalf of 'core' because it updates + * filters to use the entity_reference handler, adds a relationship and a + * reverse relationship to entity_reference field type, which is provided by + * core. */ function core_field_views_data(FieldStorageConfigInterface $field_storage) { $data = views_field_default_views_data($field_storage); @@ -794,6 +795,13 @@ function core_field_views_data(FieldStorageConfigInterface $field_storage) { $field_name = $field_storage->getName(); if ($target_entity_type instanceof ContentEntityTypeInterface) { + // Use the entity_reference filter for this field. + foreach ($table_data as $table_field_name => $table_field_data) { + if (isset($table_field_data['filter']) && $table_field_name != 'delta') { + $data[$table_name][$table_field_name]['filter']['id'] = 'entity_reference'; + } + } + // Provide a relationship for the entity type with the entity reference // field. $args = [ diff --git a/core/modules/views_ui/tests/src/Functional/FilterEntityReferenceWebTest.php b/core/modules/views_ui/tests/src/Functional/FilterEntityReferenceWebTest.php new file mode 100644 index 0000000000..ebb4fca1c6 --- /dev/null +++ b/core/modules/views_ui/tests/src/Functional/FilterEntityReferenceWebTest.php @@ -0,0 +1,181 @@ +hostEntityType = $this->drupalCreateContentType(['type' => 'page']); + $this->targetEntityType = $this->drupalCreateContentType(['type' => 'article']); + + $field_storage = FieldStorageConfig::create([ + 'entity_type' => 'node', + 'field_name' => 'field_test', + 'type' => 'entity_reference', + 'settings' => [ + 'target_type' => 'node', + ], + 'cardinality' => FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED, + ]); + $field_storage->save(); + + $field = FieldConfig::create([ + 'entity_type' => 'node', + 'field_name' => 'field_test', + 'bundle' => $this->hostEntityType->id(), + 'settings' => [ + 'handler' => 'default', + 'handler_settings' => [ + 'target_bundles' => [ + $this->targetEntityType->id() => $this->targetEntityType->label(), + ], + ], + ], + ]); + $field->save(); + + // Create 10 nodes for use as target entities. + for ($i = 0; $i < 10; $i++) { + $node = $this->drupalCreateNode(['type' => $this->targetEntityType->id()]); + $this->targetEntities[$node->id()] = $node; + } + + // Create 1 host entity to reference target entities from. + $node = $this->drupalCreateNode(['type' => $this->hostEntityType->id()]); + $this->hostEntities = [ + $node->id() => $node, + ]; + } + + /** + * Tests the filter UI. + */ + public function testFilterUi(): void { + $this->drupalGet('admin/structure/views/nojs/handler/test_filter_entity_reference/default/filter/field_test_target_id'); + + $options = $this->getUiOptions(); + // Should be sorted by title ASC. + uasort($this->targetEntities, function (EntityInterface $a, EntityInterface $b) { + return strnatcasecmp($a->getTitle(), $b->getTitle()); + }); + $i = 0; + foreach ($this->targetEntities as $id => $entity) { + $this->assertEquals($options[$i]['label'], $entity->label(), new FormattableMarkup('Expected target entity label found for option :option', [':option' => $i])); + $i++; + } + + // Change the sort field and direction. + $this->drupalGet('admin/structure/views/nojs/handler-extra/test_filter_entity_reference/default/filter/field_test_target_id'); + $edit = [ + 'options[handler_settings][sort][field]' => 'nid', + 'options[handler_settings][sort][direction]' => 'DESC', + ]; + $this->submitForm($edit, 'Apply'); + + $this->drupalGet('admin/structure/views/nojs/handler/test_filter_entity_reference/default/filter/field_test_target_id'); + // Items should now be in reverse id order. + krsort($this->targetEntities); + $options = $this->getUiOptions(); + $i = 0; + foreach ($this->targetEntities as $id => $entity) { + $this->assertEquals($options[$i]['label'], $entity->label(), new FormattableMarkup('Expected target entity label found for option :option', [':option' => $i])); + $i++; + } + + // Change bundle types. + $this->drupalGet('admin/structure/views/nojs/handler-extra/test_filter_entity_reference/default/filter/field_test_target_id'); + $edit = [ + "options[handler_settings][target_bundles][{$this->hostEntityType->id()}]" => TRUE, + "options[handler_settings][target_bundles][{$this->targetEntityType->id()}]" => TRUE, + ]; + $this->submitForm($edit, 'Apply'); + + $this->drupalGet('admin/structure/views/nojs/handler/test_filter_entity_reference/default/filter/field_test_target_id'); + $options = $this->getUiOptions(); + $i = 0; + foreach ($this->hostEntities + $this->targetEntities as $id => $entity) { + $this->assertEquals($options[$i]['label'], $entity->label(), new FormattableMarkup('Expected target entity label found for option :option', [':option' => $i])); + $i++; + } + } + + /** + * Helper method to parse options from the UI. + * + * @return array + * Array of keyed arrays containing the id and label of each option. + */ + protected function getUiOptions() { + /** @var \Behat\Mink\Element\TraversableElement[] $result */ + $result = $this->xpath('//select[@name="options[value][]"]/option'); + $this->assertNotEmpty($result, 'Options found'); + + $options = []; + foreach ($result as $option) { + $options[] = [ + 'id' => (int) $option->getValue(), + 'label' => (string) $option->getText(), + ]; + } + + return $options; + } + +}