Commit 05af46b2 authored by webchick's avatar webchick

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();
}
......
......@@ -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;
use Drupal\Core\TypedData\TranslatableInterface;
use Drupal\views\Entity\Render\EntityTranslationRenderTrait;
use Drupal\views\Plugin\CacheablePluginInterface;
use Drupal\views\Plugin\views\display\DisplayPluginBase;
use Drupal\views\Plugin\views\field\FieldPluginBase;
use Drupal\views\Plugin\views\field\UncacheableFieldHandlerTrait;
......@@ -26,10 +29,11 @@
*
* @ViewsField("bulk_form")
*/
class BulkForm extends FieldPluginBase {
class BulkForm extends FieldPluginBase implements CacheablePluginInterface {
use RedirectDestinationTrait;
use UncacheableFieldHandlerTrait;
use EntityTranslationRenderTrait;
/**
* The entity manager.
......@@ -52,6 +56,13 @@ class BulkForm extends FieldPluginBase {
*/
protected $actions = array();
/**
* The language manager.
*
* @var \Drupal\Core\Language\LanguageManagerInterface
*/
protected $languageManager;
/**
* Constructs a new BulkForm object.
*
......@@ -63,19 +74,28 @@ class BulkForm extends FieldPluginBase {
* The plugin implementation definition.
* @param \Drupal\Core\Entity\EntityManagerInterface $entity_manager
* The entity manager.
* @param \Drupal\Core\Language\LanguageManagerInterface $language_manager
* The language manager.
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityManagerInterface $entity_manager) {
public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityManagerInterface $entity_manager, LanguageManagerInterface $language_manager) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->entityManager = $entity_manager;
$this->actionStorage = $entity_manager->getStorage('action');
$this->languageManager = $language_manager;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static($configuration, $plugin_id, $plugin_definition, $container->get('entity.manager'));
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('entity.manager'),
$container->get('language_manager')
);
}
/**
......@@ -91,6 +111,50 @@ public function init(ViewExecutable $view, DisplayPluginBase $display, array &$o
});
}
/**
* {@inheritdoc}
*/
public function isCacheable() {
// @todo Consider making the bulk operation form cacheable. See
// https://www.drupal.org/node/2503009.
return FALSE;
}
/**
* {@inheritdoc}
*/
public function getCacheContexts() {
return $this->languageManager->isMultilingual() ? $this->getEntityTranslationRenderer()->getCacheContexts() : [];
}
/**
* {@inheritdoc}
*/
public function getEntityTypeId() {
return $this->getEntityType();
}
/**
* {@inheritdoc}
*/
protected function getEntityManager() {
return $this->entityManager;
}
/**
* {@inheritdoc}
*/
protected function getLanguageManager() {
return $this->languageManager;
}
/**
* {@inheritdoc}
*/
protected function getView() {
return $this->view;
}
/**
* {@inheritdoc}
*/
......@@ -178,15 +242,20 @@ public function getValue(ResultRow $row, $field = NULL) {
* The current state of the form.
*/
public function viewsForm(&$form, FormStateInterface $form_state) {
// Make sure we do not accidentally cache this form.
// @todo Evaluate this again in https://www.drupal.org/node/2503009.
$form['#cache']['max-age'] = 0;
// Add the tableselect javascript.
$form['#attached']['library'][] = 'core/drupal.tableselect';
$use_revision = array_key_exists('revision', $this->view->getQuery()->getEntityTableInfo());
// Only add the bulk form options and buttons if there are results.
if (!empty($this->view->result)) {
// Render checkboxes for all rows.
$form[$this->options['id']]['#tree'] = TRUE;
foreach ($this->view->result as $row_index => $row) {
$entity = $this->getEntity($row);
$entity = $this->getEntityTranslation($this->getEntity($row), $row);
$form[$this->options['id']][$row_index] = array(
'#type' => 'checkbox',
......@@ -195,7 +264,7 @@ public function viewsForm(&$form, FormStateInterface $form_state) {
'#title' => $this->t('Update this item'),
'#title_display' => 'invisible',
'#default_value' => !empty($form_state->getValue($this->options['id'])[$row_index]) ? 1 : NULL,
'#return_value' => $this->calculateEntityBulkFormKey($entity),
'#return_value' => $this->calculateEntityBulkFormKey($entity, $use_revision),
);
}
......@@ -294,7 +363,7 @@ public function viewsFormSubmit(&$form, FormStateInterface $form_state) {
$count++;
$entities[$entity->id()] = $entity;
$entities[$bulk_form_key] = $entity;
}
$action->execute($entities);
......@@ -343,6 +412,9 @@ public function viewsFormValidate(&$form, FormStateInterface $form_state) {
* {@inheritdoc}
*/
public function query() {
if ($this->languageManager->isMultilingual()) {
$this->getEntityTranslationRenderer()->query($this->query, $this->relationship);
}
}
/**
......@@ -368,6 +440,9 @@ protected function drupalSetMessage($message = NULL, $type = 'status', $repeat =
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity to calculate a bulk form key for.
* @param bool $use_revision
* Whether the revision id should be added to the bulk form key. This should
* be set to TRUE only if the view is listing entity revisions.
*
* @return string
* The bulk form key representing the entity's id, language and revision (if
......@@ -375,10 +450,10 @@ protected function drupalSetMessage($message = NULL, $type = 'status', $repeat =
*
* @see self::loadEntityFromBulkFormKey()
*/
protected function calculateEntityBulkFormKey(EntityInterface $entity) {
protected function calculateEntityBulkFormKey(EntityInterface $entity, $use_revision) {
$key_parts = [$entity->language()->getId(), $entity->id()];
if ($entity instanceof RevisionableInterface) {
if ($entity instanceof RevisionableInterface && $use_revision) {
$key_parts[] = $entity->getRevisionId();
}
......@@ -398,23 +473,20 @@ protected function calculateEntityBulkFormKey(EntityInterface $entity) {
*/
protected function loadEntityFromBulkFormKey($bulk_form_key) {
$key_parts = explode('-', $bulk_form_key);
$vid = NULL;
$revision_id = NULL;
// If there are 3 items, vid will be last.
if (count($key_parts) === 3) {
$vid = array_pop($key_parts);
$revision_id = array_pop($key_parts);
}
// The first two items will always be langcode and ID.
$id = array_pop($key_parts);
$langcode = array_pop($key_parts);
if ($vid) {
$entity = $this->entityManager->getStorage($this->getEntityType())->loadRevision($vid);
}
else {
$entity = $this->entityManager->getStorage($this->getEntityType())->load($id);
}
// Load the entity or a specific revision depending on the given key.
$storage = $this->entityManager->getStorage($this->getEntityType());
$entity = $revision_id ? $storage->loadRevision($revision_id) : $storage->load($id);
if ($entity instanceof TranslatableInterface) {
$entity = $entity->getTranslation($langcode);
......
......@@ -741,6 +741,44 @@ display:
plugin_id: boolean
entity_type: user
entity_field: status
default_langcode:
id: default_langcode
table: users_field_data
field: default_langcode
relationship: none
group_type: group
admin_label: ''
operator: '='
value: true
group: 1
exposed: false
expose:
operator_id: ''
label: ''
description: ''
use_operator: false
operator: ''
identifier: ''
required: false
remember: false
multiple: false
remember_roles:
authenticated: authenticated
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: user
entity_field: default_langcode
plugin_id: boolean
uid_raw:
id: uid_raw
table: users_field_data
......
......@@ -7,7 +7,6 @@
namespace Drupal\user\Form;
use Drupal\Component\Utility\SafeMarkup;
use Drupal\Core\Entity\EntityManagerInterface;
use Drupal\Core\Form\ConfirmFormBase;
use Drupal\Core\Form\FormStateInterface;
......@@ -109,24 +108,27 @@ public function buildForm(array $form, FormStateInterface $form_state) {
return $this->redirect('entity.user.collection');
}
$root = NULL;
$form['accounts'] = array('#prefix' => '<ul>', '#suffix' => '</ul>', '#tree' => TRUE);
foreach ($accounts as $uid => $account) {
foreach ($accounts as $account) {
$uid = $account->id();
// Prevent user 1 from being canceled.
if ($uid <= 1) {
$root = intval($uid) === 1 ? $account : $root;
continue;
}
$form['accounts'][$uid] = array(
'#type' => 'hidden',
'#value' => $uid,
'#prefix' => '<li>',
'#suffix' => SafeMarkup::checkPlain($account->label()) . "</li>\n",
'#suffix' => $account->label() . "</li>\n",
);
}
// Output a notice that user 1 cannot be canceled.
if (isset($accounts[1])) {
if (isset($root)) {
$redirect = (count($accounts) == 1);
$message = $this->t('The user account %name cannot be canceled.', array('%name' => $accounts[1]->label()));
$message = $this->t('The user account %name cannot be canceled.', array('%name' => $root->label()));
drupal_set_message($message, $redirect ? 'error' : 'warning');
// If only user 1 was selected, redirect to the overview.
if ($redirect) {
......
......@@ -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();
$user_bulk_form = new UserBulkForm(array(), 'user_bulk_form', $definition, $entity_manager);
$user_bulk_form = new UserBulkForm(array(), 'user_bulk_form', $definition, $entity_manager, $language_manager);
$user_bulk_form->init($executable, $display, $options);
$this->assertAttributeEquals(array_slice($actions, 0, -1, TRUE), 'actions', $user_bulk_form);
......
......@@ -263,24 +263,4 @@ protected function getRenderableFieldIds() {
return $field_ids;
}
/**
* Returns the entity translation matching the configured row language.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity object the field value being processed is attached to.
* @param \Drupal\views\ResultRow $row
* The result row the field value being processed belongs to.
*
* @return \Drupal\Core\Entity\FieldableEntityInterface
* The entity translation object for the specified row.
*/
public function getEntityTranslation(EntityInterface $entity, ResultRow $row) {
// We assume the same language should be used for all entity fields
// belonging to a single row, even if they are attached to different entity
// types. Below we apply language fallback to ensure a valid value is always
// picked.
$langcode = $this->getEntityTranslationRenderer()->getLangcode($row);
return $this->entityManager->getTranslationFromContext($entity, $langcode);
}
}
......@@ -7,7 +7,10 @@
namespace Drupal\views\Entity\Render;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\TypedData\TranslatableInterface;
use Drupal\views\Plugin\views\PluginBase;
use Drupal\views\ResultRow;
/**
* Trait used to instantiate the view's entity translation renderer.
......@@ -57,6 +60,30 @@ protected function getEntityTranslationRenderer() {
return $this->entityTranslationRenderer;
}
/**
* Returns the entity translation matching the configured row language.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity object the field value being processed is attached to.
* @param \Drupal\views\ResultRow $row
* The result row the field value being processed belongs to.
*
* @return \Drupal\Core\Entity\FieldableEntityInterface
* The entity translation object for the specified row.
*/
public function getEntityTranslation(EntityInterface $entity, ResultRow $row) {
// We assume the same language should be used for all entity fields
// belonging to a single row, even if they are attached to different entity
// types. Below we apply language fallback to ensure a valid value is always
// picked.
$translation = $entity;
if ($entity instanceof TranslatableInterface && count($entity->getTranslationLanguages()) > 1) {
$langcode = $this->getEntityTranslationRenderer()->getLangcode($row);
$translation = $this->getEntityManager()->getTranslationFromContext($entity, $langcode);
}
return $translation;
}
/**
* Returns the entity type identifier.
*
......
......@@ -1106,7 +1106,7 @@ public function testGetEntityTypeLabels() {
public function testGetTranslationFromContext() {
$this->setUpEntityManager();
$this->languageManager->expects($this->exactly(2))
$this->languageManager->expects($this->exactly(1))
->method('getFallbackCandidates')
->will($this->returnCallback(function (array $context = array()) {
$candidates = array();
......@@ -1117,17 +1117,17 @@ public function testGetTranslationFromContext() {
}));
$entity = $this->getMock('Drupal\Tests\Core\Entity\TestContentEntityInterface');
$entity->expects($this->exactly(2))
$entity->expects($this->exactly(1))
->method('getUntranslated')
->will($this->returnValue($entity));
$language = $this->getMock('\Drupal\Core\Language\LanguageInterface');
$language->expects($this->any())
->method('getId')
->will($this->returnValue('en'));
$entity->expects($this->exactly(2))
$entity->expects($this->exactly(3))
->method('language')
->will($this->returnValue($language));
$entity->expects($this->exactly(2))
$entity->expects($this->exactly(1))
->method('hasTranslation')
->will($this->returnValueMap(array(
array(LanguageInterface::LANGCODE_DEFAULT, FALSE),
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment