Commit 543b3f4e authored by plach's avatar plach

Issue #2981887 by amateescu, joachim, jibran, chr.fritsch, Manuel Garcia,...

Issue #2981887 by amateescu, joachim, jibran, chr.fritsch, Manuel Garcia, timmillwood, plach, Wim Leers, Gábor Hojtsy, Berdir, jojototh, pameeela, dawehner, catch, Bojhan, Fabianx, Jo Fitzgerald: Add a publishing status to taxonomy terms
parent dbd37f5c
...@@ -14,6 +14,7 @@ ...@@ -14,6 +14,7 @@
use Drupal\options\Plugin\Field\FieldType\ListIntegerItem; use Drupal\options\Plugin\Field\FieldType\ListIntegerItem;
use Drupal\path\Plugin\Field\FieldType\PathItem; use Drupal\path\Plugin\Field\FieldType\PathItem;
use Drupal\Tests\rest\Functional\XmlNormalizationQuirksTrait; use Drupal\Tests\rest\Functional\XmlNormalizationQuirksTrait;
use Drupal\user\StatusItem;
/** /**
* Trait for EntityResourceTestBase subclasses testing $format='xml'. * Trait for EntityResourceTestBase subclasses testing $format='xml'.
...@@ -63,6 +64,9 @@ protected function applyXmlFieldDecodingQuirks(array $normalization) { ...@@ -63,6 +64,9 @@ protected function applyXmlFieldDecodingQuirks(array $normalization) {
for ($i = 0; $i < count($normalization[$field_name]); $i++) { for ($i = 0; $i < count($normalization[$field_name]); $i++) {
switch ($field->getItemDefinition()->getClass()) { switch ($field->getItemDefinition()->getClass()) {
case BooleanItem::class: case BooleanItem::class:
case StatusItem::class:
// @todo Remove the StatusItem case in
// https://www.drupal.org/project/drupal/issues/2936864.
$value = &$normalization[$field_name][$i]['value']; $value = &$normalization[$field_name][$i]['value'];
$value = $value === TRUE ? '1' : '0'; $value = $value === TRUE ? '1' : '0';
break; break;
......
...@@ -8,6 +8,8 @@ ...@@ -8,6 +8,8 @@
use Drupal\comment\CommentInterface; use Drupal\comment\CommentInterface;
use Drupal\KernelTests\KernelTestBase; use Drupal\KernelTests\KernelTestBase;
use Drupal\node\Entity\Node; use Drupal\node\Entity\Node;
use Drupal\taxonomy\Entity\Term;
use Drupal\taxonomy\Entity\Vocabulary;
use Drupal\node\NodeInterface; use Drupal\node\NodeInterface;
use Drupal\Tests\node\Traits\ContentTypeCreationTrait; use Drupal\Tests\node\Traits\ContentTypeCreationTrait;
use Drupal\Tests\user\Traits\UserCreationTrait; use Drupal\Tests\user\Traits\UserCreationTrait;
...@@ -30,7 +32,7 @@ class EntityReferenceSelectionAccessTest extends KernelTestBase { ...@@ -30,7 +32,7 @@ class EntityReferenceSelectionAccessTest extends KernelTestBase {
* *
* @var array * @var array
*/ */
public static $modules = ['comment', 'field', 'node', 'system', 'text', 'user']; public static $modules = ['comment', 'field', 'node', 'system', 'taxonomy', 'text', 'user'];
/** /**
* {@inheritdoc} * {@inheritdoc}
...@@ -43,9 +45,10 @@ protected function setUp() { ...@@ -43,9 +45,10 @@ protected function setUp() {
$this->installEntitySchema('comment'); $this->installEntitySchema('comment');
$this->installEntitySchema('node'); $this->installEntitySchema('node');
$this->installEntitySchema('taxonomy_term');
$this->installEntitySchema('user'); $this->installEntitySchema('user');
$this->installConfig(['comment', 'field', 'node', 'user']); $this->installConfig(['comment', 'field', 'node', 'taxonomy', 'user']);
// Create the anonymous and the admin users. // Create the anonymous and the admin users.
$anonymous_user = User::create([ $anonymous_user = User::create([
...@@ -534,4 +537,148 @@ public function testCommentHandler() { ...@@ -534,4 +537,148 @@ public function testCommentHandler() {
$this->assertReferenceable($selection_options, $referenceable_tests, 'Comment handler (comment + node admin)'); $this->assertReferenceable($selection_options, $referenceable_tests, 'Comment handler (comment + node admin)');
} }
/**
* Test the term-specific overrides of the selection handler.
*/
public function testTermHandler() {
// Create a 'Tags' vocabulary.
Vocabulary::create([
'name' => 'Tags',
'description' => $this->randomMachineName(),
'vid' => 'tags',
])->save();
$selection_options = [
'target_type' => 'taxonomy_term',
'handler' => 'default',
'target_bundles' => NULL,
];
// Build a set of test data.
$term_values = [
'published1' => [
'vid' => 'tags',
'status' => 1,
'name' => 'Term published1',
],
'published2' => [
'vid' => 'tags',
'status' => 1,
'name' => 'Term published2',
],
'unpublished' => [
'vid' => 'tags',
'status' => 0,
'name' => 'Term unpublished',
],
'published3' => [
'vid' => 'tags',
'status' => 1,
'name' => 'Term published3',
'parent' => 'unpublished',
],
'published4' => [
'vid' => 'tags',
'status' => 1,
'name' => 'Term published4',
'parent' => 'published3',
],
];
$terms = [];
$term_labels = [];
foreach ($term_values as $key => $values) {
$term = Term::create($values);
if (isset($values['parent'])) {
$term->parent->entity = $terms[$values['parent']];
}
$term->save();
$terms[$key] = $term;
$term_labels[$key] = Html::escape($term->label());
}
// Test as a non-admin.
$normal_user = $this->createUser(['access content']);
$this->setCurrentUser($normal_user);
$referenceable_tests = [
[
'arguments' => [
[NULL, 'CONTAINS'],
],
'result' => [
'tags' => [
$terms['published1']->id() => $term_labels['published1'],
$terms['published2']->id() => $term_labels['published2'],
],
],
],
[
'arguments' => [
['published1', 'CONTAINS'],
['Published1', 'CONTAINS'],
],
'result' => [
'tags' => [
$terms['published1']->id() => $term_labels['published1'],
],
],
],
[
'arguments' => [
['published2', 'CONTAINS'],
['Published2', 'CONTAINS'],
],
'result' => [
'tags' => [
$terms['published2']->id() => $term_labels['published2'],
],
],
],
[
'arguments' => [
['invalid term', 'CONTAINS'],
],
'result' => [],
],
[
'arguments' => [
['Term unpublished', 'CONTAINS'],
],
'result' => [],
],
];
$this->assertReferenceable($selection_options, $referenceable_tests, 'Term handler');
// Test as an admin.
$admin_user = $this->createUser(['access content', 'administer taxonomy']);
$this->setCurrentUser($admin_user);
$referenceable_tests = [
[
'arguments' => [
[NULL, 'CONTAINS'],
],
'result' => [
'tags' => [
$terms['published1']->id() => $term_labels['published1'],
$terms['published2']->id() => $term_labels['published2'],
$terms['unpublished']->id() => $term_labels['unpublished'],
$terms['published3']->id() => '-' . $term_labels['published3'],
$terms['published4']->id() => '--' . $term_labels['published4'],
],
],
],
[
'arguments' => [
['Term unpublished', 'CONTAINS'],
],
'result' => [
'tags' => [
$terms['unpublished']->id() => $term_labels['unpublished'],
],
],
],
];
$this->assertReferenceable($selection_options, $referenceable_tests, 'Term handler (admin)');
}
} }
...@@ -4,10 +4,12 @@ ...@@ -4,10 +4,12 @@
use Drupal\Core\Entity\ContentEntityBase; use Drupal\Core\Entity\ContentEntityBase;
use Drupal\Core\Entity\EntityChangedTrait; use Drupal\Core\Entity\EntityChangedTrait;
use Drupal\Core\Entity\EntityPublishedTrait;
use Drupal\Core\Entity\EntityStorageInterface; use Drupal\Core\Entity\EntityStorageInterface;
use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Field\BaseFieldDefinition; use Drupal\Core\Field\BaseFieldDefinition;
use Drupal\taxonomy\TermInterface; use Drupal\taxonomy\TermInterface;
use Drupal\user\StatusItem;
/** /**
* Defines the taxonomy term entity. * Defines the taxonomy term entity.
...@@ -45,7 +47,8 @@ ...@@ -45,7 +47,8 @@
* "bundle" = "vid", * "bundle" = "vid",
* "label" = "name", * "label" = "name",
* "langcode" = "langcode", * "langcode" = "langcode",
* "uuid" = "uuid" * "uuid" = "uuid",
* "published" = "status",
* }, * },
* bundle_entity_type = "taxonomy_vocabulary", * bundle_entity_type = "taxonomy_vocabulary",
* field_ui_base_route = "entity.taxonomy_vocabulary.overview_form", * field_ui_base_route = "entity.taxonomy_vocabulary.overview_form",
...@@ -62,6 +65,7 @@ ...@@ -62,6 +65,7 @@
class Term extends ContentEntityBase implements TermInterface { class Term extends ContentEntityBase implements TermInterface {
use EntityChangedTrait; use EntityChangedTrait;
use EntityPublishedTrait;
/** /**
* {@inheritdoc} * {@inheritdoc}
...@@ -116,6 +120,12 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { ...@@ -116,6 +120,12 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) {
/** @var \Drupal\Core\Field\BaseFieldDefinition[] $fields */ /** @var \Drupal\Core\Field\BaseFieldDefinition[] $fields */
$fields = parent::baseFieldDefinitions($entity_type); $fields = parent::baseFieldDefinitions($entity_type);
// Add the published field.
$fields += static::publishedBaseFieldDefinitions($entity_type);
// @todo Remove the usage of StatusItem in
// https://www.drupal.org/project/drupal/issues/2936864.
$fields['status']->getItemDefinition()->setClass(StatusItem::class);
$fields['tid']->setLabel(t('Term ID')) $fields['tid']->setLabel(t('Term ID'))
->setDescription(t('The term ID.')); ->setDescription(t('The term ID.'));
......
...@@ -59,10 +59,17 @@ public function getReferenceableEntities($match = NULL, $match_operator = 'CONTA ...@@ -59,10 +59,17 @@ public function getReferenceableEntities($match = NULL, $match_operator = 'CONTA
$bundles = $this->entityManager->getBundleInfo('taxonomy_term'); $bundles = $this->entityManager->getBundleInfo('taxonomy_term');
$bundle_names = $this->getConfiguration()['target_bundles'] ?: array_keys($bundles); $bundle_names = $this->getConfiguration()['target_bundles'] ?: array_keys($bundles);
$has_admin_access = $this->currentUser->hasPermission('administer taxonomy');
$unpublished_terms = [];
foreach ($bundle_names as $bundle) { foreach ($bundle_names as $bundle) {
if ($vocabulary = Vocabulary::load($bundle)) { if ($vocabulary = Vocabulary::load($bundle)) {
/** @var \Drupal\taxonomy\TermInterface[] $terms */
if ($terms = $this->entityManager->getStorage('taxonomy_term')->loadTree($vocabulary->id(), 0, NULL, TRUE)) { if ($terms = $this->entityManager->getStorage('taxonomy_term')->loadTree($vocabulary->id(), 0, NULL, TRUE)) {
foreach ($terms as $term) { foreach ($terms as $term) {
if (!$has_admin_access && (!$term->isPublished() || in_array($term->parent->target_id, $unpublished_terms))) {
$unpublished_terms[] = $term->id();
continue;
}
$options[$vocabulary->id()][$term->id()] = str_repeat('-', $term->depth) . Html::escape($this->entityManager->getTranslationFromContext($term)->label()); $options[$vocabulary->id()][$term->id()] = str_repeat('-', $term->depth) . Html::escape($this->entityManager->getTranslationFromContext($term)->label());
} }
} }
...@@ -72,4 +79,63 @@ public function getReferenceableEntities($match = NULL, $match_operator = 'CONTA ...@@ -72,4 +79,63 @@ public function getReferenceableEntities($match = NULL, $match_operator = 'CONTA
return $options; return $options;
} }
/**
* {@inheritdoc}
*/
public function countReferenceableEntities($match = NULL, $match_operator = 'CONTAINS') {
if ($match) {
return parent::countReferenceableEntities($match, $match_operator);
}
$total = 0;
$referenceable_entities = $this->getReferenceableEntities($match, $match_operator, 0);
foreach ($referenceable_entities as $bundle => $entities) {
$total += count($entities);
}
return $total;
}
/**
* {@inheritdoc}
*/
protected function buildEntityQuery($match = NULL, $match_operator = 'CONTAINS') {
$query = parent::buildEntityQuery($match, $match_operator);
// Adding the 'taxonomy_term_access' tag is sadly insufficient for terms:
// core requires us to also know about the concept of 'published' and
// 'unpublished'.
if (!$this->currentUser->hasPermission('administer taxonomy')) {
$query->condition('status', 1);
}
return $query;
}
/**
* {@inheritdoc}
*/
public function createNewEntity($entity_type_id, $bundle, $label, $uid) {
$term = parent::createNewEntity($entity_type_id, $bundle, $label, $uid);
// In order to create a referenceable term, it needs to published.
/** @var \Drupal\taxonomy\TermInterface $term */
$term->setPublished();
return $term;
}
/**
* {@inheritdoc}
*/
public function validateReferenceableNewEntities(array $entities) {
$entities = parent::validateReferenceableNewEntities($entities);
// Mirror the conditions checked in buildEntityQuery().
if (!$this->currentUser->hasPermission('administer taxonomy')) {
$entities = array_filter($entities, function ($term) {
/** @var \Drupal\taxonomy\TermInterface $term */
return $term->isPublished();
});
}
return $entities;
}
} }
...@@ -18,19 +18,37 @@ class TermAccessControlHandler extends EntityAccessControlHandler { ...@@ -18,19 +18,37 @@ class TermAccessControlHandler extends EntityAccessControlHandler {
* {@inheritdoc} * {@inheritdoc}
*/ */
protected function checkAccess(EntityInterface $entity, $operation, AccountInterface $account) { protected function checkAccess(EntityInterface $entity, $operation, AccountInterface $account) {
if ($account->hasPermission('administer taxonomy')) {
return AccessResult::allowed()->cachePerPermissions();
}
switch ($operation) { switch ($operation) {
case 'view': case 'view':
return AccessResult::allowedIfHasPermission($account, 'access content'); $access_result = AccessResult::allowedIf($account->hasPermission('access content') && $entity->isPublished())
->cachePerPermissions()
->addCacheableDependency($entity);
if (!$access_result->isAllowed()) {
$access_result->setReason("The 'access content' permission is required and the taxonomy term must be published.");
}
return $access_result;
case 'update': case 'update':
return AccessResult::allowedIfHasPermissions($account, ["edit terms in {$entity->bundle()}", 'administer taxonomy'], 'OR'); if ($account->hasPermission("edit terms in {$entity->bundle()}")) {
return AccessResult::allowed()->cachePerPermissions();
}
return AccessResult::neutral()->setReason("The following permissions are required: 'edit terms in {$entity->bundle()}' OR 'administer taxonomy'.");
case 'delete': case 'delete':
return AccessResult::allowedIfHasPermissions($account, ["delete terms in {$entity->bundle()}", 'administer taxonomy'], 'OR'); if ($account->hasPermission("delete terms in {$entity->bundle()}")) {
return AccessResult::allowed()->cachePerPermissions();
}
return AccessResult::neutral()->setReason("The following permissions are required: 'delete terms in {$entity->bundle()}' OR 'administer taxonomy'.");
default: default:
// No opinion. // No opinion.
return AccessResult::neutral(); return AccessResult::neutral()->cachePerPermissions();
} }
} }
......
...@@ -4,11 +4,12 @@ ...@@ -4,11 +4,12 @@
use Drupal\Core\Entity\ContentEntityInterface; use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityChangedInterface; use Drupal\Core\Entity\EntityChangedInterface;
use Drupal\Core\Entity\EntityPublishedInterface;
/** /**
* Provides an interface defining a taxonomy term entity. * Provides an interface defining a taxonomy term entity.
*/ */
interface TermInterface extends ContentEntityInterface, EntityChangedInterface { interface TermInterface extends ContentEntityInterface, EntityChangedInterface, EntityPublishedInterface {
/** /**
* Gets the term's description. * Gets the term's description.
......
...@@ -5,6 +5,8 @@ ...@@ -5,6 +5,8 @@
* Install, update and uninstall functions for the taxonomy module. * Install, update and uninstall functions for the taxonomy module.
*/ */
use Drupal\Core\Field\BaseFieldDefinition;
/** /**
* Convert the custom taxonomy term hierarchy storage to a default storage. * Convert the custom taxonomy term hierarchy storage to a default storage.
*/ */
...@@ -126,3 +128,50 @@ function taxonomy_update_8503() { ...@@ -126,3 +128,50 @@ function taxonomy_update_8503() {
} }
} }
} }
/**
* Add the publishing status fields to taxonomy terms.
*/
function taxonomy_update_8601() {
$definition_update_manager = \Drupal::entityDefinitionUpdateManager();
$entity_type = $definition_update_manager->getEntityType('taxonomy_term');
// Bail out early if a field named 'status' is already installed.
if ($definition_update_manager->getFieldStorageDefinition('status', 'taxonomy_term')) {
return t('The publishing status field has <strong>not</strong> been added to taxonomy terms. See <a href=":link">this page</a> for more information on how to install it.', [
':link' => 'https://www.drupal.org/node/2985366',
]);
}
// Add the 'published' entity key to the taxonomy_term entity type.
$entity_keys = $entity_type->getKeys();
$entity_keys['published'] = 'status';
$entity_type->set('entity_keys', $entity_keys);
$definition_update_manager->updateEntityType($entity_type);
// Add the status field.
$status = BaseFieldDefinition::create('boolean')
->setLabel(t('Publishing status'))
->setDescription(t('A boolean indicating the published state.'))
->setRevisionable(TRUE)
->setTranslatable(TRUE)
->setDefaultValue(TRUE);
$has_content_translation_status_field = \Drupal::moduleHandler()->moduleExists('content_translation') && $definition_update_manager->getFieldStorageDefinition('content_translation_status', 'taxonomy_term');
if ($has_content_translation_status_field) {
$status->setInitialValueFromField('content_translation_status', TRUE);
}
else {
$status->setInitialValue(TRUE);
}
$definition_update_manager->installFieldStorageDefinition('status', 'taxonomy_term', 'taxonomy_term', $status);
// Uninstall the 'content_translation_status' field if needed.
if ($has_content_translation_status_field) {
$content_translation_status = $definition_update_manager->getFieldStorageDefinition('content_translation_status', 'taxonomy_term');
$definition_update_manager->uninstallFieldStorageDefinition($content_translation_status);
}
return t('The publishing status field has been added to taxonomy terms.');
}
...@@ -5,6 +5,9 @@ ...@@ -5,6 +5,9 @@
* Post update functions for Taxonomy. * Post update functions for Taxonomy.
*/ */
use Drupal\Core\Config\Entity\ConfigEntityUpdater;
use Drupal\views\ViewExecutable;
/** /**
* Clear caches due to updated taxonomy entity views data. * Clear caches due to updated taxonomy entity views data.
*/ */
...@@ -18,3 +21,107 @@ function taxonomy_post_update_clear_views_data_cache() { ...@@ -18,3 +21,107 @@ function taxonomy_post_update_clear_views_data_cache() {
function taxonomy_post_update_clear_entity_bundle_field_definitions_cache() { function taxonomy_post_update_clear_entity_bundle_field_definitions_cache() {
// An empty update will flush caches. // An empty update will flush caches.
} }
/**
* Add a 'published' = TRUE filter for all Taxonomy term views and converts
* existing ones that were using the 'content_translation_status' field.
*/
function taxonomy_post_update_handle_publishing_status_addition_in_views(&$sandbox = NULL) {
$definition_update_manager = \Drupal::entityDefinitionUpdateManager();
$entity_type = $definition_update_manager->getEntityType('taxonomy_term');
$published_key = $entity_type->getKey('published');
$status_filter = [
'id' => 'status',
'table' => 'taxonomy_term_field_data',
'field' => $published_key,
'relationship' => 'none',
'group_type' => 'group',
'admin_label' => '',
'operator' => '=',
'value' => '1',
'group' => 1,
'exposed' => FALSE,
'expose' => [
'operator_id' => '',
'label' => '',
'description' => '',
'use_operator' => FALSE,
'operator' => '',
'identifier' => '',
'required' => FALSE,
'remember' => FALSE,
'multiple' => FALSE,
'remember_roles' => [
'authenticated' => 'authenticated',
'anonymous' => '0',
'administrator' => '0',
],
],
'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' => 'taxonomy_term',
'entity_field' => $published_key,
'plugin_id' => 'boolean',
];
\Drupal::classResolver(ConfigEntityUpdater::class)->update($sandbox, 'view', function ($view) use ($published_key, $status_filter) {
/** @var \Drupal\views\ViewEntityInterface $view */
// Only alter taxonomy term views.
if ($view->get('base_table') !== 'taxonomy_term_field_data') {
return FALSE;
}
$displays = $view->get('display');
foreach ($displays as $display_name => &$display) {
// Update any existing 'content_translation_status fields.
$fields = isset($display['display_options']['fields']) ? $display['display_options']['fields'] : [];
foreach ($fields as $id => $field) {
if (isset($field['field']) && $field['field'] == 'content_translation_status') {
$fields[$id]['field'] = $published_key;
}
}
$display['display_options']['fields'] = $fields;
// Update any existing 'content_translation_status sorts.
$sorts = isset($display['display_options']['sorts']) ? $display['display_options']['sorts'] : [];
foreach ($sorts as $id => $sort) {
if (isset($sort['field']) && $sort['field'] == 'content_translation_status') {
$sorts[$id]['field'] = $published_key;
}
}
$display['display_options']['sorts'] = $sorts;
// Update any existing 'content_translation_status' filters or add a new
// one if necessary.
$filters = isset($display['display_options']['filters']) ? $display['display_options']['filters'] : [];
$has_status_filter = FALSE;
foreach ($filters as $id => $filter) {
if (isset($filter['field']) && $filter['field'] == 'content_translation_status') {
$filters[$id]['field'] = $published_key;
$has_status_filter = TRUE;
}
}
if (!$has_status_filter) {
$status_filter['id'] = ViewExecutable::generateHandlerId($published_key, $filters);
$filters[$status_filter['id']] = $status_filter;
}
$display['display_options']['filters'] = $filters;
}
$view->set('display', $displays);
return TRUE;
});
}
<?php
/**
* @file
* Contains database additions to drupal-8.filled.standard.php.gz for testing
* the upgrade path of https://www.drupal.org/project/drupal/issues/2981887.
*/
use Drupal\Core\Database\Database;
use Drupal\Core\Serialization\Yaml;
$connection = Database::getConnection();
$view_file = __DIR__ . '/views.view.test_taxonomy_term_view_with_content_translation_status.yml';
$view_with_cts_config = Yaml::decode(file_get_contents($view_file));
$view_file = __DIR__ . '/views.view.test_taxonomy_term_view_without_content_translation_status.yml';
$view_without_cts_config = Yaml::decode(file_get_contents($view_file));
$connection->insert('config')
->fields(['collection', 'name', 'data'])
->values([
'collection' => '',
'name' => 'views.view.test_taxonomy_term_view_with_content_translation_status',
'data' => serialize($view_with_cts_config),
])
->values([
'collection' => '',
'name' => 'views.view.test_taxonomy_term_view_without_content_translation_status',
'data' => serialize($view_without_cts_config),
])
->execute();