3 namespace Drupal\Tests\content_translation\Functional;
5 use Drupal\Component\Render\FormattableMarkup;
6 use Drupal\Core\Cache\Cache;
7 use Drupal\Core\Entity\EntityChangedInterface;
8 use Drupal\Core\Entity\EntityInterface;
9 use Drupal\Core\Language\Language;
10 use Drupal\Core\Language\LanguageInterface;
12 use Drupal\language\Entity\ConfigurableLanguage;
13 use Drupal\Tests\system\Functional\Cache\AssertPageCacheContextsAndTagsTrait;
16 * Tests the Content Translation UI.
18 abstract class ContentTranslationUITestBase extends ContentTranslationTestBase {
20 use AssertPageCacheContextsAndTagsTrait;
23 * The id of the entity being translated.
30 * Whether the behavior of the language selector should be tested.
34 protected $testLanguageSelector = TRUE;
37 * Flag to determine if "all languages" rendering is tested.
41 protected $testHTMLEscapeForAllLanguages = FALSE;
44 * Default cache contexts expected on a non-translated entity.
46 * Cache contexts will not be checked if this list is empty.
50 protected $defaultCacheContexts = ['languages:language_interface', 'theme', 'url.query_args:_wrapper_format', 'user.permissions'];
53 * Tests the basic translation UI.
55 public function testTranslationUI() {
56 $this->doTestBasicTranslation();
57 $this->doTestTranslationOverview();
58 $this->doTestOutdatedStatus();
59 $this->doTestPublishedStatus();
60 $this->doTestAuthoringInfo();
61 $this->doTestTranslationEdit();
62 $this->doTestTranslationChanged();
63 $this->doTestChangedTimeAfterSaveWithoutChanges();
64 $this->doTestTranslationDeletion();
68 * Tests the basic translation workflow.
70 protected function doTestBasicTranslation() {
71 // Create a new test entity with original values in the default language.
72 $default_langcode = $this->langcodes[0];
73 $values[$default_langcode] = $this->getNewEntityValues($default_langcode);
74 // Create the entity with the editor as owner, so that afterwards a new
75 // translation is created by the translator and the translation author is
77 $this->drupalLogin($this->editor);
78 $this->entityId = $this->createEntity($values[$default_langcode], $default_langcode);
79 $this->drupalLogin($this->translator);
80 $storage = $this->container->get('entity_type.manager')
81 ->getStorage($this->entityTypeId);
82 $storage->resetCache([$this->entityId]);
83 $entity = $storage->load($this->entityId);
84 $this->assertTrue($entity, 'Entity found in the database.');
85 $this->drupalGet($entity->urlInfo());
86 $this->assertResponse(200, 'Entity URL is valid.');
88 // Ensure that the content language cache context is not yet added to the
90 $this->assertCacheContexts($this->defaultCacheContexts);
92 $this->drupalGet($entity->urlInfo('drupal:content-translation-overview'));
93 $this->assertNoText('Source language', 'Source language column correctly hidden.');
95 $translation = $this->getTranslation($entity, $default_langcode);
96 foreach ($values[$default_langcode] as $property => $value) {
97 $stored_value = $this->getValue($translation, $property, $default_langcode);
98 $value = is_array($value) ? $value[0]['value'] : $value;
99 $message = format_string('@property correctly stored in the default language.', ['@property' => $property]);
100 $this->assertEqual($stored_value, $value, $message);
103 // Add a content translation.
105 $language = ConfigurableLanguage::load($langcode);
106 $values[$langcode] = $this->getNewEntityValues($langcode);
108 $entity_type_id = $entity->getEntityTypeId();
109 $add_url = Url::fromRoute("entity.$entity_type_id.content_translation_add", [
110 $entity->getEntityTypeId() => $entity->id(),
111 'source' => $default_langcode,
112 'target' => $langcode,
113 ], ['language' => $language]);
114 $this->drupalPostForm($add_url, $this->getEditValues($values, $langcode), $this->getFormSubmitActionForNewTranslation($entity, $langcode));
116 // Assert that HTML is not escaped unexpectedly.
117 if ($this->testHTMLEscapeForAllLanguages) {
118 $this->assertNoRaw('<span class="translation-entity-all-languages">(all languages)</span>');
119 $this->assertRaw('<span class="translation-entity-all-languages">(all languages)</span>');
122 // Ensure that the content language cache context is not yet added to the
124 $storage = $this->container->get('entity_type.manager')
125 ->getStorage($this->entityTypeId);
126 $storage->resetCache([$this->entityId]);
127 $entity = $storage->load($this->entityId);
128 $this->drupalGet($entity->urlInfo());
129 $this->assertCacheContexts(Cache::mergeContexts(['languages:language_content'], $this->defaultCacheContexts));
131 // Reset the cache of the entity, so that the new translation gets the
133 $metadata_source_translation = $this->manager->getTranslationMetadata($entity->getTranslation($default_langcode));
134 $metadata_target_translation = $this->manager->getTranslationMetadata($entity->getTranslation($langcode));
136 $author_field_name = $entity->hasField('content_translation_uid') ? 'content_translation_uid' : 'uid';
137 if ($entity->getFieldDefinition($author_field_name)->isTranslatable()) {
138 $this->assertEqual($metadata_target_translation->getAuthor()->id(), $this->translator->id(),
139 new FormattableMarkup('Author of the target translation @langcode correctly stored for translatable owner field.', ['@langcode' => $langcode]));
141 $this->assertNotEqual($metadata_target_translation->getAuthor()->id(), $metadata_source_translation->getAuthor()->id(),
142 new FormattableMarkup('Author of the target translation @target different from the author of the source translation @source for translatable owner field.',
143 ['@target' => $langcode, '@source' => $default_langcode]));
146 $this->assertEqual($metadata_target_translation->getAuthor()->id(), $this->editor->id(), 'Author of the entity remained untouched after translation for non translatable owner field.');
149 $created_field_name = $entity->hasField('content_translation_created') ? 'content_translation_created' : 'created';
150 if ($entity->getFieldDefinition($created_field_name)->isTranslatable()) {
151 $this->assertTrue($metadata_target_translation->getCreatedTime() > $metadata_source_translation->getCreatedTime(),
152 new FormattableMarkup('Translation creation timestamp of the target translation @target is newer than the creation timestamp of the source translation @source for translatable created field.',
153 ['@target' => $langcode, '@source' => $default_langcode]));
156 $this->assertEqual($metadata_target_translation->getCreatedTime(), $metadata_source_translation->getCreatedTime(), 'Creation timestamp of the entity remained untouched after translation for non translatable created field.');
159 if ($this->testLanguageSelector) {
160 $this->assertNoFieldByXPath('//select[@id="edit-langcode-0-value"]', NULL, 'Language selector correctly disabled on translations.');
162 $storage->resetCache([$this->entityId]);
163 $entity = $storage->load($this->entityId);
164 $this->drupalGet($entity->urlInfo('drupal:content-translation-overview'));
165 $this->assertNoText('Source language', 'Source language column correctly hidden.');
167 // Switch the source language.
169 $language = ConfigurableLanguage::load($langcode);
170 $source_langcode = 'it';
171 $edit = ['source_langcode[source]' => $source_langcode];
172 $entity_type_id = $entity->getEntityTypeId();
173 $add_url = Url::fromRoute("entity.$entity_type_id.content_translation_add", [
174 $entity->getEntityTypeId() => $entity->id(),
175 'source' => $default_langcode,
176 'target' => $langcode,
177 ], ['language' => $language]);
178 // This does not save anything, it merely reloads the form and fills in the
179 // fields with the values from the different source language.
180 $this->drupalPostForm($add_url, $edit, t('Change'));
181 $this->assertFieldByXPath("//input[@name=\"{$this->fieldName}[0][value]\"]", $values[$source_langcode][$this->fieldName][0]['value'], 'Source language correctly switched.');
183 // Add another translation and mark the other ones as outdated.
184 $values[$langcode] = $this->getNewEntityValues($langcode);
185 $edit = $this->getEditValues($values, $langcode) + ['content_translation[retranslate]' => TRUE];
186 $entity_type_id = $entity->getEntityTypeId();
187 $add_url = Url::fromRoute("entity.$entity_type_id.content_translation_add", [
188 $entity->getEntityTypeId() => $entity->id(),
189 'source' => $source_langcode,
190 'target' => $langcode,
191 ], ['language' => $language]);
192 $this->drupalPostForm($add_url, $edit, $this->getFormSubmitActionForNewTranslation($entity, $langcode));
193 $storage->resetCache([$this->entityId]);
194 $entity = $storage->load($this->entityId);
195 $this->drupalGet($entity->urlInfo('drupal:content-translation-overview'));
196 $this->assertText('Source language', 'Source language column correctly shown.');
198 // Check that the entered values have been correctly stored.
199 foreach ($values as $langcode => $property_values) {
200 $translation = $this->getTranslation($entity, $langcode);
201 foreach ($property_values as $property => $value) {
202 $stored_value = $this->getValue($translation, $property, $langcode);
203 $value = is_array($value) ? $value[0]['value'] : $value;
204 $message = format_string('%property correctly stored with language %language.', ['%property' => $property, '%language' => $langcode]);
205 $this->assertEqual($stored_value, $value, $message);
211 * Tests that the translation overview shows the correct values.
213 protected function doTestTranslationOverview() {
214 $storage = $this->container->get('entity_type.manager')
215 ->getStorage($this->entityTypeId);
216 $storage->resetCache([$this->entityId]);
217 $entity = $storage->load($this->entityId);
218 $translate_url = $entity->urlInfo('drupal:content-translation-overview');
219 $this->drupalGet($translate_url);
220 $translate_url->setAbsolute(FALSE);
222 foreach ($this->langcodes as $langcode) {
223 if ($entity->hasTranslation($langcode)) {
224 $language = new Language(['id' => $langcode]);
225 $view_url = $entity->url('canonical', ['language' => $language]);
226 $elements = $this->xpath('//table//a[@href=:href]', [':href' => $view_url]);
227 $this->assertEqual($elements[0]->getText(), $entity->getTranslation($langcode)->label(), new FormattableMarkup('Label correctly shown for %language translation.', ['%language' => $langcode]));
228 $edit_path = $entity->url('edit-form', ['language' => $language]);
229 $elements = $this->xpath('//table//ul[@class="dropbutton"]/li/a[@href=:href]', [':href' => $edit_path]);
230 $this->assertEqual($elements[0]->getText(), t('Edit'), new FormattableMarkup('Edit link correct for %language translation.', ['%language' => $langcode]));
236 * Tests up-to-date status tracking.
238 protected function doTestOutdatedStatus() {
239 $storage = $this->container->get('entity_type.manager')
240 ->getStorage($this->entityTypeId);
241 $storage->resetCache([$this->entityId]);
242 $entity = $storage->load($this->entityId);
244 $languages = \Drupal::languageManager()->getLanguages();
246 // Mark translations as outdated.
247 $edit = ['content_translation[retranslate]' => TRUE];
248 $edit_path = $entity->urlInfo('edit-form', ['language' => $languages[$langcode]]);
249 $this->drupalPostForm($edit_path, $edit, $this->getFormSubmitAction($entity, $langcode));
250 $storage->resetCache([$this->entityId]);
251 $entity = $storage->load($this->entityId);
253 // Check that every translation has the correct "outdated" status, and that
254 // the Translation fieldset is open if the translation is "outdated".
255 foreach ($this->langcodes as $added_langcode) {
256 $url = $entity->urlInfo('edit-form', ['language' => ConfigurableLanguage::load($added_langcode)]);
257 $this->drupalGet($url);
258 if ($added_langcode == $langcode) {
259 $this->assertFieldByXPath('//input[@name="content_translation[retranslate]"]', FALSE, 'The retranslate flag is not checked by default.');
260 $this->assertFalse($this->xpath('//details[@id="edit-content-translation" and @open="open"]'), 'The translation tab should be collapsed by default.');
263 $this->assertFieldByXPath('//input[@name="content_translation[outdated]"]', TRUE, 'The translate flag is checked by default.');
264 $this->assertTrue($this->xpath('//details[@id="edit-content-translation" and @open="open"]'), 'The translation tab is correctly expanded when the translation is outdated.');
265 $edit = ['content_translation[outdated]' => FALSE];
266 $this->drupalPostForm($url, $edit, $this->getFormSubmitAction($entity, $added_langcode));
267 $this->drupalGet($url);
268 $this->assertFieldByXPath('//input[@name="content_translation[retranslate]"]', FALSE, 'The retranslate flag is now shown.');
269 $storage = $this->container->get('entity_type.manager')
270 ->getStorage($this->entityTypeId);
271 $storage->resetCache([$this->entityId]);
272 $entity = $storage->load($this->entityId);
273 $this->assertFalse($this->manager->getTranslationMetadata($entity->getTranslation($added_langcode))->isOutdated(), 'The "outdated" status has been correctly stored.');
279 * Tests the translation publishing status.
281 protected function doTestPublishedStatus() {
282 $storage = $this->container->get('entity_type.manager')
283 ->getStorage($this->entityTypeId);
284 $storage->resetCache([$this->entityId]);
285 $entity = $storage->load($this->entityId);
287 // Unpublish translations.
288 foreach ($this->langcodes as $index => $langcode) {
290 $url = $entity->urlInfo('edit-form', ['language' => ConfigurableLanguage::load($langcode)]);
291 $edit = ['content_translation[status]' => FALSE];
292 $this->drupalPostForm($url, $edit, $this->getFormSubmitAction($entity, $langcode));
293 $storage = $this->container->get('entity_type.manager')
294 ->getStorage($this->entityTypeId);
295 $storage->resetCache([$this->entityId]);
296 $entity = $storage->load($this->entityId);
297 $this->assertFalse($this->manager->getTranslationMetadata($entity->getTranslation($langcode))->isPublished(), 'The translation has been correctly unpublished.');
301 // Check that the last published translation cannot be unpublished.
302 $this->drupalGet($entity->urlInfo('edit-form'));
303 $this->assertFieldByXPath('//input[@name="content_translation[status]" and @disabled="disabled"]', TRUE, 'The last translation is published and cannot be unpublished.');
307 * Tests the translation authoring information.
309 protected function doTestAuthoringInfo() {
310 $storage = $this->container->get('entity_type.manager')
311 ->getStorage($this->entityTypeId);
312 $storage->resetCache([$this->entityId]);
313 $entity = $storage->load($this->entityId);
316 // Post different authoring information for each translation.
317 foreach ($this->langcodes as $index => $langcode) {
318 $user = $this->drupalCreateUser();
319 $values[$langcode] = [
320 'uid' => $user->id(),
321 'created' => REQUEST_TIME - mt_rand(0, 1000),
324 'content_translation[uid]' => $user->getUsername(),
325 'content_translation[created]' => format_date($values[$langcode]['created'], 'custom', 'Y-m-d H:i:s O'),
327 $url = $entity->urlInfo('edit-form', ['language' => ConfigurableLanguage::load($langcode)]);
328 $this->drupalPostForm($url, $edit, $this->getFormSubmitAction($entity, $langcode));
331 $storage = $this->container->get('entity_type.manager')
332 ->getStorage($this->entityTypeId);
333 $storage->resetCache([$this->entityId]);
334 $entity = $storage->load($this->entityId);
335 foreach ($this->langcodes as $langcode) {
336 $metadata = $this->manager->getTranslationMetadata($entity->getTranslation($langcode));
337 $this->assertEqual($metadata->getAuthor()->id(), $values[$langcode]['uid'], 'Translation author correctly stored.');
338 $this->assertEqual($metadata->getCreatedTime(), $values[$langcode]['created'], 'Translation date correctly stored.');
341 // Try to post non valid values and check that they are rejected.
344 // User names have by default length 8.
345 'content_translation[uid]' => $this->randomMachineName(12),
346 'content_translation[created]' => '19/11/1978',
348 $this->drupalPostForm($entity->urlInfo('edit-form'), $edit, $this->getFormSubmitAction($entity, $langcode));
349 $this->assertTrue($this->xpath('//div[contains(@class, "error")]//ul'), 'Invalid values generate a list of form errors.');
350 $metadata = $this->manager->getTranslationMetadata($entity->getTranslation($langcode));
351 $this->assertEqual($metadata->getAuthor()->id(), $values[$langcode]['uid'], 'Translation author correctly kept.');
352 $this->assertEqual($metadata->getCreatedTime(), $values[$langcode]['created'], 'Translation date correctly kept.');
356 * Tests translation deletion.
358 protected function doTestTranslationDeletion() {
359 // Confirm and delete a translation.
360 $this->drupalLogin($this->translator);
362 $storage = $this->container->get('entity_type.manager')
363 ->getStorage($this->entityTypeId);
364 $storage->resetCache([$this->entityId]);
365 $entity = $storage->load($this->entityId);
366 $language = ConfigurableLanguage::load($langcode);
367 $url = $entity->urlInfo('edit-form', ['language' => $language]);
368 $this->drupalPostForm($url, [], t('Delete translation'));
369 $this->drupalPostForm(NULL, [], t('Delete @language translation', ['@language' => $language->getName()]));
370 $storage->resetCache([$this->entityId]);
371 $entity = $storage->load($this->entityId, TRUE);
372 if ($this->assertTrue(is_object($entity), 'Entity found')) {
373 $translations = $entity->getTranslationLanguages();
374 $this->assertTrue(count($translations) == 2 && empty($translations[$langcode]), 'Translation successfully deleted.');
377 // Check that the translator cannot delete the original translation.
378 $args = [$this->entityTypeId => $entity->id(), 'language' => 'en'];
379 $this->drupalGet(Url::fromRoute("entity.$this->entityTypeId.content_translation_delete", $args));
380 $this->assertResponse(403);
384 * Returns an array of entity field values to be tested.
386 protected function getNewEntityValues($langcode) {
387 return [$this->fieldName => [['value' => $this->randomMachineName(16)]]];
391 * Returns an edit array containing the values to be posted.
393 protected function getEditValues($values, $langcode, $new = FALSE) {
394 $edit = $values[$langcode];
395 $langcode = $new ? LanguageInterface::LANGCODE_NOT_SPECIFIED : $langcode;
396 foreach ($values[$langcode] as $property => $value) {
397 if (is_array($value)) {
398 $edit["{$property}[0][value]"] = $value[0]['value'];
399 unset($edit[$property]);
406 * Returns the form action value when submitting a new translation.
408 * @param \Drupal\Core\Entity\EntityInterface $entity
409 * The entity being tested.
410 * @param string $langcode
411 * Language code for the form.
414 * Name of the button to hit.
416 protected function getFormSubmitActionForNewTranslation(EntityInterface $entity, $langcode) {
417 $entity->addTranslation($langcode, $entity->toArray());
418 return $this->getFormSubmitAction($entity, $langcode);
422 * Returns the form action value to be used to submit the entity form.
424 * @param \Drupal\Core\Entity\EntityInterface $entity
425 * The entity being tested.
426 * @param string $langcode
427 * Language code for the form.
430 * Name of the button to hit.
432 protected function getFormSubmitAction(EntityInterface $entity, $langcode) {
433 return t('Save') . $this->getFormSubmitSuffix($entity, $langcode);
437 * Returns appropriate submit button suffix based on translatability.
439 * @param \Drupal\Core\Entity\EntityInterface $entity
440 * The entity being tested.
441 * @param string $langcode
442 * Language code for the form.
445 * Submit button suffix based on translatability.
447 protected function getFormSubmitSuffix(EntityInterface $entity, $langcode) {
452 * Returns the translation object to use to retrieve the translated values.
454 * @param \Drupal\Core\Entity\EntityInterface $entity
455 * The entity being tested.
456 * @param string $langcode
457 * The language code identifying the translation to be retrieved.
459 * @return \Drupal\Core\TypedData\TranslatableInterface
460 * The translation object to act on.
462 protected function getTranslation(EntityInterface $entity, $langcode) {
463 return $entity->getTranslation($langcode);
467 * Returns the value for the specified property in the given language.
469 * @param \Drupal\Core\Entity\EntityInterface $translation
470 * The translation object the property value should be retrieved from.
471 * @param string $property
473 * @param string $langcode
474 * The property value.
477 * The property value.
479 protected function getValue(EntityInterface $translation, $property, $langcode) {
480 $key = $property == 'user_id' ? 'target_id' : 'value';
481 return $translation->get($property)->{$key};
485 * Returns the name of the field that implements the changed timestamp.
487 * @param \Drupal\Core\Entity\EntityInterface $entity
488 * The entity being tested.
493 protected function getChangedFieldName($entity) {
494 return $entity->hasField('content_translation_changed') ? 'content_translation_changed' : 'changed';
498 * Tests edit content translation.
500 protected function doTestTranslationEdit() {
501 $storage = $this->container->get('entity_type.manager')
502 ->getStorage($this->entityTypeId);
503 $storage->resetCache([$this->entityId]);
504 $entity = $storage->load($this->entityId);
505 $languages = $this->container->get('language_manager')->getLanguages();
507 foreach ($this->langcodes as $langcode) {
508 // We only want to test the title for non-english translations.
509 if ($langcode != 'en') {
510 $options = ['language' => $languages[$langcode]];
511 $url = $entity->urlInfo('edit-form', $options);
512 $this->drupalGet($url);
514 $this->assertRaw($entity->getTranslation($langcode)->label());
520 * Tests the basic translation workflow.
522 protected function doTestTranslationChanged() {
523 $storage = $this->container->get('entity_type.manager')
524 ->getStorage($this->entityTypeId);
525 $storage->resetCache([$this->entityId]);
526 $entity = $storage->load($this->entityId);
527 $changed_field_name = $this->getChangedFieldName($entity);
528 $definition = $entity->getFieldDefinition($changed_field_name);
529 $config = $definition->getConfig($entity->bundle());
531 foreach ([FALSE, TRUE] as $translatable_changed_field) {
532 if ($definition->isTranslatable()) {
533 // For entities defining a translatable changed field we want to test
534 // the correct behavior of that field even if the translatability is
535 // revoked. In that case the changed timestamp should be synchronized
536 // across all translations.
537 $config->setTranslatable($translatable_changed_field);
540 elseif ($translatable_changed_field) {
541 // For entities defining a non-translatable changed field we cannot
542 // declare the field as translatable on the fly by modifying its config
543 // because the schema doesn't support this.
547 foreach ($entity->getTranslationLanguages() as $language) {
548 // Ensure different timestamps.
551 $langcode = $language->getId();
554 $this->fieldName . '[0][value]' => $this->randomString(),
556 $edit_path = $entity->urlInfo('edit-form', ['language' => $language]);
557 $this->drupalPostForm($edit_path, $edit, $this->getFormSubmitAction($entity, $langcode));
559 $storage = $this->container->get('entity_type.manager')
560 ->getStorage($this->entityTypeId);
561 $storage->resetCache([$this->entityId]);
562 $entity = $storage->load($this->entityId);
564 $entity->getChangedTimeAcrossTranslations(), $entity->getTranslation($langcode)->getChangedTime(),
565 format_string('Changed time for language %language is the latest change over all languages.', ['%language' => $language->getName()])
570 foreach ($entity->getTranslationLanguages() as $language) {
571 $next_timestamp = $entity->getTranslation($language->getId())->getChangedTime();
572 if (!in_array($next_timestamp, $timestamps)) {
573 $timestamps[] = $next_timestamp;
577 if ($translatable_changed_field) {
579 count($timestamps), count($entity->getTranslationLanguages()),
580 'All timestamps from all languages are different.'
585 count($timestamps), 1,
586 'All timestamps from all languages are identical.'
593 * Test the changed time after API and FORM save without changes.
595 public function doTestChangedTimeAfterSaveWithoutChanges() {
596 $storage = $this->container->get('entity_type.manager')
597 ->getStorage($this->entityTypeId);
598 $storage->resetCache([$this->entityId]);
599 $entity = $storage->load($this->entityId);
600 // Test only entities, which implement the EntityChangedInterface.
601 if ($entity instanceof EntityChangedInterface) {
602 $changed_timestamp = $entity->getChangedTime();
605 $storage = $this->container->get('entity_type.manager')
606 ->getStorage($this->entityTypeId);
607 $storage->resetCache([$this->entityId]);
608 $entity = $storage->load($this->entityId);
609 $this->assertEqual($changed_timestamp, $entity->getChangedTime(), 'The entity\'s changed time wasn\'t updated after API save without changes.');
611 // Ensure different save timestamps.
614 // Save the entity on the regular edit form.
615 $language = $entity->language();
616 $edit_path = $entity->urlInfo('edit-form', ['language' => $language]);
617 $this->drupalPostForm($edit_path, [], $this->getFormSubmitAction($entity, $language->getId()));
619 $storage->resetCache([$this->entityId]);
620 $entity = $storage->load($this->entityId);
621 $this->assertNotEqual($changed_timestamp, $entity->getChangedTime(), 'The entity\'s changed time was updated after form save without changes.');