Commit 05af46b2 authored by webchick's avatar webchick
Browse files

Issue #2484037 by plach, Gábor Hojtsy, YesCT, dawehner: Make Views bulk...

Issue #2484037 by plach, Gábor Hojtsy, YesCT, dawehner: Make Views bulk operations entity translation aware
parent 53065cb5
......@@ -8,7 +8,6 @@
namespace Drupal\Core\Action;
use Drupal\Core\Plugin\PluginBase;
use Drupal\Core\Action\ActionInterface;
/**
* Provides a base implementation for an Action plugin.
......
......@@ -972,20 +972,22 @@ public function getTranslationFromContext(EntityInterface $entity, $langcode = N
}
// Retrieve language fallback candidates to perform the entity language
// negotiation.
$context['data'] = $entity;
$context += array('operation' => 'entity_view', 'langcode' => $langcode);
$candidates = $this->languageManager->getFallbackCandidates($context);
// Ensure the default language has the proper language code.
$default_language = $entity->getUntranslated()->language();
$candidates[$default_language->getId()] = LanguageInterface::LANGCODE_DEFAULT;
// Return the most fitting entity translation.
foreach ($candidates as $candidate) {
if ($entity->hasTranslation($candidate)) {
$translation = $entity->getTranslation($candidate);
break;
// negotiation, unless the current translation is already the desired one.
if ($entity->language()->getId() != $langcode) {
$context['data'] = $entity;
$context += array('operation' => 'entity_view', 'langcode' => $langcode);
$candidates = $this->languageManager->getFallbackCandidates($context);
// Ensure the default language has the proper language code.
$default_language = $entity->getUntranslated()->language();
$candidates[$default_language->getId()] = LanguageInterface::LANGCODE_DEFAULT;
// Return the most fitting entity translation.
foreach ($candidates as $candidate) {
if ($entity->hasTranslation($candidate)) {
$translation = $entity->getTranslation($candidate);
break;
}
}
}
}
......
......@@ -9,12 +9,11 @@
use Drupal\Core\Entity\EntityManagerInterface;
use Drupal\Core\Form\ConfirmFormBase;
use Drupal\Core\Url;
use Drupal\Component\Utility\SafeMarkup;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Url;
use Drupal\user\PrivateTempStoreFactory;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
/**
* Provides a node deletion confirmation form.
......@@ -24,9 +23,9 @@ class DeleteMultiple extends ConfirmFormBase {
/**
* The array of nodes to delete.
*
* @var array
* @var string[][]
*/
protected $nodes = array();
protected $nodeInfo = array();
/**
* The tempstore factory.
......@@ -76,7 +75,7 @@ public function getFormId() {
* {@inheritdoc}
*/
public function getQuestion() {
return $this->formatPlural(count($this->nodes), 'Are you sure you want to delete this item?', 'Are you sure you want to delete these items?');
return $this->formatPlural(count($this->nodeInfo), 'Are you sure you want to delete this item?', 'Are you sure you want to delete these items?');
}
/**
......@@ -97,16 +96,48 @@ public function getConfirmText() {
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state) {
$this->nodes = $this->tempStoreFactory->get('node_multiple_delete_confirm')->get(\Drupal::currentUser()->id());
if (empty($this->nodes)) {
$this->nodeInfo = $this->tempStoreFactory->get('node_multiple_delete_confirm')->get(\Drupal::currentUser()->id());
if (empty($this->nodeInfo)) {
return new RedirectResponse($this->getCancelUrl()->setAbsolute()->toString());
}
/** @var \Drupal\node\NodeInterface[] $nodes */
$nodes = $this->storage->loadMultiple(array_keys($this->nodeInfo));
$items = [];
foreach ($this->nodeInfo as $id => $langcodes) {
foreach ($langcodes as $langcode) {
$node = $nodes[$id]->getTranslation($langcode);
$key = $id . ':' . $langcode;
$default_key = $id . ':' . $node->getUntranslated()->language()->getId();
// If we have a translated entity we build a nested list of translations
// that will be deleted.
$languages = $node->getTranslationLanguages();
if (count($languages) > 1 && $node->isDefaultTranslation()) {
$names = [];
foreach ($languages as $translation_langcode => $language) {
$names[] = $language->getName();
unset($items[$id . ':' . $translation_langcode]);
}
$items[$default_key] = [
'label' => [
'#markup' => $this->t('@label (Original translation) - <em>The following content translations will be deleted:</em>', ['@label' => $node->label()]),
],
'deleted_translations' => [
'#theme' => 'item_list',
'#items' => $names,
],
];
}
elseif (!isset($items[$default_key])) {
$items[$key] = $node->label();
}
}
}
$form['nodes'] = array(
'#theme' => 'item_list',
'#items' => array_map(function ($node) {
return SafeMarkup::checkPlain($node->label());
}, $this->nodes),
'#items' => $items,
);
$form = parent::buildForm($form, $form_state);
......@@ -117,13 +148,56 @@ public function buildForm(array $form, FormStateInterface $form_state) {
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
if ($form_state->getValue('confirm') && !empty($this->nodes)) {
$this->storage->delete($this->nodes);
if ($form_state->getValue('confirm') && !empty($this->nodeInfo)) {
$total_count = 0;
$delete_nodes = [];
/** @var \Drupal\Core\Entity\ContentEntityInterface[][] $delete_translations */
$delete_translations = [];
/** @var \Drupal\node\NodeInterface[] $nodes */
$nodes = $this->storage->loadMultiple(array_keys($this->nodeInfo));
foreach ($this->nodeInfo as $id => $langcodes) {
foreach ($langcodes as $langcode) {
$node = $nodes[$id]->getTranslation($langcode);
if ($node->isDefaultTranslation()) {
$delete_nodes[$id] = $node;
unset($delete_translations[$id]);
$total_count += count($node->getTranslationLanguages());
}
elseif (!isset($delete_nodes[$id])) {
$delete_translations[$id][] = $node;
}
}
}
if ($delete_nodes) {
$this->storage->delete($delete_nodes);
$this->logger('content')->notice('Deleted @count posts.', array('@count' => count($delete_nodes)));
}
if ($delete_translations) {
$count = 0;
foreach ($delete_translations as $id => $translations) {
$node = $nodes[$id]->getUntranslated();
foreach ($translations as $translation) {
$node->removeTranslation($translation->language()->getId());
}
$node->save();
$count += count($translations);
}
if ($count) {
$total_count += $count;
$this->logger('content')->notice('Deleted @count content translations.', array('@count' => $count));
}
}
if ($total_count) {
drupal_set_message($this->formatPlural($total_count, 'Deleted 1 post.', 'Deleted @count posts.'));
}
$this->tempStoreFactory->get('node_multiple_delete_confirm')->delete(\Drupal::currentUser()->id());
$count = count($this->nodes);
$this->logger('content')->notice('Deleted @count posts.', array('@count' => $count));
drupal_set_message($this->formatPlural($count, 'Deleted 1 post.', 'Deleted @count posts.'));
}
$form_state->setRedirect('system.admin_content');
}
......
......@@ -77,7 +77,13 @@ public static function create(ContainerInterface $container, array $configuratio
* {@inheritdoc}
*/
public function executeMultiple(array $entities) {
$this->tempStore->set($this->currentUser->id(), $entities);
$info = [];
/** @var \Drupal\node\NodeInterface $node */
foreach ($entities as $node) {
$langcode = $node->language()->getId();
$info[$node->id()][$langcode] = $langcode;
}
$this->tempStore->set($this->currentUser->id(), $info);
}
/**
......
......@@ -25,6 +25,9 @@ class SaveNode extends ActionBase {
* {@inheritdoc}
*/
public function execute($entity = NULL) {
// We need to change at least one value, otherwise the changed timestamp
// will not be updated.
$entity->changed = 0;
$entity->save();
}
......
......@@ -7,7 +7,9 @@
namespace Drupal\node\Tests\Views;
use Drupal\node\Entity\Node;
use Drupal\Component\Utility\SafeMarkup;
use Drupal\language\Entity\ConfigurableLanguage;
use Drupal\views\Views;
/**
* Tests a node bulk form.
......@@ -17,6 +19,13 @@
*/
class BulkFormTest extends NodeTestBase {
/**
* Modules to be enabled.
*
* @var array
*/
public static $modules = array('node_test_views', 'language');
/**
* Views used by this test.
*
......@@ -25,30 +34,87 @@ class BulkFormTest extends NodeTestBase {
public static $testViews = array('test_node_bulk_form');
/**
* Tests the node bulk form.
* The test nodes.
*
* @var \Drupal\node\NodeInterface[]
*/
public function testBulkForm() {
$node_storage = $this->container->get('entity.manager')->getStorage('node');
$this->drupalLogin($this->drupalCreateUser(array('administer nodes', 'access content overview', 'bypass node access')));
$node = $this->drupalCreateNode(array(
'promote' => FALSE,
));
protected $nodes;
/**
* {@inheritdoc}
*/
protected function setUp($import_test_views = TRUE) {
parent::setUp($import_test_views);
ConfigurableLanguage::createFromLangcode('fr')->save();
ConfigurableLanguage::createFromLangcode('it')->save();
// Create some test nodes.
$this->nodes = [];
$langcodes = ['en', 'fr', 'it'];
for ($i = 1; $i <= 5; $i++) {
$langcode = $langcodes[($i - 1) % 3];
$values = [
'title' => $this->randomMachineName() . ' [' . $i . ':' . $langcode . ']',
'langcode' => $langcode,
'promote' => FALSE,
];
$node = $this->drupalCreateNode($values);
$this->pass(SafeMarkup::format('Node %title created with language %langcode.', ['%title' => $node->label(), '%langcode' => $node->language()->getId()]));
$this->nodes[] = $node;
}
// Create translations for all languages for some nodes.
for ($i = 0; $i < 2; $i++) {
$node = $this->nodes[$i];
foreach ($langcodes as $langcode) {
if (!$node->hasTranslation($langcode)) {
$title = $this->randomMachineName() . ' [' . $node->id() . ':' . $langcode . ']';
$translation = $node->addTranslation($langcode, ['title' => $title, 'promote' => FALSE]);
$this->pass(SafeMarkup::format('Translation %title created with language %langcode.', ['%title' => $translation->label(), '%langcode' => $translation->language()->getId()]));
}
}
$node->save();
}
// Create a node with only one translation.
$node = $this->nodes[2];
$langcode = 'en';
$title = $this->randomMachineName() . ' [' . $node->id() . ':' . $langcode . ']';
$translation = $node->addTranslation($langcode, ['title' => $title]);
$this->pass(SafeMarkup::format('Translation %title created with language %langcode.', ['%title' => $translation->label(), '%langcode' => $translation->language()->getId()]));
$node->save();
// Check that all created translations are selected by the test view.
$view = Views::getView('test_node_bulk_form');
$view->execute();
$this->assertEqual(count($view->result), 10, 'All created translations are selected.');
// Check the operations are accessible to the logged in user.
$this->drupalLogin($this->drupalCreateUser(array('administer nodes', 'access content overview', 'bypass node access')));
$this->drupalGet('test-node-bulk-form');
$elements = $this->xpath('//select[@id="edit-action"]//option');
$this->assertIdentical(count($elements), 8, 'All node operations are found.');
}
/**
* Tests the node bulk form.
*/
public function testBulkForm() {
// Unpublish a node using the bulk form.
$node = reset($this->nodes);
$this->assertTrue($node->isPublished(), 'Node is initially published');
$this->assertTrue($node->getTranslation('fr')->isPublished(), 'Node translation is published');
$this->assertTrue($node->getTranslation('it')->isPublished(), 'Node translation is published');
$edit = array(
'node_bulk_form[0]' => TRUE,
'action' => 'node_unpublish_action',
);
$this->drupalPostForm(NULL, $edit, t('Apply'));
// Re-load the node and check the status.
$node_storage->resetCache(array($node->id()));
$node = $node_storage->load($node->id());
$node = $this->loadNode($node->id());
$this->assertFalse($node->isPublished(), 'Node has been unpublished');
$this->assertTrue($node->getTranslation('fr')->isPublished(), 'Node translation has not been unpublished');
$this->assertTrue($node->getTranslation('it')->isPublished(), 'Node translation has not been unpublished');
// Publish action.
$edit = array(
......@@ -56,22 +122,22 @@ public function testBulkForm() {
'action' => 'node_publish_action',
);
$this->drupalPostForm(NULL, $edit, t('Apply'));
// Re-load the node and check the status.
$node_storage->resetCache(array($node->id()));
$node = $node_storage->load($node->id());
$this->assertTrue($node->isPublished(), 'Node has been published');
$node = $this->loadNode($node->id());
$this->assertTrue($node->isPublished(), 'Node has been published again');
// Make sticky action.
$this->assertFalse($node->isSticky(), 'Node is not sticky');
$this->assertFalse($node->getTranslation('fr')->isSticky(), 'Node translation is not sticky');
$this->assertFalse($node->getTranslation('it')->isSticky(), 'Node translation is not sticky');
$edit = array(
'node_bulk_form[0]' => TRUE,
'action' => 'node_make_sticky_action',
);
$this->drupalPostForm(NULL, $edit, t('Apply'));
// Re-load the node and check the sticky flag.
$node_storage->resetCache(array($node->id()));
$node = $node_storage->load($node->id());
$this->assertTrue($node->isSticky(), 'Node has been made sticky');
$node = $this->loadNode($node->id());
$this->assertTrue($node->isPublished(), 'Node has been made sticky');
$this->assertFalse($node->getTranslation('fr')->isSticky(), 'Node translation has not been made sticky');
$this->assertFalse($node->getTranslation('it')->isSticky(), 'Node translation has not been made sticky');
// Make unsticky action.
$edit = array(
......@@ -79,22 +145,22 @@ public function testBulkForm() {
'action' => 'node_make_unsticky_action',
);
$this->drupalPostForm(NULL, $edit, t('Apply'));
// Re-load the node and check the sticky flag.
$node_storage->resetCache(array($node->id()));
$node = $node_storage->load($node->id());
$node = $this->loadNode($node->id());
$this->assertFalse($node->isSticky(), 'Node is not sticky anymore');
// Promote to front page.
$this->assertFalse($node->isPromoted(), 'Node is not promoted to the front page');
$this->assertFalse($node->getTranslation('fr')->isPromoted(), 'Node translation is not promoted to the front page');
$this->assertFalse($node->getTranslation('it')->isPromoted(), 'Node translation is not promoted to the front page');
$edit = array(
'node_bulk_form[0]' => TRUE,
'action' => 'node_promote_action',
);
$this->drupalPostForm(NULL, $edit, t('Apply'));
// Re-load the node and check the promoted flag.
$node_storage->resetCache(array($node->id()));
$node = $node_storage->load($node->id());
$node = $this->loadNode($node->id());
$this->assertTrue($node->isPromoted(), 'Node has been promoted to the front page');
$this->assertFalse($node->getTranslation('fr')->isPromoted(), 'Node translation has not been promoted to the front page');
$this->assertFalse($node->getTranslation('it')->isPromoted(), 'Node translation has not been promoted to the front page');
// Demote from front page.
$edit = array(
......@@ -102,22 +168,113 @@ public function testBulkForm() {
'action' => 'node_unpromote_action',
);
$this->drupalPostForm(NULL, $edit, t('Apply'));
// Re-load the node and check the promoted flag.
$node_storage->resetCache(array($node->id()));
$node = $node_storage->load($node->id());
$node = $this->loadNode($node->id());
$this->assertFalse($node->isPromoted(), 'Node has been demoted');
// Delete node.
// Select a bunch of translated and untranslated nodes and check that
// operations are always applied to individual translations.
$edit = array(
'node_bulk_form[0]' => TRUE,
// Original and all translations.
'node_bulk_form[0]' => TRUE, // Node 1, English, original.
'node_bulk_form[1]' => TRUE, // Node 1, French.
'node_bulk_form[2]' => TRUE, // Node 1, Italian.
// Original and only one translation.
'node_bulk_form[3]' => TRUE, // Node 2, English.
'node_bulk_form[4]' => TRUE, // Node 2, French, original.
'node_bulk_form[5]' => FALSE, // Node 2, Italian.
// Only a single translation.
'node_bulk_form[6]' => TRUE, // Node 3, English.
'node_bulk_form[7]' => FALSE, // Node 3, Italian, original.
// Only a single untranslated node.
'node_bulk_form[8]' => TRUE, // Node 4, English, untranslated.
'node_bulk_form[9]' => FALSE, // Node 5, French, untranslated.
'action' => 'node_unpublish_action',
);
$this->drupalPostForm(NULL, $edit, t('Apply'));
$node = $this->loadNode(1);
$this->assertFalse($node->getTranslation('en')->isPublished(), '1: English translation has been unpublished');
$this->assertFalse($node->getTranslation('fr')->isPublished(), '1: French translation has been unpublished');
$this->assertFalse($node->getTranslation('it')->isPublished(), '1: Italian translation has been unpublished');
$node = $this->loadNode(2);
$this->assertFalse($node->getTranslation('en')->isPublished(), '2: English translation has been unpublished');
$this->assertFalse($node->getTranslation('fr')->isPublished(), '2: French translation has been unpublished');
$this->assertTrue($node->getTranslation('it')->isPublished(), '2: Italian translation has not been unpublished');
$node = $this->loadNode(3);
$this->assertFalse($node->getTranslation('en')->isPublished(), '3: English translation has been unpublished');
$this->assertTrue($node->getTranslation('it')->isPublished(), '3: Italian translation has not been unpublished');
$node = $this->loadNode(4);
$this->assertFalse($node->isPublished(), '4: Node has been unpublished');
$node = $this->loadNode(5);
$this->assertTrue($node->isPublished(), '5: Node has not been unpublished');
}
/**
* Test multiple deletion.
*/
public function testBulkDeletion() {
// Select a bunch of translated and untranslated nodes and check that
// nodes and individual translations are properly deleted.
$edit = array(
// Original and all translations.
'node_bulk_form[0]' => TRUE, // Node 1, English, original.
'node_bulk_form[1]' => TRUE, // Node 1, French.
'node_bulk_form[2]' => TRUE, // Node 1, Italian.
// Original and only one translation.
'node_bulk_form[3]' => TRUE, // Node 2, English.
'node_bulk_form[4]' => TRUE, // Node 2, French, original.
'node_bulk_form[5]' => FALSE, // Node 2, Italian.
// Only a single translation.
'node_bulk_form[6]' => TRUE, // Node 3, English.
'node_bulk_form[7]' => FALSE, // Node 3, Italian, original.
// Only a single untranslated node.
'node_bulk_form[8]' => TRUE, // Node 4, English, untranslated.
'node_bulk_form[9]' => FALSE, // Node 5, French, untranslated.
'action' => 'node_delete_action',
);
$this->drupalPostForm(NULL, $edit, t('Apply'));
$label = $this->loadNode(1)->label();
$this->assertText("$label (Original translation) - The following content translations will be deleted:");
$label = $this->loadNode(2)->label();
$this->assertText("$label (Original translation) - The following content translations will be deleted:");
$label = $this->loadNode(3)->getTranslation('en')->label();
$this->assertText($label);
$this->assertNoText("$label (Original translation) - The following content translations will be deleted:");
$label = $this->loadNode(4)->label();
$this->assertText($label);
$this->assertNoText("$label (Original translation) - The following content translations will be deleted:");
$this->drupalPostForm(NULL, array(), t('Delete'));
// Re-load the node and check if it has been deleted.
$node_storage->resetCache(array($node->id()));
$node = $node_storage->load($node->id());
$this->assertNull($node, 'Node has been deleted');
$node = $this->loadNode(1);
$this->assertNull($node, '1: Node has been deleted');
$node = $this->loadNode(2);
$this->assertNull($node, '2: Node has been deleted');
$node = $this->loadNode(3);
$result = count($node->getTranslationLanguages()) && $node->language()->getId() == 'it';
$this->assertTrue($result, '3: English translation has been deleted');
$node = $this->loadNode(4);
$this->assertNull($node, '4: Node has been deleted');
$node = $this->loadNode(5);
$this->assertTrue($node, '5: Node has not been deleted');
$this->assertText('Deleted 8 posts.');
}
/**
* Load the specified node from the storage.
*
* @param int $id
* The node identifier.
*
* @return \Drupal\node\NodeInterface
* The loaded node.
*/
protected function loadNode($id) {
/** @var \Drupal\node\NodeStorage $storage */
$storage = $this->container->get('entity.manager')->getStorage('node');
$storage->resetCache([$id]);
return $storage->load($id);
}
}
......@@ -39,12 +39,27 @@ display:
sorts:
nid:
id: nid
table: node
table: node_field_data
field: nid
order: ASC
plugin_id: standard
entity_type: node
entity_field: nid
langcode:
id: langcode
table: node_field_data
field: langcode
relationship: none
group_type: group
admin_label: ''
order: ASC
exposed: false
expose:
label: ''
entity_type: node
entity_field: langcode
plugin_id: standard
display_extenders: { }
page_1:
display_plugin: page
id: page_1
......
......@@ -57,6 +57,8 @@ public function testConstructor() {
->with('action')
->will($this->returnValue($entity_storage));
$language_manager = $this->getMock('Drupal\Core\Language\LanguageManagerInterface');
$views_data = $this->getMockBuilder('Drupal\views\ViewsData')
->disableOriginalConstructor()
->getMock();
......@@ -87,7 +89,7 @@ public function testConstructor() {
$definition['title'] = '';
$options = array();
$node_bulk_form = new NodeBulkForm(array(), 'node_bulk_form', $definition, $entity_manager);
$node_bulk_form = new NodeBulkForm(array(), 'node_bulk_form', $definition, $entity_manager, $language_manager);
$node_bulk_form->init($executable, $display, $options);
$this->assertAttributeEquals(array_slice($actions, 0, -1, TRUE), 'actions', $node_bulk_form);
......
......@@ -11,8 +11,11 @@
use Drupal\Core\Entity\EntityManagerInterface;
use Drupal\Core\Entity\RevisionableInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\Core\Routing\RedirectDestinationTrait;