3 namespace Drupal\content_translation\Tests;
5 use Drupal\Core\Cache\Cache;
6 use Drupal\Core\Entity\EntityChangedInterface;
7 use Drupal\Core\Entity\EntityInterface;
8 use Drupal\Core\Language\Language;
9 use Drupal\Core\Language\LanguageInterface;
11 use Drupal\language\Entity\ConfigurableLanguage;
12 use Drupal\Component\Utility\SafeMarkup;
13 use Drupal\system\Tests\Cache\AssertPageCacheContextsAndTagsTrait;
16 * Tests the Content Translation UI.
18 * @deprecated Scheduled for removal in Drupal 9.0.0.
19 * Use \Drupal\Tests\content_translation\Functional\ContentTranslationUITestBase instead.
21 abstract class ContentTranslationUITestBase extends ContentTranslationTestBase {
23 use AssertPageCacheContextsAndTagsTrait;
26 * The id of the entity being translated.
33 * Whether the behavior of the language selector should be tested.
37 protected $testLanguageSelector = TRUE;
40 * Flag that tells whether the HTML escaping of all languages works or not
41 * after SafeMarkup change.
45 protected $testHTMLEscapeForAllLanguages = FALSE;
48 * Default cache contexts expected on a non-translated entity.
50 * Cache contexts will not be checked if this list is empty.
54 protected $defaultCacheContexts = ['languages:language_interface', 'theme', 'url.query_args:_wrapper_format', 'user.permissions'];
57 * Tests the basic translation UI.
59 public function testTranslationUI() {
60 $this->doTestBasicTranslation();
61 $this->doTestTranslationOverview();
62 $this->doTestOutdatedStatus();
63 $this->doTestPublishedStatus();
64 $this->doTestAuthoringInfo();
65 $this->doTestTranslationEdit();
66 $this->doTestTranslationChanged();
67 $this->doTestChangedTimeAfterSaveWithoutChanges();
68 $this->doTestTranslationDeletion();
72 * Tests the basic translation workflow.
74 protected function doTestBasicTranslation() {
75 // Create a new test entity with original values in the default language.
76 $default_langcode = $this->langcodes[0];
77 $values[$default_langcode] = $this->getNewEntityValues($default_langcode);
78 // Create the entity with the editor as owner, so that afterwards a new
79 // translation is created by the translator and the translation author is
81 $this->drupalLogin($this->editor);
82 $this->entityId = $this->createEntity($values[$default_langcode], $default_langcode);
83 $this->drupalLogin($this->translator);
84 $storage = $this->container->get('entity_type.manager')
85 ->getStorage($this->entityTypeId);
86 $storage->resetCache([$this->entityId]);
87 $entity = $storage->load($this->entityId);
88 $this->assertTrue($entity, 'Entity found in the database.');
89 $this->drupalGet($entity->urlInfo());
90 $this->assertResponse(200, 'Entity URL is valid.');
92 // Ensure that the content language cache context is not yet added to the
94 $this->assertCacheContexts($this->defaultCacheContexts);
96 $this->drupalGet($entity->urlInfo('drupal:content-translation-overview'));
97 $this->assertNoText('Source language', 'Source language column correctly hidden.');
99 $translation = $this->getTranslation($entity, $default_langcode);
100 foreach ($values[$default_langcode] as $property => $value) {
101 $stored_value = $this->getValue($translation, $property, $default_langcode);
102 $value = is_array($value) ? $value[0]['value'] : $value;
103 $message = format_string('@property correctly stored in the default language.', ['@property' => $property]);
104 $this->assertEqual($stored_value, $value, $message);
107 // Add a content translation.
109 $language = ConfigurableLanguage::load($langcode);
110 $values[$langcode] = $this->getNewEntityValues($langcode);
112 $entity_type_id = $entity->getEntityTypeId();
113 $add_url = Url::fromRoute("entity.$entity_type_id.content_translation_add", [
114 $entity->getEntityTypeId() => $entity->id(),
115 'source' => $default_langcode,
116 'target' => $langcode
117 ], ['language' => $language]);
118 $this->drupalPostForm($add_url, $this->getEditValues($values, $langcode), $this->getFormSubmitActionForNewTranslation($entity, $langcode));
120 // Assert that HTML is escaped in "all languages" in UI after SafeMarkup
122 if ($this->testHTMLEscapeForAllLanguages) {
123 $this->assertNoRaw('<span class="translation-entity-all-languages">(all languages)</span>');
124 $this->assertRaw('<span class="translation-entity-all-languages">(all languages)</span>');
127 // Ensure that the content language cache context is not yet added to the
129 $storage = $this->container->get('entity_type.manager')
130 ->getStorage($this->entityTypeId);
131 $storage->resetCache([$this->entityId]);
132 $entity = $storage->load($this->entityId);
133 $this->drupalGet($entity->urlInfo());
134 $this->assertCacheContexts(Cache::mergeContexts(['languages:language_content'], $this->defaultCacheContexts));
136 // Reset the cache of the entity, so that the new translation gets the
138 $metadata_source_translation = $this->manager->getTranslationMetadata($entity->getTranslation($default_langcode));
139 $metadata_target_translation = $this->manager->getTranslationMetadata($entity->getTranslation($langcode));
141 $author_field_name = $entity->hasField('content_translation_uid') ? 'content_translation_uid' : 'uid';
142 if ($entity->getFieldDefinition($author_field_name)->isTranslatable()) {
143 $this->assertEqual($metadata_target_translation->getAuthor()->id(), $this->translator->id(),
144 SafeMarkup::format('Author of the target translation @langcode correctly stored for translatable owner field.', ['@langcode' => $langcode]));
146 $this->assertNotEqual($metadata_target_translation->getAuthor()->id(), $metadata_source_translation->getAuthor()->id(),
147 SafeMarkup::format('Author of the target translation @target different from the author of the source translation @source for translatable owner field.',
148 ['@target' => $langcode, '@source' => $default_langcode]));
151 $this->assertEqual($metadata_target_translation->getAuthor()->id(), $this->editor->id(), 'Author of the entity remained untouched after translation for non translatable owner field.');
154 $created_field_name = $entity->hasField('content_translation_created') ? 'content_translation_created' : 'created';
155 if ($entity->getFieldDefinition($created_field_name)->isTranslatable()) {
156 $this->assertTrue($metadata_target_translation->getCreatedTime() > $metadata_source_translation->getCreatedTime(),
157 SafeMarkup::format('Translation creation timestamp of the target translation @target is newer than the creation timestamp of the source translation @source for translatable created field.',
158 ['@target' => $langcode, '@source' => $default_langcode]));
161 $this->assertEqual($metadata_target_translation->getCreatedTime(), $metadata_source_translation->getCreatedTime(), 'Creation timestamp of the entity remained untouched after translation for non translatable created field.');
164 if ($this->testLanguageSelector) {
165 $this->assertNoFieldByXPath('//select[@id="edit-langcode-0-value"]', NULL, 'Language selector correctly disabled on translations.');
167 $storage->resetCache([$this->entityId]);
168 $entity = $storage->load($this->entityId);
169 $this->drupalGet($entity->urlInfo('drupal:content-translation-overview'));
170 $this->assertNoText('Source language', 'Source language column correctly hidden.');
172 // Switch the source language.
174 $language = ConfigurableLanguage::load($langcode);
175 $source_langcode = 'it';
176 $edit = ['source_langcode[source]' => $source_langcode];
177 $entity_type_id = $entity->getEntityTypeId();
178 $add_url = Url::fromRoute("entity.$entity_type_id.content_translation_add", [
179 $entity->getEntityTypeId() => $entity->id(),
180 'source' => $default_langcode,
181 'target' => $langcode
182 ], ['language' => $language]);
183 // This does not save anything, it merely reloads the form and fills in the
184 // fields with the values from the different source language.
185 $this->drupalPostForm($add_url, $edit, t('Change'));
186 $this->assertFieldByXPath("//input[@name=\"{$this->fieldName}[0][value]\"]", $values[$source_langcode][$this->fieldName][0]['value'], 'Source language correctly switched.');
188 // Add another translation and mark the other ones as outdated.
189 $values[$langcode] = $this->getNewEntityValues($langcode);
190 $edit = $this->getEditValues($values, $langcode) + ['content_translation[retranslate]' => TRUE];
191 $entity_type_id = $entity->getEntityTypeId();
192 $add_url = Url::fromRoute("entity.$entity_type_id.content_translation_add", [
193 $entity->getEntityTypeId() => $entity->id(),
194 'source' => $source_langcode,
195 'target' => $langcode
196 ], ['language' => $language]);
197 $this->drupalPostForm($add_url, $edit, $this->getFormSubmitActionForNewTranslation($entity, $langcode));
198 $storage->resetCache([$this->entityId]);
199 $entity = $storage->load($this->entityId);
200 $this->drupalGet($entity->urlInfo('drupal:content-translation-overview'));
201 $this->assertText('Source language', 'Source language column correctly shown.');
203 // Check that the entered values have been correctly stored.
204 foreach ($values as $langcode => $property_values) {
205 $translation = $this->getTranslation($entity, $langcode);
206 foreach ($property_values as $property => $value) {
207 $stored_value = $this->getValue($translation, $property, $langcode);
208 $value = is_array($value) ? $value[0]['value'] : $value;
209 $message = format_string('%property correctly stored with language %language.', ['%property' => $property, '%language' => $langcode]);
210 $this->assertEqual($stored_value, $value, $message);
216 * Tests that the translation overview shows the correct values.
218 protected function doTestTranslationOverview() {
219 $storage = $this->container->get('entity_type.manager')
220 ->getStorage($this->entityTypeId);
221 $storage->resetCache([$this->entityId]);
222 $entity = $storage->load($this->entityId);
223 $translate_url = $entity->urlInfo('drupal:content-translation-overview');
224 $this->drupalGet($translate_url);
225 $translate_url->setAbsolute(FALSE);
227 foreach ($this->langcodes as $langcode) {
228 if ($entity->hasTranslation($langcode)) {
229 $language = new Language(['id' => $langcode]);
230 $view_url = $entity->url('canonical', ['language' => $language]);
231 $elements = $this->xpath('//table//a[@href=:href]', [':href' => $view_url]);
232 $this->assertEqual((string) $elements[0], $entity->getTranslation($langcode)->label(), format_string('Label correctly shown for %language translation.', ['%language' => $langcode]));
233 $edit_path = $entity->url('edit-form', ['language' => $language]);
234 $elements = $this->xpath('//table//ul[@class="dropbutton"]/li/a[@href=:href]', [':href' => $edit_path]);
235 $this->assertEqual((string) $elements[0], t('Edit'), format_string('Edit link correct for %language translation.', ['%language' => $langcode]));
241 * Tests up-to-date status tracking.
243 protected function doTestOutdatedStatus() {
244 $storage = $this->container->get('entity_type.manager')
245 ->getStorage($this->entityTypeId);
246 $storage->resetCache([$this->entityId]);
247 $entity = $storage->load($this->entityId);
249 $languages = \Drupal::languageManager()->getLanguages();
251 // Mark translations as outdated.
252 $edit = ['content_translation[retranslate]' => TRUE];
253 $edit_path = $entity->urlInfo('edit-form', ['language' => $languages[$langcode]]);
254 $this->drupalPostForm($edit_path, $edit, $this->getFormSubmitAction($entity, $langcode));
255 $storage->resetCache([$this->entityId]);
256 $entity = $storage->load($this->entityId);
258 // Check that every translation has the correct "outdated" status, and that
259 // the Translation fieldset is open if the translation is "outdated".
260 foreach ($this->langcodes as $added_langcode) {
261 $url = $entity->urlInfo('edit-form', ['language' => ConfigurableLanguage::load($added_langcode)]);
262 $this->drupalGet($url);
263 if ($added_langcode == $langcode) {
264 $this->assertFieldByXPath('//input[@name="content_translation[retranslate]"]', FALSE, 'The retranslate flag is not checked by default.');
265 $this->assertFalse($this->xpath('//details[@id="edit-content-translation" and @open="open"]'), 'The translation tab should be collapsed by default.');
268 $this->assertFieldByXPath('//input[@name="content_translation[outdated]"]', TRUE, 'The translate flag is checked by default.');
269 $this->assertTrue($this->xpath('//details[@id="edit-content-translation" and @open="open"]'), 'The translation tab is correctly expanded when the translation is outdated.');
270 $edit = ['content_translation[outdated]' => FALSE];
271 $this->drupalPostForm($url, $edit, $this->getFormSubmitAction($entity, $added_langcode));
272 $this->drupalGet($url);
273 $this->assertFieldByXPath('//input[@name="content_translation[retranslate]"]', FALSE, 'The retranslate flag is now shown.');
274 $storage = $this->container->get('entity_type.manager')
275 ->getStorage($this->entityTypeId);
276 $storage->resetCache([$this->entityId]);
277 $entity = $storage->load($this->entityId);
278 $this->assertFalse($this->manager->getTranslationMetadata($entity->getTranslation($added_langcode))->isOutdated(), 'The "outdated" status has been correctly stored.');
284 * Tests the translation publishing status.
286 protected function doTestPublishedStatus() {
287 $storage = $this->container->get('entity_type.manager')
288 ->getStorage($this->entityTypeId);
289 $storage->resetCache([$this->entityId]);
290 $entity = $storage->load($this->entityId);
292 // Unpublish translations.
293 foreach ($this->langcodes as $index => $langcode) {
295 $url = $entity->urlInfo('edit-form', ['language' => ConfigurableLanguage::load($langcode)]);
296 $edit = ['content_translation[status]' => FALSE];
297 $this->drupalPostForm($url, $edit, $this->getFormSubmitAction($entity, $langcode));
298 $storage = $this->container->get('entity_type.manager')
299 ->getStorage($this->entityTypeId);
300 $storage->resetCache([$this->entityId]);
301 $entity = $storage->load($this->entityId);
302 $this->assertFalse($this->manager->getTranslationMetadata($entity->getTranslation($langcode))->isPublished(), 'The translation has been correctly unpublished.');
306 // Check that the last published translation cannot be unpublished.
307 $this->drupalGet($entity->urlInfo('edit-form'));
308 $this->assertFieldByXPath('//input[@name="content_translation[status]" and @disabled="disabled"]', TRUE, 'The last translation is published and cannot be unpublished.');
312 * Tests the translation authoring information.
314 protected function doTestAuthoringInfo() {
315 $storage = $this->container->get('entity_type.manager')
316 ->getStorage($this->entityTypeId);
317 $storage->resetCache([$this->entityId]);
318 $entity = $storage->load($this->entityId);
321 // Post different authoring information for each translation.
322 foreach ($this->langcodes as $index => $langcode) {
323 $user = $this->drupalCreateUser();
324 $values[$langcode] = [
325 'uid' => $user->id(),
326 'created' => REQUEST_TIME - mt_rand(0, 1000),
329 'content_translation[uid]' => $user->getUsername(),
330 'content_translation[created]' => format_date($values[$langcode]['created'], 'custom', 'Y-m-d H:i:s O'),
332 $url = $entity->urlInfo('edit-form', ['language' => ConfigurableLanguage::load($langcode)]);
333 $this->drupalPostForm($url, $edit, $this->getFormSubmitAction($entity, $langcode));
336 $storage = $this->container->get('entity_type.manager')
337 ->getStorage($this->entityTypeId);
338 $storage->resetCache([$this->entityId]);
339 $entity = $storage->load($this->entityId);
340 foreach ($this->langcodes as $langcode) {
341 $metadata = $this->manager->getTranslationMetadata($entity->getTranslation($langcode));
342 $this->assertEqual($metadata->getAuthor()->id(), $values[$langcode]['uid'], 'Translation author correctly stored.');
343 $this->assertEqual($metadata->getCreatedTime(), $values[$langcode]['created'], 'Translation date correctly stored.');
346 // Try to post non valid values and check that they are rejected.
349 // User names have by default length 8.
350 'content_translation[uid]' => $this->randomMachineName(12),
351 'content_translation[created]' => '19/11/1978',
353 $this->drupalPostForm($entity->urlInfo('edit-form'), $edit, $this->getFormSubmitAction($entity, $langcode));
354 $this->assertTrue($this->xpath('//div[contains(@class, "error")]//ul'), 'Invalid values generate a list of form errors.');
355 $metadata = $this->manager->getTranslationMetadata($entity->getTranslation($langcode));
356 $this->assertEqual($metadata->getAuthor()->id(), $values[$langcode]['uid'], 'Translation author correctly kept.');
357 $this->assertEqual($metadata->getCreatedTime(), $values[$langcode]['created'], 'Translation date correctly kept.');
361 * Tests translation deletion.
363 protected function doTestTranslationDeletion() {
364 // Confirm and delete a translation.
365 $this->drupalLogin($this->translator);
367 $storage = $this->container->get('entity_type.manager')
368 ->getStorage($this->entityTypeId);
369 $storage->resetCache([$this->entityId]);
370 $entity = $storage->load($this->entityId);
371 $language = ConfigurableLanguage::load($langcode);
372 $url = $entity->urlInfo('edit-form', ['language' => $language]);
373 $this->drupalPostForm($url, [], t('Delete translation'));
374 $this->drupalPostForm(NULL, [], t('Delete @language translation', ['@language' => $language->getName()]));
375 $storage->resetCache([$this->entityId]);
376 $entity = $storage->load($this->entityId, TRUE);
377 if ($this->assertTrue(is_object($entity), 'Entity found')) {
378 $translations = $entity->getTranslationLanguages();
379 $this->assertTrue(count($translations) == 2 && empty($translations[$langcode]), 'Translation successfully deleted.');
382 // Check that the translator cannot delete the original translation.
383 $args = [$this->entityTypeId => $entity->id(), 'language' => 'en'];
384 $this->drupalGet(Url::fromRoute("entity.$this->entityTypeId.content_translation_delete", $args));
385 $this->assertResponse(403);
389 * Returns an array of entity field values to be tested.
391 protected function getNewEntityValues($langcode) {
392 return [$this->fieldName => [['value' => $this->randomMachineName(16)]]];
396 * Returns an edit array containing the values to be posted.
398 protected function getEditValues($values, $langcode, $new = FALSE) {
399 $edit = $values[$langcode];
400 $langcode = $new ? LanguageInterface::LANGCODE_NOT_SPECIFIED : $langcode;
401 foreach ($values[$langcode] as $property => $value) {
402 if (is_array($value)) {
403 $edit["{$property}[0][value]"] = $value[0]['value'];
404 unset($edit[$property]);
411 * Returns the form action value when submitting a new translation.
413 * @param \Drupal\Core\Entity\EntityInterface $entity
414 * The entity being tested.
415 * @param string $langcode
416 * Language code for the form.
419 * Name of the button to hit.
421 protected function getFormSubmitActionForNewTranslation(EntityInterface $entity, $langcode) {
422 $entity->addTranslation($langcode, $entity->toArray());
423 return $this->getFormSubmitAction($entity, $langcode);
427 * Returns the form action value to be used to submit the entity form.
429 * @param \Drupal\Core\Entity\EntityInterface $entity
430 * The entity being tested.
431 * @param string $langcode
432 * Language code for the form.
435 * Name of the button to hit.
437 protected function getFormSubmitAction(EntityInterface $entity, $langcode) {
438 return t('Save') . $this->getFormSubmitSuffix($entity, $langcode);
442 * Returns appropriate submit button suffix based on translatability.
444 * @param \Drupal\Core\Entity\EntityInterface $entity
445 * The entity being tested.
446 * @param string $langcode
447 * Language code for the form.
450 * Submit button suffix based on translatability.
452 protected function getFormSubmitSuffix(EntityInterface $entity, $langcode) {
457 * Returns the translation object to use to retrieve the translated values.
459 * @param \Drupal\Core\Entity\EntityInterface $entity
460 * The entity being tested.
461 * @param string $langcode
462 * The language code identifying the translation to be retrieved.
464 * @return \Drupal\Core\TypedData\TranslatableInterface
465 * The translation object to act on.
467 protected function getTranslation(EntityInterface $entity, $langcode) {
468 return $entity->getTranslation($langcode);
472 * Returns the value for the specified property in the given language.
474 * @param \Drupal\Core\Entity\EntityInterface $translation
475 * The translation object the property value should be retrieved from.
476 * @param string $property
478 * @param string $langcode
479 * The property value.
482 * The property value.
484 protected function getValue(EntityInterface $translation, $property, $langcode) {
485 $key = $property == 'user_id' ? 'target_id' : 'value';
486 return $translation->get($property)->{$key};
490 * Returns the name of the field that implements the changed timestamp.
492 * @param \Drupal\Core\Entity\EntityInterface $entity
493 * The entity being tested.
498 protected function getChangedFieldName($entity) {
499 return $entity->hasField('content_translation_changed') ? 'content_translation_changed' : 'changed';
503 * Tests edit content translation.
505 protected function doTestTranslationEdit() {
506 $storage = $this->container->get('entity_type.manager')
507 ->getStorage($this->entityTypeId);
508 $storage->resetCache([$this->entityId]);
509 $entity = $storage->load($this->entityId);
510 $languages = $this->container->get('language_manager')->getLanguages();
512 foreach ($this->langcodes as $langcode) {
513 // We only want to test the title for non-english translations.
514 if ($langcode != 'en') {
515 $options = ['language' => $languages[$langcode]];
516 $url = $entity->urlInfo('edit-form', $options);
517 $this->drupalGet($url);
519 $this->assertRaw($entity->getTranslation($langcode)->label());
525 * Tests the basic translation workflow.
527 protected function doTestTranslationChanged() {
528 $storage = $this->container->get('entity_type.manager')
529 ->getStorage($this->entityTypeId);
530 $storage->resetCache([$this->entityId]);
531 $entity = $storage->load($this->entityId);
532 $changed_field_name = $this->getChangedFieldName($entity);
533 $definition = $entity->getFieldDefinition($changed_field_name);
534 $config = $definition->getConfig($entity->bundle());
536 foreach ([FALSE, TRUE] as $translatable_changed_field) {
537 if ($definition->isTranslatable()) {
538 // For entities defining a translatable changed field we want to test
539 // the correct behavior of that field even if the translatability is
540 // revoked. In that case the changed timestamp should be synchronized
541 // across all translations.
542 $config->setTranslatable($translatable_changed_field);
545 elseif ($translatable_changed_field) {
546 // For entities defining a non-translatable changed field we cannot
547 // declare the field as translatable on the fly by modifying its config
548 // because the schema doesn't support this.
552 foreach ($entity->getTranslationLanguages() as $language) {
553 // Ensure different timestamps.
556 $langcode = $language->getId();
559 $this->fieldName . '[0][value]' => $this->randomString(),
561 $edit_path = $entity->urlInfo('edit-form', ['language' => $language]);
562 $this->drupalPostForm($edit_path, $edit, $this->getFormSubmitAction($entity, $langcode));
564 $storage = $this->container->get('entity_type.manager')
565 ->getStorage($this->entityTypeId);
566 $storage->resetCache([$this->entityId]);
567 $entity = $storage->load($this->entityId);
569 $entity->getChangedTimeAcrossTranslations(), $entity->getTranslation($langcode)->getChangedTime(),
570 format_string('Changed time for language %language is the latest change over all languages.', ['%language' => $language->getName()])
575 foreach ($entity->getTranslationLanguages() as $language) {
576 $next_timestamp = $entity->getTranslation($language->getId())->getChangedTime();
577 if (!in_array($next_timestamp, $timestamps)) {
578 $timestamps[] = $next_timestamp;
582 if ($translatable_changed_field) {
584 count($timestamps), count($entity->getTranslationLanguages()),
585 'All timestamps from all languages are different.'
590 count($timestamps), 1,
591 'All timestamps from all languages are identical.'
598 * Test the changed time after API and FORM save without changes.
600 public function doTestChangedTimeAfterSaveWithoutChanges() {
601 $storage = $this->container->get('entity_type.manager')
602 ->getStorage($this->entityTypeId);
603 $storage->resetCache([$this->entityId]);
604 $entity = $storage->load($this->entityId);
605 // Test only entities, which implement the EntityChangedInterface.
606 if ($entity instanceof EntityChangedInterface) {
607 $changed_timestamp = $entity->getChangedTime();
610 $storage = $this->container->get('entity_type.manager')
611 ->getStorage($this->entityTypeId);
612 $storage->resetCache([$this->entityId]);
613 $entity = $storage->load($this->entityId);
614 $this->assertEqual($changed_timestamp, $entity->getChangedTime(), 'The entity\'s changed time wasn\'t updated after API save without changes.');
616 // Ensure different save timestamps.
619 // Save the entity on the regular edit form.
620 $language = $entity->language();
621 $edit_path = $entity->urlInfo('edit-form', ['language' => $language]);
622 $this->drupalPostForm($edit_path, [], $this->getFormSubmitAction($entity, $language->getId()));
624 $storage->resetCache([$this->entityId]);
625 $entity = $storage->load($this->entityId);
626 $this->assertNotEqual($changed_timestamp, $entity->getChangedTime(), 'The entity\'s changed time was updated after form save without changes.');