diff --git a/core/modules/content_moderation/config/schema/content_moderation.views.schema.yml b/core/modules/content_moderation/config/schema/content_moderation.views.schema.yml new file mode 100644 index 0000000..c9ae1af --- /dev/null +++ b/core/modules/content_moderation/config/schema/content_moderation.views.schema.yml @@ -0,0 +1,18 @@ +views.filter.latest_revision: + type: views_filter + label: 'Latest revision' + mapping: + value: + type: string + label: 'Value' + +views.filter.moderation_state_filter: + type: views.filter.in_operator + label: 'Moderation state filter' + mapping: + limit_workflows: + type: sequence + label: 'Workflow' + sequence: + type: string + label: 'Workflow' diff --git a/core/modules/content_moderation/src/Plugin/views/filter/ModerationStateFilter.php b/core/modules/content_moderation/src/Plugin/views/filter/ModerationStateFilter.php new file mode 100644 index 0000000..6069788 --- /dev/null +++ b/core/modules/content_moderation/src/Plugin/views/filter/ModerationStateFilter.php @@ -0,0 +1,291 @@ +entityTypeManager = $entity_type_manager; + $this->bundleInfo = $bundle_info; + $this->workflowStorage = $workflow_storage; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('entity_type.manager'), + $container->get('entity_type.bundle.info'), + $container->get('entity_type.manager')->getStorage('workflow') + ); + } + + /** + * {@inheritdoc} + */ + public function getCacheTags() { + return Cache::mergeTags(parent::getCacheTags(), $this->entityTypeManager->getDefinition('workflow')->getListCacheTags()); + } + + /** + * {@inheritdoc} + */ + public function getCacheContexts() { + return Cache::mergeContexts(parent::getCacheContexts(), $this->entityTypeManager->getDefinition('workflow')->getListCacheContexts()); + } + + /** + * {@inheritdoc} + */ + public function getValueOptions() { + if (isset($this->valueOptions)) { + return $this->valueOptions; + } + $this->valueOptions = []; + + // Find all workflows which are moderating entity types of the same type the + // view is displaying. + foreach ($this->workflowStorage->loadByProperties(['type' => 'content_moderation']) as $workflow) { + /** @var \Drupal\content_moderation\Plugin\WorkflowType\ContentModerationInterface $workflow_type */ + $workflow_type = $workflow->getTypePlugin(); + if (in_array($this->getEntityType(), $workflow_type->getEntityTypes(), TRUE)) { + foreach ($workflow_type->getStates() as $state_id => $state) { + $this->valueOptions[$workflow->label()][implode('-', [$workflow->id(), $state_id])] = $state->label(); + } + } + } + + return $this->valueOptions; + } + + /** + * {@inheritdoc} + */ + public function ensureMyTable() { + if (!isset($this->tableAlias)) { + $table_alias = $this->query->ensureTable($this->table, $this->relationship); + + // Filter the moderation states of the content via the + // ContentModerationState field revision table, joining either the entity + // field data or revision table. This allows filtering states against either + // the default or latest revision, depending on the relationship of the + // filter. + $left_entity_type = $this->entityTypeManager->getDefinition($this->getEntityType()); + $entity_type = $this->entityTypeManager->getDefinition('content_moderation_state'); + $configuration = [ + 'table' => $entity_type->getRevisionDataTable(), + 'field' => 'content_entity_revision_id', + 'left_table' => $table_alias, + 'left_field' => $left_entity_type->getKey('revision'), + 'extra' => [ + [ + 'field' => 'content_entity_type_id', + 'value' => $left_entity_type->id(), + ], + ], + ]; + if ($left_entity_type->isTranslatable()) { + $configuration['extra'][] = [ + 'field' => $entity_type->getKey('langcode'), + 'left_field' => $left_entity_type->getKey('langcode'), + ]; + } + $join = Views::pluginManager('join')->createInstance('standard', $configuration); + $this->tableAlias = $this->query->addRelationship('content_moderation_state', $join, 'content_moderation_state_field_revision'); + } + + return $this->tableAlias; + } + + /** + * {@inheritdoc} + */ + protected function opSimple() { + if (empty($this->value)) { + return; + } + + $entity_type = $this->entityTypeManager->getDefinition($this->getEntityType()); + if ($entity_type->hasKey('bundle')) { + // Get a list of bundles that are being moderated by the workflows + // configured in this filter. + $workflow_ids = $this->getWorkflowIds(); + $moderated_bundles = []; + foreach ($this->bundleInfo->getBundleInfo($this->getEntityType()) as $bundle_id => $bundle) { + if (isset($bundle['workflow']) && in_array($bundle['workflow'], $workflow_ids, TRUE)) { + $moderated_bundles[] = $bundle_id; + } + } + + // If we have a list of moderated bundles, restrict the query to show only + // entities in those bundles. + if ($moderated_bundles) { + $entity_base_table_alias = $this->table; + + // The bundle field of an entity type is not revisionable so we need to + // join the data table. + $entity_base_table = $entity_type->isTranslatable() ? $entity_type->getDataTable() : $entity_type->getBaseTable(); + $entity_revision_base_table = $entity_type->isTranslatable() ? $entity_type->getRevisionDataTable() : $entity_type->getRevisionTable(); + if ($this->table === $entity_revision_base_table) { + $configuration = [ + 'table' => $entity_base_table, + 'field' => $entity_type->getKey('id'), + 'left_table' => $entity_revision_base_table, + 'left_field' => $entity_type->getKey('id'), + 'type' => 'INNER', + ]; + if ($entity_type->isTranslatable()) { + $configuration['extra'][] = [ + 'field' => $entity_type->getKey('langcode'), + 'left_field' => $entity_type->getKey('langcode'), + ]; + } + + $join = Views::pluginManager('join')->createInstance('standard', $configuration); + $entity_base_table_alias = $this->query->addRelationship($entity_base_table, $join, $entity_revision_base_table); + } + + $this->query->addWhere($this->options['group'], "$entity_base_table_alias.{$entity_type->getKey('bundle')}", $moderated_bundles, 'IN'); + } + // Otherwise, force the query to return an empty result. + else { + $this->query->addWhereExpression($this->options['group'], '1 = 0'); + return; + } + } + + $this->ensureMyTable(); + + if ($this->operator == 'in') { + $operator = "="; + } + else { + $operator = "<>"; + } + + // The values are strings composed from the workflow ID and the state ID, so + // we need to create a complex WHERE condition. + $field = new Condition('OR'); + foreach ((array) $this->value as $value) { + list($workflow_id, $state_id) = explode('-', $value, 2); + + $and = new Condition('AND'); + $and + ->condition("$this->tableAlias.workflow", $workflow_id, '=') + ->condition("$this->tableAlias.$this->realField", $state_id, $operator); + + $field->condition($and); + } + + $this->query->addWhere($this->options['group'], $field); + } + + /** + * {@inheritdoc} + */ + public function calculateDependencies() { + $dependencies = parent::calculateDependencies(); + + if ($workflow_ids = $this->getWorkflowIds()) { + /** @var \Drupal\workflows\WorkflowInterface $workflow */ + foreach ($this->workflowStorage->loadMultiple($workflow_ids) as $workflow) { + $dependencies[$workflow->getConfigDependencyKey()][] = $workflow->getConfigDependencyName(); + } + } + + return $dependencies; + } + + /** + * {@inheritdoc} + */ + public function onDependencyRemoval(array $dependencies) { + // See if this handler is responsible for any of the dependencies being + // removed. If this is the case, indicate that this handler needs to be + // removed from the View. + $remove = FALSE; + // Get all the current dependencies for this handler. + $current_dependencies = $this->calculateDependencies(); + foreach ($current_dependencies as $group => $dependency_list) { + // Check if any of the handler dependencies match the dependencies being + // removed. + foreach ($dependency_list as $config_key) { + if (isset($dependencies[$group]) && array_key_exists($config_key, $dependencies[$group])) { + // This handlers dependency matches a dependency being removed, + // indicate that this handler needs to be removed. + $remove = TRUE; + break 2; + } + } + } + return $remove; + } + + /** + * Gets the list of Workflow IDs configured for this filter. + * + * @return array + * And array of workflow IDs. + */ + protected function getWorkflowIds() { + $workflow_ids = []; + foreach ((array) $this->value as $value) { + list($workflow_id) = explode('-', $value, 2); + $workflow_ids[] = $workflow_id; + } + + return array_unique($workflow_ids); + } + +} diff --git a/core/modules/content_moderation/src/ViewsData.php b/core/modules/content_moderation/src/ViewsData.php index 57d6d76..0325db0 100644 --- a/core/modules/content_moderation/src/ViewsData.php +++ b/core/modules/content_moderation/src/ViewsData.php @@ -79,6 +79,7 @@ public function getViewsData() { ], ], 'field' => ['default_formatter' => 'content_moderation_state'], + 'filter' => ['id' => 'moderation_state_filter', 'allow empty' => TRUE], ]; $revision_table = $entity_type->getRevisionDataTable() ?: $entity_type->getRevisionTable(); @@ -98,6 +99,7 @@ public function getViewsData() { ], ], 'field' => ['default_formatter' => 'content_moderation_state'], + 'filter' => ['id' => 'moderation_state_filter', 'allow empty' => TRUE], ]; } diff --git a/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_state_filter.yml b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_state_filter.yml new file mode 100644 index 0000000..12d9771 --- /dev/null +++ b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_state_filter.yml @@ -0,0 +1,247 @@ +langcode: en +status: true +dependencies: + module: + - content_moderation + - node + - user +id: test_content_moderation_state_filter +label: test_content_moderation_state_filter +module: views +description: '' +tag: '' +base_table: node_field_data +base_field: nid +core: 8.x +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: none + options: + offset: 0 + 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: + nid: + id: nid + table: node_field_data + field: nid + relationship: none + group_type: group + admin_label: '' + label: '' + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: false + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + click_sort_column: value + type: number_integer + settings: + thousand_separator: '' + prefix_suffix: false + 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 + entity_type: node + entity_field: nid + plugin_id: field + filters: + moderation_state: + id: moderation_state + table: node_field_data + field: moderation_state + relationship: none + group_type: group + admin_label: '' + operator: in + value: { } + group: 1 + exposed: true + expose: + operator_id: moderation_state_op + label: 'Default Revision State' + description: '' + use_operator: false + operator: moderation_state_op + identifier: default_revision_state + required: false + remember: false + multiple: false + 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: { } + entity_type: node + plugin_id: moderation_state_filter + moderation_state_1: + id: moderation_state_1 + table: node_field_data + field: moderation_state + relationship: none + group_type: group + admin_label: '' + operator: 'not empty' + value: { } + group: 1 + exposed: false + expose: + operator_id: '' + label: '' + description: '' + use_operator: false + operator: '' + identifier: '' + required: false + remember: false + multiple: false + remember_roles: + authenticated: authenticated + 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: { } + entity_type: node + plugin_id: moderation_state_filter + sorts: { } + header: { } + footer: { } + empty: { } + relationships: { } + arguments: { } + display_extenders: { } + cache_metadata: + max-age: -1 + contexts: + - 'languages:language_content' + - 'languages:language_interface' + - url + - 'user.node_grants:view' + - user.permissions + tags: + - 'config:workflow_list' + page_1: + display_plugin: page + id: page_1 + display_title: Page + position: 1 + display_options: + display_extenders: { } + path: filter-test-path + cache_metadata: + max-age: -1 + contexts: + - 'languages:language_content' + - 'languages:language_interface' + - url + - 'user.node_grants:view' + - user.permissions + tags: + - 'config:workflow_list' + diff --git a/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_state_filter_entity_test.yml b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_state_filter_entity_test.yml new file mode 100644 index 0000000..0683b98 --- /dev/null +++ b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_state_filter_entity_test.yml @@ -0,0 +1,168 @@ +langcode: en +status: true +dependencies: + module: + - content_moderation + - entity_test +id: test_content_moderation_state_filter_entity_test +label: test_content_moderation_state_filter_entity_test +module: views +description: '' +tag: '' +base_table: entity_test_no_bundle +base_field: id +core: 8.x +display: + default: + display_plugin: default + id: default + display_title: Master + position: 0 + display_options: + access: + type: none + options: { } + 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: none + options: + offset: 0 + 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: + entity_id: + id: entity_id + table: content_revision_tracker + field: entity_id + relationship: none + group_type: group + admin_label: '' + label: '' + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: false + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + plugin_id: standard + filters: + moderation_state: + id: moderation_state + table: entity_test_no_bundle + field: moderation_state + relationship: none + group_type: group + admin_label: '' + operator: in + value: { } + group: 1 + exposed: true + expose: + operator_id: moderation_state_op + label: 'Moderation state' + description: '' + use_operator: false + operator: moderation_state_op + identifier: moderation_state + required: false + remember: false + multiple: false + 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: { } + entity_type: entity_test_no_bundle + plugin_id: moderation_state_filter + sorts: { } + header: { } + footer: { } + empty: { } + relationships: { } + arguments: { } + display_extenders: { } + cache_metadata: + max-age: -1 + contexts: + - 'languages:language_interface' + - url + tags: { } + diff --git a/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_state_filter_revision_table.yml b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_state_filter_revision_table.yml new file mode 100644 index 0000000..f49d418 --- /dev/null +++ b/core/modules/content_moderation/tests/modules/content_moderation_test_views/config/install/views.view.test_content_moderation_state_filter_revision_table.yml @@ -0,0 +1,204 @@ +langcode: en +status: true +dependencies: + module: + - content_moderation + - user +id: test_content_moderation_state_filter_revision_table +label: test_content_moderation_state_filter_revision_table +module: views +description: '' +tag: '' +base_table: node_field_revision +base_field: vid +core: 8.x +display: + default: + display_plugin: default + id: default + display_title: Master + position: 0 + display_options: + access: + type: perm + options: + perm: 'view all revisions' + 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: 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: + nid: + id: nid + table: node_field_revision + field: nid + relationship: none + group_type: group + admin_label: '' + label: '' + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: false + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + click_sort_column: value + type: number_integer + settings: + thousand_separator: '' + prefix_suffix: true + 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 + entity_type: node + entity_field: nid + plugin_id: field + filters: + moderation_state: + id: moderation_state + table: node_field_revision + field: moderation_state + relationship: none + group_type: group + admin_label: '' + operator: in + value: { } + group: 1 + exposed: true + expose: + operator_id: moderation_state_op + label: 'Moderation state' + description: '' + use_operator: false + operator: moderation_state_op + identifier: moderation_state + required: false + remember: false + multiple: false + 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: { } + entity_type: node + plugin_id: moderation_state_filter + sorts: { } + header: { } + footer: { } + empty: { } + relationships: { } + arguments: { } + 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/content_moderation/tests/modules/content_moderation_test_views/content_moderation_test_views.info.yml b/core/modules/content_moderation/tests/modules/content_moderation_test_views/content_moderation_test_views.info.yml index b96ef84..e3d37c3 100644 --- a/core/modules/content_moderation/tests/modules/content_moderation_test_views/content_moderation_test_views.info.yml +++ b/core/modules/content_moderation/tests/modules/content_moderation_test_views/content_moderation_test_views.info.yml @@ -8,3 +8,4 @@ dependencies: - content_moderation - node - views + - entity_test diff --git a/core/modules/content_moderation/tests/src/Functional/ViewsModerationStateFilterTest.php b/core/modules/content_moderation/tests/src/Functional/ViewsModerationStateFilterTest.php new file mode 100644 index 0000000..de5a337 --- /dev/null +++ b/core/modules/content_moderation/tests/src/Functional/ViewsModerationStateFilterTest.php @@ -0,0 +1,258 @@ + 'example_a', + ])->save(); + NodeType::create([ + 'type' => 'example_b', + ])->save(); + + $new_workflow = Workflow::create([ + 'type' => 'content_moderation', + 'id' => 'new_workflow', + 'label' => 'New workflow', + ]); + $new_workflow->getTypePlugin()->addState('bar', 'Bar'); + $new_workflow->save(); + + $this->drupalLogin($this->drupalCreateUser(['administer workflows', 'administer views'])); + } + + /** + * Tests the dependency handling of the moderation state filter. + * + * @covers ::calculateDependencies + * @covers ::onDependencyRemoval + */ + public function testModerationStateFilterDependencyHandling() { + // First, check that the view doesn't have any config dependency when there + // are no states configured in the filter. + $view_id = 'test_content_moderation_state_filter'; + $view = Views::getView($view_id); + + $this->assertWorkflowDependencies([], $view); + $this->assertTrue($view->storage->status()); + + // Configure the Editorial workflow for a node bundle, set the filter value + // to use one its states and check that the workflow is now a dependency of + // the view. + $this->drupalPostForm('admin/config/workflow/workflows/manage/editorial/type/node', [ + 'bundles[example_a]' => TRUE, + ], 'Save'); + + $edit['options[value][]'] = ['editorial-published']; + $this->drupalPostForm("admin/structure/views/nojs/handler/$view_id/default/filter/moderation_state", $edit, 'Apply'); + $this->drupalPostForm("admin/structure/views/view/$view_id", [], 'Save'); + + $view = Views::getView($view_id); + $this->assertWorkflowDependencies(['editorial'], $view); + $this->assertTrue($view->storage->status()); + + // Create another workflow and repeat the checks above. + $this->drupalPostForm('admin/config/workflow/workflows/add', [ + 'label' => 'Translation', + 'id' => 'translation', + 'workflow_type' => 'content_moderation', + ], 'Save'); + $this->drupalPostForm('admin/config/workflow/workflows/manage/translation/add_state', [ + 'label' => 'Needs Review', + 'id' => 'needs_review', + ], 'Save'); + $this->drupalPostForm('admin/config/workflow/workflows/manage/translation/type/node', [ + 'bundles[example_b]' => TRUE, + ], 'Save'); + + $edit['options[value][]'] = ['editorial-published', 'translation-needs_review']; + $this->drupalPostForm("admin/structure/views/nojs/handler/$view_id/default/filter/moderation_state", $edit, 'Apply'); + $this->drupalPostForm("admin/structure/views/view/$view_id", [], 'Save'); + + $view = Views::getView($view_id); + $this->assertWorkflowDependencies(['editorial', 'translation'], $view); + $this->assertTrue(isset($view->storage->getDisplay('default')['display_options']['filters']['moderation_state'])); + $this->assertTrue($view->storage->status()); + + // Remove the 'Translation' workflow. + $this->drupalPostForm('admin/config/workflow/workflows/manage/translation/delete', [], 'Delete'); + + // Check that the view has been disabled, the filter has been deleted, the + // view can be saved and there are no more config dependencies. + $view = Views::getView($view_id); + $this->assertFalse($view->storage->status()); + $this->assertFalse(isset($view->storage->getDisplay('default')['display_options']['filters']['moderation_state'])); + $this->drupalPostForm("admin/structure/views/view/$view_id", [], 'Save'); + $this->assertWorkflowDependencies([], $view); + } + + /** + * Tests the moderation state filter when the configured workflow is changed. + */ + public function testWorkflowChanges() { + $view_id = 'test_content_moderation_state_filter'; + + // Update the view and make the default filter not exposed anymore, + // otherwise all results will be shown when there are no more moderated + // bundles left. + $this->drupalPostForm("admin/structure/views/nojs/handler/$view_id/default/filter/moderation_state", [], 'Hide filter'); + $this->drupalPostForm("admin/structure/views/view/$view_id", [], 'Save'); + + // First, apply the Editorial workflow to both of our content types. + $this->drupalPostForm('admin/config/workflow/workflows/manage/editorial/type/node', [ + 'bundles[example_a]' => TRUE, + 'bundles[example_b]' => TRUE, + ], 'Save'); + \Drupal::service('entity_type.bundle.info')->clearCachedBundles(); + + // Add a few nodes in various moderation states. + $this->createNode(['type' => 'example_a', 'moderation_state' => 'published']); + $this->createNode(['type' => 'example_b', 'moderation_state' => 'published']); + $archived_node_a = $this->createNode(['type' => 'example_a', 'moderation_state' => 'archived']); + $archived_node_b = $this->createNode(['type' => 'example_b', 'moderation_state' => 'archived']); + + // Configure the view to only show nodes in the 'archived' moderation state. + $edit['options[value][]'] = ['editorial-archived']; + $this->drupalPostForm("admin/structure/views/nojs/handler/$view_id/default/filter/moderation_state", $edit, 'Apply'); + $this->drupalPostForm("admin/structure/views/view/$view_id", [], 'Save'); + + // Check that only the archived nodes from both bundles are displayed by the + // view. + $view = Views::getView($view_id); + $this->executeView($view); + $this->assertIdenticalResultset($view, [['nid' => $archived_node_a->id()], ['nid' => $archived_node_b->id()]], ['nid' => 'nid']); + + // Remove the Editorial workflow from one of the bundles. + $this->drupalPostForm('admin/config/workflow/workflows/manage/editorial/type/node', [ + 'bundles[example_a]' => TRUE, + 'bundles[example_b]' => FALSE, + ], 'Save'); + \Drupal::service('entity_type.bundle.info')->clearCachedBundles(); + + $view = Views::getView($view_id); + $this->executeView($view); + $this->assertIdenticalResultset($view, [['nid' => $archived_node_a->id()]], ['nid' => 'nid']); + + // Check that the view can still be edited and saved without any + // intervention. + $this->drupalPostForm("admin/structure/views/view/$view_id", [], 'Save'); + + // Remove the Editorial workflow from both bundles. + $this->drupalPostForm('admin/config/workflow/workflows/manage/editorial/type/node', [ + 'bundles[example_a]' => FALSE, + 'bundles[example_b]' => FALSE, + ], 'Save'); + \Drupal::service('entity_type.bundle.info')->clearCachedBundles(); + + $view = Views::getView($view_id); + $this->executeView($view); + + // Check that the view doesn't return any result. + $this->assertEmpty($view->result); + + // Check that the view can not be edited without any intervention anymore + // because the user needs to fix the filter. + $this->drupalPostForm("admin/structure/views/view/$view_id", [], 'Save'); + $this->assertSession()->pageTextContains('No valid values found on filter: Content: Moderation state.'); + } + + /** + * Tests the content moderation state filter caching is correct. + */ + public function testFilterRenderCache() { + // Initially all states of the workflow are displayed. + $this->drupalPostForm('admin/config/workflow/workflows/manage/editorial/type/node', [ + 'bundles[example_a]' => TRUE, + ], 'Save'); + $this->assertFilterStates(['All', 'editorial-draft', 'editorial-published', 'editorial-archived']); + + // Adding a new state to the editorial workflow will display that state in + // the list of filters. + $this->drupalPostForm('admin/config/workflow/workflows/manage/editorial/add_state', [ + 'label' => 'Foo', + 'id' => 'foo', + ], 'Save'); + $this->assertFilterStates(['All', 'editorial-draft', 'editorial-published', 'editorial-archived', 'editorial-foo']); + + // Adding a second workflow to nodes will also show new states. + $this->drupalPostForm('admin/config/workflow/workflows/manage/new_workflow/type/node', [ + 'bundles[example_b]' => TRUE, + ], 'Save'); + $this->assertFilterStates(['All', 'editorial-draft', 'editorial-published', 'editorial-archived', 'editorial-foo', 'new_workflow-draft', 'new_workflow-published', 'new_workflow-bar']); + } + + /** + * Assert the states which appear in the filter. + * + * @param array $states + * The states which should appear in the filter. + */ + protected function assertFilterStates($states) { + $this->drupalGet('/filter-test-path'); + + $this->assertSession()->elementsCount('css', '#edit-default-revision-state option', count($states)); + foreach ($states as $state) { + $this->assertSession()->optionExists('default_revision_state', $state); + } + } + + /** + * Asserts the views dependencies on workflow config entities. + * + * @param string[] $workflow_ids + * An array of workflow IDs to check. + * @param \Drupal\views\ViewExecutable $view + * An executable View object. + */ + protected function assertWorkflowDependencies(array $workflow_ids, ViewExecutable $view) { + $dependencies = $view->getDependencies(); + + $expected = []; + foreach (Workflow::loadMultiple($workflow_ids) as $workflow) { + $expected[] = $workflow->getConfigDependencyName(); + } + + if ($expected) { + $this->assertSame($expected, $dependencies['config']); + } + else { + $this->assertTrue(!isset($dependencies['config'])); + } + } + +} diff --git a/core/modules/content_moderation/tests/src/Kernel/ViewsModerationStateFilterTest.php b/core/modules/content_moderation/tests/src/Kernel/ViewsModerationStateFilterTest.php new file mode 100644 index 0000000..6c40bc3 --- /dev/null +++ b/core/modules/content_moderation/tests/src/Kernel/ViewsModerationStateFilterTest.php @@ -0,0 +1,298 @@ +installEntitySchema('user'); + $this->installEntitySchema('node'); + $this->installEntitySchema('content_moderation_state'); + $this->installEntitySchema('entity_test_no_bundle'); + $this->installSchema('node', 'node_access'); + $this->installConfig('content_moderation_test_views'); + $this->installConfig('content_moderation'); + + $node_type = NodeType::create([ + 'type' => 'example', + ]); + $node_type->save(); + + $node_type = NodeType::create([ + 'type' => 'another_example', + ]); + $node_type->save(); + + $node_type = NodeType::create([ + 'type' => 'example_non_moderated', + ]); + $node_type->save(); + + ConfigurableLanguage::createFromLangcode('fr')->save(); + } + + /** + * Tests the content moderation state filter. + */ + public function testStateFilterViewsRelationship() { + $workflow = Workflow::load('editorial'); + $workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example'); + $workflow->getTypePlugin()->addState('translated_draft', 'Bar'); + $configuration = $workflow->getTypePlugin()->getConfiguration(); + $configuration['states']['translated_draft'] += [ + 'published' => FALSE, + 'default_revision' => FALSE, + ]; + $workflow->getTypePlugin()->setConfiguration($configuration); + $workflow->save(); + + // Create a published default revision and one forward draft revision. + $node = Node::create([ + 'type' => 'example', + 'title' => 'Test Node', + 'moderation_state' => 'published', + ]); + $node->save(); + $node->setNewRevision(); + $node->moderation_state = 'draft'; + $node->save(); + + // Create a draft default revision. + $second_node = Node::create([ + 'type' => 'example', + 'title' => 'Second Node', + 'moderation_state' => 'draft', + ]); + $second_node->save(); + + // Create a published default revision. + $third_node = Node::create([ + 'type' => 'example', + 'title' => 'Third node', + 'moderation_state' => 'published', + ]); + $third_node->save(); + + // Add a non-moderated node. + $fourth_node = Node::create([ + 'type' => 'example_non_moderated', + 'title' => 'Fourth node', + ]); + $fourth_node->save(); + + // Create a translated published revision. + $translated_forward_revision = $third_node->addTranslation('fr'); + $translated_forward_revision->title = 'Translated Node'; + $translated_forward_revision->setNewRevision(TRUE); + $translated_forward_revision->moderation_state = 'translated_draft'; + $translated_forward_revision->save(); + + // Four revisions for the nodes when no filter. + $this->assertNodesWithFilters([$node, $second_node, $third_node, $third_node], []); + + // The default revision of node one and three is published. + $this->assertNodesWithFilters([$node, $third_node], [ + 'default_revision_state' => 'editorial-published', + ]); + + // The default revision of node two is draft. + $this->assertNodesWithFilters([$second_node], [ + 'default_revision_state' => 'editorial-draft', + ]); + + // Test the same three revisions on a view displaying content revisions. + // Both nodes have one draft revision. + $this->assertNodesWithFilters([$node, $second_node], [ + 'moderation_state' => 'editorial-draft', + ], 'test_content_moderation_state_filter_revision_table'); + // Creating a new forward revision of node three, creates a second published + // revision of of the original language, hence there are two published + // revisions of node three. + $this->assertNodesWithFilters([$node, $third_node, $third_node], [ + 'moderation_state' => 'editorial-published', + ], 'test_content_moderation_state_filter_revision_table'); + // There is a single forward translated revision with a new state, which is + // also filterable. + $this->assertNodesWithFilters([$translated_forward_revision], [ + 'moderation_state' => 'editorial-translated_draft', + ], 'test_content_moderation_state_filter_revision_table'); + } + + /** + * Test the moderation filter with a non-translateable entity type. + */ + public function testNonTranslateableEntityType() { + $workflow = Workflow::load('editorial'); + $workflow->getTypePlugin()->addEntityTypeAndBundle('entity_test_no_bundle', 'entity_test_no_bundle'); + $workflow->save(); + + $test_entity = EntityTestNoBundle::create([ + 'moderation_state' => 'draft', + ]); + $test_entity->save(); + + $view = Views::getView('test_content_moderation_state_filter_entity_test'); + $view->setExposedInput([ + 'moderation_state' => 'editorial-draft', + ]); + $view->execute(); + $this->assertIdenticalResultset($view, [['id' => $test_entity->id()]], ['id' => 'id']); + } + + /** + * Tests the list of states in the filter plugin. + */ + public function testStateFilterStatesList() { + // By default a view of nodes will not have states to filter. + $this->assertPluginStates([]); + + // Adding a content type to the editorial workflow will enable all of the + // editorial states. + $workflow = Workflow::load('editorial'); + $workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example'); + $workflow->save(); + $this->assertPluginStates([ + 'Editorial' => [ + 'editorial-draft' => 'Draft', + 'editorial-published' => 'Published', + 'editorial-archived' => 'Archived', + ], + ]); + + // Adding a workflow which is not content moderation will not add any + // additional states to the views filter. + $workflow = Workflow::create(['id' => 'test', 'type' => 'workflow_type_complex_test']); + $workflow->getTypePlugin()->addState('draft', 'Draft'); + $workflow->save(); + $this->assertPluginStates([ + 'Editorial' => [ + 'editorial-draft' => 'Draft', + 'editorial-published' => 'Published', + 'editorial-archived' => 'Archived', + ], + ]); + + // Adding a new content moderation workflow will add additional states to + // filter. + $workflow = Workflow::create(['id' => 'moderation_test', 'type' => 'content_moderation', 'label' => 'Moderation test']); + $workflow->getTypePlugin()->addState('foo', 'Foo State'); + $workflow->getTypePlugin()->addEntityTypeAndBundle('node', 'example'); + $workflow->save(); + $this->assertPluginStates([ + 'Editorial' => [ + 'editorial-draft' => 'Draft', + 'editorial-published' => 'Published', + 'editorial-archived' => 'Archived', + ], + 'Moderation test' => [ + 'moderation_test-foo' => 'Foo State', + 'moderation_test-draft' => 'Draft', + 'moderation_test-published' => 'Published', + ], + ]); + + // Deleting a workflow will remove the states from the filter. + $workflow = Workflow::load('moderation_test'); + $workflow->delete(); + $this->assertPluginStates([ + 'Editorial' => [ + 'editorial-draft' => 'Draft', + 'editorial-published' => 'Published', + 'editorial-archived' => 'Archived', + ], + ]); + + // Deleting a state from a workflow will remove the state from the filter. + $workflow = Workflow::load('editorial'); + $workflow->getTypePlugin()->deleteState('archived'); + $workflow->save(); + $this->assertPluginStates([ + 'Editorial' => [ + 'editorial-draft' => 'Draft', + 'editorial-published' => 'Published', + ], + ]); + } + + /** + * Assert the plugin states. + * + * @param string[] $states + * The states which should appear in the filter. + */ + protected function assertPluginStates($states) { + $plugin = Views::pluginManager('filter')->createInstance('moderation_state_filter', []); + $view = Views::getView('test_content_moderation_state_filter'); + $plugin->init($view, $view->getDisplay()); + $this->assertEquals($states, $plugin->getValueOptions()); + } + + /** + * Assert the nodes appear when the test view is executed. + * + * @param \Drupal\node\NodeInterface[] $nodes + * Nodes to assert are in the views result. + * @param array $filters + * An array of filters to apply to the view. + * @param string $view_id + * The view to execute for the results. + */ + protected function assertNodesWithFilters(array $nodes, array $filters, $view_id = 'test_content_moderation_state_filter') { + $view = Views::getView($view_id); + $view->setExposedInput($filters); + $view->execute(); + + // Verify the join configuration. + $query = $view->getQuery(); + $join = $query->getTableInfo('content_moderation_state')['join']; + $configuration = $join->configuration; + $this->assertEquals('content_moderation_state_field_revision', $configuration['table']); + $this->assertEquals('content_entity_revision_id', $configuration['field']); + $this->assertEquals('vid', $configuration['left_field']); + $this->assertEquals('content_entity_type_id', $configuration['extra'][0]['field']); + $this->assertEquals('node', $configuration['extra'][0]['value']); + $this->assertEquals('langcode', $configuration['extra'][1]['field']); + $this->assertEquals('langcode', $configuration['extra'][1]['left_field']); + + $expected_result = []; + foreach ($nodes as $node) { + $expected_result[] = ['nid' => $node->id()]; + } + $this->assertIdenticalResultset($view, $expected_result, ['nid' => 'nid']); + } + +} diff --git a/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestNoBundle.php b/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestNoBundle.php index 5907021..0369a64 100644 --- a/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestNoBundle.php +++ b/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestNoBundle.php @@ -9,6 +9,9 @@ * id = "entity_test_no_bundle", * label = @Translation("Entity Test without bundle"), * base_table = "entity_test_no_bundle", + * handlers = { + * "views_data" = "Drupal\views\EntityViewsData" + * }, * entity_keys = { * "id" = "id", * "revision" = "revision_id", diff --git a/core/modules/views/src/Plugin/views/filter/InOperator.php b/core/modules/views/src/Plugin/views/filter/InOperator.php index cff42b9..1e6682d 100644 --- a/core/modules/views/src/Plugin/views/filter/InOperator.php +++ b/core/modules/views/src/Plugin/views/filter/InOperator.php @@ -229,7 +229,9 @@ protected function valueForm(&$form, FormStateInterface $form_state) { '#default_value' => $default_value, // These are only valid for 'select' type, but do no harm to checkboxes. '#multiple' => TRUE, - '#size' => count($options) > 8 ? 8 : count($options), + // The value options can be a multidimensional array if the value form + // type is a select list, so make sure that they are counted correctly. + '#size' => min(count($options, COUNT_RECURSIVE), 8), ]; $user_input = $form_state->getUserInput(); if ($exposed && !isset($user_input[$identifier])) { diff --git a/core/modules/workflows/src/WorkflowTypeInterface.php b/core/modules/workflows/src/WorkflowTypeInterface.php index 7509e7c..0cb47a4 100644 --- a/core/modules/workflows/src/WorkflowTypeInterface.php +++ b/core/modules/workflows/src/WorkflowTypeInterface.php @@ -124,7 +124,7 @@ public function hasState($state_id); * A list of state IDs to get. If NULL then all states will be returned. * * @return \Drupal\workflows\StateInterface[] - * An array of workflow states. + * An array of workflow states, keyed by state IDs. * * @throws \InvalidArgumentException * Thrown if $state_ids contains a state ID that does not exist.